Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89ee111395 |
|
Before Width: | Height: | Size: 345 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 86 KiB |
@@ -89,6 +89,7 @@ jobs:
|
||||
DOTNET_NOLOGO: true
|
||||
NUGET_PACKAGES: ${{ github.workspace }}\.nuget\packages
|
||||
PUBLISH_DIR: ${{ github.workspace }}\artifacts\publish\win-x64
|
||||
RELEASE_DIR: ${{ github.workspace }}\artifacts\release
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -120,13 +121,24 @@ jobs:
|
||||
- name: Publish win-x64 CLI
|
||||
run: dotnet publish src/SharpEmu.CLI/SharpEmu.CLI.csproj -c Release -r win-x64 --self-contained true --no-restore -p:PublishDir="${env:PUBLISH_DIR}"
|
||||
|
||||
- name: Create release archive
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path $env:RELEASE_DIR -Force | Out-Null
|
||||
|
||||
$archiveName = "sharpemu-${{ needs.init.outputs.version }}-win-x64.zip"
|
||||
$archivePath = Join-Path $env:RELEASE_DIR $archiveName
|
||||
if (Test-Path $archivePath) {
|
||||
Remove-Item $archivePath -Force
|
||||
}
|
||||
|
||||
Compress-Archive -Path (Join-Path $env:PUBLISH_DIR '*') -DestinationPath $archivePath -CompressionLevel Optimal
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: sharpemu-win-x64-${{ needs.init.outputs.short-sha }}
|
||||
path: ${{ env.PUBLISH_DIR }}
|
||||
path: ${{ env.RELEASE_DIR }}\sharpemu-${{ needs.init.outputs.version }}-win-x64.zip
|
||||
if-no-files-found: error
|
||||
include-hidden-files: true
|
||||
|
||||
build-posix:
|
||||
name: Build ${{ matrix.rid }}
|
||||
@@ -146,6 +158,7 @@ jobs:
|
||||
DOTNET_NOLOGO: true
|
||||
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
|
||||
PUBLISH_DIR: ${{ github.workspace }}/artifacts/publish/${{ matrix.rid }}
|
||||
RELEASE_DIR: ${{ github.workspace }}/artifacts/release
|
||||
SPIRV_HEADERS_COMMIT: ad9184e76a66b1001c29db9b0a3e87f646c64de0
|
||||
# SpirvModuleBuilder emits SPIR-V 1.5 and VulkanVideoPresenter requests Vulkan 1.2.
|
||||
SPIRV_TARGET_ENV: vulkan1.2
|
||||
@@ -210,13 +223,19 @@ jobs:
|
||||
if: matrix.rid == 'osx-x64'
|
||||
run: scripts/fetch-macos-moltenvk.sh "$PUBLISH_DIR"
|
||||
|
||||
- name: Create release archive
|
||||
run: |
|
||||
mkdir -p "$RELEASE_DIR"
|
||||
# tar keeps the executable bit, which zip would drop.
|
||||
tar -czf "$RELEASE_DIR/sharpemu-${{ needs.init.outputs.version }}-${{ matrix.rid }}.tar.gz" \
|
||||
-C "$PUBLISH_DIR" .
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: sharpemu-${{ matrix.rid }}-${{ needs.init.outputs.short-sha }}
|
||||
path: ${{ env.PUBLISH_DIR }}
|
||||
path: ${{ env.RELEASE_DIR }}/sharpemu-${{ needs.init.outputs.version }}-${{ matrix.rid }}.tar.gz
|
||||
if-no-files-found: error
|
||||
include-hidden-files: true
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
@@ -236,28 +255,6 @@ jobs:
|
||||
with:
|
||||
path: release
|
||||
|
||||
- name: Package release assets
|
||||
shell: bash
|
||||
env:
|
||||
SHORT_SHA: ${{ needs.init.outputs.short-sha }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
win_dir="release/sharpemu-win-x64-${SHORT_SHA}"
|
||||
linux_dir="release/sharpemu-linux-x64-${SHORT_SHA}"
|
||||
macos_dir="release/sharpemu-osx-x64-${SHORT_SHA}"
|
||||
for package_dir in "${win_dir}" "${linux_dir}" "${macos_dir}"; do
|
||||
test -d "${package_dir}"
|
||||
done
|
||||
|
||||
mkdir -p release-assets
|
||||
(cd "${win_dir}" && zip -q -r "../../release-assets/sharpemu-${VERSION}-win-x64.zip" .)
|
||||
|
||||
chmod +x "${linux_dir}/SharpEmu" "${macos_dir}/SharpEmu"
|
||||
tar -czf "release-assets/sharpemu-${VERSION}-linux-x64.tar.gz" -C "${linux_dir}" .
|
||||
tar -czf "release-assets/sharpemu-${VERSION}-osx-x64.tar.gz" -C "${macos_dir}" .
|
||||
|
||||
- name: Create release
|
||||
shell: bash
|
||||
env:
|
||||
@@ -267,9 +264,9 @@ jobs:
|
||||
RELEASE_TAG: ${{ needs.init.outputs.release-tag }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
mapfile -t assets < <(find release-assets -maxdepth 1 -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
|
||||
if [ "${#assets[@]}" -ne 3 ]; then
|
||||
echo "Expected 3 release assets, found ${#assets[@]}." >&2
|
||||
mapfile -t assets < <(find release -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release assets found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -42,4 +42,3 @@ ehthumbs.db
|
||||
|
||||
.vs/
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -26,25 +26,6 @@ Before opening a pull request, please keep the following in mind:
|
||||
|
||||
If you're unsure about a design decision, feel free to open a discussion or draft PR first.
|
||||
|
||||
## Pull Request Expectations
|
||||
|
||||
Pull requests should provide real, observable emulator behavior rather than only suppressing errors or unresolved imports.
|
||||
|
||||
Changes that only return success, zero, or fabricated handles without implementing the expected state, output, or side effects will generally not be accepted. Functions that create resources, write output structures, register callbacks, or expose runtime state should model the behavior required by the guest.
|
||||
|
||||
When applicable, PRs should include:
|
||||
|
||||
- The affected game or application.
|
||||
- Relevant logs or failing imports.
|
||||
- Behavior before and after the change.
|
||||
- Real game testing and known limitations.
|
||||
|
||||
Avoid submitting large collections of speculative NIDs or unrelated exports. Keep each PR focused on one problem or a closely related set of changes.
|
||||
|
||||
Large architectural changes should be discussed with the maintainers before implementation. Contributors are encouraged to ask first when they are uncertain whether a proposed direction fits the project.
|
||||
|
||||
Opening a PR does not guarantee that it will be merged. Maintainers evaluate changes based on correctness, evidence, testing, scope, maintenance cost, and the long-term direction of the project.
|
||||
|
||||
## AI-Assisted Contributions
|
||||
|
||||
AI-assisted development is welcome and may be used for research, reverse engineering, code generation, or documentation.
|
||||
|
||||
@@ -9,18 +9,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<SharpEmuVersion>0.0.2-beta.5</SharpEmuVersion>
|
||||
<SharpEmuVersion>0.0.2-beta.3</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,7 +11,6 @@ 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" />
|
||||
|
||||
@@ -25,14 +25,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="#support">
|
||||
<img src="https://img.shields.io/badge/Support-GitHub%20Sponsors%20%26%20Crypto-EA4AAA?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="Support SharpEmu">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
> [!NOTE]
|
||||
> SharpEmu supports Windows x64, Linux x64, and macOS x64. Apple Silicon Macs
|
||||
> can run the macOS x64 build through Rosetta 2, and Windows on ARM devices
|
||||
@@ -144,18 +136,6 @@ Provided valuable references for filesystem handling and low-level C# implementa
|
||||
|
||||
- [**GPL-2.0 license**](https://github.com/sharpemu/sharpemu/blob/main/LICENSE)
|
||||
|
||||
## Support
|
||||
|
||||
Support SharpEmu via GitHub Sponsors or cryptocurrency. Every contribution helps fund ongoing development and long-term maintenance. GitHub Sponsors is the preferred way to support the project, but cryptocurrency donations are also appreciated.
|
||||
|
||||
### ETH/USDT
|
||||
|
||||
`0xF315F5d986c790bB3A58DbE60F1B2760997dEd82`
|
||||
|
||||
### BTC
|
||||
|
||||
`bc1qmr9k8899njys5ny63xsues4jgmkk96erslrkmv`
|
||||
|
||||
## Contributing
|
||||
|
||||
Before opening an issue or pull request, please read our contribution guidelines:
|
||||
|
||||
@@ -8,8 +8,6 @@ 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,14 +14,11 @@ 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.ShaderCompiler.Tests/SharpEmu.ShaderCompiler.Tests.csproj" />
|
||||
<Project Path="tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<!--
|
||||
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
|
||||
```
|
||||
@@ -9,67 +9,38 @@ Demon's Souls plays Bink 2 (.bk2) files through a Bink implementation linked
|
||||
directly into eboot.bin. It does not use libSceVideodec, therefore an HLE video
|
||||
decoder cannot observe or replace those frames.
|
||||
|
||||
SharpEmu observes successful guest .bk2 opens and, when a Bink decoder is
|
||||
SharpEmu observes successful guest .bk2 opens and, when a Bink bridge is
|
||||
available, presents its decoded BGRA frames at the normal guest-flip boundary.
|
||||
This preserves the game's own timing and lets the host Vulkan presenter display
|
||||
the movie without trying to execute the PS5-specific Bink GPU decode path.
|
||||
|
||||
The default path decodes by calling FFmpeg's own C API directly from managed
|
||||
code (`src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs`, via the
|
||||
[FFmpeg.AutoGen](https://github.com/Ruslan-B/FFmpeg.AutoGen) P/Invoke
|
||||
bindings) against a custom FFmpeg build
|
||||
(`github.com/sharpemu/ffmpeg-core`, LGPL-2.1) that adds a Bink 2 decoder to
|
||||
FFmpeg 7.1.2; see "Supplying the FFmpeg libraries" below for where those
|
||||
libraries come from. No proprietary RAD SDK is needed to build or run
|
||||
SharpEmu, and there is no C/C++ code of SharpEmu's own involved in decoding
|
||||
-- SharpEmu.CLI.csproj only downloads a prebuilt release archive.
|
||||
|
||||
Set `SHARPEMU_BINK_MODE=guest` to leave decoding to the Bink implementation
|
||||
statically linked into the game instead. Set `skip` only when explicitly
|
||||
testing a title whose cinematics are optional.
|
||||
Without an adapter, Bink 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.
|
||||
|
||||
Set SHARPEMU_BINK_MODE=dummy to retain the open and show a built-in,
|
||||
non-decoded placeholder frame. This requires no SDK, but is a visual diagnostic
|
||||
only; it does not decode the movie or alter its game logic.
|
||||
SHARPEMU_BINK_MODE=native is equivalent to the default and mainly useful for
|
||||
being explicit about it.
|
||||
only; it does not decode the movie or alter its game logic. Set
|
||||
SHARPEMU_BINK_MODE=native to force native bridge mode.
|
||||
|
||||
The experimental `SHARPEMU_BINK_MODE=ffmpeg` override is unrelated to the
|
||||
default path above: instead of calling into FFmpeg in-process, it spawns a
|
||||
standalone `ffmpeg` executable and reads raw frames from its stdout
|
||||
(`src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs`). SharpEmu searches
|
||||
`SHARPEMU_FFMPEG_PATH`, the executable directory, its `ffmpeg` subdirectory,
|
||||
and then `PATH` (plus a couple of common Homebrew paths on macOS). That
|
||||
`ffmpeg` build must contain a Bink 2 decoder itself; a stock FFmpeg build that
|
||||
only recognizes the Bink container is not sufficient. Most users want the
|
||||
default `native` mode instead, which always has Bink 2 support since it's
|
||||
built against `ffmpeg-core` specifically.
|
||||
## Supplying the adapter
|
||||
|
||||
## Supplying the FFmpeg libraries
|
||||
Bink 2 is proprietary. Obtain a compatible Mac Bink 2 SDK from RAD Game Tools,
|
||||
then compile sharpemu_bink2_bridge.c against the SDK's bink.h and Mac library.
|
||||
The adapter deliberately contains only a three-function C ABI so the managed
|
||||
emulator never depends on RAD's private binary ABI.
|
||||
|
||||
`dotnet publish` fetches a prebuilt release of `github.com/sharpemu/ffmpeg-core`
|
||||
(the tag is pinned in `SharpEmu.CLI.csproj`'s `FfmpegRuntimeTag`, matched to
|
||||
the `FFmpeg.AutoGen` package version in `Directory.Packages.props` -- both
|
||||
need to agree on the same FFmpeg ABI) and copies its dynamically linked
|
||||
libraries into a `plugins` folder next to the published executable. No C
|
||||
toolchain is required to build SharpEmu; publishing just downloads a zip.
|
||||
`plugins` is a loose, unpacked folder rather than something embedded in the
|
||||
single-file bundle, so the OS loader can resolve the libraries' own
|
||||
inter-dependencies (`avcodec` depends on `avutil`, etc.) itself.
|
||||
Place the resulting libsharpemu_bink2_bridge.dylib next to the SharpEmu
|
||||
executable, or point to it explicitly:
|
||||
|
||||
A plain `dotnet publish` with no `-r` still works: it defaults to the host
|
||||
machine's own RID (see `Directory.Build.props`), so it fetches the matching
|
||||
`ffmpeg-core` archive and populates `plugins` without any extra flags.
|
||||
Passing an explicit `-r <rid>` (e.g. to cross-publish `linux-x64` from
|
||||
Windows) still overrides that default normally.
|
||||
SHARPEMU_BINK2_BRIDGE=/absolute/path/libsharpemu_bink2_bridge.dylib \
|
||||
./SharpEmu /path/to/eboot.bin
|
||||
|
||||
To use a different set of FFmpeg libraries, drop them into the published
|
||||
`plugins` folder yourself (matching FFmpeg's own file-naming and versioning
|
||||
conventions, e.g. `avformat-61.dll` / `libavformat.so.61` / matching
|
||||
`.dylib`) -- `FfmpegNativeBinkFrameSource` points `ffmpeg.RootPath` at that
|
||||
folder and does not otherwise care where the files came from.
|
||||
The expected exports are sharpemu_bink2_open_utf8,
|
||||
sharpemu_bink2_decode_next_bgra, and sharpemu_bink2_close. The supplied
|
||||
adapter opens one movie, exposes BGRA pixels, and advances after each decoded
|
||||
frame. The managed side validates dimensions and retains ownership of the
|
||||
destination buffer.
|
||||
|
||||
If the libraries are absent or fail to load, `FfmpegNativeBinkFrameSource.TryOpen`
|
||||
degrades gracefully: SharpEmu logs one informational line ("Bink2 bridge
|
||||
could not open movie ...") and leaves the guest's own rendering path
|
||||
untouched, rather than crashing.
|
||||
If the bridge is absent in native mode, SharpEmu logs one informational line
|
||||
and retains the existing guest rendering path.
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
# Guest write watch
|
||||
|
||||
`GuestWriteWatch` is an optional diagnostic tool. It helps you find managed
|
||||
code and HLE code that damage guest memory. The tool starts only if you set one
|
||||
or more `SHARPEMU_WATCH_*` environment variables.
|
||||
|
||||
The tool monitors writes through the SharpEmu managed virtual-memory APIs. It
|
||||
does not monitor stores that native guest code makes directly. Use a platform
|
||||
debugger or a hardware watchpoint to monitor these stores.
|
||||
|
||||
## Watch modes
|
||||
|
||||
- `SHARPEMU_WATCH_WRITE=0x<address>` logs a write that overlaps the eight-byte
|
||||
block at the specified guest address.
|
||||
- `SHARPEMU_WATCH_POOL_HEADER=1` monitors the pointer at offset `0x40`. It
|
||||
monitors the first 64 direct mappings that have a size of 64 KiB and
|
||||
protection value `0xF2`.
|
||||
- `SHARPEMU_WATCH_VALUE_PATTERN=1` logs an eight-byte write if its lower 32 bits
|
||||
are `1`. The upper 32 bits must look like a small guest-pointer prefix.
|
||||
- `SHARPEMU_WATCH_VALUE1=1` logs short writes of value `1` in the high guest
|
||||
memory range. The tool logs a maximum of 128 entries for each process.
|
||||
- `SHARPEMU_WATCH_BULK_TORN=1` scans aligned 64-bit words in bulk writes. It
|
||||
finds damaged pointer patterns and byte-shifted pointer patterns. The tool
|
||||
logs a maximum of 64 entries for each process.
|
||||
- `SHARPEMU_WATCH_BULK_DEST_HI=0x<high-dword>` scans only writes that have the
|
||||
specified upper 32 bits in the destination address.
|
||||
|
||||
For each match, the tool logs the destination address, the data pattern, and the
|
||||
managed call stack. The log uses the `watch_write` or `watch_bulk_torn` warning
|
||||
tag.
|
||||
|
||||
Use these variables together to scan bulk writes in the
|
||||
`0x00000080xxxxxxxx` region.
|
||||
|
||||
macOS and Linux:
|
||||
|
||||
```sh
|
||||
SHARPEMU_WATCH_BULK_TORN=1 \
|
||||
SHARPEMU_WATCH_BULK_DEST_HI=0x80 \
|
||||
SharpEmu /path/to/eboot.bin
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:SHARPEMU_WATCH_BULK_TORN = "1"
|
||||
$env:SHARPEMU_WATCH_BULK_DEST_HI = "0x80"
|
||||
& .\SharpEmu.exe C:\path\to\game\eboot.bin
|
||||
```
|
||||
|
||||
To reduce unnecessary log entries, use an exact `SHARPEMU_WATCH_WRITE`
|
||||
address from a crash dump.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.103",
|
||||
"rollForward": "latestFeature"
|
||||
"rollForward": "disable"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SharpEmu Emulator Project
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*
|
||||
* Build this small adapter with a licensed RAD Bink 2 SDK. The SDK and its
|
||||
* headers are not distributed by SharpEmu. See docs/bink2-bridge.md.
|
||||
*/
|
||||
#include <stdint.h>
|
||||
#include "bink.h"
|
||||
|
||||
typedef struct sharpemu_bink2_info {
|
||||
uint32_t width;
|
||||
uint32_t height;
|
||||
uint32_t frames_per_second_numerator;
|
||||
uint32_t frames_per_second_denominator;
|
||||
} sharpemu_bink2_info;
|
||||
|
||||
int sharpemu_bink2_open_utf8(const char *path, HBINK *movie, sharpemu_bink2_info *info) {
|
||||
HBINK bink;
|
||||
if (!path || !movie || !info) return 0;
|
||||
|
||||
*movie = NULL;
|
||||
|
||||
bink = BinkOpen(path, 0);
|
||||
if (!bink) return 0;
|
||||
|
||||
if (bink->Width == 0 || bink->Height == 0) {
|
||||
BinkClose(bink);
|
||||
return 0;
|
||||
}
|
||||
|
||||
*movie = bink;
|
||||
info->width = bink->Width;
|
||||
info->height = bink->Height;
|
||||
info->frames_per_second_numerator = bink->FrameRate;
|
||||
info->frames_per_second_denominator = bink->FrameRateDiv;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int sharpemu_bink2_decode_next_bgra(HBINK movie, uint8_t *destination,
|
||||
uint32_t stride, uint32_t destination_bytes) {
|
||||
uint64_t needed;
|
||||
uint64_t min_stride;
|
||||
|
||||
if (!movie || !destination) return 0;
|
||||
|
||||
min_stride = (uint64_t)movie->Width * 4;
|
||||
if ((uint64_t)stride < min_stride) return 0;
|
||||
|
||||
needed = (uint64_t)stride * movie->Height;
|
||||
if (needed > destination_bytes) return 0;
|
||||
|
||||
/* Async Bink I/O has not filled the next frame yet; retry on the next host present. */
|
||||
if (BinkWait(movie)) return 0;
|
||||
|
||||
if (!BinkDoFrame(movie)) return 0;
|
||||
|
||||
if (!BinkCopyToBuffer(movie, destination, stride, movie->Height, 0, 0, BINKSURFACE32RA)) return 0;
|
||||
|
||||
BinkNextFrame(movie);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void sharpemu_bink2_close(HBINK movie) {
|
||||
if (movie) BinkClose(movie);
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
#!/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())
|
||||
@@ -153133,7 +153133,6 @@ scePsmlMfsrGetContextBufferRequirement800M3_2
|
||||
scePsmlMfsrGetDispatchMfsrPacket1000
|
||||
scePsmlMfsrGetDispatchMfsrPacket1100
|
||||
scePsmlMfsrGetDispatchMfsrPacketSizeInDwords
|
||||
scePsmlMfsrGetDispatchMfsrPacket900
|
||||
scePsmlMfsrGetMipmapBias
|
||||
scePsmlMfsrGetSharedResourcesInitRequirement
|
||||
scePsmlMfsrInit
|
||||
|
||||
@@ -45,6 +45,11 @@ 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);
|
||||
@@ -64,7 +69,6 @@ internal static partial class Program
|
||||
}
|
||||
|
||||
args = NormalizeInternalArguments(args, out var isMitigatedChild);
|
||||
PreloadGlfw();
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
@@ -214,27 +218,6 @@ internal static partial class Program
|
||||
"as libvulkan.1.dylib.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SharpEmu.CLI.csproj publishes glfw into a "plugins" subfolder rather
|
||||
/// than flat next to the executable, which falls outside the default OS
|
||||
/// DLL/dlopen search path. Preloading it here by full path first means
|
||||
/// any later bare-name lookup (however Silk.NET/GLFW itself resolves the
|
||||
/// library) finds it already loaded in the process and reuses it -- the
|
||||
/// same technique <see cref="PreloadMacVulkanLoader"/> already relies on
|
||||
/// for the Vulkan loader.
|
||||
/// </summary>
|
||||
private static void PreloadGlfw()
|
||||
{
|
||||
var fileName = OperatingSystem.IsWindows() ? "glfw3.dll"
|
||||
: OperatingSystem.IsMacOS() ? "libglfw.3.dylib"
|
||||
: "libglfw.so.3";
|
||||
var candidate = Path.Combine(AppContext.BaseDirectory, "plugins", fileName);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
NativeLibrary.TryLoad(candidate, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static int RunEmulator(string[] args, bool isMitigatedChild)
|
||||
{
|
||||
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
|
||||
@@ -573,7 +556,12 @@ internal static partial class Program
|
||||
return false;
|
||||
}
|
||||
|
||||
string[] childArgs = [MitigatedChildFlag, .. args];
|
||||
var childArgs = new string[args.Length + 1];
|
||||
childArgs[0] = MitigatedChildFlag;
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
childArgs[i + 1] = args[i];
|
||||
}
|
||||
|
||||
var commandLine = BuildCommandLine(processPath, childArgs);
|
||||
var startupInfoEx = new STARTUPINFOEX();
|
||||
@@ -624,7 +612,7 @@ internal static partial class Program
|
||||
nint jobHandle = 0;
|
||||
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
|
||||
var created = CreateProcessW(
|
||||
null,
|
||||
processPath,
|
||||
cmdLineBuilder,
|
||||
0,
|
||||
0,
|
||||
@@ -1450,7 +1438,7 @@ internal static partial class Program
|
||||
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CreateProcessW(
|
||||
string? applicationName,
|
||||
string applicationName,
|
||||
StringBuilder commandLine,
|
||||
nint processAttributes,
|
||||
nint threadAttributes,
|
||||
|
||||
@@ -20,11 +20,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<!-- osx-x64 is the macOS target: the CPU backend executes guest x86-64
|
||||
natively, so on Apple Silicon it runs under Rosetta 2. -->
|
||||
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
|
||||
<!-- A plain "dotnet publish" with no -r defaults $(RuntimeIdentifier) to
|
||||
the host's own RID; see Directory.Build.props, which is where that
|
||||
default actually has to live (PublishDir's RID suffix is decided
|
||||
there, evaluated before this file, so a default set only here would
|
||||
be too late for it). -->
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
@@ -54,6 +49,7 @@ 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)' == ''">
|
||||
@@ -65,7 +61,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\LICENSE.txt">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
@@ -79,75 +75,17 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- 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
|
||||
<!-- Keep glfw as a loose file next to the executable; every other native
|
||||
library is embedded into the single-file bundle. -->
|
||||
<Target Name="KeepGlfwOutsideSingleFile" AfterTargets="ComputeResolvedFilesToPublishList">
|
||||
<ItemGroup>
|
||||
<_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,9 +13,4 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
{
|
||||
"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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Emulation;
|
||||
|
||||
/// <summary>
|
||||
/// Pure software implementation of the bit-field math behind AMD's SSE4a EXTRQ/INSERTQ
|
||||
/// (immediate-form) instructions.
|
||||
///
|
||||
/// The direct-execution backend runs guest PS5 code natively on the host CPU. The PS5's Zen 2
|
||||
/// cores implement AMD-only SSE4a (EXTRQ/INSERTQ), but Intel hosts - and Rosetta 2 on Apple
|
||||
/// Silicon - do not, so they raise #UD (STATUS_ILLEGAL_INSTRUCTION) instead of executing the
|
||||
/// opcode. SharpEmu already rewrites one specific compiled EXTRQ+VPBLENDD idiom at load time
|
||||
/// (see <see cref="Native.Sse4aExtrqBlendPatch"/>), but any other occurrence of EXTRQ/INSERTQ -
|
||||
/// a different register allocation, a title built with a different compiler version, and so on
|
||||
/// - still aborts the title. This class ported from Kyty's
|
||||
/// <c>Loader::X64InstructionEmulator::TryEmulateSse4a</c> provides the general bit-field
|
||||
/// extract/insert so the illegal-instruction handler can finish *any* immediate-form
|
||||
/// EXTRQ/INSERTQ in software and resume, instead of relying on a single hard-coded byte pattern.
|
||||
///
|
||||
/// The methods operate on plain 64-bit integers rather than the OS CONTEXT record so the bit
|
||||
/// math can be unit-tested in isolation; the unsafe CONTEXT/XMM plumbing lives in the backend
|
||||
/// adapter (<see cref="Native.DirectExecutionBackend"/>).
|
||||
/// </summary>
|
||||
public static class Sse4aBitFieldEmulator
|
||||
{
|
||||
public static bool IsValidBitField(int length, int index)
|
||||
{
|
||||
var len = length & 0x3F;
|
||||
var idx = index & 0x3F;
|
||||
return (len != 0 || idx == 0) && (len == 0 ? idx == 0 : idx + len <= 64);
|
||||
}
|
||||
|
||||
public static ulong ExtractBitField(ulong value, int length, int index)
|
||||
{
|
||||
var len = length & 0x3F;
|
||||
var idx = index & 0x3F;
|
||||
if (!IsValidBitField(length, index))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (len == 0)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var mask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
|
||||
return (value >> idx) & mask;
|
||||
}
|
||||
|
||||
public static ulong InsertBitField(ulong destination, ulong source, int length, int index)
|
||||
{
|
||||
var len = length & 0x3F;
|
||||
var idx = index & 0x3F;
|
||||
if (!IsValidBitField(length, index))
|
||||
{
|
||||
return destination;
|
||||
}
|
||||
|
||||
if (len == 0)
|
||||
{
|
||||
return source;
|
||||
}
|
||||
|
||||
var fieldMask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
|
||||
var destinationClearMask = fieldMask << idx;
|
||||
var sourceField = (source & fieldMask) << idx;
|
||||
return (destination & ~destinationClearMask) | sourceField;
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Threading;
|
||||
using Iced.Intel;
|
||||
using SharpEmu.Core.Cpu.Emulation;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native;
|
||||
|
||||
// General software fallback for the AMD-only instructions PS5 titles occasionally emit that a
|
||||
// Zen 2-only host implements but Intel hosts (and Rosetta 2 on Apple Silicon) do not:
|
||||
// - SSE4a EXTRQ/INSERTQ, immediate form
|
||||
// - MONITORX/MWAITX
|
||||
//
|
||||
// This is a direct port of Kyty's Loader::X64InstructionEmulator (TryEmulateSse4a /
|
||||
// TryEmulateMonitorxMwaitx). SharpEmu already special-cases exactly one compiled EXTRQ+VPBLENDD
|
||||
// byte sequence at load time (Sse4aExtrqBlendPatch), which only helps the one idiom it was
|
||||
// reverse-engineered from. This file is a general, fault-time fallback that engages for any
|
||||
// immediate-form EXTRQ/INSERTQ or MONITORX/MWAITX the narrower patch (or a title using a
|
||||
// different compiler/register allocation) does not cover, complementing rather than replacing
|
||||
// it: the load-time patch still avoids paying the fault-and-recover cost on the hot path it was
|
||||
// built for, while this method is the safety net for everything else.
|
||||
//
|
||||
// This is deliberately additive: DirectExecutionBackend.IllegalInstruction.cs (the BMI1/BMI2/ABM
|
||||
// fallback) is untouched, and this method is only reached from VectoredHandler after that one
|
||||
// has already declined to handle the fault.
|
||||
public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
// Byte offset of Xmm0 within the Win64 CONTEXT record: FltSave (the XMM_SAVE_AREA32/FXSAVE
|
||||
// image) starts right after Rip at offset 256, and XmmRegisters[0] sits 160 bytes into that
|
||||
// area (32-byte header + 8 legacy x87/MMX slots x 16 bytes). 256 + 160 = 416 (0x1A0). Cross-
|
||||
// checked against this file's own Win64ContextSize (0x4D0): rebuilding the whole CONTEXT
|
||||
// layout field-by-field from offset 0 lands on the same 0x4D0 total, which would not happen
|
||||
// if this offset (or anything before it) were wrong.
|
||||
private const int Win64ContextXmm0Offset = 0x1A0;
|
||||
|
||||
private static int _sse4aSoftwareFallbackAnnounced;
|
||||
private static long _sse4aInstructionsEmulated;
|
||||
private static int _monitorxSoftwareFallbackAnnounced;
|
||||
private static long _monitorxInstructionsEmulated;
|
||||
|
||||
private unsafe bool TryRecoverAmdCompatInstruction(void* contextRecord, ulong rip)
|
||||
{
|
||||
if (TryRecoverMonitorxMwaitx(contextRecord, rip))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// MONITORX/MWAITX above only ever reads guest code memory and rewrites RIP, both of
|
||||
// which the POSIX signal bridge (DirectExecutionBackend.PosixSignals.cs) faithfully
|
||||
// round-trips through the real ucontext, so it works on every supported OS. EXTRQ/
|
||||
// INSERTQ additionally read and write an XMM register: on Windows contextRecord is the
|
||||
// live CONTEXT the OS resumes the thread from, so touching the Xmm0.. slots is visible
|
||||
// to the guest, and on Linux the bridge copies the mcontext's FXSAVE image into the
|
||||
// Xmm0.. slots and writes them back through sigreturn (_posixXmmContextBridged). On
|
||||
// Darwin the XMM area is still a zeroed scratch buffer - running this there would
|
||||
// silently compute a result from stale bytes and then discard whatever it "wrote", so
|
||||
// the recovery declines until that bridge exists.
|
||||
return (OperatingSystem.IsWindows() || _posixXmmContextBridged) &&
|
||||
TryRecoverSse4aExtractInsert(contextRecord, rip);
|
||||
}
|
||||
|
||||
private unsafe bool TryRecoverMonitorxMwaitx(void* contextRecord, ulong rip)
|
||||
{
|
||||
// MONITORX (0F 01 FA) and MWAITX (0F 01 FB) are fixed 3-byte encodings with no
|
||||
// ModRM/SIB/displacement/immediate, so a raw byte compare is sufficient and unambiguous.
|
||||
var opcode = new byte[3];
|
||||
if (!TryReadHostBytes(rip, opcode) ||
|
||||
opcode[0] != 0x0F || opcode[1] != 0x01 || (opcode[2] != 0xFA && opcode[2] != 0xFB))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// PS5 titles use this pair in idle/wait loops: MONITORX arms a monitor on a cache line
|
||||
// and MWAITX blocks until that line is written (or a timeout elapses). Hosts without
|
||||
// the extension raise #UD on either one. We do not model the monitor itself, only its
|
||||
// observable effect on guest forward progress: MONITORX becomes a no-op (arming a
|
||||
// watch we never honour has no side effect of its own) and MWAITX becomes a plain
|
||||
// thread yield, i.e. treat the awaited condition as already satisfied so the guest
|
||||
// loop keeps making progress instead of executing an illegal opcode forever.
|
||||
if (opcode[2] == 0xFB)
|
||||
{
|
||||
Thread.Yield();
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, CTX_RIP, rip + 3);
|
||||
|
||||
Interlocked.Increment(ref _monitorxInstructionsEmulated);
|
||||
if (Interlocked.Exchange(ref _monitorxSoftwareFallbackAnnounced, 1) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Host lacks AMD MONITORX/MWAITX used by the guest; " +
|
||||
"emulating those instructions in software.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private unsafe bool TryRecoverSse4aExtractInsert(void* contextRecord, ulong rip)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() && !_posixXmmContextBridged ||
|
||||
!TryReadFaultingInstruction(rip, out var instruction))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var isExtrq = instruction.Mnemonic == Mnemonic.Extrq;
|
||||
var isInsertq = instruction.Mnemonic == Mnemonic.Insertq;
|
||||
if (!isExtrq && !isInsertq)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isExtrq && instruction.OpCount != 3 || isInsertq && instruction.OpCount != 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (instruction.GetOpKind(0) != OpKind.Register ||
|
||||
!TryGetXmmOffset(instruction.GetOpRegister(0), out var destOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var destLow = ReadCtxU64(contextRecord, destOffset);
|
||||
if (isExtrq)
|
||||
{
|
||||
var length = (int)instruction.GetImmediate(1);
|
||||
var index = (int)instruction.GetImmediate(2);
|
||||
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.ExtractBitField(destLow, length, index));
|
||||
WriteCtxU64(contextRecord, destOffset + 8, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (instruction.GetOpKind(1) != OpKind.Register ||
|
||||
!TryGetXmmOffset(instruction.GetOpRegister(1), out var srcOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var length = (int)instruction.GetImmediate(2);
|
||||
var index = (int)instruction.GetImmediate(3);
|
||||
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.InsertBitField(
|
||||
destLow, ReadCtxU64(contextRecord, srcOffset), length, index));
|
||||
WriteCtxU64(contextRecord, destOffset + 8, 0);
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, CTX_RIP, rip + (ulong)instruction.Length);
|
||||
|
||||
Interlocked.Increment(ref _sse4aInstructionsEmulated);
|
||||
if (Interlocked.Exchange(ref _sse4aSoftwareFallbackAnnounced, 1) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Host lacks SSE4a EXTRQ/INSERTQ used by the guest; " +
|
||||
"emulating those instructions in software.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Maps an Iced XMM register to its byte offset in the Win64 CONTEXT record. Written as an
|
||||
// explicit switch (rather than arithmetic on the Register enum) to match the style already
|
||||
// used by TryGetGprSlot/TryGetGpr64Offset in DirectExecutionBackend.IllegalInstruction.cs.
|
||||
private static bool TryGetXmmOffset(Register register, out int offset)
|
||||
{
|
||||
switch (register)
|
||||
{
|
||||
case Register.XMM0: offset = Win64ContextXmm0Offset + 16 * 0; return true;
|
||||
case Register.XMM1: offset = Win64ContextXmm0Offset + 16 * 1; return true;
|
||||
case Register.XMM2: offset = Win64ContextXmm0Offset + 16 * 2; return true;
|
||||
case Register.XMM3: offset = Win64ContextXmm0Offset + 16 * 3; return true;
|
||||
case Register.XMM4: offset = Win64ContextXmm0Offset + 16 * 4; return true;
|
||||
case Register.XMM5: offset = Win64ContextXmm0Offset + 16 * 5; return true;
|
||||
case Register.XMM6: offset = Win64ContextXmm0Offset + 16 * 6; return true;
|
||||
case Register.XMM7: offset = Win64ContextXmm0Offset + 16 * 7; return true;
|
||||
case Register.XMM8: offset = Win64ContextXmm0Offset + 16 * 8; return true;
|
||||
case Register.XMM9: offset = Win64ContextXmm0Offset + 16 * 9; return true;
|
||||
case Register.XMM10: offset = Win64ContextXmm0Offset + 16 * 10; return true;
|
||||
case Register.XMM11: offset = Win64ContextXmm0Offset + 16 * 11; return true;
|
||||
case Register.XMM12: offset = Win64ContextXmm0Offset + 16 * 12; return true;
|
||||
case Register.XMM13: offset = Win64ContextXmm0Offset + 16 * 13; return true;
|
||||
case Register.XMM14: offset = Win64ContextXmm0Offset + 16 * 14; return true;
|
||||
case Register.XMM15: offset = Win64ContextXmm0Offset + 16 * 15; return true;
|
||||
default:
|
||||
offset = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,6 @@ public sealed partial class DirectExecutionBackend
|
||||
private static int _lazyCommitTraceCount;
|
||||
private static int _guestAllocatorHoleRecoveries;
|
||||
private static int _auxiliaryThreadExecuteFaultRecoveries;
|
||||
private static int _auxiliaryThreadExecuteFaultSkips;
|
||||
private nint _workerAbortStack;
|
||||
private const uint WorkerAbortStackSize = 0x10000u;
|
||||
|
||||
private unsafe void SetupExceptionHandler()
|
||||
{
|
||||
@@ -136,11 +133,6 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (exceptionCode == StatusIllegalInstruction &&
|
||||
TryRecoverAmdCompatInstruction(contextRecord, rip))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (IsBenignHostDebugException(exceptionCode))
|
||||
{
|
||||
return -1;
|
||||
@@ -438,91 +430,18 @@ public sealed partial class DirectExecutionBackend
|
||||
void* contextRecord,
|
||||
ulong rip)
|
||||
{
|
||||
if (exceptionRecord->ExceptionCode != 3221225477u)
|
||||
if (exceptionRecord->ExceptionCode != 3221225477u ||
|
||||
rip >= 0x0000000800000000UL ||
|
||||
_activeGuestThreadState is not { Name: "tbb_thead" } activeThread)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prefer ThreadStatic active state; fall back to host-thread name when
|
||||
// concurrent TBB AVs race logging (tLT61: recover skipped, then Fatal).
|
||||
GuestThreadState? activeThread = _activeGuestThreadState;
|
||||
if (activeThread is null || activeThread.Name != "tbb_thead")
|
||||
{
|
||||
var hostName = Thread.CurrentThread.Name;
|
||||
if (hostName is null ||
|
||||
!hostName.StartsWith("SharpEmu-tbb_thead", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
activeThread = FindGuestThreadStateByHostThreadId(unchecked((int)GetCurrentThreadId()));
|
||||
if (activeThread is null || activeThread.Name != "tbb_thead")
|
||||
{
|
||||
var skip = Interlocked.Increment(ref _auxiliaryThreadExecuteFaultSkips);
|
||||
if (skip <= 8 || skip % 64 == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] tbb_recover skip #{skip}: rip=0x{rip:X16} " +
|
||||
$"host='{hostName}' active={(activeThread?.Name ?? "null")}");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var hostExit = ActiveEntryReturnSentinelRip;
|
||||
if (hostExit < 0x10000)
|
||||
{
|
||||
hostExit = unchecked((ulong)_guestReturnStub);
|
||||
}
|
||||
|
||||
// Prefer worker-abort (SetEvent + ExitThread) over host_exit→RunEpilogue:
|
||||
// the latter FailFasts the process after TBB recover (tLT28/30 silent die).
|
||||
// Do NOT abandon mutexes here — managed HLE from inside VEH can re-enter
|
||||
// and Fatal (tLT73). NativeGuestExecutor.Run abandons after detecting abort.
|
||||
var abortRip = unchecked((ulong)_workerAbortStub);
|
||||
if (abortRip >= 0x10000)
|
||||
{
|
||||
// Do NOT SetEvent from managed VEH: that wakes the renter which may
|
||||
// TerminateThread while this thread is still inside VEH return
|
||||
// (tLTA2: recover logged, no respawning, process die). Abort stub
|
||||
// SetEvent's only after CONTINUE_EXECUTION resumes at park.
|
||||
|
||||
// Prefer the entry-stub-saved host RSP (real CreateThread stack).
|
||||
// Do not treat mid-range host stacks as guest — Astro worker stacks
|
||||
// often sit in 0x02xxxxxx_xxxx and were wrongly replaced with a
|
||||
// shared VirtualAlloc abort stack (concurrent TBB AV → die).
|
||||
var hostRspSlot = TlsGetValue(_hostRspSlotTlsIndex);
|
||||
ulong hostRsp = 0;
|
||||
if (hostRspSlot != 0)
|
||||
{
|
||||
hostRsp = *(ulong*)hostRspSlot;
|
||||
}
|
||||
|
||||
if (hostRsp < 0x10000)
|
||||
{
|
||||
hostRsp = EnsureWorkerAbortStackRsp();
|
||||
}
|
||||
|
||||
if (hostRsp >= 0x10000)
|
||||
{
|
||||
WriteCtxU64(contextRecord, 152, hostRsp & ~0xFUL);
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, 120, 0);
|
||||
WriteCtxU64(contextRecord, 248, abortRip);
|
||||
var recovery = Interlocked.Increment(ref _auxiliaryThreadExecuteFaultRecoveries);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Recovered auxiliary TBB execute fault #{recovery}: " +
|
||||
$"thread=0x{activeThread.ThreadHandle:X16} target=0x{rip:X16} " +
|
||||
$"host_rsp=0x{hostRsp:X16} -> worker_abort=0x{abortRip:X16}");
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] tbb_recover: parking native worker (SetEvent+park); " +
|
||||
"renter will TerminateThread+respawn — avoids ExitThread after VEH");
|
||||
Console.Error.Flush();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hostExit < 0x10000)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
@@ -534,57 +453,13 @@ public sealed partial class DirectExecutionBackend
|
||||
_ = TryPatchActiveGuestReturnSlot(hostExit);
|
||||
WriteCtxU64(contextRecord, 120, 0);
|
||||
WriteCtxU64(contextRecord, 248, hostExit);
|
||||
var recoveryFallback = Interlocked.Increment(ref _auxiliaryThreadExecuteFaultRecoveries);
|
||||
var recovery = Interlocked.Increment(ref _auxiliaryThreadExecuteFaultRecoveries);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Recovered auxiliary TBB execute fault #{recoveryFallback}: " +
|
||||
$"[LOADER][WARN] Recovered auxiliary TBB execute fault #{recovery}: " +
|
||||
$"thread=0x{activeThread.ThreadHandle:X16} target=0x{rip:X16} -> host_exit=0x{hostExit:X16}");
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] tbb_recover: resumed at host_exit (abort stub unavailable); " +
|
||||
"subsequent FastFail/CLR must not re-enter managed VEH " +
|
||||
"(live trampoline pre-filters 0xC0000409 / 0xE0434352)");
|
||||
Console.Error.Flush();
|
||||
return true;
|
||||
}
|
||||
|
||||
private GuestThreadState? FindGuestThreadStateByHostThreadId(int hostThreadId)
|
||||
{
|
||||
if (hostThreadId == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var thread in SnapshotGuestThreads())
|
||||
{
|
||||
if (Volatile.Read(ref thread.HostThreadId) == hostThreadId)
|
||||
{
|
||||
return thread;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private unsafe ulong EnsureWorkerAbortStackRsp()
|
||||
{
|
||||
if (_workerAbortStack == 0)
|
||||
{
|
||||
_workerAbortStack = (nint)VirtualAlloc(null, WorkerAbortStackSize, 12288u, 4u);
|
||||
if (_workerAbortStack == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Grow-down stack: hand out near the top with alignment headroom.
|
||||
return (ulong)(_workerAbortStack + (nint)WorkerAbortStackSize - 0x100) & ~0xFUL;
|
||||
}
|
||||
|
||||
private unsafe bool TryRecoverGuestInt41(uint exceptionCode, void* contextRecord, ulong rip)
|
||||
{
|
||||
if (!_ignoreGuestInt41 || exceptionCode != 3221225477u || rip < 0x10000)
|
||||
@@ -603,7 +478,7 @@ public sealed partial class DirectExecutionBackend
|
||||
if (count <= 16 || count % 65536 == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (default-on; set SHARPEMU_IGNORE_INT41=0 to disable)");
|
||||
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (SHARPEMU_IGNORE_INT41=1)");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -530,12 +530,9 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
|
||||
}
|
||||
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
|
||||
{
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
|
||||
}
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
|
||||
StoreImportVectorReturn(cpuContext, argPackPtr);
|
||||
if (dispatchResolved &&
|
||||
orbisGen2Result == OrbisGen2Result.ORBIS_GEN2_OK &&
|
||||
@@ -1329,12 +1326,9 @@ public sealed partial class DirectExecutionBackend
|
||||
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
|
||||
}
|
||||
}
|
||||
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
|
||||
{
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
|
||||
}
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
|
||||
StoreImportVectorReturn(cpuContext, argPackPtr);
|
||||
|
||||
if (returnValue != (int)OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
@@ -1404,13 +1398,11 @@ public sealed partial class DirectExecutionBackend
|
||||
"vWU-odnS+fU" or // sceAmprMeasureCommandSizeReadFile
|
||||
"sSAUCCU1dv4" or // sceAmprMeasureCommandSizeWriteKernelEventQueue_04_00
|
||||
"C+IEj+BsAFM" or // sceAmprMeasureCommandSizeWriteAddressOnCompletion
|
||||
"4fgtGfXDrFc" or // sceAmprMeasureCommandSizeWriteAddress_04_00
|
||||
"tZDDEo2tE5k" or // sceAmprCommandBufferGetSize
|
||||
"GnxKOHEawhk" or // sceAmprCommandBufferGetCurrentOffset
|
||||
"gzndltBEzWc" or // sceAmprCommandBufferGetNumCommands
|
||||
"H896Pt-yB4I" or // sceAmprCommandBufferWriteKernelEventQueue_04_00
|
||||
"sJXyWHjP-F8" or // sceAmprCommandBufferWriteAddressOnCompletion
|
||||
"j0+3uJMxYJY" or // sceAmprCommandBufferWriteAddress_04_00
|
||||
"mPpPxv5CZt4" or // sceSystemServiceGetHdrToneMapLuminance
|
||||
"1FZBKy8HeNU" or // sceVideoOutGetVblankStatus
|
||||
"ASoW5WE-UPo" or // sceKernelAprSubmitCommandBufferAndGetResult
|
||||
@@ -1418,8 +1410,6 @@ 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
|
||||
@@ -1446,12 +1436,6 @@ 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 expectedPollSemaBusy =
|
||||
string.Equals(nid, "12wOHk8ywb0", StringComparison.Ordinal) &&
|
||||
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
var expectedNetAcceptWouldBlock =
|
||||
string.Equals(nid, "PIWqhn9oSxc", StringComparison.Ordinal) &&
|
||||
resultValue == unchecked((int)0x80410123);
|
||||
@@ -1465,8 +1449,6 @@ public sealed partial class DirectExecutionBackend
|
||||
!expectedTimedWaitTimeout &&
|
||||
!expectedEqueueTimeout &&
|
||||
!expectedMutexTrylockBusy &&
|
||||
!expectedSemaphoreTrywaitAgain &&
|
||||
!expectedPollSemaBusy &&
|
||||
!expectedNetAcceptWouldBlock &&
|
||||
!expectedUserServiceNoEvent &&
|
||||
!expectedPrivacyInvalidParameter)
|
||||
@@ -1560,13 +1542,11 @@ public sealed partial class DirectExecutionBackend
|
||||
"vWU-odnS+fU" or
|
||||
"sSAUCCU1dv4" or
|
||||
"C+IEj+BsAFM" or
|
||||
"4fgtGfXDrFc" or
|
||||
"tZDDEo2tE5k" or
|
||||
"GnxKOHEawhk" or
|
||||
"gzndltBEzWc" or
|
||||
"H896Pt-yB4I" or
|
||||
"sJXyWHjP-F8" or
|
||||
"j0+3uJMxYJY" or
|
||||
"mPpPxv5CZt4" or
|
||||
"1FZBKy8HeNU" or
|
||||
"ASoW5WE-UPo" or
|
||||
@@ -1591,8 +1571,6 @@ 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
|
||||
|
||||
@@ -29,29 +29,9 @@ public sealed partial class DirectExecutionBackend
|
||||
private static readonly bool NativeGuestWorkersDisabled =
|
||||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_NATIVE_GUEST_WORKERS"), "1", StringComparison.Ordinal);
|
||||
|
||||
// Cap concurrent native-worker Runs. Astro's tbb_thead burst overlaps many
|
||||
// UnmanagedCallersOnly prologues; a large prewarm + unbounded concurrency
|
||||
// FailFasts (0xC0000409) mid-storm with no VEH breadcrumb. Pool size and
|
||||
// in-flight Runs are separate knobs.
|
||||
private static readonly int NativeWorkerMaxConcurrent = ReadNativeWorkerMaxConcurrent();
|
||||
|
||||
private static int ReadNativeWorkerMaxConcurrent()
|
||||
{
|
||||
if (int.TryParse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_NATIVE_WORKER_MAX_CONCURRENT"),
|
||||
out var parsed) &&
|
||||
parsed > 0)
|
||||
{
|
||||
return Math.Clamp(parsed, 1, 64);
|
||||
}
|
||||
|
||||
return 2;
|
||||
}
|
||||
|
||||
private readonly object _nativeWorkerGate = new();
|
||||
private readonly List<NativeGuestExecutor> _allNativeWorkers = new();
|
||||
private readonly Stack<NativeGuestExecutor> _idleNativeWorkers = new();
|
||||
private readonly SemaphoreSlim _nativeWorkerRunLimiter = new(NativeWorkerMaxConcurrent);
|
||||
private bool _nativeWorkersDisposed;
|
||||
private int _nativeWorkerCreationFailedLogged;
|
||||
|
||||
@@ -69,9 +49,6 @@ public sealed partial class DirectExecutionBackend
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern uint WaitForSingleObject(nint hHandle, uint dwMilliseconds);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool TerminateThread(nint hThread, uint dwExitCode);
|
||||
|
||||
// Runs an emitted guest entry stub. Preferred path is a pooled native worker
|
||||
// thread; falls back to the historical inline calli (guest frames above this
|
||||
// thread's managed frames) when workers are disabled or unavailable.
|
||||
@@ -79,148 +56,40 @@ public sealed partial class DirectExecutionBackend
|
||||
// Callers set the Active* thread-statics before emitting the stub and read the
|
||||
// yield/forced-exit flags right after this returns, so the worker outcome is
|
||||
// copied back into this thread's statics before returning.
|
||||
private unsafe int RunGuestEntryStub(void* entryStub, ulong hostRspSlot, bool requireNativeWorker = false)
|
||||
private unsafe int RunGuestEntryStub(void* entryStub, ulong hostRspSlot)
|
||||
{
|
||||
// Limit in-flight native Runs before renting so the idle pool is not
|
||||
// drained by threads blocked on the concurrency gate.
|
||||
_nativeWorkerRunLimiter.Wait();
|
||||
NativeGuestExecutor? worker = null;
|
||||
var worker = RentNativeGuestExecutor();
|
||||
if (worker is null)
|
||||
{
|
||||
TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot);
|
||||
return CallNativeEntry(entryStub);
|
||||
}
|
||||
try
|
||||
{
|
||||
// Astro can spawn a burst of tbb_thead while workers are still in
|
||||
// TerminateThread+respawn. Wait for a native worker — never fall back
|
||||
// to managed inline (FailFast) and never throw (uncaught throw mid-
|
||||
// storm was a silent process die).
|
||||
var maxAttempts = requireNativeWorker ? 500 : 48;
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++)
|
||||
{
|
||||
worker = RentNativeGuestExecutor();
|
||||
if (worker is not null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!requireNativeWorker)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
Thread.Sleep(attempt < 32 ? 1 : 4);
|
||||
}
|
||||
|
||||
if (worker is null)
|
||||
{
|
||||
if (requireNativeWorker)
|
||||
{
|
||||
var n = Interlocked.Increment(ref _tbbNativeWorkerRefuseCount);
|
||||
if (n <= 8 || n % 32 == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][ERROR] tbb_native_worker unavailable #{n} after {maxAttempts} attempts; " +
|
||||
"skipping run (no managed inline, no throw)");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
|
||||
_activeGuestThreadYieldRequested = true;
|
||||
_activeGuestThreadYieldReason = "tbb_native_worker_unavailable";
|
||||
_activeForcedGuestExit = true;
|
||||
return unchecked((int)0x80020012);
|
||||
}
|
||||
|
||||
TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot);
|
||||
return CallNativeEntry(entryStub);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var state = _activeGuestThreadState;
|
||||
if (state is { Name: "tbb_thead" })
|
||||
{
|
||||
var n = Interlocked.Increment(ref _tbbNativeRunEnterCount);
|
||||
if (n <= 12 || n % 64 == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] tbb_run_enter #{n} native_tid_pending handle=0x{state.ThreadHandle:X16} " +
|
||||
$"max_concurrent={NativeWorkerMaxConcurrent}");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
}
|
||||
|
||||
var nativeReturn = worker.Run(
|
||||
_activeCpuContext!,
|
||||
state,
|
||||
GuestThreadExecution.CurrentGuestThreadHandle,
|
||||
_activeEntryReturnSentinelRip,
|
||||
_activeGuestReturnSlotAddress,
|
||||
(nint)hostRspSlot,
|
||||
(nint)entryStub,
|
||||
state?.AffinityMask ?? 0,
|
||||
out var yieldRequested,
|
||||
out var yieldReason,
|
||||
out var forcedExit);
|
||||
_activeGuestThreadYieldRequested = yieldRequested;
|
||||
_activeGuestThreadYieldReason = yieldReason;
|
||||
_activeForcedGuestExit = forcedExit;
|
||||
return nativeReturn;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReturnNativeGuestExecutor(worker);
|
||||
}
|
||||
var state = _activeGuestThreadState;
|
||||
var nativeReturn = worker.Run(
|
||||
_activeCpuContext!,
|
||||
state,
|
||||
GuestThreadExecution.CurrentGuestThreadHandle,
|
||||
_activeEntryReturnSentinelRip,
|
||||
_activeGuestReturnSlotAddress,
|
||||
(nint)hostRspSlot,
|
||||
(nint)entryStub,
|
||||
state?.AffinityMask ?? 0,
|
||||
out var yieldRequested,
|
||||
out var yieldReason,
|
||||
out var forcedExit);
|
||||
_activeGuestThreadYieldRequested = yieldRequested;
|
||||
_activeGuestThreadYieldReason = yieldReason;
|
||||
_activeForcedGuestExit = forcedExit;
|
||||
return nativeReturn;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_nativeWorkerRunLimiter.Release();
|
||||
ReturnNativeGuestExecutor(worker);
|
||||
}
|
||||
}
|
||||
|
||||
private static int _tbbNativeRunEnterCount;
|
||||
private static int _tbbNativeWorkerRefuseCount;
|
||||
internal static int _tbbWorkerPrologueFaultCount;
|
||||
|
||||
private void PrewarmNativeGuestWorkers(int count)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() || NativeGuestWorkersDisabled || count <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var warmed = new List<NativeGuestExecutor>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var worker = NativeGuestExecutor.TryCreate(this);
|
||||
if (worker is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
warmed.Add(worker);
|
||||
}
|
||||
|
||||
lock (_nativeWorkerGate)
|
||||
{
|
||||
if (_nativeWorkersDisposed)
|
||||
{
|
||||
foreach (var worker in warmed)
|
||||
{
|
||||
worker.Dispose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var worker in warmed)
|
||||
{
|
||||
_allNativeWorkers.Add(worker);
|
||||
_idleNativeWorkers.Push(worker);
|
||||
}
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] Native guest workers prewarmed: {warmed.Count}/{count} " +
|
||||
$"max_concurrent={NativeWorkerMaxConcurrent}");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
|
||||
private NativeGuestExecutor? RentNativeGuestExecutor()
|
||||
{
|
||||
// NativeGuestExecutor emits a Win32 wait loop and creates it with
|
||||
@@ -531,22 +400,6 @@ public sealed partial class DirectExecutionBackend
|
||||
return false;
|
||||
}
|
||||
FlushInstructionCache(GetCurrentProcess(), _loopStub, LoopStubSize);
|
||||
return StartWorkerThread();
|
||||
}
|
||||
|
||||
private bool RestartWorkerThread()
|
||||
{
|
||||
if (_loopStub == null || _controlBlock == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
*(int*)_controlBlock = 0;
|
||||
return StartWorkerThread();
|
||||
}
|
||||
|
||||
private bool StartWorkerThread()
|
||||
{
|
||||
_threadHandle = CreateThread(
|
||||
0,
|
||||
WorkerStackReservation,
|
||||
@@ -592,49 +445,6 @@ public sealed partial class DirectExecutionBackend
|
||||
_runForcedExit = false;
|
||||
SignalWorkAvailable();
|
||||
WaitWorkCompleted();
|
||||
|
||||
// Normal path: RunEpilogue/ExitRun clears _entered before SetEvent(done).
|
||||
// TBB abort stub SetEvent's without ExitRun — _entered stays true.
|
||||
if (_entered)
|
||||
{
|
||||
var waitRc = WaitForSingleObject(_threadHandle, 500u);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Native guest worker tid={_nativeThreadId} aborted during run; " +
|
||||
$"wait_rc=0x{waitRc:X8} respawning");
|
||||
Console.Error.Flush();
|
||||
if (_runState is { } abortedState)
|
||||
{
|
||||
_ = GuestThreadExecution.NotifyGuestThreadAbandoned(
|
||||
abortedState.ThreadHandle,
|
||||
"tbb_worker_abort");
|
||||
Volatile.Write(ref abortedState.HostThreadId, _prevHostThreadId);
|
||||
}
|
||||
_entered = false;
|
||||
if (_threadHandle != 0)
|
||||
{
|
||||
// Abort stub parks (no ExitThread). Force-kill the parked OS
|
||||
// thread so we can recreate the loop without process teardown.
|
||||
if (waitRc != 0u)
|
||||
{
|
||||
_ = TerminateThread(_threadHandle, unchecked((uint)(-1)));
|
||||
_ = WaitForSingleObject(_threadHandle, 1000u);
|
||||
}
|
||||
CloseHandle(_threadHandle);
|
||||
_threadHandle = 0;
|
||||
_nativeThreadId = 0;
|
||||
}
|
||||
if (!RestartWorkerThread())
|
||||
{
|
||||
_runPrologueFailed = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_runPrologueFailed = false;
|
||||
_runForcedExit = true;
|
||||
_runNativeResult = 0;
|
||||
}
|
||||
}
|
||||
|
||||
_runContext = null;
|
||||
_runState = null;
|
||||
yieldRequested = _runYieldRequested;
|
||||
@@ -642,22 +452,7 @@ public sealed partial class DirectExecutionBackend
|
||||
forcedExit = _runForcedExit;
|
||||
if (_runPrologueFailed)
|
||||
{
|
||||
// Never throw out of the native-worker rent path: an uncaught
|
||||
// exception mid-TBB storm kills the process with no FailFast
|
||||
// breadcrumb.
|
||||
var n = Interlocked.Increment(ref _tbbWorkerPrologueFaultCount);
|
||||
if (n <= 8 || n % 32 == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][ERROR] tbb_worker prologue fault #{n}; soft-fail run " +
|
||||
$"(tid={_nativeThreadId})");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
|
||||
yieldRequested = true;
|
||||
yieldReason = "tbb_worker_prologue_fault";
|
||||
forcedExit = true;
|
||||
return unchecked((int)0x80020012);
|
||||
throw new InvalidOperationException("Native guest worker failed to bind the run ambient (prologue fault)");
|
||||
}
|
||||
return _runNativeResult;
|
||||
}
|
||||
@@ -751,18 +546,6 @@ public sealed partial class DirectExecutionBackend
|
||||
_activeGuestThreadState = _runState;
|
||||
backend.BindTlsBase(_runContext!);
|
||||
TlsSetValue(backend._hostRspSlotTlsIndex, _runHostRspSlot);
|
||||
if (backend._workerDoneEventTlsIndex != uint.MaxValue)
|
||||
{
|
||||
nint doneHandle = OperatingSystem.IsWindows()
|
||||
? _workCompleted!.SafeWaitHandle.DangerousGetHandle()
|
||||
: _doneSemaphore;
|
||||
TlsSetValue(backend._workerDoneEventTlsIndex, doneHandle);
|
||||
}
|
||||
if (backend._tbbAbortEligibleTlsIndex != uint.MaxValue)
|
||||
{
|
||||
nint eligible = _runState is { Name: "tbb_thead" } ? 1 : 0;
|
||||
TlsSetValue(backend._tbbAbortEligibleTlsIndex, eligible);
|
||||
}
|
||||
if (_runState is { } state)
|
||||
{
|
||||
_prevHostThreadId = Volatile.Read(ref state.HostThreadId);
|
||||
@@ -797,14 +580,6 @@ public sealed partial class DirectExecutionBackend
|
||||
Volatile.Write(ref state.HostThreadId, _prevHostThreadId);
|
||||
}
|
||||
TlsSetValue(_backend._hostRspSlotTlsIndex, _prevHostRspSlot);
|
||||
if (_backend._workerDoneEventTlsIndex != uint.MaxValue)
|
||||
{
|
||||
TlsSetValue(_backend._workerDoneEventTlsIndex, 0);
|
||||
}
|
||||
if (_backend._tbbAbortEligibleTlsIndex != uint.MaxValue)
|
||||
{
|
||||
TlsSetValue(_backend._tbbAbortEligibleTlsIndex, 0);
|
||||
}
|
||||
GuestThreadExecution.RestoreGuestThread(_prevGuestThreadHandle);
|
||||
_activeExecutionBackend = _prevBackend;
|
||||
_activeCpuContext = _prevContext;
|
||||
|
||||
@@ -50,19 +50,6 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
private const int LinuxUcontextGregsOffset = 40;
|
||||
private const int LinuxGregsErrOffset = 19 * 8;
|
||||
|
||||
// The kernel's x86-64 sigcontext places the FXSAVE-image pointer right
|
||||
// after the general registers it hands to the handler: err(152)
|
||||
// trapno(160) oldmask(168) cr2(176) fpstate(184), all relative to
|
||||
// GetPosixRegisterBase. glibc and musl both overlay this kernel layout
|
||||
// verbatim (glibc's mcontext_t.fpregs is the same slot), so the offset
|
||||
// is libc-independent. Inside the FXSAVE image the XMM registers start
|
||||
// at +160 (32-byte header + 8 legacy x87/MMX slots x 16 bytes) - the
|
||||
// same relative position they occupy in the Win64 CONTEXT's FltSave
|
||||
// area (Win64ContextXmm0Offset = 256 + 160).
|
||||
private const int LinuxGregsFpstateOffset = 184;
|
||||
private const int FxsaveXmmOffset = 160;
|
||||
private const int XmmBlockSize = 16 * 16;
|
||||
|
||||
// Byte offsets of the general registers relative to GetPosixRegisterBase,
|
||||
// ordered to match the contiguous Win64 CONTEXT block CTX_RAX..CTX_RIP
|
||||
// (rax, rcx, rdx, rbx, rsp, rbp, rsi, rdi, r8..r15, rip). Verified
|
||||
@@ -84,15 +71,6 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
[ThreadStatic]
|
||||
private static int _posixSignalHandlerDepth;
|
||||
|
||||
// True while the current thread's in-flight POSIX fault carries the real
|
||||
// XMM registers in the CONTEXT scratch buffer and writes to them will
|
||||
// reach the mcontext on resume. Gates recovery paths (SSE4a EXTRQ/
|
||||
// INSERTQ) that would otherwise compute results from a zeroed XMM area
|
||||
// and silently discard what they "wrote". Darwin is not bridged yet, so
|
||||
// the flag stays false there.
|
||||
[ThreadStatic]
|
||||
private static bool _posixXmmContextBridged;
|
||||
|
||||
private void SetupPosixExceptionHandler()
|
||||
{
|
||||
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_POSIX_SIGNALS"), "1", StringComparison.Ordinal))
|
||||
@@ -274,26 +252,6 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
WriteCtxU64(contextRecord, CTX_RAX + i * 8, *(ulong*)(registers + offsets[i]));
|
||||
}
|
||||
|
||||
// Bridge the XMM registers alongside the GPRs where the layout is
|
||||
// known: on Linux the fpstate pointer and FXSAVE image are kernel
|
||||
// ABI, so recovery paths that read or write XMM state (SSE4a
|
||||
// EXTRQ/INSERTQ) see the live registers and their writes reach the
|
||||
// guest through sigreturn.
|
||||
byte* fpstate = null;
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
fpstate = *(byte**)(registers + LinuxGregsFpstateOffset);
|
||||
if (fpstate != null)
|
||||
{
|
||||
Buffer.MemoryCopy(
|
||||
fpstate + FxsaveXmmOffset,
|
||||
contextRecord + Win64ContextXmm0Offset,
|
||||
XmmBlockSize,
|
||||
XmmBlockSize);
|
||||
}
|
||||
}
|
||||
_posixXmmContextBridged = fpstate != null;
|
||||
|
||||
EXCEPTION_RECORD record = default;
|
||||
record.ExceptionAddress = (void*)ReadCtxU64(contextRecord, CTX_RIP);
|
||||
if (signal == PosixSigIll)
|
||||
@@ -359,14 +317,6 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
{
|
||||
*(ulong*)(registers + offsets[i]) = ReadCtxU64(contextRecord, CTX_RAX + i * 8);
|
||||
}
|
||||
if (fpstate != null)
|
||||
{
|
||||
Buffer.MemoryCopy(
|
||||
contextRecord + Win64ContextXmm0Offset,
|
||||
fpstate + FxsaveXmmOffset,
|
||||
XmmBlockSize,
|
||||
XmmBlockSize);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ internal sealed unsafe partial class WindowsFaultHandling : IHostFaultHandling
|
||||
|
||||
public nint CreateHandlerThunk(nint managedCallback, uint hostRspSwitchTlsSlot, nint tlsGetValueAddress)
|
||||
{
|
||||
const uint stubSize = 1024u;
|
||||
const uint stubSize = 256u;
|
||||
void* ptr = (void*)_memory.Allocate(0, stubSize, HostPageProtection.ReadWriteExecute);
|
||||
if (ptr == null)
|
||||
{
|
||||
@@ -43,15 +43,11 @@ internal sealed unsafe partial class WindowsFaultHandling : IHostFaultHandling
|
||||
// managed code; the CLR's own VEH handles its exceptions. MSVC C++ exceptions
|
||||
// (Vulkan drivers, host CRT) are excluded too: the managed handler only ever
|
||||
// returned CONTINUE_SEARCH for them.
|
||||
//
|
||||
// FastFail (0xC0000409) is logged from this native path only: managed VEH never
|
||||
// sees it (tLT18–21 silent exits after TBB AV recovery).
|
||||
ReadOnlySpan<uint> nonManagedExceptionCodes =
|
||||
[WindowsFaultCodes.ClrManagedException, 0xE06D7363u, WindowsFaultCodes.FastFail, WindowsFaultCodes.StackOverflow];
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x01); // mov rax, [rcx] (ExceptionRecord*)
|
||||
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x00); // mov eax, [rax] (ExceptionCode)
|
||||
var passJumpOffsets = stackalloc int[nonManagedExceptionCodes.Length];
|
||||
int fastFailJumpSlot = -1;
|
||||
for (int i = 0; i < nonManagedExceptionCodes.Length; i++)
|
||||
{
|
||||
EmitByte(code, ref offset, 0x3D); // cmp eax, imm32
|
||||
@@ -59,162 +55,13 @@ internal sealed unsafe partial class WindowsFaultHandling : IHostFaultHandling
|
||||
EmitByte(code, ref offset, 0x74); // je pass
|
||||
passJumpOffsets[i] = offset;
|
||||
EmitByte(code, ref offset, 0x00);
|
||||
if (nonManagedExceptionCodes[i] == WindowsFaultCodes.FastFail)
|
||||
{
|
||||
fastFailJumpSlot = i;
|
||||
}
|
||||
}
|
||||
EmitByte(code, ref offset, 0xE9); // jmp mainBody rel32 (FastFail breadcrumb sits between)
|
||||
var mainBodyJumpSlot = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
|
||||
EmitByte(code, ref offset, 0xEB); EmitByte(code, ref offset, 0x03); // jmp over pass block
|
||||
int passOffset = offset;
|
||||
EmitByte(code, ref offset, 0x31); EmitByte(code, ref offset, 0xC0); // pass: xor eax, eax (EXCEPTION_CONTINUE_SEARCH)
|
||||
EmitByte(code, ref offset, 0xC3); // ret
|
||||
|
||||
// FastFail: native stderr breadcrumb with Context.Rip (no managed entry), then CONTINUE_SEARCH.
|
||||
// Keep in sync with DirectExecutionBackend.CreateExceptionHandlerTrampoline.
|
||||
int fastFailPassOffset = offset;
|
||||
var fastFailLogInstalled = false;
|
||||
if (fastFailJumpSlot >= 0 &&
|
||||
NativeLibrary.TryLoad("kernel32.dll", out var kernel32) &&
|
||||
NativeLibrary.TryGetExport(kernel32, "GetStdHandle", out var getStdHandle) &&
|
||||
NativeLibrary.TryGetExport(kernel32, "WriteFile", out var writeFile))
|
||||
{
|
||||
ReadOnlySpan<byte> msg =
|
||||
"[LOADER][FATAL] VEH_PASS FastFail 0xC0000409 (native; no managed VEH) rip=0x"u8;
|
||||
ReadOnlySpan<byte> hexDigits = "0123456789ABCDEF"u8;
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x41);
|
||||
EmitByte(code, ref offset, 0x08); // mov rax, [rcx+8]
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x90);
|
||||
EmitUInt32(code, ref offset, 0xF8u); // mov r10, [rax+0xF8]
|
||||
EmitByte(code, ref offset, 0x50); // push rax
|
||||
EmitByte(code, ref offset, 0x51); // push rcx
|
||||
EmitByte(code, ref offset, 0x52); // push rdx
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x50); // push r8
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x51); // push r9
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x52); // push r10
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x40); // sub rsp, 0x40
|
||||
EmitByte(code, ref offset, 0xB9); EmitUInt32(code, ref offset, unchecked((uint)-12));
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = getStdHandle;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x28); // mov [rsp+0x28], rax
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xC1);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
var msgAbsSlot = offset;
|
||||
*(nint*)(code + offset) = 0;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xC2);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xB8);
|
||||
EmitUInt32(code, ref offset, (uint)msg.Length);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20); EmitUInt32(code, ref offset, 0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x38); EmitUInt32(code, ref offset, 0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = writeFile;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8B);
|
||||
EmitByte(code, ref offset, 0x54); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x40); // mov r10, [rsp+0x40]
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xB8);
|
||||
var hexDigitsAbsSlot = offset;
|
||||
*(nint*)(code + offset) = 0;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
|
||||
EmitByte(code, ref offset, 0x5C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x30); // lea r11, [rsp+0x30]
|
||||
EmitByte(code, ref offset, 0xB9); EmitUInt32(code, ref offset, 16u);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xD0);
|
||||
int hexLoopOffset = offset;
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC1); EmitByte(code, ref offset, 0xC0);
|
||||
EmitByte(code, ref offset, 0x04);
|
||||
EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xC2);
|
||||
EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xE2); EmitByte(code, ref offset, 0x0F);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0xB6);
|
||||
EmitByte(code, ref offset, 0x14); EmitByte(code, ref offset, 0x10);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x88); EmitByte(code, ref offset, 0x13);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xC3);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xC9);
|
||||
EmitByte(code, ref offset, 0x75);
|
||||
EmitByte(code, ref offset, unchecked((byte)(hexLoopOffset - (offset + 1)))); // jnz rel8
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xC6); EmitByte(code, ref offset, 0x03);
|
||||
EmitByte(code, ref offset, 0x0A);
|
||||
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8D);
|
||||
EmitByte(code, ref offset, 0x54); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x30);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xB8);
|
||||
EmitUInt32(code, ref offset, 17u);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20); EmitUInt32(code, ref offset, 0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x38); EmitUInt32(code, ref offset, 0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = writeFile;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x40);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5A);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x59);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x58);
|
||||
EmitByte(code, ref offset, 0x5A);
|
||||
EmitByte(code, ref offset, 0x59);
|
||||
EmitByte(code, ref offset, 0x58);
|
||||
EmitByte(code, ref offset, 0x31); EmitByte(code, ref offset, 0xC0);
|
||||
EmitByte(code, ref offset, 0xC3);
|
||||
|
||||
var msgOffset = offset;
|
||||
for (int i = 0; i < msg.Length; i++)
|
||||
{
|
||||
EmitByte(code, ref offset, msg[i]);
|
||||
}
|
||||
|
||||
var hexDigitsOffset = offset;
|
||||
for (int i = 0; i < hexDigits.Length; i++)
|
||||
{
|
||||
EmitByte(code, ref offset, hexDigits[i]);
|
||||
}
|
||||
|
||||
*(nint*)(code + msgAbsSlot) = (nint)ptr + msgOffset;
|
||||
*(nint*)(code + hexDigitsAbsSlot) = (nint)ptr + hexDigitsOffset;
|
||||
code[passJumpOffsets[fastFailJumpSlot]] =
|
||||
checked((byte)(fastFailPassOffset - (passJumpOffsets[fastFailJumpSlot] + 1)));
|
||||
fastFailLogInstalled = true;
|
||||
}
|
||||
|
||||
int mainBodyOffset = offset;
|
||||
*(int*)(code + mainBodyJumpSlot) = mainBodyOffset - (mainBodyJumpSlot + sizeof(int));
|
||||
|
||||
for (int i = 0; i < nonManagedExceptionCodes.Length; i++)
|
||||
{
|
||||
if (i == fastFailJumpSlot && fastFailLogInstalled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
code[passJumpOffsets[i]] = checked((byte)(passOffset - (passJumpOffsets[i] + 1)));
|
||||
}
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x54); // push r12
|
||||
|
||||
@@ -40,9 +40,6 @@ public sealed class TrackedCpuMemory : ICpuMemory, ITrackedCpuMemory, IGuestMemo
|
||||
return result;
|
||||
}
|
||||
|
||||
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length) =>
|
||||
_inner.TryCopy(destinationAddress, sourceAddress, length);
|
||||
|
||||
public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address)
|
||||
{
|
||||
if (_inner is IGuestMemoryAllocator allocator)
|
||||
|
||||
@@ -199,32 +199,9 @@ public sealed class SelfLoader : ISelfLoader
|
||||
{
|
||||
if (!physicalVm.TryAllocateAtExact(imageBase, totalImageSize, executable: true, out var allocatedBase))
|
||||
{
|
||||
// Exact allocation failed — the host may have already claimed
|
||||
// part of this range (ASLR, Rosetta 2, or another process).
|
||||
// Try backing the fixed range page by page to claim whatever
|
||||
// free gaps exist. If the whole range is occupied the backfill
|
||||
// returns false and we surface the original failure reason.
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER] Exact allocation at main image base 0x{imageBase:X16} " +
|
||||
$"(size=0x{totalImageSize:X}) failed; attempting fixed-range backfill.");
|
||||
if (!physicalVm.TryBackFixedRange(imageBase, totalImageSize, executable: true))
|
||||
{
|
||||
// TryBackFixedRange may have partially backed pages before
|
||||
// failing. The earlier Clear() already reset all regions, so
|
||||
// this second Clear() is idempotent for everything except the
|
||||
// partial backfill — it frees only those orphaned pages.
|
||||
physicalVm.Clear();
|
||||
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
|
||||
throw new InvalidOperationException(
|
||||
$"Could not allocate main image at required base 0x{imageBase:X16} " +
|
||||
$"(size=0x{totalImageSize:X}): {reason}. " +
|
||||
"Try closing other applications, rebooting, or " +
|
||||
(OperatingSystem.IsWindows()
|
||||
? "setting SHARPEMU_DISABLE_MITIGATION_RELAUNCH=1."
|
||||
: "ensuring no other process maps into this address range."));
|
||||
}
|
||||
|
||||
allocatedBase = imageBase;
|
||||
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
|
||||
throw new InvalidOperationException(
|
||||
$"Could not allocate main image at required base 0x{imageBase:X16} (size=0x{totalImageSize:X}): {reason}.");
|
||||
}
|
||||
|
||||
imageBase = allocatedBase;
|
||||
@@ -737,9 +714,8 @@ public sealed class SelfLoader : ISelfLoader
|
||||
|
||||
importedRelocations = BuildImportedRelocations(descriptors);
|
||||
|
||||
var stubEligibleNids = CollectStubEligibleNids(descriptors, moduleManager);
|
||||
var stubImportNids = orderedImportNids
|
||||
.Where(stubEligibleNids.Contains)
|
||||
.Where(nid => ShouldCreateImportStub(nid, descriptors, moduleManager))
|
||||
.ToArray();
|
||||
var stubsByAddress = CreateImportStubMapping(virtualMemory, stubImportNids);
|
||||
Console.WriteLine($"[LOADER] Created {stubsByAddress.Count} import stubs");
|
||||
@@ -1184,35 +1160,6 @@ public sealed class SelfLoader : ISelfLoader
|
||||
isWeak);
|
||||
}
|
||||
|
||||
// Collects every NID that needs a trap import stub in a single pass over the
|
||||
// descriptors. This mirrors ShouldCreateImportStub applied per NID, but avoids
|
||||
// the O(nids * descriptors) rescan that filtering each unique NID against the
|
||||
// full descriptor list would incur on large modules. A NID qualifies as soon as
|
||||
// one of its descriptors is non-weak, or is weak but resolvable via the module
|
||||
// manager.
|
||||
private static HashSet<string> CollectStubEligibleNids(
|
||||
IReadOnlyList<RelocationDescriptor> descriptors,
|
||||
IModuleManager? moduleManager)
|
||||
{
|
||||
var eligible = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < descriptors.Count; i++)
|
||||
{
|
||||
var descriptor = descriptors[i];
|
||||
var nid = descriptor.ImportNid;
|
||||
if (nid is null || eligible.Contains(nid))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!descriptor.IsWeak || moduleManager?.TryGetExport(nid, out _) == true)
|
||||
{
|
||||
eligible.Add(nid);
|
||||
}
|
||||
}
|
||||
|
||||
return eligible;
|
||||
}
|
||||
|
||||
private static bool ShouldCreateImportStub(
|
||||
string nid,
|
||||
IReadOnlyList<RelocationDescriptor> descriptors,
|
||||
@@ -2484,19 +2431,6 @@ public sealed class SelfLoader : ISelfLoader
|
||||
Debug.Assert(
|
||||
!ShouldCreateImportStub("weak", [weak], moduleManager: null),
|
||||
"An unresolved weak symbol incorrectly received a trap import stub.");
|
||||
|
||||
var strong = new RelocationDescriptor(
|
||||
TargetAddress: 0x3000,
|
||||
Addend: 0,
|
||||
ImportNid: "strong",
|
||||
SymbolValue: 0,
|
||||
RelocationValueKind.Pointer,
|
||||
IsDataImport: false);
|
||||
var mixed = new List<RelocationDescriptor> { weak, strong };
|
||||
var eligible = CollectStubEligibleNids(mixed, moduleManager: null);
|
||||
Debug.Assert(
|
||||
eligible.Contains("strong") && !eligible.Contains("weak"),
|
||||
"CollectStubEligibleNids disagreed with the per-NID stub eligibility rule.");
|
||||
}
|
||||
|
||||
private static ulong AlignUp(ulong value, ulong alignment)
|
||||
|
||||
@@ -20,11 +20,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
private readonly Dictionary<(ulong DesiredAddress, ulong Alignment, bool Executable), ulong> _allocationSearchHints = new();
|
||||
private readonly Dictionary<ulong, ProgramHeaderFlags> _pageProtections = new();
|
||||
private bool _disposed;
|
||||
|
||||
[ThreadStatic]
|
||||
private static CommittedRangeCache? _committedRangeCache;
|
||||
|
||||
private long _mappingGeneration;
|
||||
private const ulong PageSize = 0x1000;
|
||||
private const ulong GuestAllocationArenaAddress = 0x00006000_0000_0000;
|
||||
private const ulong GuestAllocationArenaSize = 0x0100_0000;
|
||||
@@ -33,77 +28,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
private const ulong FullCommitRegionLimit = 4UL << 30;
|
||||
private const ulong DefaultLazyReservePrimeBytes = 0x0400_0000UL; // 64 MiB
|
||||
private const ulong LazyReservePrimeChunkBytes = 0x0200_0000UL; // 32 MiB
|
||||
private const int CommittedRangeCacheCapacity = 4;
|
||||
|
||||
private sealed class CommittedRangeCache
|
||||
{
|
||||
private readonly CommittedRange[] _ranges = new CommittedRange[CommittedRangeCacheCapacity];
|
||||
private PhysicalVirtualMemory? _owner;
|
||||
private long _generation;
|
||||
private int _count;
|
||||
private int _nextReplacement;
|
||||
|
||||
public bool Contains(
|
||||
PhysicalVirtualMemory owner,
|
||||
long generation,
|
||||
ulong start,
|
||||
ulong end)
|
||||
{
|
||||
if (!ReferenceEquals(_owner, owner) || _generation != generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var index = 0; index < _count; index++)
|
||||
{
|
||||
var range = _ranges[index];
|
||||
if (start >= range.Start && end <= range.End)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Add(
|
||||
PhysicalVirtualMemory owner,
|
||||
long generation,
|
||||
ulong start,
|
||||
ulong end)
|
||||
{
|
||||
if (!ReferenceEquals(_owner, owner) || _generation != generation)
|
||||
{
|
||||
_owner = owner;
|
||||
_generation = generation;
|
||||
_count = 0;
|
||||
_nextReplacement = 0;
|
||||
}
|
||||
|
||||
for (var index = 0; index < _count; index++)
|
||||
{
|
||||
var range = _ranges[index];
|
||||
if (start <= range.End && end >= range.Start)
|
||||
{
|
||||
_ranges[index] = new CommittedRange(
|
||||
Math.Min(start, range.Start),
|
||||
Math.Max(end, range.End));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_count < _ranges.Length)
|
||||
{
|
||||
_ranges[_count++] = new CommittedRange(start, end);
|
||||
return;
|
||||
}
|
||||
|
||||
_ranges[_nextReplacement] = new CommittedRange(start, end);
|
||||
_nextReplacement = (_nextReplacement + 1) % _ranges.Length;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct CommittedRange(ulong Start, ulong End);
|
||||
|
||||
// Raw Windows PAGE_* values retained for the internal region/protection
|
||||
// bookkeeping: regions and saved old-protection values always carry the raw
|
||||
@@ -238,15 +162,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
var alignedSize = (size + 0xFFF) & ~0xFFFUL;
|
||||
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
||||
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
|
||||
|
||||
// Reserve address space only for very large non-executable regions; commit is done lazily later.
|
||||
var reservedOnly = !executable &&
|
||||
alignedSize >= LargeDataReserveThreshold &&
|
||||
alignedSize > FullCommitRegionLimit;
|
||||
|
||||
var result = reservedOnly
|
||||
? _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite)
|
||||
: _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
|
||||
var result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
|
||||
if (result == 0)
|
||||
{
|
||||
return false;
|
||||
@@ -260,8 +176,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return false;
|
||||
}
|
||||
|
||||
var state = reservedOnly ? ReserveRegion(actualAddress, alignedSize) : "n/a";
|
||||
|
||||
_gate.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
@@ -270,7 +184,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
VirtualAddress = actualAddress,
|
||||
Size = alignedSize,
|
||||
IsExecutable = executable,
|
||||
IsReservedOnly = reservedOnly,
|
||||
IsReservedOnly = false,
|
||||
Protection = protection
|
||||
});
|
||||
}
|
||||
@@ -279,7 +193,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
_gate.ExitWriteLock();
|
||||
}
|
||||
|
||||
|
||||
var allocationKind = executable ? "executable memory" : "data memory";
|
||||
TraceVmem($"Allocated exact {allocationKind}: 0x{actualAddress:X16} - 0x{actualAddress + alignedSize:X16} ({alignedSize} bytes)");
|
||||
return true;
|
||||
@@ -372,7 +285,44 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
var actualAddress = result;
|
||||
|
||||
var lazyPrimeState = reservedOnly ? ReserveRegion(actualAddress, alignedSize) : "n/a";
|
||||
var lazyPrimeState = "n/a";
|
||||
if (reservedOnly)
|
||||
{
|
||||
var primeBytes = Math.Min(alignedSize, LazyReservePrimeBytes);
|
||||
if (primeBytes != 0)
|
||||
{
|
||||
ulong committedBytes = 0;
|
||||
while (committedBytes < primeBytes)
|
||||
{
|
||||
var remaining = primeBytes - committedBytes;
|
||||
var chunkBytes = Math.Min(remaining, LazyReservePrimeChunkBytes);
|
||||
var commitAddress = actualAddress + committedBytes;
|
||||
if (!_hostMemory.Commit(commitAddress, chunkBytes, HostPageProtection.ReadWrite))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
committedBytes += chunkBytes;
|
||||
}
|
||||
|
||||
if (committedBytes != 0)
|
||||
{
|
||||
lazyPrimeState = committedBytes == primeBytes
|
||||
? $"ok:{committedBytes:X}"
|
||||
: $"partial:{committedBytes:X}/{primeBytes:X}";
|
||||
TraceVmem($"Primed lazy region: 0x{actualAddress:X16} - 0x{actualAddress + committedBytes:X16} ({committedBytes} bytes)");
|
||||
}
|
||||
else
|
||||
{
|
||||
lazyPrimeState = $"fail:{primeBytes:X}";
|
||||
TraceVmem($"Failed to prime lazy region at 0x{actualAddress:X16} ({primeBytes} bytes), continuing with on-demand commit");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
lazyPrimeState = "skip:0";
|
||||
}
|
||||
}
|
||||
|
||||
_gate.EnterWriteLock();
|
||||
try
|
||||
@@ -399,146 +349,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return actualAddress;
|
||||
}
|
||||
|
||||
private string ReserveRegion(ulong actualAddress, ulong alignedSize)
|
||||
{
|
||||
var primeBytes = Math.Min(alignedSize, LazyReservePrimeBytes);
|
||||
if (primeBytes == 0)
|
||||
{
|
||||
return "skip:0";
|
||||
}
|
||||
|
||||
ulong committedBytes = 0;
|
||||
while (committedBytes < primeBytes)
|
||||
{
|
||||
var remaining = primeBytes - committedBytes;
|
||||
var chunkBytes = Math.Min(remaining, LazyReservePrimeChunkBytes);
|
||||
var commitAddress = actualAddress + committedBytes;
|
||||
if (!_hostMemory.Commit(commitAddress, chunkBytes, HostPageProtection.ReadWrite))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
committedBytes += chunkBytes;
|
||||
}
|
||||
|
||||
if (committedBytes != 0)
|
||||
{
|
||||
var state = committedBytes == primeBytes
|
||||
? $"ok:{committedBytes:X}"
|
||||
: $"partial:{committedBytes:X}/{primeBytes:X}";
|
||||
TraceVmem($"region: 0x{actualAddress:X16} - 0x{actualAddress + committedBytes:X16} ({committedBytes} bytes)");
|
||||
return state;
|
||||
}
|
||||
|
||||
TraceVmem($"Failed to reserve region at 0x{actualAddress:X16} ({primeBytes} bytes)!");
|
||||
return $"fail:{primeBytes:X}";
|
||||
}
|
||||
|
||||
public bool TryBackFixedRange(ulong address, ulong size, bool executable)
|
||||
{
|
||||
if (size == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var start = AlignDown(address, PageSize);
|
||||
var end = AlignUp(address + size, PageSize);
|
||||
if (end <= start)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
|
||||
|
||||
// Walk the range page-run by page-run. VirtualQuery reports the largest run
|
||||
// of same-state pages from the queried address, so a single query advances
|
||||
// us over whole free or occupied stretches. Only free stretches get backed;
|
||||
// stretches already reserved or committed by another allocation are left as
|
||||
// they are, which is exactly what a fixed mapping does on hardware.
|
||||
//
|
||||
// Because backing may span several disjoint free runs, allocations are
|
||||
// staged: host pages are reserved/committed first, and the corresponding
|
||||
// MemoryRegions are inserted only once every gap in the range has been
|
||||
// backed. If any gap fails to back, every earlier host allocation is freed
|
||||
// and no region is inserted, so the address space is left untouched.
|
||||
var stagedAllocations = new List<(ulong Address, ulong Size)>();
|
||||
|
||||
var cursor = start;
|
||||
while (cursor < end)
|
||||
{
|
||||
if (!_hostMemory.Query(cursor, out var info))
|
||||
{
|
||||
goto Rollback;
|
||||
}
|
||||
|
||||
var queriedEnd = info.RegionSize > ulong.MaxValue - info.BaseAddress
|
||||
? ulong.MaxValue
|
||||
: info.BaseAddress + info.RegionSize;
|
||||
var runEnd = Math.Min(end, queriedEnd);
|
||||
if (runEnd <= cursor)
|
||||
{
|
||||
goto Rollback;
|
||||
}
|
||||
|
||||
if (info.State == HostRegionState.Free)
|
||||
{
|
||||
var runSize = runEnd - cursor;
|
||||
var allocated = _hostMemory.Allocate(cursor, runSize, hostProtection);
|
||||
if (allocated != cursor)
|
||||
{
|
||||
if (allocated != 0)
|
||||
{
|
||||
_hostMemory.Free(allocated);
|
||||
}
|
||||
|
||||
goto Rollback;
|
||||
}
|
||||
|
||||
stagedAllocations.Add((cursor, runSize));
|
||||
TraceVmem($"Backed fixed range gap: 0x{cursor:X16} - 0x{runEnd:X16} ({runSize} bytes)");
|
||||
}
|
||||
|
||||
cursor = runEnd;
|
||||
}
|
||||
|
||||
if (stagedAllocations.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// All gaps backed successfully — insert regions in one batch.
|
||||
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
||||
_gate.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
foreach (var (gapAddress, gapSize) in stagedAllocations)
|
||||
{
|
||||
InsertRegionSorted(new MemoryRegion
|
||||
{
|
||||
VirtualAddress = gapAddress,
|
||||
Size = gapSize,
|
||||
IsExecutable = executable,
|
||||
IsReservedOnly = false,
|
||||
Protection = protection
|
||||
});
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.ExitWriteLock();
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
Rollback:
|
||||
foreach (var (gapAddress, _) in stagedAllocations)
|
||||
{
|
||||
_hostMemory.Free(gapAddress);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryAllocateAtOrAbove(
|
||||
ulong desiredAddress,
|
||||
ulong size,
|
||||
@@ -630,7 +440,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
_gate.ExitWriteLock();
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _mappingGeneration);
|
||||
_hostMemory.Free(address);
|
||||
}
|
||||
|
||||
@@ -802,7 +611,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
{
|
||||
_allocationSearchHints.Clear();
|
||||
}
|
||||
Interlocked.Increment(ref _mappingGeneration);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1065,15 +873,6 @@ 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
|
||||
@@ -1111,7 +910,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1137,68 +935,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
}
|
||||
|
||||
private static void NotifyGuestWriteWatch(ulong virtualAddress, ReadOnlySpan<byte> source)
|
||||
{
|
||||
if (GuestWriteWatch.Armed)
|
||||
{
|
||||
GuestWriteWatch.Check(virtualAddress, source);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length)
|
||||
{
|
||||
if (length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (length > int.MaxValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match TryWrite's managed-write notification before touching an
|
||||
// identity-mapped guest page protected by the image tracker.
|
||||
GuestImageWriteTracker.NotifyManagedWrite(destinationAddress, length);
|
||||
|
||||
_gate.EnterReadLock();
|
||||
try
|
||||
{
|
||||
var sourceRegion = FindRegion(sourceAddress, length);
|
||||
var destinationRegion = FindRegion(destinationAddress, length);
|
||||
if (sourceRegion is null || destinationRegion is null ||
|
||||
!TryResolveRegionOffset(sourceAddress, length, sourceRegion, out var sourceOffset) ||
|
||||
!TryResolveRegionOffset(destinationAddress, length, destinationRegion, out var destinationOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sourcePointer = sourceRegion.VirtualAddress + sourceOffset;
|
||||
var destinationPointer = destinationRegion.VirtualAddress + destinationOffset;
|
||||
if ((sourceRegion.IsReservedOnly &&
|
||||
!EnsureRangeCommitted(sourcePointer, length, sourceRegion)) ||
|
||||
(destinationRegion.IsReservedOnly &&
|
||||
!EnsureRangeCommitted(destinationPointer, length, destinationRegion)) ||
|
||||
!CanReadWithoutProtectionChange(sourcePointer, length, sourceRegion) ||
|
||||
!CanWriteWithoutProtectionChange(destinationPointer, length, destinationRegion))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Span.CopyTo has memmove overlap semantics, so this allocation-free
|
||||
// path safely serves both libc memcpy and libc memmove.
|
||||
new ReadOnlySpan<byte>((void*)sourcePointer, checked((int)length)).CopyTo(
|
||||
new Span<byte>((void*)destinationPointer, checked((int)length)));
|
||||
NotifyGuestWriteWatch(
|
||||
destinationAddress,
|
||||
new ReadOnlySpan<byte>((void*)destinationPointer, checked((int)length)));
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryReadExclusive(ulong virtualAddress, Span<byte> destination)
|
||||
{
|
||||
var region = FindRegion(virtualAddress, (ulong)destination.Length);
|
||||
@@ -1271,7 +1007,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1296,7 +1031,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1538,12 +1272,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
var startPage = AlignDown(address, PageSize);
|
||||
var endPage = AlignUp(address + size, PageSize);
|
||||
var mappingGeneration = Volatile.Read(ref _mappingGeneration);
|
||||
var committedRangeCache = _committedRangeCache ??= new CommittedRangeCache();
|
||||
if (committedRangeCache.Contains(this, mappingGeneration, startPage, endPage))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
var commitProtection = GetCommitProtection(region);
|
||||
|
||||
var pageAddress = startPage;
|
||||
@@ -1565,9 +1293,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
if (info.State == HostRegionState.Committed)
|
||||
{
|
||||
// The host query proved this whole range is committed. Retain
|
||||
// that result instead of caching only the caller's small span.
|
||||
CacheCommittedRange(info.BaseAddress, queriedEnd, mappingGeneration);
|
||||
pageAddress = rangeEnd;
|
||||
continue;
|
||||
}
|
||||
@@ -1583,23 +1308,12 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return false;
|
||||
}
|
||||
|
||||
CacheCommittedRange(pageAddress, rangeEnd, mappingGeneration);
|
||||
pageAddress = rangeEnd;
|
||||
}
|
||||
|
||||
CacheCommittedRange(startPage, endPage, mappingGeneration);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void CacheCommittedRange(ulong startPage, ulong endPage, long mappingGeneration)
|
||||
{
|
||||
(_committedRangeCache ??= new CommittedRangeCache()).Add(
|
||||
this,
|
||||
mappingGeneration,
|
||||
startPage,
|
||||
endPage);
|
||||
}
|
||||
|
||||
private bool TryTemporarilyProtectForRead(
|
||||
ulong address,
|
||||
ulong size,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Core.Loader;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Core.Memory;
|
||||
|
||||
@@ -94,14 +93,8 @@ public sealed class VirtualMemory : IVirtualMemory
|
||||
}
|
||||
|
||||
CopyToRegions(virtualAddress, source, regionIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (GuestWriteWatch.Armed)
|
||||
{
|
||||
GuestWriteWatch.Check(virtualAddress, source);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryValidateRange(
|
||||
|
||||
@@ -248,7 +248,7 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
{
|
||||
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
|
||||
if (!CreateProcessW(
|
||||
null,
|
||||
exePath,
|
||||
commandLine,
|
||||
0,
|
||||
0,
|
||||
@@ -629,7 +629,7 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
|
||||
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CreateProcessW(string? applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
|
||||
private static extern bool CreateProcessW(string applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern uint WaitForSingleObject(nint handle, uint milliseconds);
|
||||
|
||||
@@ -351,13 +351,6 @@ 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,9 +53,6 @@ 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
|
||||
@@ -74,7 +71,7 @@ public sealed class GuiSettings
|
||||
if (File.Exists(SettingsPath))
|
||||
{
|
||||
var json = File.ReadAllText(SettingsPath);
|
||||
return NormalizeFromJson(json);
|
||||
return JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -85,39 +82,6 @@ public sealed class GuiSettings
|
||||
return new GuiSettings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes settings and normalizes null references and null or empty list
|
||||
/// entries introduced by JSON. Empty scalar strings remain unchanged.
|
||||
/// </summary>
|
||||
internal static GuiSettings NormalizeFromJson(string json)
|
||||
{
|
||||
var settings = JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
|
||||
|
||||
settings.GameFolders = FilterNullOrEmpty(settings.GameFolders);
|
||||
settings.ExcludedGames = FilterNullOrEmpty(settings.ExcludedGames);
|
||||
settings.EnvironmentToggles = FilterNullOrEmpty(settings.EnvironmentToggles);
|
||||
settings.LogLevel ??= "Info";
|
||||
settings.Language ??= "en";
|
||||
settings.DiscordClientId ??= "1525606762248540221";
|
||||
if (settings.RenderResolutionScale <= 0 || settings.RenderResolutionScale > 2.0)
|
||||
{
|
||||
settings.RenderResolutionScale = 1.0;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -400,29 +400,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="GraphicsTabItem" Header="Graphics" FontSize="15">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock x:Name="RenderingSectionTitle" Classes="sectionTitle" Text="RENDERING" />
|
||||
|
||||
<local:SettingRow x:Name="RenderResolutionRow" Label="Internal resolution"
|
||||
Description="Render offscreen targets below native resolution and upscale on present. Lower values trade image quality for GPU headroom; takes effect on next launch.">
|
||||
<ComboBox x:Name="RenderResolutionBox" Width="160" SelectedIndex="0"
|
||||
VerticalAlignment="Center" CornerRadius="8">
|
||||
<ComboBoxItem x:Name="RenderResolution100Item" Content="100% (native)" Tag="1.0" />
|
||||
<ComboBoxItem x:Name="RenderResolution75Item" Content="75%" Tag="0.75" />
|
||||
<ComboBoxItem x:Name="RenderResolution50Item" Content="50%" Tag="0.5" />
|
||||
<ComboBoxItem x:Name="RenderResolution25Item" Content="25%" Tag="0.25" />
|
||||
</ComboBox>
|
||||
</local:SettingRow>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="EnvTabItem" Header="Environment" FontSize="15">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
||||
|
||||
@@ -92,11 +92,6 @@ public partial class MainWindow : Window
|
||||
// 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;
|
||||
@@ -155,18 +150,8 @@ public partial class MainWindow : Window
|
||||
};
|
||||
_libraryBlurTimer.Tick += (_, _) => AdvanceLibraryBlur();
|
||||
|
||||
// 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;
|
||||
};
|
||||
Activated += (_, _) => UpdateSessionBarVisibility();
|
||||
Deactivated += (_, _) => SessionBarPopup.IsOpen = false;
|
||||
|
||||
TitleBar.PointerPressed += OnTitleBarPointerPressed;
|
||||
GameList.SelectionChanged += (_, _) => UpdateSelectedGame();
|
||||
@@ -192,18 +177,6 @@ public partial class MainWindow : Window
|
||||
// it is open already uses the new values.
|
||||
LogLevelBox.SelectionChanged += (_, _) => _settings.LogLevel = SelectedLogLevel();
|
||||
TraceImportsBox.ValueChanged += (_, _) => _settings.ImportTraceLimit = (int)(TraceImportsBox.Value ?? 0);
|
||||
RenderResolutionBox.SelectionChanged += (_, _) =>
|
||||
{
|
||||
if (RenderResolutionBox.SelectedItem is ComboBoxItem { Tag: string tag } &&
|
||||
double.TryParse(
|
||||
tag,
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var scale))
|
||||
{
|
||||
_settings.RenderResolutionScale = scale;
|
||||
}
|
||||
};
|
||||
StrictToggle.IsCheckedChanged += (_, _) => _settings.StrictDynlibResolution = StrictToggle.IsChecked == true;
|
||||
LogToFileToggle.IsCheckedChanged += (_, _) => _settings.LogToFile = LogToFileToggle.IsChecked == true;
|
||||
OverrideLogFileToggle.IsCheckedChanged += (_, _) =>
|
||||
@@ -441,15 +414,6 @@ 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)
|
||||
{
|
||||
@@ -499,6 +463,11 @@ public partial class MainWindow : Window
|
||||
LaunchSelected();
|
||||
}
|
||||
|
||||
if ((pressed & HostGamepadButtons.Circle) != 0)
|
||||
{
|
||||
StopEmulator();
|
||||
}
|
||||
|
||||
_previousPadButtons = pad.Buttons;
|
||||
}
|
||||
|
||||
@@ -881,13 +850,6 @@ public partial class MainWindow : Window
|
||||
_ => 2,
|
||||
};
|
||||
TraceImportsBox.Value = Math.Clamp(_settings.ImportTraceLimit, 0, 4096);
|
||||
RenderResolutionBox.SelectedIndex = _settings.RenderResolutionScale switch
|
||||
{
|
||||
>= 0.875 => 0,
|
||||
>= 0.625 => 1,
|
||||
>= 0.375 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
StrictToggle.IsChecked = _settings.StrictDynlibResolution;
|
||||
LogToFileToggle.IsChecked = _settings.LogToFile;
|
||||
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
|
||||
@@ -1664,23 +1626,13 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1807,12 +1759,6 @@ 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;
|
||||
@@ -2055,27 +2001,16 @@ public partial class MainWindow : Window
|
||||
RestoreGameViewToFull();
|
||||
GameView.Background = Brushes.Black;
|
||||
GameView.IsHitTestVisible = true;
|
||||
_gameSurfaceHost?.SetPresentationVisible(true);
|
||||
_gameSurfaceHost?.SetCursorAutoHide(true);
|
||||
LibraryPage.IsVisible = false;
|
||||
OptionsPage.IsVisible = false;
|
||||
LibraryToolbar.IsVisible = false;
|
||||
ContentToolbar.IsVisible = false;
|
||||
ConsolePanel.IsVisible = false;
|
||||
LaunchBar.IsVisible = false;
|
||||
HideSessionLoading();
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2174,7 +2109,7 @@ public partial class MainWindow : Window
|
||||
GameView.IsVisible = false;
|
||||
GameView.IsHitTestVisible = true;
|
||||
SessionBarPopup.IsOpen = false;
|
||||
HideSessionLoading();
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
AnimateLibraryBlur(0, clearWhenComplete: true);
|
||||
MainContent.Margin = new Thickness(32, 24, 32, 20);
|
||||
ContentToolbar.IsVisible = true;
|
||||
@@ -2258,14 +2193,7 @@ public partial class MainWindow : Window
|
||||
{
|
||||
SessionLoadingTitle.Text = title;
|
||||
SessionLoadingDetail.Text = detail;
|
||||
_sessionLoadingActive = true;
|
||||
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
|
||||
}
|
||||
|
||||
private void HideSessionLoading()
|
||||
{
|
||||
_sessionLoadingActive = false;
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
SessionLoadingPopup.IsOpen = true;
|
||||
}
|
||||
|
||||
private void ReturnToLibraryWhileStopping()
|
||||
|
||||
@@ -49,7 +49,7 @@ public sealed class PerGameSettings
|
||||
var path = PathFor(titleId);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return NormalizeFromJson(File.ReadAllText(path));
|
||||
return JsonSerializer.Deserialize<PerGameSettings>(File.ReadAllText(path), SerializerOptions);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -59,18 +59,6 @@ public sealed class PerGameSettings
|
||||
return null;
|
||||
}
|
||||
|
||||
// A null list inherits global settings; only entries in a present list are sanitized.
|
||||
internal static PerGameSettings? NormalizeFromJson(string json)
|
||||
{
|
||||
var settings = JsonSerializer.Deserialize<PerGameSettings>(json, SerializerOptions);
|
||||
if (settings?.EnvironmentToggles is { } toggles)
|
||||
{
|
||||
settings.EnvironmentToggles = toggles.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
public void Save(string titleId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(titleId))
|
||||
|
||||
@@ -24,10 +24,6 @@ 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" />
|
||||
|
||||
@@ -32,7 +32,6 @@ 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;
|
||||
@@ -52,33 +51,9 @@ public static unsafe class GuestImageWriteTracker
|
||||
private static readonly object _gate = new();
|
||||
private static readonly Dictionary<ulong, TrackedRange> _rangesByAddress = new();
|
||||
|
||||
/// <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;
|
||||
// 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 = [];
|
||||
|
||||
private static readonly bool _enabled = !OperatingSystem.IsWindows() &&
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
|
||||
@@ -156,21 +131,10 @@ public static unsafe class GuestImageWriteTracker
|
||||
{
|
||||
// Never resize an object that is still reachable from the
|
||||
// signal handler's lock-free snapshot. Retire it and publish
|
||||
// a fresh immutable range, carrying the write generation so
|
||||
// resizes do not hide guest CPU rewrites from cache owners.
|
||||
var writeGeneration = Volatile.Read(ref range.WriteGeneration);
|
||||
// a fresh immutable range.
|
||||
DisarmLocked(range, "replace-range");
|
||||
_rangesByAddress.Remove(address);
|
||||
range = new TrackedRange
|
||||
{
|
||||
Address = address,
|
||||
ByteCount = byteCount,
|
||||
Start = start,
|
||||
End = start + length,
|
||||
WriteGeneration = writeGeneration,
|
||||
};
|
||||
_rangesByAddress[address] = range;
|
||||
RebuildSnapshotLocked();
|
||||
range = null;
|
||||
}
|
||||
|
||||
if (range is null)
|
||||
@@ -284,31 +248,6 @@ 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
|
||||
@@ -327,17 +266,6 @@ 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)
|
||||
{
|
||||
@@ -383,7 +311,7 @@ public static unsafe class GuestImageWriteTracker
|
||||
return false;
|
||||
}
|
||||
|
||||
var ranges = Volatile.Read(ref _rangeSnapshot).Ranges;
|
||||
var ranges = Volatile.Read(ref _rangeSnapshot);
|
||||
var writableStart = ulong.MaxValue;
|
||||
var writableEnd = 0UL;
|
||||
for (var index = 0; index < ranges.Length; index++)
|
||||
@@ -462,10 +390,6 @@ 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)
|
||||
@@ -534,7 +458,7 @@ public static unsafe class GuestImageWriteTracker
|
||||
|
||||
private static void RebuildSnapshotLocked()
|
||||
{
|
||||
Volatile.Write(ref _rangeSnapshot, new RangeSnapshot(_rangesByAddress.Values.ToArray()));
|
||||
_rangeSnapshot = _rangesByAddress.Values.ToArray();
|
||||
}
|
||||
|
||||
private static (ulong Start, ulong Length) PageAlign(ulong address, ulong byteCount)
|
||||
|
||||
@@ -221,29 +221,6 @@ public static class GuestThreadExecution
|
||||
|
||||
public static IGuestThreadScheduler? Scheduler { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fired when a guest thread is torn down without a clean pthread_exit
|
||||
/// (e.g. TBB execute-AV → worker_abort). Libs use this to abandon mutexes.
|
||||
/// </summary>
|
||||
public static event Func<ulong, string, int>? GuestThreadAbandoned;
|
||||
|
||||
public static int NotifyGuestThreadAbandoned(ulong threadHandle, string reason)
|
||||
{
|
||||
if (threadHandle == 0 || GuestThreadAbandoned is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return GuestThreadAbandoned.Invoke(threadHandle, reason);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsGuestThread => _currentGuestThreadHandle != 0;
|
||||
|
||||
public static ulong CurrentGuestThreadHandle => _currentGuestThreadHandle;
|
||||
|
||||
@@ -17,7 +17,7 @@ public static class GuestTlsTemplate
|
||||
// Must match CpuDispatcher/DirectExecutionBackend's mapped prefix. PS5
|
||||
// modules can require more than one host page of Variant II static TLS;
|
||||
// Dreaming Sarah's startup image, for example, reaches 0x1870 bytes.
|
||||
public const ulong StartupStaticTlsReservation = 0x20000UL; // Was 0x10000UL, but thats too small for GTA V
|
||||
public const ulong StartupStaticTlsReservation = 0x10000UL;
|
||||
private static readonly object _gate = new();
|
||||
private static readonly SortedDictionary<ulong, ModuleTemplate> _modules = new();
|
||||
private static readonly Dictionary<ulong, ThreadDtv> _threadDtvs = new();
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
|
||||
namespace SharpEmu.HLE;
|
||||
|
||||
// This tool monitors guest-memory writes only when a watch mode is active.
|
||||
public static class GuestWriteWatch
|
||||
{
|
||||
private const ulong WatchBytes = 8;
|
||||
private const int MaxBulkReports = 64;
|
||||
|
||||
private static readonly ulong WatchBase = Parse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_WATCH_WRITE"));
|
||||
|
||||
private static readonly bool WatchPoolHeaders = IsEnabled("SHARPEMU_WATCH_POOL_HEADER");
|
||||
|
||||
private static readonly ulong[] PoolSlots = new ulong[64];
|
||||
private static int _poolSlotCount;
|
||||
|
||||
private static readonly bool WatchValuePattern = IsEnabled("SHARPEMU_WATCH_VALUE_PATTERN");
|
||||
|
||||
private static readonly bool WatchValue1 = IsEnabled("SHARPEMU_WATCH_VALUE1");
|
||||
|
||||
private const ulong DirectBandLow = 0x100_0000_0000;
|
||||
private const ulong DirectBandHigh = 0x1000_0000_0000;
|
||||
private static int _value1Reports;
|
||||
|
||||
private static readonly bool WatchBulkTorn = IsEnabled("SHARPEMU_WATCH_BULK_TORN");
|
||||
|
||||
private static readonly ulong BulkDestHigh = Parse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_WATCH_BULK_DEST_HI"));
|
||||
private static int _bulkTornReports;
|
||||
private static int _bulkShiftReports;
|
||||
|
||||
public static bool Armed =>
|
||||
WatchBase != 0 || WatchPoolHeaders || WatchValuePattern || WatchValue1 || WatchBulkTorn;
|
||||
|
||||
public static void OnDirectMapping(ulong mappedAddress, ulong length, int protection)
|
||||
{
|
||||
if (!WatchPoolHeaders || !IsPoolMapping(length, protection))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var index = Interlocked.Increment(ref _poolSlotCount) - 1;
|
||||
if (index < PoolSlots.Length)
|
||||
{
|
||||
Volatile.Write(ref PoolSlots[index], mappedAddress + 0x40);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] watch_write armed on pool header slot 0x{mappedAddress + 0x40:X16}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void Check(ulong address, ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (WatchBulkTorn &&
|
||||
data.Length >= 8 &&
|
||||
(BulkDestHigh != 0
|
||||
? (address >> 32) == BulkDestHigh
|
||||
: address >= DirectBandLow && address < DirectBandHigh))
|
||||
{
|
||||
for (var offset = FirstAlignedOffset(address); offset + 8 <= data.Length; offset += 8)
|
||||
{
|
||||
var qword = BinaryPrimitives.ReadUInt64LittleEndian(data.Slice(offset, 8));
|
||||
var kind = ClassifyBulkValue(qword);
|
||||
if (kind is not null && ReserveBulkReport(kind))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] watch_bulk_torn HIT ({kind}) " +
|
||||
$"dest=0x{address + (ulong)offset:X16} (base=0x{address:X16}+0x{offset:X}) " +
|
||||
$"len={data.Length} qword=0x{qword:X16}{Environment.NewLine}{Environment.StackTrace}");
|
||||
Console.Error.Flush();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (WatchValue1 &&
|
||||
address >= DirectBandLow && address < DirectBandHigh &&
|
||||
data.Length is >= 1 and <= 8 &&
|
||||
LittleEndianValue(data) == 1 &&
|
||||
Interlocked.Increment(ref _value1Reports) <= 128)
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (WatchValuePattern && data.Length == 8)
|
||||
{
|
||||
var value = BinaryPrimitives.ReadUInt64LittleEndian(data);
|
||||
if ((value & 0xFFFFFFFF) == 1 && value >> 32 is > 0 and <= 0xFFFF)
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (WatchBase != 0 && Overlaps(address, data.Length, WatchBase))
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
|
||||
var slots = Math.Min(Volatile.Read(ref _poolSlotCount), PoolSlots.Length);
|
||||
for (var i = 0; i < slots; i++)
|
||||
{
|
||||
var slot = Volatile.Read(ref PoolSlots[i]);
|
||||
if (slot != 0 && Overlaps(address, data.Length, slot))
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static string? ClassifyBulkValue(ulong qword)
|
||||
{
|
||||
var low32 = qword & 0xFFFFFFFF;
|
||||
var high32 = qword >> 32;
|
||||
if (low32 == 1 && high32 is > 0 and <= 0xFFFF)
|
||||
{
|
||||
return "torn";
|
||||
}
|
||||
|
||||
var prefix = low32 & 0xFF00_0000;
|
||||
var hasShiftedPointerPrefix = prefix is 0x0800_0000 or 0x8000_0000;
|
||||
return high32 == 0 && hasShiftedPointerPrefix && (low32 & 0xFF) == 0
|
||||
? "shift"
|
||||
: null;
|
||||
}
|
||||
|
||||
internal static int FirstAlignedOffset(ulong address) =>
|
||||
(int)((8 - (address & 7)) & 7);
|
||||
|
||||
internal static bool IsPoolMapping(ulong length, int protection) =>
|
||||
length == 0x10000 && protection == 0xF2;
|
||||
|
||||
internal static bool Overlaps(ulong address, int length, ulong slot)
|
||||
{
|
||||
if (length <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var writeLength = (ulong)length - 1;
|
||||
var writeEnd = address > ulong.MaxValue - writeLength
|
||||
? ulong.MaxValue
|
||||
: address + writeLength;
|
||||
var slotEnd = slot > ulong.MaxValue - (WatchBytes - 1)
|
||||
? ulong.MaxValue
|
||||
: slot + WatchBytes - 1;
|
||||
return address <= slotEnd && slot <= writeEnd;
|
||||
}
|
||||
|
||||
private static ulong LittleEndianValue(ReadOnlySpan<byte> data)
|
||||
{
|
||||
ulong value = 0;
|
||||
for (var i = 0; i < data.Length; i++)
|
||||
{
|
||||
value |= (ulong)data[i] << (i * 8);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void Report(ulong address, ReadOnlySpan<byte> data)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] watch_write HIT addr=0x{address:X16} len={data.Length} " +
|
||||
$"first_qword=0x{LittleEndianValue(data):X16}{Environment.NewLine}{Environment.StackTrace}");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
|
||||
private static bool IsEnabled(string name) =>
|
||||
string.Equals(Environment.GetEnvironmentVariable(name), "1", StringComparison.Ordinal);
|
||||
|
||||
private static bool ReserveBulkReport(string kind) =>
|
||||
kind == "torn"
|
||||
? Interlocked.Increment(ref _bulkTornReports) <= MaxBulkReports
|
||||
: Interlocked.Increment(ref _bulkShiftReports) <= MaxBulkReports;
|
||||
|
||||
internal static ulong Parse(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
text = text.Trim();
|
||||
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
text = text[2..];
|
||||
}
|
||||
|
||||
return ulong.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value)
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,4 @@ 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,17 +15,6 @@ 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);
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using SharpEmu.Libs.Gpu;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
@@ -27,8 +26,8 @@ internal static class AgcShaderCompilerHooks
|
||||
internal static void Install()
|
||||
{
|
||||
Gen5ShaderScalarEvaluator.FallbackMemoryReader =
|
||||
KernelMemoryCompatExports.TryReadShaderGuestMemory;
|
||||
KernelMemoryCompatExports.TryReadTrackedLibcHeap;
|
||||
Gen5ShaderScalarEvaluator.GlobalMemoryPool =
|
||||
GuestDataPool.Shared;
|
||||
VulkanVideoPresenter.GuestDataPool;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +1,8 @@
|
||||
// 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>Which in-block address equation a <see cref="DetileParams"/> carries.</summary>
|
||||
internal enum DetileEquation
|
||||
{
|
||||
/// <summary>Unsupported mode/format; caller must use the CPU path or raw upload.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Exact AddrLib XOR equation (RDNA2 modes 5/9/24/27): factored X/Y terms.</summary>
|
||||
ExactXor,
|
||||
|
||||
/// <summary>Other modes: a precomputed in-block Morton/standard element-offset table.</summary>
|
||||
BlockTable,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backend-agnostic description of how to deswizzle one surface, produced by
|
||||
/// <see cref="GnmTiling.GetDetileParams"/>. Holds only plain integers and small
|
||||
/// int[] tables — no host graphics-API types — so it can cross the guest-GPU
|
||||
/// backend seam and drive a Vulkan (SPIR-V) or Metal (MSL) detile compute kernel
|
||||
/// identically to the CPU <see cref="GnmTiling.TryDetile"/> fallback. The single
|
||||
/// shared addressing formula both consume is:
|
||||
/// <code>
|
||||
/// inBlockByte = Equation == ExactXor
|
||||
/// ? XByteTerm[x & XMask] ^ YByteTerm[y & YMask]
|
||||
/// : BlockTable[(y % BlockHeight) * BlockWidth + (x % BlockWidth)] * BytesPerElement;
|
||||
/// srcByte = ((y / BlockHeight) * BlocksPerRow + (x / BlockWidth)) * BlockBytes + inBlockByte;
|
||||
/// </code>
|
||||
/// </summary>
|
||||
internal readonly record struct DetileParams(
|
||||
DetileEquation Equation,
|
||||
int ElementsWide,
|
||||
int ElementsHigh,
|
||||
int BytesPerElement,
|
||||
int BlockWidth,
|
||||
int BlockHeight,
|
||||
int BlockElements,
|
||||
int BlockBytes,
|
||||
int BlocksPerRow,
|
||||
// ExactXor: within-block BYTE offset = XByteTerm[x & XMask] ^ YByteTerm[y & YMask].
|
||||
int[] XByteTerm,
|
||||
int XMask,
|
||||
int[] YByteTerm,
|
||||
int YMask,
|
||||
// BlockTable: within-block ELEMENT offset = BlockTable[inBlockY * BlockWidth + inBlockX].
|
||||
int[] BlockTable)
|
||||
{
|
||||
/// <summary>False when the mode/format is not GPU-portable (Equation == None).</summary>
|
||||
public bool IsSupported => Equation != DetileEquation.None;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deswizzles RDNA2 (GFX10) tiled texture surfaces into linear layout so they
|
||||
/// can be uploaded to Vulkan. PS5 stores most textures in a swizzled layout
|
||||
@@ -68,11 +16,8 @@ internal readonly record struct DetileParams(
|
||||
/// other D/R and pipe/bank-XOR modes stay opt-in while their complete AddrLib
|
||||
/// equations are being ported.
|
||||
/// </summary>
|
||||
internal static unsafe class GnmTiling
|
||||
internal static 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.
|
||||
@@ -173,14 +118,6 @@ internal static unsafe 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;
|
||||
|
||||
@@ -257,164 +194,6 @@ internal static unsafe 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
|
||||
@@ -467,218 +246,50 @@ internal static unsafe class GnmTiling
|
||||
return false;
|
||||
}
|
||||
|
||||
// Address tables depend only on the swizzle equation and element size,
|
||||
// so retain them across textures instead of rebuilding them per upload.
|
||||
// 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.
|
||||
var hasExactXorPattern = TryGetExactXorPattern(swizzleMode, bppLog2, out var xorPattern);
|
||||
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));
|
||||
|
||||
// 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 blockTable = hasExactXorPattern ? [] : new int[blockWidth * blockHeight];
|
||||
for (var by = 0; !hasExactXorPattern && by < blockHeight; by++)
|
||||
{
|
||||
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) =>
|
||||
for (var bx = 0; bx < blockWidth; bx++)
|
||||
{
|
||||
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++)
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
blockTable[by * blockWidth + bx] = (int)(kind == SwizzleKind.ZOrder
|
||||
? MortonInterleave((uint)bx, (uint)by, blockWidth, blockHeight)
|
||||
: StandardSwizzleOffset((uint)bx, (uint)by, blockWidth, blockHeight));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the detile parameters for a surface without performing the copy,
|
||||
/// so a GPU compute kernel can run the deswizzle instead of the CPU. Returns
|
||||
/// <see cref="DetileParams.IsSupported"/> == false (Equation == None) when the
|
||||
/// mode/format is not GPU-portable, so the caller keeps the CPU
|
||||
/// <see cref="TryDetile"/> path or a raw upload. Reuses the same helpers and
|
||||
/// caches as <see cref="TryDetile"/>, so the two never disagree on addressing.
|
||||
/// </summary>
|
||||
public static DetileParams GetDetileParams(
|
||||
uint swizzleMode,
|
||||
int bytesPerElement,
|
||||
int elementsWide,
|
||||
int elementsHigh)
|
||||
{
|
||||
if (!ShouldDetile(swizzleMode) ||
|
||||
bytesPerElement <= 0 ||
|
||||
elementsWide <= 0 ||
|
||||
elementsHigh <= 0 ||
|
||||
!TryGetSwizzleKind(swizzleMode, out var kind, out var blockBytes))
|
||||
for (var y = 0; y < elementsHigh; y++)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
var bppLog2 = BitLog2((uint)bytesPerElement);
|
||||
if (bppLog2 < 0)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
var blockElements = blockBytes >> bppLog2;
|
||||
var (blockWidth, blockHeight) = SquareBlockDimensions(blockElements);
|
||||
if (blockWidth == 0 || blockHeight == 0)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
var blocksPerRow = (elementsWide + blockWidth - 1) / blockWidth;
|
||||
|
||||
if (TryGetExactXorPattern(swizzleMode, bppLog2, out var pattern))
|
||||
{
|
||||
var terms = _patternTermCache.GetOrAdd(
|
||||
(swizzleMode, bppLog2),
|
||||
_ => CreatePatternTerms(pattern));
|
||||
return new DetileParams(
|
||||
DetileEquation.ExactXor,
|
||||
elementsWide,
|
||||
elementsHigh,
|
||||
bytesPerElement,
|
||||
blockWidth,
|
||||
blockHeight,
|
||||
blockElements,
|
||||
blockBytes,
|
||||
blocksPerRow,
|
||||
terms.X,
|
||||
terms.XMask,
|
||||
terms.Y,
|
||||
terms.YMask,
|
||||
[]);
|
||||
}
|
||||
|
||||
var blockTable = _blockTableCache.GetOrAdd(
|
||||
(kind, blockWidth, blockHeight),
|
||||
static key => CreateBlockTable(key.Kind, key.Width, key.Height));
|
||||
return new DetileParams(
|
||||
DetileEquation.BlockTable,
|
||||
elementsWide,
|
||||
elementsHigh,
|
||||
bytesPerElement,
|
||||
blockWidth,
|
||||
blockHeight,
|
||||
blockElements,
|
||||
blockBytes,
|
||||
blocksPerRow,
|
||||
[],
|
||||
0,
|
||||
[],
|
||||
0,
|
||||
blockTable);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CPU deswizzle driven entirely by a resolved <see cref="DetileParams"/> — the
|
||||
/// exact addressing the Vulkan/Metal compute kernel runs per texel, so a
|
||||
/// backend that packaged <paramref name="parameters"/> for the GPU path can
|
||||
/// fall back to this without re-deriving the swizzle. Copies
|
||||
/// <c>ElementsWide * ElementsHigh</c> elements from <paramref name="tiled"/>
|
||||
/// into <paramref name="linear"/>; returns false when unsupported or the output
|
||||
/// span is too small. Out-of-range source elements are left zero (matching the
|
||||
/// reference), so a truncated <paramref name="tiled"/> degrades gracefully.
|
||||
/// </summary>
|
||||
public static bool DetileWithParams(
|
||||
in DetileParams parameters,
|
||||
ReadOnlySpan<byte> tiled,
|
||||
Span<byte> linear)
|
||||
{
|
||||
if (!parameters.IsSupported)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var width = parameters.ElementsWide;
|
||||
var height = parameters.ElementsHigh;
|
||||
var bpp = parameters.BytesPerElement;
|
||||
var requiredLinear = (long)width * height * bpp;
|
||||
if (width <= 0 || height <= 0 || bpp <= 0 || linear.Length < requiredLinear)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var isExactXor = parameters.Equation == DetileEquation.ExactXor;
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
var blockY = y / parameters.BlockHeight;
|
||||
var inY = y % parameters.BlockHeight;
|
||||
var yTerm = isExactXor ? parameters.YByteTerm[y & parameters.YMask] : 0;
|
||||
for (var x = 0; x < width; x++)
|
||||
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 blockX = x / parameters.BlockWidth;
|
||||
var inBlockByte = isExactXor
|
||||
? parameters.XByteTerm[x & parameters.XMask] ^ yTerm
|
||||
: parameters.BlockTable[inY * parameters.BlockWidth + (x % parameters.BlockWidth)] * bpp;
|
||||
var srcByte = ((long)blockY * parameters.BlocksPerRow + blockX) * parameters.BlockBytes + inBlockByte;
|
||||
var dstByte = ((long)y * width + x) * bpp;
|
||||
if (srcByte < 0 || srcByte + bpp > tiled.Length)
|
||||
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)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
tiled.Slice((int)srcByte, bpp).CopyTo(linear.Slice((int)dstByte, bpp));
|
||||
tiled.Slice((int)sourceByte, bytesPerElement)
|
||||
.CopyTo(linear.Slice((int)destByte, bytesPerElement));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -691,80 +302,6 @@ internal static unsafe 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);
|
||||
@@ -798,20 +335,14 @@ internal static unsafe class GnmTiling
|
||||
return pattern.Length != 0;
|
||||
}
|
||||
|
||||
// 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)
|
||||
private static long ComputePatternOffset(uint x, uint y, AddressBit[] pattern)
|
||||
{
|
||||
uint offset = 0;
|
||||
for (var bit = 0; bit < pattern.Length; bit++)
|
||||
{
|
||||
var mask = useX ? pattern[bit].XMask : pattern[bit].YMask;
|
||||
var parity = System.Numerics.BitOperations.PopCount(coordinate & mask) & 1;
|
||||
var equation = pattern[bit];
|
||||
var parity = (System.Numerics.BitOperations.PopCount(x & equation.XMask) +
|
||||
System.Numerics.BitOperations.PopCount(y & equation.YMask)) & 1;
|
||||
offset |= (uint)parity << bit;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,26 +37,10 @@ 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
|
||||
{
|
||||
@@ -130,14 +114,8 @@ internal static class GpuWaitRegistry
|
||||
continue;
|
||||
}
|
||||
|
||||
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)
|
||||
var value = readValue(address, list[i].Is64Bit);
|
||||
if (value is null || !Compare(list[i], value.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -258,201 +236,6 @@ 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;
|
||||
@@ -477,7 +260,6 @@ internal static class GpuWaitRegistry
|
||||
lock (_gate)
|
||||
{
|
||||
_waiters.Clear();
|
||||
_lastProduced.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ using SharpEmu.Libs.Kernel;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace SharpEmu.Libs.Ampr;
|
||||
|
||||
@@ -44,17 +43,17 @@ public static class AmprExports
|
||||
{
|
||||
public CachedHostFile(string path)
|
||||
{
|
||||
Handle = File.OpenHandle(
|
||||
Stream = new FileStream(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite | FileShare.Delete,
|
||||
bufferSize: 1024 * 1024,
|
||||
FileOptions.RandomAccess);
|
||||
Length = RandomAccess.GetLength(Handle);
|
||||
}
|
||||
|
||||
public SafeFileHandle Handle { get; }
|
||||
public long Length { get; }
|
||||
public object Gate { get; } = new();
|
||||
public FileStream Stream { get; }
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
@@ -340,18 +339,6 @@ 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",
|
||||
@@ -521,32 +508,6 @@ 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)
|
||||
@@ -774,7 +735,13 @@ public static class AmprExports
|
||||
return openResult;
|
||||
}
|
||||
|
||||
if (fileOffset >= (ulong)cachedFile.Length)
|
||||
long fileLength;
|
||||
lock (cachedFile.Gate)
|
||||
{
|
||||
fileLength = cachedFile.Stream.Length;
|
||||
}
|
||||
|
||||
if (fileOffset >= (ulong)fileLength)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
@@ -793,10 +760,12 @@ public static class AmprExports
|
||||
}
|
||||
|
||||
var request = (int)Math.Min((ulong)buffer.Length, size - bytesRead);
|
||||
var read = RandomAccess.Read(
|
||||
cachedFile.Handle,
|
||||
buffer.AsSpan(0, request),
|
||||
unchecked((long)absoluteOffset));
|
||||
int read;
|
||||
lock (cachedFile.Gate)
|
||||
{
|
||||
cachedFile.Stream.Position = unchecked((long)absoluteOffset);
|
||||
read = cachedFile.Stream.Read(buffer, 0, request);
|
||||
}
|
||||
|
||||
if (read <= 0)
|
||||
{
|
||||
|
||||
@@ -121,33 +121,6 @@ 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,11 +17,10 @@ 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 = 25;
|
||||
private const uint MaxCodecType = 23;
|
||||
private const int MaxInstanceIndex = 0x2FFF;
|
||||
private static readonly ConcurrentDictionary<uint, AjmContextState> Contexts = new();
|
||||
private static int _nextContextId;
|
||||
private static int _nextBatchId;
|
||||
|
||||
private sealed class AjmContextState
|
||||
{
|
||||
@@ -228,250 +227,10 @@ public static class AjmExports
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueues a decode job on a batch. Titles call this on the Bink/AJM hot
|
||||
/// path; leaving it unresolved floods Import WARN spam. This is a silence
|
||||
/// stub, not a codec: advance the batch cursor and report the input as
|
||||
/// consumed with silence produced so the title does not spin on the same
|
||||
/// packet.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "39WxhR-ePew",
|
||||
ExportName = "sceAjmBatchJobDecode",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAjm")]
|
||||
public static int AjmBatchJobDecode(CpuContext ctx)
|
||||
{
|
||||
var infoAddress = ctx[CpuRegister.Rdi];
|
||||
var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]);
|
||||
var inputAddress = ctx[CpuRegister.Rdx];
|
||||
var inputSize = ctx[CpuRegister.Rcx];
|
||||
var outputAddress = ctx[CpuRegister.R8];
|
||||
var outputSize = ctx[CpuRegister.R9];
|
||||
var resultAddress = ReadStackArg64(ctx, 0);
|
||||
|
||||
if (infoAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
|
||||
}
|
||||
|
||||
// Best-effort: bump the batch cursor when the guest filled AjmBatchInfo.
|
||||
// Still succeed without it — the unresolved stub returned 0 and titles
|
||||
// keep calling; failing here would reintroduce hot-path spam via retries.
|
||||
_ = TryAppendBatchJob(ctx, infoAddress, AjmJobRunSize);
|
||||
|
||||
// Silence: clear PCM out and claim full input consumed so the guest
|
||||
// advances its bitstream cursor instead of re-submitting forever.
|
||||
if (outputAddress != 0 && outputSize != 0 && outputSize <= MaxSilentPcmBytes)
|
||||
{
|
||||
ClearGuestMemory(ctx, outputAddress, outputSize);
|
||||
}
|
||||
|
||||
WriteDecodeStreamResult(
|
||||
ctx,
|
||||
resultAddress,
|
||||
inputConsumed: inputSize > int.MaxValue ? int.MaxValue : (int)inputSize,
|
||||
outputWritten: 0,
|
||||
totalDecodedSamples: 0,
|
||||
frames: inputSize != 0 || outputSize != 0 ? 1u : 0u);
|
||||
|
||||
Trace(
|
||||
$"batch_job_decode info=0x{infoAddress:X16} instance=0x{instanceId:X8} " +
|
||||
$"in=0x{inputAddress:X16}+0x{inputSize:X} out=0x{outputAddress:X16}+0x{outputSize:X} " +
|
||||
$"result=0x{resultAddress:X16}");
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Submits a built batch. Instant-complete silence stub: publish a batch id
|
||||
/// and clear any error out. Decode sidebands were already filled at
|
||||
/// job-enqueue time.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "5tOfnaClcqM",
|
||||
ExportName = "sceAjmBatchStart",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAjm")]
|
||||
public static int AjmBatchStart(CpuContext ctx)
|
||||
{
|
||||
var contextId = unchecked((uint)ctx[CpuRegister.Rdi]);
|
||||
var infoAddress = ctx[CpuRegister.Rsi];
|
||||
var priority = unchecked((int)ctx[CpuRegister.Rdx]);
|
||||
var errorAddress = ctx[CpuRegister.Rcx];
|
||||
var batchOutAddress = ctx[CpuRegister.R8];
|
||||
|
||||
if (infoAddress == 0 || batchOutAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
|
||||
}
|
||||
|
||||
ClearAjmBatchError(ctx, errorAddress);
|
||||
|
||||
var batchId = unchecked((uint)Interlocked.Increment(ref _nextBatchId));
|
||||
Span<byte> batchValue = stackalloc byte[sizeof(uint)];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(batchValue, batchId);
|
||||
if (!ctx.Memory.TryWrite(batchOutAddress, batchValue))
|
||||
{
|
||||
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
|
||||
}
|
||||
|
||||
Trace(
|
||||
$"batch_start context={contextId} info=0x{infoAddress:X16} " +
|
||||
$"priority={priority} batch={batchId} error=0x{errorAddress:X16}");
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "-qLsfDAywIY",
|
||||
ExportName = "sceAjmBatchWait",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAjm")]
|
||||
public static int AjmBatchWait(CpuContext ctx)
|
||||
{
|
||||
// Batches complete synchronously in Start; Wait is a no-op success.
|
||||
var errorAddress = ctx[CpuRegister.Rcx];
|
||||
ClearAjmBatchError(ctx, errorAddress);
|
||||
Trace(
|
||||
$"batch_wait context={unchecked((uint)ctx[CpuRegister.Rdi])} " +
|
||||
$"batch={unchecked((uint)ctx[CpuRegister.Rsi])} " +
|
||||
$"timeout={unchecked((uint)ctx[CpuRegister.Rdx])}");
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "NVDXiUesSbA",
|
||||
ExportName = "sceAjmBatchCancel",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAjm")]
|
||||
public static int AjmBatchCancel(CpuContext ctx)
|
||||
{
|
||||
Trace(
|
||||
$"batch_cancel context={unchecked((uint)ctx[CpuRegister.Rdi])} " +
|
||||
$"batch={unchecked((uint)ctx[CpuRegister.Rsi])}");
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
internal static void ResetForTests()
|
||||
{
|
||||
Contexts.Clear();
|
||||
Interlocked.Exchange(ref _nextContextId, 0);
|
||||
Interlocked.Exchange(ref _nextBatchId, 0);
|
||||
}
|
||||
|
||||
// AjmBatchInfo: buffer, offset, size, last_good_job, last_good_job_ra (5× u64).
|
||||
private const ulong AjmBatchInfoOffsetField = 8;
|
||||
private const ulong AjmBatchInfoSizeField = 16;
|
||||
private const ulong AjmBatchInfoLastGoodJobField = 24;
|
||||
private const ulong AjmJobRunSize = 64;
|
||||
private const ulong MaxSilentPcmBytes = 1 << 20;
|
||||
// AjmSidebandResult (8) + AjmSidebandStream (16) + AjmSidebandMFrame (8).
|
||||
private const int DecodeSidebandBytes = 32;
|
||||
|
||||
private static bool TryAppendBatchJob(CpuContext ctx, ulong infoAddress, ulong jobSize)
|
||||
{
|
||||
if (!TryReadUInt64(ctx, infoAddress, out var buffer) ||
|
||||
!TryReadUInt64(ctx, infoAddress + AjmBatchInfoOffsetField, out var offset) ||
|
||||
!TryReadUInt64(ctx, infoAddress + AjmBatchInfoSizeField, out var size))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (buffer == 0 || jobSize == 0 || offset > size || size - offset < jobSize)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var jobAddress = buffer + offset;
|
||||
ClearGuestMemory(ctx, jobAddress, jobSize);
|
||||
return TryWriteUInt64(ctx, infoAddress + AjmBatchInfoLastGoodJobField, jobAddress) &&
|
||||
TryWriteUInt64(ctx, infoAddress + AjmBatchInfoOffsetField, offset + jobSize);
|
||||
}
|
||||
|
||||
// AjmBatchError: int error_code; const void* job_addr; uint32_t cmd_offset; const void* job_ra;
|
||||
private const int AjmBatchErrorBytes = 24;
|
||||
|
||||
private static void ClearAjmBatchError(CpuContext ctx, ulong errorAddress)
|
||||
{
|
||||
if (errorAddress == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Span<byte> error = stackalloc byte[AjmBatchErrorBytes];
|
||||
error.Clear();
|
||||
_ = ctx.Memory.TryWrite(errorAddress, error);
|
||||
}
|
||||
|
||||
private static void WriteDecodeStreamResult(
|
||||
CpuContext ctx,
|
||||
ulong resultAddress,
|
||||
int inputConsumed,
|
||||
int outputWritten,
|
||||
ulong totalDecodedSamples,
|
||||
uint frames)
|
||||
{
|
||||
if (resultAddress == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Span<byte> sideband = stackalloc byte[DecodeSidebandBytes];
|
||||
sideband.Clear();
|
||||
// AjmSidebandResult.result / internal_result = 0 (OK)
|
||||
BinaryPrimitives.WriteInt32LittleEndian(sideband.Slice(8, 4), inputConsumed);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(sideband.Slice(12, 4), outputWritten);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(sideband.Slice(16, 8), totalDecodedSamples);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(sideband.Slice(24, 4), frames);
|
||||
_ = ctx.Memory.TryWrite(resultAddress, sideband);
|
||||
}
|
||||
|
||||
private static void ClearGuestMemory(CpuContext ctx, ulong address, ulong byteCount)
|
||||
{
|
||||
if (address == 0 || byteCount == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var remaining = byteCount;
|
||||
var cursor = address;
|
||||
Span<byte> zero = stackalloc byte[256];
|
||||
while (remaining > 0)
|
||||
{
|
||||
var chunk = (int)Math.Min(remaining, (ulong)zero.Length);
|
||||
if (!ctx.Memory.TryWrite(cursor, zero[..chunk]))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
cursor += (ulong)chunk;
|
||||
remaining -= (ulong)chunk;
|
||||
}
|
||||
}
|
||||
|
||||
private static ulong ReadStackArg64(CpuContext ctx, int index)
|
||||
{
|
||||
var address = ctx[CpuRegister.Rsp] + sizeof(ulong) + ((ulong)index * sizeof(ulong));
|
||||
return TryReadUInt64(ctx, address, out var value) ? value : 0;
|
||||
}
|
||||
|
||||
private static bool TryReadUInt64(CpuContext ctx, ulong address, out ulong value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
|
||||
if (!ctx.Memory.TryRead(address, buffer))
|
||||
{
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = BinaryPrimitives.ReadUInt64LittleEndian(buffer);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryWriteUInt64(CpuContext ctx, ulong address, ulong value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
||||
return ctx.Memory.TryWrite(address, buffer);
|
||||
}
|
||||
|
||||
private static void Trace(string message)
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.HLE.Host;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
|
||||
@@ -12,23 +11,8 @@ namespace SharpEmu.Libs.Audio;
|
||||
|
||||
public static class AudioOutExports
|
||||
{
|
||||
private const int AudioOutOutputParamSize = 16;
|
||||
private const int AudioOutMaximumOutputCount = 25;
|
||||
|
||||
internal const int AudioOutErrorInvalidPort = unchecked((int)0x80260003);
|
||||
internal const int AudioOutErrorInvalidPointer = unchecked((int)0x80260004);
|
||||
internal const int AudioOutErrorPortFull = unchecked((int)0x80260005);
|
||||
internal const int AudioOutErrorInvalidSize = unchecked((int)0x80260006);
|
||||
|
||||
private static readonly ConcurrentDictionary<int, PortState> Ports = new();
|
||||
private static int _nextPortHandle;
|
||||
private static Func<uint, IHostAudioStream?>? _streamFactoryForTests;
|
||||
|
||||
// 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
|
||||
{
|
||||
@@ -66,7 +50,6 @@ public static class AudioOutExports
|
||||
public int BytesPerSample { get; }
|
||||
public bool IsFloat { get; }
|
||||
public IHostAudioStream? Backend { get; }
|
||||
public object SubmissionGate { get; } = new();
|
||||
public volatile float Volume = 1.0f;
|
||||
public int BufferByteLength =>
|
||||
checked((int)BufferLength * Channels * BytesPerSample);
|
||||
@@ -94,24 +77,7 @@ public static class AudioOutExports
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (SubmissionGate)
|
||||
{
|
||||
Backend?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct OutputDescriptor(int Handle, ulong SourceAddress);
|
||||
|
||||
private struct ResolvedOutput
|
||||
{
|
||||
public int Handle;
|
||||
public ulong SourceAddress;
|
||||
public PortState Port;
|
||||
public byte[]? HostBuffer;
|
||||
public int HostBufferLength;
|
||||
public void Dispose() => Backend?.Dispose();
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
@@ -143,18 +109,9 @@ public static class AudioOutExports
|
||||
string backendName;
|
||||
try
|
||||
{
|
||||
var streamFactory = Volatile.Read(ref _streamFactoryForTests);
|
||||
if (streamFactory is not null)
|
||||
{
|
||||
backend = streamFactory(frequency);
|
||||
backendName = "test";
|
||||
}
|
||||
else
|
||||
{
|
||||
var audio = HostPlatform.Current.Audio;
|
||||
backend = audio.OpenStereoPcm16Stream(frequency);
|
||||
backendName = audio.BackendName;
|
||||
}
|
||||
var audio = HostPlatform.Current.Audio;
|
||||
backend = audio.OpenStereoPcm16Stream(frequency);
|
||||
backendName = audio.BackendName;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -198,77 +155,6 @@ 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 = "w3PdaSTSwGE",
|
||||
ExportName = "sceAudioOutOutputs",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAudioOut")]
|
||||
public static int AudioOutOutputs(CpuContext ctx)
|
||||
{
|
||||
var parameterAddress = ctx[CpuRegister.Rdi];
|
||||
var outputCount = unchecked((uint)ctx[CpuRegister.Rsi]);
|
||||
if (outputCount == 0 || outputCount > AudioOutMaximumOutputCount)
|
||||
{
|
||||
return ctx.SetReturn(AudioOutErrorPortFull);
|
||||
}
|
||||
|
||||
if (parameterAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn(AudioOutErrorInvalidPointer);
|
||||
}
|
||||
|
||||
var count = checked((int)outputCount);
|
||||
Span<byte> parameterBytes =
|
||||
stackalloc byte[AudioOutMaximumOutputCount * AudioOutOutputParamSize];
|
||||
parameterBytes = parameterBytes[..checked(count * AudioOutOutputParamSize)];
|
||||
if (!ctx.Memory.TryRead(parameterAddress, parameterBytes))
|
||||
{
|
||||
return ctx.SetReturn(AudioOutErrorInvalidPointer);
|
||||
}
|
||||
|
||||
Span<OutputDescriptor> descriptors = stackalloc OutputDescriptor[count];
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var entry = parameterBytes.Slice(i * AudioOutOutputParamSize, AudioOutOutputParamSize);
|
||||
descriptors[i] = new OutputDescriptor(
|
||||
BinaryPrimitives.ReadInt32LittleEndian(entry),
|
||||
BinaryPrimitives.ReadUInt64LittleEndian(entry[8..]));
|
||||
}
|
||||
|
||||
return ctx.SetReturn(SubmitOutputs(ctx, descriptors));
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "QOQtbeDqsT4",
|
||||
ExportName = "sceAudioOutOutput",
|
||||
@@ -280,12 +166,7 @@ public static class AudioOutExports
|
||||
var sourceAddress = ctx[CpuRegister.Rsi];
|
||||
if (!Ports.TryGetValue(handle, out var port))
|
||||
{
|
||||
// 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);
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
if (sourceAddress == 0)
|
||||
@@ -302,8 +183,6 @@ public static class AudioOutExports
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
TraceOutput(handle, port, source);
|
||||
|
||||
if (port.Backend is null)
|
||||
{
|
||||
port.PaceSilence();
|
||||
@@ -340,184 +219,6 @@ public static class AudioOutExports
|
||||
}
|
||||
}
|
||||
|
||||
private static int SubmitOutputs(CpuContext ctx, ReadOnlySpan<OutputDescriptor> descriptors)
|
||||
{
|
||||
var resolvedArray = ArrayPool<ResolvedOutput>.Shared.Rent(descriptors.Length);
|
||||
var resolved = resolvedArray.AsSpan(0, descriptors.Length);
|
||||
resolved.Clear();
|
||||
|
||||
Span<int> lockOrder = stackalloc int[descriptors.Length];
|
||||
var acquiredLocks = 0;
|
||||
try
|
||||
{
|
||||
uint bufferLength = 0;
|
||||
for (var i = 0; i < descriptors.Length; i++)
|
||||
{
|
||||
var descriptor = descriptors[i];
|
||||
for (var previous = 0; previous < i; previous++)
|
||||
{
|
||||
if (resolved[previous].Handle == descriptor.Handle)
|
||||
{
|
||||
return AudioOutErrorInvalidPort;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Ports.TryGetValue(descriptor.Handle, out var port))
|
||||
{
|
||||
return _shutdown ? 0 : AudioOutErrorInvalidPort;
|
||||
}
|
||||
|
||||
if (i == 0)
|
||||
{
|
||||
bufferLength = port.BufferLength;
|
||||
}
|
||||
else if (port.BufferLength != bufferLength)
|
||||
{
|
||||
return AudioOutErrorInvalidSize;
|
||||
}
|
||||
|
||||
resolved[i].Handle = descriptor.Handle;
|
||||
resolved[i].SourceAddress = descriptor.SourceAddress;
|
||||
resolved[i].Port = port;
|
||||
lockOrder[i] = i;
|
||||
}
|
||||
|
||||
// Every batch takes port locks in handle order. Two guest threads can
|
||||
// submit overlapping batches in a different descriptor order without
|
||||
// deadlocking each other.
|
||||
for (var i = 1; i < lockOrder.Length; i++)
|
||||
{
|
||||
var index = lockOrder[i];
|
||||
var position = i;
|
||||
while (position > 0 &&
|
||||
resolved[lockOrder[position - 1]].Handle > resolved[index].Handle)
|
||||
{
|
||||
lockOrder[position] = lockOrder[position - 1];
|
||||
position--;
|
||||
}
|
||||
|
||||
lockOrder[position] = index;
|
||||
}
|
||||
|
||||
for (; acquiredLocks < lockOrder.Length; acquiredLocks++)
|
||||
{
|
||||
Monitor.Enter(resolved[lockOrder[acquiredLocks]].Port.SubmissionGate);
|
||||
}
|
||||
|
||||
// AudioOutClose removes the handle before waiting for SubmissionGate.
|
||||
// Recheck after acquiring all gates so a close racing this batch cannot
|
||||
// turn a validated submission into a write to a disposed backend.
|
||||
for (var i = 0; i < resolved.Length; i++)
|
||||
{
|
||||
if (!Ports.TryGetValue(resolved[i].Handle, out var current) ||
|
||||
!ReferenceEquals(current, resolved[i].Port))
|
||||
{
|
||||
return _shutdown ? 0 : AudioOutErrorInvalidPort;
|
||||
}
|
||||
}
|
||||
|
||||
// Stage every guest buffer before the first host submission. A bad
|
||||
// pointer in a later descriptor therefore cannot partially enqueue the
|
||||
// earlier ports.
|
||||
for (var i = 0; i < resolved.Length; i++)
|
||||
{
|
||||
ref var output = ref resolved[i];
|
||||
if (output.SourceAddress == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var sourceBuffer = ArrayPool<byte>.Shared.Rent(output.Port.BufferByteLength);
|
||||
try
|
||||
{
|
||||
var source = sourceBuffer.AsSpan(0, output.Port.BufferByteLength);
|
||||
if (!ctx.Memory.TryRead(output.SourceAddress, source))
|
||||
{
|
||||
return AudioOutErrorInvalidPointer;
|
||||
}
|
||||
|
||||
TraceOutput(output.Handle, output.Port, source);
|
||||
|
||||
output.HostBufferLength = checked(
|
||||
(int)output.Port.BufferLength * AudioPcmConversion.OutputFrameSize);
|
||||
output.HostBuffer = ArrayPool<byte>.Shared.Rent(output.HostBufferLength);
|
||||
AudioPcmConversion.ConvertToStereoPcm16(
|
||||
source,
|
||||
output.HostBuffer.AsSpan(0, output.HostBufferLength),
|
||||
checked((int)output.Port.BufferLength),
|
||||
output.Port.Channels,
|
||||
output.Port.BytesPerSample,
|
||||
output.Port.IsFloat,
|
||||
output.Port.Volume);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(sourceBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
PortState? pacingPort = null;
|
||||
for (var i = 0; i < resolved.Length; i++)
|
||||
{
|
||||
ref var output = ref resolved[i];
|
||||
if (output.HostBuffer is null ||
|
||||
output.Port.Backend is null ||
|
||||
!output.Port.Backend.Submit(
|
||||
output.HostBuffer.AsSpan(0, output.HostBufferLength)))
|
||||
{
|
||||
if (pacingPort is null ||
|
||||
HasLongerBufferDuration(output.Port, pacingPort))
|
||||
{
|
||||
pacingPort = output.Port;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A batch is one guest scheduling point. When one or more ports have
|
||||
// no usable backend, pace once using the longest affected buffer rather
|
||||
// than sleeping once per port.
|
||||
pacingPort?.PaceSilence();
|
||||
return checked((int)resolved[0].Port.BufferLength);
|
||||
}
|
||||
finally
|
||||
{
|
||||
for (var i = acquiredLocks - 1; i >= 0; i--)
|
||||
{
|
||||
Monitor.Exit(resolved[lockOrder[i]].Port.SubmissionGate);
|
||||
}
|
||||
|
||||
for (var i = 0; i < resolved.Length; i++)
|
||||
{
|
||||
if (resolved[i].HostBuffer is { } hostBuffer)
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(hostBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
ArrayPool<ResolvedOutput>.Shared.Return(resolvedArray, clearArray: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasLongerBufferDuration(PortState candidate, PortState current) =>
|
||||
(ulong)candidate.BufferLength * current.Frequency >
|
||||
(ulong)current.BufferLength * candidate.Frequency;
|
||||
|
||||
private static void TraceOutput(int handle, PortState port, ReadOnlySpan<byte> source)
|
||||
{
|
||||
if (!_traceOutput)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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")}");
|
||||
}
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "b+uAV89IlxE",
|
||||
ExportName = "sceAudioOutSetVolume",
|
||||
@@ -565,40 +266,8 @@ 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))
|
||||
@@ -608,27 +277,6 @@ public static class AudioOutExports
|
||||
}
|
||||
}
|
||||
|
||||
internal static void SetStreamFactoryForTests(Func<uint, IHostAudioStream?>? streamFactory) =>
|
||||
Volatile.Write(ref _streamFactoryForTests, streamFactory);
|
||||
|
||||
internal static void ResetForTests()
|
||||
{
|
||||
foreach (var handle in Ports.Keys)
|
||||
{
|
||||
if (Ports.TryRemove(handle, out var port))
|
||||
{
|
||||
port.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
_nextPortHandle = 0;
|
||||
_outputCount = 0;
|
||||
Volatile.Write(ref _shutdown, false);
|
||||
Volatile.Write(ref _streamFactoryForTests, null);
|
||||
}
|
||||
|
||||
private static bool _shutdown;
|
||||
|
||||
private static bool TryGetFormat(
|
||||
int rawFormat,
|
||||
out int channels,
|
||||
|
||||
@@ -25,10 +25,6 @@ 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);
|
||||
@@ -36,8 +32,8 @@ internal static class AudioPcmConversion
|
||||
var right = channels == 1
|
||||
? left
|
||||
: ReadSample(sourceFrame, 1, bytesPerSample, isFloat);
|
||||
left = ApplyVolume(left, clampedVolume);
|
||||
right = ApplyVolume(right, clampedVolume);
|
||||
left = ApplyVolume(left, volume);
|
||||
right = ApplyVolume(right, volume);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(destination[(frame * OutputFrameSize)..], left);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(destination[((frame * OutputFrameSize) + 2)..], right);
|
||||
}
|
||||
@@ -71,10 +67,9 @@ 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 * volume);
|
||||
var scaled = MathF.Round(sample * Math.Clamp(volume, 0.0f, 1.0f));
|
||||
return (short)Math.Clamp(scaled, short.MinValue, short.MaxValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
// 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,9 +17,7 @@ public static class AvPlayerExports
|
||||
private const int FrameBufferCount = 3;
|
||||
private const int FrameInfoSize = 40;
|
||||
private const int FrameInfoExSize = 104;
|
||||
// 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 StreamInfoSize = 40;
|
||||
private const int MaxGuestPathLength = 4096;
|
||||
private static readonly object StateGate = new();
|
||||
private static readonly Dictionary<ulong, PlayerState> Players = new();
|
||||
@@ -406,8 +404,7 @@ public static class AvPlayerExports
|
||||
ExportName = "sceAvPlayerGetStreamInfoEx",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAvPlayer")]
|
||||
public static int AvPlayerGetStreamInfoEx(CpuContext ctx) =>
|
||||
GetStreamInfoCore(ctx, StreamInfoExSize);
|
||||
public static int AvPlayerSetDecoderMode(CpuContext ctx) => ValidatePlayer(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "XC9wM+xULz8",
|
||||
@@ -564,48 +561,12 @@ 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) =>
|
||||
GetStreamInfoCore(ctx, StreamInfoSize);
|
||||
|
||||
private static int GetStreamInfoCore(CpuContext ctx, int infoSize)
|
||||
public static int AvPlayerGetStreamInfo(CpuContext ctx)
|
||||
{
|
||||
var streamIndex = unchecked((uint)ctx[CpuRegister.Rsi]);
|
||||
var infoAddress = ctx[CpuRegister.Rdx];
|
||||
@@ -617,7 +578,7 @@ public static class AvPlayerExports
|
||||
return SetReturn(ctx, InvalidParameters);
|
||||
}
|
||||
|
||||
Span<byte> info = stackalloc byte[infoSize];
|
||||
Span<byte> info = stackalloc byte[StreamInfoSize];
|
||||
info.Clear();
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(info[0..], streamIndex); // 0=video, 1=audio
|
||||
if (streamIndex == 0)
|
||||
@@ -1048,7 +1009,7 @@ public static class AvPlayerExports
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var ffprobe = GetFfprobePath(ffmpeg, OperatingSystem.IsWindows());
|
||||
var ffprobe = Path.Combine(Path.GetDirectoryName(ffmpeg) ?? string.Empty, "ffprobe");
|
||||
if (!File.Exists(ffprobe))
|
||||
{
|
||||
return false;
|
||||
@@ -1131,50 +1092,13 @@ public static class AvPlayerExports
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
private static string? FindFfmpeg()
|
||||
{
|
||||
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))
|
||||
@@ -1185,16 +1109,6 @@ 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))
|
||||
@@ -1204,9 +1118,7 @@ public static class AvPlayerExports
|
||||
|
||||
var normalized = guestPath.Replace('\\', '/');
|
||||
var fileReference = normalized.StartsWith("file:", StringComparison.OrdinalIgnoreCase);
|
||||
var unrealProjectRelative =
|
||||
normalized.StartsWith("../", StringComparison.Ordinal) ||
|
||||
normalized.StartsWith("./", StringComparison.Ordinal);
|
||||
var unrealProjectRelative = false;
|
||||
if (normalized.StartsWith("file://", StringComparison.OrdinalIgnoreCase) &&
|
||||
Uri.TryCreate(normalized, UriKind.Absolute, out var uri) &&
|
||||
uri.IsFile)
|
||||
@@ -1237,10 +1149,7 @@ public static class AvPlayerExports
|
||||
|
||||
if (unrealProjectRelative)
|
||||
{
|
||||
if (!TryRemoveUnrealLeadingDotSegments(normalized, out normalized))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
normalized = RemoveUnrealLeadingDotSegments(normalized);
|
||||
}
|
||||
|
||||
var app0 = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
|
||||
@@ -1324,20 +1233,15 @@ public static class AvPlayerExports
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryRemoveUnrealLeadingDotSegments(
|
||||
string guestPath,
|
||||
out string normalized)
|
||||
private static string RemoveUnrealLeadingDotSegments(string guestPath)
|
||||
{
|
||||
var removedParent = false;
|
||||
while (guestPath.StartsWith("../", StringComparison.Ordinal) ||
|
||||
guestPath.StartsWith("./", StringComparison.Ordinal))
|
||||
{
|
||||
removedParent |= guestPath.StartsWith("../", StringComparison.Ordinal);
|
||||
guestPath = guestPath[(guestPath.IndexOf('/') + 1)..];
|
||||
}
|
||||
|
||||
normalized = guestPath;
|
||||
return !removedParent || guestPath.Contains('/');
|
||||
return guestPath;
|
||||
}
|
||||
|
||||
private static bool TryDecodeFileReference(string encoded, out string decoded)
|
||||
|
||||
@@ -18,153 +18,123 @@ 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 _frameBufferPresented;
|
||||
private static BinkFramePlayback? _playback;
|
||||
private static long _frameSerial;
|
||||
private static uint _presentationWidth = MaxHostVideoWidth;
|
||||
private static uint _presentationHeight = MaxHostVideoHeight;
|
||||
private static bool _usingDummyMovie;
|
||||
private static bool _loadAttempted;
|
||||
private static bool _availabilityReported;
|
||||
|
||||
internal static bool IsHostPlaybackActive
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
return _playback is not null || _frameBuffer is not null;
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <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 SetPresentationSize(uint width, uint height)
|
||||
internal static void ObserveGuestMovie(string hostPath)
|
||||
{
|
||||
if (width == 0 || height == 0)
|
||||
if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
|
||||
!File.Exists(hostPath))
|
||||
{
|
||||
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 _playback is not null || _frameBuffer is not null;
|
||||
return;
|
||||
}
|
||||
|
||||
var mode = ResolveMode();
|
||||
if (mode is MovieMode.Guest or MovieMode.Skip)
|
||||
if (ResolveMode() == MovieMode.Dummy)
|
||||
{
|
||||
return false;
|
||||
AttachDummyMovieLocked(hostPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_playback is not null || _frameBuffer is not null)
|
||||
var adapter = GetAdapterLocked();
|
||||
if (adapter is null)
|
||||
{
|
||||
if (PendingMoviePathSet.Add(hostPath))
|
||||
{
|
||||
PendingMoviePaths.Enqueue(hostPath);
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge queued: " +
|
||||
Path.GetFileName(hostPath));
|
||||
}
|
||||
return PendingMoviePathSet.Contains(hostPath);
|
||||
return;
|
||||
}
|
||||
|
||||
AttachMovieLocked(hostPath, mode);
|
||||
return string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase) &&
|
||||
(_playback is not null || _frameBuffer is not null);
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryDecodeNextFrame(
|
||||
bool advanceClock,
|
||||
out byte[] pixels,
|
||||
out uint width,
|
||||
out uint height,
|
||||
out bool advanced,
|
||||
out long frameSerial,
|
||||
out string hostPath)
|
||||
out uint height)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
pixels = [];
|
||||
width = 0;
|
||||
height = 0;
|
||||
advanced = false;
|
||||
frameSerial = _frameSerial;
|
||||
hostPath = _activePath ?? string.Empty;
|
||||
|
||||
if (_playback is not null)
|
||||
if (_adapter is null || _activeMovie == IntPtr.Zero || _frameBuffer is null)
|
||||
{
|
||||
if (!_playback.TryGetFrame(advanceClock, out pixels, out advanced))
|
||||
if (_usingDummyMovie && _frameBuffer is not null)
|
||||
{
|
||||
if (_playback.IsFinished)
|
||||
{
|
||||
var completedPath = _activePath;
|
||||
CloseActiveLocked();
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge completed: " +
|
||||
Path.GetFileName(completedPath));
|
||||
AttachNextQueuedMovieLocked();
|
||||
}
|
||||
return false;
|
||||
pixels = _frameBuffer;
|
||||
width = _activeInfo.Width;
|
||||
height = _activeInfo.Height;
|
||||
return true;
|
||||
}
|
||||
|
||||
width = _activeInfo.Width;
|
||||
height = _activeInfo.Height;
|
||||
if (advanced)
|
||||
{
|
||||
frameSerial = ++_frameSerial;
|
||||
}
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_frameBuffer is null)
|
||||
unsafe
|
||||
{
|
||||
return false;
|
||||
fixed (byte* destination = _frameBuffer)
|
||||
{
|
||||
if (!_adapter.DecodeNextBgra(
|
||||
_activeMovie,
|
||||
(IntPtr)destination,
|
||||
_activeInfo.Width * 4,
|
||||
(uint)_frameBuffer.Length))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pixels = _frameBuffer;
|
||||
width = _activeInfo.Width;
|
||||
height = _activeInfo.Height;
|
||||
advanced = !_frameBufferPresented;
|
||||
_frameBufferPresented = true;
|
||||
if (advanced)
|
||||
{
|
||||
frameSerial = ++_frameSerial;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -177,52 +147,6 @@ 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");
|
||||
@@ -241,22 +165,16 @@ internal static class Bink2MovieBridge
|
||||
return MovieMode.Skip;
|
||||
}
|
||||
|
||||
if (string.Equals(configured, "guest", StringComparison.OrdinalIgnoreCase))
|
||||
// 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))
|
||||
{
|
||||
return MovieMode.Guest;
|
||||
return MovieMode.Native;
|
||||
}
|
||||
|
||||
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;
|
||||
return MovieMode.Skip;
|
||||
}
|
||||
|
||||
private static void AttachDummyMovieLocked(string hostPath)
|
||||
@@ -273,61 +191,22 @@ 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 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)
|
||||
private static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
|
||||
{
|
||||
info = default;
|
||||
Span<byte> header = stackalloc byte[36];
|
||||
Span<byte> header = stackalloc byte[32];
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
stream.ReadExactly(header);
|
||||
if (!header[..3].SequenceEqual("KB2"u8))
|
||||
if (stream.Read(header) != header.Length ||
|
||||
!header[..4].SequenceEqual("KB2j"u8))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -336,11 +215,10 @@ internal static class Bink2MovieBridge
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x14, 4)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x18, 4)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x1C, 4)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x20, 4)));
|
||||
return info.FramesPerSecondNumerator != 0 &&
|
||||
info.FramesPerSecondDenominator != 0;
|
||||
1);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or EndOfStreamException)
|
||||
catch (IOException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -362,23 +240,80 @@ 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()
|
||||
{
|
||||
_playback?.Dispose();
|
||||
_playback = null;
|
||||
if (_activeMovie != IntPtr.Zero)
|
||||
{
|
||||
_adapter?.Close(_activeMovie);
|
||||
}
|
||||
|
||||
_activePath = null;
|
||||
_activeMovie = IntPtr.Zero;
|
||||
_activeInfo = default;
|
||||
_frameBuffer = null;
|
||||
_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);
|
||||
_usingDummyMovie = false;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal readonly struct Bink2MovieInfo
|
||||
private readonly struct Bink2MovieInfo
|
||||
{
|
||||
public readonly uint Width;
|
||||
public readonly uint Height;
|
||||
@@ -400,226 +335,69 @@ internal static class Bink2MovieBridge
|
||||
|
||||
private enum MovieMode
|
||||
{
|
||||
Guest,
|
||||
Skip,
|
||||
Dummy,
|
||||
Native,
|
||||
Ffmpeg,
|
||||
}
|
||||
|
||||
private static readonly Queue<string> PendingMoviePaths = new();
|
||||
private static readonly HashSet<string> PendingMoviePathSet =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private static void AttachNextQueuedMovieLocked()
|
||||
private sealed class NativeAdapter
|
||||
{
|
||||
while (PendingMoviePaths.Count > 0)
|
||||
{
|
||||
var path = PendingMoviePaths.Dequeue();
|
||||
PendingMoviePathSet.Remove(path);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int OpenUtf8Delegate(IntPtr pathUtf8, out IntPtr movie, out Bink2MovieInfo info);
|
||||
|
||||
AttachMovieLocked(path, ResolveMode());
|
||||
if (_playback is not null || _frameBuffer is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
[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)
|
||||
{
|
||||
_openUtf8 = openUtf8;
|
||||
_decodeNextBgra = decodeNextBgra;
|
||||
_close = close;
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
|
||||
internal static bool TryCreate(IntPtr library, out NativeAdapter? adapter)
|
||||
{
|
||||
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))
|
||||
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))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
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);
|
||||
adapter = new NativeAdapter(
|
||||
Marshal.GetDelegateForFunctionPointer<OpenUtf8Delegate>(open),
|
||||
Marshal.GetDelegateForFunctionPointer<DecodeNextBgraDelegate>(decode),
|
||||
Marshal.GetDelegateForFunctionPointer<CloseDelegate>(close));
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is IOException or EndOfStreamException or OverflowException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly struct BinkGuestCompletionShim
|
||||
{
|
||||
private readonly uint _fileSizeMinusHeader;
|
||||
private readonly uint _largestFrameSize;
|
||||
|
||||
internal BinkGuestCompletionShim(uint fileSizeMinusHeader, uint largestFrameSize)
|
||||
internal bool TryOpen(string path, out IntPtr movie, out Bink2MovieInfo info)
|
||||
{
|
||||
_fileSizeMinusHeader = fileSizeMinusHeader;
|
||||
_largestFrameSize = largestFrameSize;
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
|
||||
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)
|
||||
var utf8 = Marshal.StringToCoTaskMemUTF8(path);
|
||||
try
|
||||
{
|
||||
return false;
|
||||
return _openUtf8(utf8, out movie, out info) != 0 && movie != IntPtr.Zero;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeCoTaskMem(utf8);
|
||||
}
|
||||
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
bytes.Slice((int)relativeOffset, sizeof(uint)),
|
||||
value);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool DecodeNextBgra(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes) =>
|
||||
_decodeNextBgra(movie, destination, stride, destinationBytes) != 0;
|
||||
|
||||
internal void Close(IntPtr movie) => _close(movie);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,355 +0,0 @@
|
||||
// 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,37 +153,6 @@ 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",
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
// 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,7 +1,6 @@
|
||||
// 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;
|
||||
@@ -9,39 +8,11 @@ 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 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.
|
||||
/// Vulkan is the only backend today; Metal/DX12 slot in here.
|
||||
/// </summary>
|
||||
internal static class GuestGpu
|
||||
{
|
||||
private static readonly Lazy<IGuestGpuBackend> Instance = new(Create);
|
||||
private static readonly Lazy<IGuestGpuBackend> Instance = new(static () => new VulkanGuestGpuBackend());
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.Agc;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu;
|
||||
|
||||
// The types that cross the guest-GPU backend seam. Every field is either a neutral
|
||||
@@ -12,8 +10,7 @@ namespace SharpEmu.Libs.Gpu;
|
||||
// translation for its API.
|
||||
|
||||
/// <summary>A guest texture referenced by a draw or dispatch. Format/NumberType/
|
||||
/// TileMode/DstSelect/Type are raw guest descriptor codes. Depth is the
|
||||
/// normalized volume depth (one for non-3D resources).</summary>
|
||||
/// TileMode/DstSelect are raw guest descriptor codes.</summary>
|
||||
internal sealed record GuestDrawTexture(
|
||||
ulong Address,
|
||||
uint Width,
|
||||
@@ -30,20 +27,7 @@ internal sealed record GuestDrawTexture(
|
||||
uint Pitch = 0,
|
||||
uint TileMode = 0,
|
||||
uint DstSelect = 0xFAC,
|
||||
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,
|
||||
uint Type = 9,
|
||||
uint Depth = 1,
|
||||
// GPU-detile opt-in (SHARPEMU_GPU_DETILE): when Detile is non-null the AGC
|
||||
// layer skipped the CPU deswizzle and shipped the raw TILED bytes here in
|
||||
// TiledSource; the Vulkan backend detiles them on the GPU. RgbaPixels is
|
||||
// empty in that case. Both are neutral (no host graphics-API values).
|
||||
byte[]? TiledSource = null,
|
||||
DetileParams? Detile = null);
|
||||
GuestSampler Sampler = default);
|
||||
|
||||
/// <summary>Raw guest sampler descriptor dwords, copied verbatim from guest memory.</summary>
|
||||
internal readonly record struct GuestSampler(
|
||||
@@ -52,24 +36,6 @@ 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,
|
||||
uint Type = 9,
|
||||
uint Depth = 1);
|
||||
|
||||
internal sealed record GuestMemoryBuffer(
|
||||
ulong BaseAddress,
|
||||
byte[] Data,
|
||||
@@ -156,22 +122,12 @@ 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,
|
||||
GuestBlendConstant BlendConstant = default)
|
||||
GuestDepthState Depth)
|
||||
{
|
||||
public static GuestRenderState Default { get; } = new(
|
||||
[GuestBlendState.Default],
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu;
|
||||
@@ -18,10 +17,6 @@ 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);
|
||||
|
||||
@@ -193,70 +188,4 @@ 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();
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
// 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";
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Numerics;
|
||||
using SharpEmu.Libs.Agc;
|
||||
using SharpEmu.ShaderCompiler.Metal;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu.Metal;
|
||||
|
||||
/// <summary>
|
||||
/// Metal twin of <c>VulkanDetilePass</c>: runs the ExactXor detile equation from
|
||||
/// <see cref="GnmTiling.GetDetileParams"/> as a Metal compute kernel
|
||||
/// (<see cref="MslFixedShaders.CreateDetileCompute"/>), writing a linear buffer
|
||||
/// and blitting it into the sampled texture.
|
||||
///
|
||||
/// <see cref="RecordDetile"/> records the compute dispatch + blit onto a caller's
|
||||
/// command buffer and returns its transient buffers for the caller to release
|
||||
/// once that command buffer completes — the async, non-blocking shape (Metal
|
||||
/// hazard-tracks the compute-write → blit-read → sample dependency automatically,
|
||||
/// so no manual barriers are needed).
|
||||
///
|
||||
/// Only ExactXor 4-bytes/element surfaces are handled. NOTE: authored on Windows;
|
||||
/// the MSL and every Metal call here are <b>Mac-untested</b> — mirrors the
|
||||
/// verified Vulkan logic and the existing Metal message-send conventions, but
|
||||
/// must be validated on a real Metal device.
|
||||
/// </summary>
|
||||
internal sealed unsafe class MetalDetilePass : IDisposable
|
||||
{
|
||||
private const uint LocalSize = 8;
|
||||
private const int PushConstantUints = 11;
|
||||
|
||||
private readonly nint _device;
|
||||
private nint _pipelineState;
|
||||
private bool _initialized;
|
||||
private bool _disposed;
|
||||
|
||||
public MetalDetilePass(nint device)
|
||||
{
|
||||
_device = device;
|
||||
}
|
||||
|
||||
public static bool Supports(in DetileParams parameters) =>
|
||||
(parameters.Equation == DetileEquation.ExactXor ||
|
||||
parameters.Equation == DetileEquation.BlockTable) &&
|
||||
parameters.BytesPerElement is 4 or 8 or 16;
|
||||
|
||||
/// <summary>
|
||||
/// Records the deswizzle of <paramref name="tiled"/> into
|
||||
/// <paramref name="texture"/> (<paramref name="texelWidth"/> x
|
||||
/// <paramref name="texelHeight"/> texels x <paramref name="layers"/> slices)
|
||||
/// onto <paramref name="commandBuffer"/>. The kernel iterates the element grid
|
||||
/// from <paramref name="parameters"/> (for block-compressed formats a 4x4 block
|
||||
/// is one element). Does not commit; the caller releases
|
||||
/// <paramref name="transientBuffers"/> when the command buffer completes.
|
||||
/// Returns false (empty transients) when unsupported or the pipeline could not
|
||||
/// be built.
|
||||
/// </summary>
|
||||
public bool RecordDetile(
|
||||
nint commandBuffer,
|
||||
nint texture,
|
||||
uint texelWidth,
|
||||
uint texelHeight,
|
||||
uint layers,
|
||||
ReadOnlySpan<byte> tiled,
|
||||
in DetileParams parameters,
|
||||
out nint[] transientBuffers)
|
||||
{
|
||||
transientBuffers = [];
|
||||
var bytesPerElement = (uint)parameters.BytesPerElement;
|
||||
if (_disposed || commandBuffer == 0 || texture == 0 ||
|
||||
!Supports(parameters) || texelWidth == 0 || texelHeight == 0 || layers == 0 || tiled.IsEmpty ||
|
||||
tiled.Length % (int)(layers * bytesPerElement) != 0 ||
|
||||
!EnsurePipeline())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var elementsWide = (uint)parameters.ElementsWide;
|
||||
var elementsHigh = (uint)parameters.ElementsHigh;
|
||||
var uintsPerElement = bytesPerElement / sizeof(uint);
|
||||
|
||||
// Array slices are packed contiguously in the tiled buffer; each slice's
|
||||
// element stride is the whole buffer split evenly by layer.
|
||||
var srcSliceElements = (uint)((ulong)tiled.Length / bytesPerElement / layers);
|
||||
|
||||
// Binding 1 carries the within-block offset table. ExactXor: element-shifted
|
||||
// X/Y byte terms. BlockTable: GetDetileParams' block table (already element
|
||||
// offsets) in binding 1, a placeholder in binding 2. The two equations index
|
||||
// different-sized buffers, so the kernel branches and reads only one.
|
||||
uint[] xTerm;
|
||||
uint[] yTerm;
|
||||
uint equationValue;
|
||||
if (parameters.Equation == DetileEquation.BlockTable)
|
||||
{
|
||||
xTerm = new uint[parameters.BlockTable.Length];
|
||||
for (var index = 0; index < xTerm.Length; index++)
|
||||
{
|
||||
xTerm[index] = (uint)parameters.BlockTable[index];
|
||||
}
|
||||
|
||||
yTerm = [0];
|
||||
equationValue = 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
var shift = BitOperations.TrailingZeroCount((uint)parameters.BytesPerElement);
|
||||
xTerm = ToElementTerms(parameters.XByteTerm, shift);
|
||||
yTerm = ToElementTerms(parameters.YByteTerm, shift);
|
||||
equationValue = 0;
|
||||
}
|
||||
|
||||
var newBufferWithBytes = MetalNative.Selector("newBufferWithBytes:length:options:");
|
||||
var newBufferWithLength = MetalNative.Selector("newBufferWithLength:options:");
|
||||
|
||||
nint tiledBuffer;
|
||||
nint xBuffer;
|
||||
nint yBuffer;
|
||||
fixed (byte* tiledPointer = tiled)
|
||||
{
|
||||
tiledBuffer = MetalNative.SendBuffer(
|
||||
_device, newBufferWithBytes, (nint)tiledPointer, (nuint)tiled.Length, 0);
|
||||
}
|
||||
|
||||
fixed (uint* xPointer = xTerm)
|
||||
{
|
||||
xBuffer = MetalNative.SendBuffer(
|
||||
_device, newBufferWithBytes, (nint)xPointer, (nuint)xTerm.Length * sizeof(uint), 0);
|
||||
}
|
||||
|
||||
fixed (uint* yPointer = yTerm)
|
||||
{
|
||||
yBuffer = MetalNative.SendBuffer(
|
||||
_device, newBufferWithBytes, (nint)yPointer, (nuint)yTerm.Length * sizeof(uint), 0);
|
||||
}
|
||||
|
||||
var outputBytes = (nuint)elementsWide * elementsHigh * bytesPerElement * layers;
|
||||
var outputBuffer = MetalNative.SendNewBuffer(_device, newBufferWithLength, outputBytes, 0);
|
||||
|
||||
Span<uint> push =
|
||||
[
|
||||
elementsWide,
|
||||
elementsHigh,
|
||||
(uint)parameters.BlockWidth,
|
||||
(uint)parameters.BlockHeight,
|
||||
(uint)parameters.BlockElements,
|
||||
(uint)parameters.BlocksPerRow,
|
||||
(uint)parameters.XMask,
|
||||
(uint)parameters.YMask,
|
||||
srcSliceElements,
|
||||
equationValue,
|
||||
uintsPerElement,
|
||||
];
|
||||
nint paramsBuffer;
|
||||
fixed (uint* pushPointer = push)
|
||||
{
|
||||
paramsBuffer = MetalNative.SendBuffer(
|
||||
_device, newBufferWithBytes, (nint)pushPointer, (nuint)PushConstantUints * sizeof(uint), 0);
|
||||
}
|
||||
|
||||
if (tiledBuffer == 0 || xBuffer == 0 || yBuffer == 0 || outputBuffer == 0 || paramsBuffer == 0)
|
||||
{
|
||||
ReleaseAll(tiledBuffer, xBuffer, yBuffer, outputBuffer, paramsBuffer);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compute encoder: one thread per texel.
|
||||
var setBuffer = MetalNative.Selector("setBuffer:offset:atIndex:");
|
||||
var encoder = MetalNative.Send(commandBuffer, MetalNative.Selector("computeCommandEncoder"));
|
||||
MetalNative.Send(encoder, MetalNative.Selector("setComputePipelineState:"), _pipelineState);
|
||||
MetalNative.SendSetBuffer(encoder, setBuffer, tiledBuffer, 0, 0);
|
||||
MetalNative.SendSetBuffer(encoder, setBuffer, xBuffer, 0, 1);
|
||||
MetalNative.SendSetBuffer(encoder, setBuffer, yBuffer, 0, 2);
|
||||
MetalNative.SendSetBuffer(encoder, setBuffer, outputBuffer, 0, 3);
|
||||
MetalNative.SendSetBuffer(encoder, setBuffer, paramsBuffer, 0, 4);
|
||||
|
||||
// X is widened by uintsPerElement (each thread copies one word); one
|
||||
// grid-Z layer per array slice.
|
||||
var threadgroups = new MtlSize
|
||||
{
|
||||
Width = (nuint)((elementsWide * uintsPerElement + LocalSize - 1) / LocalSize),
|
||||
Height = (nuint)((elementsHigh + LocalSize - 1) / LocalSize),
|
||||
Depth = layers,
|
||||
};
|
||||
var threadsPerThreadgroup = new MtlSize { Width = LocalSize, Height = LocalSize, Depth = 1 };
|
||||
MetalNative.SendDispatch(
|
||||
encoder,
|
||||
MetalNative.Selector("dispatchThreadgroups:threadsPerThreadgroup:"),
|
||||
threadgroups,
|
||||
threadsPerThreadgroup);
|
||||
MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding"));
|
||||
|
||||
// Blit the layer-major linear output buffer into the sampled texture, one
|
||||
// slice per array layer (Metal copyFromBuffer targets a single slice). The
|
||||
// buffer is element/block-packed (row stride = elementsWide*bpp); the copy
|
||||
// region is in texels. Metal tracks the compute-write -> blit-read hazard.
|
||||
var blit = MetalNative.Send(commandBuffer, MetalNative.Selector("blitCommandEncoder"));
|
||||
var copySelector = MetalNative.Selector(
|
||||
"copyFromBuffer:sourceOffset:sourceBytesPerRow:sourceBytesPerImage:sourceSize:" +
|
||||
"toTexture:destinationSlice:destinationLevel:destinationOrigin:");
|
||||
var sliceBytes = (nuint)elementsWide * elementsHigh * bytesPerElement;
|
||||
var rowBytes = (nuint)elementsWide * bytesPerElement;
|
||||
for (uint layer = 0; layer < layers; layer++)
|
||||
{
|
||||
MetalNative.SendCopyBufferToTexture(
|
||||
blit,
|
||||
copySelector,
|
||||
outputBuffer,
|
||||
(nuint)layer * sliceBytes,
|
||||
rowBytes,
|
||||
sliceBytes,
|
||||
new MtlSize { Width = texelWidth, Height = texelHeight, Depth = 1 },
|
||||
texture,
|
||||
layer,
|
||||
0,
|
||||
new MtlOrigin { X = 0, Y = 0, Z = 0 });
|
||||
}
|
||||
|
||||
MetalNative.SendVoid(blit, MetalNative.Selector("endEncoding"));
|
||||
|
||||
transientBuffers = [tiledBuffer, xBuffer, yBuffer, outputBuffer, paramsBuffer];
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool EnsurePipeline()
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return _pipelineState != 0;
|
||||
}
|
||||
|
||||
_initialized = true;
|
||||
|
||||
var options = MetalNative.Send(
|
||||
MetalNative.Send(MetalNative.Class("MTLCompileOptions"), MetalNative.Selector("alloc")),
|
||||
MetalNative.Selector("init"));
|
||||
MetalNative.SendVoidBool(options, MetalNative.Selector("setFastMathEnabled:"), false);
|
||||
|
||||
nint libraryError = 0;
|
||||
var library = MetalNative.Send(
|
||||
_device,
|
||||
MetalNative.Selector("newLibraryWithSource:options:error:"),
|
||||
MetalNative.NsString(MslFixedShaders.CreateDetileCompute()),
|
||||
options,
|
||||
ref libraryError);
|
||||
if (library == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[GPU-DETILE] Metal detile library compile failed: {MetalNative.DescribeError(libraryError)}");
|
||||
return false;
|
||||
}
|
||||
|
||||
var function = MetalNative.Send(
|
||||
library, MetalNative.Selector("newFunctionWithName:"), MetalNative.NsString("detile_cs"));
|
||||
if (function == 0)
|
||||
{
|
||||
Console.Error.WriteLine("[GPU-DETILE] Metal detile function 'detile_cs' not found.");
|
||||
return false;
|
||||
}
|
||||
|
||||
nint pipelineError = 0;
|
||||
_pipelineState = MetalNative.Send(
|
||||
_device,
|
||||
MetalNative.Selector("newComputePipelineStateWithFunction:error:"),
|
||||
function,
|
||||
ref pipelineError);
|
||||
if (_pipelineState == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[GPU-DETILE] Metal detile pipeline failed: {MetalNative.DescribeError(pipelineError)}");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static uint[] ToElementTerms(int[] byteTerms, int shift)
|
||||
{
|
||||
var terms = new uint[byteTerms.Length];
|
||||
for (var index = 0; index < byteTerms.Length; index++)
|
||||
{
|
||||
terms[index] = (uint)byteTerms[index] >> shift;
|
||||
}
|
||||
|
||||
return terms;
|
||||
}
|
||||
|
||||
private static void ReleaseAll(params nint[] objects)
|
||||
{
|
||||
var release = MetalNative.Selector("release");
|
||||
foreach (var handle in objects)
|
||||
{
|
||||
if (handle != 0)
|
||||
{
|
||||
MetalNative.SendVoid(handle, release);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
if (_pipelineState != 0)
|
||||
{
|
||||
MetalNative.SendVoid(_pipelineState, MetalNative.Selector("release"));
|
||||
_pipelineState = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
// 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();
|
||||
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE.Host;
|
||||
using SharpEmu.HLE.Host.Posix;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu.Metal;
|
||||
|
||||
/// <summary>
|
||||
/// Keyboard state sampled from the Metal presenter's window, feeding the POSIX
|
||||
/// host input seam so pad emulation works like the Vulkan presenter's
|
||||
/// HostWindowInput. Key events arrive on the AppKit main thread as macOS
|
||||
/// virtual key codes; pad reads happen on guest threads, so state is guarded.
|
||||
/// Window gamepads are not surfaced by AppKit — controller support would go
|
||||
/// through GameController.framework and is out of scope here.
|
||||
/// </summary>
|
||||
internal static class MetalHostInput
|
||||
{
|
||||
private static readonly object Gate = new();
|
||||
private static readonly HashSet<ushort> Pressed = new();
|
||||
private static volatile bool _connected;
|
||||
|
||||
/// <summary>Registers this window's keyboard as the host input source.</summary>
|
||||
public static void Attach()
|
||||
{
|
||||
_connected = true;
|
||||
PosixHostInput.SetSource(new MetalWindowInputSource());
|
||||
Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
|
||||
}
|
||||
|
||||
// Debug automation: SHARPEMU_METAL_AUTOKEY="12:0x24,15:0x24" presses the
|
||||
// macOS key code at each elapsed-seconds mark for a few frames, letting
|
||||
// headless test runs navigate menus without a human at the keyboard.
|
||||
private static readonly List<(double At, ushort Key, bool[] State)> _autoKeys = ParseAutoKeys();
|
||||
private static readonly System.Diagnostics.Stopwatch _autoKeyClock =
|
||||
System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
private static List<(double, ushort, bool[])> ParseAutoKeys()
|
||||
{
|
||||
var keys = new List<(double, ushort, bool[])>();
|
||||
var spec = Environment.GetEnvironmentVariable("SHARPEMU_METAL_AUTOKEY");
|
||||
if (string.IsNullOrWhiteSpace(spec))
|
||||
{
|
||||
return keys;
|
||||
}
|
||||
|
||||
foreach (var entry in spec.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var parts = entry.Split(':');
|
||||
if (parts.Length == 2 &&
|
||||
double.TryParse(parts[0], out var at) &&
|
||||
TryParseKeyCode(parts[1], out var key))
|
||||
{
|
||||
keys.Add((at, key, new bool[2]));
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
private static bool TryParseKeyCode(string text, out ushort key)
|
||||
{
|
||||
return text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||||
? ushort.TryParse(text[2..], System.Globalization.NumberStyles.HexNumber, null, out key)
|
||||
: ushort.TryParse(text, out key);
|
||||
}
|
||||
|
||||
/// <summary>Called once per render frame; fires and releases scripted keys.</summary>
|
||||
public static void PumpAutoKeys()
|
||||
{
|
||||
if (_autoKeys.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var elapsed = _autoKeyClock.Elapsed.TotalSeconds;
|
||||
foreach (var (at, key, state) in _autoKeys)
|
||||
{
|
||||
if (!state[0] && elapsed >= at)
|
||||
{
|
||||
state[0] = true;
|
||||
KeyDown(key, isRepeat: false);
|
||||
Console.Error.WriteLine($"[LOADER][INFO] Metal autokey press 0x{key:X} at {elapsed:F1}s");
|
||||
}
|
||||
else if (state[0] && !state[1] && elapsed >= at + 0.2)
|
||||
{
|
||||
state[1] = true;
|
||||
KeyUp(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void KeyDown(ushort keyCode, bool isRepeat)
|
||||
{
|
||||
// kVK_F1: parity with the Vulkan window's perf-overlay toggle.
|
||||
if (keyCode == 0x7A && !isRepeat)
|
||||
{
|
||||
VideoOut.PerfOverlay.Toggle();
|
||||
}
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
Pressed.Add(keyCode);
|
||||
}
|
||||
}
|
||||
|
||||
public static void KeyUp(ushort keyCode)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
Pressed.Remove(keyCode);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsKeyCodeDown(ushort keyCode)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
return Pressed.Contains(keyCode);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MetalWindowInputSource : IPosixWindowInputSource
|
||||
{
|
||||
public bool HasKeyboardFocus => _connected;
|
||||
|
||||
public bool IsKeyDown(int virtualKey) =>
|
||||
TryMapVirtualKey(virtualKey, out var keyCode) && IsKeyCodeDown(keyCode);
|
||||
|
||||
public int GetGamepadStates(Span<HostGamepadState> destination) => 0;
|
||||
|
||||
public string? DescribeConnectedGamepad() => null;
|
||||
}
|
||||
|
||||
/// <summary>Windows virtual-key semantics (the seam's contract) to macOS
|
||||
/// kVK virtual key codes, covering the keys pad emulation polls.</summary>
|
||||
private static bool TryMapVirtualKey(int vk, out ushort keyCode)
|
||||
{
|
||||
keyCode = vk switch
|
||||
{
|
||||
0x08 => 0x33, // Backspace -> kVK_Delete
|
||||
0x09 => 0x30, // Tab
|
||||
0x0D => 0x24, // Enter -> kVK_Return
|
||||
0x1B => 0x35, // Escape
|
||||
0x20 => 0x31, // Space
|
||||
0x25 => 0x7B, // Left
|
||||
0x26 => 0x7E, // Up
|
||||
0x27 => 0x7C, // Right
|
||||
0x28 => 0x7D, // Down
|
||||
// Letters: macOS ANSI key codes are layout-position based and
|
||||
// non-contiguous, so map each polled letter explicitly.
|
||||
0x41 => 0x00, // A
|
||||
0x42 => 0x0B, // B
|
||||
0x43 => 0x08, // C
|
||||
0x44 => 0x02, // D
|
||||
0x45 => 0x0E, // E
|
||||
0x46 => 0x03, // F
|
||||
0x47 => 0x05, // G
|
||||
0x48 => 0x04, // H
|
||||
0x49 => 0x22, // I
|
||||
0x4A => 0x26, // J
|
||||
0x4B => 0x28, // K
|
||||
0x4C => 0x25, // L
|
||||
0x4D => 0x2E, // M
|
||||
0x4E => 0x2D, // N
|
||||
0x4F => 0x1F, // O
|
||||
0x50 => 0x23, // P
|
||||
0x51 => 0x0C, // Q
|
||||
0x52 => 0x0F, // R
|
||||
0x53 => 0x01, // S
|
||||
0x54 => 0x11, // T
|
||||
0x55 => 0x20, // U
|
||||
0x56 => 0x09, // V
|
||||
0x57 => 0x0D, // W
|
||||
0x58 => 0x07, // X
|
||||
0x59 => 0x10, // Y
|
||||
0x5A => 0x06, // Z
|
||||
_ => ushort.MaxValue,
|
||||
};
|
||||
return keyCode != ushort.MaxValue;
|
||||
}
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
// 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";
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
// 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++;
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,6 @@ namespace SharpEmu.Libs.Gpu.Vulkan;
|
||||
/// </summary>
|
||||
internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
|
||||
{
|
||||
public string BackendName => "Vulkan";
|
||||
|
||||
private static readonly IGuestCompiledShader DepthOnlyFragmentShader =
|
||||
new VulkanCompiledGuestShader(SpirvFixedShaders.CreateDepthOnlyFragment());
|
||||
|
||||
@@ -345,60 +343,6 @@ 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
|
||||
|
||||
@@ -80,18 +80,6 @@ public static class JsonExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "PR5k1penBLM",
|
||||
ExportName = "_ZN3sce4Json11Initializer9terminateEv",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceJson")]
|
||||
public static int InitializerTerminate(CpuContext ctx)
|
||||
{
|
||||
TraceJson("Initializer.terminate", ctx[CpuRegister.Rdi], 0);
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "Cxwy7wHq4J0",
|
||||
ExportName = "_ZN3sce4Json11Initializer10initializeEPKNS0_13InitParameterE",
|
||||
@@ -131,17 +119,6 @@ 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.PosixOpen(ctx);
|
||||
public static int Open(CpuContext ctx) => KernelMemoryCompatExports.KernelOpenUnderscore(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.PosixFstat(ctx);
|
||||
public static int Fstat(CpuContext ctx) => KernelMemoryCompatExports.KernelFstat(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "hcuQgD53UxM",
|
||||
|
||||
@@ -267,11 +267,6 @@ 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);
|
||||
@@ -315,11 +310,6 @@ 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))
|
||||
|
||||
@@ -41,87 +41,18 @@ public static class KernelPthreadCompatExports
|
||||
|
||||
private sealed class PthreadMutexState
|
||||
{
|
||||
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 ulong OwnerThreadId { get; set; }
|
||||
public int RecursionCount { get; set; }
|
||||
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; set; }
|
||||
public ManualResetEventSlim? HostSignal { get; set; }
|
||||
public required bool Cooperative { get; init; }
|
||||
public LinkedListNode<PthreadMutexWaiter>? Node { get; set; }
|
||||
public int Granted;
|
||||
}
|
||||
@@ -153,64 +84,6 @@ public static class KernelPthreadCompatExports
|
||||
static KernelPthreadCompatExports()
|
||||
{
|
||||
RunSynchronizationSelfChecks();
|
||||
GuestThreadExecution.GuestThreadAbandoned += AbandonMutexesOwnedByThread;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force-release mutexes still owned by a guest thread that is being torn
|
||||
/// down without a clean unlock (TBB worker_abort, abrupt exit). Otherwise
|
||||
/// waiters can spin forever and block splash→first GPU submit.
|
||||
/// </summary>
|
||||
public static int AbandonMutexesOwnedByThread(ulong threadId, string reason)
|
||||
{
|
||||
if (threadId == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var released = 0;
|
||||
var wakeKeys = new List<string>();
|
||||
foreach (var pair in _mutexStates)
|
||||
{
|
||||
var state = pair.Value;
|
||||
string? wakeKey = null;
|
||||
lock (state)
|
||||
{
|
||||
if (state.OwnerThreadId != threadId || state.RecursionCount <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
state.OwnerThreadId = 0;
|
||||
state.RecursionCount = 0;
|
||||
wakeKey = state.Waiters.First?.Value.Cooperative == true
|
||||
? state.Waiters.First.Value.WakeKey
|
||||
: null;
|
||||
Monitor.PulseAll(state);
|
||||
released++;
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] pthread_mutex_abandon mutex=0x{pair.Key:X16} " +
|
||||
$"owner={KernelPthreadState.DescribeThreadHandle(threadId)} " +
|
||||
$"reason={reason} waiters={state.Waiters.Count}");
|
||||
}
|
||||
|
||||
if (wakeKey is not null)
|
||||
{
|
||||
wakeKeys.Add(wakeKey);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var wakeKey in wakeKeys)
|
||||
{
|
||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(wakeKey, 1);
|
||||
}
|
||||
|
||||
if (released > 0)
|
||||
{
|
||||
Console.Error.Flush();
|
||||
}
|
||||
|
||||
return released;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
@@ -221,10 +94,7 @@ public static class KernelPthreadCompatExports
|
||||
public static int PthreadSelf(CpuContext ctx)
|
||||
{
|
||||
var currentThreadHandle = KernelPthreadState.GetCurrentThreadHandle();
|
||||
if (GuestThreadExecution.CurrentGuestThreadHandle != currentThreadHandle)
|
||||
{
|
||||
GuestThreadExecution.Scheduler?.RegisterGuestThreadContext(currentThreadHandle, ctx);
|
||||
}
|
||||
GuestThreadExecution.Scheduler?.RegisterGuestThreadContext(currentThreadHandle, ctx);
|
||||
ctx[CpuRegister.Rax] = currentThreadHandle;
|
||||
TracePthreadSelf(ctx, currentThreadHandle);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
@@ -269,13 +139,6 @@ 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",
|
||||
@@ -708,30 +571,6 @@ 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)
|
||||
@@ -782,7 +621,7 @@ public static class KernelPthreadCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
lock (state.SyncRoot)
|
||||
lock (state)
|
||||
{
|
||||
if (state.OwnerThreadId != 0 || state.RecursionCount != 0 || state.Waiters.Count != 0)
|
||||
{
|
||||
@@ -814,63 +653,11 @@ 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;
|
||||
var acquiredWhileQueueing = false;
|
||||
lock (state.SyncRoot)
|
||||
lock (state)
|
||||
{
|
||||
if (state.OwnerThreadId == currentThreadId)
|
||||
{
|
||||
@@ -881,30 +668,7 @@ public static class KernelPthreadCompatExports
|
||||
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)
|
||||
{
|
||||
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 (state.Type is MutexTypeNormal or MutexTypeAdaptiveNp)
|
||||
{
|
||||
if (tryOnly)
|
||||
{
|
||||
@@ -913,7 +677,7 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
|
||||
// Several Gen5 runtimes layer their own owner/count bookkeeping
|
||||
// over a NORMAL kernel mutex. Returning EDEADLK here
|
||||
// over a NORMAL or ADAPTIVE 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;
|
||||
@@ -932,17 +696,10 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
if (state.OwnerThreadId == 0 && state.Waiters.Count == 0)
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -954,14 +711,6 @@ 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 &&
|
||||
@@ -995,29 +744,8 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
|
||||
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
|
||||
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)
|
||||
string? nextWakeKey = null;
|
||||
lock (state)
|
||||
{
|
||||
if (state.RecursionCount <= 0)
|
||||
{
|
||||
@@ -1035,29 +763,16 @@ public static class KernelPthreadCompatExports
|
||||
if (state.RecursionCount == 0)
|
||||
{
|
||||
state.OwnerThreadId = 0;
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
nextWakeKey = state.Waiters.First?.Value.Cooperative == true
|
||||
? state.Waiters.First.Value.WakeKey
|
||||
: null;
|
||||
Monitor.PulseAll(state);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextWaiter is { Cooperative: true })
|
||||
if (nextWakeKey is not null)
|
||||
{
|
||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWaiter.WakeKey, 1);
|
||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWakeKey, 1);
|
||||
}
|
||||
|
||||
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
@@ -1517,22 +1232,8 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
|
||||
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
|
||||
lock (mutexState.SyncRoot)
|
||||
lock (mutexState)
|
||||
{
|
||||
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
|
||||
@@ -1684,32 +1385,6 @@ 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,
|
||||
@@ -1717,10 +1392,8 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1730,7 +1403,7 @@ public static class KernelPthreadCompatExports
|
||||
var mutex = new PthreadMutexState();
|
||||
PthreadMutexWaiter first;
|
||||
PthreadMutexWaiter second;
|
||||
lock (mutex.SyncRoot)
|
||||
lock (mutex)
|
||||
{
|
||||
first = EnqueueMutexWaiterLocked(mutex, 0x101, cooperative: false);
|
||||
second = EnqueueMutexWaiterLocked(mutex, 0x202, cooperative: false);
|
||||
@@ -1774,72 +1447,26 @@ 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)
|
||||
{
|
||||
ManualResetEventSlim? hostSignal = null;
|
||||
try
|
||||
lock (state)
|
||||
{
|
||||
while (true)
|
||||
while (!TryGrantMutexWaiterLocked(state, waiter))
|
||||
{
|
||||
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();
|
||||
Monitor.Wait(state);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
hostSignal?.Dispose();
|
||||
}
|
||||
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
private static bool TryGrantBlockedMutexLock(
|
||||
@@ -1850,7 +1477,7 @@ public static class KernelPthreadCompatExports
|
||||
PthreadMutexWaiter waiter)
|
||||
{
|
||||
var granted = false;
|
||||
lock (state.SyncRoot)
|
||||
lock (state)
|
||||
{
|
||||
granted = TryGrantMutexWaiterLocked(state, waiter);
|
||||
}
|
||||
@@ -1884,10 +1511,6 @@ 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,
|
||||
@@ -1903,7 +1526,7 @@ public static class KernelPthreadCompatExports
|
||||
waiter.TimeoutTimer?.Dispose();
|
||||
waiter.TimeoutTimer = null;
|
||||
|
||||
lock (waiter.MutexState.SyncRoot)
|
||||
lock (waiter.MutexState)
|
||||
{
|
||||
waiter.MutexWaiter = EnqueueMutexWaiterLocked(
|
||||
waiter.MutexState,
|
||||
@@ -1951,7 +1574,7 @@ public static class KernelPthreadCompatExports
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (waiter.MutexState.SyncRoot)
|
||||
lock (waiter.MutexState)
|
||||
{
|
||||
return TryGrantMutexWaiterLocked(waiter.MutexState, mutexWaiter);
|
||||
}
|
||||
|
||||
@@ -860,18 +860,6 @@ 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",
|
||||
@@ -1145,90 +1133,6 @@ 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",
|
||||
@@ -1915,94 +1819,4 @@ 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);
|
||||
}
|
||||
|
||||
@@ -27,12 +27,8 @@ internal static class KernelPthreadState
|
||||
internal static ulong GetCurrentThreadHandle()
|
||||
{
|
||||
var guestThreadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
|
||||
// Prefer the bound guest handle even when it is not yet in Threads.
|
||||
// Falling through to a synthetic ThreadStatic handle while a guest
|
||||
// thread is bound causes mutex owner mismatches (unlock PERM → hang).
|
||||
if (guestThreadHandle != 0)
|
||||
if (guestThreadHandle != 0 && TryGetThreadIdentity(guestThreadHandle, out _))
|
||||
{
|
||||
EnsureGuestThreadIdentity(guestThreadHandle);
|
||||
return guestThreadHandle;
|
||||
}
|
||||
|
||||
@@ -43,27 +39,15 @@ internal static class KernelPthreadState
|
||||
internal static ulong GetCurrentThreadUniqueId()
|
||||
{
|
||||
var guestThreadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
|
||||
if (guestThreadHandle != 0)
|
||||
if (guestThreadHandle != 0 && TryGetThreadIdentity(guestThreadHandle, out var identity))
|
||||
{
|
||||
return EnsureGuestThreadIdentity(guestThreadHandle).UniqueId;
|
||||
return identity.UniqueId;
|
||||
}
|
||||
|
||||
EnsureCurrentThreadRegistered();
|
||||
return _currentThreadUniqueId;
|
||||
}
|
||||
|
||||
internal static string DescribeThreadHandle(ulong threadHandle)
|
||||
{
|
||||
if (threadHandle == 0)
|
||||
{
|
||||
return "none";
|
||||
}
|
||||
|
||||
return TryGetThreadIdentity(threadHandle, out var identity)
|
||||
? $"0x{threadHandle:X16}('{identity.Name}')"
|
||||
: $"0x{threadHandle:X16}";
|
||||
}
|
||||
|
||||
internal static ulong CreateThreadHandle(string name)
|
||||
{
|
||||
var uniqueId = unchecked((ulong)Interlocked.Increment(ref _nextUniqueThreadId));
|
||||
@@ -75,18 +59,6 @@ internal static class KernelPthreadState
|
||||
return Threads.TryGetValue(threadHandle, out identity);
|
||||
}
|
||||
|
||||
private static ThreadIdentity EnsureGuestThreadIdentity(ulong guestThreadHandle)
|
||||
{
|
||||
if (Threads.TryGetValue(guestThreadHandle, out var existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var uniqueId = unchecked((ulong)Interlocked.Increment(ref _nextUniqueThreadId));
|
||||
var identity = new ThreadIdentity(uniqueId, $"Guest-0x{guestThreadHandle:X}");
|
||||
return Threads.GetOrAdd(guestThreadHandle, identity);
|
||||
}
|
||||
|
||||
private static void EnsureCurrentThreadRegistered()
|
||||
{
|
||||
if (_currentThreadHandle != 0)
|
||||
|
||||
@@ -2058,13 +2058,6 @@ 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,27 +191,6 @@ 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)}");
|
||||
@@ -449,22 +428,6 @@ 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",
|
||||
@@ -483,13 +446,6 @@ 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",
|
||||
@@ -507,19 +463,6 @@ 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",
|
||||
@@ -556,13 +499,6 @@ 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",
|
||||
@@ -613,13 +549,6 @@ 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;
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,7 @@ internal static class KernelVirtualRangeAllocator
|
||||
bool allowSearch,
|
||||
bool allowAllocateAtAlternative,
|
||||
string traceName,
|
||||
out ulong mappedAddress,
|
||||
bool backPartialOverlap = false)
|
||||
out ulong mappedAddress)
|
||||
{
|
||||
mappedAddress = 0;
|
||||
if (length == 0)
|
||||
@@ -43,18 +42,6 @@ internal static class KernelVirtualRangeAllocator
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fixed mappings must cover the whole requested window even when part of
|
||||
// it is already backed by another allocation. The single-call AllocateAt
|
||||
// below is all-or-nothing and fails outright on partial overlap, leaving
|
||||
// the untouched pages unmapped for the guest to fault into. Fill the free
|
||||
// pages directly instead.
|
||||
if (backPartialOverlap &&
|
||||
addressSpace.TryBackFixedRange(desiredAddress, length, executable))
|
||||
{
|
||||
mappedAddress = desiredAddress;
|
||||
return true;
|
||||
}
|
||||
|
||||
var allocated = addressSpace.AllocateAt(desiredAddress, length, executable, allowAllocateAtAlternative);
|
||||
if (allocated == 0)
|
||||
{
|
||||
|
||||
@@ -184,212 +184,6 @@ public static class NetExports
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POSIX alias of <see cref="NetSetsockopt"/>; identical
|
||||
/// (fd, level, option, value, length) argument order.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "fFxGkxF2bVo",
|
||||
ExportName = "setsockopt",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixSetsockopt(CpuContext ctx) => NetSetsockopt(ctx);
|
||||
|
||||
/// <summary>
|
||||
/// Reads back the socket options this backend actually tracks: SO_NBIO,
|
||||
/// SO_REUSEADDR and SO_ERROR.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Anything else returns EINVAL rather than a zero-filled buffer. A caller
|
||||
/// that receives success for an option nobody stored would treat whatever
|
||||
/// happens to be in its output buffer as the real setting, which is a harder
|
||||
/// failure to trace than an explicit rejection.
|
||||
/// </remarks>
|
||||
[SysAbiExport(
|
||||
Nid = "6O8EwYOgH9Y",
|
||||
ExportName = "getsockopt",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixGetsockopt(CpuContext ctx)
|
||||
{
|
||||
var id = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var level = unchecked((int)ctx[CpuRegister.Rsi]);
|
||||
var option = unchecked((int)ctx[CpuRegister.Rdx]);
|
||||
var valueAddress = ctx[CpuRegister.Rcx];
|
||||
var lengthAddress = ctx[CpuRegister.R8];
|
||||
if (!_sockets.TryGetValue(id, out var socket))
|
||||
{
|
||||
return SetNetError(ctx, NetErrorBadFileDescriptor, NetErrnoBadFileDescriptor);
|
||||
}
|
||||
|
||||
if (valueAddress == 0 || lengthAddress == 0 || level != 0xFFFF)
|
||||
{
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
Span<byte> lengthBytes = stackalloc byte[sizeof(int)];
|
||||
if (!ctx.Memory.TryRead(lengthAddress, lengthBytes))
|
||||
{
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
if (BinaryPrimitives.ReadInt32LittleEndian(lengthBytes) < sizeof(int))
|
||||
{
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
int value;
|
||||
switch (option)
|
||||
{
|
||||
// ORBIS_NET_SO_NBIO: mirrors what sceNetSetsockopt stored.
|
||||
case 0x1200:
|
||||
value = socket.Blocking ? 0 : 1;
|
||||
break;
|
||||
case 0x0004:
|
||||
value = (int)socket.GetSocketOption(
|
||||
SocketOptionLevel.Socket,
|
||||
SocketOptionName.ReuseAddress)! != 0 ? 1 : 0;
|
||||
break;
|
||||
// ORBIS_NET_SO_ERROR: nothing here records per-socket async errors,
|
||||
// so report "no pending error" rather than inventing one.
|
||||
case 0x1007:
|
||||
value = 0;
|
||||
break;
|
||||
default:
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
Span<byte> valueBytes = stackalloc byte[sizeof(int)];
|
||||
BinaryPrimitives.WriteInt32LittleEndian(valueBytes, value);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, sizeof(int));
|
||||
if (!ctx.Memory.TryWrite(valueAddress, valueBytes) ||
|
||||
!ctx.Memory.TryWrite(lengthAddress, lengthBytes))
|
||||
{
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
TraceNet("socket.getsockopt", id, unchecked((uint)option), unchecked((uint)value), 0);
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "fZOeZIOEmLw",
|
||||
ExportName = "send",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixSend(CpuContext ctx)
|
||||
{
|
||||
var id = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var bufferAddress = ctx[CpuRegister.Rsi];
|
||||
var length = unchecked((int)ctx[CpuRegister.Rdx]);
|
||||
if (!_sockets.TryGetValue(id, out var socket))
|
||||
{
|
||||
return SetNetError(ctx, NetErrorBadFileDescriptor, NetErrnoBadFileDescriptor);
|
||||
}
|
||||
|
||||
if (length < 0 || (length != 0 && bufferAddress == 0))
|
||||
{
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
if (length == 0)
|
||||
{
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
var payload = new byte[length];
|
||||
if (!ctx.Memory.TryRead(bufferAddress, payload))
|
||||
{
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var sent = socket.Send(payload, SocketFlags.None);
|
||||
TraceNet("socket.send", id, unchecked((uint)length), unchecked((uint)sent), 0);
|
||||
return ctx.SetReturn(sent);
|
||||
}
|
||||
catch (SocketException exception)
|
||||
when (exception.SocketErrorCode == SocketError.WouldBlock)
|
||||
{
|
||||
return SetNetError(ctx, NetErrorWouldBlock, NetErrnoWouldBlock);
|
||||
}
|
||||
catch (SocketException)
|
||||
{
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return SetNetError(ctx, NetErrorBadFileDescriptor, NetErrnoBadFileDescriptor);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a binary address as text. Pure conversion with no socket state,
|
||||
/// so it behaves identically to the console version for AF_INET/AF_INET6.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "5jRCs2axtr4",
|
||||
ExportName = "inet_ntop",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixInetNtop(CpuContext ctx)
|
||||
{
|
||||
var family = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var sourceAddress = ctx[CpuRegister.Rsi];
|
||||
var destinationAddress = ctx[CpuRegister.Rdx];
|
||||
var destinationSize = unchecked((int)ctx[CpuRegister.Rcx]);
|
||||
if (sourceAddress == 0 || destinationAddress == 0 || destinationSize <= 0)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
// ORBIS_NET_AF_INET / ORBIS_NET_AF_INET6, matching TryMapAddressFamily.
|
||||
var addressLength = family switch
|
||||
{
|
||||
2 => 4,
|
||||
28 => 16,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
if (addressLength == 0)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
var rawAddress = new byte[addressLength];
|
||||
if (!ctx.Memory.TryRead(sourceAddress, rawAddress))
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
var text = new IPAddress(rawAddress).ToString();
|
||||
var encoded = Encoding.ASCII.GetBytes(text);
|
||||
|
||||
// POSIX requires the terminator to fit as well; a truncated address string
|
||||
// is worse than a reported failure because the caller cannot detect it.
|
||||
if (encoded.Length + 1 > destinationSize)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
var buffer = new byte[encoded.Length + 1];
|
||||
encoded.CopyTo(buffer, 0);
|
||||
if (!ctx.Memory.TryWrite(destinationAddress, buffer))
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
// inet_ntop returns the destination pointer on success.
|
||||
ctx[CpuRegister.Rax] = destinationAddress;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "bErx49PgxyY",
|
||||
ExportName = "sceNetBind",
|
||||
@@ -755,52 +549,6 @@ public static class NetExports
|
||||
return true;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "8Kcp5d-q1Uo",
|
||||
ExportName = "sceNetInetPton",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNet")]
|
||||
public static int NetInetPton(CpuContext ctx)
|
||||
{
|
||||
var addressFamily = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var sourceAddress = ctx[CpuRegister.Rsi];
|
||||
var destinationAddress = ctx[CpuRegister.Rdx];
|
||||
if (sourceAddress == 0 || destinationAddress == 0)
|
||||
{
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
if (!TryReadUtf8Z(ctx, sourceAddress, MaxNameLength, out var source))
|
||||
{
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
var family = addressFamily switch
|
||||
{
|
||||
2 => AddressFamily.InterNetwork, // AF_INET
|
||||
28 => AddressFamily.InterNetworkV6, // AF_INET6
|
||||
_ => AddressFamily.Unknown,
|
||||
};
|
||||
if (family == AddressFamily.Unknown ||
|
||||
!IPAddress.TryParse(source, out var parsed) ||
|
||||
parsed.AddressFamily != family)
|
||||
{
|
||||
// Match BSD inet_pton: return 0 for a parseable-family miss.
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
var bytes = parsed.GetAddressBytes();
|
||||
if (!ctx.Memory.TryWrite(destinationAddress, bytes))
|
||||
{
|
||||
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
|
||||
}
|
||||
|
||||
TraceNet("inet_pton", addressFamily, sourceAddress, destinationAddress, (ulong)bytes.Length);
|
||||
ctx[CpuRegister.Rax] = 1;
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static void TraceNet(string operation, int id, ulong arg0, ulong arg1, ulong arg2)
|
||||
{
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NET"), "1", StringComparison.Ordinal))
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Threading;
|
||||
|
||||
@@ -26,44 +25,24 @@ public static class Ngs2Exports
|
||||
private static long _nextUid;
|
||||
private static long _renderCount;
|
||||
|
||||
// NGS2 renders one grain of interleaved float32 per sceNgs2SystemRender.
|
||||
// The grain length defaults to 256 frames (matching the 8192-byte AudioOut
|
||||
// buffers games copy it into) until the title overrides it.
|
||||
private const int DefaultGrainSamples = 256;
|
||||
private const double OutputSampleRate = 48000.0;
|
||||
|
||||
private sealed class SystemState
|
||||
{
|
||||
public SystemState(uint uid) => Uid = uid;
|
||||
|
||||
public uint Uid { get; }
|
||||
public int GrainSamples { get; set; } = DefaultGrainSamples;
|
||||
}
|
||||
|
||||
private sealed record SystemState(uint Uid);
|
||||
private sealed record RackState(ulong SystemHandle, uint RackId);
|
||||
private sealed record VoiceState(ulong RackHandle, uint VoiceIndex);
|
||||
|
||||
private sealed class VoiceState
|
||||
[SysAbiExport(
|
||||
Nid = "koBbCMvOKWw",
|
||||
ExportName = "sceNgs2SystemCreate",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNgs2")]
|
||||
public static int Ngs2SystemCreate(CpuContext ctx)
|
||||
{
|
||||
public VoiceState(ulong rackHandle, uint voiceIndex)
|
||||
var bufferInfoAddress = ctx[CpuRegister.Rsi];
|
||||
if (!TryReadContextBuffer(ctx, bufferInfoAddress, out var hostBuffer))
|
||||
{
|
||||
RackHandle = rackHandle;
|
||||
VoiceIndex = voiceIndex;
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
public ulong RackHandle { get; }
|
||||
public uint VoiceIndex { get; }
|
||||
|
||||
// Software-mixer playback state. Pcm is the fully decoded mono waveform;
|
||||
// Position is a fractional read cursor advanced at the source/output rate
|
||||
// ratio each output frame.
|
||||
public short[]? Pcm { get; set; }
|
||||
public ulong SourceAddr { get; set; }
|
||||
public int SourceRate { get; set; }
|
||||
public double Position { get; set; }
|
||||
public bool Playing { get; set; }
|
||||
public int LoopStart { get; set; } = -1;
|
||||
public int LoopEnd { get; set; }
|
||||
public float Gain { get; set; } = 1f;
|
||||
return CreateSystem(ctx, ctx[CpuRegister.Rdx], hostBuffer);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
@@ -79,34 +58,14 @@ public static class Ngs2Exports
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
|
||||
}
|
||||
|
||||
if (!TryCreateHandle(ctx, type: 1, ownerHandle: 0, out var handle) ||
|
||||
!ctx.TryWriteUInt64(outHandleAddress, handle))
|
||||
if (!TryCreateHandle(ctx, type: 1, ownerHandle: 0, out var handle))
|
||||
{
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
lock (StateGate)
|
||||
{
|
||||
Systems[handle] = new SystemState(unchecked((uint)Interlocked.Increment(ref _nextUid)));
|
||||
}
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
return CreateSystem(ctx, outHandleAddress, handle);
|
||||
}
|
||||
|
||||
// Non-allocator create: identical to the WithAllocator form for our purposes.
|
||||
// The only signature difference is the caller-supplied buffer info in rsi
|
||||
// (vs an allocator callback); the system option (rdi) and out-handle (rdx)
|
||||
// sit at the same argument positions, so we reuse the same implementation.
|
||||
// Dead Cells uses these variants — leaving sceNgs2SystemCreate unresolved
|
||||
// gave the game a garbage system handle, so every later rack/voice call
|
||||
// failed and it polled sceNgs2VoiceGetState forever, freezing at FLIP 0.
|
||||
[SysAbiExport(
|
||||
Nid = "koBbCMvOKWw",
|
||||
ExportName = "sceNgs2SystemCreate",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNgs2")]
|
||||
public static int Ngs2SystemCreate(CpuContext ctx) => Ngs2SystemCreateWithAllocator(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "u-WrYDaJA3k",
|
||||
ExportName = "sceNgs2SystemDestroy",
|
||||
@@ -135,6 +94,27 @@ public static class Ngs2Exports
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "cLV4aiT9JpA",
|
||||
ExportName = "sceNgs2RackCreate",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNgs2")]
|
||||
public static int Ngs2RackCreate(CpuContext ctx)
|
||||
{
|
||||
var bufferInfoAddress = ctx[CpuRegister.Rcx];
|
||||
if (!TryReadContextBuffer(ctx, bufferInfoAddress, out var hostBuffer))
|
||||
{
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
return CreateRack(
|
||||
ctx,
|
||||
ctx[CpuRegister.Rdi],
|
||||
unchecked((uint)ctx[CpuRegister.Rsi]),
|
||||
ctx[CpuRegister.R8],
|
||||
hostBuffer);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "U546k6orxQo",
|
||||
ExportName = "sceNgs2RackCreateWithAllocator",
|
||||
@@ -158,29 +138,14 @@ public static class Ngs2Exports
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
|
||||
}
|
||||
|
||||
if (!TryCreateHandle(ctx, type: 2, systemHandle, out var handle) ||
|
||||
!ctx.TryWriteUInt64(outHandleAddress, handle))
|
||||
if (!TryCreateHandle(ctx, type: 2, systemHandle, out var handle))
|
||||
{
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
lock (StateGate)
|
||||
{
|
||||
Racks[handle] = new RackState(systemHandle, rackId);
|
||||
}
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
return CreateRack(ctx, systemHandle, rackId, outHandleAddress, handle);
|
||||
}
|
||||
|
||||
// Non-allocator rack create: system handle (rdi), rack id (rsi) and the
|
||||
// out-handle (r8) share the WithAllocator argument layout, so reuse it.
|
||||
[SysAbiExport(
|
||||
Nid = "cLV4aiT9JpA",
|
||||
ExportName = "sceNgs2RackCreate",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNgs2")]
|
||||
public static int Ngs2RackCreate(CpuContext ctx) => Ngs2RackCreateWithAllocator(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "lCqD7oycmIM",
|
||||
ExportName = "sceNgs2RackDestroy",
|
||||
@@ -255,217 +220,14 @@ public static class Ngs2Exports
|
||||
LibraryName = "libSceNgs2")]
|
||||
public static int Ngs2VoiceControl(CpuContext ctx)
|
||||
{
|
||||
var voiceHandle = ctx[CpuRegister.Rdi];
|
||||
var paramList = ctx[CpuRegister.Rsi];
|
||||
lock (StateGate)
|
||||
{
|
||||
if (!Voices.ContainsKey(voiceHandle))
|
||||
{
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidVoiceHandle);
|
||||
}
|
||||
}
|
||||
|
||||
if (ShouldTrace())
|
||||
{
|
||||
TraceVoiceParamList(ctx, voiceHandle, paramList);
|
||||
}
|
||||
|
||||
HandleVoiceParams(ctx, voiceHandle, paramList);
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
// Parse the SceNgs2VoiceParamHead command list (header = u32 size, u32 id;
|
||||
// params are laid out contiguously) and apply the ones the mixer needs:
|
||||
// the waveform-blocks param arms a voice with decoded PCM, and the port
|
||||
// matrix param carries its output gain.
|
||||
private static void HandleVoiceParams(CpuContext ctx, ulong voiceHandle, ulong paramList)
|
||||
{
|
||||
if (paramList == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var offset = paramList;
|
||||
for (var guard = 0; guard < 32; guard++)
|
||||
{
|
||||
if (!ctx.TryReadUInt32(offset, out var size) ||
|
||||
!ctx.TryReadUInt32(offset + 4, out var id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (id)
|
||||
{
|
||||
case 0x10000001:
|
||||
ApplyWaveformParam(ctx, voiceHandle, offset);
|
||||
break;
|
||||
case 0x20010001:
|
||||
ApplyPortMatrixParam(ctx, voiceHandle, offset);
|
||||
break;
|
||||
}
|
||||
|
||||
// Advance to the next contiguous block; the game normally sends one
|
||||
// param per call (size==whole block), so stop when size is degenerate.
|
||||
if (size < 8 || size > 0x1000)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
offset += (size + 7) & ~7u;
|
||||
return SetReturn(
|
||||
ctx,
|
||||
Voices.ContainsKey(ctx[CpuRegister.Rdi]) ? 0 : OrbisNgs2ErrorInvalidVoiceHandle);
|
||||
}
|
||||
}
|
||||
|
||||
// Waveform-blocks param: the guest pointer at +8 references a "VAGp"
|
||||
// (PS-ADPCM) container. Decode it once and arm the voice for playback.
|
||||
private static void ApplyWaveformParam(CpuContext ctx, ulong voiceHandle, ulong paramOffset)
|
||||
{
|
||||
if (!ctx.TryReadUInt64(paramOffset + 8, out var dataAddr) || dataAddr <= 0x10000)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (StateGate)
|
||||
{
|
||||
if (Voices.TryGetValue(voiceHandle, out var existing) &&
|
||||
existing.SourceAddr == dataAddr && existing.Pcm is not null)
|
||||
{
|
||||
// Same waveform already armed — don't restart it every frame.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Span<byte> header = stackalloc byte[Ngs2VagDecoder.VagHeaderSize];
|
||||
if (!ctx.Memory.TryRead(dataAddr, header) || !Ngs2VagDecoder.IsVag(header))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var declaredSize = (int)BinaryPrimitives.ReadUInt32BigEndian(header[0x0C..]);
|
||||
var totalBytes = Ngs2VagDecoder.VagHeaderSize + Math.Clamp(declaredSize, 0, 8 * 1024 * 1024);
|
||||
var raw = System.Buffers.ArrayPool<byte>.Shared.Rent(totalBytes);
|
||||
try
|
||||
{
|
||||
if (!ctx.Memory.TryRead(dataAddr, raw.AsSpan(0, totalBytes)) ||
|
||||
!Ngs2VagDecoder.TryDecode(raw.AsSpan(0, totalBytes), out var waveform))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (StateGate)
|
||||
{
|
||||
if (!Voices.TryGetValue(voiceHandle, out var voice))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
voice.Pcm = waveform.Samples;
|
||||
voice.SourceAddr = dataAddr;
|
||||
voice.SourceRate = waveform.SampleRate;
|
||||
voice.LoopStart = waveform.LoopStart;
|
||||
voice.LoopEnd = waveform.LoopEnd > 0 ? waveform.LoopEnd : waveform.Samples.Length;
|
||||
voice.Position = 0;
|
||||
voice.Playing = true;
|
||||
}
|
||||
|
||||
if (ShouldTrace())
|
||||
{
|
||||
var peak = 0;
|
||||
for (var i = 0; i < waveform.Samples.Length; i++)
|
||||
{
|
||||
peak = Math.Max(peak, Math.Abs((int)waveform.Samples[i]));
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] ngs2.arm voice=0x{voiceHandle:X16} addr=0x{dataAddr:X} rate={waveform.SampleRate} samples={waveform.Samples.Length} loop={waveform.LoopStart} peak={peak}");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.Buffers.ArrayPool<byte>.Shared.Return(raw);
|
||||
}
|
||||
}
|
||||
|
||||
// Port matrix param: the first float level is a reasonable proxy for the
|
||||
// voice's output gain until per-channel panning is implemented.
|
||||
private static void ApplyPortMatrixParam(CpuContext ctx, ulong voiceHandle, ulong paramOffset)
|
||||
{
|
||||
if (!ctx.TryReadUInt32(paramOffset + 12, out var levelBits))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var level = BitConverter.UInt32BitsToSingle(levelBits);
|
||||
if (!float.IsFinite(level) || level < 0f || level > 8f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (StateGate)
|
||||
{
|
||||
if (Voices.TryGetValue(voiceHandle, out var voice))
|
||||
{
|
||||
voice.Gain = level;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empirically dump the SceNgs2VoiceParamHead-chained command list so we can
|
||||
// confirm the real struct layout (size/next/id) against public NGS2 sources
|
||||
// before building the software mixer. Assumed header: u16 size, s16 next
|
||||
// (byte offset to the next block, 0 = end), u32 id.
|
||||
private static void TraceVoiceParamList(CpuContext ctx, ulong voiceHandle, ulong paramList)
|
||||
{
|
||||
if (paramList == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Span<byte> peek = stackalloc byte[32];
|
||||
var offset = paramList;
|
||||
for (int guard = 0; guard < 32; guard++)
|
||||
{
|
||||
if (!ctx.TryReadUInt16(offset, out var size) ||
|
||||
!ctx.TryReadUInt16(offset + 2, out var next) ||
|
||||
!ctx.TryReadUInt32(offset + 4, out var id))
|
||||
{
|
||||
Console.Error.WriteLine($"[LOADER][TRACE] ngs2.voiceparam voice=0x{voiceHandle:X16} @0x{offset:X}: unreadable header");
|
||||
return;
|
||||
}
|
||||
|
||||
peek.Clear();
|
||||
var readable = Math.Min((int)Math.Max((ushort)8, size), peek.Length);
|
||||
ctx.Memory.TryRead(offset, peek[..readable]);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] ngs2.voiceparam voice=0x{voiceHandle:X16} id=0x{id:X} size={size} next={unchecked((short)next)} bytes={Convert.ToHexString(peek[..readable])}");
|
||||
|
||||
// For the waveform-blocks param, follow the embedded pointers and
|
||||
// dump the pointed-to bytes so we can tell PCM16 from ATRAC9.
|
||||
if (id == 0x10000001 && Interlocked.Increment(ref _waveformDumps) <= 8)
|
||||
{
|
||||
for (int po = 8; po + 8 <= readable; po += 8)
|
||||
{
|
||||
if (ctx.TryReadUInt64(offset + (ulong)po, out var ptr) && ptr > 0x10000 &&
|
||||
ctx.Memory.TryRead(ptr, peek))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] ngs2.waveform @+{po} ptr=0x{ptr:X} head={Convert.ToHexString(peek)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var advance = unchecked((short)next);
|
||||
if (advance <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
offset += (ulong)advance;
|
||||
}
|
||||
}
|
||||
|
||||
private static long _waveformDumps;
|
||||
private static long _renderInfoDumps;
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "AbYvTOZ8Pts",
|
||||
ExportName = "sceNgs2VoiceRunCommands",
|
||||
@@ -511,32 +273,11 @@ public static class Ngs2Exports
|
||||
{
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
// SceNgs2RenderBufferInfo: {ptr@0, size@8, waveformType@16,
|
||||
// channelsCount@20}. Mix the armed voices into the leading grain
|
||||
// as interleaved float32 — this is what the game copies to
|
||||
// sceAudioOutOutput, so it is where NGS2 audio must appear.
|
||||
var channels = 2;
|
||||
if (ctx.TryReadUInt32(entryAddress + 20, out var declaredChannels) &&
|
||||
declaredChannels is > 0 and <= 8)
|
||||
{
|
||||
channels = (int)declaredChannels;
|
||||
}
|
||||
|
||||
MixVoicesIntoGrain(ctx, systemHandle, bufferAddress, bufferSize, channels);
|
||||
|
||||
if (ShouldTrace() && Interlocked.Increment(ref _renderInfoDumps) <= 4)
|
||||
{
|
||||
Span<byte> rbi = stackalloc byte[RenderBufferInfoSize];
|
||||
ctx.Memory.TryRead(entryAddress, rbi);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] ngs2.renderbufinfo addr=0x{bufferAddress:X} size={bufferSize} ch={channels} raw={Convert.ToHexString(rbi)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var count = Interlocked.Increment(ref _renderCount);
|
||||
if (ShouldTrace() && (count <= 4 || count % 200 == 0))
|
||||
if (ShouldTrace() && (count <= 4 || count % 10_000 == 0))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] ngs2.render#{count} system=0x{systemHandle:X16} buffers={bufferInfoCount}");
|
||||
@@ -545,135 +286,6 @@ public static class Ngs2Exports
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
// Sum every armed voice belonging to this system into the leading grain of
|
||||
// the render buffer as interleaved float32. The buffer was just zeroed, so
|
||||
// this is a plain additive mix; silence stays silence when nothing plays.
|
||||
private static void MixVoicesIntoGrain(
|
||||
CpuContext ctx, ulong systemHandle, ulong bufferAddress, ulong bufferSize, int channels)
|
||||
{
|
||||
int grain;
|
||||
lock (StateGate)
|
||||
{
|
||||
if (!Systems.TryGetValue(systemHandle, out var system))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
grain = system.GrainSamples;
|
||||
}
|
||||
|
||||
var capacityFrames = (int)Math.Min((ulong)grain, bufferSize / (ulong)(channels * sizeof(float)));
|
||||
if (capacityFrames <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var floatCount = capacityFrames * channels;
|
||||
var accum = ArrayPool<float>.Shared.Rent(floatCount);
|
||||
var mixedAnything = false;
|
||||
try
|
||||
{
|
||||
Array.Clear(accum, 0, floatCount);
|
||||
lock (StateGate)
|
||||
{
|
||||
foreach (var pair in Voices)
|
||||
{
|
||||
var voice = pair.Value;
|
||||
if (!voice.Playing || voice.Pcm is null || voice.Pcm.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Racks.TryGetValue(voice.RackHandle, out var rack) ||
|
||||
rack.SystemHandle != systemHandle)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
MixOneVoice(accum, capacityFrames, channels, voice);
|
||||
mixedAnything = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (mixedAnything)
|
||||
{
|
||||
WriteGrain(ctx, bufferAddress, accum, floatCount);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<float>.Shared.Return(accum);
|
||||
}
|
||||
}
|
||||
|
||||
// Resample one voice from its source rate to 48 kHz (nearest-sample) and add
|
||||
// it to the front stereo pair. Advances the voice cursor and handles loop /
|
||||
// one-shot end. Must be called under StateGate.
|
||||
private static void MixOneVoice(float[] accum, int frames, int channels, VoiceState voice)
|
||||
{
|
||||
var pcm = voice.Pcm!;
|
||||
var loopEnd = voice.LoopEnd > 0 && voice.LoopEnd <= pcm.Length ? voice.LoopEnd : pcm.Length;
|
||||
var loopStart = voice.LoopStart;
|
||||
var step = voice.SourceRate / OutputSampleRate;
|
||||
var gain = voice.Gain / 32768f;
|
||||
var pos = voice.Position;
|
||||
for (var f = 0; f < frames; f++)
|
||||
{
|
||||
var idx = (int)pos;
|
||||
if (idx >= loopEnd)
|
||||
{
|
||||
if (loopStart >= 0 && loopStart < loopEnd)
|
||||
{
|
||||
pos = loopStart;
|
||||
idx = loopStart;
|
||||
}
|
||||
else
|
||||
{
|
||||
voice.Playing = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (idx < 0 || idx >= pcm.Length)
|
||||
{
|
||||
voice.Playing = false;
|
||||
break;
|
||||
}
|
||||
|
||||
var sample = pcm[idx] * gain;
|
||||
var baseIndex = f * channels;
|
||||
accum[baseIndex] += sample;
|
||||
if (channels > 1)
|
||||
{
|
||||
accum[baseIndex + 1] += sample;
|
||||
}
|
||||
|
||||
pos += step;
|
||||
}
|
||||
|
||||
voice.Position = pos;
|
||||
}
|
||||
|
||||
private static void WriteGrain(CpuContext ctx, ulong address, float[] accum, int count)
|
||||
{
|
||||
var bytes = ArrayPool<byte>.Shared.Rent(count * sizeof(float));
|
||||
try
|
||||
{
|
||||
var span = bytes.AsSpan(0, count * sizeof(float));
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var value = Math.Clamp(accum[i], -1f, 1f);
|
||||
BinaryPrimitives.WriteSingleLittleEndian(span.Slice(i * sizeof(float), sizeof(float)), value);
|
||||
}
|
||||
|
||||
ctx.Memory.TryWrite(address, span);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "pgFAiLR5qT4",
|
||||
ExportName = "sceNgs2SystemQueryBufferSize",
|
||||
@@ -711,25 +323,7 @@ public static class Ngs2Exports
|
||||
ExportName = "sceNgs2SystemSetGrainSamples",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNgs2")]
|
||||
public static int Ngs2SystemSetGrainSamples(CpuContext ctx)
|
||||
{
|
||||
var systemHandle = ctx[CpuRegister.Rdi];
|
||||
var grain = unchecked((int)ctx[CpuRegister.Rsi]);
|
||||
lock (StateGate)
|
||||
{
|
||||
if (!Systems.TryGetValue(systemHandle, out var system))
|
||||
{
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidSystemHandle);
|
||||
}
|
||||
|
||||
if (grain > 0 && grain <= 8192)
|
||||
{
|
||||
system.GrainSamples = grain;
|
||||
}
|
||||
}
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
public static int Ngs2SystemSetGrainSamples(CpuContext ctx) => ValidateSystem(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "-tbc2SxQD60",
|
||||
@@ -818,6 +412,67 @@ public static class Ngs2Exports
|
||||
}
|
||||
}
|
||||
|
||||
private static int CreateSystem(CpuContext ctx, ulong outHandleAddress, ulong handle)
|
||||
{
|
||||
if (outHandleAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
|
||||
}
|
||||
|
||||
if (handle == 0 || !ctx.TryWriteUInt64(outHandleAddress, handle))
|
||||
{
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
lock (StateGate)
|
||||
{
|
||||
Systems[handle] = new SystemState(unchecked((uint)Interlocked.Increment(ref _nextUid)));
|
||||
}
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
private static int CreateRack(
|
||||
CpuContext ctx,
|
||||
ulong systemHandle,
|
||||
uint rackId,
|
||||
ulong outHandleAddress,
|
||||
ulong handle)
|
||||
{
|
||||
lock (StateGate)
|
||||
{
|
||||
if (!Systems.ContainsKey(systemHandle))
|
||||
{
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidSystemHandle);
|
||||
}
|
||||
}
|
||||
|
||||
if (outHandleAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
|
||||
}
|
||||
|
||||
if (handle == 0 || !ctx.TryWriteUInt64(outHandleAddress, handle))
|
||||
{
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
lock (StateGate)
|
||||
{
|
||||
Racks[handle] = new RackState(systemHandle, rackId);
|
||||
}
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
private static bool TryReadContextBuffer(CpuContext ctx, ulong address, out ulong hostBuffer)
|
||||
{
|
||||
hostBuffer = 0;
|
||||
return address != 0 &&
|
||||
ctx.TryReadUInt64(address, out hostBuffer) &&
|
||||
hostBuffer != 0;
|
||||
}
|
||||
|
||||
private static bool TryCreateHandle(CpuContext ctx, uint type, ulong ownerHandle, out ulong handle)
|
||||
{
|
||||
handle = 0;
|
||||
|
||||