mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-25 20:28:48 +08:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77fe07efc0 | |||
| 5f37dd85e0 | |||
| 38621e7be9 | |||
| c9c0793059 | |||
| d3600c9255 | |||
| 5f97031df5 | |||
| 2a4da8c0a9 | |||
| 4c37e64c66 | |||
| fc9e3ff393 | |||
| eb47d753f6 | |||
| 6aa78bb55b | |||
| 9be6f85ef0 | |||
| 4c8c67a3dd | |||
| ada67a1924 | |||
| 2379e8988c | |||
| 105c58b380 | |||
| da35f0db47 | |||
| 1f3963c543 | |||
| 4bb1af93d7 | |||
| 0ae785c617 | |||
| e01092aa38 | |||
| 224a36eba7 | |||
| ac883e44fa | |||
| 25d741b35b | |||
| dce7c87c4d | |||
| 6ee445f0c2 | |||
| 9d187dec55 | |||
| 3574a3b145 | |||
| a1cbff8a9c | |||
| db9b20481c | |||
| 8cd46243ab | |||
| 3334707f7c | |||
| 20eda4443c | |||
| bb3318a503 | |||
| d151e151c2 | |||
| 472fc96a37 | |||
| 33be88bdf9 | |||
| 184e24fbb6 | |||
| 327018e80a | |||
| 04557fd250 | |||
| 90c72ebecf | |||
| 8ef5a54ee4 | |||
| 0c467e8c57 | |||
| 9ff60abb9b | |||
| 3ebfc56d4c | |||
| 73e8821d5b | |||
| bc51cc2c4d | |||
| d7f6e3f578 | |||
| e56e74f960 | |||
| 0f224ec036 | |||
| 85dc98dedc | |||
| 5d7d8e0edd | |||
| a60bfc9c83 | |||
| 0b83b34cda | |||
| 09812600a0 | |||
| 3005babab8 | |||
| 09bd4f028b | |||
| 2bda253927 | |||
| 71e5912c75 | |||
| a030cb5a5d | |||
| 336286e588 | |||
| cab001f265 | |||
| bab965e394 | |||
| daaeb6213e | |||
| 94153955b0 | |||
| 6dda6589d0 | |||
| 2ced3af114 | |||
| 18708aa2d3 | |||
| a709ccca17 |
@@ -89,7 +89,6 @@ 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
|
||||
@@ -121,24 +120,13 @@ 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.RELEASE_DIR }}\sharpemu-${{ needs.init.outputs.version }}-win-x64.zip
|
||||
path: ${{ env.PUBLISH_DIR }}
|
||||
if-no-files-found: error
|
||||
include-hidden-files: true
|
||||
|
||||
build-posix:
|
||||
name: Build ${{ matrix.rid }}
|
||||
@@ -158,7 +146,6 @@ 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
|
||||
@@ -223,19 +210,13 @@ 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.RELEASE_DIR }}/sharpemu-${{ needs.init.outputs.version }}-${{ matrix.rid }}.tar.gz
|
||||
path: ${{ env.PUBLISH_DIR }}
|
||||
if-no-files-found: error
|
||||
include-hidden-files: true
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
@@ -255,6 +236,28 @@ 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:
|
||||
@@ -264,9 +267,9 @@ jobs:
|
||||
RELEASE_TAG: ${{ needs.init.outputs.release-tag }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
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
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -42,3 +42,4 @@ ehthumbs.db
|
||||
|
||||
.vs/
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -9,11 +9,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<SharpEmuVersion>0.0.2-beta.3</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>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<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="FFmpeg.AutoGen" Version="7.1.1" />
|
||||
<PackageVersion Include="Iced" Version="1.21.0" />
|
||||
<PackageVersion Include="Microsoft.Build.Framework" Version="17.14.8" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||
|
||||
@@ -8,6 +8,8 @@ path = [
|
||||
"**/packages.lock.json",
|
||||
"scripts/ps5_names.txt",
|
||||
"src/SharpEmu.GUI/Languages/**",
|
||||
"src/SharpEmu.ShaderCompiler.Metal/Templates/**",
|
||||
"tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/**",
|
||||
"_logs/**",
|
||||
".github/images/**",
|
||||
".github/pull_request_template.md",
|
||||
|
||||
@@ -14,11 +14,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Project Path="src/SharpEmu.Libs/SharpEmu.Libs.csproj" />
|
||||
<Project Path="src/SharpEmu.Logging/SharpEmu.Logging.csproj" />
|
||||
<Project Path="src/SharpEmu.ShaderCompiler/SharpEmu.ShaderCompiler.csproj" />
|
||||
<Project Path="src/SharpEmu.ShaderCompiler.Metal/SharpEmu.ShaderCompiler.Metal.csproj" />
|
||||
<Project Path="src/SharpEmu.ShaderCompiler.Vulkan/SharpEmu.ShaderCompiler.Vulkan.csproj" />
|
||||
<Project Path="src/SharpEmu.SourceGenerators/SharpEmu.SourceGenerators.csproj" />
|
||||
</Folder>
|
||||
<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.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.9 MiB |
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
# Aerolib Catalog
|
||||
|
||||
```bash
|
||||
# NID to export name
|
||||
python scripts/aerolib_catalog.py lookup Zxa0VhQVTsk
|
||||
|
||||
# Export name to NID
|
||||
python scripts/aerolib_catalog.py lookup sceKernelWaitSema
|
||||
|
||||
# Search export names
|
||||
python scripts/aerolib_catalog.py search VideoOut --limit 20
|
||||
|
||||
# Export all NID/name pairs to artifacts/aerolib.txt
|
||||
python scripts/aerolib_catalog.py export
|
||||
```
|
||||
+51
-22
@@ -9,38 +9,67 @@ 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 bridge is
|
||||
SharpEmu observes successful guest .bk2 opens and, when a Bink decoder 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.
|
||||
|
||||
Without an adapter, Bink movies are skipped by default: their open call returns
|
||||
not-found so games that mark cinematics as optional progress to their next
|
||||
state instead of waiting on an empty Bink GPU texture.
|
||||
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.
|
||||
|
||||
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. Set
|
||||
SHARPEMU_BINK_MODE=native to force native bridge mode.
|
||||
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.
|
||||
|
||||
## Supplying the adapter
|
||||
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.
|
||||
|
||||
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.
|
||||
## Supplying the FFmpeg libraries
|
||||
|
||||
Place the resulting libsharpemu_bink2_bridge.dylib next to the SharpEmu
|
||||
executable, or point to it explicitly:
|
||||
`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.
|
||||
|
||||
SHARPEMU_BINK2_BRIDGE=/absolute/path/libsharpemu_bink2_bridge.dylib \
|
||||
./SharpEmu /path/to/eboot.bin
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
If the bridge is absent in native mode, SharpEmu logs one informational line
|
||||
and retains the existing guest rendering path.
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<!--
|
||||
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.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.103",
|
||||
"rollForward": "disable"
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (C) 2026 SharpEmu Emulator Project
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
NID_SUFFIX = bytes.fromhex("518d64a635ded8c1e6b039b1c3e55230")
|
||||
NID_PATTERN = re.compile(r"^[A-Za-z0-9+-]{11}$")
|
||||
DEFAULT_NAMES_FILE = Path(__file__).resolve().with_name("ps5_names.txt")
|
||||
DEFAULT_EXPORT_FILE = Path(__file__).resolve().parents[1] / "artifacts" / "aerolib.txt"
|
||||
|
||||
|
||||
def compute_nid(export_name: str) -> str:
|
||||
digest = hashlib.sha1(export_name.encode("utf-8") + NID_SUFFIX).digest()
|
||||
encoded = base64.b64encode(digest[:8][::-1]).decode("ascii")
|
||||
return encoded.rstrip("=").replace("/", "-")
|
||||
|
||||
|
||||
def read_names(path: Path) -> list[str]:
|
||||
try:
|
||||
return [
|
||||
line.strip()
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
except OSError as error:
|
||||
raise SystemExit(f"Unable to read catalog '{path}': {error}") from error
|
||||
|
||||
|
||||
def write_pair(nid: str, export_name: str) -> None:
|
||||
print(f"{nid}\t{export_name}")
|
||||
|
||||
|
||||
def lookup(args: argparse.Namespace) -> int:
|
||||
value = args.value.strip()
|
||||
if NID_PATTERN.fullmatch(value):
|
||||
for export_name in read_names(args.names):
|
||||
if compute_nid(export_name) == value:
|
||||
write_pair(value, export_name)
|
||||
return 0
|
||||
|
||||
print(f"NID not found in catalog: {value}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
names = set(read_names(args.names))
|
||||
write_pair(compute_nid(value), value)
|
||||
if value not in names:
|
||||
print("Warning: export name is not present in the catalog.", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
def search(args: argparse.Namespace) -> int:
|
||||
names = read_names(args.names)
|
||||
if args.regex:
|
||||
try:
|
||||
pattern = re.compile(args.query, 0 if args.case_sensitive else re.IGNORECASE)
|
||||
except re.error as error:
|
||||
print(f"Invalid regular expression: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
matches = (name for name in names if pattern.search(name))
|
||||
elif args.case_sensitive:
|
||||
matches = (name for name in names if args.query in name)
|
||||
else:
|
||||
query = args.query.casefold()
|
||||
matches = (name for name in names if query in name.casefold())
|
||||
|
||||
count = 0
|
||||
for export_name in matches:
|
||||
write_pair(compute_nid(export_name), export_name)
|
||||
count += 1
|
||||
if args.limit and count >= args.limit:
|
||||
break
|
||||
|
||||
if count == 0:
|
||||
print(f"No catalog names matched: {args.query}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def export_catalog(args: argparse.Namespace) -> int:
|
||||
pairs = [(compute_nid(name), name) for name in read_names(args.names)]
|
||||
if args.sort == "nid":
|
||||
pairs.sort(key=lambda pair: (pair[0], pair[1]))
|
||||
elif args.sort == "name":
|
||||
pairs.sort(key=lambda pair: pair[1])
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with args.output.open("w", encoding="utf-8", newline="\n") as output:
|
||||
output.write("# NID\tExportName\n")
|
||||
for nid, export_name in pairs:
|
||||
output.write(f"{nid}\t{export_name}\n")
|
||||
except OSError as error:
|
||||
print(f"Unable to write catalog '{args.output}': {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Wrote {len(pairs)} entries to {args.output}")
|
||||
return 0
|
||||
|
||||
|
||||
def create_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Inspect the SharpEmu PS5 export-name/NID catalog.",
|
||||
epilog=(
|
||||
"Examples:\n"
|
||||
" python scripts/aerolib_catalog.py lookup Zxa0VhQVTsk\n"
|
||||
" python scripts/aerolib_catalog.py lookup sceKernelWaitSema\n"
|
||||
" python scripts/aerolib_catalog.py search VideoOut --limit 20\n"
|
||||
" python scripts/aerolib_catalog.py export"
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--names",
|
||||
type=Path,
|
||||
default=DEFAULT_NAMES_FILE,
|
||||
help=f"source name list (default: {DEFAULT_NAMES_FILE})",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
lookup_parser = subparsers.add_parser(
|
||||
"lookup", help="resolve a NID or calculate the NID for an export name"
|
||||
)
|
||||
lookup_parser.add_argument("value", help="11-character NID or exact export name")
|
||||
lookup_parser.set_defaults(handler=lookup)
|
||||
|
||||
search_parser = subparsers.add_parser(
|
||||
"search", help="find export names and print matching NID/name pairs"
|
||||
)
|
||||
search_parser.add_argument("query", help="name substring or regular expression")
|
||||
search_parser.add_argument(
|
||||
"--limit", type=int, default=50, help="maximum matches; 0 means unlimited"
|
||||
)
|
||||
search_parser.add_argument(
|
||||
"--case-sensitive", action="store_true", help="match case exactly"
|
||||
)
|
||||
search_parser.add_argument(
|
||||
"--regex", action="store_true", help="treat the query as a regular expression"
|
||||
)
|
||||
search_parser.set_defaults(handler=search)
|
||||
|
||||
export_parser = subparsers.add_parser(
|
||||
"export", help="write every NID/name pair to a tab-separated text file"
|
||||
)
|
||||
export_parser.add_argument(
|
||||
"output",
|
||||
type=Path,
|
||||
nargs="?",
|
||||
default=DEFAULT_EXPORT_FILE,
|
||||
help=f"output file (default: {DEFAULT_EXPORT_FILE})",
|
||||
)
|
||||
export_parser.add_argument(
|
||||
"--sort",
|
||||
choices=("source", "nid", "name"),
|
||||
default="nid",
|
||||
help="output ordering (default: nid)",
|
||||
)
|
||||
export_parser.set_defaults(handler=export_catalog)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = create_parser()
|
||||
args = parser.parse_args()
|
||||
return args.handler(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -45,11 +45,6 @@ internal static partial class Program
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
// Avoid blocking full collections while guest and render threads are
|
||||
// running, and establish the GC mode before the runtime reserves the
|
||||
// fixed guest address-space window.
|
||||
System.Runtime.GCSettings.LatencyMode = System.Runtime.GCLatencyMode.SustainedLowLatency;
|
||||
|
||||
try
|
||||
{
|
||||
return Run(args);
|
||||
@@ -69,6 +64,7 @@ internal static partial class Program
|
||||
}
|
||||
|
||||
args = NormalizeInternalArguments(args, out var isMitigatedChild);
|
||||
PreloadGlfw();
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
@@ -218,6 +214,27 @@ internal static partial class Program
|
||||
"as libvulkan.1.dylib.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SharpEmu.CLI.csproj publishes glfw into a "plugins" subfolder rather
|
||||
/// than flat next to the executable, which falls outside the default OS
|
||||
/// DLL/dlopen search path. Preloading it here by full path first means
|
||||
/// any later bare-name lookup (however Silk.NET/GLFW itself resolves the
|
||||
/// library) finds it already loaded in the process and reuses it -- the
|
||||
/// same technique <see cref="PreloadMacVulkanLoader"/> already relies on
|
||||
/// for the Vulkan loader.
|
||||
/// </summary>
|
||||
private static void PreloadGlfw()
|
||||
{
|
||||
var fileName = OperatingSystem.IsWindows() ? "glfw3.dll"
|
||||
: OperatingSystem.IsMacOS() ? "libglfw.3.dylib"
|
||||
: "libglfw.so.3";
|
||||
var candidate = Path.Combine(AppContext.BaseDirectory, "plugins", fileName);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
NativeLibrary.TryLoad(candidate, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static int RunEmulator(string[] args, bool isMitigatedChild)
|
||||
{
|
||||
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
|
||||
@@ -612,7 +629,7 @@ internal static partial class Program
|
||||
nint jobHandle = 0;
|
||||
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
|
||||
var created = CreateProcessW(
|
||||
processPath,
|
||||
null,
|
||||
cmdLineBuilder,
|
||||
0,
|
||||
0,
|
||||
@@ -1438,7 +1455,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,6 +20,11 @@ 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>
|
||||
@@ -49,7 +54,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<DebugType>none</DebugType>
|
||||
<DebugSymbols>false</DebugSymbols>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == ''">
|
||||
@@ -61,7 +65,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\LICENSE.txt">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
@@ -75,17 +79,75 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Keep glfw as a loose file next to the executable; every other native
|
||||
<!-- Native libraries (glfw, FFmpeg) publish into a subfolder next to the
|
||||
executable instead of sitting loose beside it, so the publish
|
||||
directory stays uncluttered as more native deps get added. The folder
|
||||
name is a fixed constant, not derived from the RID/architecture: each
|
||||
publish output only ever holds one architecture's binaries anyway, so
|
||||
varying the name added a class of bugs (RID resolution timing, host-OS
|
||||
vs. target-RID mixups) for no benefit. Runtime code (Program.cs's
|
||||
PreloadGlfw, FfmpegNativeBinkFrameSource's RootPath) uses the same
|
||||
literal "plugins" folder name. -->
|
||||
<PropertyGroup>
|
||||
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Keep glfw as a loose file in the native subfolder; every other native
|
||||
library is embedded into the single-file bundle. -->
|
||||
<Target Name="KeepGlfwOutsideSingleFile" AfterTargets="ComputeResolvedFilesToPublishList">
|
||||
<ItemGroup>
|
||||
<_GlfwPublishFiles Include="@(ResolvedFileToPublish)"
|
||||
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
|
||||
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
|
||||
<ResolvedFileToPublish Remove="@(_GlfwPublishFiles)" />
|
||||
<ResolvedFileToPublish Include="@(_GlfwPublishFiles)">
|
||||
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
||||
<RelativePath>$(NativeLibraryFolderName)/%(Filename)%(Extension)</RelativePath>
|
||||
</ResolvedFileToPublish>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<PropertyGroup>
|
||||
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
|
||||
<FfmpegRuntimeDir>
|
||||
$(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,4 +13,9 @@ 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,596 +0,0 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.3, )",
|
||||
"resolved": "10.0.3",
|
||||
"contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA=="
|
||||
},
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.25547.20250602",
|
||||
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||
},
|
||||
"Avalonia.BuildServices": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.2",
|
||||
"contentHash": "qHDToxto1e3hci5YqbG9n0Ty8mlp3zBUN5wT66wKqaDVzXyQ0do3EnRILd4Ke9jpvsktaPpgE0YjEk7hornryQ=="
|
||||
},
|
||||
"Avalonia.FreeDesktop": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "aUwv8BNruRUOaUfMu4U3uibIUS60/rSHgGOhd8zBkLkpxY3JFJvgRbeq5ZzHIyKXCuKi18PO00YHAgCarp3wdw==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18",
|
||||
"Tmds.DBus.Protocol": "0.21.3"
|
||||
}
|
||||
},
|
||||
"Avalonia.Native": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"Avalonia.Remote.Protocol": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "vw+6ZfgTuu72dA9aVWn6u56t2nrBd5MoMU0wo/qI9XJAl/c0oYYphIvwLvJP1JorubQY4UE3d0ac8ULBhrGBiA=="
|
||||
},
|
||||
"Avalonia.Skia": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "/B4aXmNRNjG8I5U/a1xJI+bIi0XO6DDzS3mBrIKlVnJRY2CyZiUeESRQXLnIU77Z9TvqkUROs+D47s085YjFtA==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18",
|
||||
"HarfBuzzSharp": "8.3.1.1",
|
||||
"HarfBuzzSharp.NativeAssets.Linux": "8.3.1.1",
|
||||
"HarfBuzzSharp.NativeAssets.WebAssembly": "8.3.1.1",
|
||||
"SkiaSharp": "2.88.9",
|
||||
"SkiaSharp.NativeAssets.Linux": "2.88.9",
|
||||
"SkiaSharp.NativeAssets.WebAssembly": "2.88.9"
|
||||
}
|
||||
},
|
||||
"Avalonia.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "eioUHkM2PeLPETd1aEks3rvb9plbba6buIrNdrqCpwE/qgHKUjvRNBd5mUQfAbGgTLiAes524gB8uUMDhrsJVQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18",
|
||||
"Avalonia.Angle.Windows.Natives": "2.1.25547.20250602"
|
||||
}
|
||||
},
|
||||
"Avalonia.X11": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "m4Ki/G5Dovnq+6QzfS0iGbK8V77Q6oTjToMLOB0CxPCCrl3Oxywh6kIjuGJDPaN6kopMmjxlNShyQf+vPYL+JA==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18",
|
||||
"Avalonia.FreeDesktop": "11.3.18",
|
||||
"Avalonia.Skia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "tLZN66oe/uiRPTZfrCU4i8ScVGwqHNh5MHrXj0yVf4l7Mz0FhTGnQ71RGySROTmdognAs0JtluHkL41pIabWuQ==",
|
||||
"dependencies": {
|
||||
"HarfBuzzSharp.NativeAssets.Win32": "8.3.1.1",
|
||||
"HarfBuzzSharp.NativeAssets.macOS": "8.3.1.1"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.WebAssembly": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "loJweK2u/mH/3C2zBa0ggJlITIszOkK64HLAZB7FUT670dTg965whLFYHDQo69NmC4+d9UN0icLC9VHidXaVCA=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||
},
|
||||
"MicroCom.Runtime": {
|
||||
"type": "Transitive",
|
||||
"resolved": "0.11.0",
|
||||
"contentHash": "MEnrZ3UIiH40hjzMDsxrTyi8dtqB5ziv3iBeeU4bXsL/7NLSal9F1lZKpK+tfBRnUoDSdtcW3KufE4yhATOMCA=="
|
||||
},
|
||||
"Microsoft.DotNet.PlatformAbstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.1.6",
|
||||
"contentHash": "jek4XYaQ/PGUwDKKhwR8K47Uh1189PFzMeLqO83mXrXQVIpARZCcfuDedH50YDTepBkfijCZN5U/vZi++erxtg=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyModel": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.9",
|
||||
"contentHash": "fNGvKct2De8ghm0Bpfq0iWthtzIWabgOTi+gJhNOPhNJIowXNEUE2eZNW/zNCzrHVA3PXg2yZ+3cWZndC2IqYA=="
|
||||
},
|
||||
"Silk.NET.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "D7AT/nnwlB+4RZ84XY8QNGBZMJI5z9l4CSSETIJ1wCfRJzRt/341y3MRZ4HbnFz4r/IGaWOEZr86iE+0/65yyQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.DotNet.PlatformAbstractions": "3.1.6",
|
||||
"Microsoft.Extensions.DependencyModel": "9.0.9"
|
||||
}
|
||||
},
|
||||
"Silk.NET.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "UIs4sH57xlPUNHQ/1bt9rymPWlGy8IMDCNv86h0iM4TOA1CkIx0XM/n/tA4AReh1zQkNrvkxPEdZ3Blvy1dyXg==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Core": "2.23.0",
|
||||
"Ultz.Native.GLFW": "3.4.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Input.Common": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "QbJVV7kFBHEByayXCYdJtXXI9Sp4a+QAf0IdGV6uCWkFYcEmqBYW3aaNGvFOdSwTBDbHL5T/OtOCrGh4qYhk7A==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Windowing.Common": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Input.Glfw": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "KGHYqsv/IQRJtD6dloYh2tN4CkaM40vxM2kj0cGKBoCQiBDYHHhJiyDTyMPx0W7Fz5IgnhnG42ELmIAa0DH69A==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Input.Common": "2.23.0",
|
||||
"Silk.NET.Windowing.Glfw": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Maths": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "r8PdIVzME8EH0qAgbmRPO87I4GfgR2j8TofT7EMuRJDf1QluoQwnVypDoFJjQ2ZBSRsGYk5unYxxogI05Ogsmw=="
|
||||
},
|
||||
"Silk.NET.Windowing.Common": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "ThStSinmY9KQI8DGiF5XEhkLJVnBcgRTBTzL9ijg1wMZAYuckz7ykrNw04fjRm2Gryh6tCNGbvz2XaY0efeFzg==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Core": "2.23.0",
|
||||
"Silk.NET.Maths": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Windowing.Glfw": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "aYBudKmENmvLRn9p15HbdvlQTnnXskcDfTfbYwSb/4fr263rGLwYuDw/txUEc2jihHJiWCp5+75Y7z5wTJWl7g==",
|
||||
"dependencies": {
|
||||
"Silk.NET.GLFW": "2.23.0",
|
||||
"Silk.NET.Windowing.Common": "2.23.0"
|
||||
}
|
||||
},
|
||||
"SkiaSharp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "3MD5VHjXXieSHCleRLuaTXmL2pD0mB7CcOB1x2kA1I4bhptf4e3R27iM93264ZYuAq6mkUyX5XbcxnZvMJYc1Q==",
|
||||
"dependencies": {
|
||||
"SkiaSharp.NativeAssets.Win32": "2.88.9",
|
||||
"SkiaSharp.NativeAssets.macOS": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.WebAssembly": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "kt06RccBHSnAs2wDYdBSfsjIDbY3EpsOVqnlDgKdgvyuRA8ZFDaHRdWNx1VHjGgYzmnFCGiTJBnXFl5BqGwGnA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
},
|
||||
"sharpemu.core": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Iced": "[1.21.0, )",
|
||||
"SharpEmu.HLE": "[0.0.2-beta.3, )",
|
||||
"SharpEmu.Libs": "[0.0.2-beta.3, )",
|
||||
"SharpEmu.Logging": "[0.0.2-beta.3, )"
|
||||
}
|
||||
},
|
||||
"sharpemu.debugger": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SharpEmu.Core": "[0.0.2-beta.3, )",
|
||||
"SharpEmu.HLE": "[0.0.2-beta.3, )",
|
||||
"SharpEmu.Logging": "[0.0.2-beta.3, )"
|
||||
}
|
||||
},
|
||||
"sharpemu.gui": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Avalonia": "[11.3.18, )",
|
||||
"Avalonia.Desktop": "[11.3.18, )",
|
||||
"Avalonia.Fonts.Inter": "[11.3.18, )",
|
||||
"Avalonia.Themes.Fluent": "[11.3.18, )",
|
||||
"SharpEmu.Core": "[0.0.2-beta.3, )",
|
||||
"SharpEmu.Libs": "[0.0.2-beta.3, )",
|
||||
"SharpEmu.Logging": "[0.0.2-beta.3, )",
|
||||
"Tmds.DBus.Protocol": "[0.21.3, )"
|
||||
}
|
||||
},
|
||||
"sharpemu.hle": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SharpEmu.Logging": "[0.0.2-beta.3, )"
|
||||
}
|
||||
},
|
||||
"sharpemu.libs": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SharpEmu.HLE": "[0.0.2-beta.3, )",
|
||||
"SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )",
|
||||
"SharpEmu.ShaderCompiler.Vulkan": "[0.0.2-beta.3, )",
|
||||
"Silk.NET.Input": "[2.23.0, )",
|
||||
"Silk.NET.Vulkan": "[2.23.0, )",
|
||||
"Silk.NET.Vulkan.Extensions.EXT": "[2.23.0, )",
|
||||
"Silk.NET.Vulkan.Extensions.KHR": "[2.23.0, )",
|
||||
"Silk.NET.Windowing": "[2.23.0, )"
|
||||
}
|
||||
},
|
||||
"sharpemu.logging": {
|
||||
"type": "Project"
|
||||
},
|
||||
"sharpemu.shadercompiler": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SharpEmu.HLE": "[0.0.2-beta.3, )"
|
||||
}
|
||||
},
|
||||
"sharpemu.shadercompiler.vulkan": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )"
|
||||
}
|
||||
},
|
||||
"Avalonia": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[11.3.18, )",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "2C4UxhWUObWGgYKWic1x5BMMWGJP6SElb91WeOxs+X/iR26rtkqpxFFwwo50FXS9AyYnHfk8QKXDEfe7oT/kZA==",
|
||||
"dependencies": {
|
||||
"Avalonia.BuildServices": "11.3.2",
|
||||
"Avalonia.Remote.Protocol": "11.3.18",
|
||||
"MicroCom.Runtime": "0.11.0"
|
||||
}
|
||||
},
|
||||
"Avalonia.Desktop": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[11.3.18, )",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "bilMPa5vYiis6fbNovb6esKytBnOCEGojBa1XFegLCRHCP6g6PvZwS0XF/YOAGkENRlHG8dI7lohOpQ9bIkq1g==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18",
|
||||
"Avalonia.Native": "11.3.18",
|
||||
"Avalonia.Skia": "11.3.18",
|
||||
"Avalonia.Win32": "11.3.18",
|
||||
"Avalonia.X11": "11.3.18"
|
||||
}
|
||||
},
|
||||
"Avalonia.Fonts.Inter": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[11.3.18, )",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "27u6hB3Y2Ue586yjfeVakberY73VNQXtuKwe/P927XG1QPlhsfmOyifLHDDpSHG85Zl1x/Xv9IZ3+tk9FnjcZQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"Avalonia.Themes.Fluent": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[11.3.18, )",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "+Q/TJoynD0zNuu5w2gD+xcTl7GNKJFxlPYAndRLs/mTDrNbbsvv/271WyIysbMPsXSjCyBDp7RCZzQkpD6x5Bg==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"Iced": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.21.0, )",
|
||||
"resolved": "1.21.0",
|
||||
"contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg=="
|
||||
},
|
||||
"Silk.NET.Input": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "Xzl+tVwAp2eEd8blmGQjmJrsZPnp3PWG0KJjiAQHaY2Zr/ELVWeAROKXmZdCAvexzmte2JVGEy/dxnMycbxlpg==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Input.Common": "2.23.0",
|
||||
"Silk.NET.Input.Glfw": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Vulkan": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "3/irtlSWXZ3eTi8N6nelI6L34NTB8ZJHpqVMNzZx2aX7Ek9YEQ34NoQW8/Tljrtmkg8KRhHW8hKTEzZaKV8PgA==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Core": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Vulkan.Extensions.EXT": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "+Oth189ksRiL6HvGCwIdnsYHawqrbO8y49u1H61z3wsfcHhQZeVDYe/wF5LD7fk3NcdgDvwFD3mLm1QWhdZySw==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Core": "2.23.0",
|
||||
"Silk.NET.Vulkan": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Vulkan.Extensions.KHR": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "uRaf4j+SmH3DumjSSSUbFg33BnsGZUyXGj93O9NgGKZSJN3OTmNmQDxRew+/KiVLcgH6qzbto8aNGZ++j9GFWg==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Core": "2.23.0",
|
||||
"Silk.NET.Vulkan": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Windowing": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "OPNPmt/lRyUKVYrFLQXVxyATqD3MKLc1iY1oKx1/2GppgmZxVZPwN12tekrQ4C7408kgB1L5JD1Wnirqqeb2kg==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Windowing.Common": "2.23.0",
|
||||
"Silk.NET.Windowing.Glfw": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Tmds.DBus.Protocol": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[0.21.3, )",
|
||||
"resolved": "0.21.3",
|
||||
"contentHash": "hDwB8WsQoyALQKqIbwzS68UKdlnafDm4T/DkO/JrA/YIneP/rKv96SxYPVXeh3FP4i/SXfShrYftKLtciJAIlw=="
|
||||
}
|
||||
},
|
||||
"net10.0/linux-x64": {
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.25547.20250602",
|
||||
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||
},
|
||||
"Avalonia.Native": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
}
|
||||
},
|
||||
"net10.0/osx-arm64": {
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.25547.20250602",
|
||||
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||
},
|
||||
"Avalonia.Native": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
}
|
||||
},
|
||||
"net10.0/osx-x64": {
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.25547.20250602",
|
||||
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||
},
|
||||
"Avalonia.Native": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
}
|
||||
},
|
||||
"net10.0/win-x64": {
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.25547.20250602",
|
||||
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||
},
|
||||
"Avalonia.Native": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,11 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (exceptionCode == StatusIllegalInstruction &&
|
||||
TryRecoverAmdCompatInstruction(contextRecord, rip))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (IsBenignHostDebugException(exceptionCode))
|
||||
{
|
||||
return -1;
|
||||
@@ -478,7 +483,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} (SHARPEMU_IGNORE_INT41=1)");
|
||||
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (default-on; set SHARPEMU_IGNORE_INT41=0 to disable)");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -530,9 +530,12 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
|
||||
}
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
|
||||
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
|
||||
{
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
|
||||
}
|
||||
StoreImportVectorReturn(cpuContext, argPackPtr);
|
||||
if (dispatchResolved &&
|
||||
orbisGen2Result == OrbisGen2Result.ORBIS_GEN2_OK &&
|
||||
@@ -1326,9 +1329,12 @@ public sealed partial class DirectExecutionBackend
|
||||
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
|
||||
}
|
||||
}
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
|
||||
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
|
||||
{
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
|
||||
}
|
||||
StoreImportVectorReturn(cpuContext, argPackPtr);
|
||||
|
||||
if (returnValue != (int)OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
@@ -1398,11 +1404,13 @@ 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
|
||||
@@ -1410,6 +1418,8 @@ public sealed partial class DirectExecutionBackend
|
||||
"eE4Szl8sil8" or // sceKernelAprSubmitCommandBuffer
|
||||
"qvMUCyyaCSI" or // sceKernelAprSubmitCommandBufferAndGetId
|
||||
"Q2V+iqvjgC0" or // vsnprintf
|
||||
"AV6ipCNa4Rw" or // strcasecmp
|
||||
"viiwFMaNamA" or // strstr
|
||||
"q1cHNfGycLI" or // scePadRead
|
||||
"xk0AcarP3V4" or // scePadOpen
|
||||
"yH17Q6NWtVg" or // sceUserServiceGetEvent
|
||||
@@ -1436,6 +1446,9 @@ public sealed partial class DirectExecutionBackend
|
||||
var expectedMutexTrylockBusy =
|
||||
string.Equals(nid, "K-jXhbt2gn4", StringComparison.Ordinal) &&
|
||||
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
var expectedSemaphoreTrywaitAgain =
|
||||
string.Equals(nid, "H2a+IN9TP0E", StringComparison.Ordinal) &&
|
||||
result == OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN;
|
||||
var expectedNetAcceptWouldBlock =
|
||||
string.Equals(nid, "PIWqhn9oSxc", StringComparison.Ordinal) &&
|
||||
resultValue == unchecked((int)0x80410123);
|
||||
@@ -1449,6 +1462,7 @@ public sealed partial class DirectExecutionBackend
|
||||
!expectedTimedWaitTimeout &&
|
||||
!expectedEqueueTimeout &&
|
||||
!expectedMutexTrylockBusy &&
|
||||
!expectedSemaphoreTrywaitAgain &&
|
||||
!expectedNetAcceptWouldBlock &&
|
||||
!expectedUserServiceNoEvent &&
|
||||
!expectedPrivacyInvalidParameter)
|
||||
@@ -1542,11 +1556,13 @@ 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
|
||||
@@ -1571,6 +1587,8 @@ public sealed partial class DirectExecutionBackend
|
||||
"WkkeywLJcgU" or // wcslen
|
||||
"Ovb2dSJOAuE" or // strcmp
|
||||
"aesyjrHVWy4" or // strncmp
|
||||
"AV6ipCNa4Rw" or // strcasecmp
|
||||
"viiwFMaNamA" or // strstr
|
||||
"pNtJdE3x49E" or // wcscmp
|
||||
"fV2xHER+bKE" or // wcscoll
|
||||
"E8wCoUEbfzk" or // wcsncmp
|
||||
|
||||
@@ -50,6 +50,19 @@ 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
|
||||
@@ -71,6 +84,15 @@ 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))
|
||||
@@ -252,6 +274,26 @@ 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)
|
||||
@@ -317,6 +359,14 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -712,6 +712,11 @@ 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;
|
||||
@@ -1118,7 +1123,9 @@ 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);
|
||||
_ignoreGuestInt41 = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_IGNORE_INT41"), "1", StringComparison.Ordinal);
|
||||
var ignoreGuestInt41Env = Environment.GetEnvironmentVariable("SHARPEMU_IGNORE_INT41");
|
||||
_ignoreGuestInt41 = !string.Equals(ignoreGuestInt41Env, "0", StringComparison.Ordinal) &&
|
||||
!string.Equals(ignoreGuestInt41Env, "false", StringComparison.OrdinalIgnoreCase);
|
||||
_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);
|
||||
@@ -1291,6 +1298,12 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
private unsafe bool TryCreateNativeImportIntrinsic(string nid, out nint address)
|
||||
{
|
||||
if (IsHlePreferredNid(nid))
|
||||
{
|
||||
address = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nid == "1jfXLRVzisc" &&
|
||||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_USLEEP"), "1", StringComparison.Ordinal))
|
||||
{
|
||||
@@ -1402,6 +1415,54 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
0x75, 0xE7,
|
||||
0xC3,
|
||||
],
|
||||
"AV6ipCNa4Rw" =>
|
||||
[
|
||||
0x0F, 0xB6, 0x07,
|
||||
0x0F, 0xB6, 0x16,
|
||||
0x8D, 0x48, 0xBF,
|
||||
0x83, 0xF9, 0x19,
|
||||
0x77, 0x03,
|
||||
0x83, 0xC0, 0x20,
|
||||
0x8D, 0x4A, 0xBF,
|
||||
0x83, 0xF9, 0x19,
|
||||
0x77, 0x03,
|
||||
0x83, 0xC2, 0x20,
|
||||
0x29, 0xD0,
|
||||
0x75, 0x0C,
|
||||
0x85, 0xD2,
|
||||
0x74, 0x08,
|
||||
0x48, 0xFF, 0xC7,
|
||||
0x48, 0xFF, 0xC6,
|
||||
0xEB, 0xD4,
|
||||
0xC3,
|
||||
],
|
||||
"viiwFMaNamA" =>
|
||||
[
|
||||
0x0F, 0xB6, 0x16,
|
||||
0x84, 0xD2,
|
||||
0x74, 0x2D,
|
||||
0x0F, 0xB6, 0x07,
|
||||
0x84, 0xC0,
|
||||
0x74, 0x2A,
|
||||
0x38, 0xD0,
|
||||
0x75, 0x1D,
|
||||
0x4C, 0x8D, 0x47, 0x01,
|
||||
0x4C, 0x8D, 0x4E, 0x01,
|
||||
0x41, 0x0F, 0xB6, 0x09,
|
||||
0x84, 0xC9,
|
||||
0x74, 0x12,
|
||||
0x41, 0x38, 0x08,
|
||||
0x75, 0x08,
|
||||
0x49, 0xFF, 0xC0,
|
||||
0x49, 0xFF, 0xC1,
|
||||
0xEB, 0xEB,
|
||||
0x48, 0xFF, 0xC7,
|
||||
0xEB, 0xD3,
|
||||
0x48, 0x89, 0xF8,
|
||||
0xC3,
|
||||
0x31, 0xC0,
|
||||
0xC3,
|
||||
],
|
||||
"pNtJdE3x49E" or "fV2xHER+bKE" =>
|
||||
[
|
||||
0x0F, 0xB7, 0x07,
|
||||
@@ -1466,8 +1527,14 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
"Q3VBxCXhUHs" =>
|
||||
[
|
||||
0x48, 0x89, 0xF8,
|
||||
0x48, 0x89, 0xD1,
|
||||
0xF3, 0xA4,
|
||||
0x48, 0x85, 0xD2,
|
||||
0x74, 0x11,
|
||||
0x44, 0x8A, 0x06,
|
||||
0x44, 0x88, 0x07,
|
||||
0x48, 0xFF, 0xC6,
|
||||
0x48, 0xFF, 0xC7,
|
||||
0x48, 0xFF, 0xCA,
|
||||
0x75, 0xEF,
|
||||
0xC3,
|
||||
],
|
||||
"8zTFvBIAIN8" =>
|
||||
@@ -1601,7 +1668,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
private static bool IsHlePreferredNid(string nid)
|
||||
{
|
||||
return string.Equals(nid, "QrZZdJ8XsX0", StringComparison.Ordinal);
|
||||
return string.Equals(nid, "QrZZdJ8XsX0", StringComparison.Ordinal) ||
|
||||
string.Equals(nid, "Q3VBxCXhUHs", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsLibcLibrary(string libraryName)
|
||||
@@ -2541,28 +2609,22 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
private unsafe bool TryPatchSse4aExtrqBlend(nint address, byte* source)
|
||||
{
|
||||
// Rosetta does not implement AMD SSE4a EXTRQ. This exact sequence masks
|
||||
// xmm2 to its low 40 bits, then copies the resulting second dword into
|
||||
// xmm0. PEXTRB/PINSRD provides the same observable result in 12 bytes:
|
||||
// extract source byte 4 and insert the zero-extended value into lane 1.
|
||||
ReadOnlySpan<byte> pattern =
|
||||
[
|
||||
0x66, 0x0F, 0x78, 0xC2, 0x28, 0x00,
|
||||
0xC4, 0xE3, 0x79, 0x02, 0xC2, 0x02,
|
||||
];
|
||||
for (var i = 0; i < pattern.Length; i++)
|
||||
// Rosetta does not implement AMD SSE4a EXTRQ. Recognize the compiler's
|
||||
// EXTRQ+blend idiom (against whichever xmm0-xmm7 it allocated) and rewrite
|
||||
// it into an equivalent SSE4.1 sequence. Match/encode is isolated in
|
||||
// Sse4aExtrqBlendPatch so it can be unit-tested; here we only patch bytes.
|
||||
var window = new ReadOnlySpan<byte>(source, Sse4aExtrqBlendPatch.SequenceLength);
|
||||
if (!Sse4aExtrqBlendPatch.TryMatch(window, out var destRegister, out var srcRegister))
|
||||
{
|
||||
if (source[i] != pattern[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Span<byte> replacement = stackalloc byte[Sse4aExtrqBlendPatch.SequenceLength];
|
||||
if (!Sse4aExtrqBlendPatch.TryEncode(destRegister, srcRegister, replacement))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ReadOnlySpan<byte> replacement =
|
||||
[
|
||||
0x66, 0x0F, 0x3A, 0x14, 0xD0, 0x04,
|
||||
0x66, 0x0F, 0x3A, 0x22, 0xC0, 0x01,
|
||||
];
|
||||
uint oldProtect = 0;
|
||||
if (!VirtualProtect((void*)address, (nuint)replacement.Length, 64u, &oldProtect))
|
||||
{
|
||||
@@ -3893,10 +3955,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.
|
||||
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
|
||||
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
|
||||
handler,
|
||||
exceptionType,
|
||||
external.ExceptionStackBase);
|
||||
external.ExceptionStackBase));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3905,10 +3967,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.
|
||||
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
|
||||
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
|
||||
handler,
|
||||
exceptionType,
|
||||
external.ExceptionStackBase);
|
||||
external.ExceptionStackBase));
|
||||
if (logGuestExceptions)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
@@ -3953,17 +4015,17 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
}
|
||||
if (target.ExceptionDeliveryActive)
|
||||
{
|
||||
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
|
||||
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
|
||||
handler,
|
||||
exceptionType,
|
||||
exceptionStackBase);
|
||||
exceptionStackBase));
|
||||
return true;
|
||||
}
|
||||
|
||||
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
|
||||
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
|
||||
handler,
|
||||
exceptionType,
|
||||
exceptionStackBase);
|
||||
exceptionStackBase));
|
||||
if (logGuestExceptions)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
@@ -4124,7 +4186,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
RestoreInterruptedGuestThread();
|
||||
if (target.State == GuestThreadRunState.Blocked &&
|
||||
!target.ExecutorActive &&
|
||||
_pendingGuestExceptions.Remove(threadHandle, out var queued))
|
||||
TryRemovePendingGuestExceptionLocked(threadHandle, out var queued))
|
||||
{
|
||||
followUp = queued;
|
||||
}
|
||||
@@ -4210,6 +4272,11 @@ 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)
|
||||
{
|
||||
@@ -4223,7 +4290,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_pendingGuestExceptions.Remove(threadHandle, out pending))
|
||||
if (!TryRemovePendingGuestExceptionLocked(threadHandle, out pending))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -4285,6 +4352,27 @@ 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,
|
||||
@@ -4379,6 +4467,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
_guestThreads.Clear();
|
||||
_externalGuestThreads.Clear();
|
||||
_pendingGuestExceptions.Clear();
|
||||
Volatile.Write(ref _pendingGuestExceptionCount, 0);
|
||||
_activeGuestExceptionDeliveries.Clear();
|
||||
}
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ public static unsafe class JitStubs
|
||||
var pattern = TlsAccessPattern;
|
||||
var end = start + length - pattern.Length;
|
||||
|
||||
for (var ptr = start; ptr < end; ptr++)
|
||||
for (var ptr = start; ptr <= end; ptr++)
|
||||
{
|
||||
if (MatchesPattern(ptr, pattern))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native;
|
||||
|
||||
/// <summary>
|
||||
/// Recognizes Sony's AMD-only SSE4a EXTRQ+blend idiom and rewrites it into an
|
||||
/// equivalent SSE4.1 sequence. SharpEmu executes guest x86-64 natively, but
|
||||
/// Rosetta 2 and Intel hosts do not implement SSE4a, so the original opcode
|
||||
/// raises #UD -> SIGILL. The compiler emits the idiom against whichever XMM
|
||||
/// register it happens to allocate (Dead Cells uses xmm1, others xmm2), so the
|
||||
/// source register is read from the ModRM r/m field rather than hard-coded.
|
||||
///
|
||||
/// The match/encode logic is deliberately free of native page-patching so it
|
||||
/// can be unit-tested against handcrafted byte sequences.
|
||||
/// </summary>
|
||||
public static class Sse4aExtrqBlendPatch
|
||||
{
|
||||
/// <summary>Length in bytes of both the matched idiom and its replacement.</summary>
|
||||
public const int SequenceLength = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Matches the 12-byte idiom, extracting the destination register D and the
|
||||
/// source (scratch) register N:
|
||||
/// <code>
|
||||
/// EXTRQ xmmN, 0x28, 0x00 ; 66 0F 78 /0 28 00 mask xmmN to low 40 bits
|
||||
/// VPBLENDD xmmD, xmmD, xmmN, 2 ; C4 E3 vvvv 02 /r 02 copy dword 1 into xmmD
|
||||
/// </code>
|
||||
/// N lives in the ModRM r/m field of both instructions; D (the blend
|
||||
/// destination and src1) lives in the VPBLENDD ModRM reg field and VEX.vvvv.
|
||||
/// Both are xmm0-xmm7 (the VEX byte1 0xE3 pins R/X/B, so no xmm8-15 extension).
|
||||
/// The compiler allocates whichever registers it likes — Dead Cells builds use
|
||||
/// D=xmm0 and D=xmm3, others differ — so both are read from the encoding.
|
||||
/// </summary>
|
||||
public static bool TryMatch(ReadOnlySpan<byte> source, out int destRegister, out int srcRegister)
|
||||
{
|
||||
destRegister = -1;
|
||||
srcRegister = -1;
|
||||
if (source.Length < SequenceLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// EXTRQ xmmN, 0x28, 0x00 : 66 0F 78, ModRM (mod=11 reg=000 rm=N), 28, 00.
|
||||
if (source[0] != 0x66 || source[1] != 0x0F || source[2] != 0x78 ||
|
||||
(source[3] & 0xF8) != 0xC0 || source[4] != 0x28 || source[5] != 0x00)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var n = source[3] & 0x07;
|
||||
|
||||
// VPBLENDD xmmD, xmmD, xmmN, 2 : C4 E3 <W=0 vvvv=~D L=0 pp=01> 02 ModRM 02.
|
||||
// VEX.byte2 fixed bits (W, L, pp) must read 0b*0000*01; vvvv encodes ~D.
|
||||
if (source[6] != 0xC4 || source[7] != 0xE3 || (source[8] & 0x87) != 0x01 ||
|
||||
source[9] != 0x02 || source[11] != 0x02)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var d = (~(source[8] >> 3)) & 0x0F;
|
||||
if (d > 7)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// ModRM: mod=11, reg=D (dest = src1), rm=N (src2 = the masked register).
|
||||
if (source[10] != (0xC0 | (d << 3) | n))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
destRegister = d;
|
||||
srcRegister = n;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the SSE4.1 equivalent into <paramref name="destination"/>:
|
||||
/// <code>
|
||||
/// PEXTRB eax, xmmN, 4 ; 66 0F 3A 14 /r 04 extract byte 4 (zero-extended)
|
||||
/// PINSRD xmmD, eax, 1 ; 66 0F 3A 22 /r 01 insert into xmmD dword lane 1
|
||||
/// </code>
|
||||
/// After EXTRQ masks xmmN to its low 40 bits, dword 1 is just byte 4
|
||||
/// zero-extended, so the two-instruction extract/insert reproduces the exact
|
||||
/// observable result the AMD idiom left in xmmD. eax is a caller-dead scratch
|
||||
/// at every site the compiler emits this idiom.
|
||||
/// </summary>
|
||||
public static bool TryEncode(int destRegister, int srcRegister, Span<byte> destination)
|
||||
{
|
||||
if ((uint)destRegister > 7 || (uint)srcRegister > 7 || destination.Length < SequenceLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// PEXTRB eax, xmmN, 4 : ModRM (mod=11 reg=N rm=000 -> eax), imm8 = byte index 4.
|
||||
destination[0] = 0x66;
|
||||
destination[1] = 0x0F;
|
||||
destination[2] = 0x3A;
|
||||
destination[3] = 0x14;
|
||||
destination[4] = (byte)(0xC0 | (srcRegister << 3));
|
||||
destination[5] = 0x04;
|
||||
|
||||
// PINSRD xmmD, eax, 1 : ModRM (mod=11 reg=D -> xmmD, rm=000 -> eax), lane 1.
|
||||
destination[6] = 0x66;
|
||||
destination[7] = 0x0F;
|
||||
destination[8] = 0x3A;
|
||||
destination[9] = 0x22;
|
||||
destination[10] = (byte)(0xC0 | (destRegister << 3));
|
||||
destination[11] = 0x01;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,9 @@ 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,9 +199,32 @@ public sealed class SelfLoader : ISelfLoader
|
||||
{
|
||||
if (!physicalVm.TryAllocateAtExact(imageBase, totalImageSize, executable: true, out var allocatedBase))
|
||||
{
|
||||
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
|
||||
throw new InvalidOperationException(
|
||||
$"Could not allocate main image at required base 0x{imageBase:X16} (size=0x{totalImageSize:X}): {reason}.");
|
||||
// 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;
|
||||
}
|
||||
|
||||
imageBase = allocatedBase;
|
||||
@@ -714,8 +737,9 @@ public sealed class SelfLoader : ISelfLoader
|
||||
|
||||
importedRelocations = BuildImportedRelocations(descriptors);
|
||||
|
||||
var stubEligibleNids = CollectStubEligibleNids(descriptors, moduleManager);
|
||||
var stubImportNids = orderedImportNids
|
||||
.Where(nid => ShouldCreateImportStub(nid, descriptors, moduleManager))
|
||||
.Where(stubEligibleNids.Contains)
|
||||
.ToArray();
|
||||
var stubsByAddress = CreateImportStubMapping(virtualMemory, stubImportNids);
|
||||
Console.WriteLine($"[LOADER] Created {stubsByAddress.Count} import stubs");
|
||||
@@ -1160,6 +1184,35 @@ 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,
|
||||
@@ -2431,6 +2484,19 @@ 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,6 +20,11 @@ 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;
|
||||
@@ -28,6 +33,77 @@ 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
|
||||
@@ -349,6 +425,111 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return actualAddress;
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -440,6 +621,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
_gate.ExitWriteLock();
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _mappingGeneration);
|
||||
_hostMemory.Free(address);
|
||||
}
|
||||
|
||||
@@ -611,6 +793,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
{
|
||||
_allocationSearchHints.Clear();
|
||||
}
|
||||
Interlocked.Increment(ref _mappingGeneration);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -873,6 +1056,15 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
public bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source)
|
||||
{
|
||||
// A managed write into a page the guest-image write tracker has
|
||||
// protected surfaces as a fatal AccessViolation — the runtime turns
|
||||
// SIGSEGV in managed code into an exception before the resumable
|
||||
// signal bridge can restore access (native guest stores recover
|
||||
// there). Pre-visit the span so tracked pages are unprotected and
|
||||
// their owners dirtied before the copy; guest addresses are
|
||||
// host-identical, matching the tracker's fault addresses.
|
||||
GuestImageWriteTracker.NotifyManagedWrite(virtualAddress, (ulong)source.Length);
|
||||
|
||||
var requiresExclusiveAccess = false;
|
||||
_gate.EnterReadLock();
|
||||
try
|
||||
@@ -910,6 +1102,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -935,6 +1128,68 @@ 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);
|
||||
@@ -1007,6 +1262,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1031,6 +1287,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1272,6 +1529,12 @@ 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;
|
||||
@@ -1293,6 +1556,9 @@ 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;
|
||||
}
|
||||
@@ -1308,12 +1574,23 @@ 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,6 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Core.Loader;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Core.Memory;
|
||||
|
||||
@@ -93,8 +94,14 @@ public sealed class VirtualMemory : IVirtualMemory
|
||||
}
|
||||
|
||||
CopyToRegions(virtualAddress, source, regionIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (GuestWriteWatch.Armed)
|
||||
{
|
||||
GuestWriteWatch.Check(virtualAddress, source);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryValidateRange(
|
||||
|
||||
@@ -248,7 +248,7 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
{
|
||||
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
|
||||
if (!CreateProcessW(
|
||||
exePath,
|
||||
null,
|
||||
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);
|
||||
|
||||
@@ -351,6 +351,13 @@ public sealed class GameSurfaceHost : NativeControlHost
|
||||
var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale));
|
||||
var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale));
|
||||
var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height;
|
||||
if (Environment.GetEnvironmentVariable("SHARPEMU_TRACE_SURFACE_SIZE") == "1")
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[GUI][TRACE] GameSurfaceHost.UpdateSurfaceSize bounds={Bounds.Width}x{Bounds.Height} " +
|
||||
$"scale={renderScale} computed={width}x{height} changed={sizeChanged} " +
|
||||
$"prevSurface={_surface.PixelWidth}x{_surface.PixelHeight}");
|
||||
}
|
||||
_surface.UpdatePixelSize(width, height);
|
||||
|
||||
if (!sizeChanged)
|
||||
|
||||
@@ -53,6 +53,9 @@ public sealed class GuiSettings
|
||||
/// <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
|
||||
@@ -71,7 +74,7 @@ public sealed class GuiSettings
|
||||
if (File.Exists(SettingsPath))
|
||||
{
|
||||
var json = File.ReadAllText(SettingsPath);
|
||||
return JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
|
||||
return NormalizeFromJson(json);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -82,6 +85,39 @@ 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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -125,5 +125,48 @@
|
||||
"Dialog.PsExecutables": "ملفات PS التنفيذية",
|
||||
"Dialog.SaveLogFile": "حدد مكان حفظ ملف السجل",
|
||||
"Dialog.PlainTextFiles": "ملفات نصية عادية",
|
||||
"Dialog.LogFiles": "ملفات السجل"
|
||||
}
|
||||
"Dialog.LogFiles": "ملفات السجل",
|
||||
|
||||
"Library.Context.GameSettings": "إعدادات اللعبة…",
|
||||
"Options.Env.Tab": "البيئة",
|
||||
"Options.Section.Environment": "متغيرات البيئة",
|
||||
"Options.Env.Desc": "خيارات تُمرر إلى المحاكي كمتغيرات بيئة عند التشغيل.",
|
||||
"Options.Env.Bthid.Desc": "الإبلاغ عن أن Bluetooth HID غير متاح للألعاب التي تنتظر برمجيات عجلة القيادة/FFB فيها إلى ما لا نهاية.\nاتركه معطلاً عادةً. بعض الألعاب تتجمد عند فشل التهيئة.",
|
||||
"Options.Env.LoopGuard.Desc": "عدم إجبار الألعاب التي تكرر النداء نفسه لفترة طويلة على الإغلاق.\nجرّب هذا عندما تُغلق لعبة نفسها أثناء التحميل.",
|
||||
"Options.Env.WritableApp0.Desc": "السماح للألعاب بإنشاء الملفات والكتابة داخل مجلد التثبيت الخاص بها.\nمطلوب للنسخ غير المحزومة التي تكتب بيانات الحفظ أو الإعدادات تحت /app0.",
|
||||
"Options.Env.VkValidation.Desc": "تفعيل طبقات التحقق في Vulkan لتصحيح أخطاء وحدة معالجة الرسوميات.\nبطيء. يتطلب تثبيت Vulkan SDK.",
|
||||
"Options.Env.DumpSpirv.Desc": "تفريغ شيدرات AGC وترجماتها إلى SPIR-V في مجلد shader-dumps.\nاستخدمه عند الإبلاغ عن أخطاء الشيدرات أو العرض.",
|
||||
"Options.Env.LogDirectMemory.Desc": "تسجيل تخصيصات الذاكرة المباشرة وإخفاقاتها في وحدة التحكم.\nاستخدمه عندما تنهار لعبة أو تُغلق أثناء الإقلاع.",
|
||||
"Options.Env.LogIo.Desc": "تسجيل فتح الملفات وقراءتها وحلّ المسارات في وحدة التحكم.\nاستخدمه عندما لا تجد لعبة ملفات بياناتها أثناء الإقلاع.",
|
||||
"Options.Env.LogNp.Desc": "تسجيل نداءات مكتبة NP (شبكة PlayStation) في وحدة التحكم.",
|
||||
"Common.Save": "حفظ",
|
||||
"Common.Cancel": "إلغاء",
|
||||
"PerGame.Title": "إعدادات خاصة باللعبة — {0} ({1})",
|
||||
"PerGame.InheritNote": "الصفوف غير المحددة ترث الإعدادات الافتراضية العامة.",
|
||||
"PerGame.EnvToggles.Label": "مفاتيح البيئة",
|
||||
"PerGame.EnvToggles.Desc": "تجاوز المجموعة العامة من مفاتيح SHARPEMU_* لهذه اللعبة.",
|
||||
"Options.About": "حول",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "الكود المصدري والمشكلات وتطوير المشروع.",
|
||||
"About.Github.LatestCommitLabel": "أحدث Commit",
|
||||
"About.Github.LatestCommitDescription": "أحدث commit على الفرع main",
|
||||
"About.Discord.Label": "دسكورد",
|
||||
"About.Discord.Desc": "انضم إلى المجتمع واحصل على الدعم وتابع التطوير.",
|
||||
"About.GithubButton": "ساهم على GitHub!",
|
||||
"About.DiscordButton": "انضم إلى دسكوردنا!",
|
||||
"Updater.Auto.Label": "التحقق من التحديثات عند بدء التشغيل",
|
||||
"Updater.Auto.Desc": "يتحقق من GitHub دون تأخير بدء التشغيل.",
|
||||
"Updater.Label": "التحديثات",
|
||||
"Updater.Check": "التحقق من التحديثات",
|
||||
"Updater.DownloadRestart": "تنزيل وإعادة التشغيل",
|
||||
"Updater.Status.Ready": "الإصدار الحالي: {0}",
|
||||
"Updater.Status.Checking": "جارٍ التحقق من التحديثات…",
|
||||
"Updater.Status.Current": "أنت على أحدث إصدار ({0}).",
|
||||
"Updater.Status.Available": "يتوفر إصدار جديد: {0}",
|
||||
"Updater.Status.Downloading": "جارٍ تنزيل التحديث… {0}%",
|
||||
"Updater.Status.Installing": "جارٍ تثبيت التحديث…",
|
||||
"Updater.Status.Timeout": "انتهت مهلة التحقق من التحديثات بعد 10 ثوانٍ.",
|
||||
"Updater.Status.Failed": "تعذر التحقق من التحديثات.",
|
||||
"Updater.Status.ChecksumFailed": "فشل التحديث المنزَّل في اجتياز تحقق SHA-256.",
|
||||
"Updater.Status.Unsupported": "يتطلب التحديث التلقائي إصدار x64 لنظام Windows أو Linux أو macOS."
|
||||
}
|
||||
|
||||
@@ -142,5 +142,32 @@
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Discord.Desc": "Participe da comunidade, obtenha suporte e acompanhe o desenvolvimento.",
|
||||
"About.GithubButton": "Contribua no GitHub!",
|
||||
"About.DiscordButton": "Entre no nosso Discord!"
|
||||
"About.DiscordButton": "Entre no nosso Discord!",
|
||||
|
||||
"Library.Context.GameSettings": "Configurações do jogo…",
|
||||
"Options.Env.WritableApp0.Desc": "Permite que os jogos criem e gravem arquivos dentro da própria pasta de instalação.\nNecessário para dumps não empacotados que gravam seus saves ou configurações em /app0.",
|
||||
"Options.Env.LogIo.Desc": "Registra no console a abertura e leitura de arquivos e a resolução de caminhos.\nUse quando um jogo não encontrar seus arquivos de dados durante a inicialização.",
|
||||
"Common.Save": "Salvar",
|
||||
"Common.Cancel": "Cancelar",
|
||||
"PerGame.Title": "Configurações por jogo — {0} ({1})",
|
||||
"PerGame.InheritNote": "As linhas desmarcadas herdam os padrões globais.",
|
||||
"PerGame.EnvToggles.Label": "Variáveis de ambiente",
|
||||
"PerGame.EnvToggles.Desc": "Substitui o conjunto global de opções SHARPEMU_* para este jogo.",
|
||||
"About.Github.LatestCommitLabel": "Último commit",
|
||||
"About.Github.LatestCommitDescription": "Último commit na branch main",
|
||||
"Updater.Auto.Label": "Verificar atualizações ao iniciar",
|
||||
"Updater.Auto.Desc": "Consulta o GitHub sem atrasar a inicialização.",
|
||||
"Updater.Label": "Atualizações",
|
||||
"Updater.Check": "Verificar atualizações",
|
||||
"Updater.DownloadRestart": "Baixar e reiniciar",
|
||||
"Updater.Status.Ready": "Build atual: {0}",
|
||||
"Updater.Status.Checking": "Verificando atualizações…",
|
||||
"Updater.Status.Current": "Você está atualizado ({0}).",
|
||||
"Updater.Status.Available": "Um novo build está disponível: {0}",
|
||||
"Updater.Status.Downloading": "Baixando atualização… {0}%",
|
||||
"Updater.Status.Installing": "Instalando atualização…",
|
||||
"Updater.Status.Timeout": "A verificação de atualizações expirou após 10 segundos.",
|
||||
"Updater.Status.Failed": "Não foi possível verificar as atualizações.",
|
||||
"Updater.Status.ChecksumFailed": "A atualização baixada falhou na verificação SHA-256.",
|
||||
"Updater.Status.Unsupported": "A atualização automática requer um build x64 para Windows, Linux ou macOS."
|
||||
}
|
||||
|
||||
@@ -125,5 +125,48 @@
|
||||
"Dialog.PsExecutables": "PS-Ausführbare Dateien",
|
||||
"Dialog.SaveLogFile": "Protokolldatei speichern unter",
|
||||
"Dialog.PlainTextFiles": "Textdateien",
|
||||
"Dialog.LogFiles": "Protokolldateien"
|
||||
"Dialog.LogFiles": "Protokolldateien",
|
||||
|
||||
"Library.Context.GameSettings": "Spieleinstellungen…",
|
||||
"Options.Env.Tab": "Umgebung",
|
||||
"Options.Section.Environment": "UMGEBUNGSVARIABLEN",
|
||||
"Options.Env.Desc": "Schalter, die dem Emulator beim Start als Umgebungsvariablen übergeben werden.",
|
||||
"Options.Env.Bthid.Desc": "Bluetooth-HID als nicht verfügbar melden, wenn die Lenkrad-/FFB-Middleware eines Titels endlos wartet.\nNormalerweise ausgeschaltet lassen. Manche Titel frieren ein, wenn die Initialisierung fehlschlägt.",
|
||||
"Options.Env.LoopGuard.Desc": "Titel nicht zwangsweise beenden, wenn sie denselben Aufruf zu lange wiederholen.\nAusprobieren, wenn ein Spiel sich beim Laden von selbst beendet.",
|
||||
"Options.Env.WritableApp0.Desc": "Titeln erlauben, Dateien in ihrem Installationsordner anzulegen und zu schreiben.\nNötig für entpackte Dumps, die ihre Spielstände oder Konfiguration unter /app0 speichern.",
|
||||
"Options.Env.VkValidation.Desc": "Vulkan-Validierungsschichten für GPU-Debugging aktivieren.\nLangsam. Erfordert ein installiertes Vulkan SDK.",
|
||||
"Options.Env.DumpSpirv.Desc": "AGC-Shader und ihre SPIR-V-Übersetzungen im Ordner shader-dumps ablegen.\nBeim Melden von Shader- oder Grafikfehlern verwenden.",
|
||||
"Options.Env.LogDirectMemory.Desc": "Direkte Speicherzuweisungen und Fehler in der Konsole protokollieren.\nVerwenden, wenn ein Spiel beim Start abbricht oder sich beendet.",
|
||||
"Options.Env.LogIo.Desc": "Datei-Öffnen, -Lesen und Pfadauflösung in der Konsole protokollieren.\nVerwenden, wenn ein Spiel beim Start seine Datendateien nicht findet.",
|
||||
"Options.Env.LogNp.Desc": "NP-Bibliotheksaufrufe (PlayStation Network) in der Konsole protokollieren.",
|
||||
"Common.Save": "Speichern",
|
||||
"Common.Cancel": "Abbrechen",
|
||||
"PerGame.Title": "Spielspezifische Einstellungen — {0} ({1})",
|
||||
"PerGame.InheritNote": "Nicht angehakte Zeilen übernehmen die globalen Standardwerte.",
|
||||
"PerGame.EnvToggles.Label": "Umgebungsschalter",
|
||||
"PerGame.EnvToggles.Desc": "Die globalen SHARPEMU_*-Schalter für dieses Spiel überschreiben.",
|
||||
"Options.About": "Über",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Quellcode, Issues und Projektentwicklung.",
|
||||
"About.Github.LatestCommitLabel": "Neuester Commit",
|
||||
"About.Github.LatestCommitDescription": "Neuester Commit auf dem main-Branch",
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Discord.Desc": "Tritt der Community bei, erhalte Support und verfolge die Entwicklung.",
|
||||
"About.GithubButton": "Auf GitHub mitwirken!",
|
||||
"About.DiscordButton": "Tritt unserem Discord bei!",
|
||||
"Updater.Auto.Label": "Beim Start nach Updates suchen",
|
||||
"Updater.Auto.Desc": "Fragt GitHub ab, ohne den Start zu verzögern.",
|
||||
"Updater.Label": "Updates",
|
||||
"Updater.Check": "Nach Updates suchen",
|
||||
"Updater.DownloadRestart": "Herunterladen und neu starten",
|
||||
"Updater.Status.Ready": "Aktueller Build: {0}",
|
||||
"Updater.Status.Checking": "Suche nach Updates…",
|
||||
"Updater.Status.Current": "Du bist auf dem neuesten Stand ({0}).",
|
||||
"Updater.Status.Available": "Ein neuer Build ist verfügbar: {0}",
|
||||
"Updater.Status.Downloading": "Update wird heruntergeladen… {0}%",
|
||||
"Updater.Status.Installing": "Update wird installiert…",
|
||||
"Updater.Status.Timeout": "Die Updateprüfung ist nach 10 Sekunden abgelaufen.",
|
||||
"Updater.Status.Failed": "Updates konnten nicht geprüft werden.",
|
||||
"Updater.Status.ChecksumFailed": "Das heruntergeladene Update hat die SHA-256-Prüfung nicht bestanden.",
|
||||
"Updater.Status.Unsupported": "Automatische Updates erfordern einen x64-Build für Windows, Linux oder macOS."
|
||||
}
|
||||
|
||||
@@ -125,5 +125,48 @@
|
||||
"Dialog.PsExecutables": "PS-programmer",
|
||||
"Dialog.SaveLogFile": "Vælg hvor logfilen skal gemmes",
|
||||
"Dialog.PlainTextFiles": "Almindelige tekstfiler",
|
||||
"Dialog.LogFiles": "Logfiler"
|
||||
"Dialog.LogFiles": "Logfiler",
|
||||
|
||||
"Library.Context.GameSettings": "Spilindstillinger…",
|
||||
"Options.Env.Tab": "Miljø",
|
||||
"Options.Section.Environment": "MILJØVARIABLER",
|
||||
"Options.Env.Desc": "Kontakter, der gives videre til emulatoren som miljøvariabler ved start.",
|
||||
"Options.Env.Bthid.Desc": "Rapportér Bluetooth HID som utilgængelig for titler, hvis rat-/FFB-middleware venter i det uendelige.\nLad den normalt være slået fra. Nogle titler fryser, når initialiseringen fejler.",
|
||||
"Options.Env.LoopGuard.Desc": "Tving ikke titler til at lukke, når de gentager det samme kald for længe.\nPrøv dette, når et spil lukker af sig selv under indlæsning.",
|
||||
"Options.Env.WritableApp0.Desc": "Tillad titler at oprette og skrive filer i deres installationsmappe.\nKræves af upakkede dumps, der skriver deres gemte data eller konfiguration under /app0.",
|
||||
"Options.Env.VkValidation.Desc": "Aktivér Vulkan-valideringslag til GPU-fejlfinding.\nLangsomt. Kræver at Vulkan SDK er installeret.",
|
||||
"Options.Env.DumpSpirv.Desc": "Gem AGC-shadere og deres SPIR-V-oversættelser i mappen shader-dumps.\nBrug dette, når du rapporterer shader- eller grafikfejl.",
|
||||
"Options.Env.LogDirectMemory.Desc": "Log direkte hukommelsestildelinger og fejl til konsollen.\nBrug dette, når et spil afbryder eller lukker under opstart.",
|
||||
"Options.Env.LogIo.Desc": "Log åbning og læsning af filer samt stiopslag til konsollen.\nBrug dette, når et spil ikke kan finde sine datafiler under opstart.",
|
||||
"Options.Env.LogNp.Desc": "Log NP-bibliotekskald (PlayStation Network) til konsollen.",
|
||||
"Common.Save": "Gem",
|
||||
"Common.Cancel": "Annuller",
|
||||
"PerGame.Title": "Indstillinger pr. spil — {0} ({1})",
|
||||
"PerGame.InheritNote": "Umarkerede rækker arver de globale standardværdier.",
|
||||
"PerGame.EnvToggles.Label": "Miljøkontakter",
|
||||
"PerGame.EnvToggles.Desc": "Tilsidesæt det globale sæt SHARPEMU_*-kontakter for dette spil.",
|
||||
"Options.About": "Om",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Kildekode, issues og projektudvikling.",
|
||||
"About.Github.LatestCommitLabel": "Seneste commit",
|
||||
"About.Github.LatestCommitDescription": "Seneste commit på main-branchen",
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Discord.Desc": "Bliv en del af fællesskabet, få hjælp og følg udviklingen.",
|
||||
"About.GithubButton": "Bidrag på GitHub!",
|
||||
"About.DiscordButton": "Bliv medlem af vores Discord!",
|
||||
"Updater.Auto.Label": "Søg efter opdateringer ved start",
|
||||
"Updater.Auto.Desc": "Tjekker GitHub uden at forsinke opstarten.",
|
||||
"Updater.Label": "Opdateringer",
|
||||
"Updater.Check": "Søg efter opdateringer",
|
||||
"Updater.DownloadRestart": "Download og genstart",
|
||||
"Updater.Status.Ready": "Nuværende build: {0}",
|
||||
"Updater.Status.Checking": "Søger efter opdateringer…",
|
||||
"Updater.Status.Current": "Du er opdateret ({0}).",
|
||||
"Updater.Status.Available": "Et nyt build er tilgængeligt: {0}",
|
||||
"Updater.Status.Downloading": "Downloader opdatering… {0}%",
|
||||
"Updater.Status.Installing": "Installerer opdatering…",
|
||||
"Updater.Status.Timeout": "Opdateringstjekket fik timeout efter 10 sekunder.",
|
||||
"Updater.Status.Failed": "Kunne ikke søge efter opdateringer.",
|
||||
"Updater.Status.ChecksumFailed": "Den downloadede opdatering bestod ikke SHA-256-verifikationen.",
|
||||
"Updater.Status.Unsupported": "Automatisk opdatering kræver et x64-build til Windows, Linux eller macOS."
|
||||
}
|
||||
|
||||
@@ -135,5 +135,39 @@
|
||||
"About.Github.LatestCommitDescription": "Último commit en la rama main",
|
||||
"About.Discord.Desc": "Únete a la comunidad, recibe soporte y sigue el desarrollo.",
|
||||
"About.GithubButton": "Contribuye en GitHub!",
|
||||
"About.DiscordButton": "Únete a nuestro Discord!"
|
||||
"About.DiscordButton": "Únete a nuestro Discord!",
|
||||
|
||||
"Library.Context.GameSettings": "Ajustes del juego…",
|
||||
"Options.Env.Tab": "Entorno",
|
||||
"Options.Section.Environment": "VARIABLES DE ENTORNO",
|
||||
"Options.Env.Desc": "Opciones que se pasan al emulador como variables de entorno al iniciar.",
|
||||
"Options.Env.Bthid.Desc": "Indicar que Bluetooth HID no está disponible para títulos cuyo middleware de volante/FFB espera indefinidamente.\nDéjalo desactivado normalmente. Algunos títulos se congelan cuando la inicialización falla.",
|
||||
"Options.Env.LoopGuard.Desc": "No forzar el cierre de títulos que repiten la misma llamada durante demasiado tiempo.\nPruébalo cuando un juego se cierre solo durante la carga.",
|
||||
"Options.Env.WritableApp0.Desc": "Permitir que los títulos creen y escriban archivos dentro de su carpeta de instalación.\nNecesario para dumps sin empaquetar que guardan sus datos o configuración en /app0.",
|
||||
"Options.Env.VkValidation.Desc": "Activar las capas de validación de Vulkan para depurar la GPU.\nLento. Requiere tener instalado el SDK de Vulkan.",
|
||||
"Options.Env.DumpSpirv.Desc": "Volcar los shaders AGC y sus traducciones SPIR-V a la carpeta shader-dumps.\nÚsalo al informar de errores de shaders o de renderizado.",
|
||||
"Options.Env.LogDirectMemory.Desc": "Registrar en la consola las asignaciones de memoria directa y sus fallos.\nÚsalo cuando un juego se aborte o se cierre durante el arranque.",
|
||||
"Options.Env.LogIo.Desc": "Registrar en la consola la apertura y lectura de archivos y la resolución de rutas.\nÚsalo cuando un juego no encuentre sus archivos de datos durante el arranque.",
|
||||
"Options.Env.LogNp.Desc": "Registrar en la consola las llamadas a la biblioteca NP (PlayStation Network).",
|
||||
"Common.Save": "Guardar",
|
||||
"Common.Cancel": "Cancelar",
|
||||
"PerGame.Title": "Ajustes por juego — {0} ({1})",
|
||||
"PerGame.InheritNote": "Las filas sin marcar heredan los valores globales.",
|
||||
"PerGame.EnvToggles.Label": "Variables de entorno",
|
||||
"PerGame.EnvToggles.Desc": "Sustituir el conjunto global de opciones SHARPEMU_* para este juego.",
|
||||
"Updater.Auto.Label": "Buscar actualizaciones al iniciar",
|
||||
"Updater.Auto.Desc": "Consulta GitHub sin retrasar el arranque.",
|
||||
"Updater.Label": "Actualizaciones",
|
||||
"Updater.Check": "Buscar actualizaciones",
|
||||
"Updater.DownloadRestart": "Descargar y reiniciar",
|
||||
"Updater.Status.Ready": "Build actual: {0}",
|
||||
"Updater.Status.Checking": "Buscando actualizaciones…",
|
||||
"Updater.Status.Current": "Estás al día ({0}).",
|
||||
"Updater.Status.Available": "Hay un nuevo build disponible: {0}",
|
||||
"Updater.Status.Downloading": "Descargando actualización… {0}%",
|
||||
"Updater.Status.Installing": "Instalando actualización…",
|
||||
"Updater.Status.Timeout": "La comprobación de actualizaciones caducó tras 10 segundos.",
|
||||
"Updater.Status.Failed": "No se pudieron comprobar las actualizaciones.",
|
||||
"Updater.Status.ChecksumFailed": "La actualización descargada no superó la verificación SHA-256.",
|
||||
"Updater.Status.Unsupported": "La actualización automática requiere un build x64 de Windows, Linux o macOS."
|
||||
}
|
||||
|
||||
@@ -142,5 +142,32 @@
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Discord.Desc": "Rejoignez la communauté, obtenez de l’aide et suivez le développement.",
|
||||
"About.GithubButton": "Contribuer sur GitHub !",
|
||||
"About.DiscordButton": "Rejoindre notre Discord !"
|
||||
"About.DiscordButton": "Rejoindre notre Discord !",
|
||||
|
||||
"Library.Context.GameSettings": "Paramètres du jeu…",
|
||||
"Options.Env.WritableApp0.Desc": "Autoriser les jeux à créer et écrire des fichiers dans leur dossier d’installation.\nNécessaire pour les dumps non empaquetés qui écrivent leurs sauvegardes ou leur configuration sous /app0.",
|
||||
"Options.Env.LogIo.Desc": "Journaliser l’ouverture et la lecture des fichiers ainsi que la résolution des chemins dans la console.\nÀ utiliser quand un jeu ne trouve pas ses fichiers de données au démarrage.",
|
||||
"Common.Save": "Enregistrer",
|
||||
"Common.Cancel": "Annuler",
|
||||
"PerGame.Title": "Paramètres par jeu — {0} ({1})",
|
||||
"PerGame.InheritNote": "Les lignes non cochées héritent des valeurs globales par défaut.",
|
||||
"PerGame.EnvToggles.Label": "Variables d’environnement",
|
||||
"PerGame.EnvToggles.Desc": "Remplacer l’ensemble global des options SHARPEMU_* pour ce jeu.",
|
||||
"About.Github.LatestCommitLabel": "Dernier commit",
|
||||
"About.Github.LatestCommitDescription": "Dernier commit sur la branche main",
|
||||
"Updater.Auto.Label": "Vérifier les mises à jour au démarrage",
|
||||
"Updater.Auto.Desc": "Interroge GitHub sans retarder le démarrage.",
|
||||
"Updater.Label": "Mises à jour",
|
||||
"Updater.Check": "Vérifier les mises à jour",
|
||||
"Updater.DownloadRestart": "Télécharger et redémarrer",
|
||||
"Updater.Status.Ready": "Build actuel : {0}",
|
||||
"Updater.Status.Checking": "Recherche de mises à jour…",
|
||||
"Updater.Status.Current": "Vous êtes à jour ({0}).",
|
||||
"Updater.Status.Available": "Un nouveau build est disponible : {0}",
|
||||
"Updater.Status.Downloading": "Téléchargement de la mise à jour… {0}%",
|
||||
"Updater.Status.Installing": "Installation de la mise à jour…",
|
||||
"Updater.Status.Timeout": "La vérification des mises à jour a expiré après 10 secondes.",
|
||||
"Updater.Status.Failed": "Impossible de vérifier les mises à jour.",
|
||||
"Updater.Status.ChecksumFailed": "La mise à jour téléchargée a échoué à la vérification SHA-256.",
|
||||
"Updater.Status.Unsupported": "La mise à jour automatique nécessite un build x64 pour Windows, Linux ou macOS."
|
||||
}
|
||||
|
||||
@@ -142,5 +142,32 @@
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Discord.Desc": "Csatlakozz a közösséghe, kérj segítéget és kövesd nyomon a fejlesztést.",
|
||||
"About.GithubButton": "Járulj hozzá GitHubon!",
|
||||
"About.DiscordButton": "Csatlakozz a Discordunhoz!"
|
||||
}
|
||||
"About.DiscordButton": "Csatlakozz a Discordunhoz!",
|
||||
|
||||
"Library.Context.GameSettings": "Játékbeállítások…",
|
||||
"Options.Env.WritableApp0.Desc": "Engedélyezi, hogy a játékok fájlokat hozzanak létre és írjanak a telepítési mappájukban.\nA kicsomagolt dumpokhoz szükséges, amelyek a mentéseiket vagy beállításaikat az /app0 alá írják.",
|
||||
"Options.Env.LogIo.Desc": "A fájlmegnyitások, olvasások és útvonal-feloldások naplózása a konzolra.\nAkkor használd, ha egy játék indításkor nem találja az adatfájljait.",
|
||||
"Common.Save": "Mentés",
|
||||
"Common.Cancel": "Mégse",
|
||||
"PerGame.Title": "Játékonkénti beállítások — {0} ({1})",
|
||||
"PerGame.InheritNote": "A be nem jelölt sorok a globális alapértelmezéseket öröklik.",
|
||||
"PerGame.EnvToggles.Label": "Környezeti kapcsolók",
|
||||
"PerGame.EnvToggles.Desc": "A globális SHARPEMU_* kapcsolókészlet felülírása ennél a játéknál.",
|
||||
"About.Github.LatestCommitLabel": "Legutóbbi commit",
|
||||
"About.Github.LatestCommitDescription": "A main ág legutóbbi commitja",
|
||||
"Updater.Auto.Label": "Frissítések keresése indításkor",
|
||||
"Updater.Auto.Desc": "A GitHubot az indítás késleltetése nélkül ellenőrzi.",
|
||||
"Updater.Label": "Frissítések",
|
||||
"Updater.Check": "Frissítések keresése",
|
||||
"Updater.DownloadRestart": "Letöltés és újraindítás",
|
||||
"Updater.Status.Ready": "Jelenlegi build: {0}",
|
||||
"Updater.Status.Checking": "Frissítések keresése…",
|
||||
"Updater.Status.Current": "Naprakész vagy ({0}).",
|
||||
"Updater.Status.Available": "Új build érhető el: {0}",
|
||||
"Updater.Status.Downloading": "Frissítés letöltése… {0}%",
|
||||
"Updater.Status.Installing": "Frissítés telepítése…",
|
||||
"Updater.Status.Timeout": "A frissítés-ellenőrzés 10 másodperc után túllépte az időkorlátot.",
|
||||
"Updater.Status.Failed": "Nem sikerült frissítéseket keresni.",
|
||||
"Updater.Status.ChecksumFailed": "A letöltött frissítés nem ment át az SHA-256-ellenőrzésen.",
|
||||
"Updater.Status.Unsupported": "Az automatikus frissítéshez Windows, Linux vagy macOS x64 build szükséges."
|
||||
}
|
||||
|
||||
@@ -130,5 +130,48 @@
|
||||
"Dialog.PsExecutables": "Eseguibili PS",
|
||||
"Dialog.SaveLogFile": "Scegli dove salvare il file di log",
|
||||
"Dialog.PlainTextFiles": "File di testo semplice",
|
||||
"Dialog.LogFiles": "File di log"
|
||||
"Dialog.LogFiles": "File di log",
|
||||
|
||||
"Library.Context.GameSettings": "Impostazioni del gioco…",
|
||||
"Options.Env.Tab": "Ambiente",
|
||||
"Options.Section.Environment": "VARIABILI D'AMBIENTE",
|
||||
"Options.Env.Desc": "Opzioni passate all'emulatore come variabili d'ambiente all'avvio.",
|
||||
"Options.Env.Bthid.Desc": "Segnala il Bluetooth HID come non disponibile per i titoli il cui middleware volante/FFB attende all'infinito.\nNormalmente lascialo disattivato. Alcuni titoli si bloccano quando l'inizializzazione fallisce.",
|
||||
"Options.Env.LoopGuard.Desc": "Non forzare la chiusura dei titoli che ripetono la stessa chiamata troppo a lungo.\nProvalo quando un gioco si chiude da solo durante il caricamento.",
|
||||
"Options.Env.WritableApp0.Desc": "Consenti ai titoli di creare e scrivere file nella propria cartella di installazione.\nNecessario per i dump non pacchettizzati che scrivono salvataggi o configurazioni in /app0.",
|
||||
"Options.Env.VkValidation.Desc": "Abilita i validation layer di Vulkan per il debug della GPU.\nLento. Richiede l'SDK di Vulkan installato.",
|
||||
"Options.Env.DumpSpirv.Desc": "Esporta gli shader AGC e le loro traduzioni SPIR-V nella cartella shader-dumps.\nUsalo quando segnali bug di shader o di rendering.",
|
||||
"Options.Env.LogDirectMemory.Desc": "Registra in console le allocazioni di memoria diretta e i relativi errori.\nUsalo quando un gioco si interrompe o si chiude durante l'avvio.",
|
||||
"Options.Env.LogIo.Desc": "Registra in console l'apertura e la lettura dei file e la risoluzione dei percorsi.\nUsalo quando un gioco non trova i propri file di dati durante l'avvio.",
|
||||
"Options.Env.LogNp.Desc": "Registra in console le chiamate alla libreria NP (PlayStation Network).",
|
||||
"Common.Save": "Salva",
|
||||
"Common.Cancel": "Annulla",
|
||||
"PerGame.Title": "Impostazioni per gioco — {0} ({1})",
|
||||
"PerGame.InheritNote": "Le righe non selezionate ereditano i valori globali.",
|
||||
"PerGame.EnvToggles.Label": "Variabili d'ambiente",
|
||||
"PerGame.EnvToggles.Desc": "Sovrascrivi l'insieme globale delle opzioni SHARPEMU_* per questo gioco.",
|
||||
"Options.About": "Informazioni",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Codice sorgente, issue e sviluppo del progetto.",
|
||||
"About.Github.LatestCommitLabel": "Ultimo commit",
|
||||
"About.Github.LatestCommitDescription": "Ultimo commit sul branch main",
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Discord.Desc": "Unisciti alla community, ricevi supporto e segui lo sviluppo.",
|
||||
"About.GithubButton": "Contribuisci su GitHub!",
|
||||
"About.DiscordButton": "Unisciti al nostro Discord!",
|
||||
"Updater.Auto.Label": "Controlla aggiornamenti all'avvio",
|
||||
"Updater.Auto.Desc": "Interroga GitHub senza rallentare l'avvio.",
|
||||
"Updater.Label": "Aggiornamenti",
|
||||
"Updater.Check": "Controlla aggiornamenti",
|
||||
"Updater.DownloadRestart": "Scarica e riavvia",
|
||||
"Updater.Status.Ready": "Build attuale: {0}",
|
||||
"Updater.Status.Checking": "Ricerca aggiornamenti…",
|
||||
"Updater.Status.Current": "Sei aggiornato ({0}).",
|
||||
"Updater.Status.Available": "È disponibile un nuovo build: {0}",
|
||||
"Updater.Status.Downloading": "Download dell'aggiornamento… {0}%",
|
||||
"Updater.Status.Installing": "Installazione dell'aggiornamento…",
|
||||
"Updater.Status.Timeout": "Il controllo degli aggiornamenti è scaduto dopo 10 secondi.",
|
||||
"Updater.Status.Failed": "Impossibile controllare gli aggiornamenti.",
|
||||
"Updater.Status.ChecksumFailed": "L'aggiornamento scaricato non ha superato la verifica SHA-256.",
|
||||
"Updater.Status.Unsupported": "L'aggiornamento automatico richiede un build x64 per Windows, Linux o macOS."
|
||||
}
|
||||
|
||||
@@ -125,5 +125,48 @@
|
||||
"Dialog.PsExecutables": "PlayStation 実行ファイル",
|
||||
"Dialog.SaveLogFile": "ログファイルの保存先を選択",
|
||||
"Dialog.PlainTextFiles": "プレーンテキストファイル",
|
||||
"Dialog.LogFiles": "ログファイル"
|
||||
}
|
||||
"Dialog.LogFiles": "ログファイル",
|
||||
|
||||
"Library.Context.GameSettings": "ゲーム設定…",
|
||||
"Options.Env.Tab": "環境",
|
||||
"Options.Section.Environment": "環境変数",
|
||||
"Options.Env.Desc": "起動時に環境変数としてエミュレータへ渡されるスイッチです。",
|
||||
"Options.Env.Bthid.Desc": "ハンドル/FFBミドルウェアが永久に待機するタイトル向けに、Bluetooth HIDを利用不可として報告します。\n通常はオフのままにしてください。初期化に失敗するとフリーズするタイトルもあります。",
|
||||
"Options.Env.LoopGuard.Desc": "同じ呼び出しを長時間繰り返すタイトルを強制終了しません。\nロード中にゲームが勝手に終了する場合に試してください。",
|
||||
"Options.Env.WritableApp0.Desc": "タイトルがインストールフォルダー内にファイルを作成・書き込みできるようにします。\nセーブや設定データを/app0以下に書き込む未パッケージのダンプに必要です。",
|
||||
"Options.Env.VkValidation.Desc": "GPUデバッグ用のVulkan検証レイヤーを有効にします。\n低速です。Vulkan SDKのインストールが必要です。",
|
||||
"Options.Env.DumpSpirv.Desc": "AGCシェーダーとそのSPIR-V変換をshader-dumpsフォルダーに出力します。\nシェーダーや描画のバグを報告する際に使用してください。",
|
||||
"Options.Env.LogDirectMemory.Desc": "ダイレクトメモリの割り当てと失敗をコンソールに記録します。\nゲームが起動中に中断・終了する場合に使用してください。",
|
||||
"Options.Env.LogIo.Desc": "ファイルのオープン・読み込み・パス解決の動作をコンソールに記録します。\nゲームが起動中にデータファイルを見つけられない場合に使用してください。",
|
||||
"Options.Env.LogNp.Desc": "NP(PlayStation Network)ライブラリの呼び出しをコンソールに記録します。",
|
||||
"Common.Save": "保存",
|
||||
"Common.Cancel": "キャンセル",
|
||||
"PerGame.Title": "ゲームごとの設定 — {0} ({1})",
|
||||
"PerGame.InheritNote": "チェックされていない行はグローバルの既定値を継承します。",
|
||||
"PerGame.EnvToggles.Label": "環境スイッチ",
|
||||
"PerGame.EnvToggles.Desc": "このゲームに対してグローバルのSHARPEMU_*スイッチを上書きします。",
|
||||
"Options.About": "情報",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "ソースコード、Issue、プロジェクトの開発。",
|
||||
"About.Github.LatestCommitLabel": "最新コミット",
|
||||
"About.Github.LatestCommitDescription": "mainブランチの最新コミット",
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Discord.Desc": "コミュニティに参加して、サポートを受けたり開発を追いかけたりしましょう。",
|
||||
"About.GithubButton": "GitHubで貢献しよう!",
|
||||
"About.DiscordButton": "Discordに参加しよう!",
|
||||
"Updater.Auto.Label": "起動時にアップデートを確認",
|
||||
"Updater.Auto.Desc": "起動を遅らせずにGitHubへ確認します。",
|
||||
"Updater.Label": "アップデート",
|
||||
"Updater.Check": "アップデートを確認",
|
||||
"Updater.DownloadRestart": "ダウンロードして再起動",
|
||||
"Updater.Status.Ready": "現在のビルド: {0}",
|
||||
"Updater.Status.Checking": "アップデートを確認しています…",
|
||||
"Updater.Status.Current": "最新の状態です({0})。",
|
||||
"Updater.Status.Available": "新しいビルドがあります: {0}",
|
||||
"Updater.Status.Downloading": "アップデートをダウンロード中… {0}%",
|
||||
"Updater.Status.Installing": "アップデートをインストール中…",
|
||||
"Updater.Status.Timeout": "アップデートの確認が10秒でタイムアウトしました。",
|
||||
"Updater.Status.Failed": "アップデートを確認できませんでした。",
|
||||
"Updater.Status.ChecksumFailed": "ダウンロードしたアップデートはSHA-256検証に失敗しました。",
|
||||
"Updater.Status.Unsupported": "自動アップデートにはWindows、Linux、またはmacOSのx64ビルドが必要です。"
|
||||
}
|
||||
|
||||
@@ -125,5 +125,48 @@
|
||||
"Dialog.PsExecutables": "PlayStation 실행 파일",
|
||||
"Dialog.SaveLogFile": "로그 파일 저장 위치 선택",
|
||||
"Dialog.PlainTextFiles": "일반 텍스트 파일",
|
||||
"Dialog.LogFiles": "로그 파일"
|
||||
}
|
||||
"Dialog.LogFiles": "로그 파일",
|
||||
|
||||
"Library.Context.GameSettings": "게임 설정…",
|
||||
"Options.Env.Tab": "환경",
|
||||
"Options.Section.Environment": "환경 변수",
|
||||
"Options.Env.Desc": "실행 시 환경 변수로 에뮬레이터에 전달되는 스위치입니다.",
|
||||
"Options.Env.Bthid.Desc": "휠/FFB 미들웨어가 무한 대기하는 타이틀을 위해 블루투스 HID를 사용 불가로 보고합니다.\n평소에는 꺼 두세요. 초기화에 실패하면 멈추는 타이틀도 있습니다.",
|
||||
"Options.Env.LoopGuard.Desc": "같은 호출을 너무 오래 반복하는 타이틀을 강제 종료하지 않습니다.\n게임이 로딩 중 저절로 종료될 때 시도해 보세요.",
|
||||
"Options.Env.WritableApp0.Desc": "타이틀이 설치 폴더 안에 파일을 만들고 쓸 수 있도록 허용합니다.\n세이브나 설정 데이터를 /app0 아래에 쓰는 비패키지 덤프에 필요합니다.",
|
||||
"Options.Env.VkValidation.Desc": "GPU 디버깅을 위한 Vulkan 검증 레이어를 활성화합니다.\n느립니다. Vulkan SDK가 설치되어 있어야 합니다.",
|
||||
"Options.Env.DumpSpirv.Desc": "AGC 셰이더와 SPIR-V 변환 결과를 shader-dumps 폴더에 저장합니다.\n셰이더나 렌더링 버그를 보고할 때 사용하세요.",
|
||||
"Options.Env.LogDirectMemory.Desc": "다이렉트 메모리 할당과 실패를 콘솔에 기록합니다.\n게임이 부팅 중 중단되거나 종료될 때 사용하세요.",
|
||||
"Options.Env.LogIo.Desc": "파일 열기, 읽기, 경로 확인 동작을 콘솔에 기록합니다.\n게임이 부팅 중 데이터 파일을 찾지 못할 때 사용하세요.",
|
||||
"Options.Env.LogNp.Desc": "NP(PlayStation Network) 라이브러리 호출을 콘솔에 기록합니다.",
|
||||
"Common.Save": "저장",
|
||||
"Common.Cancel": "취소",
|
||||
"PerGame.Title": "게임별 설정 — {0} ({1})",
|
||||
"PerGame.InheritNote": "선택하지 않은 항목은 전역 기본값을 따릅니다.",
|
||||
"PerGame.EnvToggles.Label": "환경 스위치",
|
||||
"PerGame.EnvToggles.Desc": "이 게임에 대해 전역 SHARPEMU_* 스위치 설정을 재정의합니다.",
|
||||
"Options.About": "정보",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "소스 코드, 이슈, 프로젝트 개발.",
|
||||
"About.Github.LatestCommitLabel": "최신 커밋",
|
||||
"About.Github.LatestCommitDescription": "main 브랜치의 최신 커밋",
|
||||
"About.Discord.Label": "디스코드",
|
||||
"About.Discord.Desc": "커뮤니티에 참여해 지원을 받고 개발 소식을 확인하세요.",
|
||||
"About.GithubButton": "GitHub에서 기여하기!",
|
||||
"About.DiscordButton": "디스코드 참여하기!",
|
||||
"Updater.Auto.Label": "시작 시 업데이트 확인",
|
||||
"Updater.Auto.Desc": "시작을 지연시키지 않고 GitHub를 확인합니다.",
|
||||
"Updater.Label": "업데이트",
|
||||
"Updater.Check": "업데이트 확인",
|
||||
"Updater.DownloadRestart": "다운로드 후 재시작",
|
||||
"Updater.Status.Ready": "현재 빌드: {0}",
|
||||
"Updater.Status.Checking": "업데이트 확인 중…",
|
||||
"Updater.Status.Current": "최신 상태입니다 ({0}).",
|
||||
"Updater.Status.Available": "새 빌드가 있습니다: {0}",
|
||||
"Updater.Status.Downloading": "업데이트 다운로드 중… {0}%",
|
||||
"Updater.Status.Installing": "업데이트 설치 중…",
|
||||
"Updater.Status.Timeout": "업데이트 확인이 10초 후 시간 초과되었습니다.",
|
||||
"Updater.Status.Failed": "업데이트를 확인할 수 없습니다.",
|
||||
"Updater.Status.ChecksumFailed": "다운로드한 업데이트가 SHA-256 검증에 실패했습니다.",
|
||||
"Updater.Status.Unsupported": "자동 업데이트에는 Windows, Linux 또는 macOS x64 빌드가 필요합니다."
|
||||
}
|
||||
|
||||
@@ -125,5 +125,48 @@
|
||||
"Dialog.PsExecutables": "PS-uitvoerbare bestanden",
|
||||
"Dialog.SaveLogFile": "Selecteer waar het logbestand moet worden opgeslagen",
|
||||
"Dialog.PlainTextFiles": "Platte tekstbestanden",
|
||||
"Dialog.LogFiles": "Logbestanden"
|
||||
"Dialog.LogFiles": "Logbestanden",
|
||||
|
||||
"Library.Context.GameSettings": "Game-instellingen…",
|
||||
"Options.Env.Tab": "Omgeving",
|
||||
"Options.Section.Environment": "OMGEVINGSVARIABELEN",
|
||||
"Options.Env.Desc": "Schakelaars die bij het starten als omgevingsvariabelen aan de emulator worden doorgegeven.",
|
||||
"Options.Env.Bthid.Desc": "Meld Bluetooth HID als niet beschikbaar voor titels waarvan de stuur-/FFB-middleware eindeloos blijft wachten.\nLaat dit normaal uit. Sommige titels bevriezen wanneer de initialisatie mislukt.",
|
||||
"Options.Env.LoopGuard.Desc": "Titels die dezelfde aanroep te lang herhalen niet geforceerd afsluiten.\nProbeer dit wanneer een game zichzelf tijdens het laden afsluit.",
|
||||
"Options.Env.WritableApp0.Desc": "Sta titels toe bestanden aan te maken en te schrijven in hun installatiemap.\nNodig voor uitgepakte dumps die hun save- of configuratiegegevens onder /app0 wegschrijven.",
|
||||
"Options.Env.VkValidation.Desc": "Schakel Vulkan-validatielagen in voor GPU-debugging.\nTraag. Vereist een geïnstalleerde Vulkan SDK.",
|
||||
"Options.Env.DumpSpirv.Desc": "Sla AGC-shaders en hun SPIR-V-vertalingen op in de map shader-dumps.\nGebruik dit bij het melden van shader- of renderfouten.",
|
||||
"Options.Env.LogDirectMemory.Desc": "Log directe geheugentoewijzingen en fouten naar de console.\nGebruik dit wanneer een game tijdens het opstarten afbreekt of afsluit.",
|
||||
"Options.Env.LogIo.Desc": "Log het openen en lezen van bestanden en het oplossen van paden naar de console.\nGebruik dit wanneer een game zijn databestanden niet kan vinden tijdens het opstarten.",
|
||||
"Options.Env.LogNp.Desc": "Log NP-bibliotheekaanroepen (PlayStation Network) naar de console.",
|
||||
"Common.Save": "Opslaan",
|
||||
"Common.Cancel": "Annuleren",
|
||||
"PerGame.Title": "Instellingen per game — {0} ({1})",
|
||||
"PerGame.InheritNote": "Niet-aangevinkte rijen erven de globale standaardwaarden.",
|
||||
"PerGame.EnvToggles.Label": "Omgevingsschakelaars",
|
||||
"PerGame.EnvToggles.Desc": "Overschrijf de globale set SHARPEMU_*-schakelaars voor deze game.",
|
||||
"Options.About": "Over",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Broncode, issues en projectontwikkeling.",
|
||||
"About.Github.LatestCommitLabel": "Nieuwste commit",
|
||||
"About.Github.LatestCommitDescription": "Nieuwste commit op de main-branch",
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Discord.Desc": "Word lid van de community, krijg ondersteuning en volg de ontwikkeling.",
|
||||
"About.GithubButton": "Draag bij op GitHub!",
|
||||
"About.DiscordButton": "Word lid van onze Discord!",
|
||||
"Updater.Auto.Label": "Bij het opstarten controleren op updates",
|
||||
"Updater.Auto.Desc": "Controleert GitHub zonder het opstarten te vertragen.",
|
||||
"Updater.Label": "Updates",
|
||||
"Updater.Check": "Controleren op updates",
|
||||
"Updater.DownloadRestart": "Downloaden en opnieuw starten",
|
||||
"Updater.Status.Ready": "Huidige build: {0}",
|
||||
"Updater.Status.Checking": "Controleren op updates…",
|
||||
"Updater.Status.Current": "Je bent up-to-date ({0}).",
|
||||
"Updater.Status.Available": "Er is een nieuwe build beschikbaar: {0}",
|
||||
"Updater.Status.Downloading": "Update downloaden… {0}%",
|
||||
"Updater.Status.Installing": "Update installeren…",
|
||||
"Updater.Status.Timeout": "De updatecontrole is na 10 seconden verlopen.",
|
||||
"Updater.Status.Failed": "Kon niet controleren op updates.",
|
||||
"Updater.Status.ChecksumFailed": "De gedownloade update is niet door de SHA-256-verificatie gekomen.",
|
||||
"Updater.Status.Unsupported": "Automatisch updaten vereist een x64-build voor Windows, Linux of macOS."
|
||||
}
|
||||
|
||||
@@ -142,5 +142,32 @@
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Discord.Desc": "Junte-se à comunidade, obtenha suporte e acompanhe o desenvolvimento.",
|
||||
"About.GithubButton": "Contribua no GitHub!",
|
||||
"About.DiscordButton": "Junte-se ao nosso Discord!"
|
||||
"About.DiscordButton": "Junte-se ao nosso Discord!",
|
||||
|
||||
"Library.Context.GameSettings": "Definições do jogo…",
|
||||
"Options.Env.WritableApp0.Desc": "Permitir que os jogos criem e escrevam ficheiros dentro da sua pasta de instalação.\nNecessário para dumps não empacotados que escrevem os seus dados guardados ou configurações em /app0.",
|
||||
"Options.Env.LogIo.Desc": "Registar na consola a abertura e leitura de ficheiros e a resolução de caminhos.\nUtilize quando um jogo não encontrar os seus ficheiros de dados durante o arranque.",
|
||||
"Common.Save": "Guardar",
|
||||
"Common.Cancel": "Cancelar",
|
||||
"PerGame.Title": "Definições por jogo — {0} ({1})",
|
||||
"PerGame.InheritNote": "As linhas não assinaladas herdam as predefinições globais.",
|
||||
"PerGame.EnvToggles.Label": "Variáveis de ambiente",
|
||||
"PerGame.EnvToggles.Desc": "Substituir o conjunto global de opções SHARPEMU_* para este jogo.",
|
||||
"About.Github.LatestCommitLabel": "Último commit",
|
||||
"About.Github.LatestCommitDescription": "Último commit no ramo main",
|
||||
"Updater.Auto.Label": "Procurar atualizações no arranque",
|
||||
"Updater.Auto.Desc": "Consulta o GitHub sem atrasar o arranque.",
|
||||
"Updater.Label": "Atualizações",
|
||||
"Updater.Check": "Procurar atualizações",
|
||||
"Updater.DownloadRestart": "Transferir e reiniciar",
|
||||
"Updater.Status.Ready": "Build atual: {0}",
|
||||
"Updater.Status.Checking": "A procurar atualizações…",
|
||||
"Updater.Status.Current": "Está atualizado ({0}).",
|
||||
"Updater.Status.Available": "Está disponível um novo build: {0}",
|
||||
"Updater.Status.Downloading": "A transferir a atualização… {0}%",
|
||||
"Updater.Status.Installing": "A instalar a atualização…",
|
||||
"Updater.Status.Timeout": "A verificação de atualizações expirou após 10 segundos.",
|
||||
"Updater.Status.Failed": "Não foi possível procurar atualizações.",
|
||||
"Updater.Status.ChecksumFailed": "A atualização transferida falhou a verificação SHA-256.",
|
||||
"Updater.Status.Unsupported": "A atualização automática requer um build x64 para Windows, Linux ou macOS."
|
||||
}
|
||||
|
||||
@@ -169,5 +169,7 @@
|
||||
"Updater.Status.Installing": "Установка обновления…",
|
||||
"Updater.Status.Timeout": "Проверка обновлений превысила лимит времени в 10 секунд.",
|
||||
"Updater.Status.Failed": "Не удалось проверить наличие обновлений.",
|
||||
"Updater.Status.Unsupported": "Автоматическое обновление требует сборку Windows, Linux или macOS x64."
|
||||
"Updater.Status.Unsupported": "Автоматическое обновление требует сборку Windows, Linux или macOS x64.",
|
||||
|
||||
"Updater.Status.ChecksumFailed": "Скачанное обновление не прошло проверку SHA-256."
|
||||
}
|
||||
|
||||
@@ -141,5 +141,33 @@
|
||||
"Updater.Status.Timeout": "Güncelleme denetimi 10 saniye sonra zaman aşımına uğradı.",
|
||||
"Updater.Status.Failed": "Güncellemeler denetlenemedi.",
|
||||
"Updater.Status.ChecksumFailed": "İndirilen güncelleme SHA-256 doğrulamasını geçemedi.",
|
||||
"Updater.Status.Unsupported": "Otomatik güncelleme Windows, Linux veya macOS x64 build'i gerektirir."
|
||||
"Updater.Status.Unsupported": "Otomatik güncelleme Windows, Linux veya macOS x64 build'i gerektirir.",
|
||||
|
||||
"Library.Context.GameSettings": "Oyun ayarları…",
|
||||
"Options.Env.Tab": "Ortam",
|
||||
"Options.Section.Environment": "ORTAM DEĞİŞKENLERİ",
|
||||
"Options.Env.Desc": "Başlatma sırasında emülatöre ortam değişkeni olarak geçirilen anahtarlar.",
|
||||
"Options.Env.Bthid.Desc": "Direksiyon/FFB katmanı sonsuza kadar bekleyen oyunlar için Bluetooth HID'i kullanılamıyor olarak bildir.\nNormalde kapalı bırakın. Bazı oyunlar başlatma başarısız olduğunda donar.",
|
||||
"Options.Env.LoopGuard.Desc": "Aynı çağrıyı uzun süre tekrarlayan oyunları zorla kapatma.\nBir oyun yükleme sırasında kendiliğinden kapanıyorsa bunu deneyin.",
|
||||
"Options.Env.WritableApp0.Desc": "Oyunların kurulum klasörlerinde dosya oluşturup yazmasına izin ver.\nKayıt veya yapılandırma verisini /app0 altına yazan paketlenmemiş dump'lar için gereklidir.",
|
||||
"Options.Env.VkValidation.Desc": "GPU hata ayıklaması için Vulkan doğrulama katmanlarını etkinleştir.\nYavaştır. Vulkan SDK'nın kurulu olması gerekir.",
|
||||
"Options.Env.DumpSpirv.Desc": "AGC shader'larını ve SPIR-V çevirilerini shader-dumps klasörüne kaydet.\nShader veya görüntü hatalarını bildirirken kullanın.",
|
||||
"Options.Env.LogDirectMemory.Desc": "Doğrudan bellek tahsislerini ve hatalarını konsola günlükle.\nBir oyun açılış sırasında çöküyor veya kapanıyorsa kullanın.",
|
||||
"Options.Env.LogIo.Desc": "Dosya açma, okuma ve yol çözümleme etkinliğini konsola günlükle.\nBir oyun açılışta veri dosyalarını bulamıyorsa kullanın.",
|
||||
"Options.Env.LogNp.Desc": "NP (PlayStation Network) kütüphane çağrılarını konsola günlükle.",
|
||||
"Common.Save": "Kaydet",
|
||||
"Common.Cancel": "İptal",
|
||||
"PerGame.Title": "Oyuna özel ayarlar — {0} ({1})",
|
||||
"PerGame.InheritNote": "İşaretlenmemiş satırlar genel varsayılanları kullanır.",
|
||||
"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",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Kaynak kodu, hata kayıtları ve proje geliştirme.",
|
||||
"About.Github.LatestCommitLabel": "Son Commit",
|
||||
"About.Github.LatestCommitDescription": "main dalındaki son commit",
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Discord.Desc": "Topluluğa katılın, destek alın ve geliştirmeyi takip edin.",
|
||||
"About.GithubButton": "GitHub'da katkıda bulun!",
|
||||
"About.DiscordButton": "Discord'umuza katıl!"
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Panel Grid.Row="1">
|
||||
<Panel Grid.Row="1" x:Name="PagesHost">
|
||||
|
||||
<!-- Library page. The tile row gets extra top margin so it sits
|
||||
closer to eye level (PS5 home-screen style) instead of hugging
|
||||
@@ -400,6 +400,29 @@ 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>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="EnvTabItem" Header="Environment" FontSize="15">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
||||
@@ -604,9 +627,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
<!-- 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 #GameView}"
|
||||
PlacementTarget="{Binding #MainContent}"
|
||||
Placement="Center"
|
||||
Topmost="True"
|
||||
ShouldUseOverlayLayer="False"
|
||||
|
||||
@@ -88,6 +88,15 @@ public partial class MainWindow : Window
|
||||
private int _detailLoadGeneration;
|
||||
private int _backdropGeneration;
|
||||
|
||||
// Bundled key art shown whenever no game-specific backdrop applies; the
|
||||
// plain window color remains the fallback when the asset fails to load.
|
||||
private Bitmap? _defaultBackdrop;
|
||||
|
||||
// Whether the native loading/closing popup should be showing; it is a
|
||||
// desktop-topmost popup, so it closes while the launcher is in the
|
||||
// background or minimized and reopens from this flag on activation.
|
||||
private bool _sessionLoadingActive;
|
||||
|
||||
// Controller navigation state.
|
||||
private readonly DispatcherTimer _gamepadTimer;
|
||||
private HostGamepadButtons _previousPadButtons;
|
||||
@@ -111,6 +120,18 @@ public partial class MainWindow : Window
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
try
|
||||
{
|
||||
_defaultBackdrop = new Bitmap(
|
||||
AssetLoader.Open(new Uri("avares://SharpEmu.GUI/Assets/pic0.png")));
|
||||
BackdropImage.Source = _defaultBackdrop;
|
||||
BackdropImage.Opacity = 1.0;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_defaultBackdrop = null; // color background remains the fallback
|
||||
}
|
||||
|
||||
GameList.ItemsSource = _visibleGames;
|
||||
ConsoleList.ItemsSource = _consoleLines;
|
||||
_consoleMirror = GuiConsoleMirror.Install((line, isError) =>
|
||||
@@ -134,8 +155,18 @@ public partial class MainWindow : Window
|
||||
};
|
||||
_libraryBlurTimer.Tick += (_, _) => AdvanceLibraryBlur();
|
||||
|
||||
Activated += (_, _) => UpdateSessionBarVisibility();
|
||||
Deactivated += (_, _) => SessionBarPopup.IsOpen = false;
|
||||
// Native popups float above every window on the desktop; they must
|
||||
// follow the launcher into the background or a minimized state.
|
||||
Activated += (_, _) =>
|
||||
{
|
||||
UpdateSessionBarVisibility();
|
||||
SessionLoadingPopup.IsOpen = _sessionLoadingActive;
|
||||
};
|
||||
Deactivated += (_, _) =>
|
||||
{
|
||||
SessionBarPopup.IsOpen = false;
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
};
|
||||
|
||||
TitleBar.PointerPressed += OnTitleBarPointerPressed;
|
||||
GameList.SelectionChanged += (_, _) => UpdateSelectedGame();
|
||||
@@ -161,6 +192,18 @@ 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 += (_, _) =>
|
||||
@@ -398,6 +441,15 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isRunning || _isStopping)
|
||||
{
|
||||
// The game renders inside the launcher window, so the launcher
|
||||
// stays active while playing. The controller belongs to the game
|
||||
// then: no navigation, and Circle/B must never stop the session.
|
||||
_previousPadButtons = pad.Buttons;
|
||||
return;
|
||||
}
|
||||
|
||||
var shoulderPressed = pad.Buttons & ~_previousPadButtons;
|
||||
if ((shoulderPressed & HostGamepadButtons.L1) != 0)
|
||||
{
|
||||
@@ -447,11 +499,6 @@ public partial class MainWindow : Window
|
||||
LaunchSelected();
|
||||
}
|
||||
|
||||
if ((pressed & HostGamepadButtons.Circle) != 0)
|
||||
{
|
||||
StopEmulator();
|
||||
}
|
||||
|
||||
_previousPadButtons = pad.Buttons;
|
||||
}
|
||||
|
||||
@@ -834,6 +881,13 @@ 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;
|
||||
@@ -1610,13 +1664,23 @@ public partial class MainWindow : Window
|
||||
base.OnPropertyChanged(change);
|
||||
if (change.Property == WindowStateProperty)
|
||||
{
|
||||
// The XAML WindowState="Maximized" assignment raises this change
|
||||
// during InitializeComponent, before named controls are wired up.
|
||||
if (WindowState == WindowState.Minimized)
|
||||
{
|
||||
_sndPreview.Pause();
|
||||
if (SessionLoadingPopup is { } popup)
|
||||
{
|
||||
popup.IsOpen = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_sndPreview.Resume();
|
||||
if (SessionLoadingPopup is { } popup)
|
||||
{
|
||||
popup.IsOpen = _sessionLoadingActive;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1631,8 +1695,20 @@ public partial class MainWindow : Window
|
||||
var generation = ++_backdropGeneration;
|
||||
BackdropImage.Opacity = 0;
|
||||
|
||||
// The bundled key art is the primary backdrop whenever the selection
|
||||
// has no art of its own; the window color stays as the last fallback.
|
||||
void ShowDefaultBackdrop()
|
||||
{
|
||||
if (generation == _backdropGeneration && _defaultBackdrop is not null)
|
||||
{
|
||||
BackdropImage.Source = _defaultBackdrop;
|
||||
BackdropImage.Opacity = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
if (game?.BackgroundPath is null)
|
||||
{
|
||||
ShowDefaultBackdrop();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1649,7 +1725,8 @@ public partial class MainWindow : Window
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return; // undecodable key art: keep the plain background
|
||||
ShowDefaultBackdrop(); // undecodable key art
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1730,6 +1807,12 @@ public partial class MainWindow : Window
|
||||
_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;
|
||||
@@ -1969,18 +2052,30 @@ public partial class MainWindow : Window
|
||||
_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;
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
HideSessionLoading();
|
||||
UpdateSessionBarVisibility();
|
||||
|
||||
// Defer so the layout pass from the margin change above settles first.
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (!_isRunning || _isStopping)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_gameSurfaceHost?.RefreshSurfaceSize();
|
||||
_gameSurfaceHost?.SetPresentationVisible(true);
|
||||
_gameSurfaceHost?.SetCursorAutoHide(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2030,11 +2125,31 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
@@ -2059,7 +2174,7 @@ public partial class MainWindow : Window
|
||||
GameView.IsVisible = false;
|
||||
GameView.IsHitTestVisible = true;
|
||||
SessionBarPopup.IsOpen = false;
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
HideSessionLoading();
|
||||
AnimateLibraryBlur(0, clearWhenComplete: true);
|
||||
MainContent.Margin = new Thickness(32, 24, 32, 20);
|
||||
ContentToolbar.IsVisible = true;
|
||||
@@ -2068,16 +2183,15 @@ public partial class MainWindow : Window
|
||||
LibraryPage.IsVisible = _activePageIndex == 0;
|
||||
LibraryToolbar.IsVisible = _activePageIndex == 0;
|
||||
OptionsPage.IsVisible = _activePageIndex == 1;
|
||||
if (GameList.SelectedItem is GameEntry game && game.Background is not null)
|
||||
{
|
||||
BackdropImage.Opacity = 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)
|
||||
{
|
||||
_libraryBlur ??= new BlurEffect();
|
||||
MainContent.Effect = _libraryBlur;
|
||||
PagesHost.Effect = _libraryBlur;
|
||||
|
||||
_libraryBlurStartRadius = _libraryBlur.Radius;
|
||||
_libraryBlurTargetRadius = Math.Max(0, targetRadius);
|
||||
@@ -2126,7 +2240,7 @@ public partial class MainWindow : Window
|
||||
|
||||
if (_clearLibraryBlurWhenComplete)
|
||||
{
|
||||
MainContent.Effect = null;
|
||||
PagesHost.Effect = null;
|
||||
_libraryBlur = null;
|
||||
_clearLibraryBlurWhenComplete = false;
|
||||
}
|
||||
@@ -2137,14 +2251,21 @@ public partial class MainWindow : Window
|
||||
_libraryBlurTimer.Stop();
|
||||
_libraryBlur = null;
|
||||
_clearLibraryBlurWhenComplete = false;
|
||||
MainContent.Effect = null;
|
||||
PagesHost.Effect = null;
|
||||
}
|
||||
|
||||
private void ShowSessionLoading(string title, string detail)
|
||||
{
|
||||
SessionLoadingTitle.Text = title;
|
||||
SessionLoadingDetail.Text = detail;
|
||||
SessionLoadingPopup.IsOpen = true;
|
||||
_sessionLoadingActive = true;
|
||||
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
|
||||
}
|
||||
|
||||
private void HideSessionLoading()
|
||||
{
|
||||
_sessionLoadingActive = false;
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
}
|
||||
|
||||
private void ReturnToLibraryWhileStopping()
|
||||
@@ -2156,10 +2277,12 @@ public partial class MainWindow : Window
|
||||
|
||||
// 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; leaving it transparent lets the library recover
|
||||
// while the native closing popup reports teardown progress.
|
||||
// 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;
|
||||
@@ -2171,7 +2294,7 @@ public partial class MainWindow : Window
|
||||
LibraryPage.IsVisible = _activePageIndex == 0;
|
||||
LibraryToolbar.IsVisible = _activePageIndex == 0;
|
||||
OptionsPage.IsVisible = _activePageIndex == 1;
|
||||
BackdropImage.Opacity = GameList.SelectedItem is GameEntry { Background: not null } ? 1 : 0;
|
||||
BackdropImage.Opacity = BackdropImage.Source is not null ? 1 : 0;
|
||||
UpdateRunButtons();
|
||||
Console.Error.WriteLine("[GUI][INFO] Library restored while embedded session is closing.");
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ public sealed class PerGameSettings
|
||||
var path = PathFor(titleId);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return JsonSerializer.Deserialize<PerGameSettings>(File.ReadAllText(path), SerializerOptions);
|
||||
return NormalizeFromJson(File.ReadAllText(path));
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -59,6 +59,18 @@ 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))
|
||||
|
||||
@@ -24,6 +24,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="SharpEmu.Libs.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
@@ -38,6 +42,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<AvaloniaResource Include="..\..\assets\images\discord.png" Link="Assets/discord.png" />
|
||||
<AvaloniaResource Include="..\..\assets\images\update-icon.png" Link="Assets/update-icon.png" />
|
||||
<AvaloniaResource Include="..\..\assets\images\commit-icon.png" Link="Assets/commit-icon.png" />
|
||||
<AvaloniaResource Include="..\..\assets\images\pic0.png" Link="Assets/pic0.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -32,6 +32,7 @@ public static unsafe class GuestImageWriteTracker
|
||||
public int Armed;
|
||||
public int FirstCpuWriteSeen;
|
||||
public int PendingFirstCpuWrite;
|
||||
public long WriteGeneration;
|
||||
public bool TraceLifetime;
|
||||
public long SourceSequence;
|
||||
public long FirstCpuWriteTraceSequence;
|
||||
@@ -51,9 +52,33 @@ public static unsafe class GuestImageWriteTracker
|
||||
private static readonly object _gate = new();
|
||||
private static readonly Dictionary<ulong, TrackedRange> _rangesByAddress = new();
|
||||
|
||||
// Snapshot array read lock-free from the signal handler; rebuilt on every
|
||||
// mutation under the gate. Signal handlers must not take managed locks.
|
||||
private static TrackedRange[] _rangeSnapshot = [];
|
||||
/// <summary>Immutable snapshot read lock-free from the signal handler and
|
||||
/// the managed-write pre-visit; rebuilt on every mutation under the gate
|
||||
/// (signal handlers must not take managed locks). Carrying the overall
|
||||
/// bounds inside the same object keeps the hot-path intersection test
|
||||
/// consistent with the array it guards.</summary>
|
||||
private sealed class RangeSnapshot
|
||||
{
|
||||
public static readonly RangeSnapshot Empty = new([]);
|
||||
|
||||
public readonly TrackedRange[] Ranges;
|
||||
public readonly ulong Start;
|
||||
public readonly ulong End;
|
||||
|
||||
public RangeSnapshot(TrackedRange[] ranges)
|
||||
{
|
||||
Ranges = ranges;
|
||||
Start = ulong.MaxValue;
|
||||
End = 0;
|
||||
foreach (var range in ranges)
|
||||
{
|
||||
Start = Math.Min(Start, range.Start);
|
||||
End = Math.Max(End, range.End);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static RangeSnapshot _rangeSnapshot = RangeSnapshot.Empty;
|
||||
|
||||
private static readonly bool _enabled = !OperatingSystem.IsWindows() &&
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
|
||||
@@ -131,10 +156,21 @@ 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.
|
||||
// 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);
|
||||
DisarmLocked(range, "replace-range");
|
||||
_rangesByAddress.Remove(address);
|
||||
range = null;
|
||||
range = new TrackedRange
|
||||
{
|
||||
Address = address,
|
||||
ByteCount = byteCount,
|
||||
Start = start,
|
||||
End = start + length,
|
||||
WriteGeneration = writeGeneration,
|
||||
};
|
||||
_rangesByAddress[address] = range;
|
||||
RebuildSnapshotLocked();
|
||||
}
|
||||
|
||||
if (range is null)
|
||||
@@ -248,6 +284,31 @@ public static unsafe class GuestImageWriteTracker
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
@@ -266,6 +327,17 @@ public static unsafe class GuestImageWriteTracker
|
||||
var end = address > ulong.MaxValue - byteCount
|
||||
? ulong.MaxValue
|
||||
: address + byteCount;
|
||||
|
||||
// Fast rejection for the hot path: this runs on every managed guest
|
||||
// write, and almost none of them touch tracked texture pages. The
|
||||
// bounds live inside the snapshot so they are always consistent with
|
||||
// the ranges the per-page visit below would consult.
|
||||
var snapshot = Volatile.Read(ref _rangeSnapshot);
|
||||
if (snapshot.Ranges.Length == 0 || end <= snapshot.Start || address >= snapshot.End)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var candidate = address;
|
||||
while (candidate < end)
|
||||
{
|
||||
@@ -311,7 +383,7 @@ public static unsafe class GuestImageWriteTracker
|
||||
return false;
|
||||
}
|
||||
|
||||
var ranges = Volatile.Read(ref _rangeSnapshot);
|
||||
var ranges = Volatile.Read(ref _rangeSnapshot).Ranges;
|
||||
var writableStart = ulong.MaxValue;
|
||||
var writableEnd = 0UL;
|
||||
for (var index = 0; index < ranges.Length; index++)
|
||||
@@ -390,6 +462,10 @@ public static unsafe class GuestImageWriteTracker
|
||||
}
|
||||
|
||||
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) != 0;
|
||||
if (wasArmed)
|
||||
{
|
||||
Interlocked.Increment(ref range.WriteGeneration);
|
||||
}
|
||||
if (wasArmed &&
|
||||
range.TraceLifetime &&
|
||||
Interlocked.CompareExchange(ref range.FirstCpuWriteSeen, 1, 0) == 0)
|
||||
@@ -458,7 +534,7 @@ public static unsafe class GuestImageWriteTracker
|
||||
|
||||
private static void RebuildSnapshotLocked()
|
||||
{
|
||||
_rangeSnapshot = _rangesByAddress.Values.ToArray();
|
||||
Volatile.Write(ref _rangeSnapshot, new RangeSnapshot(_rangesByAddress.Values.ToArray()));
|
||||
}
|
||||
|
||||
private static (ulong Start, ulong Length) PageAlign(ulong address, ulong byteCount)
|
||||
|
||||
@@ -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 = 0x10000UL;
|
||||
public const ulong StartupStaticTlsReservation = 0x20000UL; // Was 0x10000UL, but thats too small for GTA V
|
||||
private static readonly object _gate = new();
|
||||
private static readonly SortedDictionary<ulong, ModuleTemplate> _modules = new();
|
||||
private static readonly Dictionary<ulong, ThreadDtv> _threadDtvs = new();
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -10,4 +10,6 @@ public interface ICpuMemory
|
||||
bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source);
|
||||
|
||||
bool TryCompare(ulong virtualAddress, ReadOnlySpan<byte> expected) => false;
|
||||
|
||||
bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length) => false;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,17 @@ public interface IGuestAddressSpace : IGuestMemoryAllocator
|
||||
{
|
||||
ulong AllocateAt(ulong desiredAddress, ulong size, bool executable = true, bool allowAlternative = true);
|
||||
|
||||
/// <summary>
|
||||
/// Backs an entire fixed-address range, matching the guest's
|
||||
/// <c>SCE_KERNEL_MAP_FIXED</c> contract. Unlike <see cref="AllocateAt"/>, which
|
||||
/// reserves the range in one all-or-nothing host call, this walks the range and
|
||||
/// fills only the sub-ranges that are not already backed. That keeps a fixed
|
||||
/// mapping whole when part of the requested window is already occupied — the
|
||||
/// partial-overlap case where the single-call reservation fails outright and
|
||||
/// leaves the remainder unmapped for the guest to fault into.
|
||||
/// </summary>
|
||||
bool TryBackFixedRange(ulong address, ulong size, bool executable);
|
||||
|
||||
bool TryAllocateAtOrAbove(ulong desiredAddress, ulong size, bool executable, ulong alignment, out ulong actualAddress);
|
||||
|
||||
bool TryProtect(ulong address, ulong size, GuestPageProtection protection);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Libs.Acm;
|
||||
|
||||
public static class AcmExports
|
||||
{
|
||||
private static int _nextContextHandle;
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "ZIXln2K3XMk",
|
||||
ExportName = "sceAcmContextCreate",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAcm")]
|
||||
public static int AcmContextCreate(CpuContext ctx)
|
||||
{
|
||||
var outContextAddress = ctx[CpuRegister.Rdi];
|
||||
if (outContextAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
var handle = (ulong)Interlocked.Increment(ref _nextContextHandle);
|
||||
Span<byte> handleBytes = stackalloc byte[sizeof(ulong)];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(handleBytes, handle);
|
||||
return ctx.Memory.TryWrite(outContextAddress, handleBytes)
|
||||
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "jBgBjAj02R8",
|
||||
ExportName = "sceAcmContextDestroy",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAcm")]
|
||||
public static int AcmContextDestroy(CpuContext ctx)
|
||||
{
|
||||
_ = ctx;
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
}
|
||||
+833
-186
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using SharpEmu.Libs.Gpu;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
@@ -26,8 +27,8 @@ internal static class AgcShaderCompilerHooks
|
||||
internal static void Install()
|
||||
{
|
||||
Gen5ShaderScalarEvaluator.FallbackMemoryReader =
|
||||
KernelMemoryCompatExports.TryReadTrackedLibcHeap;
|
||||
KernelMemoryCompatExports.TryReadShaderGuestMemory;
|
||||
Gen5ShaderScalarEvaluator.GlobalMemoryPool =
|
||||
VulkanVideoPresenter.GuestDataPool;
|
||||
GuestDataPool.Shared;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace SharpEmu.Libs.Agc;
|
||||
|
||||
/// <summary>
|
||||
@@ -16,8 +19,11 @@ namespace SharpEmu.Libs.Agc;
|
||||
/// other D/R and pipe/bank-XOR modes stay opt-in while their complete AddrLib
|
||||
/// equations are being ported.
|
||||
/// </summary>
|
||||
internal static class GnmTiling
|
||||
internal static unsafe class GnmTiling
|
||||
{
|
||||
private const int ParallelDetileElementThreshold = 512 * 512;
|
||||
private const int MaxDetileWorkers = 4;
|
||||
|
||||
// Oberon uses the 16-pipe / 8-pixel-packer RB+ topology. These are the
|
||||
// single-sample 64 KiB equations generated by AMD AddrLib for that exact
|
||||
// topology. Each entry describes one address bit as an XOR of X/Y bits.
|
||||
@@ -118,6 +124,14 @@ internal static class GnmTiling
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static readonly HashSet<uint> _reportedModes = new();
|
||||
private static readonly ConcurrentDictionary<(uint SwizzleMode, int BppLog2), PatternTerms>
|
||||
_patternTermCache = new();
|
||||
private static readonly ConcurrentDictionary<(SwizzleKind Kind, int Width, int Height), int[]>
|
||||
_blockTableCache = new();
|
||||
private static readonly ParallelOptions _parallelDetileOptions = new()
|
||||
{
|
||||
MaxDegreeOfParallelism = Math.Min(MaxDetileWorkers, Environment.ProcessorCount),
|
||||
};
|
||||
|
||||
public static bool Enabled => _enabled || !_disabled;
|
||||
|
||||
@@ -194,6 +208,164 @@ internal static class GnmTiling
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetBlockElementDimensions(
|
||||
uint swizzleMode,
|
||||
int bytesPerElement,
|
||||
out int blockWidth,
|
||||
out int blockHeight)
|
||||
{
|
||||
blockWidth = 0;
|
||||
blockHeight = 0;
|
||||
if (bytesPerElement <= 0 ||
|
||||
!TryGetSwizzleKind(swizzleMode, out _, out var blockBytes))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var bppLog2 = BitLog2((uint)bytesPerElement);
|
||||
if (bppLog2 < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
(blockWidth, blockHeight) = SquareBlockDimensions(blockBytes >> bppLog2);
|
||||
return blockWidth != 0 && blockHeight != 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locates mip 0 in a GFX10 mip chain, which AddrLib stores smallest-first
|
||||
/// (Gfx10Lib::ComputeSurfaceInfoMacroTiled/MicroTiled).
|
||||
/// </summary>
|
||||
public static bool TryGetBaseMipPlacement(
|
||||
uint swizzleMode,
|
||||
int elementsWide,
|
||||
int elementsHigh,
|
||||
int bytesPerElement,
|
||||
uint resourceMipLevels,
|
||||
out ulong byteOffset,
|
||||
out bool inMipTail,
|
||||
out int tailElementX,
|
||||
out int tailElementY,
|
||||
out ulong chainSliceBytes)
|
||||
{
|
||||
byteOffset = 0;
|
||||
inMipTail = false;
|
||||
tailElementX = 0;
|
||||
tailElementY = 0;
|
||||
chainSliceBytes = 0;
|
||||
if (resourceMipLevels <= 1 ||
|
||||
!ShouldDetile(swizzleMode) ||
|
||||
elementsWide <= 0 ||
|
||||
elementsHigh <= 0 ||
|
||||
bytesPerElement <= 0 ||
|
||||
!TryGetSwizzleKind(swizzleMode, out _, out var blockBytes))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var bppLog2 = BitLog2((uint)bytesPerElement);
|
||||
if (bppLog2 < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var (blockWidth, blockHeight) = SquareBlockDimensions(blockBytes >> bppLog2);
|
||||
var blockSizeLog2 = BitLog2((uint)blockBytes);
|
||||
if (blockWidth == 0 || blockHeight == 0 || blockSizeLog2 < 8)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var mipLevels = (int)Math.Min(resourceMipLevels, 16u);
|
||||
var maxMipsInTail = blockSizeLog2 <= 8 ? 0
|
||||
: blockSizeLog2 <= 11
|
||||
? 1 + (1 << (blockSizeLog2 - 9))
|
||||
: blockSizeLog2 - 4;
|
||||
var tailWidth = (blockSizeLog2 & 1) != 0 ? blockWidth >> 1 : blockWidth;
|
||||
var tailHeight = (blockSizeLog2 & 1) != 0 ? blockHeight : blockHeight >> 1;
|
||||
|
||||
var firstMipInTail = mipLevels;
|
||||
var mipSizes = new ulong[mipLevels];
|
||||
for (var i = 0; i < mipLevels; i++)
|
||||
{
|
||||
var mipWidth = Math.Max(elementsWide >> i, 1);
|
||||
var mipHeight = Math.Max(elementsHigh >> i, 1);
|
||||
if (maxMipsInTail > 0 &&
|
||||
mipWidth <= tailWidth &&
|
||||
mipHeight <= tailHeight &&
|
||||
mipLevels - i <= maxMipsInTail)
|
||||
{
|
||||
firstMipInTail = i;
|
||||
break;
|
||||
}
|
||||
|
||||
var alignedWidth = (ulong)(mipWidth + blockWidth - 1) / (ulong)blockWidth * (ulong)blockWidth;
|
||||
var alignedHeight = (ulong)(mipHeight + blockHeight - 1) / (ulong)blockHeight * (ulong)blockHeight;
|
||||
mipSizes[i] = alignedWidth * alignedHeight * (ulong)bytesPerElement;
|
||||
}
|
||||
|
||||
if (firstMipInTail == 0)
|
||||
{
|
||||
var m = maxMipsInTail - 1;
|
||||
var mipOffset = m > 6 ? 16 << m : m << 8;
|
||||
var mipX = ((mipOffset >> 9) & 1) |
|
||||
((mipOffset >> 10) & 2) |
|
||||
((mipOffset >> 11) & 4) |
|
||||
((mipOffset >> 12) & 8) |
|
||||
((mipOffset >> 13) & 16) |
|
||||
((mipOffset >> 14) & 32);
|
||||
var mipY = ((mipOffset >> 8) & 1) |
|
||||
((mipOffset >> 9) & 2) |
|
||||
((mipOffset >> 10) & 4) |
|
||||
((mipOffset >> 11) & 8) |
|
||||
((mipOffset >> 12) & 16) |
|
||||
((mipOffset >> 13) & 32);
|
||||
if ((blockSizeLog2 & 1) != 0)
|
||||
{
|
||||
(mipX, mipY) = (mipY, mipX);
|
||||
if ((bppLog2 & 1) != 0)
|
||||
{
|
||||
mipY = (mipY << 1) | (mipX & 1);
|
||||
mipX >>= 1;
|
||||
}
|
||||
}
|
||||
|
||||
var (microWidth, microHeight) = SquareBlockDimensions(256 >> bppLog2);
|
||||
if (microWidth == 0 || microHeight == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
tailElementX = mipX * microWidth;
|
||||
tailElementY = mipY * microHeight;
|
||||
if (tailElementX + elementsWide > blockWidth ||
|
||||
tailElementY + elementsHigh > blockHeight)
|
||||
{
|
||||
tailElementX = 0;
|
||||
tailElementY = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
inMipTail = true;
|
||||
chainSliceBytes = (ulong)blockBytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
byteOffset = firstMipInTail < mipLevels ? (ulong)blockBytes : 0;
|
||||
chainSliceBytes = byteOffset;
|
||||
for (var i = firstMipInTail - 1; i >= 1; i--)
|
||||
{
|
||||
byteOffset += mipSizes[i];
|
||||
}
|
||||
|
||||
for (var i = 0; i < firstMipInTail; i++)
|
||||
{
|
||||
chainSliceBytes += mipSizes[i];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deswizzles <paramref name="tiled"/> into linear row-major order.
|
||||
/// Elements are pixels for uncompressed formats and 4x4 blocks for
|
||||
@@ -246,50 +418,83 @@ internal static class GnmTiling
|
||||
return false;
|
||||
}
|
||||
|
||||
// Precompute the within-block element offset for each (x, y) inside a
|
||||
// single block. The swizzle equation only depends on the in-block
|
||||
// coordinates, so this table is reused for every block — turning the
|
||||
// per-pixel bit-interleave (a loop + calls) into a single array lookup.
|
||||
// Detiling a 2048x2048 texture is millions of elements; without this the
|
||||
// per-pixel math makes DETILE unusably slow during asset streaming.
|
||||
// Address tables depend only on the swizzle equation and element size,
|
||||
// so retain them across textures instead of rebuilding them per upload.
|
||||
var hasExactXorPattern = TryGetExactXorPattern(swizzleMode, bppLog2, out var xorPattern);
|
||||
var blockTable = hasExactXorPattern ? [] : new int[blockWidth * blockHeight];
|
||||
for (var by = 0; !hasExactXorPattern && by < blockHeight; by++)
|
||||
{
|
||||
for (var bx = 0; bx < blockWidth; bx++)
|
||||
{
|
||||
blockTable[by * blockWidth + bx] = (int)(kind == SwizzleKind.ZOrder
|
||||
? MortonInterleave((uint)bx, (uint)by, blockWidth, blockHeight)
|
||||
: StandardSwizzleOffset((uint)bx, (uint)by, blockWidth, blockHeight));
|
||||
}
|
||||
}
|
||||
var patternTerms = hasExactXorPattern
|
||||
? _patternTermCache.GetOrAdd(
|
||||
(swizzleMode, bppLog2),
|
||||
_ => CreatePatternTerms(xorPattern))
|
||||
: default;
|
||||
var blockTable = hasExactXorPattern
|
||||
? []
|
||||
: _blockTableCache.GetOrAdd(
|
||||
(kind, blockWidth, blockHeight),
|
||||
static key => CreateBlockTable(key.Kind, key.Width, key.Height));
|
||||
|
||||
for (var y = 0; y < elementsHigh; y++)
|
||||
// The XOR equation offset factors cleanly into independent X and Y
|
||||
// fields — each output bit is parity(x & XMask) XOR parity(y & YMask),
|
||||
// and parity distributes over XOR, so offset(x, y) == xTerm(x) ^ yTerm(y).
|
||||
// Exact equations repeat at a small power-of-two period. Cached axis
|
||||
// terms reduce the inner loop to two array loads and one XOR.
|
||||
fixed (byte* tiledPointer = tiled)
|
||||
fixed (byte* linearPointer = linear)
|
||||
{
|
||||
var blockY = y / blockHeight;
|
||||
var inBlockY = y % blockHeight;
|
||||
var rowBlockBase = (long)blockY * blocksPerRow;
|
||||
var tableRowBase = inBlockY * blockWidth;
|
||||
var destRowBase = (long)y * elementsWide * bytesPerElement;
|
||||
for (var x = 0; x < elementsWide; x++)
|
||||
var sourceAddress = (nint)tiledPointer;
|
||||
var destinationAddress = (nint)linearPointer;
|
||||
var sourceLength = tiled.Length;
|
||||
var destinationLength = linear.Length;
|
||||
var blockWidthShift = BitLog2((uint)blockWidth);
|
||||
var blockWidthMask = blockWidth - 1;
|
||||
var detileRow = (int y) =>
|
||||
{
|
||||
var blockX = x / blockWidth;
|
||||
var inBlockX = x % blockWidth;
|
||||
|
||||
var blockIndex = rowBlockBase + blockX;
|
||||
var sourceByte = hasExactXorPattern
|
||||
? blockIndex * blockBytes + ComputePatternOffset((uint)x, (uint)y, xorPattern)
|
||||
: (blockIndex * blockElements + blockTable[tableRowBase + inBlockX]) *
|
||||
(long)bytesPerElement;
|
||||
var destByte = destRowBase + (long)x * bytesPerElement;
|
||||
if (sourceByte + bytesPerElement > tiled.Length ||
|
||||
destByte + bytesPerElement > linear.Length)
|
||||
var blockY = y / blockHeight;
|
||||
var inBlockY = y & (blockHeight - 1);
|
||||
var rowBlockBase = (long)blockY * blocksPerRow;
|
||||
var tableRowBase = inBlockY * blockWidth;
|
||||
var destRowBase = (long)y * elementsWide * bytesPerElement;
|
||||
var yTerm = hasExactXorPattern
|
||||
? patternTerms.Y[y & patternTerms.YMask]
|
||||
: 0;
|
||||
for (var x = 0; x < elementsWide; x++)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var blockX = x >> blockWidthShift;
|
||||
var inBlockX = x & blockWidthMask;
|
||||
var blockIndex = rowBlockBase + blockX;
|
||||
var sourceByte = hasExactXorPattern
|
||||
? blockIndex * blockBytes + (patternTerms.X[x & patternTerms.XMask] ^ yTerm)
|
||||
: (blockIndex * blockElements + blockTable[tableRowBase + inBlockX]) *
|
||||
(long)bytesPerElement;
|
||||
var destByte = destRowBase + (long)x * bytesPerElement;
|
||||
if (sourceByte < 0 ||
|
||||
sourceByte + bytesPerElement > sourceLength ||
|
||||
destByte + bytesPerElement > destinationLength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
tiled.Slice((int)sourceByte, bytesPerElement)
|
||||
.CopyTo(linear.Slice((int)destByte, bytesPerElement));
|
||||
CopyElement(
|
||||
(byte*)sourceAddress + sourceByte,
|
||||
(byte*)destinationAddress + destByte,
|
||||
bytesPerElement);
|
||||
}
|
||||
};
|
||||
|
||||
var elementCount = (long)elementsWide * elementsHigh;
|
||||
if (elementCount >= ParallelDetileElementThreshold && Environment.ProcessorCount > 1)
|
||||
{
|
||||
Parallel.For(
|
||||
0,
|
||||
elementsHigh,
|
||||
_parallelDetileOptions,
|
||||
detileRow);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var y = 0; y < elementsHigh; y++)
|
||||
{
|
||||
detileRow(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +507,80 @@ internal static class GnmTiling
|
||||
ZOrder,
|
||||
}
|
||||
|
||||
private readonly record struct PatternTerms(int[] X, int XMask, int[] Y, int YMask);
|
||||
|
||||
private static PatternTerms CreatePatternTerms(AddressBit[] pattern)
|
||||
{
|
||||
uint xMask = 0;
|
||||
uint yMask = 0;
|
||||
foreach (var bit in pattern)
|
||||
{
|
||||
xMask |= bit.XMask;
|
||||
yMask |= bit.YMask;
|
||||
}
|
||||
|
||||
var xLength = AxisTermPeriod(xMask);
|
||||
var yLength = AxisTermPeriod(yMask);
|
||||
var xTerms = new int[xLength];
|
||||
var yTerms = new int[yLength];
|
||||
for (var x = 0; x < xTerms.Length; x++)
|
||||
{
|
||||
xTerms[x] = (int)PatternAxisTerm((uint)x, pattern, useX: true);
|
||||
}
|
||||
|
||||
for (var y = 0; y < yTerms.Length; y++)
|
||||
{
|
||||
yTerms[y] = (int)PatternAxisTerm((uint)y, pattern, useX: false);
|
||||
}
|
||||
|
||||
return new PatternTerms(xTerms, xLength - 1, yTerms, yLength - 1);
|
||||
}
|
||||
|
||||
private static int AxisTermPeriod(uint mask) =>
|
||||
mask == 0 ? 1 : 1 << (32 - System.Numerics.BitOperations.LeadingZeroCount(mask));
|
||||
|
||||
private static int[] CreateBlockTable(SwizzleKind kind, int blockWidth, int blockHeight)
|
||||
{
|
||||
var table = new int[blockWidth * blockHeight];
|
||||
for (var y = 0; y < blockHeight; y++)
|
||||
{
|
||||
for (var x = 0; x < blockWidth; x++)
|
||||
{
|
||||
table[y * blockWidth + x] = (int)(kind == SwizzleKind.ZOrder
|
||||
? MortonInterleave((uint)x, (uint)y, blockWidth, blockHeight)
|
||||
: StandardSwizzleOffset((uint)x, (uint)y, blockWidth, blockHeight));
|
||||
}
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void CopyElement(byte* source, byte* destination, int bytesPerElement)
|
||||
{
|
||||
switch (bytesPerElement)
|
||||
{
|
||||
case 1:
|
||||
*destination = *source;
|
||||
break;
|
||||
case 2:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<ushort>(source));
|
||||
break;
|
||||
case 4:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<uint>(source));
|
||||
break;
|
||||
case 8:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<ulong>(source));
|
||||
break;
|
||||
case 16:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<UInt128>(source));
|
||||
break;
|
||||
default:
|
||||
Unsafe.CopyBlockUnaligned(destination, source, (uint)bytesPerElement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly AddressBit Zero = new(0, 0);
|
||||
|
||||
private static AddressBit X(int bit) => new(1u << bit, 0);
|
||||
@@ -335,14 +614,20 @@ internal static class GnmTiling
|
||||
return pattern.Length != 0;
|
||||
}
|
||||
|
||||
private static long ComputePatternOffset(uint x, uint y, AddressBit[] pattern)
|
||||
// The AddrLib within-block byte offset is a per-bit XOR equation:
|
||||
// offset = OR over bits of ( parity(x & XMask) XOR parity(y & YMask) ) << bit
|
||||
// Because parity distributes over XOR, that whole offset factors into two
|
||||
// independent axis terms: PatternAxisTerm(x, useX: true) ^
|
||||
// PatternAxisTerm(y, useX: false). Splitting the axes lets TryDetile cache
|
||||
// the X term per column and hoist the Y term per row instead of recomputing
|
||||
// the full 16-bit interleave (32 PopCounts) for every element.
|
||||
private static uint PatternAxisTerm(uint coordinate, AddressBit[] pattern, bool useX)
|
||||
{
|
||||
uint offset = 0;
|
||||
for (var bit = 0; bit < pattern.Length; bit++)
|
||||
{
|
||||
var equation = pattern[bit];
|
||||
var parity = (System.Numerics.BitOperations.PopCount(x & equation.XMask) +
|
||||
System.Numerics.BitOperations.PopCount(y & equation.YMask)) & 1;
|
||||
var mask = useX ? pattern[bit].XMask : pattern[bit].YMask;
|
||||
var parity = System.Numerics.BitOperations.PopCount(coordinate & mask) & 1;
|
||||
offset |= (uint)parity << bit;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,10 +37,26 @@ internal static class GpuWaitRegistry
|
||||
public long RegisteredTicks;
|
||||
public bool StaleReported;
|
||||
public object? State;
|
||||
// Latched by LatchSatisfiedByValue when a producer wrote a value that
|
||||
// satisfies this waiter. The label is frequently reused (reset to 0 for
|
||||
// the next frame) immediately after the producing write, so re-reading
|
||||
// guest memory at wake time can miss the transient satisfied window.
|
||||
// Latching records satisfaction at the moment of the write instead.
|
||||
public bool Latched;
|
||||
// Non-zero for indirect-dispatch dimension retries: a bounded deadline
|
||||
// (Stopwatch ticks) after which the waiter is resumed even if unsatisfied,
|
||||
// so a legitimately empty indirect dispatch can never stall forever.
|
||||
public long RetryDeadlineTicks;
|
||||
}
|
||||
|
||||
private static readonly object _gate = new();
|
||||
private static readonly Dictionary<ulong, List<WaitingDcb>> _waiters = new();
|
||||
// The last value each label producer wrote. Used only by the deadlock
|
||||
// breaker: our serial submission parser cannot model two GPU queues running
|
||||
// concurrently, so a label written -> reset -> re-waited across queues can
|
||||
// cycle forever even though a real producer did signal it. Keyed by (memory,
|
||||
// address) so distinct guest processes never alias.
|
||||
private static readonly Dictionary<(object, ulong), ulong> _lastProduced = new();
|
||||
|
||||
public static int Count
|
||||
{
|
||||
@@ -114,8 +130,14 @@ internal static class GpuWaitRegistry
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = readValue(address, list[i].Is64Bit);
|
||||
if (value is null || !Compare(list[i], value.Value))
|
||||
var satisfied = list[i].Latched;
|
||||
if (!satisfied)
|
||||
{
|
||||
var value = readValue(address, list[i].Is64Bit);
|
||||
satisfied = value is not null && Compare(list[i], value.Value);
|
||||
}
|
||||
|
||||
if (!satisfied)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -236,6 +258,201 @@ internal static class GpuWaitRegistry
|
||||
return matches;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records satisfaction for every waiter at <paramref name="address"/> whose
|
||||
/// condition is met by <paramref name="value"/> — the value a producer just
|
||||
/// wrote to that label. Called from the ordered producer side effect so a
|
||||
/// same-frame label reset cannot lose the wakeup. The waiters stay registered
|
||||
/// (latched) and are drained by the next CollectSatisfied. Returns true when
|
||||
/// at least one waiter latched, so the caller can trigger a wake pass.
|
||||
/// </summary>
|
||||
public static bool LatchSatisfiedByValue(object memory, ulong address, ulong value)
|
||||
{
|
||||
var latchedAny = false;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_waiters.TryGetValue(address, out var list))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < list.Count; i++)
|
||||
{
|
||||
var waiter = list[i];
|
||||
if (waiter.Latched ||
|
||||
!ReferenceEquals(waiter.Memory, memory) ||
|
||||
!Compare(waiter, value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
waiter.Latched = true;
|
||||
list[i] = waiter;
|
||||
latchedAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
return latchedAny;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes and returns waiters carrying a <see cref="WaitingDcb.RetryDeadlineTicks"/>
|
||||
/// that has elapsed. Used for indirect-dispatch dimension retries: the caller
|
||||
/// resumes them so a genuinely empty dispatch (dims that never become non-zero)
|
||||
/// is dropped after a bounded wait instead of stalling the queue forever.
|
||||
/// </summary>
|
||||
public static List<WaitingDcb>? CollectExpiredRetries(object memory, long nowTicks)
|
||||
{
|
||||
List<WaitingDcb>? expired = null;
|
||||
lock (_gate)
|
||||
{
|
||||
List<ulong>? emptied = null;
|
||||
foreach (var (address, list) in _waiters)
|
||||
{
|
||||
for (var i = list.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var waiter = list[i];
|
||||
if (waiter.RetryDeadlineTicks == 0 ||
|
||||
!ReferenceEquals(waiter.Memory, memory) ||
|
||||
nowTicks < waiter.RetryDeadlineTicks)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
expired ??= new List<WaitingDcb>();
|
||||
expired.Add(waiter);
|
||||
list.RemoveAt(i);
|
||||
}
|
||||
|
||||
if (list.Count == 0)
|
||||
{
|
||||
emptied ??= new List<ulong>();
|
||||
emptied.Add(address);
|
||||
}
|
||||
}
|
||||
|
||||
if (emptied is not null)
|
||||
{
|
||||
foreach (var address in emptied)
|
||||
{
|
||||
_waiters.Remove(address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return expired;
|
||||
}
|
||||
|
||||
public static List<WaitingDcb>? CollectAllForMemory(object memory)
|
||||
{
|
||||
List<WaitingDcb>? collected = null;
|
||||
lock (_gate)
|
||||
{
|
||||
List<ulong>? emptied = null;
|
||||
foreach (var (address, list) in _waiters)
|
||||
{
|
||||
for (var index = list.Count - 1; index >= 0; index--)
|
||||
{
|
||||
if (!ReferenceEquals(list[index].Memory, memory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
collected ??= new List<WaitingDcb>();
|
||||
collected.Add(list[index]);
|
||||
list.RemoveAt(index);
|
||||
}
|
||||
|
||||
if (list.Count == 0)
|
||||
{
|
||||
emptied ??= new List<ulong>();
|
||||
emptied.Add(address);
|
||||
}
|
||||
}
|
||||
|
||||
if (emptied is not null)
|
||||
{
|
||||
foreach (var address in emptied)
|
||||
{
|
||||
_waiters.Remove(address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
/// <summary>Records the value a label producer wrote, for the deadlock
|
||||
/// breaker. Also latches any already-waiting waiter it satisfies.</summary>
|
||||
public static bool RecordProduced(object memory, ulong address, ulong value)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_lastProduced.Count >= 8192)
|
||||
{
|
||||
_lastProduced.Clear();
|
||||
}
|
||||
|
||||
_lastProduced[(memory, address)] = value;
|
||||
}
|
||||
|
||||
return LatchSatisfiedByValue(memory, address, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Breaks cross-queue GPU deadlocks the serial parser cannot avoid: returns
|
||||
/// (and removes) waiters that have been stuck longer than
|
||||
/// <paramref name="minAgeTicks"/> and whose condition is satisfied by the
|
||||
/// last value a real producer wrote to their label — even though guest
|
||||
/// memory has since been reset. Never fabricates a value: a waiter is only
|
||||
/// released when an actual producer signalled it at least once.
|
||||
/// </summary>
|
||||
public static List<WaitingDcb>? CollectDeadlockBroken(
|
||||
object memory,
|
||||
long nowTicks,
|
||||
long minAgeTicks)
|
||||
{
|
||||
List<WaitingDcb>? broken = null;
|
||||
lock (_gate)
|
||||
{
|
||||
List<ulong>? emptied = null;
|
||||
foreach (var (address, list) in _waiters)
|
||||
{
|
||||
for (var i = list.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var waiter = list[i];
|
||||
if (!ReferenceEquals(waiter.Memory, memory) ||
|
||||
nowTicks - waiter.RegisteredTicks < minAgeTicks ||
|
||||
!_lastProduced.TryGetValue((memory, address), out var produced) ||
|
||||
!Compare(waiter, produced))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
broken ??= new List<WaitingDcb>();
|
||||
broken.Add(waiter);
|
||||
list.RemoveAt(i);
|
||||
}
|
||||
|
||||
if (list.Count == 0)
|
||||
{
|
||||
emptied ??= new List<ulong>();
|
||||
emptied.Add(address);
|
||||
}
|
||||
}
|
||||
|
||||
if (emptied is not null)
|
||||
{
|
||||
foreach (var address in emptied)
|
||||
{
|
||||
_waiters.Remove(address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return broken;
|
||||
}
|
||||
|
||||
public static bool Compare(in WaitingDcb waiter, ulong value)
|
||||
{
|
||||
var masked = value & waiter.Mask;
|
||||
@@ -260,6 +477,7 @@ internal static class GpuWaitRegistry
|
||||
lock (_gate)
|
||||
{
|
||||
_waiters.Clear();
|
||||
_lastProduced.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using SharpEmu.Libs.Kernel;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace SharpEmu.Libs.Ampr;
|
||||
|
||||
@@ -43,17 +44,17 @@ public static class AmprExports
|
||||
{
|
||||
public CachedHostFile(string path)
|
||||
{
|
||||
Stream = new FileStream(
|
||||
Handle = File.OpenHandle(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite | FileShare.Delete,
|
||||
bufferSize: 1024 * 1024,
|
||||
FileOptions.RandomAccess);
|
||||
Length = RandomAccess.GetLength(Handle);
|
||||
}
|
||||
|
||||
public object Gate { get; } = new();
|
||||
public FileStream Stream { get; }
|
||||
public SafeFileHandle Handle { get; }
|
||||
public long Length { get; }
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
@@ -339,6 +340,18 @@ public static class AmprExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "4fgtGfXDrFc",
|
||||
ExportName = "sceAmprMeasureCommandSizeWriteAddress_04_00",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAmpr")]
|
||||
public static int MeasureCommandSizeWriteAddress0400(CpuContext ctx)
|
||||
{
|
||||
TraceAmpr(ctx, "measure_write_address", 0, WriteAddressRecordSize, 0);
|
||||
ctx[CpuRegister.Rax] = WriteAddressRecordSize;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "tZDDEo2tE5k",
|
||||
ExportName = "sceAmprCommandBufferGetSize",
|
||||
@@ -508,6 +521,32 @@ public static class AmprExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "j0+3uJMxYJY",
|
||||
ExportName = "sceAmprCommandBufferWriteAddress_04_00",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAmpr")]
|
||||
public static int CommandBufferWriteAddress0400(CpuContext ctx)
|
||||
{
|
||||
var commandBuffer = ctx[CpuRegister.Rdi];
|
||||
var address = ctx[CpuRegister.Rsi];
|
||||
var value = ctx[CpuRegister.Rdx];
|
||||
|
||||
if (commandBuffer == 0 || address == 0)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (!AppendWriteAddressRecord(ctx, commandBuffer, address, value))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
TraceAmpr(ctx, "write_address", commandBuffer, address, value);
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
public static int CompleteCommandBuffer(CpuContext ctx, ulong commandBuffer)
|
||||
{
|
||||
if (commandBuffer == 0)
|
||||
@@ -735,13 +774,7 @@ public static class AmprExports
|
||||
return openResult;
|
||||
}
|
||||
|
||||
long fileLength;
|
||||
lock (cachedFile.Gate)
|
||||
{
|
||||
fileLength = cachedFile.Stream.Length;
|
||||
}
|
||||
|
||||
if (fileOffset >= (ulong)fileLength)
|
||||
if (fileOffset >= (ulong)cachedFile.Length)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
@@ -760,12 +793,10 @@ public static class AmprExports
|
||||
}
|
||||
|
||||
var request = (int)Math.Min((ulong)buffer.Length, size - bytesRead);
|
||||
int read;
|
||||
lock (cachedFile.Gate)
|
||||
{
|
||||
cachedFile.Stream.Position = unchecked((long)absoluteOffset);
|
||||
read = cachedFile.Stream.Read(buffer, 0, request);
|
||||
}
|
||||
var read = RandomAccess.Read(
|
||||
cachedFile.Handle,
|
||||
buffer.AsSpan(0, request),
|
||||
unchecked((long)absoluteOffset));
|
||||
|
||||
if (read <= 0)
|
||||
{
|
||||
|
||||
@@ -121,6 +121,33 @@ public static class AppContentExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
// Download data is not emulated as a real quota; report a comfortable
|
||||
// fixed amount of free space so titles never take the "storage full" path.
|
||||
[SysAbiExport(
|
||||
Nid = "Gl6w5i0JokY",
|
||||
ExportName = "sceAppContentDownloadDataGetAvailableSpaceKb",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAppContent")]
|
||||
public static int AppContentDownloadDataGetAvailableSpaceKb(CpuContext ctx)
|
||||
{
|
||||
const ulong availableSpaceKb = 1024UL * 1024UL; // 1 GiB
|
||||
var availableSpaceAddress = ctx[CpuRegister.Rsi];
|
||||
if (availableSpaceAddress == 0)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
Span<byte> spaceBytes = stackalloc byte[sizeof(ulong)];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(spaceBytes, availableSpaceKb);
|
||||
if (!ctx.Memory.TryWrite(availableSpaceAddress, spaceBytes))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
private static bool TryReadUserDefinedParam(uint paramId, out int value)
|
||||
{
|
||||
value = 0;
|
||||
|
||||
@@ -17,7 +17,7 @@ public static class AjmExports
|
||||
private const int OrbisAjmErrorCodecAlreadyRegistered = unchecked((int)0x80930009);
|
||||
private const int OrbisAjmErrorCodecNotRegistered = unchecked((int)0x8093000A);
|
||||
private const int OrbisAjmErrorWrongRevisionFlag = unchecked((int)0x8093000B);
|
||||
private const uint MaxCodecType = 23;
|
||||
private const uint MaxCodecType = 25;
|
||||
private const int MaxInstanceIndex = 0x2FFF;
|
||||
private static readonly ConcurrentDictionary<uint, AjmContextState> Contexts = new();
|
||||
private static int _nextContextId;
|
||||
|
||||
@@ -14,6 +14,12 @@ public static class AudioOutExports
|
||||
private static readonly ConcurrentDictionary<int, PortState> Ports = new();
|
||||
private static int _nextPortHandle;
|
||||
|
||||
// Diagnostic: confirm sceAudioOutOutput is actually called and whether the
|
||||
// guest submits real samples or silence. Gated so it costs nothing when off.
|
||||
private static readonly bool _traceOutput = string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_LOG_AUDIO_OUT"), "1", StringComparison.Ordinal);
|
||||
private static long _outputCount;
|
||||
|
||||
private sealed class PortState : IDisposable
|
||||
{
|
||||
private readonly object _paceGate = new();
|
||||
@@ -155,6 +161,37 @@ public static class AudioOutExports
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "GrQ9s4IrNaQ",
|
||||
ExportName = "sceAudioOutGetPortState",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAudioOut")]
|
||||
public static int AudioOutGetPortState(CpuContext ctx)
|
||||
{
|
||||
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var stateAddress = ctx[CpuRegister.Rsi];
|
||||
if (stateAddress == 0 || !Ports.TryGetValue(handle, out var port))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
// SceAudioOutPortState: report a connected primary output at full volume
|
||||
// so pacing/mixing code sees a live port. We do no host rerouting, so
|
||||
// rerouteCounter and flag stay zero.
|
||||
Span<byte> state = stackalloc byte[16];
|
||||
state.Clear();
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(state, 1);
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(
|
||||
state[2..], (ushort)port.Channels);
|
||||
state[7] = 127;
|
||||
if (!ctx.Memory.TryWrite(stateAddress, state))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "QOQtbeDqsT4",
|
||||
ExportName = "sceAudioOutOutput",
|
||||
@@ -166,7 +203,12 @@ public static class AudioOutExports
|
||||
var sourceAddress = ctx[CpuRegister.Rsi];
|
||||
if (!Ports.TryGetValue(handle, out var port))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
// Host shutdown disposes the ports while guest audio threads are
|
||||
// still draining their last buffers; report success so the guest
|
||||
// winds down without a per-buffer error (and its WARN log flood).
|
||||
return ctx.SetReturn(_shutdown
|
||||
? 0
|
||||
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
if (sourceAddress == 0)
|
||||
@@ -183,6 +225,17 @@ public static class AudioOutExports
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
if (_traceOutput)
|
||||
{
|
||||
var n = Interlocked.Increment(ref _outputCount);
|
||||
if (n <= 8 || n % 200 == 0)
|
||||
{
|
||||
var peak = PeakAmplitude(source, port.IsFloat, port.BytesPerSample);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] audioout.output#{n} handle={handle} bytes={source.Length} ch={port.Channels} float={port.IsFloat} vol={port.Volume:F2} peak={peak:F4} backend={(port.Backend is null ? "none" : "coreaudio")}");
|
||||
}
|
||||
}
|
||||
|
||||
if (port.Backend is null)
|
||||
{
|
||||
port.PaceSilence();
|
||||
@@ -266,8 +319,40 @@ public static class AudioOutExports
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
// Peak normalized amplitude [0,1] of an interleaved PCM buffer, used only by
|
||||
// the SHARPEMU_LOG_AUDIO_OUT diagnostic to distinguish real audio from silence.
|
||||
private static float PeakAmplitude(ReadOnlySpan<byte> source, bool isFloat, int bytesPerSample)
|
||||
{
|
||||
var peak = 0f;
|
||||
if (isFloat && bytesPerSample == 4)
|
||||
{
|
||||
for (var i = 0; i + 4 <= source.Length; i += 4)
|
||||
{
|
||||
var v = Math.Abs(System.Buffers.Binary.BinaryPrimitives.ReadSingleLittleEndian(source.Slice(i, 4)));
|
||||
if (v > peak)
|
||||
{
|
||||
peak = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (bytesPerSample == 2)
|
||||
{
|
||||
for (var i = 0; i + 2 <= source.Length; i += 2)
|
||||
{
|
||||
var v = Math.Abs(System.Buffers.Binary.BinaryPrimitives.ReadInt16LittleEndian(source.Slice(i, 2)) / 32768f);
|
||||
if (v > peak)
|
||||
{
|
||||
peak = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return peak;
|
||||
}
|
||||
|
||||
public static void ShutdownAllPorts()
|
||||
{
|
||||
Volatile.Write(ref _shutdown, true);
|
||||
foreach (var handle in Ports.Keys)
|
||||
{
|
||||
if (Ports.TryRemove(handle, out var port))
|
||||
@@ -277,6 +362,8 @@ public static class AudioOutExports
|
||||
}
|
||||
}
|
||||
|
||||
private static bool _shutdown;
|
||||
|
||||
private static bool TryGetFormat(
|
||||
int rawFormat,
|
||||
out int channels,
|
||||
|
||||
@@ -25,6 +25,10 @@ internal static class AudioPcmConversion
|
||||
float volume)
|
||||
{
|
||||
var sourceFrameSize = checked(channels * bytesPerSample);
|
||||
// Volume is constant for the whole submission, so clamp it once here
|
||||
// rather than per sample inside the loop (this runs on every real-time
|
||||
// audio buffer, hundreds of frames at a time).
|
||||
var clampedVolume = Math.Clamp(volume, 0.0f, 1.0f);
|
||||
for (var frame = 0; frame < frames; frame++)
|
||||
{
|
||||
var sourceFrame = source.Slice(frame * sourceFrameSize, sourceFrameSize);
|
||||
@@ -32,8 +36,8 @@ internal static class AudioPcmConversion
|
||||
var right = channels == 1
|
||||
? left
|
||||
: ReadSample(sourceFrame, 1, bytesPerSample, isFloat);
|
||||
left = ApplyVolume(left, volume);
|
||||
right = ApplyVolume(right, volume);
|
||||
left = ApplyVolume(left, clampedVolume);
|
||||
right = ApplyVolume(right, clampedVolume);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(destination[(frame * OutputFrameSize)..], left);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(destination[((frame * OutputFrameSize) + 2)..], right);
|
||||
}
|
||||
@@ -67,9 +71,10 @@ internal static class AudioPcmConversion
|
||||
return checked((short)MathF.Round(value * scale));
|
||||
}
|
||||
|
||||
// <paramref name="volume"/> is expected pre-clamped to [0, 1] by the caller.
|
||||
private static short ApplyVolume(short sample, float volume)
|
||||
{
|
||||
var scaled = MathF.Round(sample * Math.Clamp(volume, 0.0f, 1.0f));
|
||||
var scaled = MathF.Round(sample * volume);
|
||||
return (short)Math.Clamp(scaled, short.MinValue, short.MaxValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Libs.Audio;
|
||||
|
||||
// PS5 acoustic-propagation (3D-audio ray/portal/room) module. We do not model
|
||||
// acoustic propagation; the geometry-driven reverb/occlusion it produces is a
|
||||
// quality feature, not a correctness gate. Games (e.g. Astro Bot) call it
|
||||
// during audio init and hard-assert if any entry point is missing:
|
||||
// ASSERT ... sceAudioPropagationSystemQueryMemory failed : 0x80020002
|
||||
// The API is placement-style: QueryMemory reports a buffer size, the game
|
||||
// allocates it, and the "system"/objects live inside that caller-owned buffer,
|
||||
// so success-returning stubs let init proceed without us owning any state.
|
||||
public static class AudioPropagationExports
|
||||
{
|
||||
private const int Ok = 0;
|
||||
|
||||
// QueryMemory reports the working-set size the caller must allocate before
|
||||
// SystemCreate. rsi points at the out size/alignment; write a modest,
|
||||
// aligned block so the caller's allocation succeeds.
|
||||
[SysAbiExport(
|
||||
Nid = "7xyAxrusLko",
|
||||
ExportName = "sceAudioPropagationSystemQueryMemory",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAudioPropagation")]
|
||||
public static int SystemQueryMemory(CpuContext ctx)
|
||||
{
|
||||
var outAddress = ctx[CpuRegister.Rsi];
|
||||
if (outAddress != 0)
|
||||
{
|
||||
// {size, alignment} — 1 MiB / 256 B covers the caller's allocation.
|
||||
ctx.TryWriteUInt64(outAddress, 0x10_0000);
|
||||
ctx.TryWriteUInt64(outAddress + sizeof(ulong), 0x100);
|
||||
}
|
||||
|
||||
return ctx.SetReturn(Ok);
|
||||
}
|
||||
|
||||
[SysAbiExport(Nid = "GrA9ke1QT+E", ExportName = "sceAudioPropagationSystemQueryInfo", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SystemQueryInfo(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "aNEqtSHdUSo", ExportName = "sceAudioPropagationSystemCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SystemCreate(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "x5VPqg5iyAk", ExportName = "sceAudioPropagationSystemDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SystemDestroy(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "ile38Gl-p5M", ExportName = "sceAudioPropagationSystem", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int System(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "cMl3u+7QBBM", ExportName = "sceAudioPropagationSystemMemoryInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SystemMemoryInit(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "3B9IabLByyM", ExportName = "sceAudioPropagationSystemOptionInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SystemOptionInit(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "B2KI2AachWE", ExportName = "sceAudioPropagationSystemLock", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SystemLock(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "kIdb+iQUzCs", ExportName = "sceAudioPropagationSystemSetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SystemSetAttributes(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "VlBT16890mA", ExportName = "sceAudioPropagationSystemSetRays", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SystemSetRays(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "ht-QXT3zGxo", ExportName = "sceAudioPropagationSystemGetRays", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SystemGetRays(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "CPLV6G-eXmk", ExportName = "sceAudioPropagationSystemRegisterMaterial", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SystemRegisterMaterial(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "XKCN4gpeYsM", ExportName = "sceAudioPropagationSystemUnregisterMaterial", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SystemUnregisterMaterial(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "8bI5h8req30", ExportName = "sceAudioPropagationRoomCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int RoomCreate(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "S0JwP2AFTTE", ExportName = "sceAudioPropagationRoomDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int RoomDestroy(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "b-dYXrjSNZU", ExportName = "sceAudioPropagationPortalCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int PortalCreate(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "ZQXE-xS6MTE", ExportName = "sceAudioPropagationPortalDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int PortalDestroy(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "WXMhENV2NcA", ExportName = "sceAudioPropagationPortalSetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int PortalSetAttributes(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "i687TNRF+hw", ExportName = "sceAudioPropagationPortalSettingsInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int PortalSettingsInit(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "d84otraxt2s", ExportName = "sceAudioPropagationSourceCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SourceCreate(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "wkseM3LWPuc", ExportName = "sceAudioPropagationSourceDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SourceDestroy(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "-wsUTr31yeg", ExportName = "sceAudioPropagationSourceSetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SourceSetAttributes(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "PBcrVpEqUVY", ExportName = "sceAudioPropagationSourceCalculateAudioPaths", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SourceCalculateAudioPaths(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "eEeKqFeNI3o", ExportName = "sceAudioPropagationSourceGetAudioPath", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SourceGetAudioPath(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "G+QLTfyLMYk", ExportName = "sceAudioPropagationSourceGetAudioPathCount", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SourceGetAudioPathCount(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "aKJZx7wCma8", ExportName = "sceAudioPropagationSourceGetRays", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SourceGetRays(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "3aEY9tPXGKc", ExportName = "sceAudioPropagationSourceQueryInfo", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SourceQueryInfo(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "hhz9pITnC8k", ExportName = "sceAudioPropagationSourceRender", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SourceRender(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "SoKPzY1-3SU", ExportName = "sceAudioPropagationSourceRenderInfoInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SourceRenderInfoInit(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "tKSmk2JsMAA", ExportName = "sceAudioPropagationSourceSetAudioPath", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SourceSetAudioPath(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "5vzOS2pHMFc", ExportName = "sceAudioPropagationSourceSetAudioPaths", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SourceSetAudioPaths(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "MNmGapXrYRs", ExportName = "sceAudioPropagationSourceSetAudioPathsParamInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int SourceSetAudioPathsParamInit(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "i-0aUex3zCE", ExportName = "sceAudioPropagationAudioPathInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int AudioPathInit(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "JZIkSbmt2BE", ExportName = "sceAudioPropagationAudioPathPointInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int AudioPathPointInit(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "tL2AEPejVQE", ExportName = "sceAudioPropagationPathGetNumPoints", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int PathGetNumPoints(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "2BSFmuKtRss", ExportName = "sceAudioPropagationMaterialInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int MaterialInit(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "0r2+9UTg1BA", ExportName = "sceAudioPropagationRayInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int RayInit(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "BbOT4vBwAjs", ExportName = "sceAudioPropagationResetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int ResetAttributes(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
|
||||
[SysAbiExport(Nid = "gCmQm6dvMxw", ExportName = "sceAudioPropagationReportApi", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
|
||||
public static int ReportApi(CpuContext ctx) => ctx.SetReturn(Ok);
|
||||
}
|
||||
@@ -17,7 +17,9 @@ public static class AvPlayerExports
|
||||
private const int FrameBufferCount = 3;
|
||||
private const int FrameInfoSize = 40;
|
||||
private const int FrameInfoExSize = 104;
|
||||
private const int StreamInfoSize = 40;
|
||||
// This structure is 32 bytes. A larger write can damage the guest stack.
|
||||
private const int StreamInfoSize = 32;
|
||||
private const int StreamInfoExSize = 32;
|
||||
private const int MaxGuestPathLength = 4096;
|
||||
private static readonly object StateGate = new();
|
||||
private static readonly Dictionary<ulong, PlayerState> Players = new();
|
||||
@@ -404,7 +406,8 @@ public static class AvPlayerExports
|
||||
ExportName = "sceAvPlayerGetStreamInfoEx",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAvPlayer")]
|
||||
public static int AvPlayerSetDecoderMode(CpuContext ctx) => ValidatePlayer(ctx);
|
||||
public static int AvPlayerGetStreamInfoEx(CpuContext ctx) =>
|
||||
GetStreamInfoCore(ctx, StreamInfoExSize);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "XC9wM+xULz8",
|
||||
@@ -561,12 +564,48 @@ public static class AvPlayerExports
|
||||
}
|
||||
}
|
||||
|
||||
internal static void RegisterPlayerForTest(
|
||||
ulong handle,
|
||||
int width,
|
||||
int height,
|
||||
ulong durationMilliseconds)
|
||||
{
|
||||
PlayerState? previous;
|
||||
lock (StateGate)
|
||||
{
|
||||
Players.Remove(handle, out previous);
|
||||
Players[handle] = new PlayerState
|
||||
{
|
||||
Handle = handle,
|
||||
Width = width,
|
||||
Height = height,
|
||||
DurationMilliseconds = durationMilliseconds,
|
||||
};
|
||||
}
|
||||
|
||||
previous?.Dispose();
|
||||
}
|
||||
|
||||
internal static void RemovePlayerForTest(ulong handle)
|
||||
{
|
||||
PlayerState? player;
|
||||
lock (StateGate)
|
||||
{
|
||||
Players.Remove(handle, out player);
|
||||
}
|
||||
|
||||
player?.Dispose();
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "d8FcbzfAdQw",
|
||||
ExportName = "sceAvPlayerGetStreamInfo",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAvPlayer")]
|
||||
public static int AvPlayerGetStreamInfo(CpuContext ctx)
|
||||
public static int AvPlayerGetStreamInfo(CpuContext ctx) =>
|
||||
GetStreamInfoCore(ctx, StreamInfoSize);
|
||||
|
||||
private static int GetStreamInfoCore(CpuContext ctx, int infoSize)
|
||||
{
|
||||
var streamIndex = unchecked((uint)ctx[CpuRegister.Rsi]);
|
||||
var infoAddress = ctx[CpuRegister.Rdx];
|
||||
@@ -578,7 +617,7 @@ public static class AvPlayerExports
|
||||
return SetReturn(ctx, InvalidParameters);
|
||||
}
|
||||
|
||||
Span<byte> info = stackalloc byte[StreamInfoSize];
|
||||
Span<byte> info = stackalloc byte[infoSize];
|
||||
info.Clear();
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(info[0..], streamIndex); // 0=video, 1=audio
|
||||
if (streamIndex == 0)
|
||||
@@ -1009,7 +1048,7 @@ public static class AvPlayerExports
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var ffprobe = Path.Combine(Path.GetDirectoryName(ffmpeg) ?? string.Empty, "ffprobe");
|
||||
var ffprobe = GetFfprobePath(ffmpeg, OperatingSystem.IsWindows());
|
||||
if (!File.Exists(ffprobe))
|
||||
{
|
||||
return false;
|
||||
@@ -1092,13 +1131,50 @@ public static class AvPlayerExports
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindFfmpeg()
|
||||
internal static string? FindFfmpeg() =>
|
||||
FindFfmpeg(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH"),
|
||||
Environment.GetEnvironmentVariable("PATH"),
|
||||
OperatingSystem.IsWindows(),
|
||||
AppContext.BaseDirectory);
|
||||
|
||||
internal static string? FindFfmpeg(
|
||||
string? configured,
|
||||
string? searchPath,
|
||||
bool isWindows,
|
||||
string? baseDirectory = null)
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH");
|
||||
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
|
||||
{
|
||||
return configured;
|
||||
}
|
||||
|
||||
var executable = isWindows ? "ffmpeg.exe" : "ffmpeg";
|
||||
if (!string.IsNullOrWhiteSpace(baseDirectory))
|
||||
{
|
||||
foreach (var candidate in new[]
|
||||
{
|
||||
Path.Combine(baseDirectory, executable),
|
||||
Path.Combine(baseDirectory, "ffmpeg", executable),
|
||||
})
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var directory in (searchPath ?? string.Empty)
|
||||
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var candidate = Path.Combine(RemovePathQuotes(directory), executable);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var candidate in new[] { "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg" })
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
@@ -1109,6 +1185,16 @@ public static class AvPlayerExports
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string GetFfprobePath(string ffmpeg, bool isWindows) =>
|
||||
Path.Combine(
|
||||
Path.GetDirectoryName(ffmpeg) ?? string.Empty,
|
||||
isWindows ? "ffprobe.exe" : "ffprobe");
|
||||
|
||||
private static string RemovePathQuotes(string directory) =>
|
||||
directory.Length >= 2 && directory[0] == '"' && directory[^1] == '"'
|
||||
? directory[1..^1]
|
||||
: directory;
|
||||
|
||||
internal static string? ResolveGuestPath(string guestPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(guestPath))
|
||||
@@ -1118,7 +1204,9 @@ public static class AvPlayerExports
|
||||
|
||||
var normalized = guestPath.Replace('\\', '/');
|
||||
var fileReference = normalized.StartsWith("file:", StringComparison.OrdinalIgnoreCase);
|
||||
var unrealProjectRelative = false;
|
||||
var unrealProjectRelative =
|
||||
normalized.StartsWith("../", StringComparison.Ordinal) ||
|
||||
normalized.StartsWith("./", StringComparison.Ordinal);
|
||||
if (normalized.StartsWith("file://", StringComparison.OrdinalIgnoreCase) &&
|
||||
Uri.TryCreate(normalized, UriKind.Absolute, out var uri) &&
|
||||
uri.IsFile)
|
||||
@@ -1149,7 +1237,10 @@ public static class AvPlayerExports
|
||||
|
||||
if (unrealProjectRelative)
|
||||
{
|
||||
normalized = RemoveUnrealLeadingDotSegments(normalized);
|
||||
if (!TryRemoveUnrealLeadingDotSegments(normalized, out normalized))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var app0 = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
|
||||
@@ -1233,15 +1324,20 @@ public static class AvPlayerExports
|
||||
}
|
||||
}
|
||||
|
||||
private static string RemoveUnrealLeadingDotSegments(string guestPath)
|
||||
private static bool TryRemoveUnrealLeadingDotSegments(
|
||||
string guestPath,
|
||||
out string normalized)
|
||||
{
|
||||
var removedParent = false;
|
||||
while (guestPath.StartsWith("../", StringComparison.Ordinal) ||
|
||||
guestPath.StartsWith("./", StringComparison.Ordinal))
|
||||
{
|
||||
removedParent |= guestPath.StartsWith("../", StringComparison.Ordinal);
|
||||
guestPath = guestPath[(guestPath.IndexOf('/') + 1)..];
|
||||
}
|
||||
|
||||
return guestPath;
|
||||
normalized = guestPath;
|
||||
return !removedParent || guestPath.Contains('/');
|
||||
}
|
||||
|
||||
private static bool TryDecodeFileReference(string encoded, out string decoded)
|
||||
|
||||
@@ -18,123 +18,153 @@ namespace SharpEmu.Libs.Bink;
|
||||
internal static class Bink2MovieBridge
|
||||
{
|
||||
private const uint MaxDimension = 16384;
|
||||
private const uint MaxHostVideoWidth = 1920;
|
||||
private const uint MaxHostVideoHeight = 1080;
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static NativeAdapter? _adapter;
|
||||
private static string? _activePath;
|
||||
private static IntPtr _activeMovie;
|
||||
private static Bink2MovieInfo _activeInfo;
|
||||
private static byte[]? _frameBuffer;
|
||||
private static bool _usingDummyMovie;
|
||||
private static bool _loadAttempted;
|
||||
private static bool _availabilityReported;
|
||||
private static bool _frameBufferPresented;
|
||||
private static BinkFramePlayback? _playback;
|
||||
private static long _frameSerial;
|
||||
private static uint _presentationWidth = MaxHostVideoWidth;
|
||||
private static uint _presentationHeight = MaxHostVideoHeight;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the guest should receive a normal "file not found"
|
||||
/// result for a Bink movie. This is the safe default without a decoder:
|
||||
/// games that treat movies as optional fall through to their next state
|
||||
/// rather than submitting an empty Bink GPU texture forever.
|
||||
/// </summary>
|
||||
internal static bool ShouldSkipGuestMovie(string hostPath) =>
|
||||
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
|
||||
ResolveMode() == MovieMode.Skip;
|
||||
|
||||
internal static void ObserveGuestMovie(string hostPath)
|
||||
internal static bool IsHostPlaybackActive
|
||||
{
|
||||
if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
|
||||
!File.Exists(hostPath))
|
||||
get
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
return _playback is not null || _frameBuffer is not null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static void SetPresentationSize(uint width, uint height)
|
||||
{
|
||||
if (width == 0 || height == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
_presentationWidth = Math.Min(width, MaxHostVideoWidth);
|
||||
_presentationHeight = Math.Min(height, MaxHostVideoHeight);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true only when movie skipping was explicitly requested. Without
|
||||
/// a host adapter the guest must be allowed to run the Bink implementation
|
||||
/// statically linked into its executable.
|
||||
/// </summary>
|
||||
internal static bool ShouldSkipGuestMovie(string hostPath) =>
|
||||
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
|
||||
ResolveMode() == MovieMode.Skip;
|
||||
|
||||
/// <summary>
|
||||
/// Starts or queues host decoding. Decoded frames are only exposed as a
|
||||
/// sampled guest texture; presentation and UI composition remain guest-owned.
|
||||
/// </summary>
|
||||
internal static bool ObserveGuestMovie(string hostPath)
|
||||
{
|
||||
if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
|
||||
!File.Exists(hostPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
if (string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
return _playback is not null || _frameBuffer is not null;
|
||||
}
|
||||
|
||||
if (ResolveMode() == MovieMode.Dummy)
|
||||
var mode = ResolveMode();
|
||||
if (mode is MovieMode.Guest or MovieMode.Skip)
|
||||
{
|
||||
AttachDummyMovieLocked(hostPath);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
var adapter = GetAdapterLocked();
|
||||
if (adapter is null)
|
||||
if (_playback is not null || _frameBuffer is not null)
|
||||
{
|
||||
return;
|
||||
if (PendingMoviePathSet.Add(hostPath))
|
||||
{
|
||||
PendingMoviePaths.Enqueue(hostPath);
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge queued: " +
|
||||
Path.GetFileName(hostPath));
|
||||
}
|
||||
return PendingMoviePathSet.Contains(hostPath);
|
||||
}
|
||||
|
||||
CloseActiveLocked();
|
||||
if (!adapter.TryOpen(hostPath, out var movie, out var info))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink2 bridge could not open movie '" +
|
||||
Path.GetFileName(hostPath) + "'.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsValid(info))
|
||||
{
|
||||
adapter.Close(movie);
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink2 bridge rejected invalid movie dimensions for '" +
|
||||
Path.GetFileName(hostPath) + "'.");
|
||||
return;
|
||||
}
|
||||
|
||||
_activePath = hostPath;
|
||||
_activeMovie = movie;
|
||||
_activeInfo = info;
|
||||
_frameBuffer = GC.AllocateUninitializedArray<byte>(GetFrameBufferLength(info));
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge attached: " + Path.GetFileName(hostPath) + " " +
|
||||
info.Width + "x" + info.Height + " @ " +
|
||||
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
|
||||
AttachMovieLocked(hostPath, mode);
|
||||
return string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase) &&
|
||||
(_playback is not null || _frameBuffer is not null);
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryDecodeNextFrame(
|
||||
bool advanceClock,
|
||||
out byte[] pixels,
|
||||
out uint width,
|
||||
out uint height)
|
||||
out uint height,
|
||||
out bool advanced,
|
||||
out long frameSerial,
|
||||
out string hostPath)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
pixels = [];
|
||||
width = 0;
|
||||
height = 0;
|
||||
if (_adapter is null || _activeMovie == IntPtr.Zero || _frameBuffer is null)
|
||||
advanced = false;
|
||||
frameSerial = _frameSerial;
|
||||
hostPath = _activePath ?? string.Empty;
|
||||
|
||||
if (_playback is not null)
|
||||
{
|
||||
if (_usingDummyMovie && _frameBuffer is not null)
|
||||
if (!_playback.TryGetFrame(advanceClock, out pixels, out advanced))
|
||||
{
|
||||
pixels = _frameBuffer;
|
||||
width = _activeInfo.Width;
|
||||
height = _activeInfo.Height;
|
||||
return true;
|
||||
if (_playback.IsFinished)
|
||||
{
|
||||
var completedPath = _activePath;
|
||||
CloseActiveLocked();
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge completed: " +
|
||||
Path.GetFileName(completedPath));
|
||||
AttachNextQueuedMovieLocked();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
width = _activeInfo.Width;
|
||||
height = _activeInfo.Height;
|
||||
if (advanced)
|
||||
{
|
||||
frameSerial = ++_frameSerial;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
unsafe
|
||||
if (_frameBuffer is null)
|
||||
{
|
||||
fixed (byte* destination = _frameBuffer)
|
||||
{
|
||||
if (!_adapter.DecodeNextBgra(
|
||||
_activeMovie,
|
||||
(IntPtr)destination,
|
||||
_activeInfo.Width * 4,
|
||||
(uint)_frameBuffer.Length))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
pixels = _frameBuffer;
|
||||
width = _activeInfo.Width;
|
||||
height = _activeInfo.Height;
|
||||
advanced = !_frameBufferPresented;
|
||||
_frameBufferPresented = true;
|
||||
if (advanced)
|
||||
{
|
||||
frameSerial = ++_frameSerial;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -147,6 +177,52 @@ internal static class Bink2MovieBridge
|
||||
private static int GetFrameBufferLength(Bink2MovieInfo info) =>
|
||||
checked((int)((ulong)info.Width * info.Height * 4));
|
||||
|
||||
private static void AttachMovieLocked(string hostPath, MovieMode mode)
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case MovieMode.Dummy:
|
||||
AttachDummyMovieLocked(hostPath);
|
||||
return;
|
||||
case MovieMode.Ffmpeg:
|
||||
AttachFfmpegMovieLocked(hostPath);
|
||||
return;
|
||||
case MovieMode.Native:
|
||||
AttachNativeMovieLocked(hostPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AttachNativeMovieLocked(string hostPath)
|
||||
{
|
||||
if (!FfmpegNativeBinkFrameSource.TryOpen(
|
||||
hostPath, _presentationWidth, _presentationHeight, out var source) ||
|
||||
source is null)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink2 bridge could not open movie '" +
|
||||
Path.GetFileName(hostPath) + "'.");
|
||||
return;
|
||||
}
|
||||
|
||||
var info = new Bink2MovieInfo(
|
||||
source.Width, source.Height, source.FramesPerSecondNumerator, source.FramesPerSecondDenominator);
|
||||
if (!IsValid(info))
|
||||
{
|
||||
source.Dispose();
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink2 bridge rejected invalid movie dimensions for '" +
|
||||
Path.GetFileName(hostPath) + "'.");
|
||||
return;
|
||||
}
|
||||
|
||||
AttachPlaybackLocked(hostPath, info, source);
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge attached: " + Path.GetFileName(hostPath) + " " +
|
||||
info.Width + "x" + info.Height + " @ " +
|
||||
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
|
||||
}
|
||||
|
||||
private static MovieMode ResolveMode()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("SHARPEMU_BINK_MODE");
|
||||
@@ -165,16 +241,22 @@ internal static class Bink2MovieBridge
|
||||
return MovieMode.Skip;
|
||||
}
|
||||
|
||||
// With no SDK adapter present, returning "not found" makes optional
|
||||
// cinematics advance. Supplying either an explicit path or the normal
|
||||
// side-by-side adapter enables native playback automatically.
|
||||
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE")) ||
|
||||
EnumerateAdapterCandidates().Any(File.Exists))
|
||||
if (string.Equals(configured, "guest", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MovieMode.Native;
|
||||
return MovieMode.Guest;
|
||||
}
|
||||
|
||||
return MovieMode.Skip;
|
||||
if (string.Equals(configured, "ffmpeg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MovieMode.Ffmpeg;
|
||||
}
|
||||
|
||||
// Native is the default: FfmpegNativeBinkFrameSource.TryOpen degrades
|
||||
// gracefully (falls back to the guest's own decode, logging one
|
||||
// informational line) if the FFmpeg libraries SharpEmu.CLI.csproj
|
||||
// downloads next to the executable are genuinely unavailable, so
|
||||
// defaulting to Native unconditionally is safe.
|
||||
return MovieMode.Native;
|
||||
}
|
||||
|
||||
private static void AttachDummyMovieLocked(string hostPath)
|
||||
@@ -191,22 +273,61 @@ internal static class Bink2MovieBridge
|
||||
_activePath = hostPath;
|
||||
_activeInfo = info;
|
||||
_frameBuffer = GC.AllocateUninitializedArray<byte>(GetFrameBufferLength(info));
|
||||
_frameBufferPresented = false;
|
||||
FillDummyFrame(_frameBuffer, info.Width, info.Height);
|
||||
_usingDummyMovie = true;
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink dummy attached: " + Path.GetFileName(hostPath) + " " +
|
||||
info.Width + "x" + info.Height + ".");
|
||||
}
|
||||
|
||||
private static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
|
||||
private static void AttachFfmpegMovieLocked(string hostPath)
|
||||
{
|
||||
if (!TryReadBinkInfo(hostPath, out var info) || !IsValid(info))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink FFmpeg source has an invalid header: " +
|
||||
Path.GetFileName(hostPath));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!FfmpegBinkFrameSource.TryOpen(
|
||||
hostPath,
|
||||
info.Width,
|
||||
info.Height,
|
||||
info.FramesPerSecondNumerator,
|
||||
info.FramesPerSecondDenominator,
|
||||
out var source) || source is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AttachPlaybackLocked(hostPath, info, source);
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink FFmpeg source attached: " +
|
||||
Path.GetFileName(hostPath) + " " + info.Width + "x" + info.Height + " @ " +
|
||||
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
|
||||
}
|
||||
|
||||
private static void AttachPlaybackLocked(
|
||||
string hostPath,
|
||||
Bink2MovieInfo info,
|
||||
IBinkFrameDecoder decoder)
|
||||
{
|
||||
CloseActiveLocked();
|
||||
_activePath = hostPath;
|
||||
_activeInfo = info;
|
||||
_playback = new BinkFramePlayback(decoder);
|
||||
}
|
||||
|
||||
internal static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
|
||||
{
|
||||
info = default;
|
||||
Span<byte> header = stackalloc byte[32];
|
||||
Span<byte> header = stackalloc byte[36];
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
if (stream.Read(header) != header.Length ||
|
||||
!header[..4].SequenceEqual("KB2j"u8))
|
||||
stream.ReadExactly(header);
|
||||
if (!header[..3].SequenceEqual("KB2"u8))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -215,10 +336,11 @@ internal static class Bink2MovieBridge
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x14, 4)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x18, 4)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x1C, 4)),
|
||||
1);
|
||||
return true;
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x20, 4)));
|
||||
return info.FramesPerSecondNumerator != 0 &&
|
||||
info.FramesPerSecondDenominator != 0;
|
||||
}
|
||||
catch (IOException)
|
||||
catch (Exception exception) when (exception is IOException or EndOfStreamException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -240,80 +362,23 @@ internal static class Bink2MovieBridge
|
||||
}
|
||||
}
|
||||
|
||||
private static NativeAdapter? GetAdapterLocked()
|
||||
{
|
||||
if (_loadAttempted)
|
||||
{
|
||||
return _adapter;
|
||||
}
|
||||
|
||||
_loadAttempted = true;
|
||||
foreach (var candidate in EnumerateAdapterCandidates())
|
||||
{
|
||||
if (!NativeLibrary.TryLoad(candidate, out var library))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (NativeAdapter.TryCreate(library, out var adapter))
|
||||
{
|
||||
_adapter = adapter;
|
||||
Console.Error.WriteLine("[LOADER][INFO] Bink2 bridge loaded: " + candidate);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
NativeLibrary.Free(library);
|
||||
}
|
||||
|
||||
if (!_availabilityReported)
|
||||
{
|
||||
_availabilityReported = true;
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge unavailable; install the licensed adapter and set SHARPEMU_BINK2_BRIDGE.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateAdapterCandidates()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE");
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
yield return configured;
|
||||
}
|
||||
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
yield return Path.Combine(baseDirectory, "libsharpemu_bink2_bridge.dylib");
|
||||
}
|
||||
else if (OperatingSystem.IsWindows())
|
||||
{
|
||||
yield return Path.Combine(baseDirectory, "sharpemu_bink2_bridge.dll");
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return Path.Combine(baseDirectory, "libsharpemu_bink2_bridge.so");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CloseActiveLocked()
|
||||
{
|
||||
if (_activeMovie != IntPtr.Zero)
|
||||
{
|
||||
_adapter?.Close(_activeMovie);
|
||||
}
|
||||
|
||||
_playback?.Dispose();
|
||||
_playback = null;
|
||||
_activePath = null;
|
||||
_activeMovie = IntPtr.Zero;
|
||||
_activeInfo = default;
|
||||
_frameBuffer = null;
|
||||
_usingDummyMovie = false;
|
||||
_frameBufferPresented = false;
|
||||
|
||||
// Wake any guest _read() blocked in WaitForHostPlaybackToFinish: its
|
||||
// movie either just finished or is being pre-empted by a new attach.
|
||||
Monitor.PulseAll(Gate);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private readonly struct Bink2MovieInfo
|
||||
internal readonly struct Bink2MovieInfo
|
||||
{
|
||||
public readonly uint Width;
|
||||
public readonly uint Height;
|
||||
@@ -335,69 +400,226 @@ internal static class Bink2MovieBridge
|
||||
|
||||
private enum MovieMode
|
||||
{
|
||||
Guest,
|
||||
Skip,
|
||||
Dummy,
|
||||
Native,
|
||||
Ffmpeg,
|
||||
}
|
||||
|
||||
private sealed class NativeAdapter
|
||||
private static readonly Queue<string> PendingMoviePaths = new();
|
||||
private static readonly HashSet<string> PendingMoviePathSet =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private static void AttachNextQueuedMovieLocked()
|
||||
{
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int OpenUtf8Delegate(IntPtr pathUtf8, out IntPtr movie, out Bink2MovieInfo info);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int DecodeNextBgraDelegate(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void CloseDelegate(IntPtr movie);
|
||||
|
||||
private readonly OpenUtf8Delegate _openUtf8;
|
||||
private readonly DecodeNextBgraDelegate _decodeNextBgra;
|
||||
private readonly CloseDelegate _close;
|
||||
|
||||
private NativeAdapter(
|
||||
OpenUtf8Delegate openUtf8,
|
||||
DecodeNextBgraDelegate decodeNextBgra,
|
||||
CloseDelegate close)
|
||||
while (PendingMoviePaths.Count > 0)
|
||||
{
|
||||
_openUtf8 = openUtf8;
|
||||
_decodeNextBgra = decodeNextBgra;
|
||||
_close = close;
|
||||
var path = PendingMoviePaths.Dequeue();
|
||||
PendingMoviePathSet.Remove(path);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AttachMovieLocked(path, ResolveMode());
|
||||
if (_playback is not null || _frameBuffer is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryCreate(IntPtr library, out NativeAdapter? adapter)
|
||||
}
|
||||
// Longest a guest _read() will block waiting for real host playback to
|
||||
// finish. A safety net, not a target: real movies finish well under
|
||||
// this. Bounds the damage if a movie fails to attach/decode after being
|
||||
// queued, so the guest thread doesn't hang forever.
|
||||
private const long MaxCompletionWaitMilliseconds = 5 * 60 * 1000;
|
||||
/// <summary>
|
||||
/// Blocks the calling (guest I/O) thread until the host has actually
|
||||
/// finished presenting <paramref name="hostPath"/> — either because it
|
||||
/// played through, or because something else took over the timeline.
|
||||
///
|
||||
/// The completion shim tells the guest's own Bink header parse "this
|
||||
/// movie is one frame and already done" so its native decoder never
|
||||
/// blocks the guest on real per-frame work. Without this wait, that lie
|
||||
/// lands the instant the guest reads the header, so guest-side game
|
||||
/// logic races far ahead of whatever the host is still showing on
|
||||
/// screen: pressing a button lands on the (already-advanced) guest
|
||||
/// state, but the video visibly keeps playing, and any real-time-gated
|
||||
/// trigger later in the guest's own flow can fire against a clock that
|
||||
/// no longer matches wall time. Gating the "done" read on real host
|
||||
/// completion keeps guest pacing and on-screen playback in lockstep.
|
||||
/// </summary>
|
||||
internal static void WaitForHostPlaybackToFinish(string hostPath)
|
||||
{
|
||||
var deadline = Environment.TickCount64 + MaxCompletionWaitMilliseconds;
|
||||
lock (Gate)
|
||||
{
|
||||
adapter = null;
|
||||
if (!NativeLibrary.TryGetExport(library, "sharpemu_bink2_open_utf8", out var open) ||
|
||||
!NativeLibrary.TryGetExport(library, "sharpemu_bink2_decode_next_bgra", out var decode) ||
|
||||
!NativeLibrary.TryGetExport(library, "sharpemu_bink2_close", out var close))
|
||||
while (IsTrackedLocked(hostPath))
|
||||
{
|
||||
var remaining = deadline - Environment.TickCount64;
|
||||
if (remaining <= 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink2 bridge completion wait timed out for '" +
|
||||
Path.GetFileName(hostPath) + "'.");
|
||||
return;
|
||||
}
|
||||
|
||||
Monitor.Wait(Gate, (int)Math.Min(remaining, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTrackedLocked(string hostPath) =>
|
||||
string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
PendingMoviePathSet.Contains(hostPath);
|
||||
|
||||
internal static bool TryTakeOverGuestMovie(
|
||||
string hostPath,
|
||||
out BinkGuestCompletionShim completionShim,
|
||||
out bool observed)
|
||||
{
|
||||
completionShim = default;
|
||||
observed = ObserveGuestMovie(hostPath);
|
||||
|
||||
// Keep the real header visible so the guest creates its movie surface
|
||||
// and draw. Host-decoded pixels replace that sampled image later; a
|
||||
// one-frame completion shim would finish before the descriptor exists.
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static void NotifyGuestMovieClosed(string hostPath)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (PendingMoviePathSet.Remove(hostPath))
|
||||
{
|
||||
var retained = PendingMoviePaths
|
||||
.Where(path => !string.Equals(
|
||||
path,
|
||||
hostPath,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
PendingMoviePaths.Clear();
|
||||
foreach (var path in retained)
|
||||
{
|
||||
PendingMoviePaths.Enqueue(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Monitor.PulseAll(Gate);
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge stopped by guest close: " +
|
||||
Path.GetFileName(hostPath));
|
||||
CloseActiveLocked();
|
||||
AttachNextQueuedMovieLocked();
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryReadGuestCompletionShim(
|
||||
string hostPath,
|
||||
out BinkGuestCompletionShim completionShim)
|
||||
{
|
||||
completionShim = default;
|
||||
Span<byte> header = stackalloc byte[48];
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(hostPath);
|
||||
stream.ReadExactly(header);
|
||||
if (!header[..3].SequenceEqual("KB2"u8))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
adapter = new NativeAdapter(
|
||||
Marshal.GetDelegateForFunctionPointer<OpenUtf8Delegate>(open),
|
||||
Marshal.GetDelegateForFunctionPointer<DecodeNextBgraDelegate>(decode),
|
||||
Marshal.GetDelegateForFunctionPointer<CloseDelegate>(close));
|
||||
var frameCount = BinaryPrimitives.ReadUInt32LittleEndian(header[8..12]);
|
||||
var audioTrackCount = BinaryPrimitives.ReadUInt32LittleEndian(header[40..44]);
|
||||
if (frameCount < 2 || audioTrackCount > 256)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var revision = header[3];
|
||||
var frameIndexOffset = 44L + checked(12L * audioTrackCount);
|
||||
if (revision == (byte)'m')
|
||||
{
|
||||
frameIndexOffset += 16;
|
||||
}
|
||||
else if (revision is (byte)'i' or (byte)'j' or (byte)'k' or (byte)'n')
|
||||
{
|
||||
frameIndexOffset += 4;
|
||||
}
|
||||
|
||||
Span<byte> frameOffsets = stackalloc byte[8];
|
||||
stream.Position = frameIndexOffset;
|
||||
stream.ReadExactly(frameOffsets);
|
||||
var firstFrameOffset = BinaryPrimitives.ReadUInt32LittleEndian(frameOffsets[..4]) & ~1u;
|
||||
var secondFrameOffset = BinaryPrimitives.ReadUInt32LittleEndian(frameOffsets[4..]) & ~1u;
|
||||
if (firstFrameOffset < frameIndexOffset + 8 ||
|
||||
secondFrameOffset <= firstFrameOffset ||
|
||||
secondFrameOffset > stream.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
completionShim = new BinkGuestCompletionShim(
|
||||
secondFrameOffset - 8,
|
||||
secondFrameOffset - firstFrameOffset);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool TryOpen(string path, out IntPtr movie, out Bink2MovieInfo info)
|
||||
catch (Exception exception) when (
|
||||
exception is IOException or EndOfStreamException or OverflowException)
|
||||
{
|
||||
var utf8 = Marshal.StringToCoTaskMemUTF8(path);
|
||||
try
|
||||
{
|
||||
return _openUtf8(utf8, out movie, out info) != 0 && movie != IntPtr.Zero;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeCoTaskMem(utf8);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly struct BinkGuestCompletionShim
|
||||
{
|
||||
private readonly uint _fileSizeMinusHeader;
|
||||
private readonly uint _largestFrameSize;
|
||||
|
||||
internal BinkGuestCompletionShim(uint fileSizeMinusHeader, uint largestFrameSize)
|
||||
{
|
||||
_fileSizeMinusHeader = fileSizeMinusHeader;
|
||||
_largestFrameSize = largestFrameSize;
|
||||
}
|
||||
|
||||
internal bool DecodeNextBgra(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes) =>
|
||||
_decodeNextBgra(movie, destination, stride, destinationBytes) != 0;
|
||||
/// <summary>
|
||||
/// Rewrites the frame-count/size fields the guest's own Bink header
|
||||
/// parse reads, if this read covers them. Returns true when the
|
||||
/// NumFrames field (the field that tells the guest "this movie is
|
||||
/// done") was in range, so the caller can gate that specific read on
|
||||
/// the host's real playback actually finishing first.
|
||||
/// </summary>
|
||||
internal bool Patch(long fileOffset, Span<byte> bytes)
|
||||
{
|
||||
PatchUInt32(fileOffset, bytes, 4, _fileSizeMinusHeader);
|
||||
var touchedCompletionField = PatchUInt32(fileOffset, bytes, 8, 1);
|
||||
PatchUInt32(fileOffset, bytes, 12, _largestFrameSize);
|
||||
return touchedCompletionField;
|
||||
}
|
||||
|
||||
internal void Close(IntPtr movie) => _close(movie);
|
||||
private static bool PatchUInt32(
|
||||
long fileOffset,
|
||||
Span<byte> bytes,
|
||||
long fieldOffset,
|
||||
uint value)
|
||||
{
|
||||
var relativeOffset = fieldOffset - fileOffset;
|
||||
if (relativeOffset < 0 || relativeOffset + sizeof(uint) > bytes.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
bytes.Slice((int)relativeOffset, sizeof(uint)),
|
||||
value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace SharpEmu.Libs.Bink;
|
||||
|
||||
internal interface IBinkFrameDecoder : IDisposable
|
||||
{
|
||||
uint Width { get; }
|
||||
|
||||
uint Height { get; }
|
||||
|
||||
uint FramesPerSecondNumerator { get; }
|
||||
|
||||
uint FramesPerSecondDenominator { get; }
|
||||
|
||||
bool TryDecodeNextFrame(Span<byte> destination);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps blocking codec work away from the Vulkan presentation thread and
|
||||
/// releases decoded frames according to the movie time base.
|
||||
/// </summary>
|
||||
internal sealed class BinkFramePlayback : IDisposable
|
||||
{
|
||||
private const int BufferCount = 5;
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly IBinkFrameDecoder _decoder;
|
||||
private readonly Queue<byte[]> _freeBuffers = new();
|
||||
private readonly Queue<DecodedFrame> _decodedFrames = new();
|
||||
private readonly Thread _decoderThread;
|
||||
private byte[]? _currentFrame;
|
||||
private byte[]? _retiredFrame;
|
||||
private long _currentFrameIndex = -1;
|
||||
private long _nextDecodedFrameIndex;
|
||||
private long _playbackStartTimestamp;
|
||||
private bool _playbackClockStarted;
|
||||
private bool _decoderCompleted;
|
||||
private bool _stopRequested;
|
||||
private bool _finished;
|
||||
private int _disposed;
|
||||
|
||||
internal BinkFramePlayback(IBinkFrameDecoder decoder)
|
||||
{
|
||||
_decoder = decoder;
|
||||
Width = decoder.Width;
|
||||
Height = decoder.Height;
|
||||
FramesPerSecondNumerator = decoder.FramesPerSecondNumerator;
|
||||
FramesPerSecondDenominator = decoder.FramesPerSecondDenominator;
|
||||
|
||||
var frameBytes = checked((int)((ulong)Width * Height * 4));
|
||||
for (var index = 0; index < BufferCount; index++)
|
||||
{
|
||||
_freeBuffers.Enqueue(GC.AllocateUninitializedArray<byte>(frameBytes));
|
||||
}
|
||||
|
||||
_decoderThread = new Thread(DecodeLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "SharpEmu Bink video decoder",
|
||||
};
|
||||
_decoderThread.Start();
|
||||
}
|
||||
|
||||
internal uint Width { get; }
|
||||
|
||||
internal uint Height { get; }
|
||||
|
||||
internal uint FramesPerSecondNumerator { get; }
|
||||
|
||||
internal uint FramesPerSecondDenominator { get; }
|
||||
|
||||
internal bool IsFinished
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _finished;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal bool TryGetFrame(
|
||||
bool advanceClock,
|
||||
out byte[] pixels,
|
||||
out bool advanced)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
pixels = [];
|
||||
advanced = false;
|
||||
if (_finished)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_currentFrame is null)
|
||||
{
|
||||
if (_decodedFrames.Count == 0)
|
||||
{
|
||||
if (_decoderCompleted)
|
||||
{
|
||||
_finished = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var first = _decodedFrames.Dequeue();
|
||||
_currentFrame = first.Pixels;
|
||||
_currentFrameIndex = first.Index;
|
||||
advanced = true;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
|
||||
if (advanceClock && !_playbackClockStarted)
|
||||
{
|
||||
_playbackStartTimestamp = Stopwatch.GetTimestamp();
|
||||
_playbackClockStarted = true;
|
||||
}
|
||||
|
||||
var elapsedSeconds = _playbackClockStarted
|
||||
? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds
|
||||
: 0;
|
||||
var targetFrameIndex = (long)Math.Floor(
|
||||
elapsedSeconds * FramesPerSecondNumerator / FramesPerSecondDenominator);
|
||||
DecodedFrame? replacement = null;
|
||||
while (_decodedFrames.Count > 0 &&
|
||||
_decodedFrames.Peek().Index <= targetFrameIndex)
|
||||
{
|
||||
if (replacement is { } skipped)
|
||||
{
|
||||
_freeBuffers.Enqueue(skipped.Pixels);
|
||||
}
|
||||
replacement = _decodedFrames.Dequeue();
|
||||
}
|
||||
|
||||
if (replacement is { } next)
|
||||
{
|
||||
if (_retiredFrame is not null)
|
||||
{
|
||||
_freeBuffers.Enqueue(_retiredFrame);
|
||||
}
|
||||
_retiredFrame = _currentFrame;
|
||||
_currentFrame = next.Pixels;
|
||||
_currentFrameIndex = next.Index;
|
||||
advanced = true;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
|
||||
var frameDurationSeconds =
|
||||
(double)FramesPerSecondDenominator / FramesPerSecondNumerator;
|
||||
if (_playbackClockStarted &&
|
||||
_decoderCompleted &&
|
||||
_decodedFrames.Count == 0 &&
|
||||
elapsedSeconds >= (_currentFrameIndex + 1) * frameDurationSeconds)
|
||||
{
|
||||
_finished = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
pixels = _currentFrame;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void DecodeLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
byte[] destination;
|
||||
lock (_gate)
|
||||
{
|
||||
while (!_stopRequested && _freeBuffers.Count == 0)
|
||||
{
|
||||
Monitor.Wait(_gate);
|
||||
}
|
||||
if (_stopRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
destination = _freeBuffers.Dequeue();
|
||||
}
|
||||
|
||||
if (!_decoder.TryDecodeNextFrame(destination))
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_freeBuffers.Enqueue(destination);
|
||||
_decoderCompleted = true;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_decodedFrames.Enqueue(new DecodedFrame(
|
||||
_nextDecodedFrameIndex++, destination));
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or
|
||||
InvalidOperationException)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Bink decoder stopped: {exception.Message}");
|
||||
lock (_gate)
|
||||
{
|
||||
_decoderCompleted = true;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_stopRequested = true;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
if (Thread.CurrentThread != _decoderThread &&
|
||||
!_decoderThread.Join(TimeSpan.FromMilliseconds(100)))
|
||||
{
|
||||
_decoder.Dispose();
|
||||
_decoderThread.Join(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
else
|
||||
{
|
||||
_decoder.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct DecodedFrame(long Index, byte[] Pixels);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Diagnostics;
|
||||
using SharpEmu.Libs.AvPlayer;
|
||||
|
||||
namespace SharpEmu.Libs.Bink;
|
||||
|
||||
internal sealed class FfmpegBinkFrameSource : IBinkFrameDecoder
|
||||
{
|
||||
private readonly Process _process;
|
||||
private readonly Stream _output;
|
||||
private int _errorLines;
|
||||
private int _disposed;
|
||||
|
||||
private FfmpegBinkFrameSource(
|
||||
Process process,
|
||||
uint width,
|
||||
uint height,
|
||||
uint framesPerSecondNumerator,
|
||||
uint framesPerSecondDenominator)
|
||||
{
|
||||
_process = process;
|
||||
_output = process.StandardOutput.BaseStream;
|
||||
Width = width;
|
||||
Height = height;
|
||||
FramesPerSecondNumerator = framesPerSecondNumerator;
|
||||
FramesPerSecondDenominator = framesPerSecondDenominator;
|
||||
}
|
||||
|
||||
public uint Width { get; }
|
||||
|
||||
public uint Height { get; }
|
||||
|
||||
public uint FramesPerSecondNumerator { get; }
|
||||
|
||||
public uint FramesPerSecondDenominator { get; }
|
||||
|
||||
internal static bool IsAvailable => AvPlayerExports.FindFfmpeg() is not null;
|
||||
|
||||
internal static bool TryOpen(
|
||||
string path,
|
||||
uint width,
|
||||
uint height,
|
||||
uint framesPerSecondNumerator,
|
||||
uint framesPerSecondDenominator,
|
||||
out FfmpegBinkFrameSource? source)
|
||||
{
|
||||
source = null;
|
||||
var ffmpeg = AvPlayerExports.FindFfmpeg();
|
||||
if (ffmpeg is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo(ffmpeg)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
startInfo.ArgumentList.Add("-nostdin");
|
||||
startInfo.ArgumentList.Add("-hide_banner");
|
||||
startInfo.ArgumentList.Add("-loglevel");
|
||||
startInfo.ArgumentList.Add("error");
|
||||
startInfo.ArgumentList.Add("-i");
|
||||
startInfo.ArgumentList.Add(path);
|
||||
startInfo.ArgumentList.Add("-map");
|
||||
startInfo.ArgumentList.Add("0:v:0");
|
||||
startInfo.ArgumentList.Add("-an");
|
||||
startInfo.ArgumentList.Add("-pix_fmt");
|
||||
startInfo.ArgumentList.Add("bgra");
|
||||
startInfo.ArgumentList.Add("-f");
|
||||
startInfo.ArgumentList.Add("rawvideo");
|
||||
startInfo.ArgumentList.Add("pipe:1");
|
||||
|
||||
try
|
||||
{
|
||||
var process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
source = new FfmpegBinkFrameSource(
|
||||
process,
|
||||
width,
|
||||
height,
|
||||
framesPerSecondNumerator,
|
||||
framesPerSecondDenominator);
|
||||
process.ErrorDataReceived += source.OnErrorData;
|
||||
process.BeginErrorReadLine();
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or
|
||||
InvalidOperationException or
|
||||
System.ComponentModel.Win32Exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Bink FFmpeg decoder could not start: {exception.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDecodeNextFrame(Span<byte> destination)
|
||||
{
|
||||
try
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < destination.Length)
|
||||
{
|
||||
var read = _output.Read(destination[offset..]);
|
||||
if (read == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
offset += read;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or ObjectDisposedException)
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Bink FFmpeg stream failed: {exception.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnErrorData(object sender, DataReceivedEventArgs eventArgs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(eventArgs.Data) ||
|
||||
Interlocked.Increment(ref _errorLines) > 20)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Console.Error.WriteLine($"[LOADER][FFMPEG-BINK] {eventArgs.Data}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_output.Dispose();
|
||||
try
|
||||
{
|
||||
if (!_process.HasExited)
|
||||
{
|
||||
_process.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
_process.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using FFmpeg.AutoGen;
|
||||
|
||||
namespace SharpEmu.Libs.Bink;
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a .bk2 (or any FFmpeg-readable movie) directly via FFmpeg's C API
|
||||
/// through FFmpeg.AutoGen P/Invoke bindings against the dynamically linked
|
||||
/// libraries published by github.com/sharpemu/ffmpeg-core -- no native C
|
||||
/// bridge of our own to build. See docs/bink2-bridge.md.
|
||||
/// </summary>
|
||||
internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
||||
{
|
||||
private AVFormatContext* _formatContext;
|
||||
private AVCodecContext* _codecContext;
|
||||
private SwsContext* _swsContext;
|
||||
private AVFrame* _frame;
|
||||
private AVPacket* _packet;
|
||||
private readonly int _videoStreamIndex;
|
||||
private bool _draining;
|
||||
private int _disposed;
|
||||
|
||||
public uint Width { get; }
|
||||
|
||||
public uint Height { get; }
|
||||
|
||||
public uint FramesPerSecondNumerator { get; }
|
||||
|
||||
public uint FramesPerSecondDenominator { get; }
|
||||
|
||||
private FfmpegNativeBinkFrameSource(
|
||||
AVFormatContext* formatContext,
|
||||
AVCodecContext* codecContext,
|
||||
int videoStreamIndex,
|
||||
uint width,
|
||||
uint height,
|
||||
uint framesPerSecondNumerator,
|
||||
uint framesPerSecondDenominator)
|
||||
{
|
||||
_formatContext = formatContext;
|
||||
_codecContext = codecContext;
|
||||
_videoStreamIndex = videoStreamIndex;
|
||||
Width = width;
|
||||
Height = height;
|
||||
FramesPerSecondNumerator = framesPerSecondNumerator;
|
||||
FramesPerSecondDenominator = framesPerSecondDenominator;
|
||||
_frame = ffmpeg.av_frame_alloc();
|
||||
_packet = ffmpeg.av_packet_alloc();
|
||||
}
|
||||
|
||||
private static bool _rootPathInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// Points FFmpeg.AutoGen at the FFmpeg shared libraries SharpEmu.CLI
|
||||
/// downloads next to the executable (see SharpEmu.CLI.csproj's
|
||||
/// FetchFfmpegRuntime target); kept as loose files rather than embedded
|
||||
/// in the single-file bundle so the OS loader can resolve the normal
|
||||
/// inter-library dependencies (avcodec depends on avutil, etc.) itself.
|
||||
/// </summary>
|
||||
private static void EnsureRootPathInitialized()
|
||||
{
|
||||
if (_rootPathInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_rootPathInitialized = true;
|
||||
// SharpEmu.CLI.csproj publishes FFmpeg's shared libraries into a
|
||||
// "plugins" subfolder next to the executable rather than flat beside
|
||||
// it (see NativeLibraryFolderName in SharpEmu.CLI.csproj).
|
||||
ffmpeg.RootPath = Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||
|
||||
// ffmpeg's static constructor runs DynamicallyLoadedBindings.Initialize()
|
||||
// itself, but that constructor fires on first touch of the ffmpeg type --
|
||||
// which is the RootPath assignment above -- so it binds against the
|
||||
// default (empty) RootPath before the assignment's own setter body runs.
|
||||
// Every function resolved during that first pass permanently throws
|
||||
// NotSupportedException. Re-running Initialize() now, with RootPath
|
||||
// actually set, rebinds everything against the real search path.
|
||||
DynamicallyLoadedBindings.Initialize();
|
||||
}
|
||||
|
||||
internal static bool TryOpen(
|
||||
string path,
|
||||
uint maximumWidth,
|
||||
uint maximumHeight,
|
||||
out FfmpegNativeBinkFrameSource? source)
|
||||
{
|
||||
source = null;
|
||||
EnsureRootPathInitialized();
|
||||
|
||||
AVFormatContext* formatContext = null;
|
||||
AVCodecContext* codecContext = null;
|
||||
try
|
||||
{
|
||||
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ffmpeg.avformat_find_stream_info(formatContext, null) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
AVCodec* decoder = null;
|
||||
var videoStreamIndex = ffmpeg.av_find_best_stream(
|
||||
formatContext, AVMediaType.AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0);
|
||||
if (videoStreamIndex < 0 || decoder is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var stream = formatContext->streams[videoStreamIndex];
|
||||
codecContext = ffmpeg.avcodec_alloc_context3(decoder);
|
||||
if (codecContext is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ffmpeg.avcodec_parameters_to_context(codecContext, stream->codecpar) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
codecContext->thread_count = 0;
|
||||
codecContext->thread_type = ffmpeg.FF_THREAD_FRAME | ffmpeg.FF_THREAD_SLICE;
|
||||
if (ffmpeg.avcodec_open2(codecContext, decoder, null) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (codecContext->width <= 0 || codecContext->height <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var frameRate = ffmpeg.av_guess_frame_rate(formatContext, stream, null);
|
||||
if (frameRate.num <= 0 || frameRate.den <= 0)
|
||||
{
|
||||
frameRate = stream->avg_frame_rate;
|
||||
}
|
||||
if (frameRate.num <= 0 || frameRate.den <= 0)
|
||||
{
|
||||
frameRate = stream->r_frame_rate;
|
||||
}
|
||||
if (frameRate.num <= 0 || frameRate.den <= 0)
|
||||
{
|
||||
frameRate = new AVRational { num = 30, den = 1 };
|
||||
}
|
||||
|
||||
var outputWidth = (uint)codecContext->width;
|
||||
var outputHeight = (uint)codecContext->height;
|
||||
if (maximumWidth > 0 && maximumHeight > 0 &&
|
||||
(outputWidth > maximumWidth || outputHeight > maximumHeight))
|
||||
{
|
||||
if ((ulong)outputWidth * maximumHeight > (ulong)outputHeight * maximumWidth)
|
||||
{
|
||||
outputHeight = (uint)((ulong)outputHeight * maximumWidth / outputWidth);
|
||||
outputWidth = maximumWidth;
|
||||
}
|
||||
else
|
||||
{
|
||||
outputWidth = (uint)((ulong)outputWidth * maximumHeight / outputHeight);
|
||||
outputHeight = maximumHeight;
|
||||
}
|
||||
|
||||
outputWidth = Math.Max(1, outputWidth);
|
||||
outputHeight = Math.Max(1, outputHeight);
|
||||
}
|
||||
|
||||
source = new FfmpegNativeBinkFrameSource(
|
||||
formatContext,
|
||||
codecContext,
|
||||
videoStreamIndex,
|
||||
outputWidth,
|
||||
outputHeight,
|
||||
(uint)frameRate.num,
|
||||
(uint)frameRate.den);
|
||||
formatContext = null;
|
||||
codecContext = null;
|
||||
return true;
|
||||
}
|
||||
catch (DllNotFoundException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (codecContext is not null)
|
||||
{
|
||||
ffmpeg.avcodec_free_context(&codecContext);
|
||||
}
|
||||
|
||||
if (formatContext is not null)
|
||||
{
|
||||
ffmpeg.avformat_close_input(&formatContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDecodeNextFrame(Span<byte> destination)
|
||||
{
|
||||
var stride = checked((int)(Width * 4));
|
||||
var required = (long)stride * Height;
|
||||
if (destination.Length < required)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryReceiveFrame())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_swsContext = ffmpeg.sws_getCachedContext(
|
||||
_swsContext,
|
||||
_frame->width,
|
||||
_frame->height,
|
||||
(AVPixelFormat)_frame->format,
|
||||
(int)Width,
|
||||
(int)Height,
|
||||
AVPixelFormat.AV_PIX_FMT_BGRA,
|
||||
ffmpeg.SWS_FAST_BILINEAR,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
if (_swsContext is null)
|
||||
{
|
||||
ffmpeg.av_frame_unref(_frame);
|
||||
return false;
|
||||
}
|
||||
|
||||
fixed (byte* destinationPointer = destination)
|
||||
{
|
||||
var destinationPlanes = new byte*[4] { destinationPointer, null, null, null };
|
||||
var destinationStrides = new int[4] { stride, 0, 0, 0 };
|
||||
var convertedRows = ffmpeg.sws_scale(
|
||||
_swsContext,
|
||||
_frame->data,
|
||||
_frame->linesize,
|
||||
0,
|
||||
_frame->height,
|
||||
destinationPlanes,
|
||||
destinationStrides);
|
||||
ffmpeg.av_frame_unref(_frame);
|
||||
return convertedRows == (int)Height;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryReceiveFrame()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var receiveResult = ffmpeg.avcodec_receive_frame(_codecContext, _frame);
|
||||
if (receiveResult >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (receiveResult == ffmpeg.AVERROR_EOF)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (receiveResult != ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_draining)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryFeedPacket())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryFeedPacket()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var readResult = ffmpeg.av_read_frame(_formatContext, _packet);
|
||||
if (readResult < 0)
|
||||
{
|
||||
_draining = true;
|
||||
ffmpeg.avcodec_send_packet(_codecContext, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_packet->stream_index != _videoStreamIndex)
|
||||
{
|
||||
ffmpeg.av_packet_unref(_packet);
|
||||
continue;
|
||||
}
|
||||
|
||||
var sendResult = ffmpeg.avcodec_send_packet(_codecContext, _packet);
|
||||
ffmpeg.av_packet_unref(_packet);
|
||||
if (sendResult < 0 && sendResult != ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_swsContext is not null)
|
||||
{
|
||||
ffmpeg.sws_freeContext(_swsContext);
|
||||
_swsContext = null;
|
||||
}
|
||||
|
||||
if (_packet is not null)
|
||||
{
|
||||
var packet = _packet;
|
||||
ffmpeg.av_packet_free(&packet);
|
||||
_packet = null;
|
||||
}
|
||||
|
||||
if (_frame is not null)
|
||||
{
|
||||
var frame = _frame;
|
||||
ffmpeg.av_frame_free(&frame);
|
||||
_frame = null;
|
||||
}
|
||||
|
||||
if (_codecContext is not null)
|
||||
{
|
||||
var codecContext = _codecContext;
|
||||
ffmpeg.avcodec_free_context(&codecContext);
|
||||
_codecContext = null;
|
||||
}
|
||||
|
||||
if (_formatContext is not null)
|
||||
{
|
||||
var formatContext = _formatContext;
|
||||
ffmpeg.avformat_close_input(&formatContext);
|
||||
_formatContext = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,37 @@ public static class FontExports
|
||||
return SetSuccess(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "3BrWWFU+4ts",
|
||||
ExportName = "sceFontGetVerticalLayout",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int GetVerticalLayout(CpuContext ctx)
|
||||
{
|
||||
var layoutAddress = ctx[CpuRegister.Rsi];
|
||||
if (layoutAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
// Baseline (horizontal offset), line advance, decoration extent.
|
||||
// Mirrors the same three-float layout as GetHorizontalLayout, but
|
||||
// interpreted for vertical writing (e.g. CJK text rendered top-to-bottom).
|
||||
var values = new[] { 8.0f, 16.0f, 0.0f };
|
||||
for (var index = 0; index < values.Length; index++)
|
||||
{
|
||||
if (!TryWriteUInt32(
|
||||
ctx,
|
||||
layoutAddress + (ulong)(index * sizeof(float)),
|
||||
BitConverter.SingleToUInt32Bits(values[index])))
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
}
|
||||
|
||||
return SetSuccess(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "cKYtVmeSTcw",
|
||||
ExportName = "sceFontOpenFontSet",
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers;
|
||||
using System.Numerics;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu;
|
||||
|
||||
/// <summary>
|
||||
/// The pool backing AGC-to-presenter ownership transfers, shared by every backend
|
||||
/// (the AGC layer rents, the presenter returns, so both sides must use one pool).
|
||||
/// Guest draw snapshots churn through a small set of 128 KiB-16 MiB size classes
|
||||
/// thousands of times per second; the process-wide shared pool trims and
|
||||
/// repartitions those large arrays aggressively under GC load, causing hundreds of
|
||||
/// MiB/s of replacement byte[] allocations, so this pool is bounded and non-shared.
|
||||
/// </summary>
|
||||
internal static class GuestDataPool
|
||||
{
|
||||
public static ArrayPool<byte> Shared { get; } = new BoundedByteArrayPool(
|
||||
maxArrayLength: 16 * 1024 * 1024,
|
||||
maxCachedBytes: 256UL * 1024 * 1024,
|
||||
maxArraysPerBucket: 8);
|
||||
|
||||
public static void Trim() => ((BoundedByteArrayPool)Shared).Trim();
|
||||
|
||||
private sealed class BoundedByteArrayPool : ArrayPool<byte>
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly int _maxArrayLength;
|
||||
private readonly ulong _maxCachedBytes;
|
||||
private readonly int _maxArraysPerBucket;
|
||||
private readonly Dictionary<int, Stack<byte[]>> _cachedByBucket = [];
|
||||
private readonly HashSet<byte[]> _leases =
|
||||
new(System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
||||
private ulong _cachedBytes;
|
||||
|
||||
public BoundedByteArrayPool(
|
||||
int maxArrayLength,
|
||||
ulong maxCachedBytes,
|
||||
int maxArraysPerBucket)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArrayLength);
|
||||
ArgumentOutOfRangeException.ThrowIfZero(maxCachedBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArraysPerBucket);
|
||||
_maxArrayLength = maxArrayLength;
|
||||
_maxCachedBytes = maxCachedBytes;
|
||||
_maxArraysPerBucket = maxArraysPerBucket;
|
||||
}
|
||||
|
||||
public override byte[] Rent(int minimumLength)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(minimumLength);
|
||||
var length = GetAllocationLength(minimumLength);
|
||||
byte[]? array = null;
|
||||
lock (_gate)
|
||||
{
|
||||
if (length <= _maxArrayLength &&
|
||||
_cachedByBucket.TryGetValue(length, out var bucket) &&
|
||||
bucket.TryPop(out array))
|
||||
{
|
||||
_cachedBytes -= (ulong)array.LongLength;
|
||||
}
|
||||
|
||||
array ??= new byte[length];
|
||||
_leases.Add(array);
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
public override void Return(byte[] array, bool clearArray = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(array);
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_leases.Remove(array))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (clearArray)
|
||||
{
|
||||
Array.Clear(array);
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (array.Length > _maxArrayLength ||
|
||||
!IsBucketLength(array.Length) ||
|
||||
(ulong)array.LongLength > _maxCachedBytes -
|
||||
Math.Min(_cachedBytes, _maxCachedBytes))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_cachedByBucket.TryGetValue(array.Length, out var bucket))
|
||||
{
|
||||
bucket = new Stack<byte[]>();
|
||||
_cachedByBucket.Add(array.Length, bucket);
|
||||
}
|
||||
|
||||
if (bucket.Count >= _maxArraysPerBucket)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bucket.Push(array);
|
||||
_cachedBytes += (ulong)array.LongLength;
|
||||
}
|
||||
}
|
||||
|
||||
public void Trim()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_cachedByBucket.Clear();
|
||||
_cachedBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private int GetAllocationLength(int minimumLength)
|
||||
{
|
||||
if (minimumLength <= 16)
|
||||
{
|
||||
return 16;
|
||||
}
|
||||
|
||||
if (minimumLength > _maxArrayLength)
|
||||
{
|
||||
return minimumLength;
|
||||
}
|
||||
|
||||
return checked((int)BitOperations.RoundUpToPowerOf2((uint)minimumLength));
|
||||
}
|
||||
|
||||
private static bool IsBucketLength(int length) =>
|
||||
length >= 16 && (length & (length - 1)) == 0;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.Gpu.Metal;
|
||||
using SharpEmu.Libs.Gpu.Vulkan;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu;
|
||||
@@ -8,11 +9,39 @@ namespace SharpEmu.Libs.Gpu;
|
||||
/// <summary>
|
||||
/// Process-wide access point for the guest-GPU backend, mirroring HostPlatform for the
|
||||
/// host seam: static HLE export classes resolve the renderer through <see cref="Current"/>.
|
||||
/// Vulkan is the only backend today; Metal/DX12 slot in here.
|
||||
/// Vulkan is the default everywhere; SHARPEMU_GPU_BACKEND=metal opts into the Metal
|
||||
/// backend (macOS only) while it is being brought up. macOS flips to Metal by default
|
||||
/// once the presenter reaches parity.
|
||||
/// </summary>
|
||||
internal static class GuestGpu
|
||||
{
|
||||
private static readonly Lazy<IGuestGpuBackend> Instance = new(static () => new VulkanGuestGpuBackend());
|
||||
private static readonly Lazy<IGuestGpuBackend> Instance = new(Create);
|
||||
|
||||
public static IGuestGpuBackend Current => Instance.Value;
|
||||
|
||||
private static IGuestGpuBackend Create()
|
||||
{
|
||||
var requested = Environment.GetEnvironmentVariable("SHARPEMU_GPU_BACKEND");
|
||||
if (string.IsNullOrEmpty(requested) || requested.Equals("vulkan", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new VulkanGuestGpuBackend();
|
||||
}
|
||||
|
||||
if (requested.Equals("metal", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] SHARPEMU_GPU_BACKEND=metal is only available on macOS; using Vulkan.");
|
||||
return new VulkanGuestGpuBackend();
|
||||
}
|
||||
|
||||
Console.Error.WriteLine("[LOADER][INFO] GPU backend: Metal (SHARPEMU_GPU_BACKEND).");
|
||||
return new MetalGuestGpuBackend();
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Unknown SHARPEMU_GPU_BACKEND value '{requested}'; using Vulkan.");
|
||||
return new VulkanGuestGpuBackend();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,12 @@ internal sealed record GuestDrawTexture(
|
||||
uint Pitch = 0,
|
||||
uint TileMode = 0,
|
||||
uint DstSelect = 0xFAC,
|
||||
GuestSampler Sampler = default);
|
||||
GuestSampler Sampler = default,
|
||||
// Guest CPU write-tracker generation of the memory RgbaPixels was read
|
||||
// from; -1 when the range is untracked or the pixels were not read here.
|
||||
long WriteGeneration = -1,
|
||||
bool ArrayedView = false,
|
||||
uint ArrayLayers = 1);
|
||||
|
||||
/// <summary>Raw guest sampler descriptor dwords, copied verbatim from guest memory.</summary>
|
||||
internal readonly record struct GuestSampler(
|
||||
@@ -36,6 +41,22 @@ internal readonly record struct GuestSampler(
|
||||
uint Word2,
|
||||
uint Word3);
|
||||
|
||||
/// <summary>Identity of a texture's content in a backend texture cache, keyed
|
||||
/// entirely on raw guest descriptor values; the AGC layer uses it to skip texel
|
||||
/// copies for content the backend already holds.</summary>
|
||||
internal readonly record struct TextureContentIdentity(
|
||||
ulong Address,
|
||||
uint Width,
|
||||
uint Height,
|
||||
uint Format,
|
||||
uint NumberType,
|
||||
uint DstSelect,
|
||||
uint TileMode,
|
||||
uint Pitch,
|
||||
GuestSampler Sampler,
|
||||
bool Arrayed = false,
|
||||
uint ArrayLayers = 1);
|
||||
|
||||
internal sealed record GuestMemoryBuffer(
|
||||
ulong BaseAddress,
|
||||
byte[] Data,
|
||||
@@ -122,12 +143,22 @@ internal readonly record struct GuestBlendState(
|
||||
WriteMask: 0xFu);
|
||||
}
|
||||
|
||||
/// <summary>CB_BLEND_RED..ALPHA: the constant color referenced by the
|
||||
/// CONSTANT_COLOR / CONSTANT_ALPHA blend factors. One constant serves every
|
||||
/// render target of a draw; the hardware reset value is transparent black.</summary>
|
||||
internal readonly record struct GuestBlendConstant(
|
||||
float Red,
|
||||
float Green,
|
||||
float Blue,
|
||||
float Alpha);
|
||||
|
||||
internal sealed record GuestRenderState(
|
||||
IReadOnlyList<GuestBlendState> Blends,
|
||||
GuestRect? Scissor,
|
||||
GuestViewport? Viewport,
|
||||
GuestRasterState Raster,
|
||||
GuestDepthState Depth)
|
||||
GuestDepthState Depth,
|
||||
GuestBlendConstant BlendConstant = default)
|
||||
{
|
||||
public static GuestRenderState Default { get; } = new(
|
||||
[GuestBlendState.Default],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu;
|
||||
@@ -17,6 +18,10 @@ namespace SharpEmu.Libs.Gpu;
|
||||
/// </summary>
|
||||
internal interface IGuestGpuBackend
|
||||
{
|
||||
/// <summary>Human-readable name of this backend ("Metal", "Vulkan"), shown in
|
||||
/// the window title on macOS where either backend can run.</summary>
|
||||
string BackendName { get; }
|
||||
|
||||
/// <summary>Starts the presenter (window + device) once; safe to call repeatedly.</summary>
|
||||
void EnsureStarted(uint width, uint height);
|
||||
|
||||
@@ -188,4 +193,70 @@ internal interface IGuestGpuBackend
|
||||
/// the guest codes cross the seam and each backend maps them internally.
|
||||
/// </summary>
|
||||
bool TryGetRenderTargetOutputKind(uint dataFormat, uint numberType, out Gen5PixelOutputKind outputKind);
|
||||
|
||||
// Guest work ordering. AGC submissions execute on a single backend consumer in
|
||||
// logical guest-queue order; sequences returned here are backend work tickets.
|
||||
// A backend without a running presenter returns 0 from the Submit* methods and
|
||||
// callers fall back to executing inline.
|
||||
|
||||
/// <summary>Scopes subsequent submissions on this thread to a named guest queue.</summary>
|
||||
IDisposable EnterGuestQueue(string queueName, ulong submissionId);
|
||||
|
||||
/// <summary>Enqueues an action at its exact position in the current guest queue;
|
||||
/// returns its work sequence, or 0 when nothing could be enqueued.</summary>
|
||||
long SubmitOrderedGuestAction(Action action, string debugName);
|
||||
|
||||
/// <summary>Preserves sceAgcDcbWaitUntilSafeForRendering in queue order.</summary>
|
||||
long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex);
|
||||
|
||||
/// <summary>Blocks until the given work sequence completes; false on timeout,
|
||||
/// close, or a non-positive sequence.</summary>
|
||||
bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite);
|
||||
|
||||
/// <summary>Sequence currently executing on the guest-work consumer; diagnostics only.</summary>
|
||||
long CurrentGuestWorkSequenceForDiagnostics { get; }
|
||||
|
||||
// Guest image lifecycle beyond presentation: CPU-visible seeding, writes, and
|
||||
// extent queries the AGC layer uses to keep guest memory and backend images
|
||||
// coherent. Addresses and formats are always raw guest values.
|
||||
|
||||
/// <summary>Whether the image exists on the backend or an already-queued upload
|
||||
/// owns its initialization (a pending image may skip a duplicate upload but is
|
||||
/// not yet a valid flip source).</summary>
|
||||
bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType);
|
||||
|
||||
/// <summary>True when the first draw into this address must seed the backend
|
||||
/// image from guest memory (PS5 render targets alias guest memory, so
|
||||
/// CPU-prefilled pixels are visible before the first draw).</summary>
|
||||
bool GuestImageWantsInitialData(ulong address);
|
||||
|
||||
void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels);
|
||||
|
||||
void SubmitGuestImageFill(ulong address, uint fillValue);
|
||||
|
||||
void SubmitGuestImageWrite(ulong address, byte[] pixels);
|
||||
|
||||
bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount);
|
||||
|
||||
IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents();
|
||||
|
||||
/// <summary>Whether the backend's texture cache already holds this content; lets
|
||||
/// the AGC layer skip copying texels out of guest memory on every draw.</summary>
|
||||
bool IsTextureContentCached(in TextureContentIdentity identity);
|
||||
|
||||
/// <summary>Guest memory handle for backend self-healing (cache misses re-read
|
||||
/// texels directly instead of showing a fallback pattern).</summary>
|
||||
void AttachGuestMemory(ICpuMemory memory);
|
||||
|
||||
/// <summary>Alignment the AGC layer must apply to storage-buffer offsets before
|
||||
/// they cross the seam.</summary>
|
||||
ulong GuestStorageBufferOffsetAlignment { get; }
|
||||
|
||||
/// <summary>Counts a guest shader translation for the perf overlay.</summary>
|
||||
void CountShaderCompilation();
|
||||
|
||||
(long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters();
|
||||
|
||||
/// <summary>Asks a running presenter to close its window.</summary>
|
||||
void RequestClose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Text;
|
||||
using SharpEmu.ShaderCompiler.Metal;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu.Metal;
|
||||
|
||||
/// <summary>
|
||||
/// The Metal backend's compiled shader: MSL source plus the reflection data
|
||||
/// (<see cref="Gen5MslShader"/>) the presenter needs to create and bind pipeline
|
||||
/// states. The diagnostics payload is the source text — Metal has no portable
|
||||
/// binary form until an MTLBinaryArchive is introduced.
|
||||
/// </summary>
|
||||
internal sealed class MetalCompiledGuestShader(Gen5MslShader shader) : IGuestCompiledShader
|
||||
{
|
||||
private byte[]? _payload;
|
||||
|
||||
public Gen5MslShader Shader { get; } = shader;
|
||||
|
||||
/// <summary>MTLLibrary handle cached by the presenter after the first
|
||||
/// runtime compile; the render loop is its only reader and writer.</summary>
|
||||
internal nint CachedLibrary;
|
||||
|
||||
public byte[] Payload => _payload ??= Encoding.UTF8.GetBytes(Shader.Source);
|
||||
|
||||
public string PayloadFileExtension => "msl";
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.ShaderCompiler;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu.Metal;
|
||||
|
||||
/// <summary>
|
||||
/// MTLPixelFormat raw values — only the formats the backend maps. Declared here
|
||||
/// rather than pulled from a binding package: the Metal backend talks to the OS
|
||||
/// exclusively through objc_msgSend, so ABI constants are owned locally.
|
||||
/// </summary>
|
||||
internal enum MtlPixelFormat : uint
|
||||
{
|
||||
Invalid = 0,
|
||||
R8Unorm = 10,
|
||||
R8Snorm = 12,
|
||||
R8Uint = 13,
|
||||
R8Sint = 14,
|
||||
R16Unorm = 20,
|
||||
R16Snorm = 22,
|
||||
R16Uint = 23,
|
||||
R16Sint = 24,
|
||||
R16Float = 25,
|
||||
Rg8Unorm = 30,
|
||||
Rg8Snorm = 32,
|
||||
Rg8Uint = 33,
|
||||
Rg8Sint = 34,
|
||||
B5G6R5Unorm = 40,
|
||||
R32Uint = 53,
|
||||
R32Sint = 54,
|
||||
R32Float = 55,
|
||||
Rg16Unorm = 60,
|
||||
Rg16Uint = 63,
|
||||
Rg16Sint = 64,
|
||||
Rg16Float = 65,
|
||||
Rgba8Unorm = 70,
|
||||
Rgba8UnormSrgb = 71,
|
||||
Rgba8Uint = 73,
|
||||
Rgba8Sint = 74,
|
||||
Bgra8Unorm = 80,
|
||||
Bgra8UnormSrgb = 81,
|
||||
Rgb10A2Unorm = 90,
|
||||
Rg11B10Float = 92,
|
||||
Rgb9E5Float = 93,
|
||||
Bgr10A2Unorm = 94,
|
||||
Rg32Uint = 103,
|
||||
Rg32Sint = 104,
|
||||
Rg32Float = 105,
|
||||
Rgba16Unorm = 110,
|
||||
Rgba16Uint = 113,
|
||||
Rgba16Sint = 114,
|
||||
Rgba16Float = 115,
|
||||
Rgba32Uint = 123,
|
||||
Rgba32Sint = 124,
|
||||
Rgba32Float = 125,
|
||||
Bc1Rgba = 130,
|
||||
Bc1RgbaSrgb = 131,
|
||||
Bc2Rgba = 132,
|
||||
Bc2RgbaSrgb = 133,
|
||||
Bc3Rgba = 134,
|
||||
Bc3RgbaSrgb = 135,
|
||||
Bc4RUnorm = 140,
|
||||
Bc4RSnorm = 141,
|
||||
Bc5RgUnorm = 142,
|
||||
Bc5RgSnorm = 143,
|
||||
Bc6HRgbFloat = 150,
|
||||
Bc6HRgbUfloat = 151,
|
||||
Bc7RgbaUnorm = 152,
|
||||
Bc7RgbaUnormSrgb = 153,
|
||||
Depth32Float = 252,
|
||||
}
|
||||
|
||||
/// <summary>A sampled-texture format: the Metal pixel format plus the byte
|
||||
/// layout the upload path needs. <see cref="BlockBytes"/> is nonzero for
|
||||
/// block-compressed formats (bytes per 4x4 block); otherwise
|
||||
/// <see cref="BytesPerPixel"/> applies.</summary>
|
||||
internal readonly record struct MetalTextureFormat(
|
||||
MtlPixelFormat Format,
|
||||
uint BytesPerPixel,
|
||||
uint BlockBytes)
|
||||
{
|
||||
public bool IsBlockCompressed => BlockBytes != 0;
|
||||
}
|
||||
|
||||
internal readonly record struct MetalRenderTargetFormat(
|
||||
MtlPixelFormat Format,
|
||||
Gen5PixelOutputKind OutputKind)
|
||||
{
|
||||
public static uint GetBytesPerPixel(MtlPixelFormat format) =>
|
||||
format switch
|
||||
{
|
||||
MtlPixelFormat.R8Unorm or MtlPixelFormat.R8Uint => 1,
|
||||
MtlPixelFormat.Rg8Unorm => 2,
|
||||
MtlPixelFormat.Rg32Float => 8,
|
||||
MtlPixelFormat.Rgba16Unorm or MtlPixelFormat.Rgba16Uint or
|
||||
MtlPixelFormat.Rgba16Sint or MtlPixelFormat.Rgba16Float => 8,
|
||||
MtlPixelFormat.Rgba32Float => 16,
|
||||
_ => 4,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guest texture-descriptor codes to Metal formats, mirroring the Vulkan
|
||||
/// backend's table case for case so both backends accept the same guest
|
||||
/// formats. Guest format 9 (2:10:10:10) maps to BGR10A2 — the bit layout that
|
||||
/// matches Vulkan's A2R10G10B10 pack.
|
||||
/// </summary>
|
||||
internal static class MetalGuestFormats
|
||||
{
|
||||
/// <summary>Guest sampled-texture format to Metal, mirroring the Vulkan
|
||||
/// backend's GetTextureFormat case for case (including its RGBA8 fallback
|
||||
/// for unmapped codes, so unknown formats render something rather than
|
||||
/// nothing). BC formats upload raw blocks — Mac-family GPUs decode them
|
||||
/// natively.</summary>
|
||||
public static MetalTextureFormat DecodeTextureFormat(uint dataFormat, uint numberType)
|
||||
{
|
||||
var format = (dataFormat, numberType) switch
|
||||
{
|
||||
(1, 0) => MtlPixelFormat.R8Unorm,
|
||||
(1, 1) => MtlPixelFormat.R8Snorm,
|
||||
(1, 4) => MtlPixelFormat.R8Uint,
|
||||
(1, 5) => MtlPixelFormat.R8Sint,
|
||||
(2, 0) => MtlPixelFormat.R16Unorm,
|
||||
(2, 1) => MtlPixelFormat.R16Snorm,
|
||||
(2, 4) => MtlPixelFormat.R16Uint,
|
||||
(2, 5) => MtlPixelFormat.R16Sint,
|
||||
(2, 7) => MtlPixelFormat.R16Float,
|
||||
(3, 0) => MtlPixelFormat.Rg8Unorm,
|
||||
(3, 1) => MtlPixelFormat.Rg8Snorm,
|
||||
(3, 4) => MtlPixelFormat.Rg8Uint,
|
||||
(3, 5) => MtlPixelFormat.Rg8Sint,
|
||||
(4, 4) => MtlPixelFormat.R32Uint,
|
||||
(4, 5) => MtlPixelFormat.R32Sint,
|
||||
(4, 7) => MtlPixelFormat.R32Float,
|
||||
(5, 0) => MtlPixelFormat.Rg16Unorm,
|
||||
(5, 4) => MtlPixelFormat.Rg16Uint,
|
||||
(5, 5) => MtlPixelFormat.Rg16Sint,
|
||||
(5, 7) => MtlPixelFormat.Rg16Float,
|
||||
(6, 7) or (7, 7) => MtlPixelFormat.Rg11B10Float,
|
||||
(8, _) or (9, _) => MtlPixelFormat.Bgr10A2Unorm,
|
||||
(10, 4) => MtlPixelFormat.Rgba8Uint,
|
||||
(10, 5) => MtlPixelFormat.Rgba8Sint,
|
||||
(10, 9) => MtlPixelFormat.Rgba8UnormSrgb,
|
||||
(11, 4) => MtlPixelFormat.Rg32Uint,
|
||||
(11, 5) => MtlPixelFormat.Rg32Sint,
|
||||
(11, 7) => MtlPixelFormat.Rg32Float,
|
||||
(12, 0) => MtlPixelFormat.Rgba16Unorm,
|
||||
(12, 4) => MtlPixelFormat.Rgba16Uint,
|
||||
(12, 5) => MtlPixelFormat.Rgba16Sint,
|
||||
(12, 7) => MtlPixelFormat.Rgba16Float,
|
||||
(13, 4) or (14, 4) => MtlPixelFormat.Rgba32Uint,
|
||||
(13, 5) or (14, 5) => MtlPixelFormat.Rgba32Sint,
|
||||
(13, _) or (14, _) => MtlPixelFormat.Rgba32Float,
|
||||
(16, 0) => MtlPixelFormat.B5G6R5Unorm,
|
||||
(34, 7) => MtlPixelFormat.Rgb9E5Float,
|
||||
(169, _) => MtlPixelFormat.Bc1Rgba,
|
||||
(170, _) => MtlPixelFormat.Bc1RgbaSrgb,
|
||||
(171, _) => MtlPixelFormat.Bc2Rgba,
|
||||
(172, _) => MtlPixelFormat.Bc2RgbaSrgb,
|
||||
(173, _) => MtlPixelFormat.Bc3Rgba,
|
||||
(174, _) => MtlPixelFormat.Bc3RgbaSrgb,
|
||||
(175, 1) or (176, _) => MtlPixelFormat.Bc4RSnorm,
|
||||
(175, _) => MtlPixelFormat.Bc4RUnorm,
|
||||
(177, 1) or (178, _) => MtlPixelFormat.Bc5RgSnorm,
|
||||
(177, _) => MtlPixelFormat.Bc5RgUnorm,
|
||||
(179, _) => MtlPixelFormat.Bc6HRgbUfloat,
|
||||
(180, _) => MtlPixelFormat.Bc6HRgbFloat,
|
||||
(181, _) => MtlPixelFormat.Bc7RgbaUnorm,
|
||||
(182, _) => MtlPixelFormat.Bc7RgbaUnormSrgb,
|
||||
_ => MtlPixelFormat.Rgba8Unorm,
|
||||
};
|
||||
|
||||
var blockBytes = format switch
|
||||
{
|
||||
MtlPixelFormat.Bc1Rgba or MtlPixelFormat.Bc1RgbaSrgb or
|
||||
MtlPixelFormat.Bc4RUnorm or MtlPixelFormat.Bc4RSnorm => 8u,
|
||||
MtlPixelFormat.Bc2Rgba or MtlPixelFormat.Bc2RgbaSrgb or
|
||||
MtlPixelFormat.Bc3Rgba or MtlPixelFormat.Bc3RgbaSrgb or
|
||||
MtlPixelFormat.Bc5RgUnorm or MtlPixelFormat.Bc5RgSnorm or
|
||||
MtlPixelFormat.Bc6HRgbFloat or MtlPixelFormat.Bc6HRgbUfloat or
|
||||
MtlPixelFormat.Bc7RgbaUnorm or MtlPixelFormat.Bc7RgbaUnormSrgb => 16u,
|
||||
_ => 0u,
|
||||
};
|
||||
|
||||
var bytesPerPixel = format switch
|
||||
{
|
||||
MtlPixelFormat.R8Unorm or MtlPixelFormat.R8Snorm or
|
||||
MtlPixelFormat.R8Uint or MtlPixelFormat.R8Sint => 1u,
|
||||
MtlPixelFormat.R16Unorm or MtlPixelFormat.R16Snorm or
|
||||
MtlPixelFormat.R16Uint or MtlPixelFormat.R16Sint or
|
||||
MtlPixelFormat.R16Float or MtlPixelFormat.Rg8Unorm or
|
||||
MtlPixelFormat.Rg8Snorm or MtlPixelFormat.Rg8Uint or
|
||||
MtlPixelFormat.Rg8Sint or MtlPixelFormat.B5G6R5Unorm => 2u,
|
||||
MtlPixelFormat.Rg32Uint or MtlPixelFormat.Rg32Sint or
|
||||
MtlPixelFormat.Rg32Float or MtlPixelFormat.Rgba16Unorm or
|
||||
MtlPixelFormat.Rgba16Uint or MtlPixelFormat.Rgba16Sint or
|
||||
MtlPixelFormat.Rgba16Float => 8u,
|
||||
MtlPixelFormat.Rgba32Uint or MtlPixelFormat.Rgba32Sint or
|
||||
MtlPixelFormat.Rgba32Float => 16u,
|
||||
_ => 4u,
|
||||
};
|
||||
|
||||
return new MetalTextureFormat(format, bytesPerPixel, blockBytes);
|
||||
}
|
||||
|
||||
/// <summary>Source byte footprint of a sampled texture, block-aware —
|
||||
/// the same math the AGC layer uses to size the texel copy it ships.</summary>
|
||||
public static ulong GetTextureByteCount(in MetalTextureFormat format, uint width, uint height) =>
|
||||
format.IsBlockCompressed
|
||||
? checked(((ulong)width + 3) / 4 * (((ulong)height + 3) / 4) * format.BlockBytes)
|
||||
: checked((ulong)width * height * format.BytesPerPixel);
|
||||
|
||||
public static bool TryDecodeRenderTargetFormat(
|
||||
uint dataFormat,
|
||||
uint numberType,
|
||||
out MetalRenderTargetFormat result)
|
||||
{
|
||||
var format = (dataFormat, numberType) switch
|
||||
{
|
||||
(4, 4) => MtlPixelFormat.R32Uint,
|
||||
(4, 5) => MtlPixelFormat.R32Sint,
|
||||
(4, 7) => MtlPixelFormat.R32Float,
|
||||
(5, 4) => MtlPixelFormat.Rg16Uint,
|
||||
(5, 5) => MtlPixelFormat.Rg16Sint,
|
||||
(5, 7) => MtlPixelFormat.Rg16Float,
|
||||
(6, 7) or (7, 7) => MtlPixelFormat.Rg11B10Float,
|
||||
(9, _) => MtlPixelFormat.Bgr10A2Unorm,
|
||||
(10, 4) => MtlPixelFormat.Rgba8Uint,
|
||||
(10, 5) => MtlPixelFormat.Rgba8Sint,
|
||||
(10, 9) => MtlPixelFormat.Rgba8UnormSrgb,
|
||||
(10, _) => MtlPixelFormat.Rgba8Unorm,
|
||||
(11, 7) => MtlPixelFormat.Rg32Float,
|
||||
(12, 4) => MtlPixelFormat.Rgba16Uint,
|
||||
(12, 5) => MtlPixelFormat.Rgba16Sint,
|
||||
(12, 7) => MtlPixelFormat.Rgba16Float,
|
||||
(13, 7) or (14, 7) => MtlPixelFormat.Rgba32Float,
|
||||
(20, 0) => MtlPixelFormat.R32Uint,
|
||||
(29, 0) or (4, 0) => MtlPixelFormat.R32Float,
|
||||
(1, 0) or (36, 0) => MtlPixelFormat.R8Unorm,
|
||||
(49, 0) => MtlPixelFormat.R8Uint,
|
||||
(3, 0) => MtlPixelFormat.Rg8Unorm,
|
||||
(5, 0) => MtlPixelFormat.Rg16Unorm,
|
||||
(7, 0) => MtlPixelFormat.Rg11B10Float,
|
||||
(12, 0) => MtlPixelFormat.Rgba16Unorm,
|
||||
(13, 0) or (14, 0) => MtlPixelFormat.Rgba32Float,
|
||||
(22, 0) or (71, 0) => MtlPixelFormat.Rgba16Float,
|
||||
(56, 0) or (62, 0) or (64, 0) => MtlPixelFormat.Rgba8Unorm,
|
||||
(75, 0) => MtlPixelFormat.Rg32Float,
|
||||
_ => MtlPixelFormat.Invalid,
|
||||
};
|
||||
|
||||
if (format == MtlPixelFormat.Invalid)
|
||||
{
|
||||
result = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
var outputKind = format switch
|
||||
{
|
||||
MtlPixelFormat.R8Uint or MtlPixelFormat.R32Uint or MtlPixelFormat.Rg16Uint or
|
||||
MtlPixelFormat.Rgba8Uint or MtlPixelFormat.Rgba16Uint => Gen5PixelOutputKind.Uint,
|
||||
MtlPixelFormat.R32Sint or MtlPixelFormat.Rg16Sint or MtlPixelFormat.Rgba8Sint or
|
||||
MtlPixelFormat.Rgba16Sint => Gen5PixelOutputKind.Sint,
|
||||
_ => Gen5PixelOutputKind.Float,
|
||||
};
|
||||
result = new MetalRenderTargetFormat(format, outputKind);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.ShaderCompiler;
|
||||
using SharpEmu.ShaderCompiler.Metal;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu.Metal;
|
||||
|
||||
/// <summary>
|
||||
/// Metal backend for the guest-GPU seam: MSL codegen via
|
||||
/// SharpEmu.ShaderCompiler.Metal, rendering via the Metal presenter — the full
|
||||
/// surface (presentation, guest images, ordered flips, translated draws, and
|
||||
/// compute) with no Vulkan, MoltenVK, or windowing-library dependency.
|
||||
/// </summary>
|
||||
internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
|
||||
{
|
||||
public string BackendName => "Metal";
|
||||
|
||||
private static readonly IGuestCompiledShader DepthOnlyFragmentShader =
|
||||
new MetalCompiledGuestShader(new Gen5MslShader(
|
||||
MslFixedShaders.CreateDepthOnlyFragment(),
|
||||
"depth_only_fs",
|
||||
Gen5MslStage.Pixel,
|
||||
[],
|
||||
[],
|
||||
AttributeCount: 0,
|
||||
[]));
|
||||
|
||||
public bool TryCompileVertexShader(
|
||||
Gen5ShaderState state,
|
||||
Gen5ShaderEvaluation evaluation,
|
||||
out IGuestCompiledShader? shader,
|
||||
out string error,
|
||||
int globalBufferBase = 0,
|
||||
int totalGlobalBufferCount = -1,
|
||||
int imageBindingBase = 0,
|
||||
int scalarRegisterBufferIndex = -1,
|
||||
int requiredVertexOutputCount = 0,
|
||||
ulong storageBufferOffsetAlignment = 1)
|
||||
{
|
||||
shader = null;
|
||||
if (!Gen5MslTranslator.TryCompileVertexShader(
|
||||
state,
|
||||
evaluation,
|
||||
out var compiled,
|
||||
out error,
|
||||
globalBufferBase,
|
||||
totalGlobalBufferCount,
|
||||
imageBindingBase,
|
||||
scalarRegisterBufferIndex,
|
||||
requiredVertexOutputCount,
|
||||
storageBufferOffsetAlignment))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
shader = new MetalCompiledGuestShader(compiled);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryCompilePixelShader(
|
||||
Gen5ShaderState state,
|
||||
Gen5ShaderEvaluation evaluation,
|
||||
IReadOnlyList<Gen5PixelOutputBinding> outputs,
|
||||
out IGuestCompiledShader? shader,
|
||||
out string error,
|
||||
int globalBufferBase = 0,
|
||||
int totalGlobalBufferCount = -1,
|
||||
int imageBindingBase = 0,
|
||||
int scalarRegisterBufferIndex = -1,
|
||||
uint pixelInputEnable = 0,
|
||||
uint pixelInputAddress = 0,
|
||||
ulong storageBufferOffsetAlignment = 1)
|
||||
{
|
||||
shader = null;
|
||||
if (!Gen5MslTranslator.TryCompilePixelShader(
|
||||
state,
|
||||
evaluation,
|
||||
outputs,
|
||||
out var compiled,
|
||||
out error,
|
||||
globalBufferBase,
|
||||
totalGlobalBufferCount,
|
||||
imageBindingBase,
|
||||
scalarRegisterBufferIndex,
|
||||
pixelInputEnable,
|
||||
pixelInputAddress,
|
||||
storageBufferOffsetAlignment))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
shader = new MetalCompiledGuestShader(compiled);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryCompileComputeShader(
|
||||
Gen5ShaderState state,
|
||||
Gen5ShaderEvaluation evaluation,
|
||||
uint localSizeX,
|
||||
uint localSizeY,
|
||||
uint localSizeZ,
|
||||
out IGuestCompiledShader? shader,
|
||||
out string error,
|
||||
int totalGlobalBufferCount = -1,
|
||||
int initialScalarBufferIndex = -1,
|
||||
uint waveLaneCount = 32,
|
||||
ulong storageBufferOffsetAlignment = 1)
|
||||
{
|
||||
shader = null;
|
||||
// Wave64 compute is emulated by the translator: cross-lane ops bridge
|
||||
// the two 32-wide Apple simdgroups of a guest wave through threadgroup
|
||||
// scratch, and wave-agnostic kernels run per-thread unchanged.
|
||||
if (!Gen5MslTranslator.TryCompileComputeShader(
|
||||
state,
|
||||
evaluation,
|
||||
localSizeX,
|
||||
localSizeY,
|
||||
localSizeZ,
|
||||
out var compiled,
|
||||
out error,
|
||||
totalGlobalBufferCount,
|
||||
initialScalarBufferIndex,
|
||||
waveLaneCount,
|
||||
storageBufferOffsetAlignment))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
shader = new MetalCompiledGuestShader(compiled);
|
||||
return true;
|
||||
}
|
||||
|
||||
public IGuestCompiledShader GetDepthOnlyFragmentShader() =>
|
||||
DepthOnlyFragmentShader;
|
||||
|
||||
public bool TryGetRenderTargetOutputKind(uint dataFormat, uint numberType, out Gen5PixelOutputKind outputKind)
|
||||
{
|
||||
if (MetalGuestFormats.TryDecodeRenderTargetFormat(dataFormat, numberType, out var format))
|
||||
{
|
||||
outputKind = format.OutputKind;
|
||||
return true;
|
||||
}
|
||||
|
||||
outputKind = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void EnsureStarted(uint width, uint height) =>
|
||||
MetalVideoPresenter.EnsureStarted(width, height);
|
||||
|
||||
public void HideSplashScreen() =>
|
||||
MetalVideoPresenter.HideSplashScreen();
|
||||
|
||||
public void Submit(byte[] bgraFrame, uint width, uint height) =>
|
||||
MetalVideoPresenter.Submit(bgraFrame, width, height);
|
||||
|
||||
public bool TrySubmitGuestImage(
|
||||
ulong address,
|
||||
uint width,
|
||||
uint height,
|
||||
uint pitchInPixel) =>
|
||||
MetalVideoPresenter.TrySubmitGuestImage(address, width, height, pitchInPixel);
|
||||
|
||||
public bool TrySubmitOrderedGuestImageFlip(
|
||||
int videoOutHandle,
|
||||
int displayBufferIndex,
|
||||
ulong address,
|
||||
uint width,
|
||||
uint height,
|
||||
uint pitchInPixel) =>
|
||||
MetalVideoPresenter.TrySubmitOrderedGuestImageFlip(
|
||||
videoOutHandle,
|
||||
displayBufferIndex,
|
||||
address,
|
||||
width,
|
||||
height,
|
||||
pitchInPixel);
|
||||
|
||||
public void RegisterKnownDisplayBuffer(ulong address, uint guestFormat) =>
|
||||
MetalVideoPresenter.RegisterKnownDisplayBuffer(address, guestFormat);
|
||||
|
||||
public bool IsGpuGuestImageAvailable(ulong address, uint format, uint numberType) =>
|
||||
MetalVideoPresenter.IsGuestImageAvailable(address, format, numberType);
|
||||
|
||||
public bool TrySubmitGuestImageBlit(
|
||||
ulong sourceAddress,
|
||||
uint sourceWidth,
|
||||
uint sourceHeight,
|
||||
uint sourceFormat,
|
||||
uint sourceNumberType,
|
||||
ulong destinationAddress,
|
||||
uint destinationWidth,
|
||||
uint destinationHeight,
|
||||
uint destinationFormat,
|
||||
uint destinationNumberType) =>
|
||||
MetalVideoPresenter.TrySubmitGuestImageBlit(
|
||||
sourceAddress,
|
||||
sourceWidth,
|
||||
sourceHeight,
|
||||
sourceFormat,
|
||||
sourceNumberType,
|
||||
destinationAddress,
|
||||
destinationWidth,
|
||||
destinationHeight,
|
||||
destinationFormat,
|
||||
destinationNumberType);
|
||||
|
||||
public void SubmitGuestDraw(GuestDrawKind drawKind, uint width, uint height) =>
|
||||
MetalVideoPresenter.SubmitGuestDraw(drawKind, width, height);
|
||||
|
||||
public void SubmitTranslatedDraw(
|
||||
IGuestCompiledShader pixelShader,
|
||||
IReadOnlyList<GuestDrawTexture> textures,
|
||||
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
|
||||
uint width,
|
||||
uint height,
|
||||
uint attributeCount,
|
||||
IGuestCompiledShader? vertexShader = null,
|
||||
uint vertexCount = 3,
|
||||
uint instanceCount = 1,
|
||||
uint primitiveType = 4,
|
||||
GuestIndexBuffer? indexBuffer = null,
|
||||
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
||||
GuestRenderState? renderState = null) =>
|
||||
MetalVideoPresenter.SubmitTranslatedDraw(
|
||||
Msl(pixelShader),
|
||||
textures,
|
||||
globalMemoryBuffers,
|
||||
width,
|
||||
height,
|
||||
attributeCount,
|
||||
vertexShader is null ? null : Msl(vertexShader),
|
||||
vertexCount,
|
||||
instanceCount,
|
||||
primitiveType,
|
||||
indexBuffer,
|
||||
vertexBuffers,
|
||||
renderState);
|
||||
|
||||
public void SubmitDepthOnlyTranslatedDraw(
|
||||
IGuestCompiledShader pixelShader,
|
||||
IReadOnlyList<GuestDrawTexture> textures,
|
||||
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
|
||||
uint attributeCount,
|
||||
GuestDepthTarget depthTarget,
|
||||
IGuestCompiledShader? vertexShader = null,
|
||||
uint vertexCount = 3,
|
||||
uint instanceCount = 1,
|
||||
uint primitiveType = 4,
|
||||
GuestIndexBuffer? indexBuffer = null,
|
||||
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
||||
GuestRenderState? renderState = null,
|
||||
ulong shaderAddress = 0) =>
|
||||
MetalVideoPresenter.SubmitDepthOnlyTranslatedDraw(
|
||||
Msl(pixelShader),
|
||||
textures,
|
||||
globalMemoryBuffers,
|
||||
attributeCount,
|
||||
depthTarget,
|
||||
vertexShader is null ? null : Msl(vertexShader),
|
||||
vertexCount,
|
||||
instanceCount,
|
||||
primitiveType,
|
||||
indexBuffer,
|
||||
vertexBuffers,
|
||||
renderState,
|
||||
shaderAddress);
|
||||
|
||||
public void SubmitOffscreenTranslatedDraw(
|
||||
IGuestCompiledShader pixelShader,
|
||||
IReadOnlyList<GuestDrawTexture> textures,
|
||||
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
|
||||
uint attributeCount,
|
||||
IReadOnlyList<GuestRenderTarget> targets,
|
||||
IGuestCompiledShader? vertexShader = null,
|
||||
uint vertexCount = 3,
|
||||
uint instanceCount = 1,
|
||||
uint primitiveType = 4,
|
||||
GuestIndexBuffer? indexBuffer = null,
|
||||
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
||||
GuestRenderState? renderState = null,
|
||||
GuestDepthTarget? depthTarget = null,
|
||||
ulong shaderAddress = 0) =>
|
||||
MetalVideoPresenter.SubmitOffscreenTranslatedDraw(
|
||||
Msl(pixelShader),
|
||||
textures,
|
||||
globalMemoryBuffers,
|
||||
attributeCount,
|
||||
targets,
|
||||
vertexShader is null ? null : Msl(vertexShader),
|
||||
vertexCount,
|
||||
instanceCount,
|
||||
primitiveType,
|
||||
indexBuffer,
|
||||
vertexBuffers,
|
||||
renderState,
|
||||
depthTarget,
|
||||
shaderAddress);
|
||||
|
||||
public void SubmitStorageTranslatedDraw(
|
||||
IGuestCompiledShader pixelShader,
|
||||
IReadOnlyList<GuestDrawTexture> textures,
|
||||
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
|
||||
uint attributeCount,
|
||||
uint width,
|
||||
uint height,
|
||||
ulong shaderAddress = 0) =>
|
||||
MetalVideoPresenter.SubmitStorageTranslatedDraw(
|
||||
Msl(pixelShader),
|
||||
textures,
|
||||
globalMemoryBuffers,
|
||||
attributeCount,
|
||||
width,
|
||||
height,
|
||||
shaderAddress);
|
||||
|
||||
private static MetalCompiledGuestShader Msl(IGuestCompiledShader shader) =>
|
||||
shader as MetalCompiledGuestShader ??
|
||||
throw new InvalidOperationException(
|
||||
$"shader handle of type {shader.GetType().Name} was not compiled by the Metal backend");
|
||||
|
||||
public long SubmitComputeDispatch(
|
||||
ulong shaderAddress,
|
||||
IGuestCompiledShader computeShader,
|
||||
IReadOnlyList<GuestDrawTexture> textures,
|
||||
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
|
||||
uint groupCountX,
|
||||
uint groupCountY,
|
||||
uint groupCountZ,
|
||||
uint baseGroupX,
|
||||
uint baseGroupY,
|
||||
uint baseGroupZ,
|
||||
uint localSizeX,
|
||||
uint localSizeY,
|
||||
uint localSizeZ,
|
||||
bool isIndirect,
|
||||
bool writesGlobalMemory,
|
||||
uint threadCountX = uint.MaxValue,
|
||||
uint threadCountY = uint.MaxValue,
|
||||
uint threadCountZ = uint.MaxValue)
|
||||
{
|
||||
// The translated kernel bakes its threadgroup size; localSize and
|
||||
// isIndirect are already folded in by the AGC layer before submission.
|
||||
_ = localSizeX;
|
||||
_ = localSizeY;
|
||||
_ = localSizeZ;
|
||||
_ = isIndirect;
|
||||
return MetalVideoPresenter.SubmitComputeDispatch(
|
||||
shaderAddress,
|
||||
Msl(computeShader),
|
||||
textures,
|
||||
globalMemoryBuffers,
|
||||
groupCountX,
|
||||
groupCountY,
|
||||
groupCountZ,
|
||||
baseGroupX,
|
||||
baseGroupY,
|
||||
baseGroupZ,
|
||||
writesGlobalMemory,
|
||||
threadCountX,
|
||||
threadCountY,
|
||||
threadCountZ);
|
||||
}
|
||||
|
||||
private long _perfShaderCompilations;
|
||||
|
||||
public IDisposable EnterGuestQueue(string queueName, ulong submissionId) =>
|
||||
MetalVideoPresenter.EnterGuestQueue(queueName, submissionId);
|
||||
|
||||
public long SubmitOrderedGuestAction(Action action, string debugName) =>
|
||||
MetalVideoPresenter.SubmitOrderedGuestAction(action, debugName);
|
||||
|
||||
public long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex) =>
|
||||
MetalVideoPresenter.SubmitOrderedGuestFlipWait(videoOutHandle, displayBufferIndex);
|
||||
|
||||
public bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite) =>
|
||||
MetalVideoPresenter.WaitForGuestWork(workSequence, timeoutMilliseconds);
|
||||
|
||||
public long CurrentGuestWorkSequenceForDiagnostics =>
|
||||
MetalVideoPresenter.CurrentGuestWorkSequenceForDiagnostics;
|
||||
|
||||
public bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType) =>
|
||||
MetalVideoPresenter.IsGuestImageUploadKnown(address, format, numberType);
|
||||
|
||||
public bool GuestImageWantsInitialData(ulong address) =>
|
||||
MetalVideoPresenter.GuestImageWantsInitialData(address);
|
||||
|
||||
public void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels) =>
|
||||
MetalVideoPresenter.ProvideGuestImageInitialData(address, rgbaPixels);
|
||||
|
||||
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
|
||||
MetalVideoPresenter.SubmitGuestImageFill(address, fillValue);
|
||||
|
||||
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
|
||||
MetalVideoPresenter.SubmitGuestImageWrite(address, pixels);
|
||||
|
||||
public bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) =>
|
||||
MetalVideoPresenter.TryGetGuestImageExtent(address, out width, out height, out byteCount);
|
||||
|
||||
public IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents() =>
|
||||
MetalVideoPresenter.GetGuestImageExtents();
|
||||
|
||||
public bool IsTextureContentCached(in TextureContentIdentity identity) =>
|
||||
MetalVideoPresenter.IsTextureContentCached(identity);
|
||||
|
||||
public void AttachGuestMemory(SharpEmu.HLE.ICpuMemory memory) =>
|
||||
MetalVideoPresenter.AttachGuestMemory(memory);
|
||||
|
||||
// Over-alignment is always valid, and 256 covers every Metal buffer-offset
|
||||
// requirement (Intel Macs need 256 for constant buffers; Apple GPUs less).
|
||||
public ulong GuestStorageBufferOffsetAlignment => 256;
|
||||
|
||||
public void CountShaderCompilation() =>
|
||||
Interlocked.Increment(ref _perfShaderCompilations);
|
||||
|
||||
public (long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters()
|
||||
{
|
||||
var (draws, drawMs, pipelines) = MetalVideoPresenter.ReadAndResetDrawPerfCounters();
|
||||
return (draws, drawMs, pipelines, Interlocked.Exchange(ref _perfShaderCompilations, 0));
|
||||
}
|
||||
|
||||
public void RequestClose() =>
|
||||
MetalVideoPresenter.RequestClose();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE.Host;
|
||||
using SharpEmu.HLE.Host.Posix;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu.Metal;
|
||||
|
||||
/// <summary>
|
||||
/// Keyboard state sampled from the Metal presenter's window, feeding the POSIX
|
||||
/// host input seam so pad emulation works like the Vulkan presenter's
|
||||
/// HostWindowInput. Key events arrive on the AppKit main thread as macOS
|
||||
/// virtual key codes; pad reads happen on guest threads, so state is guarded.
|
||||
/// Window gamepads are not surfaced by AppKit — controller support would go
|
||||
/// through GameController.framework and is out of scope here.
|
||||
/// </summary>
|
||||
internal static class MetalHostInput
|
||||
{
|
||||
private static readonly object Gate = new();
|
||||
private static readonly HashSet<ushort> Pressed = new();
|
||||
private static volatile bool _connected;
|
||||
|
||||
/// <summary>Registers this window's keyboard as the host input source.</summary>
|
||||
public static void Attach()
|
||||
{
|
||||
_connected = true;
|
||||
PosixHostInput.SetSource(new MetalWindowInputSource());
|
||||
Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
|
||||
}
|
||||
|
||||
// Debug automation: SHARPEMU_METAL_AUTOKEY="12:0x24,15:0x24" presses the
|
||||
// macOS key code at each elapsed-seconds mark for a few frames, letting
|
||||
// headless test runs navigate menus without a human at the keyboard.
|
||||
private static readonly List<(double At, ushort Key, bool[] State)> _autoKeys = ParseAutoKeys();
|
||||
private static readonly System.Diagnostics.Stopwatch _autoKeyClock =
|
||||
System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
private static List<(double, ushort, bool[])> ParseAutoKeys()
|
||||
{
|
||||
var keys = new List<(double, ushort, bool[])>();
|
||||
var spec = Environment.GetEnvironmentVariable("SHARPEMU_METAL_AUTOKEY");
|
||||
if (string.IsNullOrWhiteSpace(spec))
|
||||
{
|
||||
return keys;
|
||||
}
|
||||
|
||||
foreach (var entry in spec.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var parts = entry.Split(':');
|
||||
if (parts.Length == 2 &&
|
||||
double.TryParse(parts[0], out var at) &&
|
||||
TryParseKeyCode(parts[1], out var key))
|
||||
{
|
||||
keys.Add((at, key, new bool[2]));
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static bool TryParseKeyCode(string text, out ushort key)
|
||||
{
|
||||
return text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||
? ushort.TryParse(text[2..], System.Globalization.NumberStyles.HexNumber, null, out key)
|
||||
: ushort.TryParse(text, out key);
|
||||
}
|
||||
|
||||
/// <summary>Called once per render frame; fires and releases scripted keys.</summary>
|
||||
public static void PumpAutoKeys()
|
||||
{
|
||||
if (_autoKeys.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var elapsed = _autoKeyClock.Elapsed.TotalSeconds;
|
||||
foreach (var (at, key, state) in _autoKeys)
|
||||
{
|
||||
if (!state[0] && elapsed >= at)
|
||||
{
|
||||
state[0] = true;
|
||||
KeyDown(key, isRepeat: false);
|
||||
Console.Error.WriteLine($"[LOADER][INFO] Metal autokey press 0x{key:X} at {elapsed:F1}s");
|
||||
}
|
||||
else if (state[0] && !state[1] && elapsed >= at + 0.2)
|
||||
{
|
||||
state[1] = true;
|
||||
KeyUp(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void KeyDown(ushort keyCode, bool isRepeat)
|
||||
{
|
||||
// kVK_F1: parity with the Vulkan window's perf-overlay toggle.
|
||||
if (keyCode == 0x7A && !isRepeat)
|
||||
{
|
||||
VideoOut.PerfOverlay.Toggle();
|
||||
}
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
Pressed.Add(keyCode);
|
||||
}
|
||||
}
|
||||
|
||||
public static void KeyUp(ushort keyCode)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
Pressed.Remove(keyCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsKeyCodeDown(ushort keyCode)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
return Pressed.Contains(keyCode);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MetalWindowInputSource : IPosixWindowInputSource
|
||||
{
|
||||
public bool HasKeyboardFocus => _connected;
|
||||
|
||||
public bool IsKeyDown(int virtualKey) =>
|
||||
TryMapVirtualKey(virtualKey, out var keyCode) && IsKeyCodeDown(keyCode);
|
||||
|
||||
public int GetGamepadStates(Span<HostGamepadState> destination) => 0;
|
||||
|
||||
public string? DescribeConnectedGamepad() => null;
|
||||
}
|
||||
|
||||
/// <summary>Windows virtual-key semantics (the seam's contract) to macOS
|
||||
/// kVK virtual key codes, covering the keys pad emulation polls.</summary>
|
||||
private static bool TryMapVirtualKey(int vk, out ushort keyCode)
|
||||
{
|
||||
keyCode = vk switch
|
||||
{
|
||||
0x08 => 0x33, // Backspace -> kVK_Delete
|
||||
0x09 => 0x30, // Tab
|
||||
0x0D => 0x24, // Enter -> kVK_Return
|
||||
0x1B => 0x35, // Escape
|
||||
0x20 => 0x31, // Space
|
||||
0x25 => 0x7B, // Left
|
||||
0x26 => 0x7E, // Up
|
||||
0x27 => 0x7C, // Right
|
||||
0x28 => 0x7D, // Down
|
||||
// Letters: macOS ANSI key codes are layout-position based and
|
||||
// non-contiguous, so map each polled letter explicitly.
|
||||
0x41 => 0x00, // A
|
||||
0x42 => 0x0B, // B
|
||||
0x43 => 0x08, // C
|
||||
0x44 => 0x02, // D
|
||||
0x45 => 0x0E, // E
|
||||
0x46 => 0x03, // F
|
||||
0x47 => 0x05, // G
|
||||
0x48 => 0x04, // H
|
||||
0x49 => 0x22, // I
|
||||
0x4A => 0x26, // J
|
||||
0x4B => 0x28, // K
|
||||
0x4C => 0x25, // L
|
||||
0x4D => 0x2E, // M
|
||||
0x4E => 0x2D, // N
|
||||
0x4F => 0x1F, // O
|
||||
0x50 => 0x23, // P
|
||||
0x51 => 0x0C, // Q
|
||||
0x52 => 0x0F, // R
|
||||
0x53 => 0x01, // S
|
||||
0x54 => 0x11, // T
|
||||
0x55 => 0x20, // U
|
||||
0x56 => 0x09, // V
|
||||
0x57 => 0x0D, // W
|
||||
0x58 => 0x07, // X
|
||||
0x59 => 0x10, // Y
|
||||
0x5A => 0x06, // Z
|
||||
_ => ushort.MaxValue,
|
||||
};
|
||||
return keyCode != ushort.MaxValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu.Metal;
|
||||
|
||||
// Core Graphics / Metal ABI structs passed by value through objc_msgSend. Struct
|
||||
// *returns* are deliberately never used: on x86-64 (this process runs under Rosetta
|
||||
// on Apple silicon) large struct returns switch to objc_msgSend_stret, and avoiding
|
||||
// them entirely keeps one calling convention everywhere.
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct CGRect
|
||||
{
|
||||
public double X;
|
||||
public double Y;
|
||||
public double Width;
|
||||
public double Height;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct CGSize
|
||||
{
|
||||
public double Width;
|
||||
public double Height;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct MtlClearColor
|
||||
{
|
||||
public double Red;
|
||||
public double Green;
|
||||
public double Blue;
|
||||
public double Alpha;
|
||||
}
|
||||
|
||||
/// <summary>MTLTextureSwizzleChannels: one MTLTextureSwizzle byte per output
|
||||
/// channel (Zero=0, One=1, Red=2, Green=3, Blue=4, Alpha=5).</summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct MtlTextureSwizzleChannels
|
||||
{
|
||||
public byte Red;
|
||||
public byte Green;
|
||||
public byte Blue;
|
||||
public byte Alpha;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct MtlRegion
|
||||
{
|
||||
public nuint X;
|
||||
public nuint Y;
|
||||
public nuint Z;
|
||||
public nuint Width;
|
||||
public nuint Height;
|
||||
public nuint Depth;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct MtlSize
|
||||
{
|
||||
public nuint Width;
|
||||
public nuint Height;
|
||||
public nuint Depth;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct MtlOrigin
|
||||
{
|
||||
public nuint X;
|
||||
public nuint Y;
|
||||
public nuint Z;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct MtlScissorRect
|
||||
{
|
||||
public nuint X;
|
||||
public nuint Y;
|
||||
public nuint Width;
|
||||
public nuint Height;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct MtlViewport
|
||||
{
|
||||
public double OriginX;
|
||||
public double OriginY;
|
||||
public double Width;
|
||||
public double Height;
|
||||
public double ZNear;
|
||||
public double ZFar;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Objective-C runtime access for the Metal presenter: AppKit, QuartzCore, and Metal
|
||||
/// through objc_msgSend, with one LibraryImport overload per distinct native
|
||||
/// signature. Dependency-free by design — this plus the OS frameworks is the entire
|
||||
/// Metal path, which is what keeps it NativeAOT-clean.
|
||||
/// </summary>
|
||||
internal static partial class MetalNative
|
||||
{
|
||||
private const string CoreFoundation =
|
||||
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
|
||||
|
||||
[LibraryImport(CoreFoundation)]
|
||||
public static partial nint CFRunLoopGetMain();
|
||||
|
||||
[LibraryImport(CoreFoundation)]
|
||||
public static partial void CFRunLoopStop(nint runLoop);
|
||||
|
||||
private const string ObjCLibrary = "/usr/lib/libobjc.A.dylib";
|
||||
private const string MetalFramework = "/System/Library/Frameworks/Metal.framework/Metal";
|
||||
private const string AppKitFramework = "/System/Library/Frameworks/AppKit.framework/AppKit";
|
||||
private const string QuartzCoreFramework = "/System/Library/Frameworks/QuartzCore.framework/QuartzCore";
|
||||
|
||||
private static bool _frameworksLoaded;
|
||||
|
||||
/// <summary>
|
||||
/// Makes the AppKit and QuartzCore classes visible to objc_getClass; Metal is
|
||||
/// pulled in by its own LibraryImport. Call once before any Class() lookup.
|
||||
/// </summary>
|
||||
public static void EnsureFrameworksLoaded()
|
||||
{
|
||||
if (_frameworksLoaded)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NativeLibrary.Load(AppKitFramework);
|
||||
NativeLibrary.Load(QuartzCoreFramework);
|
||||
_frameworksLoaded = true;
|
||||
}
|
||||
|
||||
[LibraryImport(MetalFramework)]
|
||||
public static partial nint MTLCreateSystemDefaultDevice();
|
||||
|
||||
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
|
||||
private static partial nint objc_getClass(string name);
|
||||
|
||||
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
|
||||
private static partial nint sel_registerName(string name);
|
||||
|
||||
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
|
||||
public static partial nint objc_allocateClassPair(nint superclass, string name, nuint extraBytes);
|
||||
|
||||
[LibraryImport(ObjCLibrary)]
|
||||
public static partial void objc_registerClassPair(nint cls);
|
||||
|
||||
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
|
||||
[return: MarshalAs(UnmanagedType.I1)]
|
||||
public static partial bool class_addMethod(nint cls, nint name, nint imp, string types);
|
||||
|
||||
[LibraryImport(ObjCLibrary)]
|
||||
public static partial nint objc_autoreleasePoolPush();
|
||||
|
||||
[LibraryImport(ObjCLibrary)]
|
||||
public static partial void objc_autoreleasePoolPop(nint pool);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial nint Send(nint receiver, nint selector);
|
||||
|
||||
/// <summary>objc_msgSend for -gpuResourceID. MTLResourceID is a one-field
|
||||
/// 8-byte struct, returned in a register on the x86-64 ABI, so it maps to a
|
||||
/// ulong return — the value written into a Tier 2 argument buffer slot.</summary>
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial ulong SendGpuResourceId(nint receiver, nint selector);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial nint Send(nint receiver, nint selector, nint argument);
|
||||
|
||||
|
||||
/// <summary>objc_msgSend for a CGRect-returning selector (e.g. -bounds).
|
||||
/// A 32-byte struct is returned via the x86-64 stret ABI — a hidden
|
||||
/// pointer to caller storage passed ahead of self/_cmd — so this must not
|
||||
/// be folded into the plain objc_msgSend overloads.</summary>
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend_stret")]
|
||||
public static partial void SendStretRect(out CGRect result, nint receiver, nint selector);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial nint Send(nint receiver, nint selector, nint argument, ref nint error);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial nint Send(nint receiver, nint selector, nint argument0, nint argument1, ref nint error);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial nint SendAtIndex(nint receiver, nint selector, nuint index);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
[return: MarshalAs(UnmanagedType.I1)]
|
||||
public static partial bool SendBool(nint receiver, nint selector);
|
||||
|
||||
/// <summary>One-argument BOOL sends, e.g. respondsToSelector:.</summary>
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
[return: MarshalAs(UnmanagedType.I1)]
|
||||
public static partial bool SendBool(nint receiver, nint selector, nint argument);
|
||||
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial double SendDouble(nint receiver, nint selector);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendVoid(nint receiver, nint selector);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendVoid(nint receiver, nint selector, nint argument);
|
||||
|
||||
/// <summary>Two-object-argument void sends, e.g. setObject:forKey:.</summary>
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendVoid(nint receiver, nint selector, nint argument0, nint argument1);
|
||||
|
||||
/// <summary>performSelectorOnMainThread:withObject:waitUntilDone: — the SEL
|
||||
/// to perform is itself an argument, followed by the object and the wait
|
||||
/// flag.</summary>
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendVoidPerformSelector(
|
||||
nint receiver,
|
||||
nint selector,
|
||||
nint performedSelector,
|
||||
nint argument,
|
||||
[MarshalAs(UnmanagedType.I1)] bool waitUntilDone);
|
||||
|
||||
/// <summary>setSwizzle: on MTLTextureDescriptor. Four one-byte
|
||||
/// MTLTextureSwizzle values, passed packed like the framework expects.</summary>
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendVoidSwizzle(
|
||||
nint receiver,
|
||||
nint selector,
|
||||
MtlTextureSwizzleChannels channels);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendVoidBool(nint receiver, nint selector, [MarshalAs(UnmanagedType.I1)] bool argument);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendVoidDouble(nint receiver, nint selector, double argument);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendVoidSize(nint receiver, nint selector, CGSize size);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendVoidRect(nint receiver, nint selector, CGRect rect);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendVoidClearColor(nint receiver, nint selector, MtlClearColor color);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendVoidBlendColor(
|
||||
nint receiver,
|
||||
nint selector,
|
||||
float red,
|
||||
float green,
|
||||
float blue,
|
||||
float alpha);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendVoidViewport(nint receiver, nint selector, MtlViewport viewport);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendSetAtIndex(nint receiver, nint selector, nint value, nuint index);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendVoidCopyTexture(nint receiver, nint selector, nint source, nint destination);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial nint SendBuffer(nint receiver, nint selector, nint bytes, nuint length, nuint options);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial nint SendNewBuffer(nint receiver, nint selector, nuint length, nuint options);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendCopyTextureToBuffer(
|
||||
nint receiver,
|
||||
nint selector,
|
||||
nint sourceTexture,
|
||||
nuint sourceSlice,
|
||||
nuint sourceLevel,
|
||||
MtlOrigin sourceOrigin,
|
||||
MtlSize sourceSize,
|
||||
nint destinationBuffer,
|
||||
nuint destinationOffset,
|
||||
nuint destinationBytesPerRow,
|
||||
nuint destinationBytesPerImage);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendCopyBufferToTexture(
|
||||
nint receiver,
|
||||
nint selector,
|
||||
nint sourceBuffer,
|
||||
nuint sourceOffset,
|
||||
nuint sourceBytesPerRow,
|
||||
nuint sourceBytesPerImage,
|
||||
MtlSize sourceSize,
|
||||
nint destinationTexture,
|
||||
nuint destinationSlice,
|
||||
nuint destinationLevel,
|
||||
MtlOrigin destinationOrigin);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendDispatch(
|
||||
nint receiver,
|
||||
nint selector,
|
||||
MtlSize threadgroups,
|
||||
MtlSize threadsPerThreadgroup);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendSetBuffer(nint receiver, nint selector, nint buffer, nuint offset, nuint index);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendVoidScissor(nint receiver, nint selector, MtlScissorRect rect);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendDrawPrimitivesInstanced(
|
||||
nint receiver,
|
||||
nint selector,
|
||||
nuint primitiveType,
|
||||
nuint vertexStart,
|
||||
nuint vertexCount,
|
||||
nuint instanceCount);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendDrawIndexedPrimitives(
|
||||
nint receiver,
|
||||
nint selector,
|
||||
nuint primitiveType,
|
||||
nuint indexCount,
|
||||
nuint indexType,
|
||||
nint indexBuffer,
|
||||
nuint indexBufferOffset,
|
||||
nuint instanceCount);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial nint SendTimer(
|
||||
nint receiver,
|
||||
nint selector,
|
||||
double interval,
|
||||
nint target,
|
||||
nint timerSelector,
|
||||
nint userInfo,
|
||||
[MarshalAs(UnmanagedType.I1)] bool repeats);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial nint SendInitFrame(nint receiver, nint selector, CGRect frame);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial nint SendInitWindow(
|
||||
nint receiver,
|
||||
nint selector,
|
||||
CGRect contentRect,
|
||||
nuint styleMask,
|
||||
nuint backing,
|
||||
[MarshalAs(UnmanagedType.I1)] bool defer);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial nint SendNextEvent(
|
||||
nint receiver,
|
||||
nint selector,
|
||||
ulong eventMask,
|
||||
nint untilDate,
|
||||
nint inMode,
|
||||
[MarshalAs(UnmanagedType.I1)] bool dequeue);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial nint SendTextureDescriptor(
|
||||
nint receiver,
|
||||
nint selector,
|
||||
nuint pixelFormat,
|
||||
nuint width,
|
||||
nuint height,
|
||||
[MarshalAs(UnmanagedType.I1)] bool mipmapped);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendReplaceRegion(
|
||||
nint receiver,
|
||||
nint selector,
|
||||
MtlRegion region,
|
||||
nuint mipmapLevel,
|
||||
nint bytes,
|
||||
nuint bytesPerRow);
|
||||
|
||||
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
|
||||
public static partial void SendDrawPrimitives(
|
||||
nint receiver,
|
||||
nint selector,
|
||||
nuint primitiveType,
|
||||
nuint vertexStart,
|
||||
nuint vertexCount);
|
||||
|
||||
public static nint Class(string name) => objc_getClass(name);
|
||||
|
||||
public static nint Selector(string name) => sel_registerName(name);
|
||||
|
||||
/// <summary>Autoreleased NSString — only valid inside an autorelease pool
|
||||
/// unless the caller retains it.</summary>
|
||||
public static nint NsString(string value)
|
||||
{
|
||||
var utf8 = Marshal.StringToCoTaskMemUTF8(value);
|
||||
try
|
||||
{
|
||||
return Send(Class("NSString"), Selector("stringWithUTF8String:"), utf8);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeCoTaskMem(utf8);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads an NSString's UTF-8 contents, or null if the handle is nil.</summary>
|
||||
public static string? ReadNsString(nint nsString)
|
||||
{
|
||||
if (nsString == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var utf8 = Send(nsString, Selector("UTF8String"));
|
||||
return utf8 == 0 ? null : Marshal.PtrToStringUTF8(utf8);
|
||||
}
|
||||
|
||||
public static string DescribeError(nint error)
|
||||
{
|
||||
if (error == 0)
|
||||
{
|
||||
return "unknown error";
|
||||
}
|
||||
|
||||
var description = Send(error, Selector("localizedDescription"));
|
||||
var utf8 = Send(description, Selector("UTF8String"));
|
||||
return Marshal.PtrToStringUTF8(utf8) ?? "unknown error";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Libs.Gpu.Metal;
|
||||
|
||||
// Guest draws and compute dispatches batch into one command buffer per drain
|
||||
// instead of one per work item, mirroring the Vulkan presenter's batched guest
|
||||
// commands: commit overhead dominated CPU time for scenes with dozens of draws
|
||||
// per frame. Ordering inside the batch is by encoder sequence (snapshot blits
|
||||
// for a draw's feedback reads are encoded before its render pass opens), and
|
||||
// everything that must observe batched work on the serial queue — flips, image
|
||||
// writes/blits, CPU-visible write-backs, the present pass — flushes first.
|
||||
internal static partial class MetalVideoPresenter
|
||||
{
|
||||
private static nint _batchCommandBuffer;
|
||||
private static bool _batchOpen;
|
||||
|
||||
/// <summary>Returns the open batch command buffer, opening one on first
|
||||
/// use. Render thread only, like the drain it serves.</summary>
|
||||
private static nint BeginBatchedGuestCommands(nint queue)
|
||||
{
|
||||
if (_batchOpen)
|
||||
{
|
||||
return _batchCommandBuffer;
|
||||
}
|
||||
|
||||
_batchCommandBuffer = MetalNative.Send(queue, MetalNative.Selector("commandBuffer"));
|
||||
_batchOpen = _batchCommandBuffer != 0;
|
||||
return _batchCommandBuffer;
|
||||
}
|
||||
|
||||
/// <summary>Commits the open batch (if any), tagging the upload pages and
|
||||
/// snapshot resources it consumed. Returns the committed command buffer so
|
||||
/// write-back sites can wait on it, or 0 when nothing was open.</summary>
|
||||
private static nint FlushBatchedGuestCommands()
|
||||
{
|
||||
if (!_batchOpen)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
_batchOpen = false;
|
||||
var commandBuffer = _batchCommandBuffer;
|
||||
_batchCommandBuffer = 0;
|
||||
MetalNative.SendVoid(commandBuffer, MetalNative.Selector("commit"));
|
||||
TagUploadPages(commandBuffer);
|
||||
TagSnapshotResources(commandBuffer);
|
||||
return commandBuffer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Libs.Gpu.Metal;
|
||||
|
||||
// Guest compute dispatches: ordered guest work like draws, with two contracts to
|
||||
// honor. Storage images are shared live through the guest-image registry so a
|
||||
// dispatch's writes are visible to later draws, blits, and flips of the same
|
||||
// address; and CPU-visible buffer writes land back in guest memory before the
|
||||
// work item completes, which is the ordering point WaitForGuestWork promises.
|
||||
internal static partial class MetalVideoPresenter
|
||||
{
|
||||
private static readonly bool _skipAllCompute =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_SKIP_ALL_COMPUTE") == "1";
|
||||
private static bool _tracedDispatchBase;
|
||||
|
||||
private sealed record ComputeGuestDispatch(
|
||||
ulong ShaderAddress,
|
||||
MetalCompiledGuestShader Shader,
|
||||
GuestDrawTexture[] Textures,
|
||||
GuestMemoryBuffer[] GlobalMemoryBuffers,
|
||||
uint GroupCountX,
|
||||
uint GroupCountY,
|
||||
uint GroupCountZ,
|
||||
uint BaseGroupX,
|
||||
uint BaseGroupY,
|
||||
uint BaseGroupZ,
|
||||
uint ThreadCountX,
|
||||
uint ThreadCountY,
|
||||
uint ThreadCountZ);
|
||||
|
||||
private static readonly Dictionary<MetalCompiledGuestShader, nint> _computePipelineCache = new();
|
||||
|
||||
public static long SubmitComputeDispatch(
|
||||
ulong shaderAddress,
|
||||
MetalCompiledGuestShader computeShader,
|
||||
IReadOnlyList<GuestDrawTexture> textures,
|
||||
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
|
||||
uint groupCountX,
|
||||
uint groupCountY,
|
||||
uint groupCountZ,
|
||||
uint baseGroupX,
|
||||
uint baseGroupY,
|
||||
uint baseGroupZ,
|
||||
bool writesGlobalMemory,
|
||||
uint threadCountX,
|
||||
uint threadCountY,
|
||||
uint threadCountZ)
|
||||
{
|
||||
var hasStorage = false;
|
||||
foreach (var texture in textures)
|
||||
{
|
||||
hasStorage |= texture.IsStorage;
|
||||
}
|
||||
|
||||
if (groupCountX == 0 ||
|
||||
groupCountY == 0 ||
|
||||
groupCountZ == 0 ||
|
||||
(!hasStorage && !writesGlobalMemory))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_closed || _thread is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Storage images a dispatch writes become flip sources and sampled
|
||||
// inputs for later work, exactly like published render targets.
|
||||
foreach (var texture in textures)
|
||||
{
|
||||
if (!texture.IsStorage || texture.Address == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var guestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType);
|
||||
if (guestFormat != 0)
|
||||
{
|
||||
_availableGuestImages[texture.Address] = guestFormat;
|
||||
}
|
||||
}
|
||||
|
||||
var sequence = EnqueueGuestWorkLocked(
|
||||
new ComputeGuestDispatch(
|
||||
shaderAddress,
|
||||
computeShader,
|
||||
ToArray(textures),
|
||||
ToArray(globalMemoryBuffers),
|
||||
groupCountX,
|
||||
groupCountY,
|
||||
groupCountZ,
|
||||
baseGroupX,
|
||||
baseGroupY,
|
||||
baseGroupZ,
|
||||
threadCountX,
|
||||
threadCountY,
|
||||
threadCountZ));
|
||||
foreach (var texture in textures)
|
||||
{
|
||||
if (texture.IsStorage && texture.Address != 0)
|
||||
{
|
||||
_guestImageWorkSequences[texture.Address] = sequence;
|
||||
}
|
||||
}
|
||||
|
||||
return sequence;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ExecuteComputeDispatch(nint device, nint queue, ComputeGuestDispatch dispatch)
|
||||
{
|
||||
if (_skipAllCompute)
|
||||
{
|
||||
ReturnPooledComputeData(dispatch);
|
||||
return;
|
||||
}
|
||||
|
||||
VideoOut.PerfOverlay.RecordDraw();
|
||||
|
||||
if ((dispatch.BaseGroupX | dispatch.BaseGroupY | dispatch.BaseGroupZ) != 0 &&
|
||||
!_tracedDispatchBase)
|
||||
{
|
||||
// Metal has no dispatch-base; the translated kernel derives its ids
|
||||
// from the raw grid position, so a nonzero base computes offset-zero
|
||||
// work until base support lands in the emitted kernel.
|
||||
_tracedDispatchBase = true;
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Metal compute dispatch with nonzero base group " +
|
||||
$"({dispatch.BaseGroupX},{dispatch.BaseGroupY},{dispatch.BaseGroupZ}); " +
|
||||
"executing without the base offset.");
|
||||
}
|
||||
|
||||
if (!TryGetComputePipeline(device, dispatch.Shader, out var pipeline))
|
||||
{
|
||||
ReturnPooledComputeData(dispatch);
|
||||
return;
|
||||
}
|
||||
|
||||
var commandBuffer = BeginBatchedGuestCommands(queue);
|
||||
|
||||
// Pre-resolve textures before the compute encoder opens: snapshot
|
||||
// blits for feedback reads encode into the batch and encoder order
|
||||
// must place them ahead of this dispatch.
|
||||
Span<nint> textureHandles = stackalloc nint[dispatch.Textures.Length];
|
||||
Span<bool> textureOwned = stackalloc bool[dispatch.Textures.Length];
|
||||
for (var index = 0; index < dispatch.Textures.Length; index++)
|
||||
{
|
||||
var descriptor = dispatch.Textures[index];
|
||||
if (descriptor.IsStorage && descriptor.Address != 0)
|
||||
{
|
||||
textureHandles[index] = EnsureStorageImage(device, descriptor)?.Texture ?? 0;
|
||||
textureOwned[index] = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
textureHandles[index] = CreateDrawTexture(
|
||||
device, commandBuffer, descriptor, out var ownedTexture);
|
||||
textureOwned[index] = ownedTexture;
|
||||
}
|
||||
}
|
||||
|
||||
var encoder = MetalNative.Send(commandBuffer, MetalNative.Selector("computeCommandEncoder"));
|
||||
MetalNative.SendVoid(encoder, MetalNative.Selector("setComputePipelineState:"), pipeline);
|
||||
|
||||
var writeBackBuffers = new List<(nint Pointer, GuestMemoryBuffer Guest)>();
|
||||
var selSetBuffer = MetalNative.Selector("setBuffer:offset:atIndex:");
|
||||
var bufferCount = dispatch.GlobalMemoryBuffers.Length;
|
||||
Span<uint> boundBytes = stackalloc uint[Math.Max(bufferCount, 1)];
|
||||
for (var index = 0; index < bufferCount; index++)
|
||||
{
|
||||
var guest = dispatch.GlobalMemoryBuffers[index];
|
||||
var pointer = UploadGlobalBuffer(
|
||||
device, guest, out var buffer, out var offset, out boundBytes[index]);
|
||||
MetalNative.SendSetBuffer(encoder, selSetBuffer, buffer, (nuint)offset, (nuint)index);
|
||||
if (guest.Writable && guest.WriteBackToGuest)
|
||||
{
|
||||
writeBackBuffers.Add((pointer, guest));
|
||||
}
|
||||
}
|
||||
|
||||
// SharpEmuUniforms: the dispatch limit clamps the overshoot threads of the
|
||||
// last threadgroup row, then each bound buffer's byte length follows
|
||||
// (including the alignment-bias prefix the shader indexes past).
|
||||
var shader = dispatch.Shader.Shader;
|
||||
var uniforms = AllocateUpload(
|
||||
device,
|
||||
16 + (Math.Max(bufferCount, 1) * sizeof(uint)),
|
||||
out var uniformsBuffer,
|
||||
out var uniformsOffset);
|
||||
WriteDispatchLimit(uniforms, 0, dispatch.ThreadCountX, dispatch.GroupCountX, shader.ThreadgroupSizeX);
|
||||
WriteDispatchLimit(uniforms, 4, dispatch.ThreadCountY, dispatch.GroupCountY, shader.ThreadgroupSizeY);
|
||||
WriteDispatchLimit(uniforms, 8, dispatch.ThreadCountZ, dispatch.GroupCountZ, shader.ThreadgroupSizeZ);
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(uniforms[12..], 0);
|
||||
for (var index = 0; index < bufferCount; index++)
|
||||
{
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
uniforms[(16 + (index * sizeof(uint)))..],
|
||||
boundBytes[index]);
|
||||
}
|
||||
|
||||
// Bind at the stage's declared SharpEmuUniforms slot (see the draw path:
|
||||
// stages compute their own index from globalBufferBase + total count).
|
||||
var uniformsIndex = shader.UniformsBufferIndex;
|
||||
MetalNative.SendSetBuffer(
|
||||
encoder,
|
||||
selSetBuffer,
|
||||
uniformsBuffer,
|
||||
(nuint)uniformsOffset,
|
||||
(nuint)(uniformsIndex >= 0 ? uniformsIndex : bufferCount));
|
||||
|
||||
var selSetTexture = MetalNative.Selector("setTexture:atIndex:");
|
||||
for (var index = 0; index < dispatch.Textures.Length; index++)
|
||||
{
|
||||
var texture = textureHandles[index];
|
||||
if (texture != 0)
|
||||
{
|
||||
MetalNative.SendSetAtIndex(encoder, selSetTexture, texture, (nuint)index);
|
||||
if (textureOwned[index])
|
||||
{
|
||||
MetalNative.SendVoid(texture, MetalNative.Selector("release"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Samplers travel in an argument buffer bound at setBuffer (see the draw
|
||||
// path), sidestepping Metal's 16-sampler-per-stage cap.
|
||||
BindSamplerArgumentBuffer(device, encoder, selSetBuffer, dispatch.Shader, dispatch.Textures);
|
||||
|
||||
MetalNative.SendDispatch(
|
||||
encoder,
|
||||
MetalNative.Selector("dispatchThreadgroups:threadsPerThreadgroup:"),
|
||||
new MtlSize
|
||||
{
|
||||
Width = dispatch.GroupCountX,
|
||||
Height = dispatch.GroupCountY,
|
||||
Depth = dispatch.GroupCountZ,
|
||||
},
|
||||
new MtlSize
|
||||
{
|
||||
Width = Math.Max(shader.ThreadgroupSizeX, 1),
|
||||
Height = Math.Max(shader.ThreadgroupSizeY, 1),
|
||||
Depth = Math.Max(shader.ThreadgroupSizeZ, 1),
|
||||
});
|
||||
MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding"));
|
||||
|
||||
// CPU-visible writes are ordering points (see the draw path): flush
|
||||
// the batch and wait so the write-back lands before this work item
|
||||
// completes. Pure-GPU dispatches stay in the open batch.
|
||||
if (writeBackBuffers.Count > 0)
|
||||
{
|
||||
var committed = FlushBatchedGuestCommands();
|
||||
MetalNative.SendVoid(committed, MetalNative.Selector("waitUntilCompleted"));
|
||||
WriteBuffersBackToGuest(writeBackBuffers);
|
||||
}
|
||||
|
||||
foreach (var descriptor in dispatch.Textures)
|
||||
{
|
||||
if (!descriptor.IsStorage || descriptor.Address == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
GuestImage? image;
|
||||
lock (_gate)
|
||||
{
|
||||
_guestImages.TryGetValue(descriptor.Address, out image);
|
||||
}
|
||||
|
||||
if (image is not null)
|
||||
{
|
||||
image.MarkContentChanged();
|
||||
}
|
||||
}
|
||||
|
||||
ReturnPooledComputeData(dispatch);
|
||||
}
|
||||
|
||||
/// <summary>The live, shared storage image for a guest address: dispatches,
|
||||
/// draws, blits, and flips of the same address all see one texture.</summary>
|
||||
private static GuestImage? EnsureStorageImage(nint device, GuestDrawTexture descriptor)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_guestImages.TryGetValue(descriptor.Address, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
if (descriptor.Width == 0 || descriptor.Height == 0 ||
|
||||
descriptor.Width > 16384 || descriptor.Height > 16384)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var format = MetalGuestFormats.TryDecodeRenderTargetFormat(
|
||||
descriptor.Format, descriptor.NumberType, out var decoded)
|
||||
? decoded.Format
|
||||
: MtlPixelFormat.Rgba8Unorm;
|
||||
var textureDescriptor = MetalNative.SendTextureDescriptor(
|
||||
MetalNative.Class("MTLTextureDescriptor"),
|
||||
MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"),
|
||||
(nuint)format,
|
||||
descriptor.Width,
|
||||
descriptor.Height,
|
||||
mipmapped: false);
|
||||
MetalNative.Send(
|
||||
textureDescriptor,
|
||||
MetalNative.Selector("setUsage:"),
|
||||
(nint)(UsageShaderRead | UsageShaderWrite | UsageRenderTarget));
|
||||
var image = new GuestImage
|
||||
{
|
||||
Texture = MetalNative.Send(
|
||||
device, MetalNative.Selector("newTextureWithDescriptor:"), textureDescriptor),
|
||||
Width = descriptor.Width,
|
||||
Height = descriptor.Height,
|
||||
Format = format,
|
||||
};
|
||||
if (image.Texture == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var bytesPerPixel = MetalRenderTargetFormat.GetBytesPerPixel(format);
|
||||
// Snapshot copies arrive in the image's native texel layout; only
|
||||
// 4-byte texels can be RGBA8 verbatim, wider ones carry native bytes.
|
||||
if ((ulong)descriptor.RgbaPixels.Length >= (ulong)descriptor.Width * bytesPerPixel)
|
||||
{
|
||||
var pitch = descriptor.Pitch != 0
|
||||
? Math.Max(descriptor.Pitch, descriptor.Width)
|
||||
: descriptor.Width;
|
||||
ReplaceTextureContents(
|
||||
image.Texture, descriptor.Width, descriptor.Height, descriptor.RgbaPixels, pitch, bytesPerPixel);
|
||||
image.MarkContentChanged();
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_guestImages.TryGetValue(descriptor.Address, out var raced))
|
||||
{
|
||||
MetalNative.SendVoid(image.Texture, MetalNative.Selector("release"));
|
||||
return raced;
|
||||
}
|
||||
|
||||
_guestImages[descriptor.Address] = image;
|
||||
_guestImageExtents[descriptor.Address] =
|
||||
(descriptor.Width, descriptor.Height, (ulong)descriptor.Width * descriptor.Height * bytesPerPixel);
|
||||
}
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
private static bool TryGetComputePipeline(nint device, MetalCompiledGuestShader shader, out nint pipeline)
|
||||
{
|
||||
lock (_computePipelineCache)
|
||||
{
|
||||
if (_computePipelineCache.TryGetValue(shader, out pipeline))
|
||||
{
|
||||
return pipeline != 0;
|
||||
}
|
||||
}
|
||||
|
||||
var function = GetShaderFunction(device, shader);
|
||||
if (function != 0)
|
||||
{
|
||||
nint error = 0;
|
||||
pipeline = MetalNative.Send(
|
||||
device,
|
||||
MetalNative.Selector("newComputePipelineStateWithFunction:error:"),
|
||||
function,
|
||||
ref error);
|
||||
if (pipeline == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Metal compute pipeline creation failed: {MetalNative.DescribeError(error)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Interlocked.Increment(ref _perfPipelineCreations);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
pipeline = 0;
|
||||
}
|
||||
|
||||
lock (_computePipelineCache)
|
||||
{
|
||||
_computePipelineCache[shader] = pipeline;
|
||||
}
|
||||
|
||||
return pipeline != 0;
|
||||
}
|
||||
|
||||
private static void WriteDispatchLimit(
|
||||
Span<byte> uniforms,
|
||||
int offset,
|
||||
uint threadCount,
|
||||
uint groupCount,
|
||||
uint threadgroupSize)
|
||||
{
|
||||
var limit = threadCount != uint.MaxValue
|
||||
? threadCount
|
||||
: groupCount * Math.Max(threadgroupSize, 1);
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
uniforms[offset..],
|
||||
limit);
|
||||
}
|
||||
|
||||
private static void ReturnPooledComputeData(ComputeGuestDispatch dispatch)
|
||||
{
|
||||
foreach (var buffer in dispatch.GlobalMemoryBuffers)
|
||||
{
|
||||
if (buffer.Pooled)
|
||||
{
|
||||
GuestDataPool.Shared.Return(buffer.Data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Libs.Gpu.Metal;
|
||||
|
||||
// Feedback reads (draws sampling a live guest render target or depth image)
|
||||
// need a fresh ordered snapshot per draw. Creating and destroying an MTLTexture
|
||||
// — and for depth reads a private staging MTLBuffer — per draw is measurable
|
||||
// CPU and allocator churn at hundreds of feedback draws per second, so both
|
||||
// recycle through a pool with the same lifecycle as the upload arena pages:
|
||||
// acquired snapshots are tagged with the command buffer that samples them at
|
||||
// commit, and return to the free list once that command buffer completes (the
|
||||
// command queue is serial, so the earlier snapshot-blit command buffer is
|
||||
// necessarily complete by then too). Everything here runs on the render thread.
|
||||
internal static partial class MetalVideoPresenter
|
||||
{
|
||||
private const int MaxFreeSnapshotResources = 16;
|
||||
|
||||
private sealed class PooledSnapshotResource
|
||||
{
|
||||
public nint Handle;
|
||||
public bool IsBuffer;
|
||||
|
||||
/// <summary>Texture identity (unused for buffers).</summary>
|
||||
public uint Format;
|
||||
public uint Width;
|
||||
public uint Height;
|
||||
public nint Usage;
|
||||
|
||||
/// <summary>Buffer capacity in bytes (unused for textures).</summary>
|
||||
public nuint Capacity;
|
||||
|
||||
/// <summary>Retained handle of the command buffer that samples this
|
||||
/// snapshot; the resource is reusable once it completes.</summary>
|
||||
public nint LastCommandBuffer;
|
||||
}
|
||||
|
||||
private static readonly List<PooledSnapshotResource> _retiredSnapshotResources = [];
|
||||
private static readonly List<PooledSnapshotResource> _pendingSnapshotResources = [];
|
||||
private static readonly List<PooledSnapshotResource> _freeSnapshotResources = [];
|
||||
|
||||
/// <summary>Returns completed snapshot resources to the free list; called
|
||||
/// once per render-loop drain, next to the upload-page recycler.</summary>
|
||||
private static void RecycleCompletedSnapshotResources()
|
||||
{
|
||||
for (var index = _retiredSnapshotResources.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var resource = _retiredSnapshotResources[index];
|
||||
if (resource.LastCommandBuffer != 0)
|
||||
{
|
||||
// MTLCommandBufferStatus: Completed = 4, Error = 5.
|
||||
var status = MetalNative.Send(
|
||||
resource.LastCommandBuffer, MetalNative.Selector("status"));
|
||||
if (status < 4)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
MetalNative.SendVoid(resource.LastCommandBuffer, MetalNative.Selector("release"));
|
||||
resource.LastCommandBuffer = 0;
|
||||
}
|
||||
|
||||
_retiredSnapshotResources.RemoveAt(index);
|
||||
if (_freeSnapshotResources.Count < MaxFreeSnapshotResources)
|
||||
{
|
||||
_freeSnapshotResources.Add(resource);
|
||||
}
|
||||
else
|
||||
{
|
||||
MetalNative.SendVoid(resource.Handle, MetalNative.Selector("release"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pops a pooled snapshot texture matching the exact identity, or
|
||||
/// creates one. The returned handle is owned by the pool — callers must not
|
||||
/// release it, and it must be tagged at the next commit.</summary>
|
||||
private static nint AcquireSnapshotTexture(
|
||||
nint device,
|
||||
MtlPixelFormat format,
|
||||
uint width,
|
||||
uint height,
|
||||
nint usage)
|
||||
{
|
||||
for (var index = 0; index < _freeSnapshotResources.Count; index++)
|
||||
{
|
||||
var candidate = _freeSnapshotResources[index];
|
||||
if (!candidate.IsBuffer &&
|
||||
candidate.Format == (uint)format &&
|
||||
candidate.Width == width &&
|
||||
candidate.Height == height &&
|
||||
candidate.Usage == usage)
|
||||
{
|
||||
_freeSnapshotResources.RemoveAt(index);
|
||||
_pendingSnapshotResources.Add(candidate);
|
||||
return candidate.Handle;
|
||||
}
|
||||
}
|
||||
|
||||
var descriptor = MetalNative.SendTextureDescriptor(
|
||||
MetalNative.Class("MTLTextureDescriptor"),
|
||||
MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"),
|
||||
(nuint)format,
|
||||
width,
|
||||
height,
|
||||
mipmapped: false);
|
||||
MetalNative.Send(descriptor, MetalNative.Selector("setUsage:"), usage);
|
||||
var handle = MetalNative.Send(
|
||||
device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor);
|
||||
if (handle == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
_pendingSnapshotResources.Add(new PooledSnapshotResource
|
||||
{
|
||||
Handle = handle,
|
||||
Format = (uint)format,
|
||||
Width = width,
|
||||
Height = height,
|
||||
Usage = usage,
|
||||
});
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>Pops a pooled private-storage staging buffer of at least
|
||||
/// <paramref name="minimumBytes"/>, or creates one. Pool-owned like
|
||||
/// <see cref="AcquireSnapshotTexture"/>.</summary>
|
||||
private static nint AcquireSnapshotBuffer(nint device, nuint minimumBytes)
|
||||
{
|
||||
for (var index = 0; index < _freeSnapshotResources.Count; index++)
|
||||
{
|
||||
var candidate = _freeSnapshotResources[index];
|
||||
if (candidate.IsBuffer && candidate.Capacity >= minimumBytes)
|
||||
{
|
||||
_freeSnapshotResources.RemoveAt(index);
|
||||
_pendingSnapshotResources.Add(candidate);
|
||||
return candidate.Handle;
|
||||
}
|
||||
}
|
||||
|
||||
// MTLResourceStorageModePrivate = 32: staging never touches the CPU.
|
||||
var handle = MetalNative.SendNewBuffer(
|
||||
device, MetalNative.Selector("newBufferWithLength:options:"), minimumBytes, 32);
|
||||
if (handle == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
_pendingSnapshotResources.Add(new PooledSnapshotResource
|
||||
{
|
||||
Handle = handle,
|
||||
IsBuffer = true,
|
||||
Capacity = minimumBytes,
|
||||
});
|
||||
return handle;
|
||||
}
|
||||
|
||||
/// <summary>Marks every snapshot resource acquired since the previous tag
|
||||
/// as owing its lifetime to <paramref name="commandBuffer"/>. Called at the
|
||||
/// same commit sites as <see cref="TagUploadPages"/>; a resource acquired
|
||||
/// for a draw that never committed is tagged by the next commit, which is
|
||||
/// conservative but safe.</summary>
|
||||
private static void TagSnapshotResources(nint commandBuffer)
|
||||
{
|
||||
if (_pendingSnapshotResources.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var resource in _pendingSnapshotResources)
|
||||
{
|
||||
resource.LastCommandBuffer = MetalNative.Send(
|
||||
commandBuffer, MetalNative.Selector("retain"));
|
||||
_retiredSnapshotResources.Add(resource);
|
||||
}
|
||||
|
||||
_pendingSnapshotResources.Clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu.Metal;
|
||||
|
||||
// Draw textures decoded from guest memory are cached across draws keyed by
|
||||
// their full descriptor identity, mirroring the Vulkan presenter's texture
|
||||
// cache: once an identity is marked cached, the AGC submit thread skips the
|
||||
// guest-memory read/detile/copy entirely (shipping empty texels) and the
|
||||
// render thread serves the cached MTLTexture — for scenes that sample large
|
||||
// textures every draw, that per-draw copy dominated both allocation churn
|
||||
// and CPU time. GuestImageWriteTracker write-protects the source pages, so
|
||||
// a guest CPU write dirties the address and the entry is evicted at the next
|
||||
// drain; the following draw ships fresh texels and re-populates the cache.
|
||||
internal static partial class MetalVideoPresenter
|
||||
{
|
||||
private const int MaxCachedDrawTextures = 2048;
|
||||
|
||||
/// <summary>Render-thread-only cache of decoded draw textures; each value
|
||||
/// holds one retain. Committed command buffers retain the textures they
|
||||
/// reference, so eviction releases immediately without a GPU drain.</summary>
|
||||
private static readonly Dictionary<TextureContentIdentity, nint> _drawTextureCache = new();
|
||||
|
||||
/// <summary>Identities the AGC submit thread may skip texel copies for.
|
||||
/// Read from the submit thread, written by the render thread.</summary>
|
||||
private static readonly ConcurrentDictionary<TextureContentIdentity, byte> _cachedDrawTextureIdentities = new();
|
||||
|
||||
internal static bool IsTextureContentCached(in TextureContentIdentity identity) =>
|
||||
_cachedDrawTextureIdentities.ContainsKey(identity);
|
||||
|
||||
/// <summary>Builds the same identity the AGC layer checks before skipping
|
||||
/// a texel copy; the two must agree field-for-field or skips and cache
|
||||
/// entries would never line up.</summary>
|
||||
private static TextureContentIdentity GetDrawTextureIdentity(GuestDrawTexture texture) => new(
|
||||
texture.Address,
|
||||
texture.Width,
|
||||
texture.Height,
|
||||
texture.Format,
|
||||
texture.NumberType,
|
||||
texture.DstSelect,
|
||||
texture.TileMode,
|
||||
texture.Pitch,
|
||||
texture.Sampler);
|
||||
|
||||
/// <summary>Caching requires the write tracker: without page protection a
|
||||
/// guest CPU write would never evict the entry and draws would sample
|
||||
/// stale texels forever. Storage textures are shader-writable on the GPU,
|
||||
/// so their content identity is not stable either.</summary>
|
||||
private static bool IsCacheableDrawTexture(GuestDrawTexture texture) =>
|
||||
GuestImageWriteTracker.Enabled &&
|
||||
texture.Address != 0 &&
|
||||
!texture.IsStorage &&
|
||||
!texture.IsFallback;
|
||||
|
||||
private static bool TryGetCachedDrawTexture(GuestDrawTexture texture, out nint handle) =>
|
||||
_drawTextureCache.TryGetValue(GetDrawTextureIdentity(texture), out handle);
|
||||
|
||||
private static void CacheDrawTexture(GuestDrawTexture texture, nint handle)
|
||||
{
|
||||
var key = GetDrawTextureIdentity(texture);
|
||||
if (_drawTextureCache.Remove(key, out var previous))
|
||||
{
|
||||
MetalNative.SendVoid(previous, MetalNative.Selector("release"));
|
||||
}
|
||||
|
||||
_ = MetalNative.Send(handle, MetalNative.Selector("retain"));
|
||||
_drawTextureCache[key] = handle;
|
||||
_cachedDrawTextureIdentities[key] = 0;
|
||||
GuestImageWriteTracker.Track(
|
||||
texture.Address,
|
||||
(ulong)texture.RgbaPixels.Length,
|
||||
Volatile.Read(ref _executingGuestWorkSequence),
|
||||
"metal.texture-cache");
|
||||
}
|
||||
|
||||
/// <summary>Runs once per drain, before any queued draw executes: a draw
|
||||
/// whose texels the submit thread skipped must never resolve to an entry
|
||||
/// the guest has since rewritten.</summary>
|
||||
private static void EvictDirtyCachedDrawTextures()
|
||||
{
|
||||
if (_drawTextureCache.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Evict by address rather than by identity: several identities can
|
||||
// share one source address (same texels, different samplers), and
|
||||
// ConsumeDirty clears the flag on first read — evicting only the
|
||||
// first identity would leave the others sampling stale texels.
|
||||
HashSet<ulong>? dirtyAddresses = null;
|
||||
foreach (var entry in _drawTextureCache)
|
||||
{
|
||||
if (dirtyAddresses is not null && dirtyAddresses.Contains(entry.Key.Address))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (GuestImageWriteTracker.ConsumeDirty(entry.Key.Address))
|
||||
{
|
||||
(dirtyAddresses ??= []).Add(entry.Key.Address);
|
||||
}
|
||||
}
|
||||
|
||||
if (dirtyAddresses is null && _drawTextureCache.Count <= MaxCachedDrawTextures)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_drawTextureCache.Count > MaxCachedDrawTextures)
|
||||
{
|
||||
foreach (var entry in _drawTextureCache)
|
||||
{
|
||||
MetalNative.SendVoid(entry.Value, MetalNative.Selector("release"));
|
||||
}
|
||||
|
||||
_drawTextureCache.Clear();
|
||||
_cachedDrawTextureIdentities.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
List<TextureContentIdentity>? evicted = null;
|
||||
foreach (var entry in _drawTextureCache)
|
||||
{
|
||||
if (dirtyAddresses!.Contains(entry.Key.Address))
|
||||
{
|
||||
(evicted ??= []).Add(entry.Key);
|
||||
}
|
||||
}
|
||||
|
||||
if (evicted is not null)
|
||||
{
|
||||
foreach (var key in evicted)
|
||||
{
|
||||
if (_drawTextureCache.Remove(key, out var handle))
|
||||
{
|
||||
_cachedDrawTextureIdentities.TryRemove(key, out _);
|
||||
MetalNative.SendVoid(handle, MetalNative.Selector("release"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var address in dirtyAddresses!)
|
||||
{
|
||||
GuestImageWriteTracker.Rearm(address);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Self-heal for the skip/eviction race: the submit thread saw a
|
||||
/// cached identity and skipped the copy, but the entry was evicted before
|
||||
/// this draw executed. Read the texels directly rather than rendering a
|
||||
/// fallback texture for the frame, sized with the same block-aware math
|
||||
/// the draw path expects.</summary>
|
||||
private static byte[]? TryReadGuestDrawTexturePixels(GuestDrawTexture texture)
|
||||
{
|
||||
var memory = _guestMemory;
|
||||
if (memory is null || texture.Address == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var width = Math.Max(texture.Width, 1u);
|
||||
var height = Math.Max(texture.Height, 1u);
|
||||
var rowLength = texture.TileMode == 0
|
||||
? Math.Max(texture.Pitch, width)
|
||||
: width;
|
||||
var format = MetalGuestFormats.DecodeTextureFormat(texture.Format, texture.NumberType);
|
||||
var byteCount = MetalGuestFormats.GetTextureByteCount(format, rowLength, height);
|
||||
if (byteCount == 0 || byteCount > int.MaxValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var pixels = new byte[(int)byteCount];
|
||||
return memory.TryRead(texture.Address, pixels) ? pixels : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Libs.Gpu.Metal;
|
||||
|
||||
// Per-draw upload data (guest global buffers, uniforms, vertex and index
|
||||
// bytes) bump-allocates from shared-storage arena pages bound by offset,
|
||||
// instead of creating one MTLBuffer and one managed copy per binding per
|
||||
// draw — which dominated allocation churn (hundreds of MB/s) and held the
|
||||
// guest flip rate well under the display rate. Pages recycle once the last
|
||||
// command buffer that referenced them reports completion; everything here
|
||||
// runs on the render thread, so no state is locked.
|
||||
internal static partial class MetalVideoPresenter
|
||||
{
|
||||
private const int UploadPageBytes = 8 * 1024 * 1024;
|
||||
|
||||
// Superset of every Metal bind-offset alignment rule (constant address
|
||||
// space on Intel Macs is the strictest at 256), and conveniently the
|
||||
// guest storage-buffer alignment the shader bias contract assumes.
|
||||
private const int UploadAlignment = 256;
|
||||
|
||||
private sealed class UploadPage
|
||||
{
|
||||
public nint Buffer;
|
||||
public nint Contents;
|
||||
public int Capacity;
|
||||
public int Offset;
|
||||
|
||||
/// <summary>Retained handle of the last command buffer that consumed
|
||||
/// data from this page; the page is reusable once it completes.</summary>
|
||||
public nint LastCommandBuffer;
|
||||
|
||||
/// <summary>Stamp of the last TagUploadPages call that saw this page,
|
||||
/// so a commit only re-tags pages it actually touched.</summary>
|
||||
public int TouchStamp;
|
||||
}
|
||||
|
||||
private static readonly List<UploadPage> _retiredUploadPages = [];
|
||||
private static readonly Stack<UploadPage> _freeUploadPages = new();
|
||||
private static readonly List<UploadPage> _touchedUploadPages = [];
|
||||
private static UploadPage? _currentUploadPage;
|
||||
private static int _uploadTouchStamp;
|
||||
|
||||
/// <summary>Returns completed pages to the free stack. Called once per
|
||||
/// render-loop drain; completion is polled (command buffer status) rather
|
||||
/// than block-based so the ObjC interop stays block-free.</summary>
|
||||
private static void RecycleCompletedUploadPages()
|
||||
{
|
||||
for (var index = _retiredUploadPages.Count - 1; index >= 0; index--)
|
||||
{
|
||||
var page = _retiredUploadPages[index];
|
||||
if (page.LastCommandBuffer != 0)
|
||||
{
|
||||
// MTLCommandBufferStatus: Completed = 4, Error = 5.
|
||||
var status = MetalNative.Send(
|
||||
page.LastCommandBuffer, MetalNative.Selector("status"));
|
||||
if (status < 4)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
MetalNative.SendVoid(page.LastCommandBuffer, MetalNative.Selector("release"));
|
||||
page.LastCommandBuffer = 0;
|
||||
}
|
||||
|
||||
_retiredUploadPages.RemoveAt(index);
|
||||
if (page.Capacity == UploadPageBytes)
|
||||
{
|
||||
page.Offset = 0;
|
||||
_freeUploadPages.Push(page);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Oversized one-off allocation; not worth pooling.
|
||||
MetalNative.SendVoid(page.Buffer, MetalNative.Selector("release"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Bump-allocates an aligned slice for CPU-written upload data.
|
||||
/// The returned span is the slice's shared-storage memory; bind the
|
||||
/// buffer at the returned offset.</summary>
|
||||
private static unsafe Span<byte> AllocateUpload(
|
||||
nint device,
|
||||
int length,
|
||||
out nint buffer,
|
||||
out int offset)
|
||||
{
|
||||
var page = _currentUploadPage;
|
||||
var aligned = page is null
|
||||
? 0
|
||||
: (page.Offset + UploadAlignment - 1) & ~(UploadAlignment - 1);
|
||||
if (page is null || aligned + length > page.Capacity)
|
||||
{
|
||||
if (page is not null)
|
||||
{
|
||||
_retiredUploadPages.Add(page);
|
||||
}
|
||||
|
||||
page = AcquireUploadPage(device, length);
|
||||
_currentUploadPage = page;
|
||||
aligned = 0;
|
||||
}
|
||||
|
||||
if (page.TouchStamp != _uploadTouchStamp)
|
||||
{
|
||||
page.TouchStamp = _uploadTouchStamp;
|
||||
_touchedUploadPages.Add(page);
|
||||
}
|
||||
|
||||
buffer = page.Buffer;
|
||||
offset = aligned;
|
||||
page.Offset = aligned + length;
|
||||
return new Span<byte>((void*)(page.Contents + aligned), length);
|
||||
}
|
||||
|
||||
private static UploadPage AcquireUploadPage(nint device, int minimumBytes)
|
||||
{
|
||||
if (minimumBytes <= UploadPageBytes && _freeUploadPages.Count > 0)
|
||||
{
|
||||
return _freeUploadPages.Pop();
|
||||
}
|
||||
|
||||
var capacity = Math.Max(minimumBytes, UploadPageBytes);
|
||||
// Options 0 = MTLResourceStorageModeShared: CPU writes are coherent
|
||||
// and write-backs read the GPU's stores after waitUntilCompleted.
|
||||
var handle = MetalNative.SendNewBuffer(
|
||||
device, MetalNative.Selector("newBufferWithLength:options:"), (nuint)capacity, 0);
|
||||
return new UploadPage
|
||||
{
|
||||
Buffer = handle,
|
||||
Contents = MetalNative.Send(handle, MetalNative.Selector("contents")),
|
||||
Capacity = capacity,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Marks every page touched since the previous tag as owing its
|
||||
/// lifetime to <paramref name="commandBuffer"/>. Called after each commit
|
||||
/// that consumed arena data.</summary>
|
||||
private static void TagUploadPages(nint commandBuffer)
|
||||
{
|
||||
if (_touchedUploadPages.Count == 0)
|
||||
{
|
||||
_uploadTouchStamp++;
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var page in _touchedUploadPages)
|
||||
{
|
||||
if (page.LastCommandBuffer != 0)
|
||||
{
|
||||
MetalNative.SendVoid(page.LastCommandBuffer, MetalNative.Selector("release"));
|
||||
}
|
||||
|
||||
page.LastCommandBuffer = MetalNative.Send(
|
||||
commandBuffer, MetalNative.Selector("retain"));
|
||||
}
|
||||
|
||||
_touchedUploadPages.Clear();
|
||||
_uploadTouchStamp++;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@ namespace SharpEmu.Libs.Gpu.Vulkan;
|
||||
/// </summary>
|
||||
internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
|
||||
{
|
||||
public string BackendName => "Vulkan";
|
||||
|
||||
private static readonly IGuestCompiledShader DepthOnlyFragmentShader =
|
||||
new VulkanCompiledGuestShader(SpirvFixedShaders.CreateDepthOnlyFragment());
|
||||
|
||||
@@ -343,6 +345,60 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
|
||||
return false;
|
||||
}
|
||||
|
||||
public IDisposable EnterGuestQueue(string queueName, ulong submissionId) =>
|
||||
VulkanVideoPresenter.EnterGuestQueue(queueName, submissionId);
|
||||
|
||||
public long SubmitOrderedGuestAction(Action action, string debugName) =>
|
||||
VulkanVideoPresenter.SubmitOrderedGuestAction(action, debugName);
|
||||
|
||||
public long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex) =>
|
||||
VulkanVideoPresenter.SubmitOrderedGuestFlipWait(videoOutHandle, displayBufferIndex);
|
||||
|
||||
public bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite) =>
|
||||
VulkanVideoPresenter.WaitForGuestWork(workSequence, timeoutMilliseconds);
|
||||
|
||||
public long CurrentGuestWorkSequenceForDiagnostics =>
|
||||
VulkanVideoPresenter.CurrentGuestWorkSequenceForDiagnostics;
|
||||
|
||||
public bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType) =>
|
||||
VulkanVideoPresenter.IsGuestImageUploadKnown(address, format, numberType);
|
||||
|
||||
public bool GuestImageWantsInitialData(ulong address) =>
|
||||
VulkanVideoPresenter.GuestImageWantsInitialData(address);
|
||||
|
||||
public void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels) =>
|
||||
VulkanVideoPresenter.ProvideGuestImageInitialData(address, rgbaPixels);
|
||||
|
||||
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
|
||||
VulkanVideoPresenter.SubmitGuestImageFill(address, fillValue);
|
||||
|
||||
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
|
||||
VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels);
|
||||
|
||||
public bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) =>
|
||||
VulkanVideoPresenter.TryGetGuestImageExtent(address, out width, out height, out byteCount);
|
||||
|
||||
public IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents() =>
|
||||
VulkanVideoPresenter.GetGuestImageExtents();
|
||||
|
||||
public bool IsTextureContentCached(in TextureContentIdentity identity) =>
|
||||
VulkanVideoPresenter.IsTextureContentCached(identity);
|
||||
|
||||
public void AttachGuestMemory(SharpEmu.HLE.ICpuMemory memory) =>
|
||||
VulkanVideoPresenter.AttachGuestMemory(memory);
|
||||
|
||||
public ulong GuestStorageBufferOffsetAlignment =>
|
||||
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment;
|
||||
|
||||
public void CountShaderCompilation() =>
|
||||
VulkanVideoPresenter.CountSpirvCompilation();
|
||||
|
||||
public (long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters() =>
|
||||
VulkanVideoPresenter.ReadAndResetPerfCounters();
|
||||
|
||||
public void RequestClose() =>
|
||||
VulkanVideoPresenter.RequestClose();
|
||||
|
||||
private static byte[] Spirv(IGuestCompiledShader shader) =>
|
||||
shader is VulkanCompiledGuestShader vulkanShader
|
||||
? vulkanShader.Spirv
|
||||
|
||||
@@ -119,6 +119,17 @@ public static class JsonExports
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
// Catalog alias NID for the same callback setter.
|
||||
#pragma warning disable SHEM004
|
||||
[SysAbiExport(
|
||||
Nid = "00oCq0RwSAY",
|
||||
ExportName = "_ZN3sce4Json11Initializer27setGlobalNullAccessCallbackEPFRKNS0_5ValueENS0_9ValueTypeEPS3_PvES7_",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceJson")]
|
||||
public static int InitializerSetGlobalNullAccessCallbackAlt(CpuContext ctx) =>
|
||||
InitializerSetGlobalNullAccessCallback(ctx);
|
||||
#pragma warning restore SHEM004
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "WSOuge5IsCg",
|
||||
ExportName = "_ZN3sce4Json14InitParameter2C1Ev",
|
||||
|
||||
@@ -362,7 +362,7 @@ public static class KernelExports
|
||||
ExportName = "open",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int Open(CpuContext ctx) => KernelMemoryCompatExports.KernelOpenUnderscore(ctx);
|
||||
public static int Open(CpuContext ctx) => KernelMemoryCompatExports.PosixOpen(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "1G3lF1Gg1k8",
|
||||
@@ -376,7 +376,7 @@ public static class KernelExports
|
||||
ExportName = "fstat",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libc")]
|
||||
public static int Fstat(CpuContext ctx) => KernelMemoryCompatExports.KernelFstat(ctx);
|
||||
public static int Fstat(CpuContext ctx) => KernelMemoryCompatExports.PosixFstat(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "hcuQgD53UxM",
|
||||
|
||||
@@ -267,6 +267,11 @@ public static partial class KernelMemoryCompatExports
|
||||
}
|
||||
|
||||
var hostPath = ResolveGuestPath(guestPath);
|
||||
if (string.IsNullOrEmpty(hostPath))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(hostPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite);
|
||||
@@ -310,6 +315,11 @@ public static partial class KernelMemoryCompatExports
|
||||
|
||||
var fromHost = ResolveGuestPath(fromGuest);
|
||||
var toHost = ResolveGuestPath(toGuest);
|
||||
if (string.IsNullOrEmpty(fromHost) || string.IsNullOrEmpty(toHost))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(fromHost))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,18 +41,87 @@ public static class KernelPthreadCompatExports
|
||||
|
||||
private sealed class PthreadMutexState
|
||||
{
|
||||
public ulong OwnerThreadId { get; set; }
|
||||
public int RecursionCount { get; set; }
|
||||
private long _ownerThreadId;
|
||||
private int _recursionCount;
|
||||
private int _queuedWaiterCount;
|
||||
|
||||
public Lock SyncRoot { get; } = new();
|
||||
public ulong OwnerThreadId
|
||||
{
|
||||
get => unchecked((ulong)Volatile.Read(ref _ownerThreadId));
|
||||
set => Volatile.Write(ref _ownerThreadId, unchecked((long)value));
|
||||
}
|
||||
|
||||
public int RecursionCount
|
||||
{
|
||||
get => Volatile.Read(ref _recursionCount);
|
||||
set => Volatile.Write(ref _recursionCount, value);
|
||||
}
|
||||
|
||||
public int QueuedWaiterCount => Volatile.Read(ref _queuedWaiterCount);
|
||||
public int Type { get; set; } = MutexTypeErrorCheck;
|
||||
public int Protocol { get; set; }
|
||||
public LinkedList<PthreadMutexWaiter> Waiters { get; } = new();
|
||||
|
||||
public bool TryAcquireUncontended(ulong threadId, bool allowWaiterBarge)
|
||||
{
|
||||
if (!allowWaiterBarge && QueuedWaiterCount != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryAcquireOwner(threadId);
|
||||
}
|
||||
|
||||
public bool TryAcquireOwner(ulong threadId)
|
||||
{
|
||||
if (Interlocked.CompareExchange(
|
||||
ref _ownerThreadId,
|
||||
unchecked((long)threadId),
|
||||
0) != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _recursionCount, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryReleaseUncontended(ulong threadId)
|
||||
{
|
||||
if (QueuedWaiterCount != 0 || RecursionCount != 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _recursionCount, 0);
|
||||
if (Interlocked.CompareExchange(
|
||||
ref _ownerThreadId,
|
||||
0,
|
||||
unchecked((long)threadId)) == unchecked((long)threadId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _recursionCount, 1);
|
||||
return false;
|
||||
}
|
||||
|
||||
public int IncrementRecursion() => Interlocked.Increment(ref _recursionCount);
|
||||
|
||||
public int DecrementRecursion() => Interlocked.Decrement(ref _recursionCount);
|
||||
|
||||
public void WaiterAddedLocked() => Interlocked.Increment(ref _queuedWaiterCount);
|
||||
|
||||
public void WaiterRemovedLocked() => Interlocked.Decrement(ref _queuedWaiterCount);
|
||||
}
|
||||
|
||||
private sealed class PthreadMutexWaiter
|
||||
{
|
||||
public required ulong ThreadId { get; init; }
|
||||
public required string WakeKey { get; init; }
|
||||
public required bool Cooperative { get; init; }
|
||||
public required bool Cooperative { get; set; }
|
||||
public ManualResetEventSlim? HostSignal { get; set; }
|
||||
public LinkedListNode<PthreadMutexWaiter>? Node { get; set; }
|
||||
public int Granted;
|
||||
}
|
||||
@@ -94,7 +163,10 @@ public static class KernelPthreadCompatExports
|
||||
public static int PthreadSelf(CpuContext ctx)
|
||||
{
|
||||
var currentThreadHandle = KernelPthreadState.GetCurrentThreadHandle();
|
||||
GuestThreadExecution.Scheduler?.RegisterGuestThreadContext(currentThreadHandle, ctx);
|
||||
if (GuestThreadExecution.CurrentGuestThreadHandle != currentThreadHandle)
|
||||
{
|
||||
GuestThreadExecution.Scheduler?.RegisterGuestThreadContext(currentThreadHandle, ctx);
|
||||
}
|
||||
ctx[CpuRegister.Rax] = currentThreadHandle;
|
||||
TracePthreadSelf(ctx, currentThreadHandle);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
@@ -139,6 +211,13 @@ public static class KernelPthreadCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "B5GmVDKwpn0",
|
||||
ExportName = "pthread_yield",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixPthreadYield(CpuContext ctx) => PthreadYield(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "GBUY7ywdULE",
|
||||
ExportName = "scePthreadRename",
|
||||
@@ -571,6 +650,30 @@ public static class KernelPthreadCompatExports
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The POSIX-named alias of <see cref="PthreadOnce"/>. libKernel exports the
|
||||
/// same routine under two NIDs, and shipped middleware links the plain name:
|
||||
/// DOOM's libcohtml, PlayFab and party modules all import this one rather
|
||||
/// than scePthreadOnce.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "Z4QosVuAsA0",
|
||||
ExportName = "pthread_once",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadOncePOSIX(CpuContext ctx) => PthreadOnce(ctx);
|
||||
|
||||
/// <summary>
|
||||
/// The POSIX-named alias of <see cref="PthreadRename"/>, following the same
|
||||
/// two-NID pattern as <see cref="PthreadOncePOSIX"/>.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "9vyP6Z7bqzc",
|
||||
ExportName = "pthread_rename_np",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadRenameNpPOSIX(CpuContext ctx) => PthreadRename(ctx);
|
||||
|
||||
private static int PthreadMutexInitCore(CpuContext ctx, ulong mutexAddress, ulong attrAddress)
|
||||
{
|
||||
if (mutexAddress == 0)
|
||||
@@ -621,7 +724,7 @@ public static class KernelPthreadCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
lock (state)
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (state.OwnerThreadId != 0 || state.RecursionCount != 0 || state.Waiters.Count != 0)
|
||||
{
|
||||
@@ -653,11 +756,63 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
|
||||
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
|
||||
if (state.TryAcquireUncontended(currentThreadId, allowWaiterBarge: tryOnly))
|
||||
{
|
||||
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (state.OwnerThreadId == currentThreadId)
|
||||
{
|
||||
if (state.Type == MutexTypeRecursive)
|
||||
{
|
||||
state.IncrementRecursion();
|
||||
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (!tryOnly && state.Type == MutexTypeAdaptiveNp &&
|
||||
IsGuestTrackedSelfLock(ctx, mutexAddress, currentThreadId))
|
||||
{
|
||||
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
|
||||
}
|
||||
|
||||
if (state.Type == MutexTypeAdaptiveNp)
|
||||
{
|
||||
var adaptiveResult = tryOnly
|
||||
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
|
||||
: (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock-idempotent", mutexAddress, resolvedAddress, state, currentThreadId, adaptiveResult);
|
||||
return adaptiveResult;
|
||||
}
|
||||
|
||||
if (state.Type == MutexTypeNormal)
|
||||
{
|
||||
if (tryOnly)
|
||||
{
|
||||
TracePthreadMutex(ctx, "trylock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
}
|
||||
|
||||
state.IncrementRecursion();
|
||||
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
var ownedResult = tryOnly
|
||||
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
|
||||
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
|
||||
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, ownedResult);
|
||||
return ownedResult;
|
||||
}
|
||||
|
||||
var canCooperativelyBlock = !tryOnly &&
|
||||
GuestThreadExecution.IsGuestThread &&
|
||||
GuestThreadExecution.TryGetCurrentImportCallFrame(out _);
|
||||
PthreadMutexWaiter? waiter = null;
|
||||
lock (state)
|
||||
var acquiredWhileQueueing = false;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (state.OwnerThreadId == currentThreadId)
|
||||
{
|
||||
@@ -668,7 +823,30 @@ public static class KernelPthreadCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (state.Type is MutexTypeNormal or MutexTypeAdaptiveNp)
|
||||
if (!tryOnly && state.Type == MutexTypeAdaptiveNp &&
|
||||
IsGuestTrackedSelfLock(ctx, mutexAddress, currentThreadId))
|
||||
{
|
||||
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
|
||||
}
|
||||
|
||||
if (state.Type == MutexTypeAdaptiveNp)
|
||||
{
|
||||
if (tryOnly)
|
||||
{
|
||||
TracePthreadMutex(ctx, "trylock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
}
|
||||
|
||||
// Gen5 runtime wrappers can layer an adaptive lock call over
|
||||
// scePthreadMutexLock for one logical acquisition, followed by
|
||||
// only one unlock. Keep the duplicate acquisition idempotent so
|
||||
// the matching unlock fully releases the HLE mutex.
|
||||
TracePthreadMutex(ctx, "lock-idempotent", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (state.Type == MutexTypeNormal)
|
||||
{
|
||||
if (tryOnly)
|
||||
{
|
||||
@@ -677,7 +855,7 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
|
||||
// Several Gen5 runtimes layer their own owner/count bookkeeping
|
||||
// over a NORMAL or ADAPTIVE kernel mutex. Returning EDEADLK here
|
||||
// over a NORMAL kernel mutex. Returning EDEADLK here
|
||||
// leaves that guest bookkeeping out of sync with the HLE owner and
|
||||
// turns the wrapper into a permanent lock/unlock retry loop. Keep
|
||||
// the compatibility recursion used by the original implementation;
|
||||
@@ -696,10 +874,17 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
}
|
||||
|
||||
if (state.OwnerThreadId == 0 && state.Waiters.Count == 0)
|
||||
// pthread_mutex_trylock succeeds whenever the mutex is not currently
|
||||
// held; unlike the blocking lock it does not queue behind waiters
|
||||
// (POSIX gives it no fairness obligation). Gating trylock on an empty
|
||||
// wait queue is wrong and, worse, lets a single stale/undrainable
|
||||
// waiter wedge a spin-on-trylock loop forever even though the mutex
|
||||
// is free (owner==0). The blocking lock still honours FIFO so real
|
||||
// blocked waiters are not starved by a barging locker.
|
||||
if (state.OwnerThreadId == 0 &&
|
||||
(tryOnly || state.Waiters.Count == 0) &&
|
||||
state.TryAcquireOwner(currentThreadId))
|
||||
{
|
||||
state.OwnerThreadId = currentThreadId;
|
||||
state.RecursionCount = 1;
|
||||
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
@@ -711,6 +896,14 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
|
||||
waiter = EnqueueMutexWaiterLocked(state, currentThreadId, canCooperativelyBlock);
|
||||
acquiredWhileQueueing = TryGrantMutexWaiterLocked(state, waiter);
|
||||
}
|
||||
|
||||
if (acquiredWhileQueueing)
|
||||
{
|
||||
waiter!.HostSignal?.Dispose();
|
||||
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (canCooperativelyBlock && waiter is not null &&
|
||||
@@ -744,8 +937,29 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
|
||||
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
|
||||
string? nextWakeKey = null;
|
||||
lock (state)
|
||||
if (state.OwnerThreadId == currentThreadId)
|
||||
{
|
||||
if (state.RecursionCount > 1)
|
||||
{
|
||||
state.DecrementRecursion();
|
||||
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (state.TryReleaseUncontended(currentThreadId))
|
||||
{
|
||||
if (state.QueuedWaiterCount != 0)
|
||||
{
|
||||
WakeFirstMutexWaiter(state);
|
||||
}
|
||||
|
||||
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
}
|
||||
|
||||
PthreadMutexWaiter? nextWaiter = null;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (state.RecursionCount <= 0)
|
||||
{
|
||||
@@ -763,16 +977,29 @@ public static class KernelPthreadCompatExports
|
||||
if (state.RecursionCount == 0)
|
||||
{
|
||||
state.OwnerThreadId = 0;
|
||||
nextWakeKey = state.Waiters.First?.Value.Cooperative == true
|
||||
? state.Waiters.First.Value.WakeKey
|
||||
: null;
|
||||
Monitor.PulseAll(state);
|
||||
|
||||
// Hand the mutex directly to the head waiter instead of only
|
||||
// waking it and relying on it to re-acquire. A woken waiter that
|
||||
// fails to self-grant (its wake races or is lost) would leave the
|
||||
// mutex "free with a queued waiter"; the fast-acquire path refuses
|
||||
// such a mutex (OwnerThreadId == 0 && Waiters.Count == 0), so every
|
||||
// later locker — including the game's main thread — then queues
|
||||
// behind a head that never advances and the process wedges.
|
||||
if (state.Waiters.First is { } headNode &&
|
||||
TryGrantMutexWaiterLocked(state, headNode.Value))
|
||||
{
|
||||
nextWaiter = headNode.Value;
|
||||
if (!nextWaiter.Cooperative)
|
||||
{
|
||||
nextWaiter.HostSignal!.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nextWakeKey is not null)
|
||||
if (nextWaiter is { Cooperative: true })
|
||||
{
|
||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWakeKey, 1);
|
||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWaiter.WakeKey, 1);
|
||||
}
|
||||
|
||||
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
@@ -1232,8 +1459,22 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
|
||||
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
|
||||
lock (mutexState)
|
||||
lock (mutexState.SyncRoot)
|
||||
{
|
||||
if (mutexState.OwnerThreadId == 0 && mutexState.RecursionCount == 0)
|
||||
{
|
||||
// The guest holds the mutex through a path our host-side tracking
|
||||
// never observed — most commonly libkernel's uncontended userspace
|
||||
// fast-path, which locks the mutex word directly without an HLE
|
||||
// call. Real pthread_cond_wait requires the caller to own the
|
||||
// mutex and does not verify it for normal mutexes, so returning
|
||||
// EPERM here is wrong: it spins the guest and, worse, leaves the
|
||||
// mutex held (the unlock below is skipped), wedging every thread
|
||||
// that later blocks on pthread_mutex_lock. Adopt ownership so the
|
||||
// unlock/wait/re-lock cycle is balanced and releases the mutex.
|
||||
_ = mutexState.TryAcquireOwner(currentThreadId);
|
||||
}
|
||||
|
||||
if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
|
||||
{
|
||||
return mutexState.OwnerThreadId == currentThreadId
|
||||
@@ -1385,6 +1626,32 @@ public static class KernelPthreadCompatExports
|
||||
bool cooperative,
|
||||
string? wakeKey = null)
|
||||
{
|
||||
// A guest thread can have at most one pending acquisition on a mutex —
|
||||
// it is either running or blocked on exactly one wait. If a waiter for
|
||||
// this thread is still queued when it comes back for a fresh
|
||||
// acquisition, that entry is a stale leftover the thread abandoned
|
||||
// (most often a cond_timedwait timeout whose re-acquire hand-off was
|
||||
// lost). Stale entries clog the FIFO head with waiters no thread is
|
||||
// blocked on, so the unlock hand-off wakes a dead wake-key and the
|
||||
// mutex wedges permanently (observed deadlocking Hades: several
|
||||
// re-acquire waiters from one thread piled ahead of a live locker).
|
||||
// Prune any prior entry for this thread before enqueueing the new one.
|
||||
if (threadId != 0)
|
||||
{
|
||||
for (var node = state.Waiters.First; node is not null;)
|
||||
{
|
||||
var next = node.Next;
|
||||
if (node.Value.ThreadId == threadId)
|
||||
{
|
||||
state.Waiters.Remove(node);
|
||||
state.WaiterRemovedLocked();
|
||||
node.Value.Node = null;
|
||||
}
|
||||
|
||||
node = next;
|
||||
}
|
||||
}
|
||||
|
||||
var waiter = new PthreadMutexWaiter
|
||||
{
|
||||
ThreadId = threadId,
|
||||
@@ -1392,8 +1659,10 @@ public static class KernelPthreadCompatExports
|
||||
WakeKey = cooperative
|
||||
? wakeKey ?? $"pthread_mutex_waiter:{Interlocked.Increment(ref _nextSynchronizationWaiterId)}"
|
||||
: string.Empty,
|
||||
HostSignal = cooperative ? null : new ManualResetEventSlim(initialState: false),
|
||||
};
|
||||
waiter.Node = state.Waiters.AddLast(waiter);
|
||||
state.WaiterAddedLocked();
|
||||
return waiter;
|
||||
}
|
||||
|
||||
@@ -1403,7 +1672,7 @@ public static class KernelPthreadCompatExports
|
||||
var mutex = new PthreadMutexState();
|
||||
PthreadMutexWaiter first;
|
||||
PthreadMutexWaiter second;
|
||||
lock (mutex)
|
||||
lock (mutex.SyncRoot)
|
||||
{
|
||||
first = EnqueueMutexWaiterLocked(mutex, 0x101, cooperative: false);
|
||||
second = EnqueueMutexWaiterLocked(mutex, 0x202, cooperative: false);
|
||||
@@ -1447,26 +1716,72 @@ public static class KernelPthreadCompatExports
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!state.TryAcquireOwner(waiter.ThreadId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
state.Waiters.Remove(waiter.Node);
|
||||
state.WaiterRemovedLocked();
|
||||
waiter.Node = null;
|
||||
state.OwnerThreadId = waiter.ThreadId;
|
||||
state.RecursionCount = 1;
|
||||
Volatile.Write(ref waiter.Granted, 1);
|
||||
Monitor.PulseAll(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void WakeFirstMutexWaiter(PthreadMutexState state)
|
||||
{
|
||||
PthreadMutexWaiter? nextWaiter;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (state.OwnerThreadId != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
nextWaiter = state.Waiters.First?.Value;
|
||||
if (nextWaiter is { Cooperative: false })
|
||||
{
|
||||
nextWaiter.HostSignal!.Set();
|
||||
}
|
||||
}
|
||||
|
||||
if (nextWaiter is { Cooperative: true })
|
||||
{
|
||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWaiter.WakeKey, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static int WaitForHostMutexLock(PthreadMutexState state, PthreadMutexWaiter waiter)
|
||||
{
|
||||
lock (state)
|
||||
ManualResetEventSlim? hostSignal = null;
|
||||
try
|
||||
{
|
||||
while (!TryGrantMutexWaiterLocked(state, waiter))
|
||||
while (true)
|
||||
{
|
||||
Monitor.Wait(state);
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (waiter.HostSignal is null)
|
||||
{
|
||||
waiter.Cooperative = false;
|
||||
waiter.HostSignal = new ManualResetEventSlim(initialState: false);
|
||||
}
|
||||
|
||||
hostSignal = waiter.HostSignal;
|
||||
if (TryGrantMutexWaiterLocked(state, waiter))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
hostSignal.Reset();
|
||||
}
|
||||
|
||||
hostSignal.Wait();
|
||||
}
|
||||
}
|
||||
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
finally
|
||||
{
|
||||
hostSignal?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGrantBlockedMutexLock(
|
||||
@@ -1477,7 +1792,7 @@ public static class KernelPthreadCompatExports
|
||||
PthreadMutexWaiter waiter)
|
||||
{
|
||||
var granted = false;
|
||||
lock (state)
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
granted = TryGrantMutexWaiterLocked(state, waiter);
|
||||
}
|
||||
@@ -1511,6 +1826,10 @@ public static class KernelPthreadCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
}
|
||||
|
||||
private static bool IsGuestTrackedSelfLock(CpuContext ctx, ulong mutexAddress, ulong currentThreadId) =>
|
||||
KernelMemoryCompatExports.TryReadUInt64Compat(ctx, mutexAddress + 8, out var guestOwner) &&
|
||||
guestOwner == currentThreadId;
|
||||
|
||||
private static bool CompleteCondWaiterLocked(
|
||||
PthreadCondState state,
|
||||
PthreadCondWaiter waiter,
|
||||
@@ -1526,7 +1845,7 @@ public static class KernelPthreadCompatExports
|
||||
waiter.TimeoutTimer?.Dispose();
|
||||
waiter.TimeoutTimer = null;
|
||||
|
||||
lock (waiter.MutexState)
|
||||
lock (waiter.MutexState.SyncRoot)
|
||||
{
|
||||
waiter.MutexWaiter = EnqueueMutexWaiterLocked(
|
||||
waiter.MutexState,
|
||||
@@ -1574,7 +1893,7 @@ public static class KernelPthreadCompatExports
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (waiter.MutexState)
|
||||
lock (waiter.MutexState.SyncRoot)
|
||||
{
|
||||
return TryGrantMutexWaiterLocked(waiter.MutexState, mutexWaiter);
|
||||
}
|
||||
|
||||
@@ -860,6 +860,18 @@ public static class KernelPthreadExtendedCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The POSIX-named alias of <see cref="PthreadAttrGetschedparam"/>. libKernel
|
||||
/// exports the same routine under two NIDs; middleware compiled against the
|
||||
/// plain POSIX headers links this one rather than scePthreadAttrGetschedparam.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "qlk9pSLsUmM",
|
||||
ExportName = "pthread_attr_getschedparam",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrGetschedparamPOSIX(CpuContext ctx) => PthreadAttrGetschedparam(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "FXPWHNk8Of0",
|
||||
ExportName = "scePthreadAttrGetschedparam",
|
||||
@@ -1133,6 +1145,90 @@ public static class KernelPthreadExtendedCompatExports
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixPthreadRwlockWrlock(CpuContext ctx) => PthreadRwlockWrlock(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "SFxTMOfuCkE",
|
||||
ExportName = "pthread_rwlock_tryrdlock",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixPthreadRwlockTryrdlock(CpuContext ctx) =>
|
||||
PthreadRwlockTryLockCore(ctx, ctx[CpuRegister.Rdi], write: false);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "XhWHn6P5R7U",
|
||||
ExportName = "pthread_rwlock_trywrlock",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixPthreadRwlockTrywrlock(CpuContext ctx) =>
|
||||
PthreadRwlockTryLockCore(ctx, ctx[CpuRegister.Rdi], write: true);
|
||||
|
||||
/// <summary>
|
||||
/// Non-blocking counterpart of <see cref="PthreadRwlockLockCore"/>: acquires
|
||||
/// only if the lock is free right now, otherwise reports BUSY.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately not routed through TryAcquireBlockedRwlock. That helper exists
|
||||
/// for the scheduler resume path and decrements WaitingWriters on success,
|
||||
/// which is correct only for a thread that previously incremented it. A fresh
|
||||
/// try never did, so reusing it would silently consume another thread's
|
||||
/// waiter count and let a queued writer be skipped.
|
||||
/// </remarks>
|
||||
private static int PthreadRwlockTryLockCore(CpuContext ctx, ulong rwlockAddress, bool write)
|
||||
{
|
||||
if (rwlockAddress == 0)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (!TryResolveRwlockState(ctx, rwlockAddress, createIfZero: true, out var resolvedAddress, out var rwlock))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
|
||||
lock (rwlock.SyncRoot)
|
||||
{
|
||||
if (write)
|
||||
{
|
||||
if (rwlock.WriterThreadId == currentThreadId || rwlock.GetReaderCount(currentThreadId) > 0)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
|
||||
}
|
||||
|
||||
// Mirrors the blocking path's re-entrant compat-writer grant so the
|
||||
// two agree on what counts as already owning the lock.
|
||||
if (rwlock.CompatWriterCounts.GetValueOrDefault(currentThreadId) > 0)
|
||||
{
|
||||
rwlock.AddCompatWriter(currentThreadId);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (rwlock.WriterThreadId != 0 ||
|
||||
rwlock.ReaderTotalCount != 0 ||
|
||||
rwlock.CompatWriterTotalCount != 0)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
}
|
||||
|
||||
DetectRwlockWriterConflict(resolvedAddress, rwlock, currentThreadId, "trywrlock");
|
||||
rwlock.WriterThreadId = currentThreadId;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (rwlock.WriterThreadId == currentThreadId)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
|
||||
}
|
||||
|
||||
if (ReaderMustWaitForRwlock(rwlock, currentThreadId))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
}
|
||||
|
||||
rwlock.AddReader(currentThreadId);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "+L98PIbGttk",
|
||||
ExportName = "scePthreadRwlockUnlock",
|
||||
@@ -1819,4 +1915,94 @@ public static class KernelPthreadExtendedCompatExports
|
||||
BinaryPrimitives.WriteInt32LittleEndian(bytes, value);
|
||||
return ctx.Memory.TryWrite(address, bytes);
|
||||
}
|
||||
|
||||
// POSIX-named aliases. libKernel exports each of these routines under two
|
||||
// NIDs -- a scePthread* name and the plain POSIX name -- and middleware
|
||||
// compiled against POSIX headers links the latter. Both take identical
|
||||
// arguments and, per the convention already used by scePthreadOnce's alias,
|
||||
// return the same OrbisGen2Result rather than translating to errno.
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "a2P9wYGeZvc",
|
||||
ExportName = "pthread_setprio",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadSetprioPOSIX(CpuContext ctx) => PthreadSetprio(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "FIs3-UQT9sg",
|
||||
ExportName = "pthread_getschedparam",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadGetschedparamPOSIX(CpuContext ctx) => PthreadGetschedparam(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "vQm4fDEsWi8",
|
||||
ExportName = "pthread_attr_getstack",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrGetstackPOSIX(CpuContext ctx) => PthreadAttrGetstack(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "Ucsu-OK+els",
|
||||
ExportName = "pthread_attr_get_np",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrGetNpPOSIX(CpuContext ctx) => PthreadAttrGet(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "JarMIy8kKEY",
|
||||
ExportName = "pthread_attr_setschedpolicy",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrSetschedpolicyPOSIX(CpuContext ctx) => PthreadAttrSetschedpolicy(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "E+tyo3lp5Lw",
|
||||
ExportName = "pthread_attr_setdetachstate",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrSetdetachstatePOSIX(CpuContext ctx) => PthreadAttrSetdetachstate(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "euKRgm0Vn2M",
|
||||
ExportName = "pthread_attr_setschedparam",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrSetschedparamPOSIX(CpuContext ctx) => PthreadAttrSetschedparam(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "7ZlAakEf0Qg",
|
||||
ExportName = "pthread_attr_setinheritsched",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrSetinheritschedPOSIX(CpuContext ctx) => PthreadAttrSetinheritsched(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "0qOtCR-ZHck",
|
||||
ExportName = "pthread_attr_getstacksize",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrGetstacksizePOSIX(CpuContext ctx) => PthreadAttrGetstacksize(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "VUT1ZSrHT0I",
|
||||
ExportName = "pthread_attr_getdetachstate",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrGetdetachstatePOSIX(CpuContext ctx) => PthreadAttrGetdetachstate(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "JKyG3SWyA10",
|
||||
ExportName = "pthread_attr_setguardsize",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrSetguardsizePOSIX(CpuContext ctx) => PthreadAttrSetguardsize(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "JNkVVsVDmOk",
|
||||
ExportName = "pthread_attr_getguardsize",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrGetguardsizePOSIX(CpuContext ctx) => PthreadAttrGetguardsize(ctx);
|
||||
}
|
||||
|
||||
@@ -2058,6 +2058,13 @@ public static class KernelRuntimeCompatExports
|
||||
LibraryName = "libKernel")]
|
||||
public static int KernelNanosleep(CpuContext ctx) => NanosleepCore(ctx, posix: false);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "NhpspxdjEKU",
|
||||
ExportName = "_nanosleep",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixNanosleepUnderscore(CpuContext ctx) => NanosleepCore(ctx, posix: true);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "yS8U2TGCe1A",
|
||||
ExportName = "nanosleep",
|
||||
|
||||
@@ -191,6 +191,27 @@ public static class KernelSemaphoreCompatExports
|
||||
WakePredicate,
|
||||
deadline))
|
||||
{
|
||||
// A signal may have arrived between releasing the semaphore gate
|
||||
// (after incrementing WaitingThreads) and the scheduler registering
|
||||
// this block. When that happens WakeBlockedThreads cannot find the
|
||||
// waiter yet and the exit-handler re-check runs later; a re-check
|
||||
// here keeps the thread from yielding to the scheduler at all when
|
||||
// the count is already sufficient.
|
||||
lock (semaphore.Gate)
|
||||
{
|
||||
if (semaphore.Count >= needCount)
|
||||
{
|
||||
semaphore.Count -= needCount;
|
||||
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
|
||||
GuestThreadExecution.TryConsumeCurrentThreadBlock(out _);
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"wait-recheck handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
|
||||
}
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
}
|
||||
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
|
||||
@@ -428,6 +449,22 @@ public static class KernelSemaphoreCompatExports
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "GEnUkDZoUwY",
|
||||
ExportName = "scePthreadSemInit",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadSemInit(CpuContext ctx)
|
||||
{
|
||||
// scePthreadSemInit(sem, flag, value, name) seems to only support private semaphores
|
||||
if (ctx[CpuRegister.Rsi] != 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
return PosixSemInit(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "YCV5dGGBcCo",
|
||||
ExportName = "sem_wait",
|
||||
@@ -446,6 +483,13 @@ public static class KernelSemaphoreCompatExports
|
||||
return KernelWaitSema(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "C36iRE0F5sE",
|
||||
ExportName = "scePthreadSemWait",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadSemWait(CpuContext ctx) => PosixSemWait(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "WBWzsRifCEA",
|
||||
ExportName = "sem_trywait",
|
||||
@@ -463,6 +507,19 @@ public static class KernelSemaphoreCompatExports
|
||||
return KernelPollSema(ctx, handle, 1);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "H2a+IN9TP0E",
|
||||
ExportName = "scePthreadSemTrywait",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadSemTryWait(CpuContext ctx)
|
||||
{
|
||||
var result = PosixSemTryWait(ctx);
|
||||
return result == (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
|
||||
? SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN)
|
||||
: result;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "w5IHyvahg-o",
|
||||
ExportName = "sem_timedwait",
|
||||
@@ -499,6 +556,13 @@ public static class KernelSemaphoreCompatExports
|
||||
return KernelSignalSema(ctx, handle, 1);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "aishVAiFaYM",
|
||||
ExportName = "scePthreadSemPost",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadSemPost(CpuContext ctx) => PosixSemPost(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "Bq+LRV-N6Hk",
|
||||
ExportName = "sem_getvalue",
|
||||
@@ -549,6 +613,13 @@ public static class KernelSemaphoreCompatExports
|
||||
return result;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "Vwc+L05e6oE",
|
||||
ExportName = "scePthreadSemDestroy",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadSemDestroy(CpuContext ctx) => PosixSemDestroy(ctx);
|
||||
|
||||
private static bool TryGetPosixSemaphoreHandle(CpuContext ctx, ulong semaphoreAddress, out uint handle)
|
||||
{
|
||||
handle = 0;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Libs.Kernel;
|
||||
|
||||
// libKernel's address-wait primitives (sceKernelSyncOnAddress*) are the PS5's
|
||||
// futex-style wait/wake: a thread parks on a guest address until another thread
|
||||
// wakes that address. Guest runtimes (seen driving Juicy Realm, PPSA19268)
|
||||
// build their own spinlocks/queues on top of it and call the wait in a hot
|
||||
// loop; left unimplemented, every wait returns immediately and the runtime
|
||||
// busy-spins forever (millions of calls, no forward progress).
|
||||
//
|
||||
// This implements wait/wake over the existing cooperative-block scheduler,
|
||||
// keyed on the address. The real primitive takes a compare value so the wait
|
||||
// only sleeps while the address still holds the expected value; that exact
|
||||
// value is not recovered here, so each wait is given a bounded deadline and
|
||||
// treated as a spurious-wakeup-tolerant park: a genuinely missed wake
|
||||
// self-heals when the deadline expires and the guest re-checks its own
|
||||
// condition, which futex callers already tolerate. A matching wake releases
|
||||
// waiters immediately through the same key.
|
||||
public static class KernelSyncOnAddressCompatExports
|
||||
{
|
||||
// Safety-net poll interval. Real releases come from the wake side (generation
|
||||
// bump + WakeBlockedThreads); this only bounds how long a wait that genuinely
|
||||
// raced/missed its wake stays parked before the guest re-evaluates. Kept
|
||||
// large: a short interval turns every parked waiter into a hot re-poll that
|
||||
// steals scheduler bandwidth from the threads that actually make progress
|
||||
// (including the ones that would issue the wake), so it must be a rare last
|
||||
// resort, not a spin substitute.
|
||||
private static readonly TimeSpan WaitSelfHealTimeout = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
// Per-address host gate for the non-cooperative (host main thread) fallback,
|
||||
// which cannot use the guest-thread scheduler's block mechanism.
|
||||
private static readonly ConcurrentDictionary<ulong, object> _hostAddressGates = new();
|
||||
|
||||
// Per-address wake generation. A wait captures the current generation and
|
||||
// its wake predicate stays unsatisfied (keeps the thread parked) until a
|
||||
// wake bumps it. This is what actually holds the thread blocked: a bare
|
||||
// "always satisfied" predicate is treated as an immediate late-arrival by
|
||||
// the dispatcher's race guard and never yields, leaving the guest to
|
||||
// busy-spin. The generation also closes the register-vs-park race for free:
|
||||
// a wake landing in that window bumps the generation, so the predicate is
|
||||
// already satisfied and the guest correctly resumes at once.
|
||||
private static readonly ConcurrentDictionary<ulong, long> _wakeGenerations = new();
|
||||
|
||||
private static long CurrentGeneration(ulong address) =>
|
||||
_wakeGenerations.TryGetValue(address, out var generation) ? generation : 0;
|
||||
|
||||
private static string WakeKey(ulong address) => $"sceKernelSyncOnAddress:{address:X16}";
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "Hc4CaR6JBL0",
|
||||
ExportName = "sceKernelSyncOnAddressWait",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int SyncOnAddressWait(CpuContext ctx)
|
||||
{
|
||||
var address = ctx[CpuRegister.Rdi];
|
||||
if (address == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
var observedGeneration = CurrentGeneration(address);
|
||||
var deadline = GuestThreadExecution.ComputeDeadlineTimestamp(WaitSelfHealTimeout);
|
||||
|
||||
// Cooperative path: stay parked until a wake bumps this address's
|
||||
// generation (or the deadline expires as a self-heal). The guest
|
||||
// re-evaluates its own condition after resuming.
|
||||
if (GuestThreadExecution.RequestCurrentThreadBlock(
|
||||
ctx,
|
||||
"sceKernelSyncOnAddressWait",
|
||||
WakeKey(address),
|
||||
resumeHandler: () => (int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
wakeHandler: () => CurrentGeneration(address) != observedGeneration,
|
||||
deadline))
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
// Non-cooperative caller (host main thread): bounded host wait so a
|
||||
// missed wake self-heals instead of hanging.
|
||||
var gate = _hostAddressGates.GetOrAdd(address, static _ => new object());
|
||||
lock (gate)
|
||||
{
|
||||
if (CurrentGeneration(address) == observedGeneration)
|
||||
{
|
||||
Monitor.Wait(gate, WaitSelfHealTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "q2y-wDIVWZA",
|
||||
ExportName = "sceKernelSyncOnAddressWake",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int SyncOnAddressWake(CpuContext ctx)
|
||||
{
|
||||
var address = ctx[CpuRegister.Rdi];
|
||||
if (address == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
// rsi carries the number of waiters to release (1 = wake-one, a large
|
||||
// value = wake-all); default to all if it looks unset.
|
||||
var requested = unchecked((long)ctx[CpuRegister.Rsi]);
|
||||
var wakeCount = requested is > 0 and < int.MaxValue ? (int)requested : int.MaxValue;
|
||||
|
||||
// Bump the generation first so a wait that has registered but not yet
|
||||
// parked sees the change and resumes instead of missing this wake.
|
||||
_wakeGenerations.AddOrUpdate(address, 1, static (_, current) => current + 1);
|
||||
|
||||
GuestThreadExecution.Scheduler?.WakeBlockedThreads(WakeKey(address), wakeCount);
|
||||
|
||||
if (_hostAddressGates.TryGetValue(address, out var gate))
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
Monitor.PulseAll(gate);
|
||||
}
|
||||
}
|
||||
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
private static int SetReturn(CpuContext ctx, OrbisGen2Result result)
|
||||
{
|
||||
var value = (int)result;
|
||||
ctx[CpuRegister.Rax] = unchecked((ulong)value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user