Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 487bda6a32 |
|
Before Width: | Height: | Size: 345 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 86 KiB |
@@ -89,6 +89,7 @@ jobs:
|
||||
DOTNET_NOLOGO: true
|
||||
NUGET_PACKAGES: ${{ github.workspace }}\.nuget\packages
|
||||
PUBLISH_DIR: ${{ github.workspace }}\artifacts\publish\win-x64
|
||||
RELEASE_DIR: ${{ github.workspace }}\artifacts\release
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -120,13 +121,24 @@ jobs:
|
||||
- name: Publish win-x64 CLI
|
||||
run: dotnet publish src/SharpEmu.CLI/SharpEmu.CLI.csproj -c Release -r win-x64 --self-contained true --no-restore -p:PublishDir="${env:PUBLISH_DIR}"
|
||||
|
||||
- name: Create release archive
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path $env:RELEASE_DIR -Force | Out-Null
|
||||
|
||||
$archiveName = "sharpemu-${{ needs.init.outputs.version }}-win-x64.zip"
|
||||
$archivePath = Join-Path $env:RELEASE_DIR $archiveName
|
||||
if (Test-Path $archivePath) {
|
||||
Remove-Item $archivePath -Force
|
||||
}
|
||||
|
||||
Compress-Archive -Path (Join-Path $env:PUBLISH_DIR '*') -DestinationPath $archivePath -CompressionLevel Optimal
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: sharpemu-win-x64-${{ needs.init.outputs.short-sha }}
|
||||
path: ${{ env.PUBLISH_DIR }}
|
||||
path: ${{ env.RELEASE_DIR }}\sharpemu-${{ needs.init.outputs.version }}-win-x64.zip
|
||||
if-no-files-found: error
|
||||
include-hidden-files: true
|
||||
|
||||
build-posix:
|
||||
name: Build ${{ matrix.rid }}
|
||||
@@ -146,6 +158,7 @@ jobs:
|
||||
DOTNET_NOLOGO: true
|
||||
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
|
||||
PUBLISH_DIR: ${{ github.workspace }}/artifacts/publish/${{ matrix.rid }}
|
||||
RELEASE_DIR: ${{ github.workspace }}/artifacts/release
|
||||
SPIRV_HEADERS_COMMIT: ad9184e76a66b1001c29db9b0a3e87f646c64de0
|
||||
# SpirvModuleBuilder emits SPIR-V 1.5 and VulkanVideoPresenter requests Vulkan 1.2.
|
||||
SPIRV_TARGET_ENV: vulkan1.2
|
||||
@@ -210,13 +223,19 @@ jobs:
|
||||
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-${{ needs.init.outputs.version }}-${{ matrix.rid }}.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.PUBLISH_DIR }}
|
||||
path: ${{ env.RELEASE_DIR }}/sharpemu-${{ needs.init.outputs.version }}-${{ matrix.rid }}.tar.gz
|
||||
if-no-files-found: error
|
||||
include-hidden-files: true
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
@@ -236,28 +255,6 @@ jobs:
|
||||
with:
|
||||
path: release
|
||||
|
||||
- name: Package release assets
|
||||
shell: bash
|
||||
env:
|
||||
SHORT_SHA: ${{ needs.init.outputs.short-sha }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
win_dir="release/sharpemu-win-x64-${SHORT_SHA}"
|
||||
linux_dir="release/sharpemu-linux-x64-${SHORT_SHA}"
|
||||
macos_dir="release/sharpemu-osx-x64-${SHORT_SHA}"
|
||||
for package_dir in "${win_dir}" "${linux_dir}" "${macos_dir}"; do
|
||||
test -d "${package_dir}"
|
||||
done
|
||||
|
||||
mkdir -p release-assets
|
||||
(cd "${win_dir}" && zip -q -r "../../release-assets/sharpemu-${VERSION}-win-x64.zip" .)
|
||||
|
||||
chmod +x "${linux_dir}/SharpEmu" "${macos_dir}/SharpEmu"
|
||||
tar -czf "release-assets/sharpemu-${VERSION}-linux-x64.tar.gz" -C "${linux_dir}" .
|
||||
tar -czf "release-assets/sharpemu-${VERSION}-osx-x64.tar.gz" -C "${macos_dir}" .
|
||||
|
||||
- name: Create release
|
||||
shell: bash
|
||||
env:
|
||||
@@ -267,9 +264,9 @@ jobs:
|
||||
RELEASE_TAG: ${{ needs.init.outputs.release-tag }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
mapfile -t assets < <(find release-assets -maxdepth 1 -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
|
||||
if [ "${#assets[@]}" -ne 3 ]; then
|
||||
echo "Expected 3 release assets, found ${#assets[@]}." >&2
|
||||
mapfile -t assets < <(find release -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release assets found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -42,4 +42,3 @@ ehthumbs.db
|
||||
|
||||
.vs/
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -26,25 +26,6 @@ Before opening a pull request, please keep the following in mind:
|
||||
|
||||
If you're unsure about a design decision, feel free to open a discussion or draft PR first.
|
||||
|
||||
## Pull Request Expectations
|
||||
|
||||
Pull requests should provide real, observable emulator behavior rather than only suppressing errors or unresolved imports.
|
||||
|
||||
Changes that only return success, zero, or fabricated handles without implementing the expected state, output, or side effects will generally not be accepted. Functions that create resources, write output structures, register callbacks, or expose runtime state should model the behavior required by the guest.
|
||||
|
||||
When applicable, PRs should include:
|
||||
|
||||
- The affected game or application.
|
||||
- Relevant logs or failing imports.
|
||||
- Behavior before and after the change.
|
||||
- Real game testing and known limitations.
|
||||
|
||||
Avoid submitting large collections of speculative NIDs or unrelated exports. Keep each PR focused on one problem or a closely related set of changes.
|
||||
|
||||
Large architectural changes should be discussed with the maintainers before implementation. Contributors are encouraged to ask first when they are uncertain whether a proposed direction fits the project.
|
||||
|
||||
Opening a PR does not guarantee that it will be merged. Maintainers evaluate changes based on correctness, evidence, testing, scope, maintenance cost, and the long-term direction of the project.
|
||||
|
||||
## AI-Assisted Contributions
|
||||
|
||||
AI-assisted development is welcome and may be used for research, reverse engineering, code generation, or documentation.
|
||||
|
||||
@@ -9,18 +9,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<SharpEmuVersion>0.0.3-hotfix-1</SharpEmuVersion>
|
||||
<SharpEmuVersion>0.0.2-beta.4</SharpEmuVersion>
|
||||
<Version>$(SharpEmuVersion)</Version>
|
||||
|
||||
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
|
||||
|
||||
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And $([MSBuild]::IsOSPlatform('Windows'))">win</_HostRidOSPrefix>
|
||||
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And '$(_HostRidOSPrefix)' == '' And $([MSBuild]::IsOSPlatform('Linux'))">linux</_HostRidOSPrefix>
|
||||
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And '$(_HostRidOSPrefix)' == '' And $([MSBuild]::IsOSPlatform('OSX'))">osx</_HostRidOSPrefix>
|
||||
<_HostRidArch Condition="'$(_HostRidOSPrefix)' != '' And '$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)' == 'Arm64'">arm64</_HostRidArch>
|
||||
<_HostRidArch Condition="'$(_HostRidOSPrefix)' != '' And '$(_HostRidArch)' == ''">x64</_HostRidArch>
|
||||
<RuntimeIdentifier Condition="'$(_HostRidOSPrefix)' != ''">$(_HostRidOSPrefix)-$(_HostRidArch)</RuntimeIdentifier>
|
||||
|
||||
<BaseIntermediateOutputPath>$(RepoRoot)artifacts/obj/$(MSBuildProjectName)/</BaseIntermediateOutputPath>
|
||||
<BaseOutputPath>$(RepoRoot)artifacts/bin/</BaseOutputPath>
|
||||
|
||||
|
||||
@@ -7,23 +7,22 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Avalonia" Version="12.1.0" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="12.1.0" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="12.1.0" />
|
||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.1.0" />
|
||||
<PackageVersion Include="FFmpeg.AutoGen" Version="7.1.1" />
|
||||
<PackageVersion Include="Avalonia" Version="11.3.18" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="11.3.18" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.18" />
|
||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.18" />
|
||||
<PackageVersion Include="Iced" Version="1.21.0" />
|
||||
<PackageVersion Include="Microsoft.Build.Framework" Version="17.14.8" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="NLayer" Version="1.14.0" />
|
||||
<PackageVersion Include="ppy.SDL3-CS" Version="2026.629.0" />
|
||||
<PackageVersion Include="Silk.NET.Input" Version="2.23.0" />
|
||||
<PackageVersion Include="Silk.NET.Vulkan" Version="2.23.0" />
|
||||
<PackageVersion Include="Silk.NET.Vulkan.Extensions.EXT" Version="2.23.0" />
|
||||
<PackageVersion Include="Silk.NET.Vulkan.Extensions.KHR" Version="2.23.0" />
|
||||
<!-- Transitive of Avalonia.Desktop; pinned. Avalonia 12 requires 0.94.1+. -->
|
||||
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.94.1" />
|
||||
<PackageVersion Include="Silk.NET.Windowing" Version="2.23.0" />
|
||||
<!-- Transitive of Avalonia.Desktop; pinned to fix GHSA-xrw6-gwf8-vvr9 -->
|
||||
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.21.3" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -13,12 +13,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
An experimental PlayStation 5 emulator for Windows, Linux and macOS.
|
||||
</p>
|
||||
|
||||
---
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/6GejPEDqpc">
|
||||
<img src="https://img.shields.io/badge/Discord-Join%20our%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join our Discord">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#support">
|
||||
<img src="https://img.shields.io/badge/Support-GitHub%20Sponsors%20%26%20Crypto-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="Support SharpEmu">
|
||||
</a>
|
||||
<strong>Join our Discord for development updates, compatibility discussions, support, and community chat.</strong>
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -134,18 +136,6 @@ Provided valuable references for filesystem handling and low-level C# implementa
|
||||
|
||||
- [**GPL-2.0 license**](https://github.com/sharpemu/sharpemu/blob/main/LICENSE)
|
||||
|
||||
## Support
|
||||
|
||||
Support SharpEmu via GitHub Sponsors or cryptocurrency. Every contribution helps fund ongoing development and long-term maintenance. GitHub Sponsors is the preferred way to support the project, but cryptocurrency donations are also appreciated.
|
||||
|
||||
### ETH/USDT
|
||||
|
||||
`0xF315F5d986c790bB3A58DbE60F1B2760997dEd82`
|
||||
|
||||
### BTC
|
||||
|
||||
`bc1qmr9k8899njys5ny63xsues4jgmkk96erslrkmv`
|
||||
|
||||
## Contributing
|
||||
|
||||
Before opening an issue or pull request, please read our contribution guidelines:
|
||||
|
||||
@@ -20,7 +20,7 @@ SPDX-FileCopyrightText = "SharpEmu Emulator Project"
|
||||
SPDX-License-Identifier = "GPL-2.0-or-later"
|
||||
|
||||
[[annotations]]
|
||||
path = "src/SharpEmu.LibAtrac9/**"
|
||||
path = "src/SharpEmu.GUI/Atrac9/**"
|
||||
precedence = "aggregate"
|
||||
SPDX-FileCopyrightText = "2018 Alex Barney"
|
||||
SPDX-License-Identifier = "MIT"
|
||||
|
||||
@@ -5,7 +5,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/SharpEmu.LibAtrac9/SharpEmu.LibAtrac9.csproj" />
|
||||
<Project Path="src/SharpEmu.CLI/SharpEmu.CLI.csproj" />
|
||||
<Project Path="src/SharpEmu.Core/SharpEmu.Core.csproj" />
|
||||
<Project Path="src/SharpEmu.DebugClient/SharpEmu.DebugClient.csproj" />
|
||||
@@ -22,7 +21,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj" />
|
||||
<Project Path="tests/SharpEmu.ShaderCompiler.Metal.Tests/SharpEmu.ShaderCompiler.Metal.Tests.csproj" />
|
||||
<Project Path="tests/SharpEmu.ShaderCompiler.Tests/SharpEmu.ShaderCompiler.Tests.csproj" />
|
||||
<Project Path="tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -9,67 +9,38 @@ Demon's Souls plays Bink 2 (.bk2) files through a Bink implementation linked
|
||||
directly into eboot.bin. It does not use libSceVideodec, therefore an HLE video
|
||||
decoder cannot observe or replace those frames.
|
||||
|
||||
SharpEmu observes successful guest .bk2 opens and, when a Bink decoder is
|
||||
SharpEmu observes successful guest .bk2 opens and, when a Bink bridge is
|
||||
available, presents its decoded BGRA frames at the normal guest-flip boundary.
|
||||
This preserves the game's own timing and lets the host Vulkan presenter display
|
||||
the movie without trying to execute the PS5-specific Bink GPU decode path.
|
||||
|
||||
The default path decodes by calling FFmpeg's own C API directly from managed
|
||||
code (`src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs`, via the
|
||||
[FFmpeg.AutoGen](https://github.com/Ruslan-B/FFmpeg.AutoGen) P/Invoke
|
||||
bindings) against a custom FFmpeg build
|
||||
(`github.com/sharpemu/ffmpeg-core`, LGPL-2.1) that adds a Bink 2 decoder to
|
||||
FFmpeg 7.1.2; see "Supplying the FFmpeg libraries" below for where those
|
||||
libraries come from. No proprietary RAD SDK is needed to build or run
|
||||
SharpEmu, and there is no C/C++ code of SharpEmu's own involved in decoding
|
||||
-- SharpEmu.CLI.csproj only downloads a prebuilt release archive.
|
||||
|
||||
Set `SHARPEMU_BINK_MODE=guest` to leave decoding to the Bink implementation
|
||||
statically linked into the game instead. Set `skip` only when explicitly
|
||||
testing a title whose cinematics are optional.
|
||||
Without an adapter, Bink files remain visible to the guest and the game's
|
||||
statically linked decoder runs normally. Set SHARPEMU_BINK_MODE=skip only when
|
||||
explicitly testing a title whose cinematics are optional.
|
||||
|
||||
Set SHARPEMU_BINK_MODE=dummy to retain the open and show a built-in,
|
||||
non-decoded placeholder frame. This requires no SDK, but is a visual diagnostic
|
||||
only; it does not decode the movie or alter its game logic.
|
||||
SHARPEMU_BINK_MODE=native is equivalent to the default and mainly useful for
|
||||
being explicit about it.
|
||||
only; it does not decode the movie or alter its game logic. Set
|
||||
SHARPEMU_BINK_MODE=native to force native bridge mode.
|
||||
|
||||
The experimental `SHARPEMU_BINK_MODE=ffmpeg` override is unrelated to the
|
||||
default path above: instead of calling into FFmpeg in-process, it spawns a
|
||||
standalone `ffmpeg` executable and reads raw frames from its stdout
|
||||
(`src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs`). SharpEmu searches
|
||||
`SHARPEMU_FFMPEG_PATH`, the executable directory, its `ffmpeg` subdirectory,
|
||||
and then `PATH` (plus a couple of common Homebrew paths on macOS). That
|
||||
`ffmpeg` build must contain a Bink 2 decoder itself; a stock FFmpeg build that
|
||||
only recognizes the Bink container is not sufficient. Most users want the
|
||||
default `native` mode instead, which always has Bink 2 support since it's
|
||||
built against `ffmpeg-core` specifically.
|
||||
## Supplying the adapter
|
||||
|
||||
## Supplying the FFmpeg libraries
|
||||
Bink 2 is proprietary. Obtain a compatible Mac Bink 2 SDK from RAD Game Tools,
|
||||
then compile sharpemu_bink2_bridge.c against the SDK's bink.h and Mac library.
|
||||
The adapter deliberately contains only a three-function C ABI so the managed
|
||||
emulator never depends on RAD's private binary ABI.
|
||||
|
||||
`dotnet publish` fetches a prebuilt release of `github.com/sharpemu/ffmpeg-core`
|
||||
(the tag is pinned in `SharpEmu.CLI.csproj`'s `FfmpegRuntimeTag`, matched to
|
||||
the `FFmpeg.AutoGen` package version in `Directory.Packages.props` -- both
|
||||
need to agree on the same FFmpeg ABI) and copies its dynamically linked
|
||||
libraries into a `plugins` folder next to the published executable. No C
|
||||
toolchain is required to build SharpEmu; publishing just downloads a zip.
|
||||
`plugins` is a loose, unpacked folder rather than something embedded in the
|
||||
single-file bundle, so the OS loader can resolve the libraries' own
|
||||
inter-dependencies (`avcodec` depends on `avutil`, etc.) itself.
|
||||
Place the resulting libsharpemu_bink2_bridge.dylib next to the SharpEmu
|
||||
executable, or point to it explicitly:
|
||||
|
||||
A plain `dotnet publish` with no `-r` still works: it defaults to the host
|
||||
machine's own RID (see `Directory.Build.props`), so it fetches the matching
|
||||
`ffmpeg-core` archive and populates `plugins` without any extra flags.
|
||||
Passing an explicit `-r <rid>` (e.g. to cross-publish `linux-x64` from
|
||||
Windows) still overrides that default normally.
|
||||
SHARPEMU_BINK2_BRIDGE=/absolute/path/libsharpemu_bink2_bridge.dylib \
|
||||
./SharpEmu /path/to/eboot.bin
|
||||
|
||||
To use a different set of FFmpeg libraries, drop them into the published
|
||||
`plugins` folder yourself (matching FFmpeg's own file-naming and versioning
|
||||
conventions, e.g. `avformat-61.dll` / `libavformat.so.61` / matching
|
||||
`.dylib`) -- `FfmpegNativeBinkFrameSource` points `ffmpeg.RootPath` at that
|
||||
folder and does not otherwise care where the files came from.
|
||||
The expected exports are sharpemu_bink2_open_utf8,
|
||||
sharpemu_bink2_decode_next_bgra, and sharpemu_bink2_close. The supplied
|
||||
adapter opens one movie, exposes BGRA pixels, and advances after each decoded
|
||||
frame. The managed side validates dimensions and retains ownership of the
|
||||
destination buffer.
|
||||
|
||||
If the libraries are absent or fail to load, `FfmpegNativeBinkFrameSource.TryOpen`
|
||||
degrades gracefully: SharpEmu logs one informational line ("Bink2 bridge
|
||||
could not open movie ...") and leaves the guest's own rendering path
|
||||
untouched, rather than crashing.
|
||||
If the bridge is absent in native mode, SharpEmu logs one informational line
|
||||
and retains the existing guest rendering path.
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
# Guest write watch
|
||||
|
||||
`GuestWriteWatch` is an optional diagnostic tool. It helps you find managed
|
||||
code and HLE code that damage guest memory. The tool starts only if you set one
|
||||
or more `SHARPEMU_WATCH_*` environment variables.
|
||||
|
||||
The tool monitors writes through the SharpEmu managed virtual-memory APIs. It
|
||||
does not monitor stores that native guest code makes directly. Use a platform
|
||||
debugger or a hardware watchpoint to monitor these stores.
|
||||
|
||||
## Watch modes
|
||||
|
||||
- `SHARPEMU_WATCH_WRITE=0x<address>` logs a write that overlaps the eight-byte
|
||||
block at the specified guest address.
|
||||
- `SHARPEMU_WATCH_POOL_HEADER=1` monitors the pointer at offset `0x40`. It
|
||||
monitors the first 64 direct mappings that have a size of 64 KiB and
|
||||
protection value `0xF2`.
|
||||
- `SHARPEMU_WATCH_VALUE_PATTERN=1` logs an eight-byte write if its lower 32 bits
|
||||
are `1`. The upper 32 bits must look like a small guest-pointer prefix.
|
||||
- `SHARPEMU_WATCH_VALUE1=1` logs short writes of value `1` in the high guest
|
||||
memory range. The tool logs a maximum of 128 entries for each process.
|
||||
- `SHARPEMU_WATCH_BULK_TORN=1` scans aligned 64-bit words in bulk writes. It
|
||||
finds damaged pointer patterns and byte-shifted pointer patterns. The tool
|
||||
logs a maximum of 64 entries for each process.
|
||||
- `SHARPEMU_WATCH_BULK_DEST_HI=0x<high-dword>` scans only writes that have the
|
||||
specified upper 32 bits in the destination address.
|
||||
|
||||
For each match, the tool logs the destination address, the data pattern, and the
|
||||
managed call stack. The log uses the `watch_write` or `watch_bulk_torn` warning
|
||||
tag.
|
||||
|
||||
Use these variables together to scan bulk writes in the
|
||||
`0x00000080xxxxxxxx` region.
|
||||
|
||||
macOS and Linux:
|
||||
|
||||
```sh
|
||||
SHARPEMU_WATCH_BULK_TORN=1 \
|
||||
SHARPEMU_WATCH_BULK_DEST_HI=0x80 \
|
||||
SharpEmu /path/to/eboot.bin
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:SHARPEMU_WATCH_BULK_TORN = "1"
|
||||
$env:SHARPEMU_WATCH_BULK_DEST_HI = "0x80"
|
||||
& .\SharpEmu.exe C:\path\to\game\eboot.bin
|
||||
```
|
||||
|
||||
To reduce unnecessary log entries, use an exact `SHARPEMU_WATCH_WRITE`
|
||||
address from a crash dump.
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SharpEmu Emulator Project
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*
|
||||
* Build this small adapter with a licensed RAD Bink 2 SDK. The SDK and its
|
||||
* headers are not distributed by SharpEmu. See docs/bink2-bridge.md.
|
||||
*/
|
||||
#include <stdint.h>
|
||||
#include "bink.h"
|
||||
|
||||
typedef struct sharpemu_bink2_info {
|
||||
uint32_t width;
|
||||
uint32_t height;
|
||||
uint32_t frames_per_second_numerator;
|
||||
uint32_t frames_per_second_denominator;
|
||||
} sharpemu_bink2_info;
|
||||
|
||||
int sharpemu_bink2_open_utf8(const char *path, HBINK *movie, sharpemu_bink2_info *info) {
|
||||
HBINK bink;
|
||||
if (!path || !movie || !info) return 0;
|
||||
|
||||
*movie = NULL;
|
||||
|
||||
bink = BinkOpen(path, 0);
|
||||
if (!bink) return 0;
|
||||
|
||||
if (bink->Width == 0 || bink->Height == 0) {
|
||||
BinkClose(bink);
|
||||
return 0;
|
||||
}
|
||||
|
||||
*movie = bink;
|
||||
info->width = bink->Width;
|
||||
info->height = bink->Height;
|
||||
info->frames_per_second_numerator = bink->FrameRate;
|
||||
info->frames_per_second_denominator = bink->FrameRateDiv;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int sharpemu_bink2_decode_next_bgra(HBINK movie, uint8_t *destination,
|
||||
uint32_t stride, uint32_t destination_bytes) {
|
||||
uint64_t needed;
|
||||
uint64_t min_stride;
|
||||
|
||||
if (!movie || !destination) return 0;
|
||||
|
||||
min_stride = (uint64_t)movie->Width * 4;
|
||||
if ((uint64_t)stride < min_stride) return 0;
|
||||
|
||||
needed = (uint64_t)stride * movie->Height;
|
||||
if (needed > destination_bytes) return 0;
|
||||
|
||||
/* Async Bink I/O has not filled the next frame yet; retry on the next host present. */
|
||||
if (BinkWait(movie)) return 0;
|
||||
|
||||
if (!BinkDoFrame(movie)) return 0;
|
||||
|
||||
if (!BinkCopyToBuffer(movie, destination, stride, movie->Height, 0, 0, BINKSURFACE32RA)) return 0;
|
||||
|
||||
BinkNextFrame(movie);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void sharpemu_bink2_close(HBINK movie) {
|
||||
if (movie) BinkClose(movie);
|
||||
}
|
||||
@@ -153133,7 +153133,6 @@ scePsmlMfsrGetContextBufferRequirement800M3_2
|
||||
scePsmlMfsrGetDispatchMfsrPacket1000
|
||||
scePsmlMfsrGetDispatchMfsrPacket1100
|
||||
scePsmlMfsrGetDispatchMfsrPacketSizeInDwords
|
||||
scePsmlMfsrGetDispatchMfsrPacket900
|
||||
scePsmlMfsrGetMipmapBias
|
||||
scePsmlMfsrGetSharedResourcesInitRequirement
|
||||
scePsmlMfsrInit
|
||||
|
||||
@@ -8,7 +8,6 @@ using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using SharpEmu.Logging;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Loader;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
@@ -46,8 +45,6 @@ internal static partial class Program
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
ConfigureManagedPluginResolution();
|
||||
|
||||
try
|
||||
{
|
||||
return Run(args);
|
||||
@@ -59,25 +56,6 @@ internal static partial class Program
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureManagedPluginResolution()
|
||||
{
|
||||
AssemblyLoadContext.Default.Resolving += static (loadContext, assemblyName) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(assemblyName.Name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var assemblyPath = Path.Combine(
|
||||
AppContext.BaseDirectory,
|
||||
"plugins",
|
||||
assemblyName.Name + ".dll");
|
||||
return File.Exists(assemblyPath)
|
||||
? loadContext.LoadFromAssemblyPath(assemblyPath)
|
||||
: null;
|
||||
};
|
||||
}
|
||||
|
||||
private static int Run(string[] args)
|
||||
{
|
||||
if (Updater.TryApply(args, out var updateExitCode))
|
||||
@@ -114,9 +92,14 @@ internal static partial class Program
|
||||
PreloadMacVulkanLoader();
|
||||
}
|
||||
|
||||
// SDL/AppKit window work belongs on the process main thread on
|
||||
// macOS. Linux uses the same model for consistent X11/Wayland
|
||||
// event ownership. Emulation remains on a worker thread.
|
||||
// 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(() =>
|
||||
@@ -147,9 +130,10 @@ internal static partial class Program
|
||||
/// starts: the CPU backend executes guest x86-64 code natively, so the
|
||||
/// host process must be x86-64 — win-x64/linux-x64 on x64 hardware, or
|
||||
/// osx-x64 under Rosetta 2 on Apple Silicon (Rosetta translates the
|
||||
/// whole process, so it still reports as X64 here). Failing up front on
|
||||
/// any other process architecture distinguishes that from MoltenVK,
|
||||
/// signal-handler, or guest-memory startup problems.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static bool CheckHostArchitecture()
|
||||
{
|
||||
@@ -193,11 +177,11 @@ internal static partial class Program
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes a Vulkan loader visible before SDL creates its Vulkan surface.
|
||||
/// 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 can then resolve the loader for SDL and Silk.NET.
|
||||
/// dyld then resolves GLFW's bare-name dlopen to the loaded image.
|
||||
/// </summary>
|
||||
private static void PreloadMacVulkanLoader()
|
||||
{
|
||||
@@ -238,13 +222,17 @@ internal static partial class Program
|
||||
return childExitCode;
|
||||
}
|
||||
|
||||
if (!TryParseArguments(
|
||||
args,
|
||||
out var ebootPath,
|
||||
out var runtimeOptions,
|
||||
out var videoOptions,
|
||||
out var logLevel,
|
||||
out var logFilePath))
|
||||
if (!TryExtractHostSurfaceArgument(args, out var emulatorArgs, out var hostSurface, out var hostSurfaceError))
|
||||
{
|
||||
Console.Error.WriteLine($"[LOADER][ERROR] {hostSurfaceError}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
HostSessionControl.SetEmbeddedHostSurface(
|
||||
hostSurface?.WindowHandle ?? 0,
|
||||
hostSurface?.DisplayHandle ?? 0);
|
||||
|
||||
if (!TryParseArguments(emulatorArgs, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath))
|
||||
{
|
||||
PrintUsage();
|
||||
return 1;
|
||||
@@ -256,11 +244,6 @@ internal static partial class Program
|
||||
}
|
||||
|
||||
SharpEmuLog.MinimumLevel = logLevel;
|
||||
if (!HostVideoHost.TryConfigureVideo(videoOptions))
|
||||
{
|
||||
Console.Error.WriteLine("[LOADER][ERROR] Video options cannot change while a presenter is active.");
|
||||
return 3;
|
||||
}
|
||||
|
||||
Log.Info(BuildInfo.Banner);
|
||||
Log.Info(HostSystemInfo.Summary);
|
||||
@@ -304,6 +287,12 @@ internal static partial class Program
|
||||
|
||||
try
|
||||
{
|
||||
if (hostSurface is not null && !VulkanVideoHost.TryAttachSurface(hostSurface))
|
||||
{
|
||||
Console.Error.WriteLine("[LOADER][ERROR] The requested GUI host surface is already active.");
|
||||
return 3;
|
||||
}
|
||||
|
||||
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
|
||||
|
||||
OrbisGen2Result result;
|
||||
@@ -373,9 +362,53 @@ internal static partial class Program
|
||||
debugHost.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
HostSessionControl.SetEmbeddedHostSurface(0);
|
||||
if (hostSurface is not null)
|
||||
{
|
||||
VulkanVideoHost.RequestClose();
|
||||
VulkanVideoHost.DetachSurface(hostSurface);
|
||||
hostSurface.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryExtractHostSurfaceArgument(
|
||||
IReadOnlyList<string> args,
|
||||
out string[] emulatorArgs,
|
||||
out VulkanHostSurface? hostSurface,
|
||||
out string? error)
|
||||
{
|
||||
const string hostSurfacePrefix = "--host-surface=";
|
||||
var remaining = new List<string>(args.Count);
|
||||
hostSurface = null;
|
||||
error = null;
|
||||
foreach (var argument in args)
|
||||
{
|
||||
if (!argument.StartsWith(hostSurfacePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
remaining.Add(argument);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hostSurface is not null)
|
||||
{
|
||||
emulatorArgs = [];
|
||||
error = "more than one GUI host surface was specified";
|
||||
return false;
|
||||
}
|
||||
|
||||
var descriptor = argument[hostSurfacePrefix.Length..];
|
||||
if (!VulkanHostSurface.TryCreateChildProcessSurface(descriptor, out hostSurface, out error))
|
||||
{
|
||||
emulatorArgs = [];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
emulatorArgs = remaining.ToArray();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void EnsureCliConsole()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
@@ -518,7 +551,12 @@ internal static partial class Program
|
||||
return false;
|
||||
}
|
||||
|
||||
string[] childArgs = [MitigatedChildFlag, .. args];
|
||||
var childArgs = new string[args.Length + 1];
|
||||
childArgs[0] = MitigatedChildFlag;
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
childArgs[i + 1] = args[i];
|
||||
}
|
||||
|
||||
var commandLine = BuildCommandLine(processPath, childArgs);
|
||||
var startupInfoEx = new STARTUPINFOEX();
|
||||
@@ -569,7 +607,7 @@ internal static partial class Program
|
||||
nint jobHandle = 0;
|
||||
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
|
||||
var created = CreateProcessW(
|
||||
null,
|
||||
processPath,
|
||||
cmdLineBuilder,
|
||||
0,
|
||||
0,
|
||||
@@ -987,7 +1025,7 @@ internal static partial class Program
|
||||
|
||||
private static void PrintUsage()
|
||||
{
|
||||
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--log-level=<level>] [--log-file[=<path>]] [--window-mode=<windowed|borderless|exclusive>] [--resolution=<WIDTHxHEIGHT>] [--display=<N>] [--refresh-rate=<HZ>] [--scaling=<fit|cover|stretch|integer>] [--vsync=<on|off>] [--hdr=<auto|on|off>] [--debug-server[=host:port]] <path-to-eboot.bin>");
|
||||
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--log-level=<level>] [--log-file[=<path>]] [--debug-server[=host:port]] <path-to-eboot.bin>");
|
||||
Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin""");
|
||||
Log.Info("Debug server: --debug-server starts a live debug listener (default 127.0.0.1:5714); connect with SharpEmu.DebugClient.");
|
||||
}
|
||||
@@ -1032,7 +1070,6 @@ internal static partial class Program
|
||||
string[] args,
|
||||
out string ebootPath,
|
||||
out SharpEmuRuntimeOptions runtimeOptions,
|
||||
out HostVideoOptions videoOptions,
|
||||
out LogLevel logLevel,
|
||||
out string? logFilePath)
|
||||
{
|
||||
@@ -1040,7 +1077,6 @@ internal static partial class Program
|
||||
{
|
||||
ebootPath = string.Empty;
|
||||
runtimeOptions = default;
|
||||
videoOptions = HostVideoOptions.Default;
|
||||
logLevel = SharpEmuLog.MinimumLevel;
|
||||
logFilePath = null;
|
||||
return false;
|
||||
@@ -1049,99 +1085,12 @@ internal static partial class Program
|
||||
var strictDynlibResolution = false;
|
||||
var importTraceLimit = 0;
|
||||
var cpuEngine = CpuExecutionEngine.NativeOnly;
|
||||
HostWindowMode? windowModeOverride = null;
|
||||
HostScalingMode? scalingModeOverride = null;
|
||||
int? windowWidthOverride = null;
|
||||
int? windowHeightOverride = null;
|
||||
int? displayIndexOverride = null;
|
||||
int? refreshRateOverride = null;
|
||||
bool? vsyncOverride = null;
|
||||
HostHdrMode? hdrModeOverride = null;
|
||||
videoOptions = HostVideoOptions.Default;
|
||||
logFilePath = null;
|
||||
logLevel = SharpEmuLog.MinimumLevel;
|
||||
var pathTokens = new List<string>(args.Length);
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var argument = args[i];
|
||||
if (TrySplitOption(argument, "--window-mode", out var windowModeText))
|
||||
{
|
||||
if (!TryParseWindowMode(windowModeText, out var windowMode))
|
||||
{
|
||||
ebootPath = string.Empty;
|
||||
runtimeOptions = default;
|
||||
return false;
|
||||
}
|
||||
windowModeOverride = windowMode;
|
||||
continue;
|
||||
}
|
||||
if (TrySplitOption(argument, "--resolution", out var resolutionText))
|
||||
{
|
||||
if (!TryParseResolution(resolutionText, out var windowWidth, out var windowHeight))
|
||||
{
|
||||
ebootPath = string.Empty;
|
||||
runtimeOptions = default;
|
||||
return false;
|
||||
}
|
||||
windowWidthOverride = windowWidth;
|
||||
windowHeightOverride = windowHeight;
|
||||
continue;
|
||||
}
|
||||
if (TrySplitOption(argument, "--display", out var displayText))
|
||||
{
|
||||
if (!int.TryParse(displayText, out var displayIndex) || displayIndex < 0)
|
||||
{
|
||||
ebootPath = string.Empty;
|
||||
runtimeOptions = default;
|
||||
return false;
|
||||
}
|
||||
displayIndexOverride = displayIndex;
|
||||
continue;
|
||||
}
|
||||
if (TrySplitOption(argument, "--refresh-rate", out var refreshText))
|
||||
{
|
||||
if (!int.TryParse(refreshText, out var refreshRate) || refreshRate < 0)
|
||||
{
|
||||
ebootPath = string.Empty;
|
||||
runtimeOptions = default;
|
||||
return false;
|
||||
}
|
||||
refreshRateOverride = refreshRate;
|
||||
continue;
|
||||
}
|
||||
if (TrySplitOption(argument, "--scaling", out var scalingText))
|
||||
{
|
||||
if (!TryParseScalingMode(scalingText, out var scalingMode))
|
||||
{
|
||||
ebootPath = string.Empty;
|
||||
runtimeOptions = default;
|
||||
return false;
|
||||
}
|
||||
scalingModeOverride = scalingMode;
|
||||
continue;
|
||||
}
|
||||
if (TrySplitOption(argument, "--vsync", out var vsyncText))
|
||||
{
|
||||
if (!TryParseSwitch(vsyncText, out var vsync))
|
||||
{
|
||||
ebootPath = string.Empty;
|
||||
runtimeOptions = default;
|
||||
return false;
|
||||
}
|
||||
vsyncOverride = vsync;
|
||||
continue;
|
||||
}
|
||||
if (TrySplitOption(argument, "--hdr", out var hdrText))
|
||||
{
|
||||
if (!TryParseHdrMode(hdrText, out var hdrMode))
|
||||
{
|
||||
ebootPath = string.Empty;
|
||||
runtimeOptions = default;
|
||||
return false;
|
||||
}
|
||||
hdrModeOverride = hdrMode;
|
||||
continue;
|
||||
}
|
||||
if (string.Equals(argument, "--strict", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
strictDynlibResolution = true;
|
||||
@@ -1303,147 +1252,9 @@ internal static partial class Program
|
||||
StrictDynlibResolution = strictDynlibResolution,
|
||||
ImportTraceLimit = importTraceLimit,
|
||||
};
|
||||
var configuredVideoOptions = LoadConfiguredVideoOptions(ebootPath);
|
||||
videoOptions = (configuredVideoOptions with
|
||||
{
|
||||
WindowMode = windowModeOverride ?? configuredVideoOptions.WindowMode,
|
||||
ScalingMode = scalingModeOverride ?? configuredVideoOptions.ScalingMode,
|
||||
Width = windowWidthOverride ?? configuredVideoOptions.Width,
|
||||
Height = windowHeightOverride ?? configuredVideoOptions.Height,
|
||||
DisplayIndex = displayIndexOverride ?? configuredVideoOptions.DisplayIndex,
|
||||
RefreshRate = refreshRateOverride ?? configuredVideoOptions.RefreshRate,
|
||||
VSync = vsyncOverride ?? configuredVideoOptions.VSync,
|
||||
HdrMode = hdrModeOverride ?? configuredVideoOptions.HdrMode,
|
||||
}).Normalize();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static HostVideoOptions LoadConfiguredVideoOptions(string ebootPath)
|
||||
{
|
||||
var defaults = HostVideoOptions.Default;
|
||||
try
|
||||
{
|
||||
var effective = EffectiveLaunchSettings.Resolve(
|
||||
GuiSettings.Load(),
|
||||
PerGameSettings.Load(TryReadTitleId(ebootPath)));
|
||||
|
||||
var windowMode = TryParseWindowMode(effective.WindowMode, out var parsedWindowMode)
|
||||
? parsedWindowMode
|
||||
: defaults.WindowMode;
|
||||
var scalingMode = TryParseScalingMode(effective.ScalingMode, out var parsedScalingMode)
|
||||
? parsedScalingMode
|
||||
: defaults.ScalingMode;
|
||||
var hasResolution = TryParseResolution(
|
||||
effective.Resolution,
|
||||
out var configuredWidth,
|
||||
out var configuredHeight);
|
||||
var hdrMode = TryParseHdrMode(effective.HdrMode, out var parsedHdrMode)
|
||||
? parsedHdrMode
|
||||
: defaults.HdrMode;
|
||||
|
||||
return new HostVideoOptions
|
||||
{
|
||||
WindowMode = windowMode,
|
||||
ScalingMode = scalingMode,
|
||||
Width = hasResolution ? configuredWidth : defaults.Width,
|
||||
Height = hasResolution ? configuredHeight : defaults.Height,
|
||||
DisplayIndex = effective.DisplayIndex,
|
||||
RefreshRate = effective.RefreshRate,
|
||||
VSync = effective.VSync,
|
||||
HdrMode = hdrMode,
|
||||
}.Normalize();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] GUI video settings could not be loaded; using defaults: {exception.Message}");
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TrySplitOption(string argument, string name, out string value)
|
||||
{
|
||||
var prefix = name + "=";
|
||||
if (argument.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
value = argument[prefix.Length..];
|
||||
return true;
|
||||
}
|
||||
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseWindowMode(string value, out HostWindowMode mode)
|
||||
{
|
||||
mode = value.ToLowerInvariant() switch
|
||||
{
|
||||
"windowed" => HostWindowMode.Windowed,
|
||||
"borderless" => HostWindowMode.Borderless,
|
||||
"exclusive" or "fullscreen" => HostWindowMode.ExclusiveFullscreen,
|
||||
_ => (HostWindowMode)(-1),
|
||||
};
|
||||
return Enum.IsDefined(mode);
|
||||
}
|
||||
|
||||
private static bool TryParseScalingMode(string value, out HostScalingMode mode)
|
||||
{
|
||||
mode = value.ToLowerInvariant() switch
|
||||
{
|
||||
"fit" => HostScalingMode.Fit,
|
||||
"cover" => HostScalingMode.Cover,
|
||||
"stretch" => HostScalingMode.Stretch,
|
||||
"integer" => HostScalingMode.Integer,
|
||||
_ => (HostScalingMode)(-1),
|
||||
};
|
||||
return Enum.IsDefined(mode);
|
||||
}
|
||||
|
||||
private static bool TryParseHdrMode(string value, out HostHdrMode mode)
|
||||
{
|
||||
mode = value.ToLowerInvariant() switch
|
||||
{
|
||||
"auto" => HostHdrMode.Auto,
|
||||
"on" or "true" or "1" => HostHdrMode.On,
|
||||
"off" or "false" or "0" => HostHdrMode.Off,
|
||||
_ => (HostHdrMode)(-1),
|
||||
};
|
||||
return Enum.IsDefined(mode);
|
||||
}
|
||||
|
||||
private static bool TryParseResolution(string value, out int width, out int height)
|
||||
{
|
||||
var parts = value.Split('x', 'X');
|
||||
if (parts.Length == 2 && int.TryParse(parts[0], out width) && int.TryParse(parts[1], out height) &&
|
||||
width >= 640 && height >= 360)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
width = 0;
|
||||
height = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseSwitch(string value, out bool enabled)
|
||||
{
|
||||
if (value is "1" || value.Equals("on", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Equals("true", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
enabled = true;
|
||||
return true;
|
||||
}
|
||||
if (value is "0" || value.Equals("off", StringComparison.OrdinalIgnoreCase) ||
|
||||
value.Equals("false", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
enabled = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
enabled = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryParseCpuEngine(string valueText, out CpuExecutionEngine engine)
|
||||
{
|
||||
if (string.Equals(valueText, "native", StringComparison.OrdinalIgnoreCase) ||
|
||||
@@ -1622,7 +1433,7 @@ internal static partial class Program
|
||||
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CreateProcessW(
|
||||
string? applicationName,
|
||||
string applicationName,
|
||||
StringBuilder commandLine,
|
||||
nint processAttributes,
|
||||
nint threadAttributes,
|
||||
|
||||
@@ -20,11 +20,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<!-- osx-x64 is the macOS target: the CPU backend executes guest x86-64
|
||||
natively, so on Apple Silicon it runs under Rosetta 2. -->
|
||||
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
|
||||
<!-- A plain "dotnet publish" with no -r defaults $(RuntimeIdentifier) to
|
||||
the host's own RID; see Directory.Build.props, which is where that
|
||||
default actually has to live (PublishDir's RID suffix is decided
|
||||
there, evaluated before this file, so a default set only here would
|
||||
be too late for it). -->
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
@@ -65,7 +60,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\LICENSE.txt">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
@@ -77,89 +72,19 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<TargetPath>Languages\%(Filename)%(Extension)</TargetPath>
|
||||
<Visible>False</Visible>
|
||||
</Content>
|
||||
<Content Include="..\SharpEmu.LibAtrac9\LICENSE.txt">
|
||||
<CopyToPublishDirectory>Always</CopyToPublishDirectory>
|
||||
<TargetPath>licenses\LibAtrac9.txt</TargetPath>
|
||||
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
||||
<Visible>False</Visible>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="KeepLibAtrac9External" BeforeTargets="_ComputeFilesToBundle">
|
||||
<!-- Keep glfw as a loose file next to the executable; every other native
|
||||
library is embedded into the single-file bundle. -->
|
||||
<Target Name="KeepGlfwOutsideSingleFile" AfterTargets="ComputeResolvedFilesToPublishList">
|
||||
<ItemGroup>
|
||||
<ResolvedFileToPublish Update="@(ResolvedFileToPublish)"
|
||||
Condition="'%(Filename)%(Extension)' == 'SharpEmu.LibAtrac9.dll'">
|
||||
<_GlfwPublishFiles Include="@(ResolvedFileToPublish)"
|
||||
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
|
||||
<ResolvedFileToPublish Remove="@(_GlfwPublishFiles)" />
|
||||
<ResolvedFileToPublish Include="@(_GlfwPublishFiles)">
|
||||
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
||||
<RelativePath>plugins\SharpEmu.LibAtrac9.dll</RelativePath>
|
||||
</ResolvedFileToPublish>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<!-- These are native debug symbols emitted by Skia/HarfBuzz, not managed
|
||||
symbols that single-file publish can bundle. They are not needed at
|
||||
runtime and would otherwise add more than 100 MB to every release. -->
|
||||
<Target Name="RemoveNativeDebugSymbols" AfterTargets="Publish">
|
||||
<ItemGroup>
|
||||
<_NativeDebugSymbols Include="$(PublishDir)**\*.pdb" />
|
||||
</ItemGroup>
|
||||
<Delete Files="@(_NativeDebugSymbols)" />
|
||||
</Target>
|
||||
|
||||
<!-- Native FFmpeg libraries publish into a subfolder next to the
|
||||
executable instead of sitting loose beside it, so the publish
|
||||
directory stays uncluttered as more native deps get added. The folder
|
||||
name is a fixed constant, not derived from the RID/architecture: each
|
||||
publish output only ever holds one architecture's binaries anyway, so
|
||||
varying the name added a class of bugs (RID resolution timing, host-OS
|
||||
vs. target-RID mixups) for no benefit. Runtime code (Program.cs's
|
||||
FfmpegNativeBinkFrameSource uses the same literal "plugins" folder
|
||||
name. -->
|
||||
<PropertyGroup>
|
||||
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
|
||||
<FfmpegRuntimeDir>
|
||||
$(BaseIntermediateOutputPath)ffmpeg-runtime/$(FfmpegRuntimeTag)/$(RuntimeIdentifier)</FfmpegRuntimeDir>
|
||||
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'win-x64'">ffmpeg-windows-x64.zip</FfmpegRuntimePackage>
|
||||
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'linux-x64'">ffmpeg-linux-x64.zip</FfmpegRuntimePackage>
|
||||
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'osx-x64'">ffmpeg-macos-x64.zip</FfmpegRuntimePackage>
|
||||
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'osx-arm64'">ffmpeg-macos-arm64.zip</FfmpegRuntimePackage>
|
||||
<FfmpegRuntimeArchive>$(FfmpegRuntimeDir)/$(FfmpegRuntimePackage)</FfmpegRuntimeArchive>
|
||||
<FfmpegRuntimeExtractDir>$(FfmpegRuntimeDir)/extracted</FfmpegRuntimeExtractDir>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="FetchFfmpegRuntime"
|
||||
BeforeTargets="Publish"
|
||||
Condition="'$(RuntimeIdentifier)' != '' And '$(FfmpegRuntimePackage)' != ''">
|
||||
<DownloadFile
|
||||
SourceUrl="https://github.com/sharpemu/ffmpeg-core/releases/download/$(FfmpegRuntimeTag)/$(FfmpegRuntimePackage)"
|
||||
DestinationFolder="$(FfmpegRuntimeDir)"
|
||||
Condition="!Exists('$(FfmpegRuntimeArchive)')" />
|
||||
<Unzip
|
||||
SourceFiles="$(FfmpegRuntimeArchive)"
|
||||
DestinationFolder="$(FfmpegRuntimeExtractDir)"
|
||||
Condition="!Exists('$(FfmpegRuntimeExtractDir)')" />
|
||||
</Target>
|
||||
|
||||
<Target Name="PublishFfmpegRuntime"
|
||||
AfterTargets="Publish"
|
||||
DependsOnTargets="FetchFfmpegRuntime"
|
||||
Condition="'$(RuntimeIdentifier)' != '' And '$(FfmpegRuntimePackage)' != ''">
|
||||
<!-- Keyed off the target $(RuntimeIdentifier), not the host OS: publishing
|
||||
e.g. linux-x64 from a Windows machine is a supported cross-publish,
|
||||
and the extracted archive's own layout (bin/*.dll vs lib/*.so*) only
|
||||
depends on which platform's ffmpeg-core package was fetched. -->
|
||||
<ItemGroup>
|
||||
<_FfmpegRuntimeFiles Condition="$(RuntimeIdentifier.StartsWith('win'))"
|
||||
Include="$(FfmpegRuntimeExtractDir)/bin/*.dll" />
|
||||
<_FfmpegRuntimeFiles Condition="!$(RuntimeIdentifier.StartsWith('win'))"
|
||||
Include="$(FfmpegRuntimeExtractDir)/lib/*.so;$(FfmpegRuntimeExtractDir)/lib/*.so.*;$(FfmpegRuntimeExtractDir)/lib/*.dylib" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(_FfmpegRuntimeFiles)"
|
||||
DestinationFolder="$(PublishDir)$(NativeLibraryFolderName)"
|
||||
SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -13,9 +13,4 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Emulation;
|
||||
|
||||
/// <summary>
|
||||
/// Pure software implementation of the bit-field math behind AMD's SSE4a EXTRQ/INSERTQ
|
||||
/// (immediate-form) instructions.
|
||||
///
|
||||
/// The direct-execution backend runs guest PS5 code natively on the host CPU. The PS5's Zen 2
|
||||
/// cores implement AMD-only SSE4a (EXTRQ/INSERTQ), but Intel hosts - and Rosetta 2 on Apple
|
||||
/// Silicon - do not, so they raise #UD (STATUS_ILLEGAL_INSTRUCTION) instead of executing the
|
||||
/// opcode. SharpEmu already rewrites one specific compiled EXTRQ+VPBLENDD idiom at load time
|
||||
/// (see <see cref="Native.Sse4aExtrqBlendPatch"/>), but any other occurrence of EXTRQ/INSERTQ -
|
||||
/// a different register allocation, a title built with a different compiler version, and so on
|
||||
/// - still aborts the title. This class ported from Kyty's
|
||||
/// <c>Loader::X64InstructionEmulator::TryEmulateSse4a</c> provides the general bit-field
|
||||
/// extract/insert so the illegal-instruction handler can finish *any* immediate-form
|
||||
/// EXTRQ/INSERTQ in software and resume, instead of relying on a single hard-coded byte pattern.
|
||||
///
|
||||
/// The methods operate on plain 64-bit integers rather than the OS CONTEXT record so the bit
|
||||
/// math can be unit-tested in isolation; the unsafe CONTEXT/XMM plumbing lives in the backend
|
||||
/// adapter (<see cref="Native.DirectExecutionBackend"/>).
|
||||
/// </summary>
|
||||
public static class Sse4aBitFieldEmulator
|
||||
{
|
||||
public static bool IsValidBitField(int length, int index)
|
||||
{
|
||||
var len = length & 0x3F;
|
||||
var idx = index & 0x3F;
|
||||
return (len != 0 || idx == 0) && (len == 0 ? idx == 0 : idx + len <= 64);
|
||||
}
|
||||
|
||||
public static ulong ExtractBitField(ulong value, int length, int index)
|
||||
{
|
||||
var len = length & 0x3F;
|
||||
var idx = index & 0x3F;
|
||||
if (!IsValidBitField(length, index))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (len == 0)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var mask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
|
||||
return (value >> idx) & mask;
|
||||
}
|
||||
|
||||
public static ulong InsertBitField(ulong destination, ulong source, int length, int index)
|
||||
{
|
||||
var len = length & 0x3F;
|
||||
var idx = index & 0x3F;
|
||||
if (!IsValidBitField(length, index))
|
||||
{
|
||||
return destination;
|
||||
}
|
||||
|
||||
if (len == 0)
|
||||
{
|
||||
return source;
|
||||
}
|
||||
|
||||
var fieldMask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
|
||||
var destinationClearMask = fieldMask << idx;
|
||||
var sourceField = (source & fieldMask) << idx;
|
||||
return (destination & ~destinationClearMask) | sourceField;
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Threading;
|
||||
using Iced.Intel;
|
||||
using SharpEmu.Core.Cpu.Emulation;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native;
|
||||
|
||||
// General software fallback for the AMD-only instructions PS5 titles occasionally emit that a
|
||||
// Zen 2-only host implements but Intel hosts (and Rosetta 2 on Apple Silicon) do not:
|
||||
// - SSE4a EXTRQ/INSERTQ, immediate form
|
||||
// - MONITORX/MWAITX
|
||||
//
|
||||
// This is a direct port of Kyty's Loader::X64InstructionEmulator (TryEmulateSse4a /
|
||||
// TryEmulateMonitorxMwaitx). SharpEmu already special-cases exactly one compiled EXTRQ+VPBLENDD
|
||||
// byte sequence at load time (Sse4aExtrqBlendPatch), which only helps the one idiom it was
|
||||
// reverse-engineered from. This file is a general, fault-time fallback that engages for any
|
||||
// immediate-form EXTRQ/INSERTQ or MONITORX/MWAITX the narrower patch (or a title using a
|
||||
// different compiler/register allocation) does not cover, complementing rather than replacing
|
||||
// it: the load-time patch still avoids paying the fault-and-recover cost on the hot path it was
|
||||
// built for, while this method is the safety net for everything else.
|
||||
//
|
||||
// This is deliberately additive: DirectExecutionBackend.IllegalInstruction.cs (the BMI1/BMI2/ABM
|
||||
// fallback) is untouched, and this method is only reached from VectoredHandler after that one
|
||||
// has already declined to handle the fault.
|
||||
public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
// Byte offset of Xmm0 within the Win64 CONTEXT record: FltSave (the XMM_SAVE_AREA32/FXSAVE
|
||||
// image) starts right after Rip at offset 256, and XmmRegisters[0] sits 160 bytes into that
|
||||
// area (32-byte header + 8 legacy x87/MMX slots x 16 bytes). 256 + 160 = 416 (0x1A0). Cross-
|
||||
// checked against this file's own Win64ContextSize (0x4D0): rebuilding the whole CONTEXT
|
||||
// layout field-by-field from offset 0 lands on the same 0x4D0 total, which would not happen
|
||||
// if this offset (or anything before it) were wrong.
|
||||
private const int Win64ContextXmm0Offset = 0x1A0;
|
||||
|
||||
private static int _sse4aSoftwareFallbackAnnounced;
|
||||
private static long _sse4aInstructionsEmulated;
|
||||
private static int _monitorxSoftwareFallbackAnnounced;
|
||||
private static long _monitorxInstructionsEmulated;
|
||||
|
||||
private unsafe bool TryRecoverAmdCompatInstruction(void* contextRecord, ulong rip)
|
||||
{
|
||||
if (TryRecoverMonitorxMwaitx(contextRecord, rip))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// MONITORX/MWAITX above only ever reads guest code memory and rewrites RIP, both of
|
||||
// which the POSIX signal bridge (DirectExecutionBackend.PosixSignals.cs) faithfully
|
||||
// round-trips through the real ucontext, so it works on every supported OS. EXTRQ/
|
||||
// INSERTQ additionally read and write an XMM register: on Windows contextRecord is the
|
||||
// live CONTEXT the OS resumes the thread from, so touching the Xmm0.. slots is visible
|
||||
// to the guest, and on Linux the bridge copies the mcontext's FXSAVE image into the
|
||||
// Xmm0.. slots and writes them back through sigreturn (_posixXmmContextBridged). On
|
||||
// Darwin the XMM area is still a zeroed scratch buffer - running this there would
|
||||
// silently compute a result from stale bytes and then discard whatever it "wrote", so
|
||||
// the recovery declines until that bridge exists.
|
||||
return (OperatingSystem.IsWindows() || _posixXmmContextBridged) &&
|
||||
TryRecoverSse4aExtractInsert(contextRecord, rip);
|
||||
}
|
||||
|
||||
private unsafe bool TryRecoverMonitorxMwaitx(void* contextRecord, ulong rip)
|
||||
{
|
||||
// MONITORX (0F 01 FA) and MWAITX (0F 01 FB) are fixed 3-byte encodings with no
|
||||
// ModRM/SIB/displacement/immediate, so a raw byte compare is sufficient and unambiguous.
|
||||
var opcode = new byte[3];
|
||||
if (!TryReadHostBytes(rip, opcode) ||
|
||||
opcode[0] != 0x0F || opcode[1] != 0x01 || (opcode[2] != 0xFA && opcode[2] != 0xFB))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// PS5 titles use this pair in idle/wait loops: MONITORX arms a monitor on a cache line
|
||||
// and MWAITX blocks until that line is written (or a timeout elapses). Hosts without
|
||||
// the extension raise #UD on either one. We do not model the monitor itself, only its
|
||||
// observable effect on guest forward progress: MONITORX becomes a no-op (arming a
|
||||
// watch we never honour has no side effect of its own) and MWAITX becomes a plain
|
||||
// thread yield, i.e. treat the awaited condition as already satisfied so the guest
|
||||
// loop keeps making progress instead of executing an illegal opcode forever.
|
||||
if (opcode[2] == 0xFB)
|
||||
{
|
||||
Thread.Yield();
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, CTX_RIP, rip + 3);
|
||||
|
||||
Interlocked.Increment(ref _monitorxInstructionsEmulated);
|
||||
if (Interlocked.Exchange(ref _monitorxSoftwareFallbackAnnounced, 1) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Host lacks AMD MONITORX/MWAITX used by the guest; " +
|
||||
"emulating those instructions in software.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private unsafe bool TryRecoverSse4aExtractInsert(void* contextRecord, ulong rip)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() && !_posixXmmContextBridged ||
|
||||
!TryReadFaultingInstruction(rip, out var instruction))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var isExtrq = instruction.Mnemonic == Mnemonic.Extrq;
|
||||
var isInsertq = instruction.Mnemonic == Mnemonic.Insertq;
|
||||
if (!isExtrq && !isInsertq)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isExtrq && instruction.OpCount != 3 || isInsertq && instruction.OpCount != 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (instruction.GetOpKind(0) != OpKind.Register ||
|
||||
!TryGetXmmOffset(instruction.GetOpRegister(0), out var destOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var destLow = ReadCtxU64(contextRecord, destOffset);
|
||||
if (isExtrq)
|
||||
{
|
||||
var length = (int)instruction.GetImmediate(1);
|
||||
var index = (int)instruction.GetImmediate(2);
|
||||
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.ExtractBitField(destLow, length, index));
|
||||
WriteCtxU64(contextRecord, destOffset + 8, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (instruction.GetOpKind(1) != OpKind.Register ||
|
||||
!TryGetXmmOffset(instruction.GetOpRegister(1), out var srcOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var length = (int)instruction.GetImmediate(2);
|
||||
var index = (int)instruction.GetImmediate(3);
|
||||
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.InsertBitField(
|
||||
destLow, ReadCtxU64(contextRecord, srcOffset), length, index));
|
||||
WriteCtxU64(contextRecord, destOffset + 8, 0);
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, CTX_RIP, rip + (ulong)instruction.Length);
|
||||
|
||||
Interlocked.Increment(ref _sse4aInstructionsEmulated);
|
||||
if (Interlocked.Exchange(ref _sse4aSoftwareFallbackAnnounced, 1) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Host lacks SSE4a EXTRQ/INSERTQ used by the guest; " +
|
||||
"emulating those instructions in software.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Maps an Iced XMM register to its byte offset in the Win64 CONTEXT record. Written as an
|
||||
// explicit switch (rather than arithmetic on the Register enum) to match the style already
|
||||
// used by TryGetGprSlot/TryGetGpr64Offset in DirectExecutionBackend.IllegalInstruction.cs.
|
||||
private static bool TryGetXmmOffset(Register register, out int offset)
|
||||
{
|
||||
switch (register)
|
||||
{
|
||||
case Register.XMM0: offset = Win64ContextXmm0Offset + 16 * 0; return true;
|
||||
case Register.XMM1: offset = Win64ContextXmm0Offset + 16 * 1; return true;
|
||||
case Register.XMM2: offset = Win64ContextXmm0Offset + 16 * 2; return true;
|
||||
case Register.XMM3: offset = Win64ContextXmm0Offset + 16 * 3; return true;
|
||||
case Register.XMM4: offset = Win64ContextXmm0Offset + 16 * 4; return true;
|
||||
case Register.XMM5: offset = Win64ContextXmm0Offset + 16 * 5; return true;
|
||||
case Register.XMM6: offset = Win64ContextXmm0Offset + 16 * 6; return true;
|
||||
case Register.XMM7: offset = Win64ContextXmm0Offset + 16 * 7; return true;
|
||||
case Register.XMM8: offset = Win64ContextXmm0Offset + 16 * 8; return true;
|
||||
case Register.XMM9: offset = Win64ContextXmm0Offset + 16 * 9; return true;
|
||||
case Register.XMM10: offset = Win64ContextXmm0Offset + 16 * 10; return true;
|
||||
case Register.XMM11: offset = Win64ContextXmm0Offset + 16 * 11; return true;
|
||||
case Register.XMM12: offset = Win64ContextXmm0Offset + 16 * 12; return true;
|
||||
case Register.XMM13: offset = Win64ContextXmm0Offset + 16 * 13; return true;
|
||||
case Register.XMM14: offset = Win64ContextXmm0Offset + 16 * 14; return true;
|
||||
case Register.XMM15: offset = Win64ContextXmm0Offset + 16 * 15; return true;
|
||||
default:
|
||||
offset = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,71 +23,14 @@ public sealed partial class DirectExecutionBackend
|
||||
private static long _perfHleTotal;
|
||||
private static long _perfHleDispatchTicks;
|
||||
|
||||
private sealed class PerfHleExportCost
|
||||
{
|
||||
public long Calls;
|
||||
public long Ticks;
|
||||
}
|
||||
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, PerfHleExportCost> _perfHleCosts = new();
|
||||
|
||||
/// <summary>
|
||||
/// Name of the export currently being dispatched on this thread, so the
|
||||
/// gateway can attribute its elapsed time once the call returns. Answering
|
||||
/// "which export is worth optimising" needs cost per export, not just call
|
||||
/// counts — a rare expensive call and a hot cheap one look identical in a
|
||||
/// frequency histogram.
|
||||
/// </summary>
|
||||
[System.ThreadStatic]
|
||||
private static string? _perfHleCurrentExport;
|
||||
|
||||
private static long _perfHleFirstTimestamp;
|
||||
|
||||
private static void RecordPerfHleDispatchTime(long ticks)
|
||||
{
|
||||
var total = System.Threading.Interlocked.Add(ref _perfHleDispatchTicks, ticks);
|
||||
var calls = System.Threading.Interlocked.Read(ref _perfHleTotal);
|
||||
|
||||
var name = _perfHleCurrentExport;
|
||||
if (name is not null)
|
||||
{
|
||||
var cost = _perfHleCosts.GetOrAdd(name, static _ => new PerfHleExportCost());
|
||||
System.Threading.Interlocked.Increment(ref cost.Calls);
|
||||
System.Threading.Interlocked.Add(ref cost.Ticks, ticks);
|
||||
}
|
||||
|
||||
if (calls > 0 && calls % 500000 == 0)
|
||||
{
|
||||
var frequency = (double)System.Diagnostics.Stopwatch.Frequency;
|
||||
var avgUs = (double)total / frequency * 1_000_000.0 / calls;
|
||||
var first = System.Threading.Interlocked.CompareExchange(ref _perfHleFirstTimestamp, 0, 0);
|
||||
var wallSeconds = first == 0
|
||||
? 0
|
||||
: (double)(System.Diagnostics.Stopwatch.GetTimestamp() - first) / frequency;
|
||||
System.Console.Error.WriteLine(
|
||||
$"[PERF][HLE] managed_dispatch_avg={avgUs:F3}us " +
|
||||
$"total_managed_s={(double)total / frequency:F2} " +
|
||||
$"wall_s={wallSeconds:F2} " +
|
||||
$"cores={(wallSeconds > 0 ? total / frequency / wallSeconds : 0):F2}");
|
||||
|
||||
var snapshot = new System.Collections.Generic.List<System.Collections.Generic.KeyValuePair<string, PerfHleExportCost>>(_perfHleCosts.Count + 16);
|
||||
foreach (var kvp in _perfHleCosts)
|
||||
{
|
||||
snapshot.Add(kvp);
|
||||
}
|
||||
|
||||
var top = snapshot
|
||||
.OrderByDescending(kvp => System.Threading.Interlocked.Read(ref kvp.Value.Ticks))
|
||||
.Take(12)
|
||||
.Select(kvp =>
|
||||
{
|
||||
var seconds = System.Threading.Interlocked.Read(ref kvp.Value.Ticks) / frequency;
|
||||
var callCount = System.Threading.Interlocked.Read(ref kvp.Value.Calls);
|
||||
var cores = wallSeconds > 0 ? seconds / wallSeconds : 0;
|
||||
var perCallUs = callCount > 0 ? seconds * 1_000_000.0 / callCount : 0;
|
||||
return $"{kvp.Key}: {cores:F2}cores {seconds:F1}s n={callCount} {perCallUs:F2}us/call";
|
||||
});
|
||||
System.Console.Error.WriteLine($"[PERF][HLE] cost: {string.Join(" | ", top)}");
|
||||
var avgUs = (double)total / System.Diagnostics.Stopwatch.Frequency * 1_000_000.0 / calls;
|
||||
System.Console.Error.WriteLine($"[PERF][HLE] managed_dispatch_avg={avgUs:F3}us total_managed_s={(double)total / System.Diagnostics.Stopwatch.Frequency:F2}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,16 +39,7 @@ public sealed partial class DirectExecutionBackend
|
||||
|
||||
private static void RecordPerfHleCall(string name)
|
||||
{
|
||||
_perfHleCurrentExport = name;
|
||||
var total = System.Threading.Interlocked.Increment(ref _perfHleTotal);
|
||||
if (total == 1)
|
||||
{
|
||||
System.Threading.Interlocked.CompareExchange(
|
||||
ref _perfHleFirstTimestamp,
|
||||
System.Diagnostics.Stopwatch.GetTimestamp(),
|
||||
0);
|
||||
}
|
||||
|
||||
if (!_perfHleNoDict)
|
||||
{
|
||||
_perfHleCounts.AddOrUpdate(name, 1, static (_, v) => v + 1);
|
||||
|
||||
@@ -19,9 +19,6 @@ public sealed partial class DirectExecutionBackend
|
||||
private static int _lazyCommitTraceCount;
|
||||
private static int _guestAllocatorHoleRecoveries;
|
||||
private static int _auxiliaryThreadExecuteFaultRecoveries;
|
||||
private static int _auxiliaryThreadExecuteFaultSkips;
|
||||
private nint _workerAbortStack;
|
||||
private const uint WorkerAbortStackSize = 0x10000u;
|
||||
|
||||
private unsafe void SetupExceptionHandler()
|
||||
{
|
||||
@@ -40,15 +37,6 @@ public sealed partial class DirectExecutionBackend
|
||||
}
|
||||
_rawExceptionHandler = (nint)AddVectoredExceptionHandler(1u, _rawExceptionHandlerStub);
|
||||
Console.Error.WriteLine($"[LOADER][INFO] Raw exception handler installed: 0x{_rawExceptionHandler:X16}");
|
||||
|
||||
// The raw handler carries the guest-image write-fault bridge, so the
|
||||
// path must be compiled before the first protected-page store can
|
||||
// reach it. Guest code has not started yet, so warming here cannot
|
||||
// race a real fault.
|
||||
SharpEmu.HLE.GuestImageWriteTracker.WarmUp();
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Guest image CPU write tracking: " +
|
||||
$"{(SharpEmu.HLE.GuestImageWriteTracker.Enabled ? "enabled" : "disabled")}");
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -64,7 +52,6 @@ public sealed partial class DirectExecutionBackend
|
||||
}
|
||||
_exceptionHandler = (nint)AddVectoredExceptionHandler(1u, _exceptionHandlerStub);
|
||||
Console.Error.WriteLine($"[LOADER][INFO] Exception handler installed: 0x{_exceptionHandler:X16}");
|
||||
SharpEmu.HLE.GuestImageWriteTracker.WarmUp();
|
||||
|
||||
_unhandledFilterDelegate = UnhandledExceptionFilter;
|
||||
_unhandledFilterHandle = GCHandle.Alloc(_unhandledFilterDelegate);
|
||||
@@ -127,13 +114,6 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (exceptionCode == 3221225477u &&
|
||||
exceptionRecord->NumberParameters >= 2 &&
|
||||
SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault(
|
||||
exceptionRecord->ExceptionInformation[1]))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (TryRecoverAuxiliaryThreadExecuteFault(exceptionRecord, contextRecord, rip))
|
||||
{
|
||||
return -1;
|
||||
@@ -153,11 +133,6 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (exceptionCode == StatusIllegalInstruction &&
|
||||
TryRecoverAmdCompatInstruction(contextRecord, rip))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (IsBenignHostDebugException(exceptionCode))
|
||||
{
|
||||
return -1;
|
||||
@@ -455,91 +430,18 @@ public sealed partial class DirectExecutionBackend
|
||||
void* contextRecord,
|
||||
ulong rip)
|
||||
{
|
||||
if (exceptionRecord->ExceptionCode != 3221225477u)
|
||||
if (exceptionRecord->ExceptionCode != 3221225477u ||
|
||||
rip >= 0x0000000800000000UL ||
|
||||
_activeGuestThreadState is not { Name: "tbb_thead" } activeThread)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prefer ThreadStatic active state; fall back to host-thread name when
|
||||
// concurrent TBB AVs race logging (tLT61: recover skipped, then Fatal).
|
||||
GuestThreadState? activeThread = _activeGuestThreadState;
|
||||
if (activeThread is null || activeThread.Name != "tbb_thead")
|
||||
{
|
||||
var hostName = Thread.CurrentThread.Name;
|
||||
if (hostName is null ||
|
||||
!hostName.StartsWith("SharpEmu-tbb_thead", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
activeThread = FindGuestThreadStateByHostThreadId(unchecked((int)GetCurrentThreadId()));
|
||||
if (activeThread is null || activeThread.Name != "tbb_thead")
|
||||
{
|
||||
var skip = Interlocked.Increment(ref _auxiliaryThreadExecuteFaultSkips);
|
||||
if (skip <= 8 || skip % 64 == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] tbb_recover skip #{skip}: rip=0x{rip:X16} " +
|
||||
$"host='{hostName}' active={(activeThread?.Name ?? "null")}");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var hostExit = ActiveEntryReturnSentinelRip;
|
||||
if (hostExit < 0x10000)
|
||||
{
|
||||
hostExit = unchecked((ulong)_guestReturnStub);
|
||||
}
|
||||
|
||||
// Prefer worker-abort (SetEvent + ExitThread) over host_exit→RunEpilogue:
|
||||
// the latter FailFasts the process after TBB recover (tLT28/30 silent die).
|
||||
// Do NOT abandon mutexes here — managed HLE from inside VEH can re-enter
|
||||
// and Fatal (tLT73). NativeGuestExecutor.Run abandons after detecting abort.
|
||||
var abortRip = unchecked((ulong)_workerAbortStub);
|
||||
if (abortRip >= 0x10000)
|
||||
{
|
||||
// Do NOT SetEvent from managed VEH: that wakes the renter which may
|
||||
// TerminateThread while this thread is still inside VEH return
|
||||
// (tLTA2: recover logged, no respawning, process die). Abort stub
|
||||
// SetEvent's only after CONTINUE_EXECUTION resumes at park.
|
||||
|
||||
// Prefer the entry-stub-saved host RSP (real CreateThread stack).
|
||||
// Do not treat mid-range host stacks as guest — Astro worker stacks
|
||||
// often sit in 0x02xxxxxx_xxxx and were wrongly replaced with a
|
||||
// shared VirtualAlloc abort stack (concurrent TBB AV → die).
|
||||
var hostRspSlot = TlsGetValue(_hostRspSlotTlsIndex);
|
||||
ulong hostRsp = 0;
|
||||
if (hostRspSlot != 0)
|
||||
{
|
||||
hostRsp = *(ulong*)hostRspSlot;
|
||||
}
|
||||
|
||||
if (hostRsp < 0x10000)
|
||||
{
|
||||
hostRsp = EnsureWorkerAbortStackRsp();
|
||||
}
|
||||
|
||||
if (hostRsp >= 0x10000)
|
||||
{
|
||||
WriteCtxU64(contextRecord, 152, hostRsp & ~0xFUL);
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, 120, 0);
|
||||
WriteCtxU64(contextRecord, 248, abortRip);
|
||||
var recovery = Interlocked.Increment(ref _auxiliaryThreadExecuteFaultRecoveries);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Recovered auxiliary TBB execute fault #{recovery}: " +
|
||||
$"thread=0x{activeThread.ThreadHandle:X16} target=0x{rip:X16} " +
|
||||
$"host_rsp=0x{hostRsp:X16} -> worker_abort=0x{abortRip:X16}");
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] tbb_recover: parking native worker (SetEvent+park); " +
|
||||
"renter will TerminateThread+respawn — avoids ExitThread after VEH");
|
||||
Console.Error.Flush();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hostExit < 0x10000)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
@@ -551,57 +453,13 @@ public sealed partial class DirectExecutionBackend
|
||||
_ = TryPatchActiveGuestReturnSlot(hostExit);
|
||||
WriteCtxU64(contextRecord, 120, 0);
|
||||
WriteCtxU64(contextRecord, 248, hostExit);
|
||||
var recoveryFallback = Interlocked.Increment(ref _auxiliaryThreadExecuteFaultRecoveries);
|
||||
var recovery = Interlocked.Increment(ref _auxiliaryThreadExecuteFaultRecoveries);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Recovered auxiliary TBB execute fault #{recoveryFallback}: " +
|
||||
$"[LOADER][WARN] Recovered auxiliary TBB execute fault #{recovery}: " +
|
||||
$"thread=0x{activeThread.ThreadHandle:X16} target=0x{rip:X16} -> host_exit=0x{hostExit:X16}");
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] tbb_recover: resumed at host_exit (abort stub unavailable); " +
|
||||
"subsequent FastFail/CLR must not re-enter managed VEH " +
|
||||
"(live trampoline pre-filters 0xC0000409 / 0xE0434352)");
|
||||
Console.Error.Flush();
|
||||
return true;
|
||||
}
|
||||
|
||||
private GuestThreadState? FindGuestThreadStateByHostThreadId(int hostThreadId)
|
||||
{
|
||||
if (hostThreadId == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var thread in SnapshotGuestThreads())
|
||||
{
|
||||
if (Volatile.Read(ref thread.HostThreadId) == hostThreadId)
|
||||
{
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private unsafe ulong EnsureWorkerAbortStackRsp()
|
||||
{
|
||||
if (_workerAbortStack == 0)
|
||||
{
|
||||
_workerAbortStack = (nint)VirtualAlloc(null, WorkerAbortStackSize, 12288u, 4u);
|
||||
if (_workerAbortStack == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Grow-down stack: hand out near the top with alignment headroom.
|
||||
return (ulong)(_workerAbortStack + (nint)WorkerAbortStackSize - 0x100) & ~0xFUL;
|
||||
}
|
||||
|
||||
private unsafe bool TryRecoverGuestInt41(uint exceptionCode, void* contextRecord, ulong rip)
|
||||
{
|
||||
if (!_ignoreGuestInt41 || exceptionCode != 3221225477u || rip < 0x10000)
|
||||
@@ -620,7 +478,7 @@ public sealed partial class DirectExecutionBackend
|
||||
if (count <= 16 || count % 65536 == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (default-on; set SHARPEMU_IGNORE_INT41=0 to disable)");
|
||||
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (SHARPEMU_IGNORE_INT41=1)");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native;
|
||||
|
||||
/// <summary>
|
||||
/// Sampling profiler for guest code. Managed profilers only see the emulator's
|
||||
/// own frames — once a guest thread is running translated code it is opaque to
|
||||
/// them, so a title that burns its cores inside its own spin loops looks like
|
||||
/// unattributed native time. This walks the guest thread registry and samples
|
||||
/// each thread's host RIP, which lands directly on the guest instruction being
|
||||
/// executed.
|
||||
/// </summary>
|
||||
public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
private static readonly bool _profileGuestRip =
|
||||
string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GUEST_RIP"),
|
||||
"1",
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static readonly int _profileGuestRipIntervalMs =
|
||||
int.TryParse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GUEST_RIP_INTERVAL_MS"),
|
||||
out var interval) && interval > 0
|
||||
? interval
|
||||
: 2;
|
||||
|
||||
private static readonly int _profileGuestRipReportSeconds =
|
||||
int.TryParse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_PROFILE_GUEST_RIP_REPORT_S"),
|
||||
out var report) && report > 0
|
||||
? report
|
||||
: 15;
|
||||
|
||||
private const ulong GuestImageBase = 0x0000_0008_0000_0000UL;
|
||||
private const ulong GuestImageLimit = 0x0000_0009_0000_0000UL;
|
||||
|
||||
private int _guestRipSamplerStarted;
|
||||
private readonly ConcurrentDictionary<ulong, long> _guestRipSamples = new();
|
||||
private readonly ConcurrentDictionary<string, long> _guestRipThreadSamples = new();
|
||||
private readonly ConcurrentDictionary<string, long> _guestWaitSamples = new();
|
||||
private readonly ConcurrentDictionary<string, long> _guestThreadWaitSamples = new();
|
||||
private long _guestRipTotalSamples;
|
||||
private long _guestWaitTotalSamples;
|
||||
private long _guestRipCaptureFailures;
|
||||
private long _guestRipSamplerErrors;
|
||||
private int _guestRipSampleCursor;
|
||||
|
||||
/// <summary>
|
||||
/// Names the HLE call a thread is parked in, using the guest RIP the import
|
||||
/// dispatcher left on its context.
|
||||
/// </summary>
|
||||
private string ResolveWaitLabel(GuestThreadState thread)
|
||||
{
|
||||
var context = thread.Context;
|
||||
if (context is null)
|
||||
{
|
||||
return "<no-context>";
|
||||
}
|
||||
|
||||
var importIndex = context.ActiveImportIndex;
|
||||
if ((uint)importIndex >= (uint)_importEntries.Length)
|
||||
{
|
||||
// Host code with no import in flight: the thread is parked by the
|
||||
// emulator's own scheduler. The cooperative block records why, which
|
||||
// is the part that actually identifies what the frame is waiting on.
|
||||
var blockReason = thread.BlockReason;
|
||||
return string.IsNullOrEmpty(blockReason)
|
||||
? "<idle-or-scheduler>"
|
||||
: $"blocked:{blockReason}";
|
||||
}
|
||||
|
||||
var entry = _importEntries[importIndex];
|
||||
return entry.Export?.Name ?? entry.Nid;
|
||||
}
|
||||
|
||||
internal void ClearActiveImportIndex()
|
||||
{
|
||||
if (!_profileGuestRip)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var context = ActiveCpuContext;
|
||||
if (context is not null)
|
||||
{
|
||||
context.ActiveImportIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureGuestRipSampler()
|
||||
{
|
||||
if (!_profileGuestRip ||
|
||||
!OperatingSystem.IsWindows() ||
|
||||
Interlocked.Exchange(ref _guestRipSamplerStarted, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var sampler = new Thread(GuestRipSampleLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "SharpEmu guest RIP sampler",
|
||||
// Sampling suspends guest threads briefly. Keep this diagnostic below
|
||||
// the title workers so it observes them without becoming the bottleneck.
|
||||
Priority = ThreadPriority.BelowNormal,
|
||||
};
|
||||
sampler.Start();
|
||||
Console.Error.WriteLine(
|
||||
$"[PERF][GUEST] RIP sampler started: interval={_profileGuestRipIntervalMs}ms " +
|
||||
$"report={_profileGuestRipReportSeconds}s");
|
||||
}
|
||||
|
||||
private void GuestRipSampleLoop()
|
||||
{
|
||||
var clock = Stopwatch.StartNew();
|
||||
var lastReportMs = 0L;
|
||||
var lastReportSamples = 0L;
|
||||
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
var guestThreads = SnapshotGuestThreads();
|
||||
var sampleIndex = guestThreads.Length == 0
|
||||
? 0
|
||||
: (int)((uint)Interlocked.Increment(ref _guestRipSampleCursor) % (uint)guestThreads.Length);
|
||||
foreach (var thread in guestThreads.Skip(sampleIndex).Take(1))
|
||||
{
|
||||
var hostThreadId = Volatile.Read(ref thread.HostThreadId);
|
||||
if (hostThreadId == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryCaptureHostThreadContext(hostThreadId, out var snapshot) ||
|
||||
!snapshot.IsValid)
|
||||
{
|
||||
Interlocked.Increment(ref _guestRipCaptureFailures);
|
||||
continue;
|
||||
}
|
||||
|
||||
_guestRipSamples.AddOrUpdate(snapshot.Rip, 1, static (_, value) => value + 1);
|
||||
_guestRipThreadSamples.AddOrUpdate(
|
||||
string.IsNullOrEmpty(thread.Name) ? "<unnamed>" : thread.Name,
|
||||
1,
|
||||
static (_, value) => value + 1);
|
||||
Interlocked.Increment(ref _guestRipTotalSamples);
|
||||
|
||||
// A host RIP means the thread is inside the emulator rather
|
||||
// than running translated code. DispatchImport parks the
|
||||
// guest RIP on the import stub for the call being serviced,
|
||||
// so the stub address names what the thread is waiting on —
|
||||
// no hot-path bookkeeping needed to find out.
|
||||
if (snapshot.Rip >= GuestImageBase && snapshot.Rip < GuestImageLimit)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_guestWaitSamples.AddOrUpdate(
|
||||
ResolveWaitLabel(thread),
|
||||
1,
|
||||
static (_, value) => value + 1);
|
||||
_guestThreadWaitSamples.AddOrUpdate(
|
||||
string.IsNullOrEmpty(thread.Name) ? "<unnamed>" : thread.Name,
|
||||
1,
|
||||
static (_, value) => value + 1);
|
||||
Interlocked.Increment(ref _guestWaitTotalSamples);
|
||||
}
|
||||
|
||||
Thread.Sleep(_profileGuestRipIntervalMs);
|
||||
|
||||
var elapsedMs = clock.ElapsedMilliseconds;
|
||||
if (elapsedMs - lastReportMs < _profileGuestRipReportSeconds * 1000L)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var samples = Interlocked.Read(ref _guestRipTotalSamples);
|
||||
ReportGuestRipSamples(samples - lastReportSamples, (elapsedMs - lastReportMs) / 1000.0);
|
||||
lastReportMs = elapsedMs;
|
||||
lastReportSamples = samples;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// A title can tear down a thread or its context during a capture.
|
||||
// The profiler must never silently die or affect guest execution.
|
||||
if (Interlocked.Increment(ref _guestRipSamplerErrors) == 1)
|
||||
{
|
||||
Console.Error.WriteLine($"[PERF][GUEST] sampler recovery: {exception.GetType().Name}: {exception.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ReportGuestRipSamples(long windowSamples, double windowSeconds)
|
||||
{
|
||||
var total = Interlocked.Read(ref _guestRipTotalSamples);
|
||||
if (total == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var byRip = new List<KeyValuePair<ulong, long>>(_guestRipSamples.Count + 16);
|
||||
foreach (var pair in _guestRipSamples)
|
||||
{
|
||||
byRip.Add(pair);
|
||||
}
|
||||
|
||||
// A tight spin lands on a handful of instructions; grouping by 4 KB page
|
||||
// as well shows which routine those instructions belong to.
|
||||
var byPage = new Dictionary<ulong, long>();
|
||||
foreach (var pair in byRip)
|
||||
{
|
||||
var page = pair.Key & ~0xFFFUL;
|
||||
byPage[page] = byPage.TryGetValue(page, out var existing)
|
||||
? existing + pair.Value
|
||||
: pair.Value;
|
||||
}
|
||||
|
||||
var byThread = new List<KeyValuePair<string, long>>(_guestRipThreadSamples.Count + 16);
|
||||
foreach (var pair in _guestRipThreadSamples)
|
||||
{
|
||||
byThread.Add(pair);
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[PERF][GUEST] samples={total} window={windowSamples} in {windowSeconds:F1}s " +
|
||||
$"capture_failures={Interlocked.Read(ref _guestRipCaptureFailures)}");
|
||||
|
||||
Console.Error.WriteLine(
|
||||
"[PERF][GUEST] top_rip: " +
|
||||
string.Join(
|
||||
" | ",
|
||||
byRip.OrderByDescending(pair => pair.Value)
|
||||
.Take(12)
|
||||
.Select(pair =>
|
||||
$"0x{pair.Key:X}{DescribeGuestAddress(pair.Key)}={pair.Value * 100.0 / total:F1}%")));
|
||||
|
||||
Console.Error.WriteLine(
|
||||
"[PERF][GUEST] top_page: " +
|
||||
string.Join(
|
||||
" | ",
|
||||
byPage.OrderByDescending(pair => pair.Value)
|
||||
.Take(8)
|
||||
.Select(pair =>
|
||||
$"0x{pair.Key:X}{DescribeGuestAddress(pair.Key)}={pair.Value * 100.0 / total:F1}%")));
|
||||
|
||||
var byWait = new List<KeyValuePair<string, long>>(_guestWaitSamples.Count + 16);
|
||||
foreach (var pair in _guestWaitSamples)
|
||||
{
|
||||
byWait.Add(pair);
|
||||
}
|
||||
|
||||
var waitTotal = Interlocked.Read(ref _guestWaitTotalSamples);
|
||||
Console.Error.WriteLine(
|
||||
$"[PERF][GUEST] waiting={waitTotal * 100.0 / total:F1}% of guest thread-time; top_wait: " +
|
||||
string.Join(
|
||||
" | ",
|
||||
byWait.OrderByDescending(pair => pair.Value)
|
||||
.Take(12)
|
||||
.Select(pair => $"{pair.Key}={pair.Value * 100.0 / total:F1}%")));
|
||||
|
||||
// Per-thread spin/park split. The global wait share mixes the job pool in
|
||||
// with a dozen dormant threads, which hides the number that matters:
|
||||
// how much of a core each worker actually burns.
|
||||
Console.Error.WriteLine(
|
||||
"[PERF][GUEST] thread_split (running/parked): " +
|
||||
string.Join(
|
||||
" | ",
|
||||
byThread.OrderByDescending(pair => pair.Value)
|
||||
.Take(10)
|
||||
.Select(pair =>
|
||||
{
|
||||
var parked = _guestThreadWaitSamples.TryGetValue(pair.Key, out var wait) ? wait : 0;
|
||||
var running = pair.Value - parked;
|
||||
return $"{pair.Key}={running * 100.0 / pair.Value:F0}%/{parked * 100.0 / pair.Value:F0}%";
|
||||
})));
|
||||
|
||||
Console.Error.WriteLine(
|
||||
"[PERF][GUEST] top_thread: " +
|
||||
string.Join(
|
||||
" | ",
|
||||
byThread.OrderByDescending(pair => pair.Value)
|
||||
.Take(10)
|
||||
.Select(pair => $"{pair.Key}={pair.Value * 100.0 / total:F1}%")));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tags a sampled address with the region it belongs to. Guest module code
|
||||
/// lives above the image base; anything else is emulator or system code that
|
||||
/// the managed profiler already covers.
|
||||
/// </summary>
|
||||
private string DescribeGuestAddress(ulong address)
|
||||
{
|
||||
|
||||
|
||||
|
||||
if (address >= GuestImageBase && address < GuestImageLimit)
|
||||
{
|
||||
return $"(app+0x{address - GuestImageBase:X})";
|
||||
}
|
||||
|
||||
for (var index = 0; index < _importEntries.Length; index++)
|
||||
{
|
||||
if (_importEntries[index].Address == (address & ~0xFUL))
|
||||
{
|
||||
return $"(stub:{_importEntries[index].Nid})";
|
||||
}
|
||||
}
|
||||
|
||||
return "(host)";
|
||||
}
|
||||
}
|
||||
@@ -54,13 +54,10 @@ public sealed partial class DirectExecutionBackend
|
||||
var startTicks = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
var r = directExecutionBackend.DispatchImport(importIndex, argPackPtr);
|
||||
RecordPerfHleDispatchTime(System.Diagnostics.Stopwatch.GetTimestamp() - startTicks);
|
||||
directExecutionBackend.ClearActiveImportIndex();
|
||||
return r;
|
||||
}
|
||||
|
||||
var result = directExecutionBackend.DispatchImport(importIndex, argPackPtr);
|
||||
directExecutionBackend.ClearActiveImportIndex();
|
||||
return result;
|
||||
return directExecutionBackend.DispatchImport(importIndex, argPackPtr);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -72,45 +69,9 @@ public sealed partial class DirectExecutionBackend
|
||||
|
||||
private unsafe static int RawVectoredHandlerManaged(void* exceptionInfo)
|
||||
{
|
||||
if (TryHandleGuestImageWriteFault(exceptionInfo))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return TryRecoverUnresolvedSentinel(exceptionInfo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Windows counterpart of the POSIX SIGSEGV bridge into
|
||||
/// <see cref="SharpEmu.HLE.GuestImageWriteTracker"/>. Guest code runs natively,
|
||||
/// so a store into a surface the GPU backend has cached is an ordinary CPU
|
||||
/// write with nothing to intercept — the page is write-protected instead and
|
||||
/// the resulting fault is what tells the backend to re-upload. Without this
|
||||
/// the cache serves the first upload forever, and anything the guest CPU
|
||||
/// draws (a software-decoded movie frame, a memset fog layer) never reaches
|
||||
/// the screen.
|
||||
/// </summary>
|
||||
private unsafe static bool TryHandleGuestImageWriteFault(void* exceptionInfo)
|
||||
{
|
||||
if (!SharpEmu.HLE.GuestImageWriteTracker.Enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var exceptionRecord = ((EXCEPTION_POINTERS*)exceptionInfo)->ExceptionRecord;
|
||||
// STATUS_ACCESS_VIOLATION, and only the write flavour: ExceptionInformation
|
||||
// is [accessKind, address] with 0=read, 1=write, 8=DEP execute.
|
||||
if (exceptionRecord->ExceptionCode != 3221225477u ||
|
||||
exceptionRecord->NumberParameters < 2 ||
|
||||
exceptionRecord->ExceptionInformation[0] != 1uL)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault(
|
||||
exceptionRecord->ExceptionInformation[1]);
|
||||
}
|
||||
|
||||
private unsafe static int RawUnhandledFilterManaged(void* exceptionInfo)
|
||||
{
|
||||
return TryRecoverUnresolvedSentinel(exceptionInfo);
|
||||
@@ -204,10 +165,6 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
RecordPerfHleCall(importStubEntry.Export?.Name ?? importStubEntry.Nid);
|
||||
}
|
||||
if (_profileGuestRip)
|
||||
{
|
||||
EnsureGuestRipSampler();
|
||||
}
|
||||
int num2 = Volatile.Read(in _rawSentinelRecoveries);
|
||||
if (num2 != _lastReportedRawSentinelRecoveries)
|
||||
{
|
||||
@@ -221,10 +178,6 @@ public sealed partial class DirectExecutionBackend
|
||||
}
|
||||
|
||||
cpuContext.Rip = importStubEntry.Address;
|
||||
if (_profileGuestRip)
|
||||
{
|
||||
cpuContext.ActiveImportIndex = importIndex;
|
||||
}
|
||||
LoadImportVolatileArguments(cpuContext, argPackPtr);
|
||||
cpuContext[CpuRegister.Rdi] = *(ulong*)argPackPtr;
|
||||
cpuContext[CpuRegister.Rsi] = *(ulong*)(argPackPtr + 8);
|
||||
@@ -577,12 +530,9 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
|
||||
}
|
||||
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
|
||||
{
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
|
||||
}
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
|
||||
StoreImportVectorReturn(cpuContext, argPackPtr);
|
||||
if (dispatchResolved &&
|
||||
orbisGen2Result == OrbisGen2Result.ORBIS_GEN2_OK &&
|
||||
@@ -1376,12 +1326,9 @@ public sealed partial class DirectExecutionBackend
|
||||
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
|
||||
}
|
||||
}
|
||||
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
|
||||
{
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
|
||||
}
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
|
||||
StoreImportVectorReturn(cpuContext, argPackPtr);
|
||||
|
||||
if (returnValue != (int)OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
@@ -1451,13 +1398,11 @@ public sealed partial class DirectExecutionBackend
|
||||
"vWU-odnS+fU" or // sceAmprMeasureCommandSizeReadFile
|
||||
"sSAUCCU1dv4" or // sceAmprMeasureCommandSizeWriteKernelEventQueue_04_00
|
||||
"C+IEj+BsAFM" or // sceAmprMeasureCommandSizeWriteAddressOnCompletion
|
||||
"4fgtGfXDrFc" or // sceAmprMeasureCommandSizeWriteAddress_04_00
|
||||
"tZDDEo2tE5k" or // sceAmprCommandBufferGetSize
|
||||
"GnxKOHEawhk" or // sceAmprCommandBufferGetCurrentOffset
|
||||
"gzndltBEzWc" or // sceAmprCommandBufferGetNumCommands
|
||||
"H896Pt-yB4I" or // sceAmprCommandBufferWriteKernelEventQueue_04_00
|
||||
"sJXyWHjP-F8" or // sceAmprCommandBufferWriteAddressOnCompletion
|
||||
"j0+3uJMxYJY" or // sceAmprCommandBufferWriteAddress_04_00
|
||||
"mPpPxv5CZt4" or // sceSystemServiceGetHdrToneMapLuminance
|
||||
"1FZBKy8HeNU" or // sceVideoOutGetVblankStatus
|
||||
"ASoW5WE-UPo" or // sceKernelAprSubmitCommandBufferAndGetResult
|
||||
@@ -1491,13 +1436,7 @@ public sealed partial class DirectExecutionBackend
|
||||
string.Equals(nid, "fzyMKs9kim0", StringComparison.Ordinal) &&
|
||||
result == OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
|
||||
var expectedMutexTrylockBusy =
|
||||
(nid is "K-jXhbt2gn4" or "upoVrzMHFeE") &&
|
||||
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
var expectedSemaphoreTrywaitAgain =
|
||||
string.Equals(nid, "H2a+IN9TP0E", StringComparison.Ordinal) &&
|
||||
result == OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN;
|
||||
var expectedPollSemaBusy =
|
||||
string.Equals(nid, "12wOHk8ywb0", StringComparison.Ordinal) &&
|
||||
string.Equals(nid, "K-jXhbt2gn4", StringComparison.Ordinal) &&
|
||||
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
var expectedNetAcceptWouldBlock =
|
||||
string.Equals(nid, "PIWqhn9oSxc", StringComparison.Ordinal) &&
|
||||
@@ -1508,19 +1447,13 @@ public sealed partial class DirectExecutionBackend
|
||||
var expectedPrivacyInvalidParameter =
|
||||
string.Equals(nid, "D-CzAxQL0XI", StringComparison.Ordinal) &&
|
||||
resultValue == unchecked((int)0x80960009);
|
||||
var expectedPlayGoChunkEnumerationEnd =
|
||||
string.Equals(nid, "uWIYLFkkwqk", StringComparison.Ordinal) &&
|
||||
resultValue == unchecked((int)0x80B2000C);
|
||||
if (!expectedFileProbeMiss &&
|
||||
!expectedTimedWaitTimeout &&
|
||||
!expectedEqueueTimeout &&
|
||||
!expectedMutexTrylockBusy &&
|
||||
!expectedSemaphoreTrywaitAgain &&
|
||||
!expectedPollSemaBusy &&
|
||||
!expectedNetAcceptWouldBlock &&
|
||||
!expectedUserServiceNoEvent &&
|
||||
!expectedPrivacyInvalidParameter &&
|
||||
!expectedPlayGoChunkEnumerationEnd)
|
||||
!expectedPrivacyInvalidParameter)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -1611,13 +1544,11 @@ public sealed partial class DirectExecutionBackend
|
||||
"vWU-odnS+fU" or
|
||||
"sSAUCCU1dv4" or
|
||||
"C+IEj+BsAFM" or
|
||||
"4fgtGfXDrFc" or
|
||||
"tZDDEo2tE5k" or
|
||||
"GnxKOHEawhk" or
|
||||
"gzndltBEzWc" or
|
||||
"H896Pt-yB4I" or
|
||||
"sJXyWHjP-F8" or
|
||||
"j0+3uJMxYJY" or
|
||||
"mPpPxv5CZt4" or
|
||||
"1FZBKy8HeNU" or
|
||||
"ASoW5WE-UPo" or
|
||||
|
||||
@@ -29,29 +29,9 @@ public sealed partial class DirectExecutionBackend
|
||||
private static readonly bool NativeGuestWorkersDisabled =
|
||||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_NATIVE_GUEST_WORKERS"), "1", StringComparison.Ordinal);
|
||||
|
||||
// Cap concurrent native-worker Runs. Astro's tbb_thead burst overlaps many
|
||||
// UnmanagedCallersOnly prologues; a large prewarm + unbounded concurrency
|
||||
// FailFasts (0xC0000409) mid-storm with no VEH breadcrumb. Pool size and
|
||||
// in-flight Runs are separate knobs.
|
||||
private static readonly int NativeWorkerMaxConcurrent = ReadNativeWorkerMaxConcurrent();
|
||||
|
||||
private static int ReadNativeWorkerMaxConcurrent()
|
||||
{
|
||||
if (int.TryParse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_NATIVE_WORKER_MAX_CONCURRENT"),
|
||||
out var parsed) &&
|
||||
parsed > 0)
|
||||
{
|
||||
return Math.Clamp(parsed, 1, 64);
|
||||
}
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
private readonly object _nativeWorkerGate = new();
|
||||
private readonly List<NativeGuestExecutor> _allNativeWorkers = new();
|
||||
private readonly Stack<NativeGuestExecutor> _idleNativeWorkers = new();
|
||||
private readonly SemaphoreSlim _nativeWorkerRunLimiter = new(NativeWorkerMaxConcurrent);
|
||||
private bool _nativeWorkersDisposed;
|
||||
private int _nativeWorkerCreationFailedLogged;
|
||||
|
||||
@@ -69,9 +49,6 @@ public sealed partial class DirectExecutionBackend
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern uint WaitForSingleObject(nint hHandle, uint dwMilliseconds);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool TerminateThread(nint hThread, uint dwExitCode);
|
||||
|
||||
// Runs an emitted guest entry stub. Preferred path is a pooled native worker
|
||||
// thread; falls back to the historical inline calli (guest frames above this
|
||||
// thread's managed frames) when workers are disabled or unavailable.
|
||||
@@ -79,148 +56,40 @@ public sealed partial class DirectExecutionBackend
|
||||
// Callers set the Active* thread-statics before emitting the stub and read the
|
||||
// yield/forced-exit flags right after this returns, so the worker outcome is
|
||||
// copied back into this thread's statics before returning.
|
||||
private unsafe int RunGuestEntryStub(void* entryStub, ulong hostRspSlot, bool requireNativeWorker = false)
|
||||
private unsafe int RunGuestEntryStub(void* entryStub, ulong hostRspSlot)
|
||||
{
|
||||
// Limit in-flight native Runs before renting so the idle pool is not
|
||||
// drained by threads blocked on the concurrency gate.
|
||||
_nativeWorkerRunLimiter.Wait();
|
||||
NativeGuestExecutor? worker = null;
|
||||
var worker = RentNativeGuestExecutor();
|
||||
if (worker is null)
|
||||
{
|
||||
TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot);
|
||||
return CallNativeEntry(entryStub);
|
||||
}
|
||||
try
|
||||
{
|
||||
// Astro can spawn a burst of tbb_thead while workers are still in
|
||||
// TerminateThread+respawn. Wait for a native worker — never fall back
|
||||
// to managed inline (FailFast) and never throw (uncaught throw mid-
|
||||
// storm was a silent process die).
|
||||
var maxAttempts = requireNativeWorker ? 500 : 48;
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++)
|
||||
{
|
||||
worker = RentNativeGuestExecutor();
|
||||
if (worker is not null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!requireNativeWorker)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
Thread.Sleep(attempt < 32 ? 1 : 4);
|
||||
}
|
||||
|
||||
if (worker is null)
|
||||
{
|
||||
if (requireNativeWorker)
|
||||
{
|
||||
var n = Interlocked.Increment(ref _tbbNativeWorkerRefuseCount);
|
||||
if (n <= 8 || n % 32 == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][ERROR] tbb_native_worker unavailable #{n} after {maxAttempts} attempts; " +
|
||||
"skipping run (no managed inline, no throw)");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
|
||||
_activeGuestThreadYieldRequested = true;
|
||||
_activeGuestThreadYieldReason = "tbb_native_worker_unavailable";
|
||||
_activeForcedGuestExit = true;
|
||||
return unchecked((int)0x80020012);
|
||||
}
|
||||
|
||||
TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot);
|
||||
return CallNativeEntry(entryStub);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var state = _activeGuestThreadState;
|
||||
if (state is { Name: "tbb_thead" })
|
||||
{
|
||||
var n = Interlocked.Increment(ref _tbbNativeRunEnterCount);
|
||||
if (n <= 12 || n % 64 == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] tbb_run_enter #{n} native_tid_pending handle=0x{state.ThreadHandle:X16} " +
|
||||
$"max_concurrent={NativeWorkerMaxConcurrent}");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
}
|
||||
|
||||
var nativeReturn = worker.Run(
|
||||
_activeCpuContext!,
|
||||
state,
|
||||
GuestThreadExecution.CurrentGuestThreadHandle,
|
||||
_activeEntryReturnSentinelRip,
|
||||
_activeGuestReturnSlotAddress,
|
||||
(nint)hostRspSlot,
|
||||
(nint)entryStub,
|
||||
state?.AffinityMask ?? 0,
|
||||
out var yieldRequested,
|
||||
out var yieldReason,
|
||||
out var forcedExit);
|
||||
_activeGuestThreadYieldRequested = yieldRequested;
|
||||
_activeGuestThreadYieldReason = yieldReason;
|
||||
_activeForcedGuestExit = forcedExit;
|
||||
return nativeReturn;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReturnNativeGuestExecutor(worker);
|
||||
}
|
||||
var state = _activeGuestThreadState;
|
||||
var nativeReturn = worker.Run(
|
||||
_activeCpuContext!,
|
||||
state,
|
||||
GuestThreadExecution.CurrentGuestThreadHandle,
|
||||
_activeEntryReturnSentinelRip,
|
||||
_activeGuestReturnSlotAddress,
|
||||
(nint)hostRspSlot,
|
||||
(nint)entryStub,
|
||||
state?.AffinityMask ?? 0,
|
||||
out var yieldRequested,
|
||||
out var yieldReason,
|
||||
out var forcedExit);
|
||||
_activeGuestThreadYieldRequested = yieldRequested;
|
||||
_activeGuestThreadYieldReason = yieldReason;
|
||||
_activeForcedGuestExit = forcedExit;
|
||||
return nativeReturn;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_nativeWorkerRunLimiter.Release();
|
||||
ReturnNativeGuestExecutor(worker);
|
||||
}
|
||||
}
|
||||
|
||||
private static int _tbbNativeRunEnterCount;
|
||||
private static int _tbbNativeWorkerRefuseCount;
|
||||
internal static int _tbbWorkerPrologueFaultCount;
|
||||
|
||||
private void PrewarmNativeGuestWorkers(int count)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() || NativeGuestWorkersDisabled || count <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var warmed = new List<NativeGuestExecutor>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var worker = NativeGuestExecutor.TryCreate(this);
|
||||
if (worker is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
warmed.Add(worker);
|
||||
}
|
||||
|
||||
lock (_nativeWorkerGate)
|
||||
{
|
||||
if (_nativeWorkersDisposed)
|
||||
{
|
||||
foreach (var worker in warmed)
|
||||
{
|
||||
worker.Dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var worker in warmed)
|
||||
{
|
||||
_allNativeWorkers.Add(worker);
|
||||
_idleNativeWorkers.Push(worker);
|
||||
}
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] Native guest workers prewarmed: {warmed.Count}/{count} " +
|
||||
$"max_concurrent={NativeWorkerMaxConcurrent}");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
|
||||
private NativeGuestExecutor? RentNativeGuestExecutor()
|
||||
{
|
||||
// NativeGuestExecutor emits a Win32 wait loop and creates it with
|
||||
@@ -531,22 +400,6 @@ public sealed partial class DirectExecutionBackend
|
||||
return false;
|
||||
}
|
||||
FlushInstructionCache(GetCurrentProcess(), _loopStub, LoopStubSize);
|
||||
return StartWorkerThread();
|
||||
}
|
||||
|
||||
private bool RestartWorkerThread()
|
||||
{
|
||||
if (_loopStub == null || _controlBlock == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
*(int*)_controlBlock = 0;
|
||||
return StartWorkerThread();
|
||||
}
|
||||
|
||||
private bool StartWorkerThread()
|
||||
{
|
||||
_threadHandle = CreateThread(
|
||||
0,
|
||||
WorkerStackReservation,
|
||||
@@ -592,49 +445,6 @@ public sealed partial class DirectExecutionBackend
|
||||
_runForcedExit = false;
|
||||
SignalWorkAvailable();
|
||||
WaitWorkCompleted();
|
||||
|
||||
// Normal path: RunEpilogue/ExitRun clears _entered before SetEvent(done).
|
||||
// TBB abort stub SetEvent's without ExitRun — _entered stays true.
|
||||
if (_entered)
|
||||
{
|
||||
var waitRc = WaitForSingleObject(_threadHandle, 500u);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Native guest worker tid={_nativeThreadId} aborted during run; " +
|
||||
$"wait_rc=0x{waitRc:X8} respawning");
|
||||
Console.Error.Flush();
|
||||
if (_runState is { } abortedState)
|
||||
{
|
||||
_ = GuestThreadExecution.NotifyGuestThreadAbandoned(
|
||||
abortedState.ThreadHandle,
|
||||
"tbb_worker_abort");
|
||||
Volatile.Write(ref abortedState.HostThreadId, _prevHostThreadId);
|
||||
}
|
||||
_entered = false;
|
||||
if (_threadHandle != 0)
|
||||
{
|
||||
// Abort stub parks (no ExitThread). Force-kill the parked OS
|
||||
// thread so we can recreate the loop without process teardown.
|
||||
if (waitRc != 0u)
|
||||
{
|
||||
_ = TerminateThread(_threadHandle, unchecked((uint)(-1)));
|
||||
_ = WaitForSingleObject(_threadHandle, 1000u);
|
||||
}
|
||||
CloseHandle(_threadHandle);
|
||||
_threadHandle = 0;
|
||||
_nativeThreadId = 0;
|
||||
}
|
||||
if (!RestartWorkerThread())
|
||||
{
|
||||
_runPrologueFailed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_runPrologueFailed = false;
|
||||
_runForcedExit = true;
|
||||
_runNativeResult = 0;
|
||||
}
|
||||
}
|
||||
|
||||
_runContext = null;
|
||||
_runState = null;
|
||||
yieldRequested = _runYieldRequested;
|
||||
@@ -642,22 +452,7 @@ public sealed partial class DirectExecutionBackend
|
||||
forcedExit = _runForcedExit;
|
||||
if (_runPrologueFailed)
|
||||
{
|
||||
// Never throw out of the native-worker rent path: an uncaught
|
||||
// exception mid-TBB storm kills the process with no FailFast
|
||||
// breadcrumb.
|
||||
var n = Interlocked.Increment(ref _tbbWorkerPrologueFaultCount);
|
||||
if (n <= 8 || n % 32 == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][ERROR] tbb_worker prologue fault #{n}; soft-fail run " +
|
||||
$"(tid={_nativeThreadId})");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
|
||||
yieldRequested = true;
|
||||
yieldReason = "tbb_worker_prologue_fault";
|
||||
forcedExit = true;
|
||||
return unchecked((int)0x80020012);
|
||||
throw new InvalidOperationException("Native guest worker failed to bind the run ambient (prologue fault)");
|
||||
}
|
||||
return _runNativeResult;
|
||||
}
|
||||
@@ -751,18 +546,6 @@ public sealed partial class DirectExecutionBackend
|
||||
_activeGuestThreadState = _runState;
|
||||
backend.BindTlsBase(_runContext!);
|
||||
TlsSetValue(backend._hostRspSlotTlsIndex, _runHostRspSlot);
|
||||
if (backend._workerDoneEventTlsIndex != uint.MaxValue)
|
||||
{
|
||||
nint doneHandle = OperatingSystem.IsWindows()
|
||||
? _workCompleted!.SafeWaitHandle.DangerousGetHandle()
|
||||
: _doneSemaphore;
|
||||
TlsSetValue(backend._workerDoneEventTlsIndex, doneHandle);
|
||||
}
|
||||
if (backend._tbbAbortEligibleTlsIndex != uint.MaxValue)
|
||||
{
|
||||
nint eligible = _runState is { Name: "tbb_thead" } ? 1 : 0;
|
||||
TlsSetValue(backend._tbbAbortEligibleTlsIndex, eligible);
|
||||
}
|
||||
if (_runState is { } state)
|
||||
{
|
||||
_prevHostThreadId = Volatile.Read(ref state.HostThreadId);
|
||||
@@ -797,14 +580,6 @@ public sealed partial class DirectExecutionBackend
|
||||
Volatile.Write(ref state.HostThreadId, _prevHostThreadId);
|
||||
}
|
||||
TlsSetValue(_backend._hostRspSlotTlsIndex, _prevHostRspSlot);
|
||||
if (_backend._workerDoneEventTlsIndex != uint.MaxValue)
|
||||
{
|
||||
TlsSetValue(_backend._workerDoneEventTlsIndex, 0);
|
||||
}
|
||||
if (_backend._tbbAbortEligibleTlsIndex != uint.MaxValue)
|
||||
{
|
||||
TlsSetValue(_backend._tbbAbortEligibleTlsIndex, 0);
|
||||
}
|
||||
GuestThreadExecution.RestoreGuestThread(_prevGuestThreadHandle);
|
||||
_activeExecutionBackend = _prevBackend;
|
||||
_activeCpuContext = _prevContext;
|
||||
|
||||
@@ -50,19 +50,6 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
private const int LinuxUcontextGregsOffset = 40;
|
||||
private const int LinuxGregsErrOffset = 19 * 8;
|
||||
|
||||
// The kernel's x86-64 sigcontext places the FXSAVE-image pointer right
|
||||
// after the general registers it hands to the handler: err(152)
|
||||
// trapno(160) oldmask(168) cr2(176) fpstate(184), all relative to
|
||||
// GetPosixRegisterBase. glibc and musl both overlay this kernel layout
|
||||
// verbatim (glibc's mcontext_t.fpregs is the same slot), so the offset
|
||||
// is libc-independent. Inside the FXSAVE image the XMM registers start
|
||||
// at +160 (32-byte header + 8 legacy x87/MMX slots x 16 bytes) - the
|
||||
// same relative position they occupy in the Win64 CONTEXT's FltSave
|
||||
// area (Win64ContextXmm0Offset = 256 + 160).
|
||||
private const int LinuxGregsFpstateOffset = 184;
|
||||
private const int FxsaveXmmOffset = 160;
|
||||
private const int XmmBlockSize = 16 * 16;
|
||||
|
||||
// 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
|
||||
@@ -84,15 +71,6 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
[ThreadStatic]
|
||||
private static int _posixSignalHandlerDepth;
|
||||
|
||||
// True while the current thread's in-flight POSIX fault carries the real
|
||||
// XMM registers in the CONTEXT scratch buffer and writes to them will
|
||||
// reach the mcontext on resume. Gates recovery paths (SSE4a EXTRQ/
|
||||
// INSERTQ) that would otherwise compute results from a zeroed XMM area
|
||||
// and silently discard what they "wrote". Darwin is not bridged yet, so
|
||||
// the flag stays false there.
|
||||
[ThreadStatic]
|
||||
private static bool _posixXmmContextBridged;
|
||||
|
||||
private void SetupPosixExceptionHandler()
|
||||
{
|
||||
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_POSIX_SIGNALS"), "1", StringComparison.Ordinal))
|
||||
@@ -274,26 +252,6 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
WriteCtxU64(contextRecord, CTX_RAX + i * 8, *(ulong*)(registers + offsets[i]));
|
||||
}
|
||||
|
||||
// Bridge the XMM registers alongside the GPRs where the layout is
|
||||
// known: on Linux the fpstate pointer and FXSAVE image are kernel
|
||||
// ABI, so recovery paths that read or write XMM state (SSE4a
|
||||
// EXTRQ/INSERTQ) see the live registers and their writes reach the
|
||||
// guest through sigreturn.
|
||||
byte* fpstate = null;
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
fpstate = *(byte**)(registers + LinuxGregsFpstateOffset);
|
||||
if (fpstate != null)
|
||||
{
|
||||
Buffer.MemoryCopy(
|
||||
fpstate + FxsaveXmmOffset,
|
||||
contextRecord + Win64ContextXmm0Offset,
|
||||
XmmBlockSize,
|
||||
XmmBlockSize);
|
||||
}
|
||||
}
|
||||
_posixXmmContextBridged = fpstate != null;
|
||||
|
||||
EXCEPTION_RECORD record = default;
|
||||
record.ExceptionAddress = (void*)ReadCtxU64(contextRecord, CTX_RIP);
|
||||
if (signal == PosixSigIll)
|
||||
@@ -359,14 +317,6 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
{
|
||||
*(ulong*)(registers + offsets[i]) = ReadCtxU64(contextRecord, CTX_RAX + i * 8);
|
||||
}
|
||||
if (fpstate != null)
|
||||
{
|
||||
Buffer.MemoryCopy(
|
||||
contextRecord + Win64ContextXmm0Offset,
|
||||
fpstate + FxsaveXmmOffset,
|
||||
XmmBlockSize,
|
||||
XmmBlockSize);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ using SharpEmu.Core.Cpu.Debugging;
|
||||
using SharpEmu.Core.Loader;
|
||||
using SharpEmu.Core.Memory;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Diagnostics;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native;
|
||||
|
||||
@@ -215,15 +214,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
private nint _guestReturnStub;
|
||||
|
||||
private nint _workerAbortStub;
|
||||
private nint _vehManagedEntryLock;
|
||||
|
||||
private uint _workerDoneEventTlsIndex = uint.MaxValue;
|
||||
|
||||
private uint _tbbAbortEligibleTlsIndex = uint.MaxValue;
|
||||
|
||||
private nint _setEventAddress;
|
||||
|
||||
private nint _rawExceptionHandler;
|
||||
|
||||
private nint _rawExceptionHandlerStub;
|
||||
@@ -722,11 +712,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
private readonly Dictionary<ulong, PendingGuestException> _pendingGuestExceptions = new Dictionary<ulong, PendingGuestException>();
|
||||
|
||||
// Import dispatch is the hottest managed path in UE titles. Most imports do
|
||||
// not have an exception queued, so publish the dictionary population and let
|
||||
// safe points skip _guestThreadGate entirely in the common case.
|
||||
private int _pendingGuestExceptionCount;
|
||||
|
||||
private readonly HashSet<ulong> _activeGuestExceptionDeliveries = new HashSet<ulong>();
|
||||
|
||||
private int _guestThreadPumpDepth;
|
||||
@@ -1051,8 +1036,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
_selfHandlePtr = GCHandle.ToIntPtr(_selfHandle);
|
||||
_guestTlsBaseTlsIndex = TlsAlloc();
|
||||
_hostRspSlotTlsIndex = TlsAlloc();
|
||||
_workerDoneEventTlsIndex = OperatingSystem.IsWindows() ? TlsAlloc() : uint.MaxValue;
|
||||
_tbbAbortEligibleTlsIndex = OperatingSystem.IsWindows() ? TlsAlloc() : uint.MaxValue;
|
||||
if (_guestTlsBaseTlsIndex == uint.MaxValue || _hostRspSlotTlsIndex == uint.MaxValue)
|
||||
{
|
||||
throw new OutOfMemoryException("Failed to allocate native TLS slots");
|
||||
@@ -1076,7 +1059,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
{
|
||||
throw new InvalidOperationException("Failed to resolve kernel32 thread timing functions");
|
||||
}
|
||||
_setEventAddress = kernel32 != 0 ? GetProcAddress(kernel32, "SetEvent") : 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1100,29 +1082,13 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
{
|
||||
throw new OutOfMemoryException("Failed to allocate host stack slot storage");
|
||||
}
|
||||
_vehManagedEntryLock = (nint)VirtualAlloc(null, 64u, 12288u, 4u);
|
||||
if (_vehManagedEntryLock == 0)
|
||||
{
|
||||
throw new OutOfMemoryException("Failed to allocate VEH managed-entry lock");
|
||||
}
|
||||
// owner (nint) + depth (int); recursive — nested VEH on same thread must reenter.
|
||||
*(nint*)_vehManagedEntryLock = 0;
|
||||
*(int*)(_vehManagedEntryLock + sizeof(nint)) = 0;
|
||||
_unresolvedReturnStub = CreateUnresolvedReturnStub();
|
||||
_guestReturnStub = CreateGuestReturnStub();
|
||||
if (_guestReturnStub == 0)
|
||||
{
|
||||
throw new OutOfMemoryException("Failed to allocate guest return stub");
|
||||
}
|
||||
_workerAbortStub = CreateWorkerAbortStub();
|
||||
if (_workerAbortStub == 0 && OperatingSystem.IsWindows())
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Worker abort stub unavailable; TBB execute-fault recover will use host_exit");
|
||||
}
|
||||
SetupExceptionHandler();
|
||||
// Cover the Astro TBB spawn storm (often 8–12 concurrent tbb_thead).
|
||||
PrewarmNativeGuestWorkers(Math.Max(NativeWorkerMaxConcurrent, 4));
|
||||
}
|
||||
|
||||
public bool TryExecute(CpuContext context, ulong entryPoint, Generation generation, IReadOnlyDictionary<ulong, string> importStubs, IReadOnlyDictionary<string, ulong> runtimeSymbols, CpuExecutionOptions executionOptions, out OrbisGen2Result result)
|
||||
@@ -1152,9 +1118,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
_logStrlenBursts = _logStrlenImports ||
|
||||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_STRLEN_BURSTS"), "1", StringComparison.Ordinal);
|
||||
_logGuestContext = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_CONTEXT"), "1", StringComparison.Ordinal);
|
||||
var ignoreGuestInt41Env = Environment.GetEnvironmentVariable("SHARPEMU_IGNORE_INT41");
|
||||
_ignoreGuestInt41 = !string.Equals(ignoreGuestInt41Env, "0", StringComparison.Ordinal) &&
|
||||
!string.Equals(ignoreGuestInt41Env, "false", StringComparison.OrdinalIgnoreCase);
|
||||
_ignoreGuestInt41 = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_IGNORE_INT41"), "1", StringComparison.Ordinal);
|
||||
_ignoredGuestInt41Count = 0;
|
||||
_logGuestThreads = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_GUEST_THREADS"), "1", StringComparison.Ordinal);
|
||||
_logUsleep = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_USLEEP"), "1", StringComparison.Ordinal);
|
||||
@@ -2438,147 +2402,9 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
return (nint)ptr;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After a TBB execute-fault, VEH redirects here on a host stack.
|
||||
/// SetEvent(done) then park forever — ExitThread from VEH CONTINUE_EXECUTION
|
||||
/// was taking down the whole process (recover logged, no respawning). The
|
||||
/// renter TerminateThread's the parked worker and respawns a clean loop.
|
||||
/// </summary>
|
||||
private unsafe nint CreateWorkerAbortStub()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() ||
|
||||
_workerDoneEventTlsIndex == uint.MaxValue ||
|
||||
_tlsGetValueAddress == 0 ||
|
||||
_setEventAddress == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
nint kernel32 = GetModuleHandle("kernel32.dll");
|
||||
nint getStdHandle = kernel32 != 0 ? GetProcAddress(kernel32, "GetStdHandle") : 0;
|
||||
nint writeFile = kernel32 != 0 ? GetProcAddress(kernel32, "WriteFile") : 0;
|
||||
nint flushFileBuffers = kernel32 != 0 ? GetProcAddress(kernel32, "FlushFileBuffers") : 0;
|
||||
|
||||
const uint stubSize = 256u;
|
||||
void* ptr = VirtualAlloc(null, stubSize, 12288u, 4u);
|
||||
if (ptr == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
byte* code = (byte*)ptr;
|
||||
int offset = 0;
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x28); // sub rsp, 0x28
|
||||
EmitByte(code, ref offset, 0xB9);
|
||||
EmitUInt32(code, ref offset, _workerDoneEventTlsIndex); // mov ecx, tls
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = _tlsGetValueAddress;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0); // call TlsGetValue
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x85);
|
||||
EmitByte(code, ref offset, 0xC0); // test rax, rax
|
||||
EmitByte(code, ref offset, 0x74); EmitByte(code, ref offset, 0x0F); // jz skip SetEvent (15 bytes)
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0xC1); // mov rcx, rax
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = _setEventAddress;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0); // call SetEvent
|
||||
|
||||
// Breadcrumb on host stack (survives silent teardown better than managed log).
|
||||
int msgAbsSlot = -1;
|
||||
if (getStdHandle != 0 && writeFile != 0)
|
||||
{
|
||||
ReadOnlySpan<byte> msg = "[LOADER][WARN] tbb_abort_stub SetEvent+park\n"u8;
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x20); // extra shadow for WriteFile args
|
||||
EmitByte(code, ref offset, 0xB9); EmitUInt32(code, ref offset, unchecked((uint)-12));
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = getStdHandle;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0xC1); // mov rcx, handle
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0xC3); // mov rbx, handle (nonvolatile for flush)
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
msgAbsSlot = offset;
|
||||
*(nint*)(code + offset) = 0;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0xC2); // mov rdx, msg
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xB8);
|
||||
EmitUInt32(code, ref offset, (uint)msg.Length);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20); // lea r9, [rsp+0x20]
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20); EmitUInt32(code, ref offset, 0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x28); EmitUInt32(code, ref offset, 0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = writeFile;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
if (flushFileBuffers != 0)
|
||||
{
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0xD9); // mov rcx, rbx
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = flushFileBuffers;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
}
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x20);
|
||||
}
|
||||
|
||||
// Park: do not ExitThread (process-wide silent die after VEH redirect).
|
||||
int parkOffset = offset;
|
||||
EmitByte(code, ref offset, 0xF3); EmitByte(code, ref offset, 0x90); // pause
|
||||
EmitByte(code, ref offset, 0xEB);
|
||||
EmitByte(code, ref offset, unchecked((byte)(parkOffset - (offset + 1)))); // jmp park
|
||||
|
||||
if (msgAbsSlot >= 0)
|
||||
{
|
||||
ReadOnlySpan<byte> msgEmbed = "[LOADER][WARN] tbb_abort_stub SetEvent+park\n"u8;
|
||||
*(nint*)(code + msgAbsSlot) = (nint)ptr + offset;
|
||||
for (int i = 0; i < msgEmbed.Length; i++)
|
||||
{
|
||||
EmitByte(code, ref offset, msgEmbed[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (offset > (int)stubSize)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][ERROR] Worker abort stub overflow: used={offset} cap={stubSize}");
|
||||
VirtualFree(ptr, 0u, 32768u);
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint oldProtect = default;
|
||||
if (!VirtualProtect(ptr, stubSize, 32u, &oldProtect))
|
||||
{
|
||||
VirtualFree(ptr, 0u, 32768u);
|
||||
return 0;
|
||||
}
|
||||
FlushInstructionCache(GetCurrentProcess(), ptr, (nuint)offset);
|
||||
return (nint)ptr;
|
||||
}
|
||||
|
||||
private unsafe nint CreateExceptionHandlerTrampoline(nint managedHandler)
|
||||
{
|
||||
// Live VEH trampoline used by SetupExceptionHandler. Must pre-filter
|
||||
// FastFail / CLR / MSVC C++ / stack-overflow the same way as
|
||||
// WindowsFaultHandling.CreateHandlerThunk: entering managed VEH while
|
||||
// the thread is in cooperative GC mode fail-fasts with
|
||||
// "UnmanagedCallersOnly method from managed code" (tLT18–22).
|
||||
// Extra headroom for native tbb abort + recursive managed-entry spinlock.
|
||||
const uint stubSize = 2048u;
|
||||
const uint stubSize = 256u;
|
||||
void* ptr = VirtualAlloc(null, stubSize, 12288u, 64u);
|
||||
if (ptr == null)
|
||||
{
|
||||
@@ -2587,305 +2413,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
byte* code = (byte*)ptr;
|
||||
int offset = 0;
|
||||
|
||||
ReadOnlySpan<uint> nonManagedExceptionCodes =
|
||||
[
|
||||
0xE0434352u, // CLR managed exception
|
||||
0xE06D7363u, // MSVC C++ exception
|
||||
0xC0000409u, // STATUS_STACK_BUFFER_OVERRUN / FailFast
|
||||
0xC00000FDu, // STATUS_STACK_OVERFLOW
|
||||
];
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x01); // mov rax, [rcx]
|
||||
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x00); // mov eax, [rax] ExceptionCode
|
||||
var passJumpOffsets = stackalloc int[nonManagedExceptionCodes.Length];
|
||||
int fastFailJumpSlot = -1;
|
||||
for (int i = 0; i < nonManagedExceptionCodes.Length; i++)
|
||||
{
|
||||
EmitByte(code, ref offset, 0x3D);
|
||||
EmitUInt32(code, ref offset, nonManagedExceptionCodes[i]);
|
||||
EmitByte(code, ref offset, 0x74);
|
||||
passJumpOffsets[i] = offset;
|
||||
EmitByte(code, ref offset, 0x00);
|
||||
if (nonManagedExceptionCodes[i] == 0xC0000409u)
|
||||
{
|
||||
fastFailJumpSlot = i;
|
||||
}
|
||||
}
|
||||
|
||||
EmitByte(code, ref offset, 0xE9); // jmp mainBody (rel32; FastFail breadcrumb sits between)
|
||||
var mainBodyJumpSlot = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
|
||||
int passOffset = offset;
|
||||
EmitByte(code, ref offset, 0x31); EmitByte(code, ref offset, 0xC0); // xor eax, eax
|
||||
EmitByte(code, ref offset, 0xC3);
|
||||
|
||||
int fastFailPassOffset = offset;
|
||||
var fastFailLogInstalled = false;
|
||||
nint kernel32 = GetModuleHandle("kernel32.dll");
|
||||
nint getStdHandle = kernel32 != 0 ? GetProcAddress(kernel32, "GetStdHandle") : 0;
|
||||
nint writeFile = kernel32 != 0 ? GetProcAddress(kernel32, "WriteFile") : 0;
|
||||
if (fastFailJumpSlot >= 0 && getStdHandle != 0 && writeFile != 0)
|
||||
{
|
||||
// Prefix + Context.Rip hex (AMD64 CONTEXT.Rip @ 0xF8) + newline.
|
||||
// Keep in sync with WindowsFaultHandling.CreateHandlerThunk.
|
||||
ReadOnlySpan<byte> msg =
|
||||
"[LOADER][FATAL] VEH_PASS FastFail 0xC0000409 (live trampoline; skip managed VEH) rip=0x"u8;
|
||||
ReadOnlySpan<byte> hexDigits = "0123456789ABCDEF"u8;
|
||||
// rcx=EXCEPTION_POINTERS*: capture Rip into r10 before clobbering.
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x41);
|
||||
EmitByte(code, ref offset, 0x08); // mov rax, [rcx+8] ContextRecord*
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x90);
|
||||
EmitUInt32(code, ref offset, 0xF8u); // mov r10, [rax+0xF8] Rip
|
||||
EmitByte(code, ref offset, 0x50);
|
||||
EmitByte(code, ref offset, 0x51);
|
||||
EmitByte(code, ref offset, 0x52);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x50);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x51);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x52); // push r10 (Rip)
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x40); // sub rsp, 0x40 (hex buf @ +0x30)
|
||||
EmitByte(code, ref offset, 0xB9); EmitUInt32(code, ref offset, unchecked((uint)-12));
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = getStdHandle;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x28); // mov [rsp+0x28], rax stderr handle
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xC1);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
var msgAbsSlot = offset;
|
||||
*(nint*)(code + offset) = 0;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xC2);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xB8);
|
||||
EmitUInt32(code, ref offset, (uint)msg.Length);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20); EmitUInt32(code, ref offset, 0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x38); EmitUInt32(code, ref offset, 0); // lpOverlapped slot
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = writeFile;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
|
||||
// Hex-encode Rip. Stack after sub 0x40: [rsp+0x40]=saved Rip (push r10).
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8B);
|
||||
EmitByte(code, ref offset, 0x54); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x40); // mov r10, [rsp+0x40]
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xB8);
|
||||
var hexDigitsAbsSlot = offset;
|
||||
*(nint*)(code + offset) = 0;
|
||||
offset += sizeof(nint); // mov r8, hexDigits
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
|
||||
EmitByte(code, ref offset, 0x5C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x30); // lea r11, [rsp+0x30] hex out
|
||||
EmitByte(code, ref offset, 0xB9); EmitUInt32(code, ref offset, 16u); // ecx = 16 nibbles
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xD0); // mov rax, r10
|
||||
int hexLoopOffset = offset;
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC1); EmitByte(code, ref offset, 0xC0);
|
||||
EmitByte(code, ref offset, 0x04); // rol rax, 4
|
||||
EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xC2); // mov edx, eax
|
||||
EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xE2); EmitByte(code, ref offset, 0x0F); // and edx, 0xF
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0xB6);
|
||||
EmitByte(code, ref offset, 0x14); EmitByte(code, ref offset, 0x10); // movzx edx, byte [r8+rdx]
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x88); EmitByte(code, ref offset, 0x13); // mov [r11], dl
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xC3); // inc r11
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xC9); // dec ecx
|
||||
EmitByte(code, ref offset, 0x75);
|
||||
EmitByte(code, ref offset, unchecked((byte)(hexLoopOffset - (offset + 1)))); // jnz hexLoop (rel8)
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xC6); EmitByte(code, ref offset, 0x03);
|
||||
EmitByte(code, ref offset, 0x0A); // mov byte [r11], '\n'
|
||||
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x28); // mov rcx, [rsp+0x28] stderr
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8D);
|
||||
EmitByte(code, ref offset, 0x54); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x30); // lea rdx, [rsp+0x30]
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xB8);
|
||||
EmitUInt32(code, ref offset, 17u); // 16 hex + newline
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20); EmitUInt32(code, ref offset, 0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x38); EmitUInt32(code, ref offset, 0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = writeFile;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
|
||||
// Flush redirected stderr so FastFail rip survives process teardown.
|
||||
nint flushFileBuffers = kernel32 != 0 ? GetProcAddress(kernel32, "FlushFileBuffers") : 0;
|
||||
if (flushFileBuffers != 0)
|
||||
{
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x28); // mov rcx, stderr
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = flushFileBuffers;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
}
|
||||
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x40);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5A); // pop r10
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x59);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x58);
|
||||
EmitByte(code, ref offset, 0x5A);
|
||||
EmitByte(code, ref offset, 0x59);
|
||||
EmitByte(code, ref offset, 0x58);
|
||||
EmitByte(code, ref offset, 0x31); EmitByte(code, ref offset, 0xC0);
|
||||
EmitByte(code, ref offset, 0xC3);
|
||||
|
||||
var msgOffset = offset;
|
||||
for (int i = 0; i < msg.Length; i++)
|
||||
{
|
||||
EmitByte(code, ref offset, msg[i]);
|
||||
}
|
||||
|
||||
var hexDigitsOffset = offset;
|
||||
for (int i = 0; i < hexDigits.Length; i++)
|
||||
{
|
||||
EmitByte(code, ref offset, hexDigits[i]);
|
||||
}
|
||||
|
||||
*(nint*)(code + msgAbsSlot) = (nint)ptr + msgOffset;
|
||||
*(nint*)(code + hexDigitsAbsSlot) = (nint)ptr + hexDigitsOffset;
|
||||
code[passJumpOffsets[fastFailJumpSlot]] =
|
||||
checked((byte)(fastFailPassOffset - (passJumpOffsets[fastFailJumpSlot] + 1)));
|
||||
fastFailLogInstalled = true;
|
||||
}
|
||||
|
||||
int mainBodyOffset = offset;
|
||||
*(int*)(code + mainBodyJumpSlot) = mainBodyOffset - (mainBodyJumpSlot + sizeof(int));
|
||||
for (int i = 0; i < nonManagedExceptionCodes.Length; i++)
|
||||
{
|
||||
if (i == fastFailJumpSlot && fastFailLogInstalled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
code[passJumpOffsets[i]] = checked((byte)(passOffset - (passJumpOffsets[i] + 1)));
|
||||
}
|
||||
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x54); // push r12
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x55); // push r13
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xE4); // mov r12, rsp
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xCD); // mov r13, rcx
|
||||
|
||||
// Native worker EXECUTE-AV abort without managed VEH.
|
||||
// Do NOT catch read/write AVs — workers need managed lazy-commit (tLTJ
|
||||
// silent-die when every worker AV was aborted). Execute faults on
|
||||
// tbb_thead are the concurrent-managed FailFast case (tLTC).
|
||||
int tbbFallthroughJump = -1;
|
||||
if (_workerAbortStub != 0 &&
|
||||
_tlsGetValueAddress != 0 &&
|
||||
_hostRspSlotTlsIndex != uint.MaxValue)
|
||||
{
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x8B);
|
||||
EmitByte(code, ref offset, 0x45); EmitByte(code, ref offset, 0x00); // mov rax, [r13]
|
||||
EmitByte(code, ref offset, 0x81); EmitByte(code, ref offset, 0x38);
|
||||
EmitUInt32(code, ref offset, 0xC0000005u); // cmp dword [rax], AV
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
|
||||
tbbFallthroughJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u); // jne fallthrough
|
||||
|
||||
// ExceptionInformation[0] == 8 → EXECUTE (DEP). Offset 32 on x64 RECORD.
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xB8); EmitUInt32(code, ref offset, 32u);
|
||||
EmitByte(code, ref offset, 0x08); // cmp qword [rax+32], 8
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
|
||||
var tbbNotExecuteJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
|
||||
int tbbNotEligibleJump = -1;
|
||||
if (_tbbAbortEligibleTlsIndex != uint.MaxValue)
|
||||
{
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0xB9);
|
||||
EmitUInt32(code, ref offset, _tbbAbortEligibleTlsIndex);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = _tlsGetValueAddress;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x85);
|
||||
EmitByte(code, ref offset, 0xC0);
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x84);
|
||||
tbbNotEligibleJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
}
|
||||
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0xB9);
|
||||
EmitUInt32(code, ref offset, _hostRspSlotTlsIndex);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = _tlsGetValueAddress;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x85);
|
||||
EmitByte(code, ref offset, 0xC0);
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x84);
|
||||
var tbbNoHostRspJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8B);
|
||||
EmitByte(code, ref offset, 0x00); // mov r8, [rax] hostRsp
|
||||
EmitByte(code, ref offset, 0x4D); EmitByte(code, ref offset, 0x85);
|
||||
EmitByte(code, ref offset, 0xC0);
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x84);
|
||||
var tbbZeroRspJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xE0); EmitByte(code, ref offset, 0xF0); // and r8, ~0xF
|
||||
EmitByte(code, ref offset, 0x4D); EmitByte(code, ref offset, 0x8B);
|
||||
EmitByte(code, ref offset, 0x4D); EmitByte(code, ref offset, 0x08); // mov r9, [r13+8]
|
||||
EmitByte(code, ref offset, 0x4D); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0x81); EmitUInt32(code, ref offset, 0x98u); // Context.Rsp
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = _workerAbortStub;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0x81); EmitUInt32(code, ref offset, 0xF8u); // Context.Rip
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x78);
|
||||
EmitUInt32(code, ref offset, 0u); // Context.Rax = 0
|
||||
|
||||
EmitByte(code, ref offset, 0xB8); EmitUInt32(code, ref offset, unchecked((uint)-1));
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0xE4); // mov rsp, r12
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5D);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5C);
|
||||
EmitByte(code, ref offset, 0xC3);
|
||||
|
||||
int tbbFallthroughOffset = offset;
|
||||
*(int*)(code + tbbFallthroughJump) = tbbFallthroughOffset - (tbbFallthroughJump + sizeof(int));
|
||||
*(int*)(code + tbbNotExecuteJump) = tbbFallthroughOffset - (tbbNotExecuteJump + sizeof(int));
|
||||
if (tbbNotEligibleJump >= 0)
|
||||
{
|
||||
*(int*)(code + tbbNotEligibleJump) = tbbFallthroughOffset - (tbbNotEligibleJump + sizeof(int));
|
||||
}
|
||||
*(int*)(code + tbbNoHostRspJump) = tbbFallthroughOffset - (tbbNoHostRspJump + sizeof(int));
|
||||
*(int*)(code + tbbZeroRspJump) = tbbFallthroughOffset - (tbbZeroRspJump + sizeof(int));
|
||||
}
|
||||
|
||||
EmitByte(code, ref offset, 0x65); EmitByte(code, ref offset, 0x48); // mov rax, gs:[8]
|
||||
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x04); EmitByte(code, ref offset, 0x25);
|
||||
EmitUInt32(code, ref offset, 8u);
|
||||
@@ -2902,67 +2433,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x28);
|
||||
// Serialize managed VEH entry (recursive spinlock). Concurrent UnmanagedCallersOnly
|
||||
// FailFast was the tLTQ silent mid-TBB pattern (enter without abort breadcrumb).
|
||||
// Lock layout: [0]=owner UniqueThread (nint), [8]=depth (int).
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xB9);
|
||||
*(nint*)(code + offset) = _vehManagedEntryLock;
|
||||
offset += sizeof(nint); // mov r9, lock*
|
||||
EmitByte(code, ref offset, 0x65); EmitByte(code, ref offset, 0x4C);
|
||||
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x14);
|
||||
EmitByte(code, ref offset, 0x25); EmitUInt32(code, ref offset, 0x48u); // mov r10, gs:[0x48]
|
||||
int hostAcquireSpin = offset;
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x01); // mov rax, [r9]
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x39); EmitByte(code, ref offset, 0xD0); // cmp rax, r10
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x84);
|
||||
int hostMineJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x85); EmitByte(code, ref offset, 0xC0); // test rax, rax
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
|
||||
int hostPauseJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0xF0); EmitByte(code, ref offset, 0x4C);
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0xB1); EmitByte(code, ref offset, 0x11); // lock cmpxchg [r9], r10
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
|
||||
int hostRetryJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x08);
|
||||
EmitUInt32(code, ref offset, 1u); // mov dword [r9+8], 1
|
||||
EmitByte(code, ref offset, 0xE9);
|
||||
int hostGotJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
int hostPauseOffset = offset;
|
||||
EmitByte(code, ref offset, 0xF3); EmitByte(code, ref offset, 0x90); // pause
|
||||
EmitByte(code, ref offset, 0xE9);
|
||||
int hostPauseBackJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
int hostMineOffset = offset;
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xFF);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x08); // inc dword [r9+8]
|
||||
int hostGotOffset = offset;
|
||||
*(int*)(code + hostMineJump) = hostMineOffset - (hostMineJump + sizeof(int));
|
||||
*(int*)(code + hostPauseJump) = hostPauseOffset - (hostPauseJump + sizeof(int));
|
||||
*(int*)(code + hostRetryJump) = hostAcquireSpin - (hostRetryJump + sizeof(int));
|
||||
*(int*)(code + hostGotJump) = hostGotOffset - (hostGotJump + sizeof(int));
|
||||
*(int*)(code + hostPauseBackJump) = hostAcquireSpin - (hostPauseBackJump + sizeof(int));
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xE9); // mov rcx, r13
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = managedHandler;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xB9);
|
||||
*(nint*)(code + offset) = _vehManagedEntryLock;
|
||||
offset += sizeof(nint); // mov r9, lock*
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xFF);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x08); // dec dword [r9+8]
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
|
||||
int hostStillJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x01); EmitUInt32(code, ref offset, 0u); // mov qword [r9], 0
|
||||
int hostStillOffset = offset;
|
||||
*(int*)(code + hostStillJump) = hostStillOffset - (hostStillJump + sizeof(int));
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0xE9);
|
||||
int hostRestoreJump = offset;
|
||||
@@ -2988,64 +2463,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xDC); // mov rsp, r11
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xB9);
|
||||
*(nint*)(code + offset) = _vehManagedEntryLock;
|
||||
offset += sizeof(nint); // mov r9, lock*
|
||||
EmitByte(code, ref offset, 0x65); EmitByte(code, ref offset, 0x4C);
|
||||
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x14);
|
||||
EmitByte(code, ref offset, 0x25); EmitUInt32(code, ref offset, 0x48u); // mov r10, gs:[0x48]
|
||||
int guestAcquireSpin = offset;
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x01); // mov rax, [r9]
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x39); EmitByte(code, ref offset, 0xD0); // cmp rax, r10
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x84);
|
||||
int guestMineJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x85); EmitByte(code, ref offset, 0xC0);
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
|
||||
int guestPauseJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0xF0); EmitByte(code, ref offset, 0x4C);
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0xB1); EmitByte(code, ref offset, 0x11);
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
|
||||
int guestRetryJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x08);
|
||||
EmitUInt32(code, ref offset, 1u);
|
||||
EmitByte(code, ref offset, 0xE9);
|
||||
int guestGotJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
int guestPauseOffset = offset;
|
||||
EmitByte(code, ref offset, 0xF3); EmitByte(code, ref offset, 0x90);
|
||||
EmitByte(code, ref offset, 0xE9);
|
||||
int guestPauseBackJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
int guestMineOffset = offset;
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xFF);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x08);
|
||||
int guestGotOffset = offset;
|
||||
*(int*)(code + guestMineJump) = guestMineOffset - (guestMineJump + sizeof(int));
|
||||
*(int*)(code + guestPauseJump) = guestPauseOffset - (guestPauseJump + sizeof(int));
|
||||
*(int*)(code + guestRetryJump) = guestAcquireSpin - (guestRetryJump + sizeof(int));
|
||||
*(int*)(code + guestGotJump) = guestGotOffset - (guestGotJump + sizeof(int));
|
||||
*(int*)(code + guestPauseBackJump) = guestAcquireSpin - (guestPauseBackJump + sizeof(int));
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xE9); // mov rcx, r13
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = managedHandler;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xB9);
|
||||
*(nint*)(code + offset) = _vehManagedEntryLock;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xFF);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x08); // dec dword [r9+8]
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
|
||||
int guestStillJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x01); EmitUInt32(code, ref offset, 0u);
|
||||
int guestStillOffset = offset;
|
||||
*(int*)(code + guestStillJump) = guestStillOffset - (guestStillJump + sizeof(int));
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0xE9);
|
||||
int guestRestoreJump = offset;
|
||||
@@ -3066,18 +2488,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
*(int*)(code + missingHostStackJump) = passThroughOffset - (missingHostStackJump + sizeof(int));
|
||||
*(int*)(code + guestRestoreJump) = restoreOffset - (guestRestoreJump + sizeof(int));
|
||||
|
||||
if (offset > (int)stubSize)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][ERROR] Exception handler trampoline overflow: used={offset} cap={stubSize}");
|
||||
VirtualFree(ptr, 0, 0x8000u);
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] VEH trampoline built: bytes={offset} native_worker_abort=" +
|
||||
$"{(_workerAbortStub != 0 && _hostRspSlotTlsIndex != uint.MaxValue)}");
|
||||
|
||||
uint oldProtect = default;
|
||||
VirtualProtect(ptr, stubSize, 32u, &oldProtect);
|
||||
FlushInstructionCache(GetCurrentProcess(), ptr, (nuint)offset);
|
||||
@@ -3652,7 +3062,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
$"[LOADER][INFO] Scheduled guest thread '{thread.Name}' handle=0x{thread.ThreadHandle:X16} " +
|
||||
$"entry=0x{thread.EntryPoint:X16} arg=0x{thread.Argument:X16} priority={thread.Priority} " +
|
||||
$"host_priority={MapGuestThreadPriority(thread.Priority)} affinity=0x{thread.AffinityMask:X}");
|
||||
LoadProgressDiagnostics.ArmIfNorthAudioThread(thread.Name);
|
||||
Pump(creatorContext, "pthread_create");
|
||||
// Pump is suppressed while another cooperative dispatch is active. The
|
||||
// background dispatcher would eventually observe this thread, but an
|
||||
@@ -4539,10 +3948,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
// unwinding. Unity can begin its next stop-the-world cycle in
|
||||
// that window; treating the new raise as part of the old delivery
|
||||
// strands the collector waiting for an acknowledgement.
|
||||
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
|
||||
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
|
||||
handler,
|
||||
exceptionType,
|
||||
external.ExceptionStackBase));
|
||||
external.ExceptionStackBase);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -4551,10 +3960,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
// managed thread corrupts the worker's control state. Queue the
|
||||
// request and let that exact executor consume it at its next HLE
|
||||
// boundary, where the original guest thread is safely paused.
|
||||
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
|
||||
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
|
||||
handler,
|
||||
exceptionType,
|
||||
external.ExceptionStackBase));
|
||||
external.ExceptionStackBase);
|
||||
if (logGuestExceptions)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
@@ -4599,17 +4008,17 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
}
|
||||
if (target.ExceptionDeliveryActive)
|
||||
{
|
||||
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
|
||||
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
|
||||
handler,
|
||||
exceptionType,
|
||||
exceptionStackBase));
|
||||
exceptionStackBase);
|
||||
return true;
|
||||
}
|
||||
|
||||
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
|
||||
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
|
||||
handler,
|
||||
exceptionType,
|
||||
exceptionStackBase));
|
||||
exceptionStackBase);
|
||||
if (logGuestExceptions)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
@@ -4770,7 +4179,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
RestoreInterruptedGuestThread();
|
||||
if (target.State == GuestThreadRunState.Blocked &&
|
||||
!target.ExecutorActive &&
|
||||
TryRemovePendingGuestExceptionLocked(threadHandle, out var queued))
|
||||
_pendingGuestExceptions.Remove(threadHandle, out var queued))
|
||||
{
|
||||
followUp = queued;
|
||||
}
|
||||
@@ -4856,11 +4265,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
CpuContext currentContext,
|
||||
GuestCpuContinuation interruptedContinuation)
|
||||
{
|
||||
if (Volatile.Read(ref _pendingGuestExceptionCount) == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var threadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
|
||||
if (threadHandle == 0)
|
||||
{
|
||||
@@ -4874,7 +4278,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryRemovePendingGuestExceptionLocked(threadHandle, out pending))
|
||||
if (!_pendingGuestExceptions.Remove(threadHandle, out pending))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -4936,27 +4340,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
}
|
||||
}
|
||||
|
||||
private void QueuePendingGuestExceptionLocked(
|
||||
ulong threadHandle,
|
||||
PendingGuestException pending)
|
||||
{
|
||||
_pendingGuestExceptions[threadHandle] = pending;
|
||||
Volatile.Write(ref _pendingGuestExceptionCount, _pendingGuestExceptions.Count);
|
||||
}
|
||||
|
||||
private bool TryRemovePendingGuestExceptionLocked(
|
||||
ulong threadHandle,
|
||||
out PendingGuestException pending)
|
||||
{
|
||||
if (!_pendingGuestExceptions.Remove(threadHandle, out pending))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _pendingGuestExceptionCount, _pendingGuestExceptions.Count);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryWriteGuestExceptionContext(
|
||||
CpuContext context,
|
||||
ulong address,
|
||||
@@ -5051,7 +4434,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
_guestThreads.Clear();
|
||||
_externalGuestThreads.Clear();
|
||||
_pendingGuestExceptions.Clear();
|
||||
Volatile.Write(ref _pendingGuestExceptionCount, 0);
|
||||
_activeGuestExceptionDeliveries.Clear();
|
||||
}
|
||||
|
||||
@@ -5314,7 +4696,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
var hostCpu = processorCount < 8
|
||||
? guestCpu % processorCount
|
||||
: processorCount >= 16
|
||||
? MapGuestCpuAcrossSmtLanes(guestCpu, processorCount)
|
||||
? guestCpu * 2
|
||||
: guestCpu;
|
||||
if (hostCpu < processorCount)
|
||||
{
|
||||
@@ -5325,45 +4707,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
return hostAffinityMask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Places guest CPUs on distinct physical cores first, then wraps onto the
|
||||
/// SMT siblings. Doubling the index alone only works while the title stays
|
||||
/// inside the first half of the guest CPU set: beyond that every mapped lane
|
||||
/// lands past the host's processor count and gets dropped, which silently
|
||||
/// leaves those threads unpinned. Demon's Souls asks for CPUs 0-12 and keeps
|
||||
/// its renderer on 9 and 11, so dropping the overflow un-pinned both the
|
||||
/// renderer and a third of its job pool onto every core at once.
|
||||
/// </summary>
|
||||
private static int MapGuestCpuAcrossSmtLanes(int guestCpu, int processorCount)
|
||||
{
|
||||
// Reserve the top lanes for the emulator itself. A title sized for a
|
||||
// console's dedicated cores will happily keep a worker per guest CPU
|
||||
// spinning on an empty queue — Demon's Souls' job pool runs ~90% busy
|
||||
// doing nothing — and spreading those across every host lane leaves the
|
||||
// GPU translation and present threads fighting them for a slice. Packing
|
||||
// near-idle spinners tighter costs them almost nothing and buys back
|
||||
// whole cores for the work that actually produces frames.
|
||||
var usableLanes = Math.Max(processorCount - EmulatorReservedLanes, 2);
|
||||
var physicalCores = usableLanes / 2;
|
||||
var lane = guestCpu % usableLanes;
|
||||
return lane < physicalCores
|
||||
? lane * 2
|
||||
: ((lane - physicalCores) * 2) + 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Host lanes kept away from guest threads. Measured on a 16-lane host with
|
||||
/// Demon's Souls: reserving 0/4/6/8 lanes gave 6.08/6.78/7.20/5.62 fps, so
|
||||
/// the useful range is a bit over a third of the machine — too few and the
|
||||
/// emulator is crowded out, too many and the guest cannot make progress.
|
||||
/// </summary>
|
||||
private static readonly int EmulatorReservedLanes =
|
||||
int.TryParse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_RESERVED_HOST_LANES"),
|
||||
out var reserved) && reserved >= 0
|
||||
? reserved
|
||||
: Math.Max(2, Environment.ProcessorCount * 3 / 8);
|
||||
|
||||
public bool TrySetGuestThreadPriority(ulong guestThreadHandle, int guestPriority)
|
||||
{
|
||||
lock (_guestThreadGate)
|
||||
@@ -5772,27 +5115,16 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
return GuestNativeCallExitReason.Exception;
|
||||
}
|
||||
FlushInstructionCache(GetCurrentProcess(), ptr, stubSize);
|
||||
if (!TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot))
|
||||
{
|
||||
reason = "failed to bind host-RSP storage for guest thread stub";
|
||||
return GuestNativeCallExitReason.Exception;
|
||||
}
|
||||
ActiveGuestThreadYieldRequested = false;
|
||||
ActiveGuestThreadYieldReason = null;
|
||||
try
|
||||
{
|
||||
// TBB execute-AV recover needs native-worker TLS (eligible/done).
|
||||
// Other guests stay on CallNativeEntry — full native-worker migration
|
||||
// increased splash hangs / UnmanagedCallersOnly (tLTN/tLTO).
|
||||
int nativeReturn;
|
||||
if (name == "tbb_thead")
|
||||
{
|
||||
nativeReturn = RunGuestEntryStub(ptr, hostRspSlot, requireNativeWorker: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot))
|
||||
{
|
||||
reason = "failed to bind host-RSP storage for guest thread stub";
|
||||
return GuestNativeCallExitReason.Exception;
|
||||
}
|
||||
nativeReturn = CallNativeEntry(ptr);
|
||||
}
|
||||
var nativeReturn = CallNativeEntry(ptr);
|
||||
if (ActiveGuestThreadYieldRequested)
|
||||
{
|
||||
reason = ActiveGuestThreadYieldReason ?? "guest thread blocked";
|
||||
@@ -5938,24 +5270,16 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
return GuestNativeCallExitReason.Exception;
|
||||
}
|
||||
FlushInstructionCache(GetCurrentProcess(), ptr, stubSize);
|
||||
if (!TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot))
|
||||
{
|
||||
reason = "failed to bind host-RSP storage for guest continuation stub";
|
||||
return GuestNativeCallExitReason.Exception;
|
||||
}
|
||||
ActiveGuestThreadYieldRequested = false;
|
||||
ActiveGuestThreadYieldReason = null;
|
||||
try
|
||||
{
|
||||
int nativeReturn;
|
||||
if (name == "tbb_thead")
|
||||
{
|
||||
nativeReturn = RunGuestEntryStub(ptr, hostRspSlot, requireNativeWorker: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot))
|
||||
{
|
||||
reason = "failed to bind host-RSP storage for guest continuation stub";
|
||||
return GuestNativeCallExitReason.Exception;
|
||||
}
|
||||
nativeReturn = CallNativeEntry(ptr);
|
||||
}
|
||||
var nativeReturn = CallNativeEntry(ptr);
|
||||
if (ActiveGuestThreadYieldRequested)
|
||||
{
|
||||
reason = ActiveGuestThreadYieldReason ?? "guest thread blocked";
|
||||
@@ -7088,16 +6412,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
VirtualFree((void*)_hostRspSlotStorage, 0u, 32768u);
|
||||
_hostRspSlotStorage = 0;
|
||||
}
|
||||
if (_vehManagedEntryLock != 0)
|
||||
{
|
||||
VirtualFree((void*)_vehManagedEntryLock, 0u, 32768u);
|
||||
_vehManagedEntryLock = 0;
|
||||
}
|
||||
if (_workerAbortStack != 0)
|
||||
{
|
||||
VirtualFree((void*)_workerAbortStack, 0u, 32768u);
|
||||
_workerAbortStack = 0;
|
||||
}
|
||||
if (_guestTlsBaseTlsIndex != uint.MaxValue)
|
||||
{
|
||||
TlsFree(_guestTlsBaseTlsIndex);
|
||||
@@ -7108,11 +6422,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
TlsFree(_hostRspSlotTlsIndex);
|
||||
_hostRspSlotTlsIndex = uint.MaxValue;
|
||||
}
|
||||
if (_workerDoneEventTlsIndex != uint.MaxValue)
|
||||
{
|
||||
TlsFree(_workerDoneEventTlsIndex);
|
||||
_workerDoneEventTlsIndex = uint.MaxValue;
|
||||
}
|
||||
if (_unresolvedReturnStub != 0)
|
||||
{
|
||||
VirtualFree((void*)_unresolvedReturnStub, 0u, 32768u);
|
||||
@@ -7123,11 +6432,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
VirtualFree((void*)_guestReturnStub, 0u, 32768u);
|
||||
_guestReturnStub = 0;
|
||||
}
|
||||
if (_workerAbortStub != 0)
|
||||
{
|
||||
VirtualFree((void*)_workerAbortStub, 0u, 32768u);
|
||||
_workerAbortStub = 0;
|
||||
}
|
||||
if (_guestContextTransferStub != 0)
|
||||
{
|
||||
VirtualFree((void*)_guestContextTransferStub, 0u, 32768u);
|
||||
|
||||
@@ -24,7 +24,7 @@ internal sealed unsafe partial class WindowsFaultHandling : IHostFaultHandling
|
||||
|
||||
public nint CreateHandlerThunk(nint managedCallback, uint hostRspSwitchTlsSlot, nint tlsGetValueAddress)
|
||||
{
|
||||
const uint stubSize = 1024u;
|
||||
const uint stubSize = 256u;
|
||||
void* ptr = (void*)_memory.Allocate(0, stubSize, HostPageProtection.ReadWriteExecute);
|
||||
if (ptr == null)
|
||||
{
|
||||
@@ -43,15 +43,11 @@ internal sealed unsafe partial class WindowsFaultHandling : IHostFaultHandling
|
||||
// managed code; the CLR's own VEH handles its exceptions. MSVC C++ exceptions
|
||||
// (Vulkan drivers, host CRT) are excluded too: the managed handler only ever
|
||||
// returned CONTINUE_SEARCH for them.
|
||||
//
|
||||
// FastFail (0xC0000409) is logged from this native path only: managed VEH never
|
||||
// sees it (tLT18–21 silent exits after TBB AV recovery).
|
||||
ReadOnlySpan<uint> nonManagedExceptionCodes =
|
||||
[WindowsFaultCodes.ClrManagedException, 0xE06D7363u, WindowsFaultCodes.FastFail, WindowsFaultCodes.StackOverflow];
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x01); // mov rax, [rcx] (ExceptionRecord*)
|
||||
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x00); // mov eax, [rax] (ExceptionCode)
|
||||
var passJumpOffsets = stackalloc int[nonManagedExceptionCodes.Length];
|
||||
int fastFailJumpSlot = -1;
|
||||
for (int i = 0; i < nonManagedExceptionCodes.Length; i++)
|
||||
{
|
||||
EmitByte(code, ref offset, 0x3D); // cmp eax, imm32
|
||||
@@ -59,162 +55,13 @@ internal sealed unsafe partial class WindowsFaultHandling : IHostFaultHandling
|
||||
EmitByte(code, ref offset, 0x74); // je pass
|
||||
passJumpOffsets[i] = offset;
|
||||
EmitByte(code, ref offset, 0x00);
|
||||
if (nonManagedExceptionCodes[i] == WindowsFaultCodes.FastFail)
|
||||
{
|
||||
fastFailJumpSlot = i;
|
||||
}
|
||||
}
|
||||
EmitByte(code, ref offset, 0xE9); // jmp mainBody rel32 (FastFail breadcrumb sits between)
|
||||
var mainBodyJumpSlot = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
|
||||
EmitByte(code, ref offset, 0xEB); EmitByte(code, ref offset, 0x03); // jmp over pass block
|
||||
int passOffset = offset;
|
||||
EmitByte(code, ref offset, 0x31); EmitByte(code, ref offset, 0xC0); // pass: xor eax, eax (EXCEPTION_CONTINUE_SEARCH)
|
||||
EmitByte(code, ref offset, 0xC3); // ret
|
||||
|
||||
// FastFail: native stderr breadcrumb with Context.Rip (no managed entry), then CONTINUE_SEARCH.
|
||||
// Keep in sync with DirectExecutionBackend.CreateExceptionHandlerTrampoline.
|
||||
int fastFailPassOffset = offset;
|
||||
var fastFailLogInstalled = false;
|
||||
if (fastFailJumpSlot >= 0 &&
|
||||
NativeLibrary.TryLoad("kernel32.dll", out var kernel32) &&
|
||||
NativeLibrary.TryGetExport(kernel32, "GetStdHandle", out var getStdHandle) &&
|
||||
NativeLibrary.TryGetExport(kernel32, "WriteFile", out var writeFile))
|
||||
{
|
||||
ReadOnlySpan<byte> msg =
|
||||
"[LOADER][FATAL] VEH_PASS FastFail 0xC0000409 (native; no managed VEH) rip=0x"u8;
|
||||
ReadOnlySpan<byte> hexDigits = "0123456789ABCDEF"u8;
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x41);
|
||||
EmitByte(code, ref offset, 0x08); // mov rax, [rcx+8]
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x90);
|
||||
EmitUInt32(code, ref offset, 0xF8u); // mov r10, [rax+0xF8]
|
||||
EmitByte(code, ref offset, 0x50); // push rax
|
||||
EmitByte(code, ref offset, 0x51); // push rcx
|
||||
EmitByte(code, ref offset, 0x52); // push rdx
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x50); // push r8
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x51); // push r9
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x52); // push r10
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x40); // sub rsp, 0x40
|
||||
EmitByte(code, ref offset, 0xB9); EmitUInt32(code, ref offset, unchecked((uint)-12));
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = getStdHandle;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x28); // mov [rsp+0x28], rax
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xC1);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
var msgAbsSlot = offset;
|
||||
*(nint*)(code + offset) = 0;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xC2);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xB8);
|
||||
EmitUInt32(code, ref offset, (uint)msg.Length);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20); EmitUInt32(code, ref offset, 0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x38); EmitUInt32(code, ref offset, 0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = writeFile;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8B);
|
||||
EmitByte(code, ref offset, 0x54); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x40); // mov r10, [rsp+0x40]
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xB8);
|
||||
var hexDigitsAbsSlot = offset;
|
||||
*(nint*)(code + offset) = 0;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
|
||||
EmitByte(code, ref offset, 0x5C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x30); // lea r11, [rsp+0x30]
|
||||
EmitByte(code, ref offset, 0xB9); EmitUInt32(code, ref offset, 16u);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xD0);
|
||||
int hexLoopOffset = offset;
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC1); EmitByte(code, ref offset, 0xC0);
|
||||
EmitByte(code, ref offset, 0x04);
|
||||
EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xC2);
|
||||
EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xE2); EmitByte(code, ref offset, 0x0F);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0xB6);
|
||||
EmitByte(code, ref offset, 0x14); EmitByte(code, ref offset, 0x10);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x88); EmitByte(code, ref offset, 0x13);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xC3);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xC9);
|
||||
EmitByte(code, ref offset, 0x75);
|
||||
EmitByte(code, ref offset, unchecked((byte)(hexLoopOffset - (offset + 1)))); // jnz rel8
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xC6); EmitByte(code, ref offset, 0x03);
|
||||
EmitByte(code, ref offset, 0x0A);
|
||||
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8D);
|
||||
EmitByte(code, ref offset, 0x54); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x30);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xB8);
|
||||
EmitUInt32(code, ref offset, 17u);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20); EmitUInt32(code, ref offset, 0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x38); EmitUInt32(code, ref offset, 0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = writeFile;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x40);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5A);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x59);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x58);
|
||||
EmitByte(code, ref offset, 0x5A);
|
||||
EmitByte(code, ref offset, 0x59);
|
||||
EmitByte(code, ref offset, 0x58);
|
||||
EmitByte(code, ref offset, 0x31); EmitByte(code, ref offset, 0xC0);
|
||||
EmitByte(code, ref offset, 0xC3);
|
||||
|
||||
var msgOffset = offset;
|
||||
for (int i = 0; i < msg.Length; i++)
|
||||
{
|
||||
EmitByte(code, ref offset, msg[i]);
|
||||
}
|
||||
|
||||
var hexDigitsOffset = offset;
|
||||
for (int i = 0; i < hexDigits.Length; i++)
|
||||
{
|
||||
EmitByte(code, ref offset, hexDigits[i]);
|
||||
}
|
||||
|
||||
*(nint*)(code + msgAbsSlot) = (nint)ptr + msgOffset;
|
||||
*(nint*)(code + hexDigitsAbsSlot) = (nint)ptr + hexDigitsOffset;
|
||||
code[passJumpOffsets[fastFailJumpSlot]] =
|
||||
checked((byte)(fastFailPassOffset - (passJumpOffsets[fastFailJumpSlot] + 1)));
|
||||
fastFailLogInstalled = true;
|
||||
}
|
||||
|
||||
int mainBodyOffset = offset;
|
||||
*(int*)(code + mainBodyJumpSlot) = mainBodyOffset - (mainBodyJumpSlot + sizeof(int));
|
||||
|
||||
for (int i = 0; i < nonManagedExceptionCodes.Length; i++)
|
||||
{
|
||||
if (i == fastFailJumpSlot && fastFailLogInstalled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
code[passJumpOffsets[i]] = checked((byte)(passOffset - (passJumpOffsets[i] + 1)));
|
||||
}
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x54); // push r12
|
||||
|
||||
@@ -40,9 +40,6 @@ public sealed class TrackedCpuMemory : ICpuMemory, ITrackedCpuMemory, IGuestMemo
|
||||
return result;
|
||||
}
|
||||
|
||||
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length) =>
|
||||
_inner.TryCopy(destinationAddress, sourceAddress, length);
|
||||
|
||||
public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address)
|
||||
{
|
||||
if (_inner is IGuestMemoryAllocator allocator)
|
||||
|
||||
@@ -199,32 +199,9 @@ public sealed class SelfLoader : ISelfLoader
|
||||
{
|
||||
if (!physicalVm.TryAllocateAtExact(imageBase, totalImageSize, executable: true, out var allocatedBase))
|
||||
{
|
||||
// Exact allocation failed — the host may have already claimed
|
||||
// part of this range (ASLR, Rosetta 2, or another process).
|
||||
// Try backing the fixed range page by page to claim whatever
|
||||
// free gaps exist. If the whole range is occupied the backfill
|
||||
// returns false and we surface the original failure reason.
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER] Exact allocation at main image base 0x{imageBase:X16} " +
|
||||
$"(size=0x{totalImageSize:X}) failed; attempting fixed-range backfill.");
|
||||
if (!physicalVm.TryBackFixedRange(imageBase, totalImageSize, executable: true))
|
||||
{
|
||||
// TryBackFixedRange may have partially backed pages before
|
||||
// failing. The earlier Clear() already reset all regions, so
|
||||
// this second Clear() is idempotent for everything except the
|
||||
// partial backfill — it frees only those orphaned pages.
|
||||
physicalVm.Clear();
|
||||
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
|
||||
throw new InvalidOperationException(
|
||||
$"Could not allocate main image at required base 0x{imageBase:X16} " +
|
||||
$"(size=0x{totalImageSize:X}): {reason}. " +
|
||||
"Try closing other applications, rebooting, or " +
|
||||
(OperatingSystem.IsWindows()
|
||||
? "setting SHARPEMU_DISABLE_MITIGATION_RELAUNCH=1."
|
||||
: "ensuring no other process maps into this address range."));
|
||||
}
|
||||
|
||||
allocatedBase = imageBase;
|
||||
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
|
||||
throw new InvalidOperationException(
|
||||
$"Could not allocate main image at required base 0x{imageBase:X16} (size=0x{totalImageSize:X}): {reason}.");
|
||||
}
|
||||
|
||||
imageBase = allocatedBase;
|
||||
@@ -737,9 +714,8 @@ public sealed class SelfLoader : ISelfLoader
|
||||
|
||||
importedRelocations = BuildImportedRelocations(descriptors);
|
||||
|
||||
var stubEligibleNids = CollectStubEligibleNids(descriptors, moduleManager);
|
||||
var stubImportNids = orderedImportNids
|
||||
.Where(stubEligibleNids.Contains)
|
||||
.Where(nid => ShouldCreateImportStub(nid, descriptors, moduleManager))
|
||||
.ToArray();
|
||||
var stubsByAddress = CreateImportStubMapping(virtualMemory, stubImportNids);
|
||||
Console.WriteLine($"[LOADER] Created {stubsByAddress.Count} import stubs");
|
||||
@@ -1184,35 +1160,6 @@ public sealed class SelfLoader : ISelfLoader
|
||||
isWeak);
|
||||
}
|
||||
|
||||
// Collects every NID that needs a trap import stub in a single pass over the
|
||||
// descriptors. This mirrors ShouldCreateImportStub applied per NID, but avoids
|
||||
// the O(nids * descriptors) rescan that filtering each unique NID against the
|
||||
// full descriptor list would incur on large modules. A NID qualifies as soon as
|
||||
// one of its descriptors is non-weak, or is weak but resolvable via the module
|
||||
// manager.
|
||||
private static HashSet<string> CollectStubEligibleNids(
|
||||
IReadOnlyList<RelocationDescriptor> descriptors,
|
||||
IModuleManager? moduleManager)
|
||||
{
|
||||
var eligible = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < descriptors.Count; i++)
|
||||
{
|
||||
var descriptor = descriptors[i];
|
||||
var nid = descriptor.ImportNid;
|
||||
if (nid is null || eligible.Contains(nid))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!descriptor.IsWeak || moduleManager?.TryGetExport(nid, out _) == true)
|
||||
{
|
||||
eligible.Add(nid);
|
||||
}
|
||||
}
|
||||
|
||||
return eligible;
|
||||
}
|
||||
|
||||
private static bool ShouldCreateImportStub(
|
||||
string nid,
|
||||
IReadOnlyList<RelocationDescriptor> descriptors,
|
||||
@@ -2484,19 +2431,6 @@ public sealed class SelfLoader : ISelfLoader
|
||||
Debug.Assert(
|
||||
!ShouldCreateImportStub("weak", [weak], moduleManager: null),
|
||||
"An unresolved weak symbol incorrectly received a trap import stub.");
|
||||
|
||||
var strong = new RelocationDescriptor(
|
||||
TargetAddress: 0x3000,
|
||||
Addend: 0,
|
||||
ImportNid: "strong",
|
||||
SymbolValue: 0,
|
||||
RelocationValueKind.Pointer,
|
||||
IsDataImport: false);
|
||||
var mixed = new List<RelocationDescriptor> { weak, strong };
|
||||
var eligible = CollectStubEligibleNids(mixed, moduleManager: null);
|
||||
Debug.Assert(
|
||||
eligible.Contains("strong") && !eligible.Contains("weak"),
|
||||
"CollectStubEligibleNids disagreed with the per-NID stub eligibility rule.");
|
||||
}
|
||||
|
||||
private static ulong AlignUp(ulong value, ulong alignment)
|
||||
|
||||
@@ -20,11 +20,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
private readonly Dictionary<(ulong DesiredAddress, ulong Alignment, bool Executable), ulong> _allocationSearchHints = new();
|
||||
private readonly Dictionary<ulong, ProgramHeaderFlags> _pageProtections = new();
|
||||
private bool _disposed;
|
||||
|
||||
[ThreadStatic]
|
||||
private static CommittedRangeCache? _committedRangeCache;
|
||||
|
||||
private long _mappingGeneration;
|
||||
private const ulong PageSize = 0x1000;
|
||||
private const ulong GuestAllocationArenaAddress = 0x00006000_0000_0000;
|
||||
private const ulong GuestAllocationArenaSize = 0x0100_0000;
|
||||
@@ -33,77 +28,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
private const ulong FullCommitRegionLimit = 4UL << 30;
|
||||
private const ulong DefaultLazyReservePrimeBytes = 0x0400_0000UL; // 64 MiB
|
||||
private const ulong LazyReservePrimeChunkBytes = 0x0200_0000UL; // 32 MiB
|
||||
private const int CommittedRangeCacheCapacity = 4;
|
||||
|
||||
private sealed class CommittedRangeCache
|
||||
{
|
||||
private readonly CommittedRange[] _ranges = new CommittedRange[CommittedRangeCacheCapacity];
|
||||
private PhysicalVirtualMemory? _owner;
|
||||
private long _generation;
|
||||
private int _count;
|
||||
private int _nextReplacement;
|
||||
|
||||
public bool Contains(
|
||||
PhysicalVirtualMemory owner,
|
||||
long generation,
|
||||
ulong start,
|
||||
ulong end)
|
||||
{
|
||||
if (!ReferenceEquals(_owner, owner) || _generation != generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var index = 0; index < _count; index++)
|
||||
{
|
||||
var range = _ranges[index];
|
||||
if (start >= range.Start && end <= range.End)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Add(
|
||||
PhysicalVirtualMemory owner,
|
||||
long generation,
|
||||
ulong start,
|
||||
ulong end)
|
||||
{
|
||||
if (!ReferenceEquals(_owner, owner) || _generation != generation)
|
||||
{
|
||||
_owner = owner;
|
||||
_generation = generation;
|
||||
_count = 0;
|
||||
_nextReplacement = 0;
|
||||
}
|
||||
|
||||
for (var index = 0; index < _count; index++)
|
||||
{
|
||||
var range = _ranges[index];
|
||||
if (start <= range.End && end >= range.Start)
|
||||
{
|
||||
_ranges[index] = new CommittedRange(
|
||||
Math.Min(start, range.Start),
|
||||
Math.Max(end, range.End));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_count < _ranges.Length)
|
||||
{
|
||||
_ranges[_count++] = new CommittedRange(start, end);
|
||||
return;
|
||||
}
|
||||
|
||||
_ranges[_nextReplacement] = new CommittedRange(start, end);
|
||||
_nextReplacement = (_nextReplacement + 1) % _ranges.Length;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct CommittedRange(ulong Start, ulong End);
|
||||
|
||||
// Raw Windows PAGE_* values retained for the internal region/protection
|
||||
// bookkeeping: regions and saved old-protection values always carry the raw
|
||||
@@ -238,22 +162,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
var alignedSize = (size + 0xFFF) & ~0xFFFUL;
|
||||
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
||||
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
|
||||
var allowLazyReserve = !executable &&
|
||||
alignedSize >= LargeDataReserveThreshold &&
|
||||
alignedSize > FullCommitRegionLimit;
|
||||
|
||||
// Commit first so titles that walk guest memory via raw host pointers
|
||||
// (GTA post-RenderThread workers) keep fully backed pages. Fall back to
|
||||
// reserve-only + lazy commit only when a huge non-exec commit fails —
|
||||
// that is the Poppy / large-reservation path #608 was aiming for.
|
||||
var reservedOnly = false;
|
||||
var result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
|
||||
if (result == 0 && allowLazyReserve)
|
||||
{
|
||||
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
|
||||
reservedOnly = result != 0;
|
||||
}
|
||||
|
||||
if (result == 0)
|
||||
{
|
||||
return false;
|
||||
@@ -267,8 +176,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return false;
|
||||
}
|
||||
|
||||
var lazyPrimeState = reservedOnly ? PrimeLazyReserveRegion(actualAddress, alignedSize) : "n/a";
|
||||
|
||||
_gate.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
@@ -277,7 +184,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
VirtualAddress = actualAddress,
|
||||
Size = alignedSize,
|
||||
IsExecutable = executable,
|
||||
IsReservedOnly = reservedOnly,
|
||||
IsReservedOnly = false,
|
||||
Protection = protection
|
||||
});
|
||||
}
|
||||
@@ -286,12 +193,8 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
_gate.ExitWriteLock();
|
||||
}
|
||||
|
||||
var allocationKind = reservedOnly
|
||||
? "reserved data memory (lazy commit)"
|
||||
: (executable ? "executable memory" : "data memory");
|
||||
TraceVmem(
|
||||
$"Allocated exact {allocationKind}: 0x{actualAddress:X16} - 0x{actualAddress + alignedSize:X16} " +
|
||||
$"({alignedSize} bytes) lazy_prime={lazyPrimeState}");
|
||||
var allocationKind = executable ? "executable memory" : "data memory";
|
||||
TraceVmem($"Allocated exact {allocationKind}: 0x{actualAddress:X16} - 0x{actualAddress + alignedSize:X16} ({alignedSize} bytes)");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -322,44 +225,55 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
||||
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
|
||||
var allowLazyReserve = !executable &&
|
||||
var reservedOnly = false;
|
||||
var preferReserveOnly = !executable &&
|
||||
alignedSize >= LargeDataReserveThreshold &&
|
||||
alignedSize > FullCommitRegionLimit;
|
||||
var reservedOnly = false;
|
||||
|
||||
// Prefer a full commit. Only fall back to reserve-only when a large
|
||||
// non-executable commit cannot be satisfied (see TryAllocateAtExact).
|
||||
ulong result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
|
||||
ulong result = 0;
|
||||
if (preferReserveOnly)
|
||||
{
|
||||
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
|
||||
if (result == 0 && allowAlternative)
|
||||
{
|
||||
result = _hostMemory.Reserve(0, alignedSize, HostPageProtection.ReadWrite);
|
||||
}
|
||||
|
||||
if (result != 0)
|
||||
{
|
||||
reservedOnly = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (result == 0)
|
||||
{
|
||||
result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
|
||||
}
|
||||
|
||||
if (result == 0)
|
||||
{
|
||||
if (!allowAlternative)
|
||||
{
|
||||
if (allowLazyReserve)
|
||||
{
|
||||
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
|
||||
reservedOnly = result != 0;
|
||||
}
|
||||
|
||||
if (result == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to allocate exact mapping at 0x{desiredAddress:X16} ({alignedSize} bytes)");
|
||||
}
|
||||
throw new InvalidOperationException($"Failed to allocate exact mapping at 0x{desiredAddress:X16} ({alignedSize} bytes)");
|
||||
}
|
||||
else
|
||||
{
|
||||
TraceVmem($"Could not allocate at 0x{desiredAddress:X16}, trying any address...");
|
||||
result = _hostMemory.Allocate(0, alignedSize, hostProtection);
|
||||
|
||||
if (result == 0 && allowLazyReserve)
|
||||
TraceVmem($"Could not allocate at 0x{desiredAddress:X16}, trying any address...");
|
||||
result = _hostMemory.Allocate(0, alignedSize, hostProtection);
|
||||
|
||||
if (result == 0)
|
||||
{
|
||||
if (!executable)
|
||||
{
|
||||
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
|
||||
if (result == 0)
|
||||
if (result == 0 && allowAlternative)
|
||||
{
|
||||
result = _hostMemory.Reserve(0, alignedSize, HostPageProtection.ReadWrite);
|
||||
}
|
||||
|
||||
reservedOnly = result != 0;
|
||||
if (result != 0)
|
||||
{
|
||||
reservedOnly = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (result == 0)
|
||||
@@ -370,7 +284,45 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
|
||||
var actualAddress = result;
|
||||
var lazyPrimeState = reservedOnly ? PrimeLazyReserveRegion(actualAddress, alignedSize) : "n/a";
|
||||
|
||||
var lazyPrimeState = "n/a";
|
||||
if (reservedOnly)
|
||||
{
|
||||
var primeBytes = Math.Min(alignedSize, LazyReservePrimeBytes);
|
||||
if (primeBytes != 0)
|
||||
{
|
||||
ulong committedBytes = 0;
|
||||
while (committedBytes < primeBytes)
|
||||
{
|
||||
var remaining = primeBytes - committedBytes;
|
||||
var chunkBytes = Math.Min(remaining, LazyReservePrimeChunkBytes);
|
||||
var commitAddress = actualAddress + committedBytes;
|
||||
if (!_hostMemory.Commit(commitAddress, chunkBytes, HostPageProtection.ReadWrite))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
committedBytes += chunkBytes;
|
||||
}
|
||||
|
||||
if (committedBytes != 0)
|
||||
{
|
||||
lazyPrimeState = committedBytes == primeBytes
|
||||
? $"ok:{committedBytes:X}"
|
||||
: $"partial:{committedBytes:X}/{primeBytes:X}";
|
||||
TraceVmem($"Primed lazy region: 0x{actualAddress:X16} - 0x{actualAddress + committedBytes:X16} ({committedBytes} bytes)");
|
||||
}
|
||||
else
|
||||
{
|
||||
lazyPrimeState = $"fail:{primeBytes:X}";
|
||||
TraceVmem($"Failed to prime lazy region at 0x{actualAddress:X16} ({primeBytes} bytes), continuing with on-demand commit");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
lazyPrimeState = "skip:0";
|
||||
}
|
||||
}
|
||||
|
||||
_gate.EnterWriteLock();
|
||||
try
|
||||
@@ -397,150 +349,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return actualAddress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits the leading slice of a reserve-only region so early guest touches
|
||||
/// succeed before on-demand <see cref="EnsureRangeCommitted"/> runs.
|
||||
/// </summary>
|
||||
private string PrimeLazyReserveRegion(ulong actualAddress, ulong alignedSize)
|
||||
{
|
||||
var primeBytes = Math.Min(alignedSize, LazyReservePrimeBytes);
|
||||
if (primeBytes == 0)
|
||||
{
|
||||
return "skip:0";
|
||||
}
|
||||
|
||||
ulong committedBytes = 0;
|
||||
while (committedBytes < primeBytes)
|
||||
{
|
||||
var remaining = primeBytes - committedBytes;
|
||||
var chunkBytes = Math.Min(remaining, LazyReservePrimeChunkBytes);
|
||||
var commitAddress = actualAddress + committedBytes;
|
||||
if (!_hostMemory.Commit(commitAddress, chunkBytes, HostPageProtection.ReadWrite))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
committedBytes += chunkBytes;
|
||||
}
|
||||
|
||||
if (committedBytes != 0)
|
||||
{
|
||||
var state = committedBytes == primeBytes
|
||||
? $"ok:{committedBytes:X}"
|
||||
: $"partial:{committedBytes:X}/{primeBytes:X}";
|
||||
TraceVmem($"Primed lazy region: 0x{actualAddress:X16} - 0x{actualAddress + committedBytes:X16} ({committedBytes} bytes)");
|
||||
return state;
|
||||
}
|
||||
|
||||
TraceVmem($"Failed to prime lazy region at 0x{actualAddress:X16} ({primeBytes} bytes), continuing with on-demand commit");
|
||||
return $"fail:{primeBytes:X}";
|
||||
}
|
||||
|
||||
public bool TryBackFixedRange(ulong address, ulong size, bool executable)
|
||||
{
|
||||
if (size == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var start = AlignDown(address, PageSize);
|
||||
var end = AlignUp(address + size, PageSize);
|
||||
if (end <= start)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
|
||||
|
||||
// Walk the range page-run by page-run. VirtualQuery reports the largest run
|
||||
// of same-state pages from the queried address, so a single query advances
|
||||
// us over whole free or occupied stretches. Only free stretches get backed;
|
||||
// stretches already reserved or committed by another allocation are left as
|
||||
// they are, which is exactly what a fixed mapping does on hardware.
|
||||
//
|
||||
// Because backing may span several disjoint free runs, allocations are
|
||||
// staged: host pages are reserved/committed first, and the corresponding
|
||||
// MemoryRegions are inserted only once every gap in the range has been
|
||||
// backed. If any gap fails to back, every earlier host allocation is freed
|
||||
// and no region is inserted, so the address space is left untouched.
|
||||
var stagedAllocations = new List<(ulong Address, ulong Size)>();
|
||||
|
||||
var cursor = start;
|
||||
while (cursor < end)
|
||||
{
|
||||
if (!_hostMemory.Query(cursor, out var info))
|
||||
{
|
||||
goto Rollback;
|
||||
}
|
||||
|
||||
var queriedEnd = info.RegionSize > ulong.MaxValue - info.BaseAddress
|
||||
? ulong.MaxValue
|
||||
: info.BaseAddress + info.RegionSize;
|
||||
var runEnd = Math.Min(end, queriedEnd);
|
||||
if (runEnd <= cursor)
|
||||
{
|
||||
goto Rollback;
|
||||
}
|
||||
|
||||
if (info.State == HostRegionState.Free)
|
||||
{
|
||||
var runSize = runEnd - cursor;
|
||||
var allocated = _hostMemory.Allocate(cursor, runSize, hostProtection);
|
||||
if (allocated != cursor)
|
||||
{
|
||||
if (allocated != 0)
|
||||
{
|
||||
_hostMemory.Free(allocated);
|
||||
}
|
||||
|
||||
goto Rollback;
|
||||
}
|
||||
|
||||
stagedAllocations.Add((cursor, runSize));
|
||||
TraceVmem($"Backed fixed range gap: 0x{cursor:X16} - 0x{runEnd:X16} ({runSize} bytes)");
|
||||
}
|
||||
|
||||
cursor = runEnd;
|
||||
}
|
||||
|
||||
if (stagedAllocations.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// All gaps backed successfully — insert regions in one batch.
|
||||
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
||||
_gate.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
foreach (var (gapAddress, gapSize) in stagedAllocations)
|
||||
{
|
||||
InsertRegionSorted(new MemoryRegion
|
||||
{
|
||||
VirtualAddress = gapAddress,
|
||||
Size = gapSize,
|
||||
IsExecutable = executable,
|
||||
IsReservedOnly = false,
|
||||
Protection = protection
|
||||
});
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.ExitWriteLock();
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
Rollback:
|
||||
foreach (var (gapAddress, _) in stagedAllocations)
|
||||
{
|
||||
_hostMemory.Free(gapAddress);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryAllocateAtOrAbove(
|
||||
ulong desiredAddress,
|
||||
ulong size,
|
||||
@@ -632,7 +440,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
_gate.ExitWriteLock();
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _mappingGeneration);
|
||||
_hostMemory.Free(address);
|
||||
}
|
||||
|
||||
@@ -804,7 +611,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
{
|
||||
_allocationSearchHints.Clear();
|
||||
}
|
||||
Interlocked.Increment(ref _mappingGeneration);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1113,7 +919,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1139,68 +944,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
}
|
||||
|
||||
private static void NotifyGuestWriteWatch(ulong virtualAddress, ReadOnlySpan<byte> source)
|
||||
{
|
||||
if (GuestWriteWatch.Armed)
|
||||
{
|
||||
GuestWriteWatch.Check(virtualAddress, source);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length)
|
||||
{
|
||||
if (length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (length > int.MaxValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match TryWrite's managed-write notification before touching an
|
||||
// identity-mapped guest page protected by the image tracker.
|
||||
GuestImageWriteTracker.NotifyManagedWrite(destinationAddress, length);
|
||||
|
||||
_gate.EnterReadLock();
|
||||
try
|
||||
{
|
||||
var sourceRegion = FindRegion(sourceAddress, length);
|
||||
var destinationRegion = FindRegion(destinationAddress, length);
|
||||
if (sourceRegion is null || destinationRegion is null ||
|
||||
!TryResolveRegionOffset(sourceAddress, length, sourceRegion, out var sourceOffset) ||
|
||||
!TryResolveRegionOffset(destinationAddress, length, destinationRegion, out var destinationOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sourcePointer = sourceRegion.VirtualAddress + sourceOffset;
|
||||
var destinationPointer = destinationRegion.VirtualAddress + destinationOffset;
|
||||
if ((sourceRegion.IsReservedOnly &&
|
||||
!EnsureRangeCommitted(sourcePointer, length, sourceRegion)) ||
|
||||
(destinationRegion.IsReservedOnly &&
|
||||
!EnsureRangeCommitted(destinationPointer, length, destinationRegion)) ||
|
||||
!CanReadWithoutProtectionChange(sourcePointer, length, sourceRegion) ||
|
||||
!CanWriteWithoutProtectionChange(destinationPointer, length, destinationRegion))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Span.CopyTo has memmove overlap semantics, so this allocation-free
|
||||
// path safely serves both libc memcpy and libc memmove.
|
||||
new ReadOnlySpan<byte>((void*)sourcePointer, checked((int)length)).CopyTo(
|
||||
new Span<byte>((void*)destinationPointer, checked((int)length)));
|
||||
NotifyGuestWriteWatch(
|
||||
destinationAddress,
|
||||
new ReadOnlySpan<byte>((void*)destinationPointer, checked((int)length)));
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryReadExclusive(ulong virtualAddress, Span<byte> destination)
|
||||
{
|
||||
var region = FindRegion(virtualAddress, (ulong)destination.Length);
|
||||
@@ -1273,7 +1016,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1298,7 +1040,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1318,26 +1059,12 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
try
|
||||
{
|
||||
var region = FindRegion(virtualAddress, 1);
|
||||
if (region is null)
|
||||
if (region is null ||
|
||||
(region.IsReservedOnly && !EnsureRangeCommitted(virtualAddress, 1, region)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Raw host pointers are walked by native/JIT code without further
|
||||
// EnsureRangeCommitted calls. For reserve-only regions, commit a
|
||||
// leading working-set chunk from this address so the common case
|
||||
// does not immediately AV on the next page.
|
||||
if (region.IsReservedOnly)
|
||||
{
|
||||
var regionEnd = region.VirtualAddress + region.Size;
|
||||
var remaining = regionEnd > virtualAddress ? regionEnd - virtualAddress : 0;
|
||||
var commitBytes = Math.Min(remaining, LazyReservePrimeChunkBytes);
|
||||
if (commitBytes == 0 || !EnsureRangeCommitted(virtualAddress, commitBytes, region))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (void*)virtualAddress;
|
||||
}
|
||||
finally
|
||||
@@ -1554,12 +1281,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
var startPage = AlignDown(address, PageSize);
|
||||
var endPage = AlignUp(address + size, PageSize);
|
||||
var mappingGeneration = Volatile.Read(ref _mappingGeneration);
|
||||
var committedRangeCache = _committedRangeCache ??= new CommittedRangeCache();
|
||||
if (committedRangeCache.Contains(this, mappingGeneration, startPage, endPage))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
var commitProtection = GetCommitProtection(region);
|
||||
|
||||
var pageAddress = startPage;
|
||||
@@ -1581,9 +1302,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
if (info.State == HostRegionState.Committed)
|
||||
{
|
||||
// The host query proved this whole range is committed. Retain
|
||||
// that result instead of caching only the caller's small span.
|
||||
CacheCommittedRange(info.BaseAddress, queriedEnd, mappingGeneration);
|
||||
pageAddress = rangeEnd;
|
||||
continue;
|
||||
}
|
||||
@@ -1599,23 +1317,12 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return false;
|
||||
}
|
||||
|
||||
CacheCommittedRange(pageAddress, rangeEnd, mappingGeneration);
|
||||
pageAddress = rangeEnd;
|
||||
}
|
||||
|
||||
CacheCommittedRange(startPage, endPage, mappingGeneration);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void CacheCommittedRange(ulong startPage, ulong endPage, long mappingGeneration)
|
||||
{
|
||||
(_committedRangeCache ??= new CommittedRangeCache()).Add(
|
||||
this,
|
||||
mappingGeneration,
|
||||
startPage,
|
||||
endPage);
|
||||
}
|
||||
|
||||
private bool TryTemporarilyProtectForRead(
|
||||
ulong address,
|
||||
ulong size,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Core.Loader;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Core.Memory;
|
||||
|
||||
@@ -94,14 +93,8 @@ public sealed class VirtualMemory : IVirtualMemory
|
||||
}
|
||||
|
||||
CopyToRegions(virtualAddress, source, regionIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (GuestWriteWatch.Armed)
|
||||
{
|
||||
GuestWriteWatch.Check(virtualAddress, source);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryValidateRange(
|
||||
|
||||
@@ -143,7 +143,6 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
KernelModuleRegistry.Reset();
|
||||
var image = LoadImage(normalizedEbootPath);
|
||||
VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version);
|
||||
KernelMemoryCompatExports.ConfigureApplicationInfo(image.TitleId);
|
||||
SaveDataExports.ConfigureApplicationInfo(image.TitleId);
|
||||
SystemServiceExports.ConfigureApplicationInfo(image.TitleId);
|
||||
_ = RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false);
|
||||
|
||||
@@ -1,32 +1,267 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Application composition root. Shared resources and styles are included in
|
||||
cascade order so individual launcher views do not redefine global visuals.
|
||||
-->
|
||||
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:SharpEmu.GUI"
|
||||
x:Class="SharpEmu.GUI.App"
|
||||
RequestedThemeVariant="Dark">
|
||||
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceInclude Source="avares://SharpEmu.GUI/Themes/Tokens.axaml" />
|
||||
<ResourceInclude Source="avares://SharpEmu.GUI/Themes/Templates/SettingRow.axaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
<Color x:Key="SystemAccentColor">#7C5CFC</Color>
|
||||
|
||||
<LinearGradientBrush x:Key="BgBrush" StartPoint="0%,0%" EndPoint="100%,100%">
|
||||
<GradientStop Offset="0" Color="#12151F" />
|
||||
<GradientStop Offset="0.55" Color="#0D1017" />
|
||||
<GradientStop Offset="1" Color="#0B0D14" />
|
||||
</LinearGradientBrush>
|
||||
|
||||
<SolidColorBrush x:Key="ChromeBrush" Color="#090C12" />
|
||||
<SolidColorBrush x:Key="CardBrush" Color="#141924" />
|
||||
<SolidColorBrush x:Key="CardBorderBrush" Color="#232B3A" />
|
||||
<SolidColorBrush x:Key="ElevatedBrush" Color="#1B2230" />
|
||||
<SolidColorBrush x:Key="TextBrush" Color="#E8ECF4" />
|
||||
<SolidColorBrush x:Key="MutedBrush" Color="#8B94A7" />
|
||||
<SolidColorBrush x:Key="FaintBrush" Color="#5A6478" />
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#7C5CFC" />
|
||||
<SolidColorBrush x:Key="AccentHoverBrush" Color="#8F73FF" />
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
|
||||
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
|
||||
<SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" />
|
||||
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
|
||||
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
|
||||
|
||||
<ControlTheme x:Key="{x:Type local:SettingRow}" TargetType="local:SettingRow">
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="PART_Label" Text="{TemplateBinding Label}" FontSize="13" />
|
||||
<TextBlock Text="{TemplateBinding Description}" FontSize="11"
|
||||
Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Description, RelativeSource={RelativeSource TemplatedParent},
|
||||
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||
<ToggleSwitch OnContent="Override" OffContent="Override" MinWidth="0"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{TemplateBinding ShowOverride}"
|
||||
IsChecked="{Binding IsOverridden, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}" />
|
||||
<ContentPresenter x:Name="PART_Slot" Content="{TemplateBinding Content}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</ControlTheme>
|
||||
</Application.Resources>
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
|
||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Base.axaml" />
|
||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Surfaces.axaml" />
|
||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Buttons.axaml" />
|
||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Inputs.axaml" />
|
||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Console.axaml" />
|
||||
<StyleInclude Source="avares://SharpEmu.GUI/Themes/Styles/Library.axaml" />
|
||||
<Style Selector="Window">
|
||||
<Setter Property="FontFamily" Value="Inter, Segoe UI, sans-serif" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="Background" Value="{StaticResource CardBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="16" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.pill">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="Padding" Value="10,3" />
|
||||
</Style>
|
||||
|
||||
<!-- Session status/hotkey badges: the title-id pill geometry with a
|
||||
tinted fill so state (RUNNING) and keys (F11) read at a glance. -->
|
||||
<Style Selector="Border.badge">
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="Padding" Value="8,2" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Border.badge.running">
|
||||
<Setter Property="Background" Value="#1E46C46B" />
|
||||
<Setter Property="BorderBrush" Value="#5546C46B" />
|
||||
</Style>
|
||||
<Style Selector="Border.badge.key">
|
||||
<Setter Property="Background" Value="#1E58A6FF" />
|
||||
<Setter Property="BorderBrush" Value="#5558A6FF" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.sectionTitle">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="LetterSpacing" Value="1.5" />
|
||||
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.fieldLabel">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
|
||||
<Setter Property="Margin" Value="0,0,0,6" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.accent">
|
||||
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Padding" Value="22,10" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.accent:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource AccentHoverBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.danger">
|
||||
<Setter Property="Background" Value="{StaticResource DangerBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Padding" Value="22,10" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.danger:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource DangerHoverBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.ghost">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="Padding" Value="12,7" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ToggleButton.ghost">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="Padding" Value="12,7" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="ToggleButton.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ToggleButton.ghost:checked /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Top-level page switcher (Library / Options): plain transparent
|
||||
buttons, not TabItem, so there is no Fluent selected-tab underline.
|
||||
The active page is conveyed by brightness alone; LB/RB gamepad
|
||||
hints flank the pair. -->
|
||||
<Style Selector="Button.segment">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
|
||||
<Setter Property="FontSize" Value="22" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="Padding" Value="6,4" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.segment:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.segment.active">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Gamepad shoulder-button hint chip (LB/RB, L1/R1). -->
|
||||
<Style Selector="Border.padHint">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="8,3" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ContextMenu">
|
||||
<Setter Property="Background" Value="{StaticResource CardBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Padding" Value="6" />
|
||||
</Style>
|
||||
<Style Selector="ContextMenu MenuItem">
|
||||
<Setter Property="Padding" Value="10,7" />
|
||||
<Setter Property="CornerRadius" Value="7" />
|
||||
</Style>
|
||||
<Style Selector="ContextMenu Separator">
|
||||
<Setter Property="Background" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="Height" Value="1" />
|
||||
<Setter Property="Margin" Value="8,4" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBox.console">
|
||||
<Setter Property="Background" Value="#0B0E14" />
|
||||
<Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Courier New, monospace" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.console ListBoxItem">
|
||||
<Setter Property="Padding" Value="10,1" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
</Style>
|
||||
|
||||
<!-- Cover-art library grid -->
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem">
|
||||
<Setter Property="Padding" Value="10" />
|
||||
<Setter Property="Margin" Value="5" />
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="RenderTransform" Value="translateY(0px)" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover">
|
||||
<Setter Property="RenderTransform" Value="translateY(-3px)" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource TileHoverBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentHoverBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.coverShadow">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="BoxShadow" Value="0 6 14 0 #55000000" />
|
||||
</Style>
|
||||
<Style Selector="Border.coverClip">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="ClipToBounds" Value="True" />
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
|
||||
</Application>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
using System.IO;
|
||||
using LibAtrac9.Utilities;
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
using System;
|
||||
using LibAtrac9.Utilities;
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
namespace LibAtrac9
|
||||
{
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
using System;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
using System;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
namespace LibAtrac9
|
||||
{
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
using LibAtrac9.Utilities;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
namespace LibAtrac9
|
||||
{
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
namespace LibAtrac9
|
||||
{
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
using System;
|
||||
using LibAtrac9.Utilities;
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
namespace LibAtrac9
|
||||
{
|
||||
@@ -1351,4 +1350,4 @@ namespace LibAtrac9
|
||||
new byte[] {0, 0, 0, 0}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
using System;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
using System;
|
||||
using System.IO;
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
namespace LibAtrac9
|
||||
{
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
using System;
|
||||
using static LibAtrac9.HuffmanCodebooks;
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
using System;
|
||||
using System.IO;
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
namespace LibAtrac9.Utilities
|
||||
{
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#nullable disable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -6,7 +6,6 @@ using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform;
|
||||
@@ -41,7 +40,7 @@ public sealed class ConsoleWindow : Window
|
||||
|
||||
_searchBox = new TextBox
|
||||
{
|
||||
PlaceholderText = loc.Get("Console.SearchWatermark"),
|
||||
Watermark = loc.Get("Console.SearchWatermark"),
|
||||
Width = 320,
|
||||
Margin = new Thickness(0, 0, 12, 0),
|
||||
};
|
||||
|
||||
@@ -248,7 +248,7 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
{
|
||||
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
|
||||
if (!CreateProcessW(
|
||||
null,
|
||||
exePath,
|
||||
commandLine,
|
||||
0,
|
||||
0,
|
||||
@@ -629,7 +629,7 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
|
||||
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CreateProcessW(string? applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
|
||||
private static extern bool CreateProcessW(string applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern uint WaitForSingleObject(nint handle, uint milliseconds);
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
/// <summary>
|
||||
/// Native child surface owned by Avalonia. The isolated emulator process uses
|
||||
/// its platform handle to create the Vulkan presentation surface, keeping the
|
||||
/// guest address space out of the GUI process.
|
||||
/// </summary>
|
||||
public sealed class GameSurfaceHost : NativeControlHost
|
||||
{
|
||||
private const uint SwpNoSize = 0x0001;
|
||||
private const uint SwpNoMove = 0x0002;
|
||||
private const uint SwpNoZOrder = 0x0004;
|
||||
private const uint SwpNoActivate = 0x0010;
|
||||
private const uint SwpShowWindow = 0x0040;
|
||||
private const uint SwpHideWindow = 0x0080;
|
||||
private const uint WsChild = 0x40000000;
|
||||
private const uint WsVisible = 0x10000000;
|
||||
private const uint WsClipSiblings = 0x04000000;
|
||||
private const uint WsClipChildren = 0x02000000;
|
||||
private const uint CsOwnDc = 0x0020;
|
||||
private const uint WmSetCursor = 0x0020;
|
||||
private const uint WmMouseMove = 0x0200;
|
||||
private const int IdcArrow = 32512;
|
||||
private const int CursorHideDelayMs = 2500;
|
||||
|
||||
private VulkanHostSurface? _surface;
|
||||
private nint _windowHandle;
|
||||
private nint _x11Display;
|
||||
private string? _win32ClassName;
|
||||
private WindowProcedure? _windowProcedure;
|
||||
private nint _metalLayer;
|
||||
private bool _presentationVisible = true;
|
||||
private DispatcherTimer? _cursorIdleTimer;
|
||||
private bool _cursorAutoHide;
|
||||
private bool _cursorHidden;
|
||||
private long _lastPointerActivity;
|
||||
|
||||
public GameSurfaceHost()
|
||||
{
|
||||
PropertyChanged += (_, change) =>
|
||||
{
|
||||
if (change.Property == BoundsProperty)
|
||||
{
|
||||
UpdateSurfaceSize();
|
||||
}
|
||||
};
|
||||
LayoutUpdated += (_, _) =>
|
||||
{
|
||||
// Fullscreen can change a monitor's DPI scale without changing
|
||||
// the logical Bounds. Refresh the native child from physical size.
|
||||
UpdateSurfaceSize();
|
||||
|
||||
// NativeControlHost may make its HWND visible again as part of a
|
||||
// later arrange pass. Keep a loading surface hidden until its
|
||||
// child process reports a real first frame.
|
||||
if (!_presentationVisible)
|
||||
{
|
||||
ApplyPresentationVisibility();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public event EventHandler<VulkanHostSurface>? SurfaceAvailable;
|
||||
|
||||
public event EventHandler<VulkanHostSurface>? SurfaceDestroyed;
|
||||
|
||||
public VulkanHostSurface? Surface => _surface;
|
||||
|
||||
public void RefreshSurfaceSize() => UpdateSurfaceSize();
|
||||
|
||||
/// <summary>
|
||||
/// Hides the platform child without detaching the Vulkan surface. This
|
||||
/// allows the launcher to return to its library while guest teardown is
|
||||
/// still finishing on the render thread.
|
||||
/// </summary>
|
||||
public void SetPresentationVisible(bool visible)
|
||||
{
|
||||
_presentationVisible = visible;
|
||||
ApplyPresentationVisibility();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-hides the mouse cursor over the game surface after a short idle
|
||||
/// period; any pointer movement brings it back. Enabling (again) restarts
|
||||
/// the idle countdown, so both "first frame presented" and "entered
|
||||
/// fullscreen" can arm it. Windows-only; a no-op elsewhere.
|
||||
/// </summary>
|
||||
public void SetCursorAutoHide(bool enabled)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cursorAutoHide = enabled;
|
||||
_lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
if (enabled)
|
||||
{
|
||||
_cursorIdleTimer ??= CreateCursorIdleTimer();
|
||||
_cursorIdleTimer.Start();
|
||||
return;
|
||||
}
|
||||
|
||||
_cursorIdleTimer?.Stop();
|
||||
ShowCursorNow();
|
||||
}
|
||||
|
||||
private DispatcherTimer CreateCursorIdleTimer()
|
||||
{
|
||||
var timer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(250),
|
||||
};
|
||||
timer.Tick += (_, _) => HideCursorWhenIdle();
|
||||
return timer;
|
||||
}
|
||||
|
||||
private void HideCursorWhenIdle()
|
||||
{
|
||||
if (!_cursorAutoHide || _cursorHidden || _windowHandle == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var idleMs = (System.Diagnostics.Stopwatch.GetTimestamp() - _lastPointerActivity) *
|
||||
1000 / System.Diagnostics.Stopwatch.Frequency;
|
||||
if (idleMs < CursorHideDelayMs)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Only swallow the cursor while it is actually over the game surface;
|
||||
// hovering launcher chrome (console, toolbar) must keep the arrow.
|
||||
if (!GetCursorPos(out var point) || WindowFromPoint(point) != _windowHandle)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cursorHidden = true;
|
||||
_ = SetCursor(0);
|
||||
}
|
||||
|
||||
private void ShowCursorNow()
|
||||
{
|
||||
if (!_cursorHidden)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cursorHidden = false;
|
||||
_ = SetCursor(LoadCursorW(0, IdcArrow));
|
||||
}
|
||||
|
||||
private void ApplyPresentationVisibility()
|
||||
{
|
||||
if (_windowHandle == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var visible = _presentationVisible;
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// SW_HIDE can be ignored for a window's initial show state. Force
|
||||
// the state through SetWindowPos so an old child swapchain cannot
|
||||
// remain composed while the next game is loading.
|
||||
var flags = SwpNoSize | SwpNoMove | SwpNoZOrder | SwpNoActivate |
|
||||
(visible ? SwpShowWindow : SwpHideWindow);
|
||||
_ = SetWindowPos(_windowHandle, 0, 0, 0, 0, 0, flags);
|
||||
}
|
||||
else if (OperatingSystem.IsLinux() && _x11Display != 0)
|
||||
{
|
||||
_ = visible
|
||||
? XMapWindow(_x11Display, _windowHandle)
|
||||
: XUnmapWindow(_x11Display, _windowHandle);
|
||||
_ = XFlush(_x11Display);
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
SendBool(_windowHandle, "setHidden:", !visible);
|
||||
}
|
||||
}
|
||||
|
||||
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle control)
|
||||
{
|
||||
PlatformHandle handle;
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
handle = CreateWin32(control);
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
handle = CreateX11(control);
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
handle = CreateMacOS();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new PlatformNotSupportedException("SharpEmu's embedded Vulkan surface is unsupported on this platform.");
|
||||
}
|
||||
|
||||
UpdateSurfaceSize();
|
||||
if (_surface is { } surface)
|
||||
{
|
||||
SurfaceAvailable?.Invoke(this, surface);
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
protected override void DestroyNativeControlCore(IPlatformHandle control)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
SetCursorAutoHide(false);
|
||||
}
|
||||
|
||||
var surface = _surface;
|
||||
_surface = null;
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
DestroyWin32();
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
DestroyX11();
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
DestroyMacOS();
|
||||
}
|
||||
|
||||
if (surface is not null)
|
||||
{
|
||||
SurfaceDestroyed?.Invoke(this, surface);
|
||||
}
|
||||
}
|
||||
|
||||
private PlatformHandle CreateWin32(IPlatformHandle control)
|
||||
{
|
||||
_win32ClassName = $"SharpEmuGameSurface-{Guid.NewGuid():N}";
|
||||
_windowProcedure = WindowProcedureImpl;
|
||||
var classInfo = new WndClassEx
|
||||
{
|
||||
Size = (uint)Marshal.SizeOf<WndClassEx>(),
|
||||
Style = CsOwnDc,
|
||||
WindowProcedure = Marshal.GetFunctionPointerForDelegate(_windowProcedure),
|
||||
Instance = GetModuleHandleW(null),
|
||||
ClassName = _win32ClassName,
|
||||
};
|
||||
|
||||
if (RegisterClassExW(ref classInfo) == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Could not register the embedded game window class (Win32 error {Marshal.GetLastWin32Error()}).");
|
||||
}
|
||||
|
||||
_windowHandle = CreateWindowExW(
|
||||
0,
|
||||
_win32ClassName,
|
||||
"SharpEmu Game Surface",
|
||||
WsChild | (_presentationVisible ? WsVisible : 0) | WsClipSiblings | WsClipChildren,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
control.Handle,
|
||||
0,
|
||||
classInfo.Instance,
|
||||
0);
|
||||
if (_windowHandle == 0)
|
||||
{
|
||||
var error = Marshal.GetLastWin32Error();
|
||||
_ = UnregisterClassW(_win32ClassName, classInfo.Instance);
|
||||
throw new InvalidOperationException($"Could not create the embedded game window (Win32 error {error}).");
|
||||
}
|
||||
|
||||
_surface = new VulkanHostSurface(
|
||||
VulkanHostSurfaceKind.Win32,
|
||||
_windowHandle,
|
||||
classInfo.Instance);
|
||||
return new PlatformHandle(_windowHandle, "HWND");
|
||||
}
|
||||
|
||||
private PlatformHandle CreateX11(IPlatformHandle control)
|
||||
{
|
||||
_x11Display = XOpenDisplay(0);
|
||||
if (_x11Display == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Could not connect to the X11 server for the embedded game surface.");
|
||||
}
|
||||
|
||||
_windowHandle = XCreateSimpleWindow(
|
||||
_x11Display,
|
||||
control.Handle,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0);
|
||||
if (_windowHandle == 0)
|
||||
{
|
||||
XCloseDisplay(_x11Display);
|
||||
_x11Display = 0;
|
||||
throw new InvalidOperationException("Could not create the X11 embedded game surface.");
|
||||
}
|
||||
|
||||
if (_presentationVisible)
|
||||
{
|
||||
_ = XMapWindow(_x11Display, _windowHandle);
|
||||
}
|
||||
_ = XFlush(_x11Display);
|
||||
_surface = new VulkanHostSurface(VulkanHostSurfaceKind.Xlib, _windowHandle, _x11Display);
|
||||
return new PlatformHandle(_windowHandle, "X11");
|
||||
}
|
||||
|
||||
private PlatformHandle CreateMacOS()
|
||||
{
|
||||
_metalLayer = CreateObjectiveCObject("CAMetalLayer");
|
||||
_windowHandle = CreateObjectiveCObject("NSView");
|
||||
SendBool(_windowHandle, "setWantsLayer:", true);
|
||||
SendPointer(_windowHandle, "setLayer:", _metalLayer);
|
||||
SendBool(_windowHandle, "setHidden:", !_presentationVisible);
|
||||
|
||||
_surface = new VulkanHostSurface(VulkanHostSurfaceKind.Metal, _windowHandle, metalLayerHandle: _metalLayer);
|
||||
return new PlatformHandle(_windowHandle, "NSView");
|
||||
}
|
||||
|
||||
private void UpdateSurfaceSize()
|
||||
{
|
||||
if (_surface is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var renderScale = (VisualRoot as TopLevel)?.RenderScaling ?? 1.0;
|
||||
var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale));
|
||||
var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale));
|
||||
var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height;
|
||||
_surface.UpdatePixelSize(width, height);
|
||||
|
||||
if (!sizeChanged)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows() && _windowHandle != 0)
|
||||
{
|
||||
_ = SetWindowPos(
|
||||
_windowHandle,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
SwpNoMove | SwpNoZOrder | SwpNoActivate);
|
||||
}
|
||||
else if (OperatingSystem.IsLinux() && _x11Display != 0 && _windowHandle != 0)
|
||||
{
|
||||
_ = XResizeWindow(_x11Display, _windowHandle, (uint)width, (uint)height);
|
||||
_ = XFlush(_x11Display);
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS() && _metalLayer != 0)
|
||||
{
|
||||
SendDouble(_metalLayer, "setContentsScale:", renderScale);
|
||||
}
|
||||
}
|
||||
|
||||
private void DestroyWin32()
|
||||
{
|
||||
if (_windowHandle != 0)
|
||||
{
|
||||
_ = DestroyWindow(_windowHandle);
|
||||
_windowHandle = 0;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_win32ClassName))
|
||||
{
|
||||
_ = UnregisterClassW(_win32ClassName, GetModuleHandleW(null));
|
||||
_win32ClassName = null;
|
||||
}
|
||||
|
||||
_windowProcedure = null;
|
||||
}
|
||||
|
||||
private void DestroyX11()
|
||||
{
|
||||
if (_x11Display != 0 && _windowHandle != 0)
|
||||
{
|
||||
_ = XDestroyWindow(_x11Display, _windowHandle);
|
||||
}
|
||||
if (_x11Display != 0)
|
||||
{
|
||||
_ = XCloseDisplay(_x11Display);
|
||||
}
|
||||
|
||||
_windowHandle = 0;
|
||||
_x11Display = 0;
|
||||
}
|
||||
|
||||
private void DestroyMacOS()
|
||||
{
|
||||
if (_windowHandle != 0)
|
||||
{
|
||||
SendVoid(_windowHandle, "release");
|
||||
}
|
||||
if (_metalLayer != 0)
|
||||
{
|
||||
SendVoid(_metalLayer, "release");
|
||||
}
|
||||
|
||||
_windowHandle = 0;
|
||||
_metalLayer = 0;
|
||||
}
|
||||
|
||||
private nint WindowProcedureImpl(nint window, uint message, nint wParam, nint lParam)
|
||||
{
|
||||
if (message == WmMouseMove)
|
||||
{
|
||||
_lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
ShowCursorNow();
|
||||
}
|
||||
else if (message == WmSetCursor && _cursorHidden)
|
||||
{
|
||||
// Win32 re-resolves the cursor on every mouse message; returning
|
||||
// TRUE here keeps the parent chain from restoring the arrow.
|
||||
_ = SetCursor(0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
return DefWindowProcW(window, message, wParam, lParam);
|
||||
}
|
||||
|
||||
private static nint CreateObjectiveCObject(string className)
|
||||
{
|
||||
var classHandle = objc_getClass(className);
|
||||
if (classHandle == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Objective-C class '{className}' is unavailable.");
|
||||
}
|
||||
|
||||
var instance = objc_msgSend_id(classHandle, sel_registerName("alloc"));
|
||||
instance = objc_msgSend_id(instance, sel_registerName("init"));
|
||||
if (instance == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Could not create Objective-C '{className}'.");
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private static void SendVoid(nint receiver, string selector) =>
|
||||
objc_msgSend_void(receiver, sel_registerName(selector));
|
||||
|
||||
private static void SendBool(nint receiver, string selector, bool value) =>
|
||||
objc_msgSend_bool(receiver, sel_registerName(selector), value ? (byte)1 : (byte)0);
|
||||
|
||||
private static void SendPointer(nint receiver, string selector, nint value) =>
|
||||
objc_msgSend_pointer(receiver, sel_registerName(selector), value);
|
||||
|
||||
private static void SendDouble(nint receiver, string selector, double value) =>
|
||||
objc_msgSend_double(receiver, sel_registerName(selector), value);
|
||||
|
||||
private delegate nint WindowProcedure(nint window, uint message, nint wParam, nint lParam);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct WndClassEx
|
||||
{
|
||||
public uint Size;
|
||||
public uint Style;
|
||||
public nint WindowProcedure;
|
||||
public int ClassExtra;
|
||||
public int WindowExtra;
|
||||
public nint Instance;
|
||||
public nint Icon;
|
||||
public nint Cursor;
|
||||
public nint Background;
|
||||
public string? MenuName;
|
||||
public string? ClassName;
|
||||
public nint IconSmall;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", CharSet = CharSet.Unicode)]
|
||||
private static extern nint GetModuleHandleW(string? moduleName);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "RegisterClassExW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern ushort RegisterClassExW(ref WndClassEx classInfo);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "UnregisterClassW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool UnregisterClassW(string className, nint instance);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "CreateWindowExW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern nint CreateWindowExW(
|
||||
uint extendedStyle,
|
||||
string className,
|
||||
string windowName,
|
||||
uint style,
|
||||
int x,
|
||||
int y,
|
||||
int width,
|
||||
int height,
|
||||
nint parent,
|
||||
nint menu,
|
||||
nint instance,
|
||||
nint parameter);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "DestroyWindow", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DestroyWindow(nint window);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "SetWindowPos", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetWindowPos(
|
||||
nint window,
|
||||
nint insertAfter,
|
||||
int x,
|
||||
int y,
|
||||
int width,
|
||||
int height,
|
||||
uint flags);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "DefWindowProcW", CharSet = CharSet.Unicode)]
|
||||
private static extern nint DefWindowProcW(nint window, uint message, nint wParam, nint lParam);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "SetCursor")]
|
||||
private static extern nint SetCursor(nint cursor);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "LoadCursorW", CharSet = CharSet.Unicode)]
|
||||
private static extern nint LoadCursorW(nint instance, nint cursorName);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "GetCursorPos")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetCursorPos(out NativePoint point);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "WindowFromPoint")]
|
||||
private static extern nint WindowFromPoint(NativePoint point);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct NativePoint
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
[DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
|
||||
private static extern nint XOpenDisplay(nint displayName);
|
||||
|
||||
[DllImport("libX11.so.6", EntryPoint = "XCreateSimpleWindow")]
|
||||
private static extern nint XCreateSimpleWindow(
|
||||
nint display,
|
||||
nint parent,
|
||||
int x,
|
||||
int y,
|
||||
uint width,
|
||||
uint height,
|
||||
uint borderWidth,
|
||||
ulong border,
|
||||
ulong background);
|
||||
|
||||
[DllImport("libX11.so.6", EntryPoint = "XMapWindow")]
|
||||
private static extern int XMapWindow(nint display, nint window);
|
||||
|
||||
[DllImport("libX11.so.6", EntryPoint = "XUnmapWindow")]
|
||||
private static extern int XUnmapWindow(nint display, nint window);
|
||||
|
||||
[DllImport("libX11.so.6", EntryPoint = "XResizeWindow")]
|
||||
private static extern int XResizeWindow(nint display, nint window, uint width, uint height);
|
||||
|
||||
[DllImport("libX11.so.6", EntryPoint = "XDestroyWindow")]
|
||||
private static extern int XDestroyWindow(nint display, nint window);
|
||||
|
||||
[DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
|
||||
private static extern int XCloseDisplay(nint display);
|
||||
|
||||
[DllImport("libX11.so.6", EntryPoint = "XFlush")]
|
||||
private static extern int XFlush(nint display);
|
||||
|
||||
[DllImport("/usr/lib/libobjc.A.dylib")]
|
||||
private static extern nint objc_getClass(string name);
|
||||
|
||||
[DllImport("/usr/lib/libobjc.A.dylib")]
|
||||
private static extern nint sel_registerName(string name);
|
||||
|
||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
||||
private static extern nint objc_msgSend_id(nint receiver, nint selector);
|
||||
|
||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
||||
private static extern void objc_msgSend_void(nint receiver, nint selector);
|
||||
|
||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
||||
private static extern void objc_msgSend_bool(nint receiver, nint selector, byte value);
|
||||
|
||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
||||
private static extern void objc_msgSend_pointer(nint receiver, nint selector, nint value);
|
||||
|
||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
||||
private static extern void objc_msgSend_double(nint receiver, nint selector, double value);
|
||||
}
|
||||
@@ -50,26 +50,9 @@ public sealed class GuiSettings
|
||||
|
||||
public bool CheckForUpdatesOnStartup { get; set; } = true;
|
||||
|
||||
public string WindowMode { get; set; } = "Windowed";
|
||||
|
||||
public string Resolution { get; set; } = "1920x1080";
|
||||
|
||||
public int DisplayIndex { get; set; }
|
||||
|
||||
public int RefreshRate { get; set; }
|
||||
|
||||
public string ScalingMode { get; set; } = "Fit";
|
||||
|
||||
public bool VSync { get; set; } = true;
|
||||
|
||||
public string HdrMode { get; set; } = "Auto";
|
||||
|
||||
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
|
||||
public List<string> EnvironmentToggles { get; set; } = new();
|
||||
|
||||
/// <summary>Internal render resolution scale (1.0 = native, 0.5 = half).</summary>
|
||||
public double RenderResolutionScale { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Discord application ID used for Rich Presence; the default is the
|
||||
/// SharpEmu application. Override to rebrand what Discord shows as
|
||||
@@ -88,7 +71,7 @@ public sealed class GuiSettings
|
||||
if (File.Exists(SettingsPath))
|
||||
{
|
||||
var json = File.ReadAllText(SettingsPath);
|
||||
return NormalizeFromJson(json);
|
||||
return JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -99,59 +82,6 @@ public sealed class GuiSettings
|
||||
return new GuiSettings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes settings and normalizes null references and null or empty list
|
||||
/// entries introduced by JSON. Empty scalar strings remain unchanged.
|
||||
/// </summary>
|
||||
internal static GuiSettings NormalizeFromJson(string json)
|
||||
{
|
||||
var settings = JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
|
||||
|
||||
settings.GameFolders = FilterNullOrEmpty(settings.GameFolders);
|
||||
settings.ExcludedGames = FilterNullOrEmpty(settings.ExcludedGames);
|
||||
settings.EnvironmentToggles = FilterNullOrEmpty(settings.EnvironmentToggles);
|
||||
settings.LogLevel ??= "Info";
|
||||
settings.Language ??= "en";
|
||||
settings.DiscordClientId ??= "1525606762248540221";
|
||||
if (settings.RenderResolutionScale <= 0 || settings.RenderResolutionScale > 2.0)
|
||||
{
|
||||
settings.RenderResolutionScale = 1.0;
|
||||
}
|
||||
settings.WindowMode = NormalizeChoice(settings.WindowMode, "Windowed", "Borderless", "Exclusive");
|
||||
settings.Resolution = NormalizeResolution(settings.Resolution);
|
||||
settings.ScalingMode = NormalizeChoice(settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
|
||||
settings.HdrMode = NormalizeChoice(settings.HdrMode, "Auto", "On", "Off");
|
||||
settings.DisplayIndex = Math.Max(0, settings.DisplayIndex);
|
||||
settings.RefreshRate = Math.Clamp(settings.RefreshRate, 0, 1000);
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
// JSON can populate non-nullable lists with null references and entries.
|
||||
private static List<string> FilterNullOrEmpty(List<string>? source)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return source.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
|
||||
}
|
||||
|
||||
private static string NormalizeChoice(string? value, string fallback, params string[] choices) =>
|
||||
choices.Prepend(fallback).FirstOrDefault(
|
||||
choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase)) ?? fallback;
|
||||
|
||||
private static string NormalizeResolution(string? value)
|
||||
{
|
||||
if (!HostDisplayOptions.TryParseResolution(value, out var width, out var height))
|
||||
{
|
||||
return "1920x1080";
|
||||
}
|
||||
|
||||
return $"{width}x{height}";
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
internal sealed record HostDisplayOption(HostDisplayInfo Display)
|
||||
{
|
||||
public int Index => Display.Index;
|
||||
|
||||
public IReadOnlyList<HostDisplayMode> Modes => Display.Modes;
|
||||
|
||||
public override string ToString() => $"{Index + 1}: {Display.Name}";
|
||||
}
|
||||
|
||||
internal sealed record HostRefreshRateOption(int Value, string Label)
|
||||
{
|
||||
public override string ToString() => Label;
|
||||
}
|
||||
|
||||
internal static class HostDisplayOptions
|
||||
{
|
||||
public static IReadOnlyList<HostDisplayOption> BuildDisplays(
|
||||
IReadOnlyList<HostDisplayInfo> detected,
|
||||
int selectedIndex)
|
||||
{
|
||||
selectedIndex = Math.Max(0, selectedIndex);
|
||||
var options = detected
|
||||
.Select(display => new HostDisplayOption(display))
|
||||
.ToList();
|
||||
if (options.Count == 0)
|
||||
{
|
||||
options.Add(new HostDisplayOption(new HostDisplayInfo(
|
||||
0,
|
||||
"Display 1",
|
||||
CreateFallbackModes())));
|
||||
}
|
||||
|
||||
if (options.All(display => display.Index != selectedIndex))
|
||||
{
|
||||
options.Add(new HostDisplayOption(new HostDisplayInfo(
|
||||
selectedIndex,
|
||||
$"Display {selectedIndex + 1}",
|
||||
options[0].Modes)));
|
||||
}
|
||||
|
||||
return options.OrderBy(display => display.Index).ToArray();
|
||||
}
|
||||
|
||||
public static HostDisplayOption SelectDisplay(
|
||||
IReadOnlyList<HostDisplayOption> displays,
|
||||
int selectedIndex) =>
|
||||
displays.FirstOrDefault(display => display.Index == selectedIndex) ?? displays[0];
|
||||
|
||||
public static IReadOnlyList<string> BuildResolutions(
|
||||
HostDisplayOption display,
|
||||
string? selectedResolution)
|
||||
{
|
||||
var resolutions = display.Modes
|
||||
.Where(mode => mode.Width > 0 && mode.Height > 0)
|
||||
.Select(mode => $"{mode.Width}x{mode.Height}")
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (TryParseResolution(selectedResolution, out var selectedWidth, out var selectedHeight))
|
||||
{
|
||||
var selected = $"{selectedWidth}x{selectedHeight}";
|
||||
if (!resolutions.Contains(selected, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
resolutions.Add(selected);
|
||||
}
|
||||
}
|
||||
|
||||
if (resolutions.Count == 0)
|
||||
{
|
||||
resolutions.Add("1920x1080");
|
||||
}
|
||||
|
||||
return resolutions
|
||||
.OrderByDescending(resolution => ResolutionArea(resolution))
|
||||
.ThenByDescending(resolution => resolution, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<HostRefreshRateOption> BuildRefreshRates(
|
||||
HostDisplayOption display,
|
||||
string? resolution,
|
||||
int selectedRefreshRate,
|
||||
string automaticLabel)
|
||||
{
|
||||
TryParseResolution(resolution, out var width, out var height);
|
||||
var rates = display.Modes
|
||||
.Where(mode => mode.Width == width && mode.Height == height && mode.RefreshRate > 0)
|
||||
.Select(mode => mode.RefreshRate)
|
||||
.Distinct()
|
||||
.OrderByDescending(rate => rate)
|
||||
.ToList();
|
||||
if (selectedRefreshRate > 0 && !rates.Contains(selectedRefreshRate))
|
||||
{
|
||||
rates.Add(selectedRefreshRate);
|
||||
rates.Sort((left, right) => right.CompareTo(left));
|
||||
}
|
||||
|
||||
return new[] { new HostRefreshRateOption(0, automaticLabel) }
|
||||
.Concat(rates.Select(rate => new HostRefreshRateOption(rate, $"{rate} Hz")))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static bool TryParseResolution(string? value, out int width, out int height)
|
||||
{
|
||||
width = 0;
|
||||
height = 0;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var separator = value.IndexOf('x', StringComparison.OrdinalIgnoreCase);
|
||||
return separator > 0 &&
|
||||
int.TryParse(value.AsSpan(0, separator), out width) &&
|
||||
int.TryParse(value.AsSpan(separator + 1), out height) &&
|
||||
width > 0 &&
|
||||
height > 0;
|
||||
}
|
||||
|
||||
private static long ResolutionArea(string resolution) =>
|
||||
TryParseResolution(resolution, out var width, out var height)
|
||||
? (long)width * height
|
||||
: 0;
|
||||
|
||||
private static IReadOnlyList<HostDisplayMode> CreateFallbackModes() =>
|
||||
[
|
||||
new HostDisplayMode(3840, 2160, 60),
|
||||
new HostDisplayMode(2560, 1440, 60),
|
||||
new HostDisplayMode(1920, 1080, 60),
|
||||
new HostDisplayMode(1280, 720, 60),
|
||||
];
|
||||
}
|
||||
@@ -41,24 +41,6 @@
|
||||
"Options.Section.Emulation": "EMULATION",
|
||||
"Options.Section.Logging": "LOGGING",
|
||||
"Options.Section.Launcher": "LAUNCHER",
|
||||
"Options.Section.Display": "DISPLAY",
|
||||
"Options.Graphics": "Graphics",
|
||||
|
||||
"Options.WindowMode.Label": "Window mode",
|
||||
"Options.WindowMode.Desc": "Regular window, desktop borderless, or exclusive fullscreen.",
|
||||
"Options.Resolution.Label": "Resolution",
|
||||
"Options.Resolution.Desc": "Initial window size or exclusive fullscreen resolution.",
|
||||
"Options.Display.Label": "Display",
|
||||
"Options.Display.Desc": "Monitor used for centering and fullscreen.",
|
||||
"Options.RefreshRate.Label": "Refresh rate",
|
||||
"Options.RefreshRate.Desc": "Exclusive fullscreen refresh rate. Automatic selects the closest mode.",
|
||||
"Options.RefreshRate.Automatic": "Automatic",
|
||||
"Options.Scaling.Label": "Scaling",
|
||||
"Options.Scaling.Desc": "Scale the native guest image without changing its internal resolution.",
|
||||
"Options.VSync.Label": "VSync",
|
||||
"Options.VSync.Desc": "Use FIFO presentation for tear-free output.",
|
||||
"Options.Hdr.Label": "HDR output",
|
||||
"Options.Hdr.Desc": "Use HDR when the selected display and graphics backend support it. Auto falls back to SDR.",
|
||||
|
||||
"Options.CpuEngine.Label": "CPU engine",
|
||||
"Options.CpuEngine.Desc": "Execution engine used to run game code.",
|
||||
@@ -105,8 +87,6 @@
|
||||
|
||||
"PerGame.Title": "Per-game settings — {0} ({1})",
|
||||
"PerGame.InheritNote": "Unchecked rows inherit the global defaults.",
|
||||
"PerGame.Tab.General": "General",
|
||||
"PerGame.Tab.Graphics": "Graphics",
|
||||
"PerGame.EnvToggles.Label": "Environment toggles",
|
||||
"PerGame.EnvToggles.Desc": "Override the global set of SHARPEMU_* switches for this game.",
|
||||
|
||||
|
||||
@@ -29,24 +29,6 @@
|
||||
"Options.Section.Emulation": "EMÜLASYON",
|
||||
"Options.Section.Logging": "GÜNLÜKLEME",
|
||||
"Options.Section.Launcher": "BAŞLATICI",
|
||||
"Options.Section.Display": "GÖRÜNTÜ",
|
||||
"Options.Graphics": "Grafik",
|
||||
|
||||
"Options.WindowMode.Label": "Pencere modu",
|
||||
"Options.WindowMode.Desc": "Normal pencere, kenarlıksız masaüstü veya özel tam ekran.",
|
||||
"Options.Resolution.Label": "Çözünürlük",
|
||||
"Options.Resolution.Desc": "Başlangıç pencere boyutu veya özel tam ekran çözünürlüğü.",
|
||||
"Options.Display.Label": "Ekran",
|
||||
"Options.Display.Desc": "Ortalama ve tam ekran için kullanılan monitör.",
|
||||
"Options.RefreshRate.Label": "Yenileme hızı",
|
||||
"Options.RefreshRate.Desc": "Özel tam ekran yenileme hızı. Otomatik, en yakın modu seçer.",
|
||||
"Options.RefreshRate.Automatic": "Otomatik",
|
||||
"Options.Scaling.Label": "Ölçekleme",
|
||||
"Options.Scaling.Desc": "Dahili çözünürlüğü değiştirmeden oyun görüntüsünü ölçekle.",
|
||||
"Options.VSync.Label": "VSync",
|
||||
"Options.VSync.Desc": "Yırtılmasız görüntü için FIFO sunumunu kullan.",
|
||||
"Options.Hdr.Label": "HDR çıkışı",
|
||||
"Options.Hdr.Desc": "Seçili ekran ve grafik backend'i destekliyorsa HDR kullan. Otomatik mod SDR'ye geri döner.",
|
||||
|
||||
"Options.CpuEngine.Label": "CPU motoru",
|
||||
"Options.CpuEngine.Desc": "Oyun kodunu çalıştırmak için kullanılan yürütme motoru.",
|
||||
@@ -177,8 +159,6 @@
|
||||
"Common.Cancel": "İptal",
|
||||
"PerGame.Title": "Oyuna özel ayarlar — {0} ({1})",
|
||||
"PerGame.InheritNote": "İşaretlenmemiş satırlar genel varsayılanları kullanır.",
|
||||
"PerGame.Tab.General": "Genel",
|
||||
"PerGame.Tab.Graphics": "Grafik",
|
||||
"PerGame.EnvToggles.Label": "Ortam anahtarları",
|
||||
"PerGame.EnvToggles.Desc": "Bu oyun için genel SHARPEMU_* anahtar kümesini geçersiz kıl.",
|
||||
"Options.About": "Hakkında",
|
||||
|
||||
@@ -5,8 +5,6 @@ using System.Text.Json;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
public sealed record LanguageInfo(string Code, string NativeName);
|
||||
|
||||
/// <summary>
|
||||
/// Loads UI strings for the launcher. Every language ships embedded in the
|
||||
/// assembly (see SharpEmu.GUI.csproj) so a release build is fully
|
||||
@@ -18,6 +16,8 @@ public sealed class Localization
|
||||
{
|
||||
public static Localization Instance { get; } = new();
|
||||
|
||||
public sealed record LanguageInfo(string Code, string NativeName);
|
||||
|
||||
private const string EmbeddedResourcePrefix = "Languages.";
|
||||
private const string EmbeddedResourceSuffix = ".json";
|
||||
|
||||
@@ -242,7 +242,7 @@ public sealed class Localization
|
||||
result = loaded;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
private bool TryLoad(string code, string json)
|
||||
{
|
||||
if (TryLoad(json, out var dict))
|
||||
|
||||
@@ -15,7 +15,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource BgBrush}"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
WindowDecorations="Full"
|
||||
ExtendClientAreaChromeHints="PreferSystemChrome"
|
||||
ExtendClientAreaTitleBarHeightHint="44"
|
||||
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico"
|
||||
KeyDown="OnKeyDown">
|
||||
@@ -60,6 +60,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<!-- Main content -->
|
||||
<Grid x:Name="MainContent" Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<!-- The game owns the full client area while running. Session controls
|
||||
use a native popup so they can stay above this native child surface. -->
|
||||
<Border x:Name="GameView" Grid.Row="0" Grid.RowSpan="4" IsVisible="False" Background="#000000" ClipToBounds="True">
|
||||
<Grid x:Name="GameSurfaceContainer" />
|
||||
</Border>
|
||||
|
||||
<!-- Library / Options page switcher, with the library toolbar sharing
|
||||
the same row on the right. Plain buttons (not TabItem) so there is
|
||||
no underline; LB/RB hint chips flank the pair and the gamepad's
|
||||
@@ -78,7 +84,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
<StackPanel Grid.Column="2" x:Name="LibraryToolbar" Orientation="Horizontal" Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<TextBox x:Name="SearchBox" PlaceholderText="Search library…" Width="240" VerticalAlignment="Center" />
|
||||
<TextBox x:Name="SearchBox" Watermark="Search library…" Width="240" VerticalAlignment="Center" />
|
||||
<Button x:Name="AddFolderButton" Classes="ghost" Content="+ Add folder" VerticalAlignment="Center" />
|
||||
<Button x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan" VerticalAlignment="Center" />
|
||||
<Button x:Name="OpenFileButton" Classes="ghost" Content="Open file…" VerticalAlignment="Center" />
|
||||
@@ -142,7 +148,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="local:GameEntry" x:CompileBindings="True">
|
||||
<DataTemplate>
|
||||
<StackPanel Width="128" Height="172" Spacing="7">
|
||||
<Border Classes="coverShadow" Width="128" Height="128">
|
||||
<Border Classes="coverClip">
|
||||
@@ -267,13 +273,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<local:SettingRow x:Name="LanguageRow" Label="Emulator language"
|
||||
Description="Language used throughout the launcher. Applies immediately.">
|
||||
<ComboBox x:Name="LanguageBox" Width="160"
|
||||
VerticalAlignment="Center" CornerRadius="8">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="local:LanguageInfo">
|
||||
<TextBlock Text="{Binding NativeName}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
VerticalAlignment="Center" CornerRadius="8"
|
||||
DisplayMemberBinding="{Binding NativeName}" />
|
||||
</local:SettingRow>
|
||||
|
||||
<local:SettingRow x:Name="TitleMusicRow" Label="Title music"
|
||||
@@ -399,68 +400,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="GraphicsTabItem" Header="Graphics" FontSize="15">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock x:Name="RenderingSectionTitle" Classes="sectionTitle" Text="RENDERING" />
|
||||
|
||||
<local:SettingRow x:Name="RenderResolutionRow" Label="Internal resolution"
|
||||
Description="Render offscreen targets below native resolution and upscale on present. Lower values trade image quality for GPU headroom; takes effect on next launch.">
|
||||
<ComboBox x:Name="RenderResolutionBox" Width="160" SelectedIndex="0"
|
||||
VerticalAlignment="Center" CornerRadius="8">
|
||||
<ComboBoxItem x:Name="RenderResolution100Item" Content="100% (native)" Tag="1.0" />
|
||||
<ComboBoxItem x:Name="RenderResolution75Item" Content="75%" Tag="0.75" />
|
||||
<ComboBoxItem x:Name="RenderResolution50Item" Content="50%" Tag="0.5" />
|
||||
<ComboBoxItem x:Name="RenderResolution25Item" Content="25%" Tag="0.25" />
|
||||
</ComboBox>
|
||||
</local:SettingRow>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock x:Name="DisplaySectionTitle" Classes="sectionTitle" Text="DISPLAY" />
|
||||
<local:SettingRow x:Name="WindowModeRow" Label="Window mode" Description="Regular window, desktop borderless, or exclusive fullscreen.">
|
||||
<ComboBox x:Name="WindowModeBox" Width="180" SelectedIndex="0" CornerRadius="8">
|
||||
<ComboBoxItem Content="Windowed" />
|
||||
<ComboBoxItem Content="Borderless" />
|
||||
<ComboBoxItem Content="Exclusive" />
|
||||
</ComboBox>
|
||||
</local:SettingRow>
|
||||
<local:SettingRow x:Name="ResolutionRow" Label="Resolution" Description="Initial window size or exclusive fullscreen resolution.">
|
||||
<ComboBox x:Name="ResolutionBox" Width="180" CornerRadius="8" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow x:Name="DisplayRow" Label="Display" Description="Monitor used for centering and fullscreen.">
|
||||
<ComboBox x:Name="DisplayBox" Width="260" CornerRadius="8" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow x:Name="RefreshRateRow" Label="Refresh rate" Description="Exclusive fullscreen refresh rate. Automatic selects the closest mode.">
|
||||
<ComboBox x:Name="RefreshRateBox" Width="180" CornerRadius="8" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow x:Name="ScalingRow" Label="Scaling" Description="Scale the native guest image without changing its internal resolution.">
|
||||
<ComboBox x:Name="ScalingModeBox" Width="180" SelectedIndex="0" CornerRadius="8">
|
||||
<ComboBoxItem Content="Fit" />
|
||||
<ComboBoxItem Content="Cover" />
|
||||
<ComboBoxItem Content="Stretch" />
|
||||
<ComboBoxItem Content="Integer" />
|
||||
</ComboBox>
|
||||
</local:SettingRow>
|
||||
<local:SettingRow x:Name="VSyncRow" Label="VSync" Description="Use FIFO presentation for tear-free output.">
|
||||
<ToggleSwitch x:Name="VSyncToggle" IsChecked="True" OnContent="On" OffContent="Off" />
|
||||
</local:SettingRow>
|
||||
<local:SettingRow x:Name="HdrRow" Label="HDR" Description="Use HDR output when the selected display and graphics backend support it.">
|
||||
<ComboBox x:Name="HdrModeBox" Width="180" SelectedIndex="0" CornerRadius="8">
|
||||
<ComboBoxItem Content="Auto" />
|
||||
<ComboBoxItem Content="On" />
|
||||
<ComboBoxItem Content="Off" />
|
||||
</ComboBox>
|
||||
</local:SettingRow>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="EnvTabItem" Header="Environment" FontSize="15">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
||||
@@ -519,12 +458,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<ToggleSwitch x:Name="EnvLogNpToggle" OnContent="On" OffContent="Off"
|
||||
VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
|
||||
<local:SettingRow x:Name="EnvGuestImageCpuSyncRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_GUEST_IMAGE_CPU_SYNC"
|
||||
Description="Re-upload guest surfaces the game's own CPU code rewrites. Enabled by default for compatibility. Disable only for titles that regress with it, such as GTA V.">
|
||||
<ToggleSwitch x:Name="EnvGuestImageCpuSyncToggle" OnContent="On" OffContent="Off"
|
||||
VerticalAlignment="Center" />
|
||||
</local:SettingRow>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -542,7 +475,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto" Margin="16,12,16,8">
|
||||
<TextBlock x:Name="ConsoleSectionTitle" Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" FontSize="12" Margin="0,0,12,0" x:Name="ConsoleSearchBox"
|
||||
PlaceholderText="Search..." Width="320" />
|
||||
Watermark="Search..." Width="320" />
|
||||
<CheckBox Grid.Column="2" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
|
||||
FontSize="12" Margin="0,0,12,0" />
|
||||
<Button Grid.Column="3" x:Name="DetachConsoleButton" Classes="ghost" Content="Split" FontSize="12"
|
||||
@@ -555,7 +488,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
|
||||
BorderBrush="{StaticResource CardBorderBrush}" CornerRadius="0,0,12,12">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="local:LogLine" x:CompileBindings="True">
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Text}" Foreground="{Binding Brush}" TextWrapping="NoWrap" />
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
@@ -571,9 +504,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<!-- Selected game cover thumbnail -->
|
||||
<Border Grid.Column="0" Classes="coverClip" Width="56" Height="56" CornerRadius="8"
|
||||
VerticalAlignment="Center">
|
||||
<Panel x:Name="SelectedCoverPanel"
|
||||
x:DataType="local:GameEntry"
|
||||
x:CompileBindings="True">
|
||||
<Panel x:Name="SelectedCoverPanel">
|
||||
<Border Background="{Binding PlaceholderBrush, FallbackValue={x:Null}}"
|
||||
IsVisible="{Binding !HasCover, FallbackValue=False}">
|
||||
<TextBlock Text="{Binding Initials}" FontSize="20" FontWeight="Bold"
|
||||
@@ -594,10 +525,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<!-- Title id / version / size badges, right next to the
|
||||
title. The title's own MaxWidth (not a "*" column) is
|
||||
what keeps them from drifting to the far right. -->
|
||||
<StackPanel Grid.Column="1" x:Name="SelectedBadgesRow"
|
||||
x:DataType="local:GameEntry"
|
||||
x:CompileBindings="True"
|
||||
Orientation="Horizontal" Spacing="6"
|
||||
<StackPanel Grid.Column="1" x:Name="SelectedBadgesRow" Orientation="Horizontal" Spacing="6"
|
||||
IsVisible="False" VerticalAlignment="Center">
|
||||
<Border Classes="pill" IsVisible="{Binding HasTitleId, FallbackValue=False}">
|
||||
<TextBlock Text="{Binding TitleId}" FontSize="10" FontWeight="SemiBold"
|
||||
@@ -634,8 +562,51 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Keep launch progress above the blurred library while the SDL game
|
||||
process owns its independent top-level window. -->
|
||||
<!-- Avalonia's regular overlay layer cannot appear over a native child
|
||||
HWND/X11/Metal surface. Keep the running-session controls in a native
|
||||
popup so the game reaches the bottom status bar without losing Stop. -->
|
||||
<primitives:Popup x:Name="SessionBarPopup"
|
||||
IsOpen="False"
|
||||
PlacementTarget="{Binding #GameView}"
|
||||
Placement="Bottom"
|
||||
VerticalOffset="-66"
|
||||
Topmost="True"
|
||||
ShouldUseOverlayLayer="False"
|
||||
TakesFocusFromNativeControl="False"
|
||||
IsLightDismissEnabled="False">
|
||||
<Border Classes="card" Width="598" Height="58" CornerRadius="16" Padding="14,8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock x:Name="SessionGameTitle" Text="GAME RUNNING" FontSize="13" FontWeight="SemiBold"
|
||||
MaxWidth="240" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<Border Classes="badge running" VerticalAlignment="Center">
|
||||
<TextBlock Text="RUNNING" FontSize="9" FontWeight="Bold" LetterSpacing="1"
|
||||
Foreground="{StaticResource SuccessBrush}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<Border x:Name="SessionF11Badge" Classes="badge key" VerticalAlignment="Center">
|
||||
<TextBlock Text="F11" FontSize="9" FontWeight="Bold"
|
||||
Foreground="{StaticResource InfoBrush}" />
|
||||
</Border>
|
||||
<TextBlock x:Name="SessionHintText" Text="Fullscreen" FontSize="11"
|
||||
Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<Button x:Name="SessionConsoleButton" Classes="ghost" Content="≡ Console" />
|
||||
<Button x:Name="SessionStopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</primitives:Popup>
|
||||
|
||||
<!-- This is a native popup rather than an Avalonia overlay because the
|
||||
emulated Vulkan surface is a native child window. -->
|
||||
<!-- Anchored to MainContent, not GameView: the surface host is parked in
|
||||
a 1x1 corner while loading/closing, which would pull a GameView-
|
||||
anchored popup into the corner with it. -->
|
||||
<primitives:Popup x:Name="SessionLoadingPopup"
|
||||
IsOpen="False"
|
||||
PlacementTarget="{Binding #MainContent}"
|
||||
@@ -649,7 +620,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<TextBlock x:Name="SessionLoadingTitle" Text="Loading game" FontSize="16" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="SessionLoadingDetail" Text="Preparing the emulation session..." FontSize="12"
|
||||
Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
|
||||
<ProgressBar x:Name="SessionLoadingProgress" IsIndeterminate="True" Height="5" />
|
||||
<ProgressBar IsIndeterminate="True" Height="5" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</primitives:Popup>
|
||||
|
||||
@@ -5,7 +5,6 @@ using Avalonia;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
@@ -16,7 +15,7 @@ using Avalonia.VisualTree;
|
||||
using SharpEmu.Core.Cpu;
|
||||
using SharpEmu.Core.Runtime;
|
||||
using SharpEmu.HLE.Host;
|
||||
using SharpEmu.Libs.Pad;
|
||||
using SharpEmu.HLE.Host.Windows;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using SharpEmu.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
@@ -62,17 +61,18 @@ public partial class MainWindow : Window
|
||||
private bool _clearLibraryBlurWhenComplete;
|
||||
|
||||
private GuiSettings _settings = new();
|
||||
private IReadOnlyList<HostDisplayOption> _hostDisplays = [];
|
||||
private bool _updatingHostDisplayOptions;
|
||||
private EmulatorProcess? _emulator;
|
||||
private GameSurfaceHost? _gameSurfaceHost;
|
||||
private ConsoleWindow? _consoleWindow;
|
||||
private GuiConsoleMirror? _consoleMirror;
|
||||
private StreamWriter? _fileLog;
|
||||
private readonly SndPreviewPlayer _sndPreview = new();
|
||||
private string? _emulatorExePath;
|
||||
private PendingLaunch? _pendingLaunch;
|
||||
private bool _gameFullscreen;
|
||||
private bool _isRunning;
|
||||
private bool _isStopping;
|
||||
private bool _awaitingFirstFrame;
|
||||
private int _autoScrollTicks;
|
||||
private int _activePageIndex;
|
||||
private Updater.UpdateInfo? _availableUpdate;
|
||||
@@ -113,7 +113,7 @@ public partial class MainWindow : Window
|
||||
string EbootPath,
|
||||
string DisplayName,
|
||||
string? TitleId,
|
||||
EffectiveLaunchSettings Settings,
|
||||
string LogLevel,
|
||||
SharpEmuRuntimeOptions RuntimeOptions);
|
||||
|
||||
public MainWindow()
|
||||
@@ -159,10 +159,12 @@ public partial class MainWindow : Window
|
||||
// follow the launcher into the background or a minimized state.
|
||||
Activated += (_, _) =>
|
||||
{
|
||||
UpdateSessionBarVisibility();
|
||||
SessionLoadingPopup.IsOpen = _sessionLoadingActive;
|
||||
};
|
||||
Deactivated += (_, _) =>
|
||||
{
|
||||
SessionBarPopup.IsOpen = false;
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
};
|
||||
|
||||
@@ -178,6 +180,8 @@ public partial class MainWindow : Window
|
||||
LaunchButton.Click += (_, _) => LaunchSelected();
|
||||
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
|
||||
StopButton.Click += (_, _) => StopEmulator();
|
||||
SessionStopButton.Click += (_, _) => StopEmulator();
|
||||
SessionConsoleButton.Click += (_, _) => ShowConsoleWindow();
|
||||
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
|
||||
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
|
||||
LibraryTabButton.Click += (_, _) => SetActivePage(0);
|
||||
@@ -188,18 +192,6 @@ public partial class MainWindow : Window
|
||||
// it is open already uses the new values.
|
||||
LogLevelBox.SelectionChanged += (_, _) => _settings.LogLevel = SelectedLogLevel();
|
||||
TraceImportsBox.ValueChanged += (_, _) => _settings.ImportTraceLimit = (int)(TraceImportsBox.Value ?? 0);
|
||||
RenderResolutionBox.SelectionChanged += (_, _) =>
|
||||
{
|
||||
if (RenderResolutionBox.SelectedItem is ComboBoxItem { Tag: string tag } &&
|
||||
double.TryParse(
|
||||
tag,
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var scale))
|
||||
{
|
||||
_settings.RenderResolutionScale = scale;
|
||||
}
|
||||
};
|
||||
StrictToggle.IsCheckedChanged += (_, _) => _settings.StrictDynlibResolution = StrictToggle.IsChecked == true;
|
||||
LogToFileToggle.IsCheckedChanged += (_, _) => _settings.LogToFile = LogToFileToggle.IsChecked == true;
|
||||
OverrideLogFileToggle.IsCheckedChanged += (_, _) =>
|
||||
@@ -216,13 +208,6 @@ public partial class MainWindow : Window
|
||||
};
|
||||
AutoUpdateToggle.IsCheckedChanged += (_, _) =>
|
||||
_settings.CheckForUpdatesOnStartup = AutoUpdateToggle.IsChecked == true;
|
||||
WindowModeBox.SelectionChanged += (_, _) => _settings.WindowMode = SelectedComboText(WindowModeBox, "Windowed");
|
||||
DisplayBox.SelectionChanged += (_, _) => OnHostDisplayChanged();
|
||||
ResolutionBox.SelectionChanged += (_, _) => OnHostResolutionChanged();
|
||||
RefreshRateBox.SelectionChanged += (_, _) => OnHostRefreshRateChanged();
|
||||
ScalingModeBox.SelectionChanged += (_, _) => _settings.ScalingMode = SelectedComboText(ScalingModeBox, "Fit");
|
||||
VSyncToggle.IsCheckedChanged += (_, _) => _settings.VSync = VSyncToggle.IsChecked == true;
|
||||
HdrModeBox.SelectionChanged += (_, _) => _settings.HdrMode = SelectedComboText(HdrModeBox, "Auto");
|
||||
UpdateButton.Click += async (_, _) => await OnUpdateButtonAsync();
|
||||
SelectLogFilePathButton.Click += async (_, _) => await SelectLogFilePathAsync();
|
||||
EnvBthidToggle.IsCheckedChanged += (_, _) =>
|
||||
@@ -241,10 +226,6 @@ public partial class MainWindow : Window
|
||||
SetEnvironmentToggle("SHARPEMU_LOG_IO", EnvLogIoToggle.IsChecked == true);
|
||||
EnvLogNpToggle.IsCheckedChanged += (_, _) =>
|
||||
SetEnvironmentToggle("SHARPEMU_LOG_NP", EnvLogNpToggle.IsChecked == true);
|
||||
EnvGuestImageCpuSyncToggle.IsCheckedChanged += (_, _) =>
|
||||
SetEnvironmentToggle(
|
||||
"SHARPEMU_GUEST_IMAGE_CPU_SYNC",
|
||||
EnvGuestImageCpuSyncToggle.IsChecked == true);
|
||||
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
|
||||
|
||||
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
|
||||
@@ -261,7 +242,8 @@ public partial class MainWindow : Window
|
||||
Opened += async (_, _) => await OnOpenedAsync();
|
||||
Closing += (_, _) => OnWindowClosing();
|
||||
|
||||
SdlLauncherGamepad.EnsureStarted();
|
||||
WindowsDualSenseReader.EnsureStarted();
|
||||
WindowsXInputReader.EnsureStarted();
|
||||
_gamepadTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(50),
|
||||
@@ -432,7 +414,8 @@ public partial class MainWindow : Window
|
||||
|
||||
private void PollGamepad()
|
||||
{
|
||||
if (!SdlLauncherGamepad.TryGetState(out var pad))
|
||||
// DualSense wins when both are connected; XInput covers Xbox pads.
|
||||
if (!WindowsDualSenseReader.TryGetState(out var pad) && !WindowsXInputReader.TryGetState(out pad))
|
||||
{
|
||||
_previousPadButtons = HostGamepadButtons.None;
|
||||
return;
|
||||
@@ -448,8 +431,9 @@ public partial class MainWindow : Window
|
||||
|
||||
if (_isRunning || _isStopping)
|
||||
{
|
||||
// The controller belongs to the separate game window while a
|
||||
// session is active; Circle/B must never stop the session.
|
||||
// The game renders inside the launcher window, so the launcher
|
||||
// stays active while playing. The controller belongs to the game
|
||||
// then: no navigation, and Circle/B must never stop the session.
|
||||
_previousPadButtons = pad.Buttons;
|
||||
return;
|
||||
}
|
||||
@@ -595,7 +579,7 @@ public partial class MainWindow : Window
|
||||
|
||||
private void OnLanguageChanged()
|
||||
{
|
||||
if (LanguageBox.SelectedItem is not LanguageInfo language)
|
||||
if (LanguageBox.SelectedItem is not Localization.LanguageInfo language)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -617,7 +601,7 @@ public partial class MainWindow : Window
|
||||
LibraryTabButton.Content = loc.Get("Page.Library");
|
||||
OptionsTabButton.Content = loc.Get("Page.Options");
|
||||
|
||||
SearchBox.PlaceholderText = loc.Get("Library.SearchWatermark");
|
||||
SearchBox.Watermark = loc.Get("Library.SearchWatermark");
|
||||
AddFolderButton.Content = loc.Get("Library.AddFolder");
|
||||
RescanButton.Content = loc.Get("Library.Rescan");
|
||||
OpenFileButton.Content = loc.Get("Library.OpenFile");
|
||||
@@ -688,32 +672,14 @@ public partial class MainWindow : Window
|
||||
AutoUpdateRow.Label = loc.Get("Updater.Auto.Label");
|
||||
AutoUpdateRow.Description = loc.Get("Updater.Auto.Desc");
|
||||
|
||||
GraphicsTabItem.Header = loc.Get("Options.Graphics");
|
||||
DisplaySectionTitle.Text = loc.Get("Options.Section.Display");
|
||||
WindowModeRow.Label = loc.Get("Options.WindowMode.Label");
|
||||
WindowModeRow.Description = loc.Get("Options.WindowMode.Desc");
|
||||
ResolutionRow.Label = loc.Get("Options.Resolution.Label");
|
||||
ResolutionRow.Description = loc.Get("Options.Resolution.Desc");
|
||||
DisplayRow.Label = loc.Get("Options.Display.Label");
|
||||
DisplayRow.Description = loc.Get("Options.Display.Desc");
|
||||
RefreshRateRow.Label = loc.Get("Options.RefreshRate.Label");
|
||||
RefreshRateRow.Description = loc.Get("Options.RefreshRate.Desc");
|
||||
ScalingRow.Label = loc.Get("Options.Scaling.Label");
|
||||
ScalingRow.Description = loc.Get("Options.Scaling.Desc");
|
||||
VSyncRow.Label = loc.Get("Options.VSync.Label");
|
||||
VSyncRow.Description = loc.Get("Options.VSync.Desc");
|
||||
HdrRow.Label = loc.Get("Options.Hdr.Label");
|
||||
HdrRow.Description = loc.Get("Options.Hdr.Desc");
|
||||
RefreshHostRefreshRates(_settings.RefreshRate);
|
||||
|
||||
foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle, AutoUpdateToggle, VSyncToggle })
|
||||
foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle, AutoUpdateToggle })
|
||||
{
|
||||
toggle.OnContent = loc.Get("Common.On");
|
||||
toggle.OffContent = loc.Get("Common.Off");
|
||||
}
|
||||
|
||||
ConsoleSectionTitle.Text = loc.Get("Console.Title");
|
||||
ConsoleSearchBox.PlaceholderText = loc.Get("Console.SearchWatermark");
|
||||
ConsoleSearchBox.Watermark = loc.Get("Console.SearchWatermark");
|
||||
AutoScrollCheck.Content = loc.Get("Console.AutoScroll");
|
||||
DetachConsoleButton.Content = loc.Get("Console.Split");
|
||||
CopyLogButton.Content = loc.Get("Console.Copy");
|
||||
@@ -779,21 +745,91 @@ public partial class MainWindow : Window
|
||||
|
||||
private void OnKeyDown(object sender, KeyEventArgs args)
|
||||
{
|
||||
if (args.Key == Key.F11 && !_isRunning)
|
||||
args.Handled = true;
|
||||
switch (args.Key)
|
||||
{
|
||||
WindowState = WindowState == WindowState.FullScreen
|
||||
? WindowState.Maximized
|
||||
: WindowState.FullScreen;
|
||||
args.Handled = true;
|
||||
case Key.F11:
|
||||
OnWindowFullScreen(this, new RoutedEventArgs());
|
||||
break;
|
||||
default:
|
||||
args.Handled = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPreviewKeyDown(object? sender, KeyEventArgs args)
|
||||
{
|
||||
// The session runs in its own SDL window and takes keyboard focus with
|
||||
// it, so launcher buttons no longer see game input and nothing has to
|
||||
// be swallowed here. Kept as the wired handler because the launcher
|
||||
// still needs a preview hook for its own shortcuts.
|
||||
// While a session is on screen, Enter and Space are game input
|
||||
// (Cross button). Keyboard focus stays on the launcher window, so a
|
||||
// previously clicked, still-focused button (console toggle, session
|
||||
// bar) would also activate and reshape the game view. Swallow the
|
||||
// keys before button activation; the emulator process reads raw key
|
||||
// state and is unaffected. Fullscreen hides those buttons, which is
|
||||
// why this only manifested in windowed sessions.
|
||||
if (_isRunning && GameView.IsVisible &&
|
||||
args.Key is Key.Enter or Key.Space)
|
||||
{
|
||||
args.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnWindowFullScreen(object sender, RoutedEventArgs args)
|
||||
{
|
||||
if (WindowState == WindowState.FullScreen)
|
||||
{
|
||||
// Leaving F11 should restore a monitor-sized window with the
|
||||
// launcher chrome, not fall back to the design-time window size.
|
||||
WindowState = WindowState.Maximized;
|
||||
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome;
|
||||
TitleBar.IsVisible = true;
|
||||
StatusBar.IsVisible = true;
|
||||
if (_gameFullscreen)
|
||||
{
|
||||
_gameFullscreen = false;
|
||||
Grid.SetRow(MainContent, 1);
|
||||
Grid.SetRowSpan(MainContent, 1);
|
||||
MainContent.Margin = _isRunning
|
||||
? new Thickness(0)
|
||||
: new Thickness(32, 24, 32, 20);
|
||||
ContentToolbar.IsVisible = !_isRunning;
|
||||
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
||||
LaunchBar.IsVisible = true;
|
||||
QueueGameSurfaceResize();
|
||||
UpdateSessionBarVisibility();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
WindowState = WindowState.FullScreen;
|
||||
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
|
||||
TitleBar.IsVisible = false;
|
||||
StatusBar.IsVisible = false;
|
||||
if (_isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible)
|
||||
{
|
||||
// The native child receives its new physical Bounds as soon
|
||||
// as this grid spans the monitor. The presenter recreates its
|
||||
// swapchain from that size, rather than stretching 720p.
|
||||
_gameFullscreen = true;
|
||||
// Re-arming restarts the idle countdown, so the cursor also
|
||||
// hides a moment after F11 even without further mouse motion.
|
||||
_gameSurfaceHost?.SetCursorAutoHide(true);
|
||||
Grid.SetRow(MainContent, 0);
|
||||
Grid.SetRowSpan(MainContent, 3);
|
||||
MainContent.Margin = new Thickness(0);
|
||||
ContentToolbar.IsVisible = false;
|
||||
ConsolePanel.IsVisible = false;
|
||||
LaunchBar.IsVisible = false;
|
||||
QueueGameSurfaceResize();
|
||||
UpdateSessionBarVisibility();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueGameSurfaceResize()
|
||||
{
|
||||
Dispatcher.UIThread.Post(
|
||||
() => _gameSurfaceHost?.RefreshSurfaceSize(),
|
||||
DispatcherPriority.Render);
|
||||
}
|
||||
|
||||
private void OnWindowClosing()
|
||||
@@ -802,7 +838,6 @@ public partial class MainWindow : Window
|
||||
_consoleFlushTimer.Stop();
|
||||
_libraryBlurTimer.Stop();
|
||||
_gamepadTimer.Stop();
|
||||
SdlLauncherGamepad.Shutdown();
|
||||
_sndPreview.Stop();
|
||||
_discord?.Dispose();
|
||||
_consoleWindow?.Close();
|
||||
@@ -834,13 +869,6 @@ public partial class MainWindow : Window
|
||||
_ => 2,
|
||||
};
|
||||
TraceImportsBox.Value = Math.Clamp(_settings.ImportTraceLimit, 0, 4096);
|
||||
RenderResolutionBox.SelectedIndex = _settings.RenderResolutionScale switch
|
||||
{
|
||||
>= 0.875 => 0,
|
||||
>= 0.625 => 1,
|
||||
>= 0.375 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
StrictToggle.IsChecked = _settings.StrictDynlibResolution;
|
||||
LogToFileToggle.IsChecked = _settings.LogToFile;
|
||||
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
|
||||
@@ -855,141 +883,9 @@ public partial class MainWindow : Window
|
||||
EnvLogDirectMemoryToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_DIRECT_MEMORY");
|
||||
EnvLogIoToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_IO");
|
||||
EnvLogNpToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_NP");
|
||||
EnvGuestImageCpuSyncToggle.IsChecked =
|
||||
_settings.EnvironmentToggles.Contains("SHARPEMU_GUEST_IMAGE_CPU_SYNC");
|
||||
WindowModeBox.SelectedIndex = ChoiceIndex(_settings.WindowMode, "Windowed", "Borderless", "Exclusive");
|
||||
LoadHostDisplayOptions();
|
||||
ScalingModeBox.SelectedIndex = ChoiceIndex(_settings.ScalingMode, "Fit", "Cover", "Stretch", "Integer");
|
||||
VSyncToggle.IsChecked = _settings.VSync;
|
||||
HdrModeBox.SelectedIndex = ChoiceIndex(_settings.HdrMode, "Auto", "On", "Off");
|
||||
UpdateLogFilePathText();
|
||||
}
|
||||
|
||||
private static string SelectedComboText(ComboBox comboBox, string fallback) =>
|
||||
comboBox.SelectedItem switch
|
||||
{
|
||||
ComboBoxItem item => item.Content?.ToString() ?? fallback,
|
||||
string value => value,
|
||||
_ => fallback,
|
||||
};
|
||||
|
||||
private void LoadHostDisplayOptions()
|
||||
{
|
||||
_updatingHostDisplayOptions = true;
|
||||
try
|
||||
{
|
||||
_hostDisplays = HostDisplayOptions.BuildDisplays(
|
||||
HostDisplayCatalog.Query(),
|
||||
_settings.DisplayIndex);
|
||||
DisplayBox.ItemsSource = _hostDisplays;
|
||||
var display = HostDisplayOptions.SelectDisplay(_hostDisplays, _settings.DisplayIndex);
|
||||
DisplayBox.SelectedItem = display;
|
||||
PopulateHostModes(display, _settings.Resolution, _settings.RefreshRate);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingHostDisplayOptions = false;
|
||||
}
|
||||
|
||||
SyncHostVideoSettings();
|
||||
}
|
||||
|
||||
private void OnHostDisplayChanged()
|
||||
{
|
||||
if (_updatingHostDisplayOptions || DisplayBox.SelectedItem is not HostDisplayOption display)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_updatingHostDisplayOptions = true;
|
||||
try
|
||||
{
|
||||
PopulateHostModes(display, _settings.Resolution, _settings.RefreshRate);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingHostDisplayOptions = false;
|
||||
}
|
||||
|
||||
SyncHostVideoSettings();
|
||||
}
|
||||
|
||||
private void OnHostResolutionChanged()
|
||||
{
|
||||
if (_updatingHostDisplayOptions || DisplayBox.SelectedItem is not HostDisplayOption)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_settings.Resolution = SelectedComboText(ResolutionBox, "1920x1080");
|
||||
RefreshHostRefreshRates(_settings.RefreshRate);
|
||||
OnHostRefreshRateChanged();
|
||||
}
|
||||
|
||||
private void OnHostRefreshRateChanged()
|
||||
{
|
||||
if (!_updatingHostDisplayOptions && RefreshRateBox.SelectedItem is HostRefreshRateOption refreshRate)
|
||||
{
|
||||
_settings.RefreshRate = refreshRate.Value;
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateHostModes(
|
||||
HostDisplayOption display,
|
||||
string selectedResolution,
|
||||
int selectedRefreshRate)
|
||||
{
|
||||
var resolutions = HostDisplayOptions.BuildResolutions(display, selectedResolution);
|
||||
ResolutionBox.ItemsSource = resolutions;
|
||||
ResolutionBox.SelectedItem = resolutions.FirstOrDefault(resolution =>
|
||||
string.Equals(resolution, selectedResolution, StringComparison.OrdinalIgnoreCase)) ?? resolutions[0];
|
||||
RefreshHostRefreshRates(selectedRefreshRate);
|
||||
}
|
||||
|
||||
private void RefreshHostRefreshRates(int selectedRefreshRate)
|
||||
{
|
||||
if (DisplayBox.SelectedItem is not HostDisplayOption display)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var wasUpdating = _updatingHostDisplayOptions;
|
||||
_updatingHostDisplayOptions = true;
|
||||
try
|
||||
{
|
||||
var rates = HostDisplayOptions.BuildRefreshRates(
|
||||
display,
|
||||
SelectedComboText(ResolutionBox, _settings.Resolution),
|
||||
selectedRefreshRate,
|
||||
Localization.Instance.Get("Options.RefreshRate.Automatic"));
|
||||
RefreshRateBox.ItemsSource = rates;
|
||||
RefreshRateBox.SelectedItem = rates.FirstOrDefault(rate => rate.Value == selectedRefreshRate) ?? rates[0];
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingHostDisplayOptions = wasUpdating;
|
||||
}
|
||||
}
|
||||
|
||||
private void SyncHostVideoSettings()
|
||||
{
|
||||
if (DisplayBox.SelectedItem is HostDisplayOption display)
|
||||
{
|
||||
_settings.DisplayIndex = display.Index;
|
||||
}
|
||||
|
||||
_settings.Resolution = SelectedComboText(ResolutionBox, "1920x1080");
|
||||
_settings.RefreshRate = RefreshRateBox.SelectedItem is HostRefreshRateOption refreshRate
|
||||
? refreshRate.Value
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static int ChoiceIndex(string value, params string[] choices)
|
||||
{
|
||||
var index = Array.FindIndex(choices, choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase));
|
||||
return index < 0 ? 0 : index;
|
||||
}
|
||||
|
||||
private async Task OnUpdateButtonAsync()
|
||||
{
|
||||
if (_availableUpdate is null)
|
||||
@@ -1879,32 +1775,19 @@ public partial class MainWindow : Window
|
||||
// launcher process so every platform receives the same launch options.
|
||||
foreach (var staleName in _appliedEnvironmentVariables)
|
||||
{
|
||||
if (!effective.EnvironmentToggles.Any(entry =>
|
||||
TryParseEnvironmentEntry(entry, out var name, out _) &&
|
||||
string.Equals(name, staleName, StringComparison.OrdinalIgnoreCase)))
|
||||
if (!effective.EnvironmentToggles.Contains(staleName))
|
||||
{
|
||||
Environment.SetEnvironmentVariable(staleName, null);
|
||||
}
|
||||
}
|
||||
|
||||
_appliedEnvironmentVariables.Clear();
|
||||
foreach (var entry in effective.EnvironmentToggles)
|
||||
foreach (var name in effective.EnvironmentToggles)
|
||||
{
|
||||
if (!TryParseEnvironmentEntry(entry, out var name, out var value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
Environment.SetEnvironmentVariable(name, "1");
|
||||
_appliedEnvironmentVariables.Add(name);
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable(
|
||||
"SHARPEMU_RENDER_SCALE",
|
||||
_settings.RenderResolutionScale.ToString(
|
||||
"0.###",
|
||||
System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
if (SharpEmuLog.TryParseLevel(effective.LogLevel, out var logLevel))
|
||||
{
|
||||
SharpEmuLog.MinimumLevel = logLevel;
|
||||
@@ -1919,6 +1802,7 @@ public partial class MainWindow : Window
|
||||
|
||||
_isRunning = true;
|
||||
_runningGameName = displayName;
|
||||
SessionGameTitle.Text = displayName;
|
||||
_runningGameTitleId = resolvedTitleId;
|
||||
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
StatusDot.Fill = SuccessLineBrush;
|
||||
@@ -1927,25 +1811,18 @@ public partial class MainWindow : Window
|
||||
UpdateRunButtons();
|
||||
UpdateDiscordPresence();
|
||||
|
||||
BeginSessionUi();
|
||||
ShowGameView();
|
||||
_pendingLaunch = new PendingLaunch(
|
||||
Path.GetFullPath(ebootPath),
|
||||
displayName,
|
||||
_runningGameTitleId,
|
||||
effective,
|
||||
effective.LogLevel,
|
||||
runtimeOptions);
|
||||
|
||||
StartPendingSession();
|
||||
}
|
||||
|
||||
private static bool TryParseEnvironmentEntry(string entry, out string name, out string value)
|
||||
{
|
||||
var separator = entry.IndexOf('=');
|
||||
name = separator >= 0 ? entry[..separator] : entry;
|
||||
value = separator >= 0 ? entry[(separator + 1)..] : "1";
|
||||
return name.StartsWith("SHARPEMU_", StringComparison.OrdinalIgnoreCase) &&
|
||||
name.Length > "SHARPEMU_".Length &&
|
||||
value.Length != 0;
|
||||
if (_gameSurfaceHost?.Surface is { } surface)
|
||||
{
|
||||
StartPendingSession(surface);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1974,6 +1851,9 @@ public partial class MainWindow : Window
|
||||
|
||||
_isStopping = true;
|
||||
StopButton.IsEnabled = false;
|
||||
SessionStopButton.IsEnabled = false;
|
||||
SessionHintText.Text = Localization.Instance.Get("Launch.Stopping");
|
||||
SessionF11Badge.IsVisible = false;
|
||||
ShowSessionLoading("Closing game", "Waiting for the emulation session to exit...");
|
||||
_emulator.Stop();
|
||||
_runningGameName = null;
|
||||
@@ -1981,6 +1861,7 @@ public partial class MainWindow : Window
|
||||
StatusText.Text = Localization.Instance.Get("Launch.Stopping");
|
||||
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
|
||||
UpdateDiscordPresence();
|
||||
UpdateSessionBarVisibility();
|
||||
ReturnToLibraryWhileStopping();
|
||||
}
|
||||
|
||||
@@ -2023,7 +1904,8 @@ public partial class MainWindow : Window
|
||||
_emulator?.Dispose();
|
||||
_emulator = null;
|
||||
_pendingLaunch = null;
|
||||
EndSessionUi();
|
||||
DisposeGameSurfaceHost();
|
||||
HideGameView();
|
||||
|
||||
var meaningKey = exitCode switch
|
||||
{
|
||||
@@ -2056,7 +1938,7 @@ public partial class MainWindow : Window
|
||||
UpdateDiscordPresence();
|
||||
}
|
||||
|
||||
private void StartPendingSession()
|
||||
private void StartPendingSession(VulkanHostSurface surface)
|
||||
{
|
||||
if (_pendingLaunch is not { } launch || _emulator is not null)
|
||||
{
|
||||
@@ -2076,7 +1958,7 @@ public partial class MainWindow : Window
|
||||
|
||||
try
|
||||
{
|
||||
var arguments = BuildEmulatorArguments(launch);
|
||||
var arguments = BuildEmulatorArguments(launch, surface);
|
||||
_emulator = process;
|
||||
_pendingLaunch = null;
|
||||
process.Start(
|
||||
@@ -2098,12 +1980,12 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> BuildEmulatorArguments(PendingLaunch launch)
|
||||
private List<string> BuildEmulatorArguments(PendingLaunch launch, VulkanHostSurface surface)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"--cpu-engine=native",
|
||||
$"--log-level={launch.Settings.LogLevel}",
|
||||
$"--log-level={launch.LogLevel}",
|
||||
};
|
||||
if (launch.RuntimeOptions.StrictDynlibResolution)
|
||||
{
|
||||
@@ -2114,13 +1996,16 @@ public partial class MainWindow : Window
|
||||
arguments.Add($"--trace-imports={launch.RuntimeOptions.ImportTraceLimit}");
|
||||
}
|
||||
|
||||
arguments.Add($"--window-mode={launch.Settings.WindowMode.ToLowerInvariant()}");
|
||||
arguments.Add($"--resolution={launch.Settings.Resolution}");
|
||||
arguments.Add($"--display={launch.Settings.DisplayIndex}");
|
||||
arguments.Add($"--refresh-rate={launch.Settings.RefreshRate}");
|
||||
arguments.Add($"--scaling={launch.Settings.ScalingMode.ToLowerInvariant()}");
|
||||
arguments.Add($"--vsync={(launch.Settings.VSync ? "on" : "off")}");
|
||||
arguments.Add($"--hdr={launch.Settings.HdrMode.ToLowerInvariant()}");
|
||||
if (surface.TryGetChildProcessDescriptor(out var descriptor))
|
||||
{
|
||||
arguments.Add($"--host-surface={descriptor}");
|
||||
}
|
||||
else
|
||||
{
|
||||
AppendConsoleLine(
|
||||
"[GUI][WARN] Embedded child surfaces are unavailable on this platform; opening a game window instead.",
|
||||
WarningLineBrush);
|
||||
}
|
||||
|
||||
arguments.Add(launch.EbootPath);
|
||||
return arguments;
|
||||
@@ -2129,8 +2014,8 @@ public partial class MainWindow : Window
|
||||
private void OnEmulatorOutput(string line, bool isError)
|
||||
{
|
||||
_pendingLines.Enqueue((line, isError));
|
||||
if (!line.Contains("Vulkan VideoOut presented first frame:", StringComparison.Ordinal) &&
|
||||
!line.Contains("Vulkan VideoOut ready:", StringComparison.Ordinal))
|
||||
if (!line.Contains("[VIDEOOUT][INFO] Hosted splash ready.", StringComparison.Ordinal) &&
|
||||
!line.Contains("[VIDEOOUT][INFO] Hosted first frame presented.", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -2139,25 +2024,132 @@ public partial class MainWindow : Window
|
||||
{
|
||||
if (_isRunning && !_isStopping)
|
||||
{
|
||||
ShowSessionStatus("Game is running");
|
||||
_awaitingFirstFrame = false;
|
||||
ClearLibraryBlur();
|
||||
MainContent.Margin = new Thickness(0);
|
||||
RestoreGameViewToFull();
|
||||
GameView.Background = Brushes.Black;
|
||||
GameView.IsHitTestVisible = true;
|
||||
_gameSurfaceHost?.SetPresentationVisible(true);
|
||||
_gameSurfaceHost?.SetCursorAutoHide(true);
|
||||
LibraryPage.IsVisible = false;
|
||||
OptionsPage.IsVisible = false;
|
||||
LibraryToolbar.IsVisible = false;
|
||||
ContentToolbar.IsVisible = false;
|
||||
ConsolePanel.IsVisible = false;
|
||||
LaunchBar.IsVisible = false;
|
||||
HideSessionLoading();
|
||||
UpdateSessionBarVisibility();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void BeginSessionUi()
|
||||
private GameSurfaceHost EnsureGameSurfaceHost()
|
||||
{
|
||||
_isStopping = false;
|
||||
AnimateLibraryBlur(LaunchBlurRadius);
|
||||
ShowSessionLoading("Loading game", "Preparing the emulation session...");
|
||||
LaunchBar.IsVisible = true;
|
||||
if (_gameSurfaceHost is not null)
|
||||
{
|
||||
return _gameSurfaceHost;
|
||||
}
|
||||
|
||||
var host = new GameSurfaceHost();
|
||||
// Configure this before attaching it to Avalonia so its first native
|
||||
// HWND is hidden while the child process starts.
|
||||
host.SetPresentationVisible(false);
|
||||
host.SurfaceAvailable += (_, surface) =>
|
||||
{
|
||||
if (ReferenceEquals(_gameSurfaceHost, host))
|
||||
{
|
||||
StartPendingSession(surface);
|
||||
}
|
||||
};
|
||||
host.SurfaceDestroyed += (_, surface) => OnGameSurfaceDestroyed(host, surface);
|
||||
_gameSurfaceHost = host;
|
||||
GameSurfaceContainer.Children.Add(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
private void EndSessionUi()
|
||||
private void DisposeGameSurfaceHost()
|
||||
{
|
||||
var host = _gameSurfaceHost;
|
||||
if (host is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_gameSurfaceHost = null;
|
||||
host.SetPresentationVisible(false);
|
||||
GameSurfaceContainer.Children.Remove(host);
|
||||
}
|
||||
|
||||
private void OnGameSurfaceDestroyed(GameSurfaceHost host, VulkanHostSurface surface)
|
||||
{
|
||||
if (ReferenceEquals(_gameSurfaceHost, host) && _isRunning)
|
||||
{
|
||||
StopEmulator();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The native host attachment is a real child window: it sits above every
|
||||
/// Avalonia control it covers and swallows their mouse input regardless of
|
||||
/// hit-test settings. While the library must stay interactive (loading,
|
||||
/// closing), the surface is parked offscreen AT FULL SIZE via a negative
|
||||
/// margin. It must not be shrunk instead: the emulator child polls the
|
||||
/// HWND client size and its presenter defers swapchain creation while the
|
||||
/// surface is 1px, which would deadlock the loading handshake.
|
||||
/// </summary>
|
||||
private void ParkGameViewOffscreen()
|
||||
{
|
||||
GameView.Margin = new Thickness(-20000, 0, 20000, 0);
|
||||
}
|
||||
|
||||
private void RestoreGameViewToFull()
|
||||
{
|
||||
GameView.Margin = new Thickness(0);
|
||||
}
|
||||
|
||||
private void ShowGameView()
|
||||
{
|
||||
_isStopping = false;
|
||||
_awaitingFirstFrame = true;
|
||||
var host = EnsureGameSurfaceHost();
|
||||
ParkGameViewOffscreen();
|
||||
GameView.IsVisible = true;
|
||||
GameView.Background = Brushes.Transparent;
|
||||
GameView.IsHitTestVisible = false;
|
||||
host.SetPresentationVisible(false);
|
||||
AnimateLibraryBlur(LaunchBlurRadius);
|
||||
SessionHintText.Text = "Fullscreen";
|
||||
SessionF11Badge.IsVisible = true;
|
||||
UpdateSessionBarVisibility();
|
||||
ShowSessionLoading("Loading game", "Preparing the emulation session...");
|
||||
}
|
||||
|
||||
private void HideGameView()
|
||||
{
|
||||
if (_gameFullscreen && WindowState == WindowState.FullScreen)
|
||||
{
|
||||
OnWindowFullScreen(this, new RoutedEventArgs());
|
||||
}
|
||||
|
||||
_gameSurfaceHost?.SetCursorAutoHide(false);
|
||||
_gameSurfaceHost?.SetPresentationVisible(false);
|
||||
_awaitingFirstFrame = false;
|
||||
GameView.IsVisible = false;
|
||||
GameView.IsHitTestVisible = true;
|
||||
SessionBarPopup.IsOpen = false;
|
||||
HideSessionLoading();
|
||||
AnimateLibraryBlur(0, clearWhenComplete: true);
|
||||
LaunchBar.IsVisible = true;
|
||||
MainContent.Margin = new Thickness(32, 24, 32, 20);
|
||||
ContentToolbar.IsVisible = true;
|
||||
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
||||
LaunchBar.IsVisible = true;
|
||||
LibraryPage.IsVisible = _activePageIndex == 0;
|
||||
LibraryToolbar.IsVisible = _activePageIndex == 0;
|
||||
OptionsPage.IsVisible = _activePageIndex == 1;
|
||||
// Game art when the source still holds it, otherwise the bundled
|
||||
// default; a bare color only when neither is available.
|
||||
BackdropImage.Opacity = BackdropImage.Source is not null ? 1 : 0;
|
||||
}
|
||||
|
||||
private void AnimateLibraryBlur(double targetRadius, bool clearWhenComplete = false)
|
||||
@@ -2229,20 +2221,7 @@ public partial class MainWindow : Window
|
||||
private void ShowSessionLoading(string title, string detail)
|
||||
{
|
||||
SessionLoadingTitle.Text = title;
|
||||
SessionLoadingTitle.IsVisible = true;
|
||||
SessionLoadingDetail.Text = detail;
|
||||
SessionLoadingDetail.IsVisible = true;
|
||||
SessionLoadingProgress.IsVisible = true;
|
||||
_sessionLoadingActive = true;
|
||||
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
|
||||
}
|
||||
|
||||
private void ShowSessionStatus(string message)
|
||||
{
|
||||
SessionLoadingTitle.Text = message;
|
||||
SessionLoadingTitle.IsVisible = true;
|
||||
SessionLoadingDetail.IsVisible = false;
|
||||
SessionLoadingProgress.IsVisible = false;
|
||||
_sessionLoadingActive = true;
|
||||
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
|
||||
}
|
||||
@@ -2255,11 +2234,33 @@ public partial class MainWindow : Window
|
||||
|
||||
private void ReturnToLibraryWhileStopping()
|
||||
{
|
||||
if (_gameFullscreen && WindowState == WindowState.FullScreen)
|
||||
{
|
||||
OnWindowFullScreen(this, new RoutedEventArgs());
|
||||
}
|
||||
|
||||
// Keep the native child alive until the session exits, but hide it
|
||||
// immediately. Destroying it while Vulkan still owns the surface can
|
||||
// crash the GUI; parking it in the 1x1 corner lets the library
|
||||
// recover — and stay clickable — while the native closing popup
|
||||
// reports teardown progress.
|
||||
_gameSurfaceHost?.SetPresentationVisible(false);
|
||||
_awaitingFirstFrame = false;
|
||||
ParkGameViewOffscreen();
|
||||
GameView.Background = Brushes.Transparent;
|
||||
GameView.IsHitTestVisible = false;
|
||||
SessionBarPopup.IsOpen = false;
|
||||
AnimateLibraryBlur(LaunchBlurRadius);
|
||||
MainContent.Margin = new Thickness(32, 24, 32, 20);
|
||||
ContentToolbar.IsVisible = true;
|
||||
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
||||
LaunchBar.IsVisible = true;
|
||||
LibraryPage.IsVisible = _activePageIndex == 0;
|
||||
LibraryToolbar.IsVisible = _activePageIndex == 0;
|
||||
OptionsPage.IsVisible = _activePageIndex == 1;
|
||||
BackdropImage.Opacity = BackdropImage.Source is not null ? 1 : 0;
|
||||
UpdateRunButtons();
|
||||
Console.Error.WriteLine("[GUI][INFO] Waiting for the SDL game process to exit.");
|
||||
Console.Error.WriteLine("[GUI][INFO] Library restored while embedded session is closing.");
|
||||
}
|
||||
|
||||
private void OpenFileLog(string? titleId)
|
||||
@@ -2318,9 +2319,16 @@ public partial class MainWindow : Window
|
||||
{
|
||||
LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry;
|
||||
StopButton.IsEnabled = _isRunning && !_isStopping;
|
||||
SessionStopButton.IsEnabled = _isRunning && !_isStopping;
|
||||
OpenFileButton.IsEnabled = !_isRunning;
|
||||
}
|
||||
|
||||
private void UpdateSessionBarVisibility()
|
||||
{
|
||||
SessionBarPopup.IsOpen = _isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible &&
|
||||
!_gameFullscreen && WindowState != WindowState.FullScreen;
|
||||
}
|
||||
|
||||
// ---- Console ----
|
||||
|
||||
private void FlushPendingConsoleLines()
|
||||
|
||||
@@ -21,20 +21,6 @@ public sealed class PerGameSettings
|
||||
|
||||
public bool? LogToFile { get; set; }
|
||||
|
||||
public string? WindowMode { get; set; }
|
||||
|
||||
public string? Resolution { get; set; }
|
||||
|
||||
public int? DisplayIndex { get; set; }
|
||||
|
||||
public int? RefreshRate { get; set; }
|
||||
|
||||
public string? ScalingMode { get; set; }
|
||||
|
||||
public bool? VSync { get; set; }
|
||||
|
||||
public string? HdrMode { get; set; }
|
||||
|
||||
public List<string>? EnvironmentToggles { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
@@ -43,13 +29,6 @@ public sealed class PerGameSettings
|
||||
ImportTraceLimit is null &&
|
||||
StrictDynlibResolution is null &&
|
||||
LogToFile is null &&
|
||||
WindowMode is null &&
|
||||
Resolution is null &&
|
||||
DisplayIndex is null &&
|
||||
RefreshRate is null &&
|
||||
ScalingMode is null &&
|
||||
VSync is null &&
|
||||
HdrMode is null &&
|
||||
EnvironmentToggles is null;
|
||||
|
||||
public static string DirectoryPath =>
|
||||
@@ -70,7 +49,7 @@ public sealed class PerGameSettings
|
||||
var path = PathFor(titleId);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return NormalizeFromJson(File.ReadAllText(path));
|
||||
return JsonSerializer.Deserialize<PerGameSettings>(File.ReadAllText(path), SerializerOptions);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -80,18 +59,6 @@ public sealed class PerGameSettings
|
||||
return null;
|
||||
}
|
||||
|
||||
// A null list inherits global settings; only entries in a present list are sanitized.
|
||||
internal static PerGameSettings? NormalizeFromJson(string json)
|
||||
{
|
||||
var settings = JsonSerializer.Deserialize<PerGameSettings>(json, SerializerOptions);
|
||||
if (settings?.EnvironmentToggles is { } toggles)
|
||||
{
|
||||
settings.EnvironmentToggles = toggles.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
public void Save(string titleId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(titleId))
|
||||
@@ -137,13 +104,6 @@ public sealed record EffectiveLaunchSettings(
|
||||
int ImportTraceLimit,
|
||||
bool StrictDynlibResolution,
|
||||
bool LogToFile,
|
||||
string WindowMode,
|
||||
string Resolution,
|
||||
int DisplayIndex,
|
||||
int RefreshRate,
|
||||
string ScalingMode,
|
||||
bool VSync,
|
||||
string HdrMode,
|
||||
IReadOnlyList<string> EnvironmentToggles)
|
||||
{
|
||||
public static EffectiveLaunchSettings Resolve(GuiSettings global, PerGameSettings? perGame) => new(
|
||||
@@ -151,12 +111,5 @@ public sealed record EffectiveLaunchSettings(
|
||||
perGame?.ImportTraceLimit ?? global.ImportTraceLimit,
|
||||
perGame?.StrictDynlibResolution ?? global.StrictDynlibResolution,
|
||||
perGame?.LogToFile ?? global.LogToFile,
|
||||
perGame?.WindowMode ?? global.WindowMode,
|
||||
perGame?.Resolution ?? global.Resolution,
|
||||
Math.Max(0, perGame?.DisplayIndex ?? global.DisplayIndex),
|
||||
Math.Clamp(perGame?.RefreshRate ?? global.RefreshRate, 0, 1000),
|
||||
perGame?.ScalingMode ?? global.ScalingMode,
|
||||
perGame?.VSync ?? global.VSync,
|
||||
perGame?.HdrMode ?? global.HdrMode,
|
||||
perGame?.EnvironmentToggles ?? global.EnvironmentToggles);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
@@ -13,9 +12,6 @@ public sealed class PerGameSettingsDialog : Window
|
||||
{
|
||||
private static readonly string[] LogLevels =
|
||||
{ "Trace", "Debug", "Info", "Warning", "Error", "Critical" };
|
||||
private static readonly string[] WindowModes = { "Windowed", "Borderless", "Exclusive" };
|
||||
private static readonly string[] ScalingModes = { "Fit", "Cover", "Stretch", "Integer" };
|
||||
private static readonly string[] HdrModes = { "Auto", "On", "Off" };
|
||||
|
||||
private static readonly string[] EnvToggles =
|
||||
{
|
||||
@@ -27,12 +23,9 @@ public sealed class PerGameSettingsDialog : Window
|
||||
"SHARPEMU_LOG_DIRECT_MEMORY",
|
||||
"SHARPEMU_LOG_IO",
|
||||
"SHARPEMU_LOG_NP",
|
||||
"SHARPEMU_GUEST_IMAGE_CPU_SYNC",
|
||||
};
|
||||
|
||||
private readonly string _titleId;
|
||||
private IReadOnlyList<HostDisplayOption> _hostDisplays = [];
|
||||
private bool _updatingHostDisplayOptions;
|
||||
|
||||
private readonly SettingRow _logLevelRow;
|
||||
private readonly ComboBox _logLevel = new() { ItemsSource = LogLevels, Width = 160 };
|
||||
@@ -49,27 +42,6 @@ public sealed class PerGameSettingsDialog : Window
|
||||
private readonly SettingRow _logToFileRow;
|
||||
private readonly ToggleSwitch _logToFile = new();
|
||||
|
||||
private readonly SettingRow _windowModeRow;
|
||||
private readonly ComboBox _windowMode = new() { ItemsSource = WindowModes, Width = 160 };
|
||||
|
||||
private readonly SettingRow _resolutionRow;
|
||||
private readonly ComboBox _resolution = new() { Width = 160 };
|
||||
|
||||
private readonly SettingRow _displayIndexRow;
|
||||
private readonly ComboBox _displayIndex = new() { Width = 240 };
|
||||
|
||||
private readonly SettingRow _refreshRateRow;
|
||||
private readonly ComboBox _refreshRate = new() { Width = 160 };
|
||||
|
||||
private readonly SettingRow _scalingModeRow;
|
||||
private readonly ComboBox _scalingMode = new() { ItemsSource = ScalingModes, Width = 160 };
|
||||
|
||||
private readonly SettingRow _vsyncRow;
|
||||
private readonly ToggleSwitch _vsync = new();
|
||||
|
||||
private readonly SettingRow _hdrModeRow;
|
||||
private readonly ComboBox _hdrMode = new() { ItemsSource = HdrModes, Width = 160 };
|
||||
|
||||
private readonly SettingRow _envRow;
|
||||
private readonly StackPanel _envList = new() { Orientation = Orientation.Vertical, Spacing = 8, Margin = new(0, 4, 0, 0) };
|
||||
private readonly List<(string Name, ToggleSwitch Box)> _envBoxes = new();
|
||||
@@ -88,20 +60,13 @@ public sealed class PerGameSettingsDialog : Window
|
||||
|
||||
Background = new SolidColorBrush(Color.Parse("#0D1017"));
|
||||
|
||||
_strict.OnContent = _logToFile.OnContent = _vsync.OnContent = loc.Get("Common.On");
|
||||
_strict.OffContent = _logToFile.OffContent = _vsync.OffContent = loc.Get("Common.Off");
|
||||
_strict.OnContent = _logToFile.OnContent = loc.Get("Common.On");
|
||||
_strict.OffContent = _logToFile.OffContent = loc.Get("Common.Off");
|
||||
|
||||
_logLevelRow = Row(loc.Get("Options.LogLevel.Label"), loc.Get("Options.LogLevel.Desc"), _logLevel);
|
||||
_traceRow = Row(loc.Get("Options.TraceImports.Label"), loc.Get("Options.TraceImports.Desc"), _trace);
|
||||
_strictRow = Row(loc.Get("Options.Strict.Label"), loc.Get("Options.Strict.Desc"), _strict);
|
||||
_logToFileRow = Row(loc.Get("Options.LogToFile.Label"), loc.Get("Options.LogToFile.Desc"), _logToFile);
|
||||
_windowModeRow = Row(loc.Get("Options.WindowMode.Label"), loc.Get("Options.WindowMode.Desc"), _windowMode);
|
||||
_resolutionRow = Row(loc.Get("Options.Resolution.Label"), loc.Get("Options.Resolution.Desc"), _resolution);
|
||||
_displayIndexRow = Row(loc.Get("Options.Display.Label"), loc.Get("Options.Display.Desc"), _displayIndex);
|
||||
_refreshRateRow = Row(loc.Get("Options.RefreshRate.Label"), loc.Get("Options.RefreshRate.Desc"), _refreshRate);
|
||||
_scalingModeRow = Row(loc.Get("Options.Scaling.Label"), loc.Get("Options.Scaling.Desc"), _scalingMode);
|
||||
_vsyncRow = Row(loc.Get("Options.VSync.Label"), loc.Get("Options.VSync.Desc"), _vsync);
|
||||
_hdrModeRow = Row(loc.Get("Options.Hdr.Label"), loc.Get("Options.Hdr.Desc"), _hdrMode);
|
||||
_envRow = new SettingRow
|
||||
{
|
||||
Label = loc.Get("PerGame.EnvToggles.Label"),
|
||||
@@ -116,22 +81,6 @@ public sealed class PerGameSettingsDialog : Window
|
||||
_envList.Children.Add(box);
|
||||
}
|
||||
|
||||
var general = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(0, 12, 0, 0) };
|
||||
general.Children.Add(Card(loc.Get("Options.Section.Emulation"), _strictRow));
|
||||
general.Children.Add(Card(loc.Get("Options.Section.Logging"), _logLevelRow, _traceRow, _logToFileRow));
|
||||
general.Children.Add(Card(loc.Get("Options.Section.Environment"), _envRow, _envList));
|
||||
|
||||
var graphics = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(0, 12, 0, 0) };
|
||||
graphics.Children.Add(Card(
|
||||
loc.Get("Options.Section.Display"),
|
||||
_windowModeRow,
|
||||
_resolutionRow,
|
||||
_displayIndexRow,
|
||||
_refreshRateRow,
|
||||
_scalingModeRow,
|
||||
_vsyncRow,
|
||||
_hdrModeRow));
|
||||
|
||||
var content = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(16) };
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
@@ -139,14 +88,9 @@ public sealed class PerGameSettingsDialog : Window
|
||||
Foreground = new SolidColorBrush(Color.Parse("#8B94A7")),
|
||||
FontSize = 12,
|
||||
});
|
||||
content.Children.Add(new TabControl
|
||||
{
|
||||
ItemsSource = new[]
|
||||
{
|
||||
new TabItem { Header = loc.Get("PerGame.Tab.General"), Content = general },
|
||||
new TabItem { Header = loc.Get("PerGame.Tab.Graphics"), Content = graphics },
|
||||
},
|
||||
});
|
||||
content.Children.Add(Card(loc.Get("Options.Section.Emulation"), _strictRow));
|
||||
content.Children.Add(Card(loc.Get("Options.Section.Logging"), _logLevelRow, _traceRow, _logToFileRow));
|
||||
content.Children.Add(Card(loc.Get("Options.Section.Environment"), _envRow, _envList));
|
||||
|
||||
var save = new Button { Content = loc.Get("Common.Save"), Classes = { "accent" } };
|
||||
var cancel = new Button { Content = loc.Get("Common.Cancel"), Classes = { "ghost" } };
|
||||
@@ -175,8 +119,6 @@ public sealed class PerGameSettingsDialog : Window
|
||||
root.Children.Add(buttonBar);
|
||||
Content = root;
|
||||
|
||||
_displayIndex.SelectionChanged += (_, _) => OnHostDisplayChanged();
|
||||
_resolution.SelectionChanged += (_, _) => OnHostResolutionChanged();
|
||||
LoadValues(global);
|
||||
_envRow.PropertyChanged += (_, e) =>
|
||||
{
|
||||
@@ -212,38 +154,16 @@ public sealed class PerGameSettingsDialog : Window
|
||||
|
||||
private void LoadValues(GuiSettings global)
|
||||
{
|
||||
var existing = PerGameSettings.Load(_titleId);
|
||||
var displayIndex = Math.Max(0, existing?.DisplayIndex ?? global.DisplayIndex);
|
||||
var resolution = existing?.Resolution ?? global.Resolution;
|
||||
var refreshRate = Math.Clamp(existing?.RefreshRate ?? global.RefreshRate, 0, 1000);
|
||||
|
||||
_updatingHostDisplayOptions = true;
|
||||
try
|
||||
{
|
||||
_hostDisplays = HostDisplayOptions.BuildDisplays(HostDisplayCatalog.Query(), displayIndex);
|
||||
_displayIndex.ItemsSource = _hostDisplays;
|
||||
var display = HostDisplayOptions.SelectDisplay(_hostDisplays, displayIndex);
|
||||
_displayIndex.SelectedItem = display;
|
||||
PopulateHostModes(display, resolution, refreshRate);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingHostDisplayOptions = false;
|
||||
}
|
||||
|
||||
_logLevel.SelectedItem = Array.IndexOf(LogLevels, global.LogLevel) >= 0 ? global.LogLevel : "Info";
|
||||
_trace.Value = global.ImportTraceLimit;
|
||||
_strict.IsChecked = global.StrictDynlibResolution;
|
||||
_logToFile.IsChecked = global.LogToFile;
|
||||
_windowMode.SelectedItem = ChoiceOrDefault(WindowModes, global.WindowMode, "Windowed");
|
||||
_scalingMode.SelectedItem = ChoiceOrDefault(ScalingModes, global.ScalingMode, "Fit");
|
||||
_vsync.IsChecked = global.VSync;
|
||||
_hdrMode.SelectedItem = ChoiceOrDefault(HdrModes, global.HdrMode, "Auto");
|
||||
foreach (var (name, box) in _envBoxes)
|
||||
{
|
||||
box.IsChecked = IsEnvironmentEnabled(global.EnvironmentToggles, name, defaultValue: false);
|
||||
box.IsChecked = global.EnvironmentToggles.Contains(name);
|
||||
}
|
||||
|
||||
var existing = PerGameSettings.Load(_titleId);
|
||||
if (existing is null)
|
||||
{
|
||||
return;
|
||||
@@ -258,120 +178,16 @@ public sealed class PerGameSettingsDialog : Window
|
||||
if (existing.ImportTraceLimit is { } t) { _traceRow.IsOverridden = true; _trace.Value = t; }
|
||||
if (existing.StrictDynlibResolution is { } s) { _strictRow.IsOverridden = true; _strict.IsChecked = s; }
|
||||
if (existing.LogToFile is { } l) { _logToFileRow.IsOverridden = true; _logToFile.IsChecked = l; }
|
||||
if (existing.WindowMode is { } windowMode && WindowModes.Contains(windowMode, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_windowModeRow.IsOverridden = true;
|
||||
_windowMode.SelectedItem = ChoiceOrDefault(WindowModes, windowMode, "Windowed");
|
||||
}
|
||||
if (existing.Resolution is not null)
|
||||
{
|
||||
_resolutionRow.IsOverridden = true;
|
||||
}
|
||||
if (existing.DisplayIndex is not null)
|
||||
{
|
||||
_displayIndexRow.IsOverridden = true;
|
||||
}
|
||||
if (existing.RefreshRate is not null)
|
||||
{
|
||||
_refreshRateRow.IsOverridden = true;
|
||||
}
|
||||
if (existing.ScalingMode is { } scalingMode && ScalingModes.Contains(scalingMode, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_scalingModeRow.IsOverridden = true;
|
||||
_scalingMode.SelectedItem = ChoiceOrDefault(ScalingModes, scalingMode, "Fit");
|
||||
}
|
||||
if (existing.VSync is { } vsync)
|
||||
{
|
||||
_vsyncRow.IsOverridden = true;
|
||||
_vsync.IsChecked = vsync;
|
||||
}
|
||||
if (existing.HdrMode is { } hdrMode && HdrModes.Contains(hdrMode, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_hdrModeRow.IsOverridden = true;
|
||||
_hdrMode.SelectedItem = ChoiceOrDefault(HdrModes, hdrMode, "Auto");
|
||||
}
|
||||
if (existing.EnvironmentToggles is { } env)
|
||||
{
|
||||
_envRow.IsOverridden = true;
|
||||
foreach (var (name, box) in _envBoxes)
|
||||
{
|
||||
box.IsChecked = IsEnvironmentEnabled(env, name, defaultValue: false);
|
||||
box.IsChecked = env.Contains(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ChoiceOrDefault(string[] choices, string? value, string fallback) =>
|
||||
choices.FirstOrDefault(choice => string.Equals(choice, value, StringComparison.OrdinalIgnoreCase)) ?? fallback;
|
||||
|
||||
private void OnHostDisplayChanged()
|
||||
{
|
||||
if (_updatingHostDisplayOptions || _displayIndex.SelectedItem is not HostDisplayOption display)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_updatingHostDisplayOptions = true;
|
||||
try
|
||||
{
|
||||
PopulateHostModes(
|
||||
display,
|
||||
_resolution.SelectedItem as string ?? "1920x1080",
|
||||
SelectedRefreshRate());
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingHostDisplayOptions = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHostResolutionChanged()
|
||||
{
|
||||
if (_updatingHostDisplayOptions || _displayIndex.SelectedItem is not HostDisplayOption display)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedRefreshRate = SelectedRefreshRate();
|
||||
_updatingHostDisplayOptions = true;
|
||||
try
|
||||
{
|
||||
PopulateRefreshRates(display, _resolution.SelectedItem as string, selectedRefreshRate);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingHostDisplayOptions = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateHostModes(
|
||||
HostDisplayOption display,
|
||||
string selectedResolution,
|
||||
int selectedRefreshRate)
|
||||
{
|
||||
var resolutions = HostDisplayOptions.BuildResolutions(display, selectedResolution);
|
||||
_resolution.ItemsSource = resolutions;
|
||||
_resolution.SelectedItem = resolutions.FirstOrDefault(resolution =>
|
||||
string.Equals(resolution, selectedResolution, StringComparison.OrdinalIgnoreCase)) ?? resolutions[0];
|
||||
PopulateRefreshRates(display, _resolution.SelectedItem as string, selectedRefreshRate);
|
||||
}
|
||||
|
||||
private void PopulateRefreshRates(
|
||||
HostDisplayOption display,
|
||||
string? resolution,
|
||||
int selectedRefreshRate)
|
||||
{
|
||||
var rates = HostDisplayOptions.BuildRefreshRates(
|
||||
display,
|
||||
resolution,
|
||||
selectedRefreshRate,
|
||||
Localization.Instance.Get("Options.RefreshRate.Automatic"));
|
||||
_refreshRate.ItemsSource = rates;
|
||||
_refreshRate.SelectedItem = rates.FirstOrDefault(rate => rate.Value == selectedRefreshRate) ?? rates[0];
|
||||
}
|
||||
|
||||
private int SelectedRefreshRate() =>
|
||||
_refreshRate.SelectedItem is HostRefreshRateOption refreshRate ? refreshRate.Value : 0;
|
||||
|
||||
private void Persist()
|
||||
{
|
||||
var settings = new PerGameSettings
|
||||
@@ -380,47 +196,10 @@ public sealed class PerGameSettingsDialog : Window
|
||||
ImportTraceLimit = _traceRow.IsOverridden ? (int)(_trace.Value ?? 0) : null,
|
||||
StrictDynlibResolution = _strictRow.IsOverridden ? _strict.IsChecked == true : null,
|
||||
LogToFile = _logToFileRow.IsOverridden ? _logToFile.IsChecked == true : null,
|
||||
WindowMode = _windowModeRow.IsOverridden ? _windowMode.SelectedItem as string : null,
|
||||
Resolution = _resolutionRow.IsOverridden ? _resolution.SelectedItem as string : null,
|
||||
DisplayIndex = _displayIndexRow.IsOverridden && _displayIndex.SelectedItem is HostDisplayOption display
|
||||
? display.Index
|
||||
EnvironmentToggles = _envRow.IsOverridden
|
||||
? _envBoxes.Where(e => e.Box.IsChecked == true).Select(e => e.Name).ToList()
|
||||
: null,
|
||||
RefreshRate = _refreshRateRow.IsOverridden ? SelectedRefreshRate() : null,
|
||||
ScalingMode = _scalingModeRow.IsOverridden ? _scalingMode.SelectedItem as string : null,
|
||||
VSync = _vsyncRow.IsOverridden ? _vsync.IsChecked == true : null,
|
||||
HdrMode = _hdrModeRow.IsOverridden ? _hdrMode.SelectedItem as string : null,
|
||||
EnvironmentToggles = _envRow.IsOverridden ? BuildEnvironmentEntries() : null,
|
||||
};
|
||||
settings.Save(_titleId);
|
||||
}
|
||||
|
||||
private List<string> BuildEnvironmentEntries()
|
||||
{
|
||||
return _envBoxes
|
||||
.Where(entry => entry.Box.IsChecked == true)
|
||||
.Select(entry => entry.Name)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool IsEnvironmentEnabled(
|
||||
IEnumerable<string> entries,
|
||||
string name,
|
||||
bool defaultValue)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (string.Equals(entry, name + "=0", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(entry, name, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(entry, name + "=1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,24 +9,21 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
the executable is started without arguments. -->
|
||||
<PropertyGroup>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<!-- Required by the source-generated LibraryImport stubs in the linked
|
||||
controller readers below. -->
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Dependency-free; provides the BuildInfo provenance shown in the
|
||||
title bar. -->
|
||||
<ItemGroup>
|
||||
<!-- Games run in isolated SDL-window processes; the GUI owns launch and
|
||||
session controls only. -->
|
||||
<ProjectReference Include="..\SharpEmu.LibAtrac9\SharpEmu.LibAtrac9.csproj" />
|
||||
<!-- The GUI owns the native presentation control while each game runs in
|
||||
an isolated emulator process. -->
|
||||
<ProjectReference Include="..\SharpEmu.Core\SharpEmu.Core.csproj" />
|
||||
<ProjectReference Include="..\SharpEmu.Libs\SharpEmu.Libs.csproj" />
|
||||
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="SharpEmu.Libs.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Window and text defaults shared by all launcher views.
|
||||
-->
|
||||
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Style Selector="Window">
|
||||
<Setter Property="FontFamily" Value="Inter, Segoe UI, sans-serif" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.sectionTitle">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="LetterSpacing" Value="1.5" />
|
||||
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.fieldLabel">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
|
||||
<Setter Property="Margin" Value="0,0,0,6" />
|
||||
</Style>
|
||||
</Styles>
|
||||
@@ -1,84 +0,0 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Shared launcher button variants and page switcher styles.
|
||||
-->
|
||||
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Style Selector="Button.accent">
|
||||
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Padding" Value="22,10" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.accent:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource AccentHoverBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.danger">
|
||||
<Setter Property="Background" Value="{StaticResource DangerBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Padding" Value="22,10" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.danger:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource DangerHoverBrush}" />
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.ghost">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="Padding" Value="12,7" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ToggleButton.ghost">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="Padding" Value="12,7" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="ToggleButton.ghost:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ToggleButton.ghost:checked /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Top-level page switcher (Library / Options): plain transparent
|
||||
buttons, not TabItem, so there is no Fluent selected-tab underline.
|
||||
The active page is conveyed by brightness alone; LB/RB gamepad
|
||||
hints flank the pair. -->
|
||||
<Style Selector="Button.segment">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
|
||||
<Setter Property="FontSize" Value="22" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="Padding" Value="6,4" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.segment:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.segment.active">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
</Styles>
|
||||
@@ -1,18 +0,0 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Console list typography and compact item spacing.
|
||||
-->
|
||||
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Style Selector="ListBox.console">
|
||||
<Setter Property="Background" Value="#0B0E14" />
|
||||
<Setter Property="FontFamily" Value="Cascadia Mono, Consolas, Courier New, monospace" />
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.console ListBoxItem">
|
||||
<Setter Property="Padding" Value="10,1" />
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
</Style>
|
||||
</Styles>
|
||||
@@ -1,29 +0,0 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Shared text input and context-menu styles.
|
||||
-->
|
||||
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ContextMenu">
|
||||
<Setter Property="Background" Value="{StaticResource CardBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="Padding" Value="6" />
|
||||
</Style>
|
||||
<Style Selector="ContextMenu MenuItem">
|
||||
<Setter Property="Padding" Value="10,7" />
|
||||
<Setter Property="CornerRadius" Value="7" />
|
||||
</Style>
|
||||
<Style Selector="ContextMenu Separator">
|
||||
<Setter Property="Background" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="Height" Value="1" />
|
||||
<Setter Property="Margin" Value="8,4" />
|
||||
</Style>
|
||||
</Styles>
|
||||
@@ -1,37 +0,0 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Cover-art library item states and motion.
|
||||
-->
|
||||
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem">
|
||||
<Setter Property="Padding" Value="10" />
|
||||
<Setter Property="Margin" Value="5" />
|
||||
<Setter Property="CornerRadius" Value="14" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="RenderTransform" Value="translateY(0px)" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover">
|
||||
<Setter Property="RenderTransform" Value="translateY(-3px)" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource TileHoverBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ListBox.tileGrid ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource TileSelectedBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentHoverBrush}" />
|
||||
</Style>
|
||||
</Styles>
|
||||
@@ -1,55 +0,0 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Shared card, badge, hint and cover surfaces.
|
||||
-->
|
||||
|
||||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="Background" Value="{StaticResource CardBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Padding" Value="16" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.pill">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="Padding" Value="10,3" />
|
||||
</Style>
|
||||
|
||||
<!-- Session status/hotkey badges: the title-id pill geometry with a
|
||||
tinted fill so state (RUNNING) and keys (F11) read at a glance. -->
|
||||
<Style Selector="Border.badge">
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="Padding" Value="8,2" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Border.badge.running">
|
||||
<Setter Property="Background" Value="#1E46C46B" />
|
||||
<Setter Property="BorderBrush" Value="#5546C46B" />
|
||||
</Style>
|
||||
<Style Selector="Border.badge.key">
|
||||
<Setter Property="Background" Value="#1E58A6FF" />
|
||||
<Setter Property="BorderBrush" Value="#5558A6FF" />
|
||||
</Style>
|
||||
|
||||
<!-- Gamepad shoulder-button hint chip (LB/RB, L1/R1). -->
|
||||
<Style Selector="Border.padHint">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="8,3" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.coverShadow">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="BoxShadow" Value="0 6 14 0 #55000000" />
|
||||
</Style>
|
||||
<Style Selector="Border.coverClip">
|
||||
<Setter Property="CornerRadius" Value="10" />
|
||||
<Setter Property="ClipToBounds" Value="True" />
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
</Style>
|
||||
</Styles>
|
||||
@@ -1,34 +0,0 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Control theme for the shared launcher settings row.
|
||||
-->
|
||||
|
||||
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:SharpEmu.GUI">
|
||||
<ControlTheme x:Key="{x:Type local:SettingRow}" TargetType="local:SettingRow">
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="PART_Label" Text="{TemplateBinding Label}" FontSize="13" />
|
||||
<TextBlock Text="{TemplateBinding Description}" FontSize="11"
|
||||
Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Description, RelativeSource={RelativeSource TemplatedParent},
|
||||
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
||||
<ToggleSwitch OnContent="Override" OffContent="Override" MinWidth="0"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{TemplateBinding ShowOverride}"
|
||||
IsChecked="{Binding IsOverridden, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}" />
|
||||
<ContentPresenter x:Name="PART_Slot"
|
||||
Content="{TemplateBinding Content}"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</ControlTheme>
|
||||
</ResourceDictionary>
|
||||
@@ -1,32 +0,0 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Shared colors and brushes used throughout the launcher.
|
||||
-->
|
||||
|
||||
<ResourceDictionary xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Color x:Key="SystemAccentColor">#7C5CFC</Color>
|
||||
|
||||
<LinearGradientBrush x:Key="BgBrush" StartPoint="0%,0%" EndPoint="100%,100%">
|
||||
<GradientStop Offset="0" Color="#12151F" />
|
||||
<GradientStop Offset="0.55" Color="#0D1017" />
|
||||
<GradientStop Offset="1" Color="#0B0D14" />
|
||||
</LinearGradientBrush>
|
||||
|
||||
<SolidColorBrush x:Key="ChromeBrush" Color="#090C12" />
|
||||
<SolidColorBrush x:Key="CardBrush" Color="#141924" />
|
||||
<SolidColorBrush x:Key="CardBorderBrush" Color="#232B3A" />
|
||||
<SolidColorBrush x:Key="ElevatedBrush" Color="#1B2230" />
|
||||
<SolidColorBrush x:Key="TextBrush" Color="#E8ECF4" />
|
||||
<SolidColorBrush x:Key="MutedBrush" Color="#8B94A7" />
|
||||
<SolidColorBrush x:Key="FaintBrush" Color="#5A6478" />
|
||||
<SolidColorBrush x:Key="AccentBrush" Color="#7C5CFC" />
|
||||
<SolidColorBrush x:Key="AccentHoverBrush" Color="#8F73FF" />
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
|
||||
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
|
||||
<SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" />
|
||||
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
|
||||
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
|
||||
</ResourceDictionary>
|
||||
@@ -20,15 +20,6 @@ public sealed class CpuContext(ICpuMemory memory, Generation generation)
|
||||
|
||||
public ulong Rip { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Index of the import this context is currently executing, or -1 when it is
|
||||
/// running guest code. Only maintained while guest profiling is enabled;
|
||||
/// <see cref="Rip"/> alone cannot answer "what is this thread inside right
|
||||
/// now" because it keeps pointing at the last import stub after the call
|
||||
/// returns.
|
||||
/// </summary>
|
||||
public int ActiveImportIndex { get; set; } = -1;
|
||||
|
||||
public ulong Rflags { get; set; }
|
||||
|
||||
public ulong FsBase { get; set; }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
@@ -31,15 +30,8 @@ public static unsafe class GuestImageWriteTracker
|
||||
public ulong End;
|
||||
public int Dirty;
|
||||
public int Armed;
|
||||
/// <summary>
|
||||
/// When false the range is watch-only: managed writes still dirty it via
|
||||
/// <see cref="NotifyManagedWrite"/>, but pages are never write-protected
|
||||
/// so native CPU stores do not fault.
|
||||
/// </summary>
|
||||
public bool Protect;
|
||||
public int FirstCpuWriteSeen;
|
||||
public int PendingFirstCpuWrite;
|
||||
public long WriteGeneration;
|
||||
public bool TraceLifetime;
|
||||
public long SourceSequence;
|
||||
public long FirstCpuWriteTraceSequence;
|
||||
@@ -87,11 +79,8 @@ public static unsafe class GuestImageWriteTracker
|
||||
|
||||
private static RangeSnapshot _rangeSnapshot = RangeSnapshot.Empty;
|
||||
|
||||
private static readonly bool _enabled =
|
||||
string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC"),
|
||||
"1",
|
||||
StringComparison.Ordinal);
|
||||
private static readonly bool _enabled = !OperatingSystem.IsWindows() &&
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
|
||||
private static readonly (bool Wildcard, ulong[] Addresses) _lifetimeTraceFilter =
|
||||
ParseAddressList(Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_ADDRS"));
|
||||
private static readonly (bool Wildcard, string[] Sources) _lifetimeSourceTraceFilter =
|
||||
@@ -105,67 +94,14 @@ public static unsafe class GuestImageWriteTracker
|
||||
_enabled && _lifetimeTraceEnabled ? GetMonotonicNanoseconds() : 0;
|
||||
private static long _lifetimeTraceSequence;
|
||||
|
||||
private const uint PageReadonly = 0x02;
|
||||
private const uint PageReadWrite = 0x04;
|
||||
|
||||
[DllImport("libc", EntryPoint = "mprotect", SetLastError = true)]
|
||||
private static extern int Mprotect(nint address, nuint length, int protection);
|
||||
|
||||
[DllImport("libc", EntryPoint = "clock_gettime", SetLastError = false)]
|
||||
private static extern int ClockGetTime(int clockId, Timespec* time);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern int VirtualProtect(
|
||||
nint lpAddress,
|
||||
nuint dwSize,
|
||||
uint flNewProtect,
|
||||
out uint lpflOldProtect);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern nint VirtualAlloc(
|
||||
nint lpAddress,
|
||||
nuint dwSize,
|
||||
uint flAllocationType,
|
||||
uint flProtect);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern int VirtualFree(nint lpAddress, nuint dwSize, uint dwFreeType);
|
||||
|
||||
private const uint MemCommit = 0x1000;
|
||||
private const uint MemReserve = 0x2000;
|
||||
private const uint MemRelease = 0x8000;
|
||||
|
||||
public static bool Enabled => _enabled;
|
||||
|
||||
/// <summary>
|
||||
/// Test/diagnostics helper: whether <paramref name="address"/> is tracked
|
||||
/// with write protection armed (watch-only ranges report protect=false).
|
||||
/// </summary>
|
||||
public static bool TryGetProtectionState(
|
||||
ulong address,
|
||||
out bool protect,
|
||||
out bool armed)
|
||||
{
|
||||
protect = false;
|
||||
armed = false;
|
||||
if (!_enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_rangesByAddress.TryGetValue(address, out var range))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
protect = range.Protect;
|
||||
armed = Volatile.Read(ref range.Armed) != 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the fault-handling path once outside signal context so every
|
||||
/// branch is JIT-compiled (and, under Rosetta 2, translated) before a real
|
||||
@@ -178,17 +114,7 @@ public static unsafe class GuestImageWriteTracker
|
||||
return;
|
||||
}
|
||||
|
||||
// VirtualProtect only belongs on VirtualAlloc/mmap pages. Warming on
|
||||
// CRT heap memory makes neighbouring heap metadata read-only and
|
||||
// crashes the process on Windows.
|
||||
var scratch = OperatingSystem.IsWindows()
|
||||
? VirtualAlloc(0, 4096, MemCommit | MemReserve, PageReadWrite)
|
||||
: (nint)NativeMemory.AllocZeroed(4096);
|
||||
if (scratch == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var scratch = NativeMemory.AllocZeroed(4096);
|
||||
try
|
||||
{
|
||||
// Warm the timestamp P/Invoke used by the signal-safe scalar
|
||||
@@ -202,29 +128,16 @@ public static unsafe class GuestImageWriteTracker
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
_ = VirtualFree(scratch, 0, MemRelease);
|
||||
}
|
||||
else
|
||||
{
|
||||
NativeMemory.Free((void*)scratch);
|
||||
}
|
||||
NativeMemory.Free(scratch);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a range. When <paramref name="protect"/> is true, arms write
|
||||
/// protection so native stores fault and mark the range dirty. When false,
|
||||
/// the range is watch-only (managed HLE writes still dirty via
|
||||
/// <see cref="NotifyManagedWrite"/>) and never <c>VirtualProtect</c>'d.
|
||||
/// </summary>
|
||||
/// <summary>Registers a range and arms write protection on it.</summary>
|
||||
public static void Track(
|
||||
ulong address,
|
||||
ulong byteCount,
|
||||
long sourceSequence = 0,
|
||||
string source = "unspecified",
|
||||
bool protect = true)
|
||||
string source = "unspecified")
|
||||
{
|
||||
if (!_enabled || address == 0 || byteCount == 0)
|
||||
{
|
||||
@@ -242,23 +155,10 @@ public static unsafe class GuestImageWriteTracker
|
||||
{
|
||||
// Never resize an object that is still reachable from the
|
||||
// signal handler's lock-free snapshot. Retire it and publish
|
||||
// a fresh immutable range, carrying the write generation so
|
||||
// resizes do not hide guest CPU rewrites from cache owners.
|
||||
var writeGeneration = Volatile.Read(ref range.WriteGeneration);
|
||||
var keepProtect = range.Protect || protect;
|
||||
// a fresh immutable range.
|
||||
DisarmLocked(range, "replace-range");
|
||||
_rangesByAddress.Remove(address);
|
||||
range = new TrackedRange
|
||||
{
|
||||
Address = address,
|
||||
ByteCount = byteCount,
|
||||
Start = start,
|
||||
End = start + length,
|
||||
Protect = keepProtect,
|
||||
WriteGeneration = writeGeneration,
|
||||
};
|
||||
_rangesByAddress[address] = range;
|
||||
RebuildSnapshotLocked();
|
||||
range = null;
|
||||
}
|
||||
|
||||
if (range is null)
|
||||
@@ -269,7 +169,6 @@ public static unsafe class GuestImageWriteTracker
|
||||
ByteCount = byteCount,
|
||||
Start = start,
|
||||
End = start + length,
|
||||
Protect = protect,
|
||||
TraceLifetime =
|
||||
ShouldTraceRange(start, start + length) || ShouldTraceSource(source),
|
||||
SourceSequence = sourceSequence,
|
||||
@@ -281,22 +180,13 @@ public static unsafe class GuestImageWriteTracker
|
||||
else
|
||||
{
|
||||
FlushPendingFirstCpuWrite(range);
|
||||
// Protect is sticky: a later watch-only Track (texture cache)
|
||||
// must not disarm an RT that already needs page faults.
|
||||
if (protect && !range.Protect)
|
||||
{
|
||||
range.Protect = true;
|
||||
}
|
||||
}
|
||||
|
||||
range.SourceSequence = sourceSequence;
|
||||
range.Source = source;
|
||||
range.TraceLifetime =
|
||||
ShouldTraceRange(range.Start, range.End) || ShouldTraceSource(source);
|
||||
if (range.Protect)
|
||||
{
|
||||
ArmLocked(range, "arm");
|
||||
}
|
||||
ArmLocked(range, "arm");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,39 +265,13 @@ public static unsafe class GuestImageWriteTracker
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_rangesByAddress.TryGetValue(address, out var range) &&
|
||||
range.Protect)
|
||||
if (_rangesByAddress.TryGetValue(address, out var range))
|
||||
{
|
||||
ArmLocked(range, "rearm");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the monotonic first-write generation for a tracked allocation.
|
||||
/// Unlike the consuming dirty flag, this remains changed after another
|
||||
/// cache owner consumes and re-arms the range.
|
||||
/// </summary>
|
||||
public static bool TryGetWriteGeneration(ulong address, out long generation)
|
||||
{
|
||||
generation = 0;
|
||||
if (!_enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_rangesByAddress.TryGetValue(address, out var range))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
generation = Volatile.Read(ref range.WriteGeneration);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prepares pages touched by a managed HLE memory write. Native guest
|
||||
/// stores fault and enter <see cref="TryHandleWriteFault"/> through the
|
||||
@@ -544,7 +408,10 @@ public static unsafe class GuestImageWriteTracker
|
||||
}
|
||||
|
||||
if (needsUnprotect &&
|
||||
!TrySetProtection(writableStart, writableEnd - writableStart, writable: true))
|
||||
Mprotect(
|
||||
(nint)writableStart,
|
||||
(nuint)(writableEnd - writableStart),
|
||||
ProtRead | ProtWrite) != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -558,14 +425,6 @@ public static unsafe class GuestImageWriteTracker
|
||||
}
|
||||
|
||||
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) != 0;
|
||||
var wasDirty = Interlocked.Exchange(ref range.Dirty, 1) != 0;
|
||||
// Protected ranges bump generation once per arm/fault cycle.
|
||||
// Watch-only ranges never arm, so bump on the first dirty mark
|
||||
// (NotifyManagedWrite) so cache owners still see a rewrite.
|
||||
if (wasArmed || (!range.Protect && !wasDirty))
|
||||
{
|
||||
Interlocked.Increment(ref range.WriteGeneration);
|
||||
}
|
||||
if (wasArmed &&
|
||||
range.TraceLifetime &&
|
||||
Interlocked.CompareExchange(ref range.FirstCpuWriteSeen, 1, 0) == 0)
|
||||
@@ -580,6 +439,8 @@ public static unsafe class GuestImageWriteTracker
|
||||
Volatile.Write(ref range.PendingFirstCpuWrite, 1);
|
||||
Volatile.Write(ref range.FirstCpuWriteSeen, 2);
|
||||
}
|
||||
|
||||
Volatile.Write(ref range.Dirty, 1);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -595,7 +456,10 @@ public static unsafe class GuestImageWriteTracker
|
||||
|
||||
// A new publication/rearm starts a new first-write lifetime.
|
||||
Volatile.Write(ref range.FirstCpuWriteSeen, 0);
|
||||
var failed = !TrySetProtection(range.Start, range.End - range.Start, writable: false);
|
||||
var failed = Mprotect(
|
||||
(nint)range.Start,
|
||||
(nuint)(range.End - range.Start),
|
||||
ProtRead) != 0;
|
||||
if (failed)
|
||||
{
|
||||
Volatile.Write(ref range.Armed, 0);
|
||||
@@ -615,7 +479,10 @@ public static unsafe class GuestImageWriteTracker
|
||||
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) == 1;
|
||||
if (wasArmed)
|
||||
{
|
||||
_ = TrySetProtection(range.Start, range.End - range.Start, writable: true);
|
||||
_ = Mprotect(
|
||||
(nint)range.Start,
|
||||
(nuint)(range.End - range.Start),
|
||||
ProtRead | ProtWrite);
|
||||
}
|
||||
|
||||
if (range.TraceLifetime)
|
||||
@@ -626,13 +493,7 @@ public static unsafe class GuestImageWriteTracker
|
||||
|
||||
private static void RebuildSnapshotLocked()
|
||||
{
|
||||
// Fault / NotifyManagedWrite hot paths must only see protected ranges.
|
||||
// Watch-only texture-cache registrations used to widen Start..End across
|
||||
// nearly all GPU memory so every managed guest write walked this path.
|
||||
var protectedRanges = _rangesByAddress.Values
|
||||
.Where(static range => range.Protect)
|
||||
.ToArray();
|
||||
Volatile.Write(ref _rangeSnapshot, new RangeSnapshot(protectedRanges));
|
||||
Volatile.Write(ref _rangeSnapshot, new RangeSnapshot(_rangesByAddress.Values.ToArray()));
|
||||
}
|
||||
|
||||
private static (ulong Start, ulong Length) PageAlign(ulong address, ulong byteCount)
|
||||
@@ -777,35 +638,8 @@ public static unsafe class GuestImageWriteTracker
|
||||
$"fault=0x{faultAddress:X16} page=0x{faultPage:X16}");
|
||||
}
|
||||
|
||||
private static bool TrySetProtection(ulong start, ulong length, bool writable)
|
||||
{
|
||||
if (length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return VirtualProtect(
|
||||
(nint)start,
|
||||
(nuint)length,
|
||||
writable ? PageReadWrite : PageReadonly,
|
||||
out _) != 0;
|
||||
}
|
||||
|
||||
return Mprotect(
|
||||
(nint)start,
|
||||
(nuint)length,
|
||||
writable ? ProtRead | ProtWrite : ProtRead) == 0;
|
||||
}
|
||||
|
||||
private static long GetMonotonicNanoseconds()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return Stopwatch.GetTimestamp() * 1_000_000_000L / Stopwatch.Frequency;
|
||||
}
|
||||
|
||||
Timespec time;
|
||||
return ClockGetTime(ClockMonotonicRaw, &time) == 0
|
||||
? unchecked((time.Seconds * 1_000_000_000L) + time.Nanoseconds)
|
||||
|
||||
@@ -221,29 +221,6 @@ public static class GuestThreadExecution
|
||||
|
||||
public static IGuestThreadScheduler? Scheduler { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fired when a guest thread is torn down without a clean pthread_exit
|
||||
/// (e.g. TBB execute-AV → worker_abort). Libs use this to abandon mutexes.
|
||||
/// </summary>
|
||||
public static event Func<ulong, string, int>? GuestThreadAbandoned;
|
||||
|
||||
public static int NotifyGuestThreadAbandoned(ulong threadHandle, string reason)
|
||||
{
|
||||
if (threadHandle == 0 || GuestThreadAbandoned is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return GuestThreadAbandoned.Invoke(threadHandle, reason);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsGuestThread => _currentGuestThreadHandle != 0;
|
||||
|
||||
public static ulong CurrentGuestThreadHandle => _currentGuestThreadHandle;
|
||||
|
||||
@@ -17,7 +17,7 @@ public static class GuestTlsTemplate
|
||||
// Must match CpuDispatcher/DirectExecutionBackend's mapped prefix. PS5
|
||||
// modules can require more than one host page of Variant II static TLS;
|
||||
// Dreaming Sarah's startup image, for example, reaches 0x1870 bytes.
|
||||
public const ulong StartupStaticTlsReservation = 0x20000UL; // Was 0x10000UL, but thats too small for GTA V
|
||||
public const ulong StartupStaticTlsReservation = 0x10000UL;
|
||||
private static readonly object _gate = new();
|
||||
private static readonly SortedDictionary<ulong, ModuleTemplate> _modules = new();
|
||||
private static readonly Dictionary<ulong, ThreadDtv> _threadDtvs = new();
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
|
||||
namespace SharpEmu.HLE;
|
||||
|
||||
// This tool monitors guest-memory writes only when a watch mode is active.
|
||||
public static class GuestWriteWatch
|
||||
{
|
||||
private const ulong WatchBytes = 8;
|
||||
private const int MaxBulkReports = 64;
|
||||
|
||||
private static readonly ulong WatchBase = Parse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_WATCH_WRITE"));
|
||||
|
||||
private static readonly bool WatchPoolHeaders = IsEnabled("SHARPEMU_WATCH_POOL_HEADER");
|
||||
|
||||
private static readonly ulong[] PoolSlots = new ulong[64];
|
||||
private static int _poolSlotCount;
|
||||
|
||||
private static readonly bool WatchValuePattern = IsEnabled("SHARPEMU_WATCH_VALUE_PATTERN");
|
||||
|
||||
private static readonly bool WatchValue1 = IsEnabled("SHARPEMU_WATCH_VALUE1");
|
||||
|
||||
private const ulong DirectBandLow = 0x100_0000_0000;
|
||||
private const ulong DirectBandHigh = 0x1000_0000_0000;
|
||||
private static int _value1Reports;
|
||||
|
||||
private static readonly bool WatchBulkTorn = IsEnabled("SHARPEMU_WATCH_BULK_TORN");
|
||||
|
||||
private static readonly ulong BulkDestHigh = Parse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_WATCH_BULK_DEST_HI"));
|
||||
private static int _bulkTornReports;
|
||||
private static int _bulkShiftReports;
|
||||
|
||||
public static bool Armed =>
|
||||
WatchBase != 0 || WatchPoolHeaders || WatchValuePattern || WatchValue1 || WatchBulkTorn;
|
||||
|
||||
public static void OnDirectMapping(ulong mappedAddress, ulong length, int protection)
|
||||
{
|
||||
if (!WatchPoolHeaders || !IsPoolMapping(length, protection))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var index = Interlocked.Increment(ref _poolSlotCount) - 1;
|
||||
if (index < PoolSlots.Length)
|
||||
{
|
||||
Volatile.Write(ref PoolSlots[index], mappedAddress + 0x40);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] watch_write armed on pool header slot 0x{mappedAddress + 0x40:X16}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void Check(ulong address, ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (WatchBulkTorn &&
|
||||
data.Length >= 8 &&
|
||||
(BulkDestHigh != 0
|
||||
? (address >> 32) == BulkDestHigh
|
||||
: address >= DirectBandLow && address < DirectBandHigh))
|
||||
{
|
||||
for (var offset = FirstAlignedOffset(address); offset + 8 <= data.Length; offset += 8)
|
||||
{
|
||||
var qword = BinaryPrimitives.ReadUInt64LittleEndian(data.Slice(offset, 8));
|
||||
var kind = ClassifyBulkValue(qword);
|
||||
if (kind is not null && ReserveBulkReport(kind))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] watch_bulk_torn HIT ({kind}) " +
|
||||
$"dest=0x{address + (ulong)offset:X16} (base=0x{address:X16}+0x{offset:X}) " +
|
||||
$"len={data.Length} qword=0x{qword:X16}{Environment.NewLine}{Environment.StackTrace}");
|
||||
Console.Error.Flush();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (WatchValue1 &&
|
||||
address >= DirectBandLow && address < DirectBandHigh &&
|
||||
data.Length is >= 1 and <= 8 &&
|
||||
LittleEndianValue(data) == 1 &&
|
||||
Interlocked.Increment(ref _value1Reports) <= 128)
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (WatchValuePattern && data.Length == 8)
|
||||
{
|
||||
var value = BinaryPrimitives.ReadUInt64LittleEndian(data);
|
||||
if ((value & 0xFFFFFFFF) == 1 && value >> 32 is > 0 and <= 0xFFFF)
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (WatchBase != 0 && Overlaps(address, data.Length, WatchBase))
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
|
||||
var slots = Math.Min(Volatile.Read(ref _poolSlotCount), PoolSlots.Length);
|
||||
for (var i = 0; i < slots; i++)
|
||||
{
|
||||
var slot = Volatile.Read(ref PoolSlots[i]);
|
||||
if (slot != 0 && Overlaps(address, data.Length, slot))
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static string? ClassifyBulkValue(ulong qword)
|
||||
{
|
||||
var low32 = qword & 0xFFFFFFFF;
|
||||
var high32 = qword >> 32;
|
||||
if (low32 == 1 && high32 is > 0 and <= 0xFFFF)
|
||||
{
|
||||
return "torn";
|
||||
}
|
||||
|
||||
var prefix = low32 & 0xFF00_0000;
|
||||
var hasShiftedPointerPrefix = prefix is 0x0800_0000 or 0x8000_0000;
|
||||
return high32 == 0 && hasShiftedPointerPrefix && (low32 & 0xFF) == 0
|
||||
? "shift"
|
||||
: null;
|
||||
}
|
||||
|
||||
internal static int FirstAlignedOffset(ulong address) =>
|
||||
(int)((8 - (address & 7)) & 7);
|
||||
|
||||
internal static bool IsPoolMapping(ulong length, int protection) =>
|
||||
length == 0x10000 && protection == 0xF2;
|
||||
|
||||
internal static bool Overlaps(ulong address, int length, ulong slot)
|
||||
{
|
||||
if (length <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var writeLength = (ulong)length - 1;
|
||||
var writeEnd = address > ulong.MaxValue - writeLength
|
||||
? ulong.MaxValue
|
||||
: address + writeLength;
|
||||
var slotEnd = slot > ulong.MaxValue - (WatchBytes - 1)
|
||||
? ulong.MaxValue
|
||||
: slot + WatchBytes - 1;
|
||||
return address <= slotEnd && slot <= writeEnd;
|
||||
}
|
||||
|
||||
private static ulong LittleEndianValue(ReadOnlySpan<byte> data)
|
||||
{
|
||||
ulong value = 0;
|
||||
for (var i = 0; i < data.Length; i++)
|
||||
{
|
||||
value |= (ulong)data[i] << (i * 8);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void Report(ulong address, ReadOnlySpan<byte> data)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] watch_write HIT addr=0x{address:X16} len={data.Length} " +
|
||||
$"first_qword=0x{LittleEndianValue(data):X16}{Environment.NewLine}{Environment.StackTrace}");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
|
||||
private static bool IsEnabled(string name) =>
|
||||
string.Equals(Environment.GetEnvironmentVariable(name), "1", StringComparison.Ordinal);
|
||||
|
||||
private static bool ReserveBulkReport(string kind) =>
|
||||
kind == "torn"
|
||||
? Interlocked.Increment(ref _bulkTornReports) <= MaxBulkReports
|
||||
: Interlocked.Increment(ref _bulkShiftReports) <= MaxBulkReports;
|
||||
|
||||
internal static ulong Parse(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
text = text.Trim();
|
||||
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
text = text[2..];
|
||||
}
|
||||
|
||||
return ulong.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value)
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace SharpEmu.HLE.Host;
|
||||
|
||||
/// <summary>
|
||||
/// How much guest audio the host device has actually played, in seconds.
|
||||
///
|
||||
/// This is the only clock in the emulator that advances at the rate the player
|
||||
/// hears. Wall clock runs ahead of it whenever the guest cannot feed the device
|
||||
/// (the stream underruns and the missing time is never played), so anything
|
||||
/// that has to stay in step with the guest's audio — host-decoded video being
|
||||
/// the case that matters — has to follow this rather than <see cref="Stopwatch"/>.
|
||||
///
|
||||
/// Reported per stream and kept as the furthest-along value: the guest's ports
|
||||
/// all carry one mix, and the leading port is the one whose position the
|
||||
/// listener perceives.
|
||||
/// </summary>
|
||||
public static class GuestAudioClock
|
||||
{
|
||||
private static long _playedMicroseconds;
|
||||
private static long _lastAdvanceTimestamp;
|
||||
|
||||
/// <summary>Seconds of guest audio the device has played. Monotonic.</summary>
|
||||
public static double PlayedSeconds =>
|
||||
Interlocked.Read(ref _playedMicroseconds) / 1_000_000.0;
|
||||
|
||||
/// <summary>
|
||||
/// True while a stream has reported progress recently. False means no guest
|
||||
/// audio is playing, and callers must fall back to wall clock rather than
|
||||
/// stalling on a clock that will never advance.
|
||||
/// </summary>
|
||||
public static bool IsRunning
|
||||
{
|
||||
get
|
||||
{
|
||||
var last = Interlocked.Read(ref _lastAdvanceTimestamp);
|
||||
return last != 0 &&
|
||||
Stopwatch.GetElapsedTime(last) < TimeSpan.FromMilliseconds(250);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Report(double playedSeconds)
|
||||
{
|
||||
if (double.IsNaN(playedSeconds) || playedSeconds < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var microseconds = (long)(playedSeconds * 1_000_000.0);
|
||||
var current = Interlocked.Read(ref _playedMicroseconds);
|
||||
while (microseconds > current)
|
||||
{
|
||||
var seen = Interlocked.CompareExchange(
|
||||
ref _playedMicroseconds,
|
||||
microseconds,
|
||||
current);
|
||||
if (seen == current)
|
||||
{
|
||||
Interlocked.Exchange(ref _lastAdvanceTimestamp, Stopwatch.GetTimestamp());
|
||||
return;
|
||||
}
|
||||
|
||||
current = seen;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,44 +28,8 @@ public enum HostGamepadButtons : uint
|
||||
R3 = 1 << 13,
|
||||
Options = 1 << 14,
|
||||
TouchPad = 1 << 15,
|
||||
Create = 1 << 16,
|
||||
Ps = 1 << 17,
|
||||
Mic = 1 << 18,
|
||||
}
|
||||
|
||||
public enum HostGamepadType : byte
|
||||
{
|
||||
Generic,
|
||||
DualShock4,
|
||||
DualSense,
|
||||
}
|
||||
|
||||
public enum HostGamepadConnection : byte
|
||||
{
|
||||
Unknown,
|
||||
Wired,
|
||||
Wireless,
|
||||
}
|
||||
|
||||
public readonly record struct HostMotionState(
|
||||
bool Available,
|
||||
float AccelerationX,
|
||||
float AccelerationY,
|
||||
float AccelerationZ,
|
||||
float AngularVelocityX,
|
||||
float AngularVelocityY,
|
||||
float AngularVelocityZ);
|
||||
|
||||
public readonly record struct HostTouchPoint(
|
||||
bool Active,
|
||||
byte Id,
|
||||
float X,
|
||||
float Y);
|
||||
|
||||
public readonly record struct HostTouchState(
|
||||
HostTouchPoint First,
|
||||
HostTouchPoint Second);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of one host gamepad: sticks are 0..255 with 128 centered and Y growing
|
||||
/// downward; triggers 0..255. Unmanaged on purpose so per-frame polls can stackalloc
|
||||
@@ -79,57 +43,4 @@ public readonly record struct HostGamepadState(
|
||||
byte RightX,
|
||||
byte RightY,
|
||||
byte LeftTrigger,
|
||||
byte RightTrigger,
|
||||
HostGamepadType Type = HostGamepadType.Generic,
|
||||
HostGamepadConnection Connection = HostGamepadConnection.Unknown,
|
||||
HostMotionState Motion = default,
|
||||
HostTouchState Touch = default,
|
||||
byte BatteryPercent = 0);
|
||||
|
||||
/// <summary>A complete 11-byte DualSense adaptive-trigger command.</summary>
|
||||
public readonly record struct HostAdaptiveTriggerEffect(
|
||||
byte B0,
|
||||
byte B1,
|
||||
byte B2,
|
||||
byte B3,
|
||||
byte B4,
|
||||
byte B5,
|
||||
byte B6,
|
||||
byte B7,
|
||||
byte B8,
|
||||
byte B9,
|
||||
byte B10,
|
||||
byte FallbackStrength = 0)
|
||||
{
|
||||
public static HostAdaptiveTriggerEffect FromBytes(ReadOnlySpan<byte> source, byte fallbackStrength = 0)
|
||||
{
|
||||
if (source.Length < 11)
|
||||
{
|
||||
throw new ArgumentException("Adaptive-trigger source is too small.", nameof(source));
|
||||
}
|
||||
|
||||
return new HostAdaptiveTriggerEffect(
|
||||
source[0], source[1], source[2], source[3], source[4], source[5],
|
||||
source[6], source[7], source[8], source[9], source[10], fallbackStrength);
|
||||
}
|
||||
|
||||
public void CopyTo(Span<byte> destination)
|
||||
{
|
||||
if (destination.Length < 11)
|
||||
{
|
||||
throw new ArgumentException("Adaptive-trigger destination is too small.", nameof(destination));
|
||||
}
|
||||
|
||||
destination[0] = B0;
|
||||
destination[1] = B1;
|
||||
destination[2] = B2;
|
||||
destination[3] = B3;
|
||||
destination[4] = B4;
|
||||
destination[5] = B5;
|
||||
destination[6] = B6;
|
||||
destination[7] = B7;
|
||||
destination[8] = B8;
|
||||
destination[9] = B9;
|
||||
destination[10] = B10;
|
||||
}
|
||||
}
|
||||
byte RightTrigger);
|
||||
|
||||
@@ -19,11 +19,5 @@ public interface IHostAudioOutput
|
||||
/// Throws when the host has no usable output device; callers degrade to a silent
|
||||
/// port and pace the guest instead.
|
||||
/// </summary>
|
||||
/// <param name="sampleRate">Host stream sample rate in Hz.</param>
|
||||
/// <param name="maxQueuedPcmBytes">
|
||||
/// Soft backpressure cap for queued stereo PCM16. Default 32 KiB (~171 ms at
|
||||
/// 48 kHz) matches classic AudioOut latency. Bursty AudioOut2 / FMOD feeders
|
||||
/// may pass a deeper cap to avoid underruns.
|
||||
/// </param>
|
||||
IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024);
|
||||
IHostAudioStream OpenStereoPcm16Stream(uint sampleRate);
|
||||
}
|
||||
|
||||
@@ -15,17 +15,4 @@ public interface IHostAudioStream : IDisposable
|
||||
/// audio, in which case the caller paces the guest itself.
|
||||
/// </summary>
|
||||
bool Submit(ReadOnlySpan<byte> stereoPcm16);
|
||||
|
||||
/// <summary>
|
||||
/// Audio already handed to the device and not yet played, in milliseconds —
|
||||
/// the cushion protecting playback from a late submission. Zero means the
|
||||
/// device has run dry and is emitting silence.
|
||||
///
|
||||
/// Callers that pace the guest against an emulated hardware queue need this:
|
||||
/// pacing purely on wall clock releases exactly one buffer per buffer-period
|
||||
/// and so keeps the cushion at zero, which turns any scheduling jitter into
|
||||
/// an audible dropout. Returns -1 when the backend cannot report a depth, in
|
||||
/// which case callers must fall back to their own pacing.
|
||||
/// </summary>
|
||||
int QueuedMilliseconds => -1;
|
||||
}
|
||||
|
||||
@@ -32,11 +32,6 @@ public interface IHostInput
|
||||
/// </summary>
|
||||
void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger);
|
||||
|
||||
/// <summary>Applies native DualSense trigger effects when supported.</summary>
|
||||
void SetAdaptiveTriggerEffect(
|
||||
HostAdaptiveTriggerEffect? leftTrigger,
|
||||
HostAdaptiveTriggerEffect? rightTrigger);
|
||||
|
||||
void SetLightbar(byte red, byte green, byte blue);
|
||||
|
||||
void ResetLightbar();
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.HLE.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Optional host-audio extension for backends that can accept the guest's
|
||||
/// interleaved PCM layout directly and perform device conversion themselves.
|
||||
/// </summary>
|
||||
public interface IHostPcmAudioOutput : IHostAudioOutput
|
||||
{
|
||||
IHostAudioStream OpenPcmStream(uint sampleRate, int channels, HostPcmFormat format);
|
||||
}
|
||||
|
||||
public enum HostPcmFormat
|
||||
{
|
||||
Signed16,
|
||||
Float32,
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.HLE.Host;
|
||||
|
||||
/// <summary>Input snapshots produced by the active host window.</summary>
|
||||
public interface IHostWindowInputSource
|
||||
{
|
||||
bool HasKeyboardFocus { get; }
|
||||
|
||||
bool IsKeyDown(int virtualKey);
|
||||
|
||||
int GetGamepadStates(Span<HostGamepadState> destination);
|
||||
|
||||
string? DescribeConnectedGamepad();
|
||||
|
||||
void SetRumble(byte largeMotor, byte smallMotor);
|
||||
|
||||
void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger);
|
||||
|
||||
void SetAdaptiveTriggerEffect(
|
||||
HostAdaptiveTriggerEffect? leftTrigger,
|
||||
HostAdaptiveTriggerEffect? rightTrigger);
|
||||
|
||||
void SetLightbar(byte red, byte green, byte blue);
|
||||
|
||||
void ResetLightbar();
|
||||
}
|
||||
|
||||
/// <summary>Process-wide bridge between the window layer and host input.</summary>
|
||||
public static class HostWindowInputSource
|
||||
{
|
||||
private static IHostWindowInputSource? _current;
|
||||
|
||||
public static IHostWindowInputSource? Current => Volatile.Read(ref _current);
|
||||
|
||||
public static void Set(IHostWindowInputSource source) =>
|
||||
Volatile.Write(ref _current, source);
|
||||
|
||||
public static void Clear(IHostWindowInputSource source) =>
|
||||
Interlocked.CompareExchange(ref _current, null, source);
|
||||
}
|
||||
@@ -14,6 +14,9 @@ namespace SharpEmu.HLE.Host.Posix;
|
||||
/// </summary>
|
||||
internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
|
||||
{
|
||||
// 32KB of stereo PCM16 at 48kHz is ~170ms; keep the same device-side
|
||||
// queue depth the WinMM/CoreAudio ports enforce in managed code.
|
||||
private const uint DeviceLatencyMicroseconds = 170_000;
|
||||
private const int StreamPlayback = 0;
|
||||
private const int FormatS16LittleEndian = 2;
|
||||
private const int AccessReadWriteInterleaved = 3;
|
||||
@@ -24,7 +27,7 @@ internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
|
||||
private nint _pcm;
|
||||
private bool _disposed;
|
||||
|
||||
public PosixAlsaAudioStream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
|
||||
public PosixAlsaAudioStream(uint sampleRate)
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
@@ -44,14 +47,6 @@ internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
|
||||
$"snd_pcm_open(\"{device}\") failed: {DescribeError(status)}.");
|
||||
}
|
||||
|
||||
// Match WinMM/CoreAudio soft queue depth: 32 KiB stereo PCM16 @ 48 kHz
|
||||
// is ~170 ms. AudioOut2 may request a deeper bed.
|
||||
var queuedBytes = Math.Max(maxQueuedPcmBytes, 4 * 1024);
|
||||
var latencyMicroseconds = (uint)Math.Clamp(
|
||||
(long)queuedBytes * 1_000_000L / Math.Max(sampleRate * 4u, 1u),
|
||||
20_000L,
|
||||
2_000_000L);
|
||||
|
||||
status = snd_pcm_set_params(
|
||||
_pcm,
|
||||
FormatS16LittleEndian,
|
||||
@@ -59,7 +54,7 @@ internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
|
||||
2,
|
||||
sampleRate,
|
||||
1,
|
||||
latencyMicroseconds);
|
||||
DeviceLatencyMicroseconds);
|
||||
if (status != 0)
|
||||
{
|
||||
_ = snd_pcm_close(_pcm);
|
||||
|
||||
@@ -13,11 +13,11 @@ namespace SharpEmu.HLE.Host.Posix;
|
||||
/// </summary>
|
||||
internal sealed unsafe class PosixCoreAudioStream : IHostAudioStream
|
||||
{
|
||||
private const int MaximumQueuedPcmBytes = 32 * 1024;
|
||||
private const uint FormatLinearPcm = 0x6C70636D; // 'lpcm'
|
||||
private const uint FlagIsSignedInteger = 0x4;
|
||||
private const uint FlagIsPacked = 0x8;
|
||||
|
||||
private readonly int _maximumQueuedPcmBytes;
|
||||
private readonly object _gate = new();
|
||||
private readonly AutoResetEvent _completion = new(false);
|
||||
private readonly Queue<nint> _freeBuffers = new();
|
||||
@@ -27,15 +27,13 @@ internal sealed unsafe class PosixCoreAudioStream : IHostAudioStream
|
||||
private bool _started;
|
||||
private bool _disposed;
|
||||
|
||||
public PosixCoreAudioStream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
|
||||
public PosixCoreAudioStream(uint sampleRate)
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
{
|
||||
throw new PlatformNotSupportedException("CoreAudio is only available on macOS.");
|
||||
}
|
||||
|
||||
_maximumQueuedPcmBytes = Math.Max(maxQueuedPcmBytes, 4 * 1024);
|
||||
|
||||
var format = new AudioStreamBasicDescription
|
||||
{
|
||||
SampleRate = sampleRate,
|
||||
@@ -75,7 +73,7 @@ internal sealed unsafe class PosixCoreAudioStream : IHostAudioStream
|
||||
|
||||
var outputLength = stereoPcm16.Length;
|
||||
while (_queuedPcmBytes != 0 &&
|
||||
_queuedPcmBytes + outputLength > _maximumQueuedPcmBytes)
|
||||
_queuedPcmBytes + outputLength > MaximumQueuedPcmBytes)
|
||||
{
|
||||
Monitor.Exit(_gate);
|
||||
try
|
||||
|
||||
@@ -12,10 +12,10 @@ internal sealed class PosixHostAudio : IHostAudioOutput
|
||||
{
|
||||
public string BackendName => OperatingSystem.IsMacOS() ? "coreaudio" : "alsa";
|
||||
|
||||
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
|
||||
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate)
|
||||
{
|
||||
return OperatingSystem.IsMacOS()
|
||||
? new PosixCoreAudioStream(sampleRate, maxQueuedPcmBytes)
|
||||
: new PosixAlsaAudioStream(sampleRate, maxQueuedPcmBytes);
|
||||
? new PosixCoreAudioStream(sampleRate)
|
||||
: new PosixAlsaAudioStream(sampleRate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.HLE.Host.Posix;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges a window-provided input source into the host input seam. POSIX
|
||||
/// hosts have no user32/XInput/raw-HID readers; keyboard and gamepad state
|
||||
/// come from the presenter's GLFW window instead, which registers itself via
|
||||
/// <see cref="SetSource"/> once the window exists. Until then (and with no
|
||||
/// window at all, e.g. headless runs) every query reports neutral input.
|
||||
/// Rumble and lightbar are unsupported by the GLFW input layer and no-op.
|
||||
/// </summary>
|
||||
public interface IPosixWindowInputSource
|
||||
{
|
||||
/// <summary>True while the window's keyboard is delivering events.</summary>
|
||||
bool HasKeyboardFocus { get; }
|
||||
|
||||
/// <summary>Windows virtual-key semantics; the source translates.</summary>
|
||||
bool IsKeyDown(int virtualKey);
|
||||
|
||||
/// <summary>Same contract as <see cref="IHostInput.GetGamepadStates"/>.</summary>
|
||||
int GetGamepadStates(Span<HostGamepadState> destination);
|
||||
|
||||
string? DescribeConnectedGamepad();
|
||||
}
|
||||
|
||||
// Public so the presenter's window layer (SharpEmu.Libs) can register its
|
||||
// input source; the platform still constructs the singleton itself.
|
||||
public sealed class PosixHostInput : IHostInput
|
||||
{
|
||||
private static volatile IPosixWindowInputSource? _source;
|
||||
|
||||
/// <summary>Called by the presenter's window layer when input is ready.</summary>
|
||||
public static void SetSource(IPosixWindowInputSource source)
|
||||
{
|
||||
_source = source;
|
||||
}
|
||||
|
||||
public void EnsureStarted()
|
||||
{
|
||||
// Device readers are event-driven off the window thread; nothing to start.
|
||||
}
|
||||
|
||||
public int GetGamepadStates(Span<HostGamepadState> destination)
|
||||
{
|
||||
return _source?.GetGamepadStates(destination) ?? 0;
|
||||
}
|
||||
|
||||
public string? DescribeConnectedGamepad() => _source?.DescribeConnectedGamepad();
|
||||
|
||||
public void SetRumble(byte largeMotor, byte smallMotor)
|
||||
{
|
||||
}
|
||||
|
||||
public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger)
|
||||
{
|
||||
}
|
||||
|
||||
public void SetLightbar(byte red, byte green, byte blue)
|
||||
{
|
||||
}
|
||||
|
||||
public void ResetLightbar()
|
||||
{
|
||||
}
|
||||
|
||||
public bool IsHostWindowFocused()
|
||||
{
|
||||
// GLFW only delivers key events to the focused window, so a
|
||||
// delivering keyboard implies focus.
|
||||
return _source?.HasKeyboardFocus ?? IsEmbeddedX11WindowFocused();
|
||||
}
|
||||
|
||||
public bool IsKeyDown(int virtualKey)
|
||||
{
|
||||
var source = _source;
|
||||
if (source is not null)
|
||||
{
|
||||
return source.IsKeyDown(virtualKey);
|
||||
}
|
||||
|
||||
return IsEmbeddedX11WindowFocused() && IsEmbeddedX11KeyDown(virtualKey);
|
||||
}
|
||||
|
||||
private static bool IsEmbeddedX11WindowFocused()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var display = HostSessionControl.EmbeddedHostDisplay;
|
||||
var window = HostSessionControl.EmbeddedHostWindow;
|
||||
if (display == 0 || window == 0 || XGetInputFocus(display, out var focusedWindow, out _) == 0 || focusedWindow == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return GetTopLevelWindow(display, focusedWindow) == GetTopLevelWindow(display, window);
|
||||
}
|
||||
|
||||
private static bool IsEmbeddedX11KeyDown(int virtualKey)
|
||||
{
|
||||
var display = HostSessionControl.EmbeddedHostDisplay;
|
||||
var keysym = ToX11Keysym(virtualKey);
|
||||
if (display == 0 || keysym == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var keycode = XKeysymToKeycode(display, keysym);
|
||||
if (keycode == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var keymap = new byte[32];
|
||||
XQueryKeymap(display, keymap);
|
||||
return (keymap[keycode >> 3] & (1 << (keycode & 7))) != 0;
|
||||
}
|
||||
|
||||
private static nint GetTopLevelWindow(nint display, nint window)
|
||||
{
|
||||
var current = window;
|
||||
for (var depth = 0; depth < 16; depth++)
|
||||
{
|
||||
if (XQueryTree(display, current, out var root, out var parent, out var children, out _) == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (children != 0)
|
||||
{
|
||||
XFree(children);
|
||||
}
|
||||
|
||||
if (parent == 0 || parent == root)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static nuint ToX11Keysym(int virtualKey)
|
||||
{
|
||||
return virtualKey switch
|
||||
{
|
||||
0x08 => 0xFF08, // Backspace
|
||||
0x09 => 0xFF09, // Tab
|
||||
0x0D => 0xFF0D, // Return
|
||||
0x1B => 0xFF1B, // Escape
|
||||
0x25 => 0xFF51, // Left
|
||||
0x26 => 0xFF52, // Up
|
||||
0x27 => 0xFF53, // Right
|
||||
0x28 => 0xFF54, // Down
|
||||
>= 0x41 and <= 0x5A => (nuint)virtualKey,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
||||
private static extern int XGetInputFocus(nint display, out nint focus, out int revertTo);
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
||||
private static extern int XQueryKeymap(nint display, [System.Runtime.InteropServices.Out] byte[] keysReturn);
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
||||
private static extern byte XKeysymToKeycode(nint display, nuint keysym);
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
||||
private static extern int XQueryTree(
|
||||
nint display,
|
||||
nint window,
|
||||
out nint root,
|
||||
out nint parent,
|
||||
out nint children,
|
||||
out uint childCount);
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
||||
private static extern int XFree(nint data);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE.Host.Sdl;
|
||||
|
||||
namespace SharpEmu.HLE.Host.Posix;
|
||||
|
||||
internal sealed class PosixHostPlatform : IHostPlatform
|
||||
@@ -13,7 +11,7 @@ internal sealed class PosixHostPlatform : IHostPlatform
|
||||
|
||||
public IHostSymbolResolver Symbols { get; } = new PosixHostSymbolResolver();
|
||||
|
||||
public IHostAudioOutput Audio { get; } = new SdlHostAudio();
|
||||
public IHostAudioOutput Audio { get; } = new PosixHostAudio();
|
||||
|
||||
public IHostInput Input { get; } = new WindowHostInput();
|
||||
public IHostInput Input { get; } = new PosixHostInput();
|
||||
}
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using SDL;
|
||||
using static SDL.SDL3;
|
||||
|
||||
namespace SharpEmu.HLE.Host.Sdl;
|
||||
|
||||
internal sealed unsafe class SdlHostAudio : IHostPcmAudioOutput
|
||||
{
|
||||
/// <summary>
|
||||
/// Cap for streams this class paces itself (AudioOut). Blocking the guest
|
||||
/// here is that path's only pacing, so the device settles at this depth —
|
||||
/// it is the playback latency, and the floor under it is how much jitter the
|
||||
/// stream can absorb before it runs dry.
|
||||
/// </summary>
|
||||
private static readonly int TargetQueuedMilliseconds =
|
||||
int.TryParse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_AUDIO_LATENCY_MS"),
|
||||
out var latencyMs) && latencyMs > 0
|
||||
? latencyMs
|
||||
: 60;
|
||||
|
||||
private const int MaximumWaitMilliseconds = 250;
|
||||
private static readonly object InitGate = new();
|
||||
private static bool _initialized;
|
||||
|
||||
public string BackendName => "sdl3";
|
||||
|
||||
/// <summary>
|
||||
/// Stereo PCM16 stream with a caller-chosen backpressure cap. Callers that
|
||||
/// pace the guest themselves pass a deeper cap so this class's backpressure
|
||||
/// does not fight their pacing.
|
||||
/// </summary>
|
||||
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
|
||||
=> OpenStream(
|
||||
sampleRate,
|
||||
channels: 2,
|
||||
HostPcmFormat.Signed16,
|
||||
maxQueuedPcmBytes > 0 ? maxQueuedPcmBytes : 32 * 1024);
|
||||
|
||||
/// <summary>
|
||||
/// Guest-format stream for AudioOut, which has no queue model of its own:
|
||||
/// blocking here is that path's only pacing, so the device settles at
|
||||
/// TargetQueuedMilliseconds and that depth is the playback latency.
|
||||
/// </summary>
|
||||
public IHostAudioStream OpenPcmStream(uint sampleRate, int channels, HostPcmFormat format)
|
||||
{
|
||||
var bytesPerSample = format == HostPcmFormat.Float32 ? sizeof(float) : sizeof(short);
|
||||
var cap = checked((int)((long)sampleRate * channels * bytesPerSample *
|
||||
TargetQueuedMilliseconds / 1_000));
|
||||
return OpenStream(sampleRate, channels, format, cap);
|
||||
}
|
||||
|
||||
private static IHostAudioStream OpenStream(
|
||||
uint sampleRate,
|
||||
int channels,
|
||||
HostPcmFormat format,
|
||||
int maximumQueuedBytes)
|
||||
{
|
||||
if (sampleRate is < 8_000 or > 384_000 || channels is < 1 or > 8)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
sampleRate is < 8_000 or > 384_000 ? nameof(sampleRate) : nameof(channels));
|
||||
}
|
||||
|
||||
EnsureInitialized();
|
||||
return new AudioStream(sampleRate, channels, format, maximumQueuedBytes);
|
||||
}
|
||||
|
||||
private static void EnsureInitialized()
|
||||
{
|
||||
lock (InitGate)
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ((SDL_WasInit(SDL_InitFlags.SDL_INIT_AUDIO) & SDL_InitFlags.SDL_INIT_AUDIO) == 0 &&
|
||||
!SDL_InitSubSystem(SDL_InitFlags.SDL_INIT_AUDIO))
|
||||
{
|
||||
throw new InvalidOperationException($"SDL audio initialization failed: {GetError()}");
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetError()
|
||||
{
|
||||
var error = Unsafe_SDL_GetError();
|
||||
return error is null ? "unknown SDL error" : Marshal.PtrToStringUTF8((nint)error) ?? "unknown SDL error";
|
||||
}
|
||||
|
||||
private static readonly bool _traceQueue = string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_LOG_AUDIO_QUEUE"),
|
||||
"1",
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static int _nextStreamId;
|
||||
|
||||
private sealed class AudioStream : IHostAudioStream
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly int _maximumQueuedBytes;
|
||||
private readonly int _bytesPerFrame;
|
||||
private readonly uint _sampleRate;
|
||||
private readonly int _streamId = Interlocked.Increment(ref _nextStreamId);
|
||||
private SDL_AudioStream* _stream;
|
||||
private bool _disposed;
|
||||
private long _totalSubmittedBytes;
|
||||
|
||||
// Queue diagnostics for the current report window.
|
||||
private long _windowStart = Stopwatch.GetTimestamp();
|
||||
private long _submissions;
|
||||
private long _submittedBytes;
|
||||
private long _blockedTicks;
|
||||
private long _drops;
|
||||
private long _emptyObservations;
|
||||
private int _minQueuedBytes = int.MaxValue;
|
||||
private int _maxQueuedBytes;
|
||||
private long _queuedByteSum;
|
||||
|
||||
public AudioStream(
|
||||
uint sampleRate,
|
||||
int channels,
|
||||
HostPcmFormat format,
|
||||
int maximumQueuedBytes)
|
||||
{
|
||||
var bytesPerSample = format == HostPcmFormat.Float32 ? sizeof(float) : sizeof(short);
|
||||
_bytesPerFrame = channels * bytesPerSample;
|
||||
_sampleRate = sampleRate;
|
||||
var spec = new SDL_AudioSpec
|
||||
{
|
||||
format = format == HostPcmFormat.Float32
|
||||
? SDL_AudioFormat.SDL_AUDIO_F32LE
|
||||
: SDL_AudioFormat.SDL_AUDIO_S16LE,
|
||||
channels = checked((byte)channels),
|
||||
freq = checked((int)sampleRate),
|
||||
};
|
||||
|
||||
_stream = SDL_OpenAudioDeviceStream(
|
||||
SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK,
|
||||
&spec,
|
||||
null,
|
||||
IntPtr.Zero);
|
||||
if (_stream is null)
|
||||
{
|
||||
throw new InvalidOperationException($"SDL audio stream creation failed: {GetError()}");
|
||||
}
|
||||
|
||||
if (!SDL_ResumeAudioStreamDevice(_stream))
|
||||
{
|
||||
SDL_DestroyAudioStream(_stream);
|
||||
_stream = null;
|
||||
throw new InvalidOperationException($"SDL audio stream start failed: {GetError()}");
|
||||
}
|
||||
|
||||
_maximumQueuedBytes = maximumQueuedBytes;
|
||||
}
|
||||
|
||||
public int QueuedMilliseconds
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed || _stream is null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
var bytesPerSecond = (double)_bytesPerFrame * _sampleRate;
|
||||
return bytesPerSecond <= 0
|
||||
? -1
|
||||
: (int)(SDL_GetAudioStreamQueued(_stream) / bytesPerSecond * 1000.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool Submit(ReadOnlySpan<byte> pcm)
|
||||
{
|
||||
if (pcm.IsEmpty)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed || _stream is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var blockStart = Stopwatch.GetTimestamp();
|
||||
var deadline = blockStart +
|
||||
(Stopwatch.Frequency * MaximumWaitMilliseconds / 1_000);
|
||||
int queued;
|
||||
var overrun = false;
|
||||
while ((queued = SDL_GetAudioStreamQueued(_stream)) > _maximumQueuedBytes)
|
||||
{
|
||||
if (Stopwatch.GetTimestamp() >= deadline)
|
||||
{
|
||||
// Enqueue anyway rather than discarding the buffer. A gap in
|
||||
// the stream is an audible click; the extra latency of one
|
||||
// over-deep submission is not, and the queue recovers as soon
|
||||
// as the device drains back under the cap.
|
||||
overrun = true;
|
||||
break;
|
||||
}
|
||||
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
|
||||
RecordSubmission(queued, blockStart, dropped: overrun, bytes: pcm.Length);
|
||||
bool submitted;
|
||||
fixed (byte* data = pcm)
|
||||
{
|
||||
submitted = SDL_PutAudioStreamData(_stream, (nint)data, pcm.Length);
|
||||
}
|
||||
|
||||
if (submitted)
|
||||
{
|
||||
// Everything handed over minus what the device still holds is
|
||||
// what the player has actually heard.
|
||||
_totalSubmittedBytes += pcm.Length;
|
||||
var bytesPerSecond = (double)_bytesPerFrame * _sampleRate;
|
||||
if (bytesPerSecond > 0)
|
||||
{
|
||||
GuestAudioClock.Report(
|
||||
Math.Max(0, _totalSubmittedBytes - queued - pcm.Length) / bytesPerSecond);
|
||||
}
|
||||
}
|
||||
|
||||
return submitted;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Samples the queue depth at the moment the guest was allowed to write.
|
||||
/// That depth is the playback latency the guest's audio is subject to, so
|
||||
/// it is the number to look at when the sound is late; an observed depth
|
||||
/// of zero is a genuine underrun, which is what a crackle sounds like.
|
||||
/// Caller holds <see cref="_gate"/>.
|
||||
/// </summary>
|
||||
private void RecordSubmission(int queuedBytes, long blockStart, bool dropped, int bytes)
|
||||
{
|
||||
if (!_traceQueue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
_submissions++;
|
||||
_submittedBytes += bytes;
|
||||
_blockedTicks += now - blockStart;
|
||||
_queuedByteSum += queuedBytes;
|
||||
_minQueuedBytes = Math.Min(_minQueuedBytes, queuedBytes);
|
||||
_maxQueuedBytes = Math.Max(_maxQueuedBytes, queuedBytes);
|
||||
if (dropped)
|
||||
{
|
||||
_drops++;
|
||||
}
|
||||
|
||||
if (queuedBytes == 0)
|
||||
{
|
||||
_emptyObservations++;
|
||||
}
|
||||
|
||||
var elapsedTicks = now - _windowStart;
|
||||
if (elapsedTicks < Stopwatch.Frequency)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_windowStart = now;
|
||||
var seconds = elapsedTicks / (double)Stopwatch.Frequency;
|
||||
var bytesPerSecond = (double)_bytesPerFrame * _sampleRate;
|
||||
Console.Error.WriteLine(
|
||||
$"[PERF][AUDIO] stream#{_streamId} {seconds:F1}s " +
|
||||
$"queued_ms min={ToMilliseconds(_minQueuedBytes, bytesPerSecond):F0} " +
|
||||
$"avg={ToMilliseconds((int)(_queuedByteSum / Math.Max(1, _submissions)), bytesPerSecond):F0} " +
|
||||
$"max={ToMilliseconds(_maxQueuedBytes, bytesPerSecond):F0} " +
|
||||
$"cap={ToMilliseconds(_maximumQueuedBytes, bytesPerSecond):F0} " +
|
||||
$"submits/s={_submissions / seconds:F0} " +
|
||||
$"fill={_submittedBytes / seconds / bytesPerSecond * 100.0:F0}% " +
|
||||
$"blocked={_blockedTicks * 100.0 / elapsedTicks:F0}% " +
|
||||
$"empty={_emptyObservations} drops={_drops}");
|
||||
|
||||
_submissions = 0;
|
||||
_submittedBytes = 0;
|
||||
_blockedTicks = 0;
|
||||
_drops = 0;
|
||||
_emptyObservations = 0;
|
||||
_minQueuedBytes = int.MaxValue;
|
||||
_maxQueuedBytes = 0;
|
||||
_queuedByteSum = 0;
|
||||
}
|
||||
|
||||
private static double ToMilliseconds(int bytes, double bytesPerSecond) =>
|
||||
bytesPerSecond <= 0 ? 0 : bytes / bytesPerSecond * 1000.0;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
if (_stream is not null)
|
||||
{
|
||||
SDL_ClearAudioStream(_stream);
|
||||
SDL_DestroyAudioStream(_stream);
|
||||
_stream = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.HLE.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Routes emulated input through the active cross-platform host window.
|
||||
/// </summary>
|
||||
internal sealed class WindowHostInput : IHostInput
|
||||
{
|
||||
public void EnsureStarted()
|
||||
{
|
||||
// SDL owns device discovery and pumps it on the window thread.
|
||||
}
|
||||
|
||||
public int GetGamepadStates(Span<HostGamepadState> destination) =>
|
||||
HostWindowInputSource.Current?.GetGamepadStates(destination) ?? 0;
|
||||
|
||||
public string? DescribeConnectedGamepad() =>
|
||||
HostWindowInputSource.Current?.DescribeConnectedGamepad();
|
||||
|
||||
public void SetRumble(byte largeMotor, byte smallMotor) =>
|
||||
HostWindowInputSource.Current?.SetRumble(largeMotor, smallMotor);
|
||||
|
||||
public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) =>
|
||||
HostWindowInputSource.Current?.SetTriggerRumble(leftTrigger, rightTrigger);
|
||||
|
||||
public void SetAdaptiveTriggerEffect(
|
||||
HostAdaptiveTriggerEffect? leftTrigger,
|
||||
HostAdaptiveTriggerEffect? rightTrigger) =>
|
||||
HostWindowInputSource.Current?.SetAdaptiveTriggerEffect(leftTrigger, rightTrigger);
|
||||
|
||||
public void SetLightbar(byte red, byte green, byte blue) =>
|
||||
HostWindowInputSource.Current?.SetLightbar(red, green, blue);
|
||||
|
||||
public void ResetLightbar() => HostWindowInputSource.Current?.ResetLightbar();
|
||||
|
||||
public bool IsHostWindowFocused() =>
|
||||
HostWindowInputSource.Current?.HasKeyboardFocus ?? false;
|
||||
|
||||
public bool IsKeyDown(int virtualKey) =>
|
||||
HostWindowInputSource.Current?.IsKeyDown(virtualKey) ?? false;
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace SharpEmu.HLE.Host.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a DualSense controller over raw HID on a background thread.
|
||||
/// Supports USB (input report 0x01) and Bluetooth (extended report 0x31,
|
||||
/// activated by requesting feature report 0x05), with hot-plug retry.
|
||||
/// </summary>
|
||||
public static class WindowsDualSenseReader
|
||||
{
|
||||
private const ushort SonyVendorId = 0x054C;
|
||||
private const ushort DualSenseProductId = 0x0CE6;
|
||||
private const ushort DualSenseEdgeProductId = 0x0DF2;
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static HostGamepadState _state;
|
||||
private static bool _started;
|
||||
|
||||
// Output (rumble/lightbar) state, all guarded by Gate.
|
||||
private static string? _devicePath;
|
||||
private static bool _bluetooth;
|
||||
private static bool _outputReady;
|
||||
private static bool _lightbarSetupPending;
|
||||
private static byte _outputSequence;
|
||||
private static FileStream? _outputStream;
|
||||
private static byte _motorLeft;
|
||||
private static byte _motorRight;
|
||||
private static byte _lightbarRed;
|
||||
private static byte _lightbarGreen;
|
||||
private static byte _lightbarBlue = 64; // PS-style blue default
|
||||
private static byte _playerLeds = 0x04; // center LED = player 1
|
||||
|
||||
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
|
||||
public static void EnsureStarted()
|
||||
{
|
||||
// The GUI source-links this reader and calls it directly, without the
|
||||
// host-platform resolution that otherwise guarantees Windows.
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
if (_started)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_started = true;
|
||||
var thread = new Thread(ReadLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "DualSenseReader",
|
||||
};
|
||||
thread.Start();
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetState(out HostGamepadState state)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
state = _state;
|
||||
}
|
||||
|
||||
return state.Connected;
|
||||
}
|
||||
|
||||
private static void SetState(in HostGamepadState state)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
_state = state;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sets rumble; large = left/strong motor, small = right/weak.</summary>
|
||||
internal static void SetRumble(byte largeMotor, byte smallMotor)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (_motorLeft == largeMotor && _motorRight == smallMotor)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_motorLeft = largeMotor;
|
||||
_motorRight = smallMotor;
|
||||
SendOutputLocked();
|
||||
}
|
||||
}
|
||||
|
||||
internal static void SetLightbar(byte red, byte green, byte blue)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (_lightbarRed == red && _lightbarGreen == green && _lightbarBlue == blue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lightbarRed = red;
|
||||
_lightbarGreen = green;
|
||||
_lightbarBlue = blue;
|
||||
SendOutputLocked();
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ResetLightbar() => SetLightbar(0, 0, 64);
|
||||
|
||||
private static void OnDeviceIdentified(string path, bool bluetooth)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
_devicePath = path;
|
||||
_bluetooth = bluetooth;
|
||||
_outputReady = true;
|
||||
_lightbarSetupPending = true;
|
||||
// Announce ourselves on the hardware: default lightbar + player 1 LED.
|
||||
SendOutputLocked();
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnDeviceLost()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
_devicePath = null;
|
||||
_outputReady = false;
|
||||
_motorLeft = 0;
|
||||
_motorRight = 0;
|
||||
_outputStream?.Dispose();
|
||||
_outputStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SendOutputLocked()
|
||||
{
|
||||
if (!_outputReady || _devicePath is null)
|
||||
{
|
||||
return; // flushed by OnDeviceIdentified once connected
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_outputStream is null)
|
||||
{
|
||||
var handle = WindowsHidNative.CreateFile(
|
||||
_devicePath,
|
||||
WindowsHidNative.GenericRead | WindowsHidNative.GenericWrite,
|
||||
WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite,
|
||||
0, WindowsHidNative.OpenExisting, 0, 0);
|
||||
if (handle.IsInvalid)
|
||||
{
|
||||
handle.Dispose();
|
||||
return; // read-only device access: outputs unavailable
|
||||
}
|
||||
|
||||
_outputStream = new FileStream(handle, FileAccess.Write, bufferSize: 1);
|
||||
}
|
||||
|
||||
var report = BuildOutputReportLocked();
|
||||
_outputStream.Write(report, 0, report.Length);
|
||||
_outputStream.Flush();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_outputStream?.Dispose();
|
||||
_outputStream = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildOutputReportLocked()
|
||||
{
|
||||
// Common 47-byte output payload (offsets per the DualSense output
|
||||
// report layout, same as Linux hid-playstation).
|
||||
Span<byte> common = stackalloc byte[47];
|
||||
common[0] = 0x03; // valid_flag0: compatible vibration + haptics select
|
||||
common[1] = 0x04 | 0x10; // valid_flag1: lightbar + player indicator
|
||||
common[2] = _motorRight; // right (weak) motor
|
||||
common[3] = _motorLeft; // left (strong) motor
|
||||
if (_lightbarSetupPending)
|
||||
{
|
||||
common[38] |= 0x02; // valid_flag2: lightbar setup control enable
|
||||
common[41] = 0x01; // lightbar_setup: light on
|
||||
_lightbarSetupPending = false;
|
||||
}
|
||||
|
||||
common[43] = _playerLeds;
|
||||
common[44] = _lightbarRed;
|
||||
common[45] = _lightbarGreen;
|
||||
common[46] = _lightbarBlue;
|
||||
|
||||
if (!_bluetooth)
|
||||
{
|
||||
var usbReport = new byte[48];
|
||||
usbReport[0] = 0x02;
|
||||
common.CopyTo(usbReport.AsSpan(1));
|
||||
return usbReport;
|
||||
}
|
||||
|
||||
// Bluetooth: 0x31 wrapper with sequence tag and CRC32 over a 0xA2
|
||||
// seed byte plus the first 74 report bytes.
|
||||
var btReport = new byte[78];
|
||||
btReport[0] = 0x31;
|
||||
btReport[1] = (byte)((_outputSequence & 0x0F) << 4);
|
||||
_outputSequence = (byte)((_outputSequence + 1) & 0x0F);
|
||||
btReport[2] = 0x10;
|
||||
common.CopyTo(btReport.AsSpan(3));
|
||||
var crc = Crc32(0xA2, btReport.AsSpan(0, 74));
|
||||
btReport[74] = (byte)crc;
|
||||
btReport[75] = (byte)(crc >> 8);
|
||||
btReport[76] = (byte)(crc >> 16);
|
||||
btReport[77] = (byte)(crc >> 24);
|
||||
return btReport;
|
||||
}
|
||||
|
||||
private static uint Crc32(byte seed, ReadOnlySpan<byte> data)
|
||||
{
|
||||
var crc = Crc32Update(0xFFFFFFFFu, seed);
|
||||
foreach (var value in data)
|
||||
{
|
||||
crc = Crc32Update(crc, value);
|
||||
}
|
||||
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
private static uint Crc32Update(uint crc, byte value)
|
||||
{
|
||||
crc ^= value;
|
||||
for (var bit = 0; bit < 8; bit++)
|
||||
{
|
||||
crc = (crc >> 1) ^ (0xEDB88320u & (uint)-(int)(crc & 1));
|
||||
}
|
||||
|
||||
return crc;
|
||||
}
|
||||
|
||||
private static void ReadLoop()
|
||||
{
|
||||
var announcedConnect = false;
|
||||
while (true)
|
||||
{
|
||||
SafeFileHandle? handle = null;
|
||||
try
|
||||
{
|
||||
handle = OpenDualSense(out var devicePath);
|
||||
if (handle is null || devicePath is null)
|
||||
{
|
||||
SetState(default);
|
||||
announcedConnect = false;
|
||||
Thread.Sleep(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bluetooth quirk: the DualSense sends a simplified report
|
||||
// until feature report 0x05 is requested, which switches it
|
||||
// to the full 0x31 input report. Harmless over USB.
|
||||
var feature = new byte[41];
|
||||
feature[0] = 0x05;
|
||||
_ = WindowsHidNative.HidD_GetFeature(handle, feature, feature.Length);
|
||||
|
||||
if (!announcedConnect)
|
||||
{
|
||||
Console.Error.WriteLine("[LOADER][INFO] DualSense controller connected.");
|
||||
announcedConnect = true;
|
||||
}
|
||||
|
||||
using var stream = new FileStream(handle, FileAccess.Read, bufferSize: 1);
|
||||
handle = null; // stream owns it now
|
||||
var buffer = new byte[256];
|
||||
var transportKnown = false;
|
||||
while (true)
|
||||
{
|
||||
var read = stream.Read(buffer, 0, buffer.Length);
|
||||
if (read <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (TryParseReport(buffer.AsSpan(0, read), out var state))
|
||||
{
|
||||
if (!transportKnown)
|
||||
{
|
||||
// The first parsed report tells us the transport,
|
||||
// which the output (rumble/lightbar) path needs.
|
||||
transportKnown = true;
|
||||
OnDeviceIdentified(devicePath, bluetooth: buffer[0] == 0x31);
|
||||
}
|
||||
|
||||
SetState(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Unplugged or read error: fall through and retry.
|
||||
}
|
||||
finally
|
||||
{
|
||||
handle?.Dispose();
|
||||
}
|
||||
|
||||
if (announcedConnect)
|
||||
{
|
||||
Console.Error.WriteLine("[LOADER][INFO] DualSense controller disconnected.");
|
||||
announcedConnect = false;
|
||||
}
|
||||
|
||||
OnDeviceLost();
|
||||
SetState(default);
|
||||
Thread.Sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
private static SafeFileHandle? OpenDualSense(out string? devicePath)
|
||||
{
|
||||
devicePath = null;
|
||||
foreach (var path in WindowsHidNative.EnumerateHidDevicePaths())
|
||||
{
|
||||
// Open without access rights just to query VID/PID.
|
||||
using var probe = WindowsHidNative.CreateFile(
|
||||
path, 0, WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite, 0, WindowsHidNative.OpenExisting, 0, 0);
|
||||
if (probe.IsInvalid)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var attributes = new WindowsHidNative.HiddAttributes { Size = 12 };
|
||||
if (!WindowsHidNative.HidD_GetAttributes(probe, ref attributes) ||
|
||||
attributes.VendorId != SonyVendorId ||
|
||||
(attributes.ProductId != DualSenseProductId && attributes.ProductId != DualSenseEdgeProductId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read+write so feature reports work; fall back to read-only.
|
||||
var handle = WindowsHidNative.CreateFile(
|
||||
path,
|
||||
WindowsHidNative.GenericRead | WindowsHidNative.GenericWrite,
|
||||
WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite,
|
||||
0, WindowsHidNative.OpenExisting, 0, 0);
|
||||
if (handle.IsInvalid)
|
||||
{
|
||||
handle.Dispose();
|
||||
handle = WindowsHidNative.CreateFile(
|
||||
path,
|
||||
WindowsHidNative.GenericRead,
|
||||
WindowsHidNative.FileShareRead | WindowsHidNative.FileShareWrite,
|
||||
0, WindowsHidNative.OpenExisting, 0, 0);
|
||||
}
|
||||
|
||||
if (!handle.IsInvalid)
|
||||
{
|
||||
devicePath = path;
|
||||
return handle;
|
||||
}
|
||||
|
||||
handle.Dispose();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseReport(ReadOnlySpan<byte> report, out HostGamepadState state)
|
||||
{
|
||||
// USB: report id 0x01, payload starts at [1].
|
||||
// Bluetooth extended: report id 0x31, sequence byte at [1], payload at [2].
|
||||
int offset;
|
||||
if (report.Length >= 11 && report[0] == 0x01)
|
||||
{
|
||||
offset = 1;
|
||||
}
|
||||
else if (report.Length >= 12 && report[0] == 0x31)
|
||||
{
|
||||
offset = 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
state = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
var leftX = report[offset + 0];
|
||||
var leftY = report[offset + 1];
|
||||
var rightX = report[offset + 2];
|
||||
var rightY = report[offset + 3];
|
||||
var l2 = report[offset + 4];
|
||||
var r2 = report[offset + 5];
|
||||
var buttons0 = report[offset + 7];
|
||||
var buttons1 = report[offset + 8];
|
||||
var buttons2 = report[offset + 9];
|
||||
|
||||
var buttons = HostGamepadButtons.None;
|
||||
buttons |= (buttons0 & 0x10) != 0 ? HostGamepadButtons.Square : 0;
|
||||
buttons |= (buttons0 & 0x20) != 0 ? HostGamepadButtons.Cross : 0;
|
||||
buttons |= (buttons0 & 0x40) != 0 ? HostGamepadButtons.Circle : 0;
|
||||
buttons |= (buttons0 & 0x80) != 0 ? HostGamepadButtons.Triangle : 0;
|
||||
buttons |= HatToButtons(buttons0 & 0x0F);
|
||||
buttons |= (buttons1 & 0x01) != 0 ? HostGamepadButtons.L1 : 0;
|
||||
buttons |= (buttons1 & 0x02) != 0 ? HostGamepadButtons.R1 : 0;
|
||||
buttons |= (buttons1 & 0x04) != 0 ? HostGamepadButtons.L2 : 0;
|
||||
buttons |= (buttons1 & 0x08) != 0 ? HostGamepadButtons.R2 : 0;
|
||||
buttons |= (buttons1 & 0x20) != 0 ? HostGamepadButtons.Options : 0;
|
||||
buttons |= (buttons1 & 0x40) != 0 ? HostGamepadButtons.L3 : 0;
|
||||
buttons |= (buttons1 & 0x80) != 0 ? HostGamepadButtons.R3 : 0;
|
||||
buttons |= (buttons2 & 0x02) != 0 ? HostGamepadButtons.TouchPad : 0;
|
||||
|
||||
state = new HostGamepadState(
|
||||
Connected: true,
|
||||
Buttons: buttons,
|
||||
LeftX: leftX,
|
||||
LeftY: leftY,
|
||||
RightX: rightX,
|
||||
RightY: rightY,
|
||||
LeftTrigger: l2,
|
||||
RightTrigger: r2);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static HostGamepadButtons HatToButtons(int hat) => hat switch
|
||||
{
|
||||
0 => HostGamepadButtons.Up,
|
||||
1 => HostGamepadButtons.Up | HostGamepadButtons.Right,
|
||||
2 => HostGamepadButtons.Right,
|
||||
3 => HostGamepadButtons.Right | HostGamepadButtons.Down,
|
||||
4 => HostGamepadButtons.Down,
|
||||
5 => HostGamepadButtons.Down | HostGamepadButtons.Left,
|
||||
6 => HostGamepadButtons.Left,
|
||||
7 => HostGamepadButtons.Left | HostGamepadButtons.Up,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace SharpEmu.HLE.Host.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal Win32 HID interop used to talk to a DualSense controller
|
||||
/// directly, without any external input library.
|
||||
/// </summary>
|
||||
internal static partial class WindowsHidNative
|
||||
{
|
||||
internal const int DigcfPresent = 0x02;
|
||||
internal const int DigcfDeviceInterface = 0x10;
|
||||
internal const uint GenericRead = 0x80000000;
|
||||
internal const uint GenericWrite = 0x40000000;
|
||||
internal const uint FileShareRead = 0x1;
|
||||
internal const uint FileShareWrite = 0x2;
|
||||
internal const uint OpenExisting = 3;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct SpDeviceInterfaceData
|
||||
{
|
||||
public int CbSize;
|
||||
public Guid InterfaceClassGuid;
|
||||
public int Flags;
|
||||
public nint Reserved;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct HiddAttributes
|
||||
{
|
||||
public int Size;
|
||||
public ushort VendorId;
|
||||
public ushort ProductId;
|
||||
public ushort VersionNumber;
|
||||
}
|
||||
|
||||
[LibraryImport("hid.dll")]
|
||||
internal static partial void HidD_GetHidGuid(out Guid hidGuid);
|
||||
|
||||
[LibraryImport("hid.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool HidD_GetAttributes(SafeFileHandle hidDeviceObject, ref HiddAttributes attributes);
|
||||
|
||||
[LibraryImport("hid.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool HidD_GetFeature(SafeFileHandle hidDeviceObject, [In, Out] byte[] reportBuffer, int reportBufferLength);
|
||||
|
||||
[LibraryImport("setupapi.dll", EntryPoint = "SetupDiGetClassDevsW")]
|
||||
internal static partial nint SetupDiGetClassDevs(ref Guid classGuid, nint enumerator, nint hwndParent, int flags);
|
||||
|
||||
[LibraryImport("setupapi.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool SetupDiEnumDeviceInterfaces(
|
||||
nint deviceInfoSet,
|
||||
nint deviceInfoData,
|
||||
ref Guid interfaceClassGuid,
|
||||
int memberIndex,
|
||||
ref SpDeviceInterfaceData deviceInterfaceData);
|
||||
|
||||
[LibraryImport("setupapi.dll", EntryPoint = "SetupDiGetDeviceInterfaceDetailW")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool SetupDiGetDeviceInterfaceDetail(
|
||||
nint deviceInfoSet,
|
||||
ref SpDeviceInterfaceData deviceInterfaceData,
|
||||
nint deviceInterfaceDetailData,
|
||||
int deviceInterfaceDetailDataSize,
|
||||
out int requiredSize,
|
||||
nint deviceInfoData);
|
||||
|
||||
[LibraryImport("setupapi.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static partial bool SetupDiDestroyDeviceInfoList(nint deviceInfoSet);
|
||||
|
||||
[LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
|
||||
internal static partial SafeFileHandle CreateFile(
|
||||
string fileName,
|
||||
uint desiredAccess,
|
||||
uint shareMode,
|
||||
nint securityAttributes,
|
||||
uint creationDisposition,
|
||||
uint flagsAndAttributes,
|
||||
nint templateFile);
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates the device paths of all present HID interfaces.
|
||||
/// </summary>
|
||||
internal static List<string> EnumerateHidDevicePaths()
|
||||
{
|
||||
var paths = new List<string>();
|
||||
HidD_GetHidGuid(out var hidGuid);
|
||||
var deviceInfoSet = SetupDiGetClassDevs(ref hidGuid, 0, 0, DigcfPresent | DigcfDeviceInterface);
|
||||
if (deviceInfoSet == -1 || deviceInfoSet == 0)
|
||||
{
|
||||
return paths;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var interfaceData = new SpDeviceInterfaceData
|
||||
{
|
||||
CbSize = Marshal.SizeOf<SpDeviceInterfaceData>(),
|
||||
};
|
||||
|
||||
for (var index = 0; SetupDiEnumDeviceInterfaces(deviceInfoSet, 0, ref hidGuid, index, ref interfaceData); index++)
|
||||
{
|
||||
SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, 0, 0, out var requiredSize, 0);
|
||||
if (requiredSize <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var detailBuffer = Marshal.AllocHGlobal(requiredSize);
|
||||
try
|
||||
{
|
||||
// SP_DEVICE_INTERFACE_DETAIL_DATA_W.cbSize is 8 on x64
|
||||
// (DWORD + aligned WCHAR[1]); the path string follows it.
|
||||
Marshal.WriteInt32(detailBuffer, 8);
|
||||
if (SetupDiGetDeviceInterfaceDetail(deviceInfoSet, ref interfaceData, detailBuffer, requiredSize, out _, 0) &&
|
||||
Marshal.PtrToStringUni(detailBuffer + 4) is { Length: > 0 } path)
|
||||
{
|
||||
paths.Add(path);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(detailBuffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetupDiDestroyDeviceInfoList(deviceInfoSet);
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace SharpEmu.HLE.Host.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Windows input backend: DualSense over raw HID plus XInput controllers for gamepads,
|
||||
/// user32 for the keyboard-fallback queries. Rumble fans out to every reader; lightbar
|
||||
/// only exists on the DualSense.
|
||||
/// </summary>
|
||||
internal sealed partial class WindowsHostInput : IHostInput
|
||||
{
|
||||
public void EnsureStarted()
|
||||
{
|
||||
WindowsDualSenseReader.EnsureStarted();
|
||||
WindowsXInputReader.EnsureStarted();
|
||||
}
|
||||
|
||||
public int GetGamepadStates(Span<HostGamepadState> destination)
|
||||
{
|
||||
var count = 0;
|
||||
if (count < destination.Length && WindowsDualSenseReader.TryGetState(out var dualSense))
|
||||
{
|
||||
destination[count++] = dualSense;
|
||||
}
|
||||
|
||||
if (count < destination.Length && WindowsXInputReader.TryGetState(out var xinput))
|
||||
{
|
||||
destination[count++] = xinput;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public string? DescribeConnectedGamepad()
|
||||
{
|
||||
if (WindowsDualSenseReader.TryGetState(out _))
|
||||
{
|
||||
return "DualSense";
|
||||
}
|
||||
|
||||
return WindowsXInputReader.TryGetState(out _) ? "Xbox controller" : null;
|
||||
}
|
||||
|
||||
public void SetRumble(byte largeMotor, byte smallMotor)
|
||||
{
|
||||
WindowsDualSenseReader.SetRumble(largeMotor, smallMotor);
|
||||
WindowsXInputReader.SetRumble(largeMotor, smallMotor);
|
||||
}
|
||||
|
||||
public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) =>
|
||||
WindowsXInputReader.SetTriggerRumble(leftTrigger, rightTrigger);
|
||||
|
||||
public void SetLightbar(byte red, byte green, byte blue) =>
|
||||
WindowsDualSenseReader.SetLightbar(red, green, blue);
|
||||
|
||||
public void ResetLightbar() => WindowsDualSenseReader.ResetLightbar();
|
||||
|
||||
public bool IsHostWindowFocused()
|
||||
{
|
||||
var foregroundWindow = GetForegroundWindow();
|
||||
if (foregroundWindow == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
GetWindowThreadProcessId(foregroundWindow, out var processId);
|
||||
if (processId == (uint)Environment.ProcessId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// The GUI runs the emulator in an isolated child process. Its native
|
||||
// Vulkan surface is a child of the GUI window, so the foreground
|
||||
// window belongs to the launcher process rather than this one.
|
||||
var embeddedHostWindow = HostSessionControl.EmbeddedHostWindow;
|
||||
var hostTopLevelWindow = embeddedHostWindow == 0
|
||||
? 0
|
||||
: GetAncestor(embeddedHostWindow, GetAncestorRoot);
|
||||
return hostTopLevelWindow != 0 && foregroundWindow == hostTopLevelWindow;
|
||||
}
|
||||
|
||||
public bool IsKeyDown(int virtualKey) =>
|
||||
(GetAsyncKeyState(virtualKey) & 0x8000) != 0;
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
private static partial short GetAsyncKeyState(int vKey);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
private static partial nint GetForegroundWindow();
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
private static partial uint GetWindowThreadProcessId(nint hWnd, out uint processId);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
private static partial nint GetAncestor(nint hWnd, uint gaFlags);
|
||||
|
||||
private const uint GetAncestorRoot = 2;
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE.Host.Sdl;
|
||||
|
||||
namespace SharpEmu.HLE.Host.Windows;
|
||||
|
||||
internal sealed class WindowsHostPlatform : IHostPlatform
|
||||
@@ -13,7 +11,7 @@ internal sealed class WindowsHostPlatform : IHostPlatform
|
||||
|
||||
public IHostSymbolResolver Symbols { get; } = new WindowsHostSymbolResolver();
|
||||
|
||||
public IHostAudioOutput Audio { get; } = new SdlHostAudio();
|
||||
public IHostAudioOutput Audio { get; } = new WindowsWaveOutAudio();
|
||||
|
||||
public IHostInput Input { get; } = new WindowHostInput();
|
||||
public IHostInput Input { get; } = new WindowsHostInput();
|
||||
}
|
||||
|
||||