Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db4339f698 | |||
| 0535783f46 | |||
| 99004a3ccd | |||
| e1a3b92567 | |||
| 8f9456229a | |||
| 5b602c0232 | |||
| 26c502914c | |||
| a158960c20 | |||
| 5228335f15 | |||
| 21f964a0dc | |||
| 6133313a83 | |||
| 6db095ec82 | |||
| 5a08a9bb43 | |||
| f9d92135a0 | |||
| 8779c96c3a | |||
| 4c6cff1116 | |||
| 82ab181861 | |||
| 007bf6fa73 | |||
| 8e1e89c024 | |||
| 2764aaab3f | |||
| 7b950166d7 | |||
| eb1195e59a | |||
| e13cb28267 | |||
| 956da769a3 | |||
| d7bd814fb9 | |||
| 96fde5764f | |||
| 7a108c6f87 | |||
| eb252af7b3 | |||
| 8e5a0bfb19 | |||
| d991e32b15 | |||
| 93829e3242 | |||
| 4191a9e12b | |||
| 559b7f0a84 | |||
| 2272b9b576 | |||
| 8dd3172c0f | |||
| e7ea186ea8 | |||
| 74a519875b | |||
| 4682e64e81 | |||
| 912883de05 | |||
| f704586a8d | |||
| d3600c9255 | |||
| 5f97031df5 | |||
| 2a4da8c0a9 | |||
| 4c37e64c66 | |||
| fc9e3ff393 | |||
| eb47d753f6 | |||
| 6aa78bb55b | |||
| 9be6f85ef0 | |||
| 4c8c67a3dd | |||
| ada67a1924 | |||
| 2379e8988c | |||
| 105c58b380 | |||
| da35f0db47 | |||
| 1f3963c543 | |||
| 4bb1af93d7 | |||
| 0ae785c617 | |||
| e01092aa38 | |||
| 224a36eba7 | |||
| ac883e44fa | |||
| 25d741b35b | |||
| dce7c87c4d | |||
| 6ee445f0c2 | |||
| 9d187dec55 | |||
| 3574a3b145 | |||
| a1cbff8a9c | |||
| db9b20481c | |||
| 8cd46243ab | |||
| 3334707f7c | |||
| 20eda4443c | |||
| bb3318a503 | |||
| d151e151c2 | |||
| 472fc96a37 | |||
| 33be88bdf9 | |||
| 184e24fbb6 | |||
| 327018e80a | |||
| 04557fd250 | |||
| 90c72ebecf | |||
| 8ef5a54ee4 | |||
| 0c467e8c57 | |||
| 9ff60abb9b | |||
| 3ebfc56d4c | |||
| 73e8821d5b | |||
| bc51cc2c4d | |||
| d7f6e3f578 | |||
| e56e74f960 | |||
| 0f224ec036 | |||
| 85dc98dedc | |||
| 5d7d8e0edd | |||
| a60bfc9c83 | |||
| 0b83b34cda | |||
| 09812600a0 | |||
| 3005babab8 | |||
| 09bd4f028b | |||
| 2bda253927 | |||
| 71e5912c75 | |||
| a030cb5a5d | |||
| 336286e588 | |||
| cab001f265 | |||
| bab965e394 |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 104 KiB |
@@ -89,7 +89,6 @@ jobs:
|
||||
DOTNET_NOLOGO: true
|
||||
NUGET_PACKAGES: ${{ github.workspace }}\.nuget\packages
|
||||
PUBLISH_DIR: ${{ github.workspace }}\artifacts\publish\win-x64
|
||||
RELEASE_DIR: ${{ github.workspace }}\artifacts\release
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -121,24 +120,13 @@ jobs:
|
||||
- name: Publish win-x64 CLI
|
||||
run: dotnet publish src/SharpEmu.CLI/SharpEmu.CLI.csproj -c Release -r win-x64 --self-contained true --no-restore -p:PublishDir="${env:PUBLISH_DIR}"
|
||||
|
||||
- name: Create release archive
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path $env:RELEASE_DIR -Force | Out-Null
|
||||
|
||||
$archiveName = "sharpemu-${{ needs.init.outputs.version }}-win-x64.zip"
|
||||
$archivePath = Join-Path $env:RELEASE_DIR $archiveName
|
||||
if (Test-Path $archivePath) {
|
||||
Remove-Item $archivePath -Force
|
||||
}
|
||||
|
||||
Compress-Archive -Path (Join-Path $env:PUBLISH_DIR '*') -DestinationPath $archivePath -CompressionLevel Optimal
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: sharpemu-win-x64-${{ needs.init.outputs.short-sha }}
|
||||
path: ${{ env.RELEASE_DIR }}\sharpemu-${{ needs.init.outputs.version }}-win-x64.zip
|
||||
path: ${{ env.PUBLISH_DIR }}
|
||||
if-no-files-found: error
|
||||
include-hidden-files: true
|
||||
|
||||
build-posix:
|
||||
name: Build ${{ matrix.rid }}
|
||||
@@ -158,7 +146,6 @@ jobs:
|
||||
DOTNET_NOLOGO: true
|
||||
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
|
||||
PUBLISH_DIR: ${{ github.workspace }}/artifacts/publish/${{ matrix.rid }}
|
||||
RELEASE_DIR: ${{ github.workspace }}/artifacts/release
|
||||
SPIRV_HEADERS_COMMIT: ad9184e76a66b1001c29db9b0a3e87f646c64de0
|
||||
# SpirvModuleBuilder emits SPIR-V 1.5 and VulkanVideoPresenter requests Vulkan 1.2.
|
||||
SPIRV_TARGET_ENV: vulkan1.2
|
||||
@@ -223,19 +210,13 @@ jobs:
|
||||
if: matrix.rid == 'osx-x64'
|
||||
run: scripts/fetch-macos-moltenvk.sh "$PUBLISH_DIR"
|
||||
|
||||
- name: Create release archive
|
||||
run: |
|
||||
mkdir -p "$RELEASE_DIR"
|
||||
# tar keeps the executable bit, which zip would drop.
|
||||
tar -czf "$RELEASE_DIR/sharpemu-${{ needs.init.outputs.version }}-${{ matrix.rid }}.tar.gz" \
|
||||
-C "$PUBLISH_DIR" .
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: sharpemu-${{ matrix.rid }}-${{ needs.init.outputs.short-sha }}
|
||||
path: ${{ env.RELEASE_DIR }}/sharpemu-${{ needs.init.outputs.version }}-${{ matrix.rid }}.tar.gz
|
||||
path: ${{ env.PUBLISH_DIR }}
|
||||
if-no-files-found: error
|
||||
include-hidden-files: true
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
@@ -255,6 +236,28 @@ jobs:
|
||||
with:
|
||||
path: release
|
||||
|
||||
- name: Package release assets
|
||||
shell: bash
|
||||
env:
|
||||
SHORT_SHA: ${{ needs.init.outputs.short-sha }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
win_dir="release/sharpemu-win-x64-${SHORT_SHA}"
|
||||
linux_dir="release/sharpemu-linux-x64-${SHORT_SHA}"
|
||||
macos_dir="release/sharpemu-osx-x64-${SHORT_SHA}"
|
||||
for package_dir in "${win_dir}" "${linux_dir}" "${macos_dir}"; do
|
||||
test -d "${package_dir}"
|
||||
done
|
||||
|
||||
mkdir -p release-assets
|
||||
(cd "${win_dir}" && zip -q -r "../../release-assets/sharpemu-${VERSION}-win-x64.zip" .)
|
||||
|
||||
chmod +x "${linux_dir}/SharpEmu" "${macos_dir}/SharpEmu"
|
||||
tar -czf "release-assets/sharpemu-${VERSION}-linux-x64.tar.gz" -C "${linux_dir}" .
|
||||
tar -czf "release-assets/sharpemu-${VERSION}-osx-x64.tar.gz" -C "${macos_dir}" .
|
||||
|
||||
- name: Create release
|
||||
shell: bash
|
||||
env:
|
||||
@@ -264,9 +267,9 @@ jobs:
|
||||
RELEASE_TAG: ${{ needs.init.outputs.release-tag }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
mapfile -t assets < <(find release -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release assets found." >&2
|
||||
mapfile -t assets < <(find release-assets -maxdepth 1 -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
|
||||
if [ "${#assets[@]}" -ne 3 ]; then
|
||||
echo "Expected 3 release assets, found ${#assets[@]}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -42,3 +42,4 @@ ehthumbs.db
|
||||
|
||||
.vs/
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -26,6 +26,25 @@ 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,11 +9,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<SharpEmuVersion>0.0.2-beta.3</SharpEmuVersion>
|
||||
<SharpEmuVersion>0.0.2-beta.5</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,11 +11,13 @@ 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" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="NLayer" Version="1.14.0" />
|
||||
<PackageVersion Include="Silk.NET.Input" Version="2.23.0" />
|
||||
<PackageVersion Include="Silk.NET.Vulkan" Version="2.23.0" />
|
||||
<PackageVersion Include="Silk.NET.Vulkan.Extensions.EXT" Version="2.23.0" />
|
||||
|
||||
@@ -13,14 +13,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
An experimental PlayStation 5 emulator for Windows, Linux and macOS.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/6GejPEDqpc">
|
||||
<img src="https://img.shields.io/badge/Discord-Join%20our%20Community-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Join our Discord">
|
||||
</a>
|
||||
</p>
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<strong>Join our Discord for development updates, compatibility discussions, support, and community chat.</strong>
|
||||
<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>
|
||||
|
||||
---
|
||||
@@ -136,6 +134,18 @@ 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:
|
||||
|
||||
@@ -21,6 +21,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj" />
|
||||
<Project Path="tests/SharpEmu.ShaderCompiler.Metal.Tests/SharpEmu.ShaderCompiler.Metal.Tests.csproj" />
|
||||
<Project Path="tests/SharpEmu.ShaderCompiler.Tests/SharpEmu.ShaderCompiler.Tests.csproj" />
|
||||
<Project Path="tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
# Aerolib Catalog
|
||||
|
||||
```bash
|
||||
# NID to export name
|
||||
python scripts/aerolib_catalog.py lookup Zxa0VhQVTsk
|
||||
|
||||
# Export name to NID
|
||||
python scripts/aerolib_catalog.py lookup sceKernelWaitSema
|
||||
|
||||
# Search export names
|
||||
python scripts/aerolib_catalog.py search VideoOut --limit 20
|
||||
|
||||
# Export all NID/name pairs to artifacts/aerolib.txt
|
||||
python scripts/aerolib_catalog.py export
|
||||
```
|
||||
@@ -9,38 +9,67 @@ Demon's Souls plays Bink 2 (.bk2) files through a Bink implementation linked
|
||||
directly into eboot.bin. It does not use libSceVideodec, therefore an HLE video
|
||||
decoder cannot observe or replace those frames.
|
||||
|
||||
SharpEmu observes successful guest .bk2 opens and, when a Bink bridge is
|
||||
SharpEmu observes successful guest .bk2 opens and, when a Bink decoder is
|
||||
available, presents its decoded BGRA frames at the normal guest-flip boundary.
|
||||
This preserves the game's own timing and lets the host Vulkan presenter display
|
||||
the movie without trying to execute the PS5-specific Bink GPU decode path.
|
||||
|
||||
Without an adapter, Bink movies are skipped by default: their open call returns
|
||||
not-found so games that mark cinematics as optional progress to their next
|
||||
state instead of waiting on an empty Bink GPU texture.
|
||||
The default path decodes by calling FFmpeg's own C API directly from managed
|
||||
code (`src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs`, via the
|
||||
[FFmpeg.AutoGen](https://github.com/Ruslan-B/FFmpeg.AutoGen) P/Invoke
|
||||
bindings) against a custom FFmpeg build
|
||||
(`github.com/sharpemu/ffmpeg-core`, LGPL-2.1) that adds a Bink 2 decoder to
|
||||
FFmpeg 7.1.2; see "Supplying the FFmpeg libraries" below for where those
|
||||
libraries come from. No proprietary RAD SDK is needed to build or run
|
||||
SharpEmu, and there is no C/C++ code of SharpEmu's own involved in decoding
|
||||
-- SharpEmu.CLI.csproj only downloads a prebuilt release archive.
|
||||
|
||||
Set `SHARPEMU_BINK_MODE=guest` to leave decoding to the Bink implementation
|
||||
statically linked into the game instead. Set `skip` only when explicitly
|
||||
testing a title whose cinematics are optional.
|
||||
|
||||
Set SHARPEMU_BINK_MODE=dummy to retain the open and show a built-in,
|
||||
non-decoded placeholder frame. This requires no SDK, but is a visual diagnostic
|
||||
only; it does not decode the movie or alter its game logic. Set
|
||||
SHARPEMU_BINK_MODE=native to force native bridge mode.
|
||||
only; it does not decode the movie or alter its game logic.
|
||||
SHARPEMU_BINK_MODE=native is equivalent to the default and mainly useful for
|
||||
being explicit about it.
|
||||
|
||||
## Supplying the adapter
|
||||
The experimental `SHARPEMU_BINK_MODE=ffmpeg` override is unrelated to the
|
||||
default path above: instead of calling into FFmpeg in-process, it spawns a
|
||||
standalone `ffmpeg` executable and reads raw frames from its stdout
|
||||
(`src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs`). SharpEmu searches
|
||||
`SHARPEMU_FFMPEG_PATH`, the executable directory, its `ffmpeg` subdirectory,
|
||||
and then `PATH` (plus a couple of common Homebrew paths on macOS). That
|
||||
`ffmpeg` build must contain a Bink 2 decoder itself; a stock FFmpeg build that
|
||||
only recognizes the Bink container is not sufficient. Most users want the
|
||||
default `native` mode instead, which always has Bink 2 support since it's
|
||||
built against `ffmpeg-core` specifically.
|
||||
|
||||
Bink 2 is proprietary. Obtain a compatible Mac Bink 2 SDK from RAD Game Tools,
|
||||
then compile sharpemu_bink2_bridge.c against the SDK's bink.h and Mac library.
|
||||
The adapter deliberately contains only a three-function C ABI so the managed
|
||||
emulator never depends on RAD's private binary ABI.
|
||||
## Supplying the FFmpeg libraries
|
||||
|
||||
Place the resulting libsharpemu_bink2_bridge.dylib next to the SharpEmu
|
||||
executable, or point to it explicitly:
|
||||
`dotnet publish` fetches a prebuilt release of `github.com/sharpemu/ffmpeg-core`
|
||||
(the tag is pinned in `SharpEmu.CLI.csproj`'s `FfmpegRuntimeTag`, matched to
|
||||
the `FFmpeg.AutoGen` package version in `Directory.Packages.props` -- both
|
||||
need to agree on the same FFmpeg ABI) and copies its dynamically linked
|
||||
libraries into a `plugins` folder next to the published executable. No C
|
||||
toolchain is required to build SharpEmu; publishing just downloads a zip.
|
||||
`plugins` is a loose, unpacked folder rather than something embedded in the
|
||||
single-file bundle, so the OS loader can resolve the libraries' own
|
||||
inter-dependencies (`avcodec` depends on `avutil`, etc.) itself.
|
||||
|
||||
SHARPEMU_BINK2_BRIDGE=/absolute/path/libsharpemu_bink2_bridge.dylib \
|
||||
./SharpEmu /path/to/eboot.bin
|
||||
A plain `dotnet publish` with no `-r` still works: it defaults to the host
|
||||
machine's own RID (see `Directory.Build.props`), so it fetches the matching
|
||||
`ffmpeg-core` archive and populates `plugins` without any extra flags.
|
||||
Passing an explicit `-r <rid>` (e.g. to cross-publish `linux-x64` from
|
||||
Windows) still overrides that default normally.
|
||||
|
||||
The expected exports are sharpemu_bink2_open_utf8,
|
||||
sharpemu_bink2_decode_next_bgra, and sharpemu_bink2_close. The supplied
|
||||
adapter opens one movie, exposes BGRA pixels, and advances after each decoded
|
||||
frame. The managed side validates dimensions and retains ownership of the
|
||||
destination buffer.
|
||||
To use a different set of FFmpeg libraries, drop them into the published
|
||||
`plugins` folder yourself (matching FFmpeg's own file-naming and versioning
|
||||
conventions, e.g. `avformat-61.dll` / `libavformat.so.61` / matching
|
||||
`.dylib`) -- `FfmpegNativeBinkFrameSource` points `ffmpeg.RootPath` at that
|
||||
folder and does not otherwise care where the files came from.
|
||||
|
||||
If the bridge is absent in native mode, SharpEmu logs one informational line
|
||||
and retains the existing guest rendering path.
|
||||
If the libraries are absent or fail to load, `FfmpegNativeBinkFrameSource.TryOpen`
|
||||
degrades gracefully: SharpEmu logs one informational line ("Bink2 bridge
|
||||
could not open movie ...") and leaves the guest's own rendering path
|
||||
untouched, rather than crashing.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
# Guest write watch
|
||||
|
||||
`GuestWriteWatch` is an optional diagnostic tool. It helps you find managed
|
||||
code and HLE code that damage guest memory. The tool starts only if you set one
|
||||
or more `SHARPEMU_WATCH_*` environment variables.
|
||||
|
||||
The tool monitors writes through the SharpEmu managed virtual-memory APIs. It
|
||||
does not monitor stores that native guest code makes directly. Use a platform
|
||||
debugger or a hardware watchpoint to monitor these stores.
|
||||
|
||||
## Watch modes
|
||||
|
||||
- `SHARPEMU_WATCH_WRITE=0x<address>` logs a write that overlaps the eight-byte
|
||||
block at the specified guest address.
|
||||
- `SHARPEMU_WATCH_POOL_HEADER=1` monitors the pointer at offset `0x40`. It
|
||||
monitors the first 64 direct mappings that have a size of 64 KiB and
|
||||
protection value `0xF2`.
|
||||
- `SHARPEMU_WATCH_VALUE_PATTERN=1` logs an eight-byte write if its lower 32 bits
|
||||
are `1`. The upper 32 bits must look like a small guest-pointer prefix.
|
||||
- `SHARPEMU_WATCH_VALUE1=1` logs short writes of value `1` in the high guest
|
||||
memory range. The tool logs a maximum of 128 entries for each process.
|
||||
- `SHARPEMU_WATCH_BULK_TORN=1` scans aligned 64-bit words in bulk writes. It
|
||||
finds damaged pointer patterns and byte-shifted pointer patterns. The tool
|
||||
logs a maximum of 64 entries for each process.
|
||||
- `SHARPEMU_WATCH_BULK_DEST_HI=0x<high-dword>` scans only writes that have the
|
||||
specified upper 32 bits in the destination address.
|
||||
|
||||
For each match, the tool logs the destination address, the data pattern, and the
|
||||
managed call stack. The log uses the `watch_write` or `watch_bulk_torn` warning
|
||||
tag.
|
||||
|
||||
Use these variables together to scan bulk writes in the
|
||||
`0x00000080xxxxxxxx` region.
|
||||
|
||||
macOS and Linux:
|
||||
|
||||
```sh
|
||||
SHARPEMU_WATCH_BULK_TORN=1 \
|
||||
SHARPEMU_WATCH_BULK_DEST_HI=0x80 \
|
||||
SharpEmu /path/to/eboot.bin
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:SHARPEMU_WATCH_BULK_TORN = "1"
|
||||
$env:SHARPEMU_WATCH_BULK_DEST_HI = "0x80"
|
||||
& .\SharpEmu.exe C:\path\to\game\eboot.bin
|
||||
```
|
||||
|
||||
To reduce unnecessary log entries, use an exact `SHARPEMU_WATCH_WRITE`
|
||||
address from a crash dump.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.103",
|
||||
"rollForward": "disable"
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SharpEmu Emulator Project
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*
|
||||
* Build this small adapter with a licensed RAD Bink 2 SDK. The SDK and its
|
||||
* headers are not distributed by SharpEmu. See docs/bink2-bridge.md.
|
||||
*/
|
||||
#include <stdint.h>
|
||||
#include "bink.h"
|
||||
|
||||
typedef struct sharpemu_bink2_info {
|
||||
uint32_t width;
|
||||
uint32_t height;
|
||||
uint32_t frames_per_second_numerator;
|
||||
uint32_t frames_per_second_denominator;
|
||||
} sharpemu_bink2_info;
|
||||
|
||||
int sharpemu_bink2_open_utf8(const char *path, HBINK *movie, sharpemu_bink2_info *info) {
|
||||
HBINK bink;
|
||||
if (!path || !movie || !info) return 0;
|
||||
|
||||
*movie = NULL;
|
||||
|
||||
bink = BinkOpen(path, 0);
|
||||
if (!bink) return 0;
|
||||
|
||||
if (bink->Width == 0 || bink->Height == 0) {
|
||||
BinkClose(bink);
|
||||
return 0;
|
||||
}
|
||||
|
||||
*movie = bink;
|
||||
info->width = bink->Width;
|
||||
info->height = bink->Height;
|
||||
info->frames_per_second_numerator = bink->FrameRate;
|
||||
info->frames_per_second_denominator = bink->FrameRateDiv;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int sharpemu_bink2_decode_next_bgra(HBINK movie, uint8_t *destination,
|
||||
uint32_t stride, uint32_t destination_bytes) {
|
||||
uint64_t needed;
|
||||
uint64_t min_stride;
|
||||
|
||||
if (!movie || !destination) return 0;
|
||||
|
||||
min_stride = (uint64_t)movie->Width * 4;
|
||||
if ((uint64_t)stride < min_stride) return 0;
|
||||
|
||||
needed = (uint64_t)stride * movie->Height;
|
||||
if (needed > destination_bytes) return 0;
|
||||
|
||||
/* Async Bink I/O has not filled the next frame yet; retry on the next host present. */
|
||||
if (BinkWait(movie)) return 0;
|
||||
|
||||
if (!BinkDoFrame(movie)) return 0;
|
||||
|
||||
if (!BinkCopyToBuffer(movie, destination, stride, movie->Height, 0, 0, BINKSURFACE32RA)) return 0;
|
||||
|
||||
BinkNextFrame(movie);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void sharpemu_bink2_close(HBINK movie) {
|
||||
if (movie) BinkClose(movie);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (C) 2026 SharpEmu Emulator Project
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
NID_SUFFIX = bytes.fromhex("518d64a635ded8c1e6b039b1c3e55230")
|
||||
NID_PATTERN = re.compile(r"^[A-Za-z0-9+-]{11}$")
|
||||
DEFAULT_NAMES_FILE = Path(__file__).resolve().with_name("ps5_names.txt")
|
||||
DEFAULT_EXPORT_FILE = Path(__file__).resolve().parents[1] / "artifacts" / "aerolib.txt"
|
||||
|
||||
|
||||
def compute_nid(export_name: str) -> str:
|
||||
digest = hashlib.sha1(export_name.encode("utf-8") + NID_SUFFIX).digest()
|
||||
encoded = base64.b64encode(digest[:8][::-1]).decode("ascii")
|
||||
return encoded.rstrip("=").replace("/", "-")
|
||||
|
||||
|
||||
def read_names(path: Path) -> list[str]:
|
||||
try:
|
||||
return [
|
||||
line.strip()
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
except OSError as error:
|
||||
raise SystemExit(f"Unable to read catalog '{path}': {error}") from error
|
||||
|
||||
|
||||
def write_pair(nid: str, export_name: str) -> None:
|
||||
print(f"{nid}\t{export_name}")
|
||||
|
||||
|
||||
def lookup(args: argparse.Namespace) -> int:
|
||||
value = args.value.strip()
|
||||
if NID_PATTERN.fullmatch(value):
|
||||
for export_name in read_names(args.names):
|
||||
if compute_nid(export_name) == value:
|
||||
write_pair(value, export_name)
|
||||
return 0
|
||||
|
||||
print(f"NID not found in catalog: {value}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
names = set(read_names(args.names))
|
||||
write_pair(compute_nid(value), value)
|
||||
if value not in names:
|
||||
print("Warning: export name is not present in the catalog.", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
def search(args: argparse.Namespace) -> int:
|
||||
names = read_names(args.names)
|
||||
if args.regex:
|
||||
try:
|
||||
pattern = re.compile(args.query, 0 if args.case_sensitive else re.IGNORECASE)
|
||||
except re.error as error:
|
||||
print(f"Invalid regular expression: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
matches = (name for name in names if pattern.search(name))
|
||||
elif args.case_sensitive:
|
||||
matches = (name for name in names if args.query in name)
|
||||
else:
|
||||
query = args.query.casefold()
|
||||
matches = (name for name in names if query in name.casefold())
|
||||
|
||||
count = 0
|
||||
for export_name in matches:
|
||||
write_pair(compute_nid(export_name), export_name)
|
||||
count += 1
|
||||
if args.limit and count >= args.limit:
|
||||
break
|
||||
|
||||
if count == 0:
|
||||
print(f"No catalog names matched: {args.query}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def export_catalog(args: argparse.Namespace) -> int:
|
||||
pairs = [(compute_nid(name), name) for name in read_names(args.names)]
|
||||
if args.sort == "nid":
|
||||
pairs.sort(key=lambda pair: (pair[0], pair[1]))
|
||||
elif args.sort == "name":
|
||||
pairs.sort(key=lambda pair: pair[1])
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with args.output.open("w", encoding="utf-8", newline="\n") as output:
|
||||
output.write("# NID\tExportName\n")
|
||||
for nid, export_name in pairs:
|
||||
output.write(f"{nid}\t{export_name}\n")
|
||||
except OSError as error:
|
||||
print(f"Unable to write catalog '{args.output}': {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Wrote {len(pairs)} entries to {args.output}")
|
||||
return 0
|
||||
|
||||
|
||||
def create_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Inspect the SharpEmu PS5 export-name/NID catalog.",
|
||||
epilog=(
|
||||
"Examples:\n"
|
||||
" python scripts/aerolib_catalog.py lookup Zxa0VhQVTsk\n"
|
||||
" python scripts/aerolib_catalog.py lookup sceKernelWaitSema\n"
|
||||
" python scripts/aerolib_catalog.py search VideoOut --limit 20\n"
|
||||
" python scripts/aerolib_catalog.py export"
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--names",
|
||||
type=Path,
|
||||
default=DEFAULT_NAMES_FILE,
|
||||
help=f"source name list (default: {DEFAULT_NAMES_FILE})",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
lookup_parser = subparsers.add_parser(
|
||||
"lookup", help="resolve a NID or calculate the NID for an export name"
|
||||
)
|
||||
lookup_parser.add_argument("value", help="11-character NID or exact export name")
|
||||
lookup_parser.set_defaults(handler=lookup)
|
||||
|
||||
search_parser = subparsers.add_parser(
|
||||
"search", help="find export names and print matching NID/name pairs"
|
||||
)
|
||||
search_parser.add_argument("query", help="name substring or regular expression")
|
||||
search_parser.add_argument(
|
||||
"--limit", type=int, default=50, help="maximum matches; 0 means unlimited"
|
||||
)
|
||||
search_parser.add_argument(
|
||||
"--case-sensitive", action="store_true", help="match case exactly"
|
||||
)
|
||||
search_parser.add_argument(
|
||||
"--regex", action="store_true", help="treat the query as a regular expression"
|
||||
)
|
||||
search_parser.set_defaults(handler=search)
|
||||
|
||||
export_parser = subparsers.add_parser(
|
||||
"export", help="write every NID/name pair to a tab-separated text file"
|
||||
)
|
||||
export_parser.add_argument(
|
||||
"output",
|
||||
type=Path,
|
||||
nargs="?",
|
||||
default=DEFAULT_EXPORT_FILE,
|
||||
help=f"output file (default: {DEFAULT_EXPORT_FILE})",
|
||||
)
|
||||
export_parser.add_argument(
|
||||
"--sort",
|
||||
choices=("source", "nid", "name"),
|
||||
default="nid",
|
||||
help="output ordering (default: nid)",
|
||||
)
|
||||
export_parser.set_defaults(handler=export_catalog)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = create_parser()
|
||||
args = parser.parse_args()
|
||||
return args.handler(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -153133,6 +153133,7 @@ scePsmlMfsrGetContextBufferRequirement800M3_2
|
||||
scePsmlMfsrGetDispatchMfsrPacket1000
|
||||
scePsmlMfsrGetDispatchMfsrPacket1100
|
||||
scePsmlMfsrGetDispatchMfsrPacketSizeInDwords
|
||||
scePsmlMfsrGetDispatchMfsrPacket900
|
||||
scePsmlMfsrGetMipmapBias
|
||||
scePsmlMfsrGetSharedResourcesInitRequirement
|
||||
scePsmlMfsrInit
|
||||
|
||||
@@ -45,11 +45,6 @@ internal static partial class Program
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
// Avoid blocking full collections while guest and render threads are
|
||||
// running, and establish the GC mode before the runtime reserves the
|
||||
// fixed guest address-space window.
|
||||
System.Runtime.GCSettings.LatencyMode = System.Runtime.GCLatencyMode.SustainedLowLatency;
|
||||
|
||||
try
|
||||
{
|
||||
return Run(args);
|
||||
@@ -69,6 +64,7 @@ internal static partial class Program
|
||||
}
|
||||
|
||||
args = NormalizeInternalArguments(args, out var isMitigatedChild);
|
||||
PreloadGlfw();
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
@@ -218,6 +214,27 @@ internal static partial class Program
|
||||
"as libvulkan.1.dylib.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SharpEmu.CLI.csproj publishes glfw into a "plugins" subfolder rather
|
||||
/// than flat next to the executable, which falls outside the default OS
|
||||
/// DLL/dlopen search path. Preloading it here by full path first means
|
||||
/// any later bare-name lookup (however Silk.NET/GLFW itself resolves the
|
||||
/// library) finds it already loaded in the process and reuses it -- the
|
||||
/// same technique <see cref="PreloadMacVulkanLoader"/> already relies on
|
||||
/// for the Vulkan loader.
|
||||
/// </summary>
|
||||
private static void PreloadGlfw()
|
||||
{
|
||||
var fileName = OperatingSystem.IsWindows() ? "glfw3.dll"
|
||||
: OperatingSystem.IsMacOS() ? "libglfw.3.dylib"
|
||||
: "libglfw.so.3";
|
||||
var candidate = Path.Combine(AppContext.BaseDirectory, "plugins", fileName);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
NativeLibrary.TryLoad(candidate, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static int RunEmulator(string[] args, bool isMitigatedChild)
|
||||
{
|
||||
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
|
||||
@@ -556,12 +573,7 @@ internal static partial class Program
|
||||
return false;
|
||||
}
|
||||
|
||||
var childArgs = new string[args.Length + 1];
|
||||
childArgs[0] = MitigatedChildFlag;
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
childArgs[i + 1] = args[i];
|
||||
}
|
||||
string[] childArgs = [MitigatedChildFlag, .. args];
|
||||
|
||||
var commandLine = BuildCommandLine(processPath, childArgs);
|
||||
var startupInfoEx = new STARTUPINFOEX();
|
||||
@@ -612,7 +624,7 @@ internal static partial class Program
|
||||
nint jobHandle = 0;
|
||||
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
|
||||
var created = CreateProcessW(
|
||||
processPath,
|
||||
null,
|
||||
cmdLineBuilder,
|
||||
0,
|
||||
0,
|
||||
@@ -1438,7 +1450,7 @@ internal static partial class Program
|
||||
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CreateProcessW(
|
||||
string applicationName,
|
||||
string? applicationName,
|
||||
StringBuilder commandLine,
|
||||
nint processAttributes,
|
||||
nint threadAttributes,
|
||||
|
||||
@@ -20,6 +20,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<!-- osx-x64 is the macOS target: the CPU backend executes guest x86-64
|
||||
natively, so on Apple Silicon it runs under Rosetta 2. -->
|
||||
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
|
||||
<!-- A plain "dotnet publish" with no -r defaults $(RuntimeIdentifier) to
|
||||
the host's own RID; see Directory.Build.props, which is where that
|
||||
default actually has to live (PublishDir's RID suffix is decided
|
||||
there, evaluated before this file, so a default set only here would
|
||||
be too late for it). -->
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
@@ -49,7 +54,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<DebugType>none</DebugType>
|
||||
<DebugSymbols>false</DebugSymbols>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == ''">
|
||||
@@ -75,17 +79,75 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Keep glfw as a loose file next to the executable; every other native
|
||||
<!-- Native libraries (glfw, FFmpeg) publish into a subfolder next to the
|
||||
executable instead of sitting loose beside it, so the publish
|
||||
directory stays uncluttered as more native deps get added. The folder
|
||||
name is a fixed constant, not derived from the RID/architecture: each
|
||||
publish output only ever holds one architecture's binaries anyway, so
|
||||
varying the name added a class of bugs (RID resolution timing, host-OS
|
||||
vs. target-RID mixups) for no benefit. Runtime code (Program.cs's
|
||||
PreloadGlfw, FfmpegNativeBinkFrameSource's RootPath) uses the same
|
||||
literal "plugins" folder name. -->
|
||||
<PropertyGroup>
|
||||
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Keep glfw as a loose file in the native subfolder; every other native
|
||||
library is embedded into the single-file bundle. -->
|
||||
<Target Name="KeepGlfwOutsideSingleFile" AfterTargets="ComputeResolvedFilesToPublishList">
|
||||
<ItemGroup>
|
||||
<_GlfwPublishFiles Include="@(ResolvedFileToPublish)"
|
||||
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
|
||||
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
|
||||
<ResolvedFileToPublish Remove="@(_GlfwPublishFiles)" />
|
||||
<ResolvedFileToPublish Include="@(_GlfwPublishFiles)">
|
||||
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
||||
<RelativePath>$(NativeLibraryFolderName)/%(Filename)%(Extension)</RelativePath>
|
||||
</ResolvedFileToPublish>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<PropertyGroup>
|
||||
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
|
||||
<FfmpegRuntimeDir>
|
||||
$(BaseIntermediateOutputPath)ffmpeg-runtime/$(FfmpegRuntimeTag)/$(RuntimeIdentifier)</FfmpegRuntimeDir>
|
||||
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'win-x64'">ffmpeg-windows-x64.zip</FfmpegRuntimePackage>
|
||||
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'linux-x64'">ffmpeg-linux-x64.zip</FfmpegRuntimePackage>
|
||||
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'osx-x64'">ffmpeg-macos-x64.zip</FfmpegRuntimePackage>
|
||||
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'osx-arm64'">ffmpeg-macos-arm64.zip</FfmpegRuntimePackage>
|
||||
<FfmpegRuntimeArchive>$(FfmpegRuntimeDir)/$(FfmpegRuntimePackage)</FfmpegRuntimeArchive>
|
||||
<FfmpegRuntimeExtractDir>$(FfmpegRuntimeDir)/extracted</FfmpegRuntimeExtractDir>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="FetchFfmpegRuntime"
|
||||
BeforeTargets="Publish"
|
||||
Condition="'$(RuntimeIdentifier)' != '' And '$(FfmpegRuntimePackage)' != ''">
|
||||
<DownloadFile
|
||||
SourceUrl="https://github.com/sharpemu/ffmpeg-core/releases/download/$(FfmpegRuntimeTag)/$(FfmpegRuntimePackage)"
|
||||
DestinationFolder="$(FfmpegRuntimeDir)"
|
||||
Condition="!Exists('$(FfmpegRuntimeArchive)')" />
|
||||
<Unzip
|
||||
SourceFiles="$(FfmpegRuntimeArchive)"
|
||||
DestinationFolder="$(FfmpegRuntimeExtractDir)"
|
||||
Condition="!Exists('$(FfmpegRuntimeExtractDir)')" />
|
||||
</Target>
|
||||
|
||||
<Target Name="PublishFfmpegRuntime"
|
||||
AfterTargets="Publish"
|
||||
DependsOnTargets="FetchFfmpegRuntime"
|
||||
Condition="'$(RuntimeIdentifier)' != '' And '$(FfmpegRuntimePackage)' != ''">
|
||||
<!-- Keyed off the target $(RuntimeIdentifier), not the host OS: publishing
|
||||
e.g. linux-x64 from a Windows machine is a supported cross-publish,
|
||||
and the extracted archive's own layout (bin/*.dll vs lib/*.so*) only
|
||||
depends on which platform's ffmpeg-core package was fetched. -->
|
||||
<ItemGroup>
|
||||
<_FfmpegRuntimeFiles Condition="$(RuntimeIdentifier.StartsWith('win'))"
|
||||
Include="$(FfmpegRuntimeExtractDir)/bin/*.dll" />
|
||||
<_FfmpegRuntimeFiles Condition="!$(RuntimeIdentifier.StartsWith('win'))"
|
||||
Include="$(FfmpegRuntimeExtractDir)/lib/*.so;$(FfmpegRuntimeExtractDir)/lib/*.so.*;$(FfmpegRuntimeExtractDir)/lib/*.dylib" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(_FfmpegRuntimeFiles)"
|
||||
DestinationFolder="$(PublishDir)$(NativeLibraryFolderName)"
|
||||
SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -13,4 +13,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>
|
||||
|
||||
@@ -1,603 +0,0 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.8, )",
|
||||
"resolved": "10.0.8",
|
||||
"contentHash": "dVbSXGIFNR5nZcv2tOLoWI+a9T4jtFd77IYjuND+QVe360qWgAF7H0WtoopYhRw/+SgpGUTyrkrh+65+ClNnfw=="
|
||||
},
|
||||
"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.Metal": "[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.metal": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )"
|
||||
}
|
||||
},
|
||||
"sharpemu.shadercompiler.vulkan": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )"
|
||||
}
|
||||
},
|
||||
"Avalonia": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[11.3.18, )",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "2C4UxhWUObWGgYKWic1x5BMMWGJP6SElb91WeOxs+X/iR26rtkqpxFFwwo50FXS9AyYnHfk8QKXDEfe7oT/kZA==",
|
||||
"dependencies": {
|
||||
"Avalonia.BuildServices": "11.3.2",
|
||||
"Avalonia.Remote.Protocol": "11.3.18",
|
||||
"MicroCom.Runtime": "0.11.0"
|
||||
}
|
||||
},
|
||||
"Avalonia.Desktop": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[11.3.18, )",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "bilMPa5vYiis6fbNovb6esKytBnOCEGojBa1XFegLCRHCP6g6PvZwS0XF/YOAGkENRlHG8dI7lohOpQ9bIkq1g==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18",
|
||||
"Avalonia.Native": "11.3.18",
|
||||
"Avalonia.Skia": "11.3.18",
|
||||
"Avalonia.Win32": "11.3.18",
|
||||
"Avalonia.X11": "11.3.18"
|
||||
}
|
||||
},
|
||||
"Avalonia.Fonts.Inter": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[11.3.18, )",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "27u6hB3Y2Ue586yjfeVakberY73VNQXtuKwe/P927XG1QPlhsfmOyifLHDDpSHG85Zl1x/Xv9IZ3+tk9FnjcZQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"Avalonia.Themes.Fluent": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[11.3.18, )",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "+Q/TJoynD0zNuu5w2gD+xcTl7GNKJFxlPYAndRLs/mTDrNbbsvv/271WyIysbMPsXSjCyBDp7RCZzQkpD6x5Bg==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"Iced": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.21.0, )",
|
||||
"resolved": "1.21.0",
|
||||
"contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg=="
|
||||
},
|
||||
"Silk.NET.Input": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "Xzl+tVwAp2eEd8blmGQjmJrsZPnp3PWG0KJjiAQHaY2Zr/ELVWeAROKXmZdCAvexzmte2JVGEy/dxnMycbxlpg==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Input.Common": "2.23.0",
|
||||
"Silk.NET.Input.Glfw": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Vulkan": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "3/irtlSWXZ3eTi8N6nelI6L34NTB8ZJHpqVMNzZx2aX7Ek9YEQ34NoQW8/Tljrtmkg8KRhHW8hKTEzZaKV8PgA==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Core": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Vulkan.Extensions.EXT": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "+Oth189ksRiL6HvGCwIdnsYHawqrbO8y49u1H61z3wsfcHhQZeVDYe/wF5LD7fk3NcdgDvwFD3mLm1QWhdZySw==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Core": "2.23.0",
|
||||
"Silk.NET.Vulkan": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Vulkan.Extensions.KHR": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "uRaf4j+SmH3DumjSSSUbFg33BnsGZUyXGj93O9NgGKZSJN3OTmNmQDxRew+/KiVLcgH6qzbto8aNGZ++j9GFWg==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Core": "2.23.0",
|
||||
"Silk.NET.Vulkan": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Windowing": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "OPNPmt/lRyUKVYrFLQXVxyATqD3MKLc1iY1oKx1/2GppgmZxVZPwN12tekrQ4C7408kgB1L5JD1Wnirqqeb2kg==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Windowing.Common": "2.23.0",
|
||||
"Silk.NET.Windowing.Glfw": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Tmds.DBus.Protocol": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[0.21.3, )",
|
||||
"resolved": "0.21.3",
|
||||
"contentHash": "hDwB8WsQoyALQKqIbwzS68UKdlnafDm4T/DkO/JrA/YIneP/rKv96SxYPVXeh3FP4i/SXfShrYftKLtciJAIlw=="
|
||||
}
|
||||
},
|
||||
"net10.0/linux-x64": {
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.25547.20250602",
|
||||
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||
},
|
||||
"Avalonia.Native": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
}
|
||||
},
|
||||
"net10.0/osx-arm64": {
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.25547.20250602",
|
||||
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||
},
|
||||
"Avalonia.Native": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
}
|
||||
},
|
||||
"net10.0/osx-x64": {
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.25547.20250602",
|
||||
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||
},
|
||||
"Avalonia.Native": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
}
|
||||
},
|
||||
"net10.0/win-x64": {
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.25547.20250602",
|
||||
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||
},
|
||||
"Avalonia.Native": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Emulation;
|
||||
|
||||
/// <summary>
|
||||
/// Pure software implementation of the bit-field math behind AMD's SSE4a EXTRQ/INSERTQ
|
||||
/// (immediate-form) instructions.
|
||||
///
|
||||
/// The direct-execution backend runs guest PS5 code natively on the host CPU. The PS5's Zen 2
|
||||
/// cores implement AMD-only SSE4a (EXTRQ/INSERTQ), but Intel hosts - and Rosetta 2 on Apple
|
||||
/// Silicon - do not, so they raise #UD (STATUS_ILLEGAL_INSTRUCTION) instead of executing the
|
||||
/// opcode. SharpEmu already rewrites one specific compiled EXTRQ+VPBLENDD idiom at load time
|
||||
/// (see <see cref="Native.Sse4aExtrqBlendPatch"/>), but any other occurrence of EXTRQ/INSERTQ -
|
||||
/// a different register allocation, a title built with a different compiler version, and so on
|
||||
/// - still aborts the title. This class ported from Kyty's
|
||||
/// <c>Loader::X64InstructionEmulator::TryEmulateSse4a</c> provides the general bit-field
|
||||
/// extract/insert so the illegal-instruction handler can finish *any* immediate-form
|
||||
/// EXTRQ/INSERTQ in software and resume, instead of relying on a single hard-coded byte pattern.
|
||||
///
|
||||
/// The methods operate on plain 64-bit integers rather than the OS CONTEXT record so the bit
|
||||
/// math can be unit-tested in isolation; the unsafe CONTEXT/XMM plumbing lives in the backend
|
||||
/// adapter (<see cref="Native.DirectExecutionBackend"/>).
|
||||
/// </summary>
|
||||
public static class Sse4aBitFieldEmulator
|
||||
{
|
||||
public static bool IsValidBitField(int length, int index)
|
||||
{
|
||||
var len = length & 0x3F;
|
||||
var idx = index & 0x3F;
|
||||
return (len != 0 || idx == 0) && (len == 0 ? idx == 0 : idx + len <= 64);
|
||||
}
|
||||
|
||||
public static ulong ExtractBitField(ulong value, int length, int index)
|
||||
{
|
||||
var len = length & 0x3F;
|
||||
var idx = index & 0x3F;
|
||||
if (!IsValidBitField(length, index))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (len == 0)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var mask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
|
||||
return (value >> idx) & mask;
|
||||
}
|
||||
|
||||
public static ulong InsertBitField(ulong destination, ulong source, int length, int index)
|
||||
{
|
||||
var len = length & 0x3F;
|
||||
var idx = index & 0x3F;
|
||||
if (!IsValidBitField(length, index))
|
||||
{
|
||||
return destination;
|
||||
}
|
||||
|
||||
if (len == 0)
|
||||
{
|
||||
return source;
|
||||
}
|
||||
|
||||
var fieldMask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
|
||||
var destinationClearMask = fieldMask << idx;
|
||||
var sourceField = (source & fieldMask) << idx;
|
||||
return (destination & ~destinationClearMask) | sourceField;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Threading;
|
||||
using Iced.Intel;
|
||||
using SharpEmu.Core.Cpu.Emulation;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native;
|
||||
|
||||
// General software fallback for the AMD-only instructions PS5 titles occasionally emit that a
|
||||
// Zen 2-only host implements but Intel hosts (and Rosetta 2 on Apple Silicon) do not:
|
||||
// - SSE4a EXTRQ/INSERTQ, immediate form
|
||||
// - MONITORX/MWAITX
|
||||
//
|
||||
// This is a direct port of Kyty's Loader::X64InstructionEmulator (TryEmulateSse4a /
|
||||
// TryEmulateMonitorxMwaitx). SharpEmu already special-cases exactly one compiled EXTRQ+VPBLENDD
|
||||
// byte sequence at load time (Sse4aExtrqBlendPatch), which only helps the one idiom it was
|
||||
// reverse-engineered from. This file is a general, fault-time fallback that engages for any
|
||||
// immediate-form EXTRQ/INSERTQ or MONITORX/MWAITX the narrower patch (or a title using a
|
||||
// different compiler/register allocation) does not cover, complementing rather than replacing
|
||||
// it: the load-time patch still avoids paying the fault-and-recover cost on the hot path it was
|
||||
// built for, while this method is the safety net for everything else.
|
||||
//
|
||||
// This is deliberately additive: DirectExecutionBackend.IllegalInstruction.cs (the BMI1/BMI2/ABM
|
||||
// fallback) is untouched, and this method is only reached from VectoredHandler after that one
|
||||
// has already declined to handle the fault.
|
||||
public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
// Byte offset of Xmm0 within the Win64 CONTEXT record: FltSave (the XMM_SAVE_AREA32/FXSAVE
|
||||
// image) starts right after Rip at offset 256, and XmmRegisters[0] sits 160 bytes into that
|
||||
// area (32-byte header + 8 legacy x87/MMX slots x 16 bytes). 256 + 160 = 416 (0x1A0). Cross-
|
||||
// checked against this file's own Win64ContextSize (0x4D0): rebuilding the whole CONTEXT
|
||||
// layout field-by-field from offset 0 lands on the same 0x4D0 total, which would not happen
|
||||
// if this offset (or anything before it) were wrong.
|
||||
private const int Win64ContextXmm0Offset = 0x1A0;
|
||||
|
||||
private static int _sse4aSoftwareFallbackAnnounced;
|
||||
private static long _sse4aInstructionsEmulated;
|
||||
private static int _monitorxSoftwareFallbackAnnounced;
|
||||
private static long _monitorxInstructionsEmulated;
|
||||
|
||||
private unsafe bool TryRecoverAmdCompatInstruction(void* contextRecord, ulong rip)
|
||||
{
|
||||
if (TryRecoverMonitorxMwaitx(contextRecord, rip))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// MONITORX/MWAITX above only ever reads guest code memory and rewrites RIP, both of
|
||||
// which the POSIX signal bridge (DirectExecutionBackend.PosixSignals.cs) faithfully
|
||||
// round-trips through the real ucontext, so it works on every supported OS. EXTRQ/
|
||||
// INSERTQ additionally read and write an XMM register: on Windows contextRecord is the
|
||||
// live CONTEXT the OS resumes the thread from, so touching the Xmm0.. slots is visible
|
||||
// to the guest, and on Linux the bridge copies the mcontext's FXSAVE image into the
|
||||
// Xmm0.. slots and writes them back through sigreturn (_posixXmmContextBridged). On
|
||||
// Darwin the XMM area is still a zeroed scratch buffer - running this there would
|
||||
// silently compute a result from stale bytes and then discard whatever it "wrote", so
|
||||
// the recovery declines until that bridge exists.
|
||||
return (OperatingSystem.IsWindows() || _posixXmmContextBridged) &&
|
||||
TryRecoverSse4aExtractInsert(contextRecord, rip);
|
||||
}
|
||||
|
||||
private unsafe bool TryRecoverMonitorxMwaitx(void* contextRecord, ulong rip)
|
||||
{
|
||||
// MONITORX (0F 01 FA) and MWAITX (0F 01 FB) are fixed 3-byte encodings with no
|
||||
// ModRM/SIB/displacement/immediate, so a raw byte compare is sufficient and unambiguous.
|
||||
var opcode = new byte[3];
|
||||
if (!TryReadHostBytes(rip, opcode) ||
|
||||
opcode[0] != 0x0F || opcode[1] != 0x01 || (opcode[2] != 0xFA && opcode[2] != 0xFB))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// PS5 titles use this pair in idle/wait loops: MONITORX arms a monitor on a cache line
|
||||
// and MWAITX blocks until that line is written (or a timeout elapses). Hosts without
|
||||
// the extension raise #UD on either one. We do not model the monitor itself, only its
|
||||
// observable effect on guest forward progress: MONITORX becomes a no-op (arming a
|
||||
// watch we never honour has no side effect of its own) and MWAITX becomes a plain
|
||||
// thread yield, i.e. treat the awaited condition as already satisfied so the guest
|
||||
// loop keeps making progress instead of executing an illegal opcode forever.
|
||||
if (opcode[2] == 0xFB)
|
||||
{
|
||||
Thread.Yield();
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, CTX_RIP, rip + 3);
|
||||
|
||||
Interlocked.Increment(ref _monitorxInstructionsEmulated);
|
||||
if (Interlocked.Exchange(ref _monitorxSoftwareFallbackAnnounced, 1) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Host lacks AMD MONITORX/MWAITX used by the guest; " +
|
||||
"emulating those instructions in software.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private unsafe bool TryRecoverSse4aExtractInsert(void* contextRecord, ulong rip)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() && !_posixXmmContextBridged ||
|
||||
!TryReadFaultingInstruction(rip, out var instruction))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var isExtrq = instruction.Mnemonic == Mnemonic.Extrq;
|
||||
var isInsertq = instruction.Mnemonic == Mnemonic.Insertq;
|
||||
if (!isExtrq && !isInsertq)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isExtrq && instruction.OpCount != 3 || isInsertq && instruction.OpCount != 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (instruction.GetOpKind(0) != OpKind.Register ||
|
||||
!TryGetXmmOffset(instruction.GetOpRegister(0), out var destOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var destLow = ReadCtxU64(contextRecord, destOffset);
|
||||
if (isExtrq)
|
||||
{
|
||||
var length = (int)instruction.GetImmediate(1);
|
||||
var index = (int)instruction.GetImmediate(2);
|
||||
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.ExtractBitField(destLow, length, index));
|
||||
WriteCtxU64(contextRecord, destOffset + 8, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (instruction.GetOpKind(1) != OpKind.Register ||
|
||||
!TryGetXmmOffset(instruction.GetOpRegister(1), out var srcOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var length = (int)instruction.GetImmediate(2);
|
||||
var index = (int)instruction.GetImmediate(3);
|
||||
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.InsertBitField(
|
||||
destLow, ReadCtxU64(contextRecord, srcOffset), length, index));
|
||||
WriteCtxU64(contextRecord, destOffset + 8, 0);
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, CTX_RIP, rip + (ulong)instruction.Length);
|
||||
|
||||
Interlocked.Increment(ref _sse4aInstructionsEmulated);
|
||||
if (Interlocked.Exchange(ref _sse4aSoftwareFallbackAnnounced, 1) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Host lacks SSE4a EXTRQ/INSERTQ used by the guest; " +
|
||||
"emulating those instructions in software.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Maps an Iced XMM register to its byte offset in the Win64 CONTEXT record. Written as an
|
||||
// explicit switch (rather than arithmetic on the Register enum) to match the style already
|
||||
// used by TryGetGprSlot/TryGetGpr64Offset in DirectExecutionBackend.IllegalInstruction.cs.
|
||||
private static bool TryGetXmmOffset(Register register, out int offset)
|
||||
{
|
||||
switch (register)
|
||||
{
|
||||
case Register.XMM0: offset = Win64ContextXmm0Offset + 16 * 0; return true;
|
||||
case Register.XMM1: offset = Win64ContextXmm0Offset + 16 * 1; return true;
|
||||
case Register.XMM2: offset = Win64ContextXmm0Offset + 16 * 2; return true;
|
||||
case Register.XMM3: offset = Win64ContextXmm0Offset + 16 * 3; return true;
|
||||
case Register.XMM4: offset = Win64ContextXmm0Offset + 16 * 4; return true;
|
||||
case Register.XMM5: offset = Win64ContextXmm0Offset + 16 * 5; return true;
|
||||
case Register.XMM6: offset = Win64ContextXmm0Offset + 16 * 6; return true;
|
||||
case Register.XMM7: offset = Win64ContextXmm0Offset + 16 * 7; return true;
|
||||
case Register.XMM8: offset = Win64ContextXmm0Offset + 16 * 8; return true;
|
||||
case Register.XMM9: offset = Win64ContextXmm0Offset + 16 * 9; return true;
|
||||
case Register.XMM10: offset = Win64ContextXmm0Offset + 16 * 10; return true;
|
||||
case Register.XMM11: offset = Win64ContextXmm0Offset + 16 * 11; return true;
|
||||
case Register.XMM12: offset = Win64ContextXmm0Offset + 16 * 12; return true;
|
||||
case Register.XMM13: offset = Win64ContextXmm0Offset + 16 * 13; return true;
|
||||
case Register.XMM14: offset = Win64ContextXmm0Offset + 16 * 14; return true;
|
||||
case Register.XMM15: offset = Win64ContextXmm0Offset + 16 * 15; return true;
|
||||
default:
|
||||
offset = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ 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()
|
||||
{
|
||||
@@ -52,6 +55,7 @@ public sealed partial class DirectExecutionBackend
|
||||
}
|
||||
_exceptionHandler = (nint)AddVectoredExceptionHandler(1u, _exceptionHandlerStub);
|
||||
Console.Error.WriteLine($"[LOADER][INFO] Exception handler installed: 0x{_exceptionHandler:X16}");
|
||||
SharpEmu.HLE.GuestImageWriteTracker.WarmUp();
|
||||
|
||||
_unhandledFilterDelegate = UnhandledExceptionFilter;
|
||||
_unhandledFilterHandle = GCHandle.Alloc(_unhandledFilterDelegate);
|
||||
@@ -114,6 +118,13 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (exceptionCode == 3221225477u &&
|
||||
exceptionRecord->NumberParameters >= 2 &&
|
||||
SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault(
|
||||
exceptionRecord->ExceptionInformation[1]))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (TryRecoverAuxiliaryThreadExecuteFault(exceptionRecord, contextRecord, rip))
|
||||
{
|
||||
return -1;
|
||||
@@ -133,6 +144,11 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (exceptionCode == StatusIllegalInstruction &&
|
||||
TryRecoverAmdCompatInstruction(contextRecord, rip))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (IsBenignHostDebugException(exceptionCode))
|
||||
{
|
||||
return -1;
|
||||
@@ -430,18 +446,91 @@ public sealed partial class DirectExecutionBackend
|
||||
void* contextRecord,
|
||||
ulong rip)
|
||||
{
|
||||
if (exceptionRecord->ExceptionCode != 3221225477u ||
|
||||
rip >= 0x0000000800000000UL ||
|
||||
_activeGuestThreadState is not { Name: "tbb_thead" } activeThread)
|
||||
if (exceptionRecord->ExceptionCode != 3221225477u)
|
||||
{
|
||||
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(
|
||||
@@ -453,13 +542,57 @@ public sealed partial class DirectExecutionBackend
|
||||
_ = TryPatchActiveGuestReturnSlot(hostExit);
|
||||
WriteCtxU64(contextRecord, 120, 0);
|
||||
WriteCtxU64(contextRecord, 248, hostExit);
|
||||
var recovery = Interlocked.Increment(ref _auxiliaryThreadExecuteFaultRecoveries);
|
||||
var recoveryFallback = Interlocked.Increment(ref _auxiliaryThreadExecuteFaultRecoveries);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Recovered auxiliary TBB execute fault #{recovery}: " +
|
||||
$"[LOADER][WARN] Recovered auxiliary TBB execute fault #{recoveryFallback}: " +
|
||||
$"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)
|
||||
@@ -478,7 +611,7 @@ public sealed partial class DirectExecutionBackend
|
||||
if (count <= 16 || count % 65536 == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (SHARPEMU_IGNORE_INT41=1)");
|
||||
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (default-on; set SHARPEMU_IGNORE_INT41=0 to disable)");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -69,6 +69,15 @@ public sealed partial class DirectExecutionBackend
|
||||
|
||||
private unsafe static int RawVectoredHandlerManaged(void* exceptionInfo)
|
||||
{
|
||||
EXCEPTION_RECORD* exceptionRecord = ((EXCEPTION_POINTERS*)exceptionInfo)->ExceptionRecord;
|
||||
if (exceptionRecord->ExceptionCode == 3221225477u &&
|
||||
exceptionRecord->NumberParameters >= 2 &&
|
||||
SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault(
|
||||
exceptionRecord->ExceptionInformation[1]))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return TryRecoverUnresolvedSentinel(exceptionInfo);
|
||||
}
|
||||
|
||||
@@ -530,9 +539,12 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
|
||||
}
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
|
||||
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
|
||||
{
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
|
||||
}
|
||||
StoreImportVectorReturn(cpuContext, argPackPtr);
|
||||
if (dispatchResolved &&
|
||||
orbisGen2Result == OrbisGen2Result.ORBIS_GEN2_OK &&
|
||||
@@ -1326,9 +1338,12 @@ public sealed partial class DirectExecutionBackend
|
||||
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
|
||||
}
|
||||
}
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
|
||||
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
|
||||
{
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
|
||||
}
|
||||
StoreImportVectorReturn(cpuContext, argPackPtr);
|
||||
|
||||
if (returnValue != (int)OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
@@ -1398,11 +1413,13 @@ public sealed partial class DirectExecutionBackend
|
||||
"vWU-odnS+fU" or // sceAmprMeasureCommandSizeReadFile
|
||||
"sSAUCCU1dv4" or // sceAmprMeasureCommandSizeWriteKernelEventQueue_04_00
|
||||
"C+IEj+BsAFM" or // sceAmprMeasureCommandSizeWriteAddressOnCompletion
|
||||
"4fgtGfXDrFc" or // sceAmprMeasureCommandSizeWriteAddress_04_00
|
||||
"tZDDEo2tE5k" or // sceAmprCommandBufferGetSize
|
||||
"GnxKOHEawhk" or // sceAmprCommandBufferGetCurrentOffset
|
||||
"gzndltBEzWc" or // sceAmprCommandBufferGetNumCommands
|
||||
"H896Pt-yB4I" or // sceAmprCommandBufferWriteKernelEventQueue_04_00
|
||||
"sJXyWHjP-F8" or // sceAmprCommandBufferWriteAddressOnCompletion
|
||||
"j0+3uJMxYJY" or // sceAmprCommandBufferWriteAddress_04_00
|
||||
"mPpPxv5CZt4" or // sceSystemServiceGetHdrToneMapLuminance
|
||||
"1FZBKy8HeNU" or // sceVideoOutGetVblankStatus
|
||||
"ASoW5WE-UPo" or // sceKernelAprSubmitCommandBufferAndGetResult
|
||||
@@ -1410,6 +1427,8 @@ public sealed partial class DirectExecutionBackend
|
||||
"eE4Szl8sil8" or // sceKernelAprSubmitCommandBuffer
|
||||
"qvMUCyyaCSI" or // sceKernelAprSubmitCommandBufferAndGetId
|
||||
"Q2V+iqvjgC0" or // vsnprintf
|
||||
"AV6ipCNa4Rw" or // strcasecmp
|
||||
"viiwFMaNamA" or // strstr
|
||||
"q1cHNfGycLI" or // scePadRead
|
||||
"xk0AcarP3V4" or // scePadOpen
|
||||
"yH17Q6NWtVg" or // sceUserServiceGetEvent
|
||||
@@ -1436,6 +1455,12 @@ 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);
|
||||
@@ -1449,6 +1474,8 @@ public sealed partial class DirectExecutionBackend
|
||||
!expectedTimedWaitTimeout &&
|
||||
!expectedEqueueTimeout &&
|
||||
!expectedMutexTrylockBusy &&
|
||||
!expectedSemaphoreTrywaitAgain &&
|
||||
!expectedPollSemaBusy &&
|
||||
!expectedNetAcceptWouldBlock &&
|
||||
!expectedUserServiceNoEvent &&
|
||||
!expectedPrivacyInvalidParameter)
|
||||
@@ -1542,11 +1569,13 @@ public sealed partial class DirectExecutionBackend
|
||||
"vWU-odnS+fU" or
|
||||
"sSAUCCU1dv4" or
|
||||
"C+IEj+BsAFM" or
|
||||
"4fgtGfXDrFc" or
|
||||
"tZDDEo2tE5k" or
|
||||
"GnxKOHEawhk" or
|
||||
"gzndltBEzWc" or
|
||||
"H896Pt-yB4I" or
|
||||
"sJXyWHjP-F8" or
|
||||
"j0+3uJMxYJY" or
|
||||
"mPpPxv5CZt4" or
|
||||
"1FZBKy8HeNU" or
|
||||
"ASoW5WE-UPo" or
|
||||
@@ -1571,6 +1600,8 @@ public sealed partial class DirectExecutionBackend
|
||||
"WkkeywLJcgU" or // wcslen
|
||||
"Ovb2dSJOAuE" or // strcmp
|
||||
"aesyjrHVWy4" or // strncmp
|
||||
"AV6ipCNa4Rw" or // strcasecmp
|
||||
"viiwFMaNamA" or // strstr
|
||||
"pNtJdE3x49E" or // wcscmp
|
||||
"fV2xHER+bKE" or // wcscoll
|
||||
"E8wCoUEbfzk" or // wcsncmp
|
||||
|
||||
@@ -29,9 +29,29 @@ 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;
|
||||
|
||||
@@ -49,6 +69,9 @@ 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.
|
||||
@@ -56,40 +79,148 @@ 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)
|
||||
private unsafe int RunGuestEntryStub(void* entryStub, ulong hostRspSlot, bool requireNativeWorker = false)
|
||||
{
|
||||
var worker = RentNativeGuestExecutor();
|
||||
if (worker is null)
|
||||
{
|
||||
TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot);
|
||||
return CallNativeEntry(entryStub);
|
||||
}
|
||||
// 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;
|
||||
try
|
||||
{
|
||||
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;
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReturnNativeGuestExecutor(worker);
|
||||
_nativeWorkerRunLimiter.Release();
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -400,6 +531,22 @@ 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,
|
||||
@@ -445,6 +592,49 @@ 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;
|
||||
@@ -452,7 +642,22 @@ public sealed partial class DirectExecutionBackend
|
||||
forcedExit = _runForcedExit;
|
||||
if (_runPrologueFailed)
|
||||
{
|
||||
throw new InvalidOperationException("Native guest worker failed to bind the run ambient (prologue fault)");
|
||||
// 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);
|
||||
}
|
||||
return _runNativeResult;
|
||||
}
|
||||
@@ -546,6 +751,18 @@ 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);
|
||||
@@ -580,6 +797,14 @@ 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,6 +50,19 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
private const int LinuxUcontextGregsOffset = 40;
|
||||
private const int LinuxGregsErrOffset = 19 * 8;
|
||||
|
||||
// The kernel's x86-64 sigcontext places the FXSAVE-image pointer right
|
||||
// after the general registers it hands to the handler: err(152)
|
||||
// trapno(160) oldmask(168) cr2(176) fpstate(184), all relative to
|
||||
// GetPosixRegisterBase. glibc and musl both overlay this kernel layout
|
||||
// verbatim (glibc's mcontext_t.fpregs is the same slot), so the offset
|
||||
// is libc-independent. Inside the FXSAVE image the XMM registers start
|
||||
// at +160 (32-byte header + 8 legacy x87/MMX slots x 16 bytes) - the
|
||||
// same relative position they occupy in the Win64 CONTEXT's FltSave
|
||||
// area (Win64ContextXmm0Offset = 256 + 160).
|
||||
private const int LinuxGregsFpstateOffset = 184;
|
||||
private const int FxsaveXmmOffset = 160;
|
||||
private const int XmmBlockSize = 16 * 16;
|
||||
|
||||
// Byte offsets of the general registers relative to GetPosixRegisterBase,
|
||||
// ordered to match the contiguous Win64 CONTEXT block CTX_RAX..CTX_RIP
|
||||
// (rax, rcx, rdx, rbx, rsp, rbp, rsi, rdi, r8..r15, rip). Verified
|
||||
@@ -71,6 +84,15 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
[ThreadStatic]
|
||||
private static int _posixSignalHandlerDepth;
|
||||
|
||||
// True while the current thread's in-flight POSIX fault carries the real
|
||||
// XMM registers in the CONTEXT scratch buffer and writes to them will
|
||||
// reach the mcontext on resume. Gates recovery paths (SSE4a EXTRQ/
|
||||
// INSERTQ) that would otherwise compute results from a zeroed XMM area
|
||||
// and silently discard what they "wrote". Darwin is not bridged yet, so
|
||||
// the flag stays false there.
|
||||
[ThreadStatic]
|
||||
private static bool _posixXmmContextBridged;
|
||||
|
||||
private void SetupPosixExceptionHandler()
|
||||
{
|
||||
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_POSIX_SIGNALS"), "1", StringComparison.Ordinal))
|
||||
@@ -252,6 +274,26 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
WriteCtxU64(contextRecord, CTX_RAX + i * 8, *(ulong*)(registers + offsets[i]));
|
||||
}
|
||||
|
||||
// Bridge the XMM registers alongside the GPRs where the layout is
|
||||
// known: on Linux the fpstate pointer and FXSAVE image are kernel
|
||||
// ABI, so recovery paths that read or write XMM state (SSE4a
|
||||
// EXTRQ/INSERTQ) see the live registers and their writes reach the
|
||||
// guest through sigreturn.
|
||||
byte* fpstate = null;
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
fpstate = *(byte**)(registers + LinuxGregsFpstateOffset);
|
||||
if (fpstate != null)
|
||||
{
|
||||
Buffer.MemoryCopy(
|
||||
fpstate + FxsaveXmmOffset,
|
||||
contextRecord + Win64ContextXmm0Offset,
|
||||
XmmBlockSize,
|
||||
XmmBlockSize);
|
||||
}
|
||||
}
|
||||
_posixXmmContextBridged = fpstate != null;
|
||||
|
||||
EXCEPTION_RECORD record = default;
|
||||
record.ExceptionAddress = (void*)ReadCtxU64(contextRecord, CTX_RIP);
|
||||
if (signal == PosixSigIll)
|
||||
@@ -317,6 +359,14 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
{
|
||||
*(ulong*)(registers + offsets[i]) = ReadCtxU64(contextRecord, CTX_RAX + i * 8);
|
||||
}
|
||||
if (fpstate != null)
|
||||
{
|
||||
Buffer.MemoryCopy(
|
||||
contextRecord + Win64ContextXmm0Offset,
|
||||
fpstate + FxsaveXmmOffset,
|
||||
XmmBlockSize,
|
||||
XmmBlockSize);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ using SharpEmu.Core.Cpu.Debugging;
|
||||
using SharpEmu.Core.Loader;
|
||||
using SharpEmu.Core.Memory;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Diagnostics;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native;
|
||||
|
||||
@@ -214,6 +215,15 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
private nint _guestReturnStub;
|
||||
|
||||
private nint _workerAbortStub;
|
||||
private nint _vehManagedEntryLock;
|
||||
|
||||
private uint _workerDoneEventTlsIndex = uint.MaxValue;
|
||||
|
||||
private uint _tbbAbortEligibleTlsIndex = uint.MaxValue;
|
||||
|
||||
private nint _setEventAddress;
|
||||
|
||||
private nint _rawExceptionHandler;
|
||||
|
||||
private nint _rawExceptionHandlerStub;
|
||||
@@ -712,6 +722,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
private readonly Dictionary<ulong, PendingGuestException> _pendingGuestExceptions = new Dictionary<ulong, PendingGuestException>();
|
||||
|
||||
// Import dispatch is the hottest managed path in UE titles. Most imports do
|
||||
// not have an exception queued, so publish the dictionary population and let
|
||||
// safe points skip _guestThreadGate entirely in the common case.
|
||||
private int _pendingGuestExceptionCount;
|
||||
|
||||
private readonly HashSet<ulong> _activeGuestExceptionDeliveries = new HashSet<ulong>();
|
||||
|
||||
private int _guestThreadPumpDepth;
|
||||
@@ -1036,6 +1051,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
_selfHandlePtr = GCHandle.ToIntPtr(_selfHandle);
|
||||
_guestTlsBaseTlsIndex = TlsAlloc();
|
||||
_hostRspSlotTlsIndex = TlsAlloc();
|
||||
_workerDoneEventTlsIndex = OperatingSystem.IsWindows() ? TlsAlloc() : uint.MaxValue;
|
||||
_tbbAbortEligibleTlsIndex = OperatingSystem.IsWindows() ? TlsAlloc() : uint.MaxValue;
|
||||
if (_guestTlsBaseTlsIndex == uint.MaxValue || _hostRspSlotTlsIndex == uint.MaxValue)
|
||||
{
|
||||
throw new OutOfMemoryException("Failed to allocate native TLS slots");
|
||||
@@ -1059,6 +1076,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
{
|
||||
throw new InvalidOperationException("Failed to resolve kernel32 thread timing functions");
|
||||
}
|
||||
_setEventAddress = kernel32 != 0 ? GetProcAddress(kernel32, "SetEvent") : 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1082,13 +1100,29 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
{
|
||||
throw new OutOfMemoryException("Failed to allocate host stack slot storage");
|
||||
}
|
||||
_vehManagedEntryLock = (nint)VirtualAlloc(null, 64u, 12288u, 4u);
|
||||
if (_vehManagedEntryLock == 0)
|
||||
{
|
||||
throw new OutOfMemoryException("Failed to allocate VEH managed-entry lock");
|
||||
}
|
||||
// owner (nint) + depth (int); recursive — nested VEH on same thread must reenter.
|
||||
*(nint*)_vehManagedEntryLock = 0;
|
||||
*(int*)(_vehManagedEntryLock + sizeof(nint)) = 0;
|
||||
_unresolvedReturnStub = CreateUnresolvedReturnStub();
|
||||
_guestReturnStub = CreateGuestReturnStub();
|
||||
if (_guestReturnStub == 0)
|
||||
{
|
||||
throw new OutOfMemoryException("Failed to allocate guest return stub");
|
||||
}
|
||||
_workerAbortStub = CreateWorkerAbortStub();
|
||||
if (_workerAbortStub == 0 && OperatingSystem.IsWindows())
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Worker abort stub unavailable; TBB execute-fault recover will use host_exit");
|
||||
}
|
||||
SetupExceptionHandler();
|
||||
// Cover the Astro TBB spawn storm (often 8–12 concurrent tbb_thead).
|
||||
PrewarmNativeGuestWorkers(Math.Max(NativeWorkerMaxConcurrent, 4));
|
||||
}
|
||||
|
||||
public bool TryExecute(CpuContext context, ulong entryPoint, Generation generation, IReadOnlyDictionary<ulong, string> importStubs, IReadOnlyDictionary<string, ulong> runtimeSymbols, CpuExecutionOptions executionOptions, out OrbisGen2Result result)
|
||||
@@ -1118,7 +1152,9 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
_logStrlenBursts = _logStrlenImports ||
|
||||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_STRLEN_BURSTS"), "1", StringComparison.Ordinal);
|
||||
_logGuestContext = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_CONTEXT"), "1", StringComparison.Ordinal);
|
||||
_ignoreGuestInt41 = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_IGNORE_INT41"), "1", StringComparison.Ordinal);
|
||||
var ignoreGuestInt41Env = Environment.GetEnvironmentVariable("SHARPEMU_IGNORE_INT41");
|
||||
_ignoreGuestInt41 = !string.Equals(ignoreGuestInt41Env, "0", StringComparison.Ordinal) &&
|
||||
!string.Equals(ignoreGuestInt41Env, "false", StringComparison.OrdinalIgnoreCase);
|
||||
_ignoredGuestInt41Count = 0;
|
||||
_logGuestThreads = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_GUEST_THREADS"), "1", StringComparison.Ordinal);
|
||||
_logUsleep = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_USLEEP"), "1", StringComparison.Ordinal);
|
||||
@@ -1408,6 +1444,54 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
0x75, 0xE7,
|
||||
0xC3,
|
||||
],
|
||||
"AV6ipCNa4Rw" =>
|
||||
[
|
||||
0x0F, 0xB6, 0x07,
|
||||
0x0F, 0xB6, 0x16,
|
||||
0x8D, 0x48, 0xBF,
|
||||
0x83, 0xF9, 0x19,
|
||||
0x77, 0x03,
|
||||
0x83, 0xC0, 0x20,
|
||||
0x8D, 0x4A, 0xBF,
|
||||
0x83, 0xF9, 0x19,
|
||||
0x77, 0x03,
|
||||
0x83, 0xC2, 0x20,
|
||||
0x29, 0xD0,
|
||||
0x75, 0x0C,
|
||||
0x85, 0xD2,
|
||||
0x74, 0x08,
|
||||
0x48, 0xFF, 0xC7,
|
||||
0x48, 0xFF, 0xC6,
|
||||
0xEB, 0xD4,
|
||||
0xC3,
|
||||
],
|
||||
"viiwFMaNamA" =>
|
||||
[
|
||||
0x0F, 0xB6, 0x16,
|
||||
0x84, 0xD2,
|
||||
0x74, 0x2D,
|
||||
0x0F, 0xB6, 0x07,
|
||||
0x84, 0xC0,
|
||||
0x74, 0x2A,
|
||||
0x38, 0xD0,
|
||||
0x75, 0x1D,
|
||||
0x4C, 0x8D, 0x47, 0x01,
|
||||
0x4C, 0x8D, 0x4E, 0x01,
|
||||
0x41, 0x0F, 0xB6, 0x09,
|
||||
0x84, 0xC9,
|
||||
0x74, 0x12,
|
||||
0x41, 0x38, 0x08,
|
||||
0x75, 0x08,
|
||||
0x49, 0xFF, 0xC0,
|
||||
0x49, 0xFF, 0xC1,
|
||||
0xEB, 0xEB,
|
||||
0x48, 0xFF, 0xC7,
|
||||
0xEB, 0xD3,
|
||||
0x48, 0x89, 0xF8,
|
||||
0xC3,
|
||||
0x31, 0xC0,
|
||||
0xC3,
|
||||
],
|
||||
"pNtJdE3x49E" or "fV2xHER+bKE" =>
|
||||
[
|
||||
0x0F, 0xB7, 0x07,
|
||||
@@ -2354,9 +2438,147 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
return (nint)ptr;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After a TBB execute-fault, VEH redirects here on a host stack.
|
||||
/// SetEvent(done) then park forever — ExitThread from VEH CONTINUE_EXECUTION
|
||||
/// was taking down the whole process (recover logged, no respawning). The
|
||||
/// renter TerminateThread's the parked worker and respawns a clean loop.
|
||||
/// </summary>
|
||||
private unsafe nint CreateWorkerAbortStub()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() ||
|
||||
_workerDoneEventTlsIndex == uint.MaxValue ||
|
||||
_tlsGetValueAddress == 0 ||
|
||||
_setEventAddress == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
nint kernel32 = GetModuleHandle("kernel32.dll");
|
||||
nint getStdHandle = kernel32 != 0 ? GetProcAddress(kernel32, "GetStdHandle") : 0;
|
||||
nint writeFile = kernel32 != 0 ? GetProcAddress(kernel32, "WriteFile") : 0;
|
||||
nint flushFileBuffers = kernel32 != 0 ? GetProcAddress(kernel32, "FlushFileBuffers") : 0;
|
||||
|
||||
const uint stubSize = 256u;
|
||||
void* ptr = VirtualAlloc(null, stubSize, 12288u, 4u);
|
||||
if (ptr == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
byte* code = (byte*)ptr;
|
||||
int offset = 0;
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x28); // sub rsp, 0x28
|
||||
EmitByte(code, ref offset, 0xB9);
|
||||
EmitUInt32(code, ref offset, _workerDoneEventTlsIndex); // mov ecx, tls
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = _tlsGetValueAddress;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0); // call TlsGetValue
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x85);
|
||||
EmitByte(code, ref offset, 0xC0); // test rax, rax
|
||||
EmitByte(code, ref offset, 0x74); EmitByte(code, ref offset, 0x0F); // jz skip SetEvent (15 bytes)
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0xC1); // mov rcx, rax
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = _setEventAddress;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0); // call SetEvent
|
||||
|
||||
// Breadcrumb on host stack (survives silent teardown better than managed log).
|
||||
int msgAbsSlot = -1;
|
||||
if (getStdHandle != 0 && writeFile != 0)
|
||||
{
|
||||
ReadOnlySpan<byte> msg = "[LOADER][WARN] tbb_abort_stub SetEvent+park\n"u8;
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x20); // extra shadow for WriteFile args
|
||||
EmitByte(code, ref offset, 0xB9); EmitUInt32(code, ref offset, unchecked((uint)-12));
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = getStdHandle;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0xC1); // mov rcx, handle
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0xC3); // mov rbx, handle (nonvolatile for flush)
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
msgAbsSlot = offset;
|
||||
*(nint*)(code + offset) = 0;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0xC2); // mov rdx, msg
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xB8);
|
||||
EmitUInt32(code, ref offset, (uint)msg.Length);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20); // lea r9, [rsp+0x20]
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20); EmitUInt32(code, ref offset, 0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x28); EmitUInt32(code, ref offset, 0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = writeFile;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
if (flushFileBuffers != 0)
|
||||
{
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0xD9); // mov rcx, rbx
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = flushFileBuffers;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
}
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x20);
|
||||
}
|
||||
|
||||
// Park: do not ExitThread (process-wide silent die after VEH redirect).
|
||||
int parkOffset = offset;
|
||||
EmitByte(code, ref offset, 0xF3); EmitByte(code, ref offset, 0x90); // pause
|
||||
EmitByte(code, ref offset, 0xEB);
|
||||
EmitByte(code, ref offset, unchecked((byte)(parkOffset - (offset + 1)))); // jmp park
|
||||
|
||||
if (msgAbsSlot >= 0)
|
||||
{
|
||||
ReadOnlySpan<byte> msgEmbed = "[LOADER][WARN] tbb_abort_stub SetEvent+park\n"u8;
|
||||
*(nint*)(code + msgAbsSlot) = (nint)ptr + offset;
|
||||
for (int i = 0; i < msgEmbed.Length; i++)
|
||||
{
|
||||
EmitByte(code, ref offset, msgEmbed[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (offset > (int)stubSize)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][ERROR] Worker abort stub overflow: used={offset} cap={stubSize}");
|
||||
VirtualFree(ptr, 0u, 32768u);
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint oldProtect = default;
|
||||
if (!VirtualProtect(ptr, stubSize, 32u, &oldProtect))
|
||||
{
|
||||
VirtualFree(ptr, 0u, 32768u);
|
||||
return 0;
|
||||
}
|
||||
FlushInstructionCache(GetCurrentProcess(), ptr, (nuint)offset);
|
||||
return (nint)ptr;
|
||||
}
|
||||
|
||||
private unsafe nint CreateExceptionHandlerTrampoline(nint managedHandler)
|
||||
{
|
||||
const uint stubSize = 256u;
|
||||
// Live VEH trampoline used by SetupExceptionHandler. Must pre-filter
|
||||
// FastFail / CLR / MSVC C++ / stack-overflow the same way as
|
||||
// WindowsFaultHandling.CreateHandlerThunk: entering managed VEH while
|
||||
// the thread is in cooperative GC mode fail-fasts with
|
||||
// "UnmanagedCallersOnly method from managed code" (tLT18–22).
|
||||
// Extra headroom for native tbb abort + recursive managed-entry spinlock.
|
||||
const uint stubSize = 2048u;
|
||||
void* ptr = VirtualAlloc(null, stubSize, 12288u, 64u);
|
||||
if (ptr == null)
|
||||
{
|
||||
@@ -2365,10 +2587,305 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
byte* code = (byte*)ptr;
|
||||
int offset = 0;
|
||||
|
||||
ReadOnlySpan<uint> nonManagedExceptionCodes =
|
||||
[
|
||||
0xE0434352u, // CLR managed exception
|
||||
0xE06D7363u, // MSVC C++ exception
|
||||
0xC0000409u, // STATUS_STACK_BUFFER_OVERRUN / FailFast
|
||||
0xC00000FDu, // STATUS_STACK_OVERFLOW
|
||||
];
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x01); // mov rax, [rcx]
|
||||
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x00); // mov eax, [rax] ExceptionCode
|
||||
var passJumpOffsets = stackalloc int[nonManagedExceptionCodes.Length];
|
||||
int fastFailJumpSlot = -1;
|
||||
for (int i = 0; i < nonManagedExceptionCodes.Length; i++)
|
||||
{
|
||||
EmitByte(code, ref offset, 0x3D);
|
||||
EmitUInt32(code, ref offset, nonManagedExceptionCodes[i]);
|
||||
EmitByte(code, ref offset, 0x74);
|
||||
passJumpOffsets[i] = offset;
|
||||
EmitByte(code, ref offset, 0x00);
|
||||
if (nonManagedExceptionCodes[i] == 0xC0000409u)
|
||||
{
|
||||
fastFailJumpSlot = i;
|
||||
}
|
||||
}
|
||||
|
||||
EmitByte(code, ref offset, 0xE9); // jmp mainBody (rel32; FastFail breadcrumb sits between)
|
||||
var mainBodyJumpSlot = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
|
||||
int passOffset = offset;
|
||||
EmitByte(code, ref offset, 0x31); EmitByte(code, ref offset, 0xC0); // xor eax, eax
|
||||
EmitByte(code, ref offset, 0xC3);
|
||||
|
||||
int fastFailPassOffset = offset;
|
||||
var fastFailLogInstalled = false;
|
||||
nint kernel32 = GetModuleHandle("kernel32.dll");
|
||||
nint getStdHandle = kernel32 != 0 ? GetProcAddress(kernel32, "GetStdHandle") : 0;
|
||||
nint writeFile = kernel32 != 0 ? GetProcAddress(kernel32, "WriteFile") : 0;
|
||||
if (fastFailJumpSlot >= 0 && getStdHandle != 0 && writeFile != 0)
|
||||
{
|
||||
// Prefix + Context.Rip hex (AMD64 CONTEXT.Rip @ 0xF8) + newline.
|
||||
// Keep in sync with WindowsFaultHandling.CreateHandlerThunk.
|
||||
ReadOnlySpan<byte> msg =
|
||||
"[LOADER][FATAL] VEH_PASS FastFail 0xC0000409 (live trampoline; skip managed VEH) rip=0x"u8;
|
||||
ReadOnlySpan<byte> hexDigits = "0123456789ABCDEF"u8;
|
||||
// rcx=EXCEPTION_POINTERS*: capture Rip into r10 before clobbering.
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x41);
|
||||
EmitByte(code, ref offset, 0x08); // mov rax, [rcx+8] ContextRecord*
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x90);
|
||||
EmitUInt32(code, ref offset, 0xF8u); // mov r10, [rax+0xF8] Rip
|
||||
EmitByte(code, ref offset, 0x50);
|
||||
EmitByte(code, ref offset, 0x51);
|
||||
EmitByte(code, ref offset, 0x52);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x50);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x51);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x52); // push r10 (Rip)
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x40); // sub rsp, 0x40 (hex buf @ +0x30)
|
||||
EmitByte(code, ref offset, 0xB9); EmitUInt32(code, ref offset, unchecked((uint)-12));
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = getStdHandle;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x28); // mov [rsp+0x28], rax stderr handle
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xC1);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
var msgAbsSlot = offset;
|
||||
*(nint*)(code + offset) = 0;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xC2);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xB8);
|
||||
EmitUInt32(code, ref offset, (uint)msg.Length);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20); EmitUInt32(code, ref offset, 0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x38); EmitUInt32(code, ref offset, 0); // lpOverlapped slot
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = writeFile;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
|
||||
// Hex-encode Rip. Stack after sub 0x40: [rsp+0x40]=saved Rip (push r10).
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8B);
|
||||
EmitByte(code, ref offset, 0x54); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x40); // mov r10, [rsp+0x40]
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xB8);
|
||||
var hexDigitsAbsSlot = offset;
|
||||
*(nint*)(code + offset) = 0;
|
||||
offset += sizeof(nint); // mov r8, hexDigits
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
|
||||
EmitByte(code, ref offset, 0x5C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x30); // lea r11, [rsp+0x30] hex out
|
||||
EmitByte(code, ref offset, 0xB9); EmitUInt32(code, ref offset, 16u); // ecx = 16 nibbles
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xD0); // mov rax, r10
|
||||
int hexLoopOffset = offset;
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC1); EmitByte(code, ref offset, 0xC0);
|
||||
EmitByte(code, ref offset, 0x04); // rol rax, 4
|
||||
EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xC2); // mov edx, eax
|
||||
EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xE2); EmitByte(code, ref offset, 0x0F); // and edx, 0xF
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0xB6);
|
||||
EmitByte(code, ref offset, 0x14); EmitByte(code, ref offset, 0x10); // movzx edx, byte [r8+rdx]
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x88); EmitByte(code, ref offset, 0x13); // mov [r11], dl
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xC3); // inc r11
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xC9); // dec ecx
|
||||
EmitByte(code, ref offset, 0x75);
|
||||
EmitByte(code, ref offset, unchecked((byte)(hexLoopOffset - (offset + 1)))); // jnz hexLoop (rel8)
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xC6); EmitByte(code, ref offset, 0x03);
|
||||
EmitByte(code, ref offset, 0x0A); // mov byte [r11], '\n'
|
||||
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x28); // mov rcx, [rsp+0x28] stderr
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8D);
|
||||
EmitByte(code, ref offset, 0x54); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x30); // lea rdx, [rsp+0x30]
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xB8);
|
||||
EmitUInt32(code, ref offset, 17u); // 16 hex + newline
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8D);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x20); EmitUInt32(code, ref offset, 0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x44); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x38); EmitUInt32(code, ref offset, 0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = writeFile;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
|
||||
// Flush redirected stderr so FastFail rip survives process teardown.
|
||||
nint flushFileBuffers = kernel32 != 0 ? GetProcAddress(kernel32, "FlushFileBuffers") : 0;
|
||||
if (flushFileBuffers != 0)
|
||||
{
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x24);
|
||||
EmitByte(code, ref offset, 0x28); // mov rcx, stderr
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = flushFileBuffers;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
}
|
||||
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x40);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5A); // pop r10
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x59);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x58);
|
||||
EmitByte(code, ref offset, 0x5A);
|
||||
EmitByte(code, ref offset, 0x59);
|
||||
EmitByte(code, ref offset, 0x58);
|
||||
EmitByte(code, ref offset, 0x31); EmitByte(code, ref offset, 0xC0);
|
||||
EmitByte(code, ref offset, 0xC3);
|
||||
|
||||
var msgOffset = offset;
|
||||
for (int i = 0; i < msg.Length; i++)
|
||||
{
|
||||
EmitByte(code, ref offset, msg[i]);
|
||||
}
|
||||
|
||||
var hexDigitsOffset = offset;
|
||||
for (int i = 0; i < hexDigits.Length; i++)
|
||||
{
|
||||
EmitByte(code, ref offset, hexDigits[i]);
|
||||
}
|
||||
|
||||
*(nint*)(code + msgAbsSlot) = (nint)ptr + msgOffset;
|
||||
*(nint*)(code + hexDigitsAbsSlot) = (nint)ptr + hexDigitsOffset;
|
||||
code[passJumpOffsets[fastFailJumpSlot]] =
|
||||
checked((byte)(fastFailPassOffset - (passJumpOffsets[fastFailJumpSlot] + 1)));
|
||||
fastFailLogInstalled = true;
|
||||
}
|
||||
|
||||
int mainBodyOffset = offset;
|
||||
*(int*)(code + mainBodyJumpSlot) = mainBodyOffset - (mainBodyJumpSlot + sizeof(int));
|
||||
for (int i = 0; i < nonManagedExceptionCodes.Length; i++)
|
||||
{
|
||||
if (i == fastFailJumpSlot && fastFailLogInstalled)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
code[passJumpOffsets[i]] = checked((byte)(passOffset - (passJumpOffsets[i] + 1)));
|
||||
}
|
||||
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x54); // push r12
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x55); // push r13
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xE4); // mov r12, rsp
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xCD); // mov r13, rcx
|
||||
|
||||
// Native worker EXECUTE-AV abort without managed VEH.
|
||||
// Do NOT catch read/write AVs — workers need managed lazy-commit (tLTJ
|
||||
// silent-die when every worker AV was aborted). Execute faults on
|
||||
// tbb_thead are the concurrent-managed FailFast case (tLTC).
|
||||
int tbbFallthroughJump = -1;
|
||||
if (_workerAbortStub != 0 &&
|
||||
_tlsGetValueAddress != 0 &&
|
||||
_hostRspSlotTlsIndex != uint.MaxValue)
|
||||
{
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x8B);
|
||||
EmitByte(code, ref offset, 0x45); EmitByte(code, ref offset, 0x00); // mov rax, [r13]
|
||||
EmitByte(code, ref offset, 0x81); EmitByte(code, ref offset, 0x38);
|
||||
EmitUInt32(code, ref offset, 0xC0000005u); // cmp dword [rax], AV
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
|
||||
tbbFallthroughJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u); // jne fallthrough
|
||||
|
||||
// ExceptionInformation[0] == 8 → EXECUTE (DEP). Offset 32 on x64 RECORD.
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xB8); EmitUInt32(code, ref offset, 32u);
|
||||
EmitByte(code, ref offset, 0x08); // cmp qword [rax+32], 8
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
|
||||
var tbbNotExecuteJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
|
||||
int tbbNotEligibleJump = -1;
|
||||
if (_tbbAbortEligibleTlsIndex != uint.MaxValue)
|
||||
{
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0xB9);
|
||||
EmitUInt32(code, ref offset, _tbbAbortEligibleTlsIndex);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = _tlsGetValueAddress;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x85);
|
||||
EmitByte(code, ref offset, 0xC0);
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x84);
|
||||
tbbNotEligibleJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
}
|
||||
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0xB9);
|
||||
EmitUInt32(code, ref offset, _hostRspSlotTlsIndex);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = _tlsGetValueAddress;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x85);
|
||||
EmitByte(code, ref offset, 0xC0);
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x84);
|
||||
var tbbNoHostRspJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8B);
|
||||
EmitByte(code, ref offset, 0x00); // mov r8, [rax] hostRsp
|
||||
EmitByte(code, ref offset, 0x4D); EmitByte(code, ref offset, 0x85);
|
||||
EmitByte(code, ref offset, 0xC0);
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x84);
|
||||
var tbbZeroRspJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x83);
|
||||
EmitByte(code, ref offset, 0xE0); EmitByte(code, ref offset, 0xF0); // and r8, ~0xF
|
||||
EmitByte(code, ref offset, 0x4D); EmitByte(code, ref offset, 0x8B);
|
||||
EmitByte(code, ref offset, 0x4D); EmitByte(code, ref offset, 0x08); // mov r9, [r13+8]
|
||||
EmitByte(code, ref offset, 0x4D); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0x81); EmitUInt32(code, ref offset, 0x98u); // Context.Rsp
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = _workerAbortStub;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0x81); EmitUInt32(code, ref offset, 0xF8u); // Context.Rip
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x78);
|
||||
EmitUInt32(code, ref offset, 0u); // Context.Rax = 0
|
||||
|
||||
EmitByte(code, ref offset, 0xB8); EmitUInt32(code, ref offset, unchecked((uint)-1));
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89);
|
||||
EmitByte(code, ref offset, 0xE4); // mov rsp, r12
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5D);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5C);
|
||||
EmitByte(code, ref offset, 0xC3);
|
||||
|
||||
int tbbFallthroughOffset = offset;
|
||||
*(int*)(code + tbbFallthroughJump) = tbbFallthroughOffset - (tbbFallthroughJump + sizeof(int));
|
||||
*(int*)(code + tbbNotExecuteJump) = tbbFallthroughOffset - (tbbNotExecuteJump + sizeof(int));
|
||||
if (tbbNotEligibleJump >= 0)
|
||||
{
|
||||
*(int*)(code + tbbNotEligibleJump) = tbbFallthroughOffset - (tbbNotEligibleJump + sizeof(int));
|
||||
}
|
||||
*(int*)(code + tbbNoHostRspJump) = tbbFallthroughOffset - (tbbNoHostRspJump + sizeof(int));
|
||||
*(int*)(code + tbbZeroRspJump) = tbbFallthroughOffset - (tbbZeroRspJump + sizeof(int));
|
||||
}
|
||||
|
||||
EmitByte(code, ref offset, 0x65); EmitByte(code, ref offset, 0x48); // mov rax, gs:[8]
|
||||
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x04); EmitByte(code, ref offset, 0x25);
|
||||
EmitUInt32(code, ref offset, 8u);
|
||||
@@ -2385,11 +2902,67 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x28);
|
||||
// Serialize managed VEH entry (recursive spinlock). Concurrent UnmanagedCallersOnly
|
||||
// FailFast was the tLTQ silent mid-TBB pattern (enter without abort breadcrumb).
|
||||
// Lock layout: [0]=owner UniqueThread (nint), [8]=depth (int).
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xB9);
|
||||
*(nint*)(code + offset) = _vehManagedEntryLock;
|
||||
offset += sizeof(nint); // mov r9, lock*
|
||||
EmitByte(code, ref offset, 0x65); EmitByte(code, ref offset, 0x4C);
|
||||
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x14);
|
||||
EmitByte(code, ref offset, 0x25); EmitUInt32(code, ref offset, 0x48u); // mov r10, gs:[0x48]
|
||||
int hostAcquireSpin = offset;
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x01); // mov rax, [r9]
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x39); EmitByte(code, ref offset, 0xD0); // cmp rax, r10
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x84);
|
||||
int hostMineJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x85); EmitByte(code, ref offset, 0xC0); // test rax, rax
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
|
||||
int hostPauseJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0xF0); EmitByte(code, ref offset, 0x4C);
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0xB1); EmitByte(code, ref offset, 0x11); // lock cmpxchg [r9], r10
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
|
||||
int hostRetryJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x08);
|
||||
EmitUInt32(code, ref offset, 1u); // mov dword [r9+8], 1
|
||||
EmitByte(code, ref offset, 0xE9);
|
||||
int hostGotJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
int hostPauseOffset = offset;
|
||||
EmitByte(code, ref offset, 0xF3); EmitByte(code, ref offset, 0x90); // pause
|
||||
EmitByte(code, ref offset, 0xE9);
|
||||
int hostPauseBackJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
int hostMineOffset = offset;
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xFF);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x08); // inc dword [r9+8]
|
||||
int hostGotOffset = offset;
|
||||
*(int*)(code + hostMineJump) = hostMineOffset - (hostMineJump + sizeof(int));
|
||||
*(int*)(code + hostPauseJump) = hostPauseOffset - (hostPauseJump + sizeof(int));
|
||||
*(int*)(code + hostRetryJump) = hostAcquireSpin - (hostRetryJump + sizeof(int));
|
||||
*(int*)(code + hostGotJump) = hostGotOffset - (hostGotJump + sizeof(int));
|
||||
*(int*)(code + hostPauseBackJump) = hostAcquireSpin - (hostPauseBackJump + sizeof(int));
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xE9); // mov rcx, r13
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = managedHandler;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xB9);
|
||||
*(nint*)(code + offset) = _vehManagedEntryLock;
|
||||
offset += sizeof(nint); // mov r9, lock*
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xFF);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x08); // dec dword [r9+8]
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
|
||||
int hostStillJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x01); EmitUInt32(code, ref offset, 0u); // mov qword [r9], 0
|
||||
int hostStillOffset = offset;
|
||||
*(int*)(code + hostStillJump) = hostStillOffset - (hostStillJump + sizeof(int));
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0xE9);
|
||||
int hostRestoreJump = offset;
|
||||
@@ -2415,11 +2988,64 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xDC); // mov rsp, r11
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xB9);
|
||||
*(nint*)(code + offset) = _vehManagedEntryLock;
|
||||
offset += sizeof(nint); // mov r9, lock*
|
||||
EmitByte(code, ref offset, 0x65); EmitByte(code, ref offset, 0x4C);
|
||||
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x14);
|
||||
EmitByte(code, ref offset, 0x25); EmitUInt32(code, ref offset, 0x48u); // mov r10, gs:[0x48]
|
||||
int guestAcquireSpin = offset;
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x01); // mov rax, [r9]
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x39); EmitByte(code, ref offset, 0xD0); // cmp rax, r10
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x84);
|
||||
int guestMineJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x85); EmitByte(code, ref offset, 0xC0);
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
|
||||
int guestPauseJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0xF0); EmitByte(code, ref offset, 0x4C);
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0xB1); EmitByte(code, ref offset, 0x11);
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
|
||||
int guestRetryJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x08);
|
||||
EmitUInt32(code, ref offset, 1u);
|
||||
EmitByte(code, ref offset, 0xE9);
|
||||
int guestGotJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
int guestPauseOffset = offset;
|
||||
EmitByte(code, ref offset, 0xF3); EmitByte(code, ref offset, 0x90);
|
||||
EmitByte(code, ref offset, 0xE9);
|
||||
int guestPauseBackJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
int guestMineOffset = offset;
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xFF);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x08);
|
||||
int guestGotOffset = offset;
|
||||
*(int*)(code + guestMineJump) = guestMineOffset - (guestMineJump + sizeof(int));
|
||||
*(int*)(code + guestPauseJump) = guestPauseOffset - (guestPauseJump + sizeof(int));
|
||||
*(int*)(code + guestRetryJump) = guestAcquireSpin - (guestRetryJump + sizeof(int));
|
||||
*(int*)(code + guestGotJump) = guestGotOffset - (guestGotJump + sizeof(int));
|
||||
*(int*)(code + guestPauseBackJump) = guestAcquireSpin - (guestPauseBackJump + sizeof(int));
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xE9); // mov rcx, r13
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = managedHandler;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xB9);
|
||||
*(nint*)(code + offset) = _vehManagedEntryLock;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0xFF);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x08); // dec dword [r9+8]
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x85);
|
||||
int guestStillJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0xC7);
|
||||
EmitByte(code, ref offset, 0x01); EmitUInt32(code, ref offset, 0u);
|
||||
int guestStillOffset = offset;
|
||||
*(int*)(code + guestStillJump) = guestStillOffset - (guestStillJump + sizeof(int));
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0xE9);
|
||||
int guestRestoreJump = offset;
|
||||
@@ -2440,6 +3066,18 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
*(int*)(code + missingHostStackJump) = passThroughOffset - (missingHostStackJump + sizeof(int));
|
||||
*(int*)(code + guestRestoreJump) = restoreOffset - (guestRestoreJump + sizeof(int));
|
||||
|
||||
if (offset > (int)stubSize)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][ERROR] Exception handler trampoline overflow: used={offset} cap={stubSize}");
|
||||
VirtualFree(ptr, 0, 0x8000u);
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] VEH trampoline built: bytes={offset} native_worker_abort=" +
|
||||
$"{(_workerAbortStub != 0 && _hostRspSlotTlsIndex != uint.MaxValue)}");
|
||||
|
||||
uint oldProtect = default;
|
||||
VirtualProtect(ptr, stubSize, 32u, &oldProtect);
|
||||
FlushInstructionCache(GetCurrentProcess(), ptr, (nuint)offset);
|
||||
@@ -3014,6 +3652,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
$"[LOADER][INFO] Scheduled guest thread '{thread.Name}' handle=0x{thread.ThreadHandle:X16} " +
|
||||
$"entry=0x{thread.EntryPoint:X16} arg=0x{thread.Argument:X16} priority={thread.Priority} " +
|
||||
$"host_priority={MapGuestThreadPriority(thread.Priority)} affinity=0x{thread.AffinityMask:X}");
|
||||
LoadProgressDiagnostics.ArmIfNorthAudioThread(thread.Name);
|
||||
Pump(creatorContext, "pthread_create");
|
||||
// Pump is suppressed while another cooperative dispatch is active. The
|
||||
// background dispatcher would eventually observe this thread, but an
|
||||
@@ -3900,10 +4539,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
// unwinding. Unity can begin its next stop-the-world cycle in
|
||||
// that window; treating the new raise as part of the old delivery
|
||||
// strands the collector waiting for an acknowledgement.
|
||||
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
|
||||
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
|
||||
handler,
|
||||
exceptionType,
|
||||
external.ExceptionStackBase);
|
||||
external.ExceptionStackBase));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3912,10 +4551,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
// managed thread corrupts the worker's control state. Queue the
|
||||
// request and let that exact executor consume it at its next HLE
|
||||
// boundary, where the original guest thread is safely paused.
|
||||
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
|
||||
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
|
||||
handler,
|
||||
exceptionType,
|
||||
external.ExceptionStackBase);
|
||||
external.ExceptionStackBase));
|
||||
if (logGuestExceptions)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
@@ -3960,17 +4599,17 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
}
|
||||
if (target.ExceptionDeliveryActive)
|
||||
{
|
||||
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
|
||||
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
|
||||
handler,
|
||||
exceptionType,
|
||||
exceptionStackBase);
|
||||
exceptionStackBase));
|
||||
return true;
|
||||
}
|
||||
|
||||
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
|
||||
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
|
||||
handler,
|
||||
exceptionType,
|
||||
exceptionStackBase);
|
||||
exceptionStackBase));
|
||||
if (logGuestExceptions)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
@@ -4131,7 +4770,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
RestoreInterruptedGuestThread();
|
||||
if (target.State == GuestThreadRunState.Blocked &&
|
||||
!target.ExecutorActive &&
|
||||
_pendingGuestExceptions.Remove(threadHandle, out var queued))
|
||||
TryRemovePendingGuestExceptionLocked(threadHandle, out var queued))
|
||||
{
|
||||
followUp = queued;
|
||||
}
|
||||
@@ -4217,6 +4856,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
CpuContext currentContext,
|
||||
GuestCpuContinuation interruptedContinuation)
|
||||
{
|
||||
if (Volatile.Read(ref _pendingGuestExceptionCount) == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var threadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
|
||||
if (threadHandle == 0)
|
||||
{
|
||||
@@ -4230,7 +4874,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_pendingGuestExceptions.Remove(threadHandle, out pending))
|
||||
if (!TryRemovePendingGuestExceptionLocked(threadHandle, out pending))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -4292,6 +4936,27 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
}
|
||||
}
|
||||
|
||||
private void QueuePendingGuestExceptionLocked(
|
||||
ulong threadHandle,
|
||||
PendingGuestException pending)
|
||||
{
|
||||
_pendingGuestExceptions[threadHandle] = pending;
|
||||
Volatile.Write(ref _pendingGuestExceptionCount, _pendingGuestExceptions.Count);
|
||||
}
|
||||
|
||||
private bool TryRemovePendingGuestExceptionLocked(
|
||||
ulong threadHandle,
|
||||
out PendingGuestException pending)
|
||||
{
|
||||
if (!_pendingGuestExceptions.Remove(threadHandle, out pending))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _pendingGuestExceptionCount, _pendingGuestExceptions.Count);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryWriteGuestExceptionContext(
|
||||
CpuContext context,
|
||||
ulong address,
|
||||
@@ -4386,6 +5051,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
_guestThreads.Clear();
|
||||
_externalGuestThreads.Clear();
|
||||
_pendingGuestExceptions.Clear();
|
||||
Volatile.Write(ref _pendingGuestExceptionCount, 0);
|
||||
_activeGuestExceptionDeliveries.Clear();
|
||||
}
|
||||
|
||||
@@ -5067,16 +5733,27 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
return GuestNativeCallExitReason.Exception;
|
||||
}
|
||||
FlushInstructionCache(GetCurrentProcess(), ptr, stubSize);
|
||||
if (!TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot))
|
||||
{
|
||||
reason = "failed to bind host-RSP storage for guest thread stub";
|
||||
return GuestNativeCallExitReason.Exception;
|
||||
}
|
||||
ActiveGuestThreadYieldRequested = false;
|
||||
ActiveGuestThreadYieldReason = null;
|
||||
try
|
||||
{
|
||||
var nativeReturn = CallNativeEntry(ptr);
|
||||
// TBB execute-AV recover needs native-worker TLS (eligible/done).
|
||||
// Other guests stay on CallNativeEntry — full native-worker migration
|
||||
// increased splash hangs / UnmanagedCallersOnly (tLTN/tLTO).
|
||||
int nativeReturn;
|
||||
if (name == "tbb_thead")
|
||||
{
|
||||
nativeReturn = RunGuestEntryStub(ptr, hostRspSlot, requireNativeWorker: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot))
|
||||
{
|
||||
reason = "failed to bind host-RSP storage for guest thread stub";
|
||||
return GuestNativeCallExitReason.Exception;
|
||||
}
|
||||
nativeReturn = CallNativeEntry(ptr);
|
||||
}
|
||||
if (ActiveGuestThreadYieldRequested)
|
||||
{
|
||||
reason = ActiveGuestThreadYieldReason ?? "guest thread blocked";
|
||||
@@ -5222,16 +5899,24 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
return GuestNativeCallExitReason.Exception;
|
||||
}
|
||||
FlushInstructionCache(GetCurrentProcess(), ptr, stubSize);
|
||||
if (!TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot))
|
||||
{
|
||||
reason = "failed to bind host-RSP storage for guest continuation stub";
|
||||
return GuestNativeCallExitReason.Exception;
|
||||
}
|
||||
ActiveGuestThreadYieldRequested = false;
|
||||
ActiveGuestThreadYieldReason = null;
|
||||
try
|
||||
{
|
||||
var nativeReturn = CallNativeEntry(ptr);
|
||||
int nativeReturn;
|
||||
if (name == "tbb_thead")
|
||||
{
|
||||
nativeReturn = RunGuestEntryStub(ptr, hostRspSlot, requireNativeWorker: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot))
|
||||
{
|
||||
reason = "failed to bind host-RSP storage for guest continuation stub";
|
||||
return GuestNativeCallExitReason.Exception;
|
||||
}
|
||||
nativeReturn = CallNativeEntry(ptr);
|
||||
}
|
||||
if (ActiveGuestThreadYieldRequested)
|
||||
{
|
||||
reason = ActiveGuestThreadYieldReason ?? "guest thread blocked";
|
||||
@@ -6364,6 +7049,16 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
VirtualFree((void*)_hostRspSlotStorage, 0u, 32768u);
|
||||
_hostRspSlotStorage = 0;
|
||||
}
|
||||
if (_vehManagedEntryLock != 0)
|
||||
{
|
||||
VirtualFree((void*)_vehManagedEntryLock, 0u, 32768u);
|
||||
_vehManagedEntryLock = 0;
|
||||
}
|
||||
if (_workerAbortStack != 0)
|
||||
{
|
||||
VirtualFree((void*)_workerAbortStack, 0u, 32768u);
|
||||
_workerAbortStack = 0;
|
||||
}
|
||||
if (_guestTlsBaseTlsIndex != uint.MaxValue)
|
||||
{
|
||||
TlsFree(_guestTlsBaseTlsIndex);
|
||||
@@ -6374,6 +7069,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
TlsFree(_hostRspSlotTlsIndex);
|
||||
_hostRspSlotTlsIndex = uint.MaxValue;
|
||||
}
|
||||
if (_workerDoneEventTlsIndex != uint.MaxValue)
|
||||
{
|
||||
TlsFree(_workerDoneEventTlsIndex);
|
||||
_workerDoneEventTlsIndex = uint.MaxValue;
|
||||
}
|
||||
if (_unresolvedReturnStub != 0)
|
||||
{
|
||||
VirtualFree((void*)_unresolvedReturnStub, 0u, 32768u);
|
||||
@@ -6384,6 +7084,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
VirtualFree((void*)_guestReturnStub, 0u, 32768u);
|
||||
_guestReturnStub = 0;
|
||||
}
|
||||
if (_workerAbortStub != 0)
|
||||
{
|
||||
VirtualFree((void*)_workerAbortStub, 0u, 32768u);
|
||||
_workerAbortStub = 0;
|
||||
}
|
||||
if (_guestContextTransferStub != 0)
|
||||
{
|
||||
VirtualFree((void*)_guestContextTransferStub, 0u, 32768u);
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@ internal sealed unsafe partial class WindowsFaultHandling : IHostFaultHandling
|
||||
|
||||
public nint CreateHandlerThunk(nint managedCallback, uint hostRspSwitchTlsSlot, nint tlsGetValueAddress)
|
||||
{
|
||||
const uint stubSize = 256u;
|
||||
const uint stubSize = 1024u;
|
||||
void* ptr = (void*)_memory.Allocate(0, stubSize, HostPageProtection.ReadWriteExecute);
|
||||
if (ptr == null)
|
||||
{
|
||||
@@ -43,11 +43,15 @@ 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
|
||||
@@ -55,13 +59,162 @@ 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, 0xEB); EmitByte(code, ref offset, 0x03); // jmp over pass block
|
||||
EmitByte(code, ref offset, 0xE9); // jmp mainBody rel32 (FastFail breadcrumb sits between)
|
||||
var mainBodyJumpSlot = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
|
||||
int passOffset = offset;
|
||||
EmitByte(code, ref offset, 0x31); EmitByte(code, ref offset, 0xC0); // 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,6 +40,9 @@ public sealed class TrackedCpuMemory : ICpuMemory, ITrackedCpuMemory, IGuestMemo
|
||||
return result;
|
||||
}
|
||||
|
||||
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length) =>
|
||||
_inner.TryCopy(destinationAddress, sourceAddress, length);
|
||||
|
||||
public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address)
|
||||
{
|
||||
if (_inner is IGuestMemoryAllocator allocator)
|
||||
|
||||
@@ -199,9 +199,32 @@ public sealed class SelfLoader : ISelfLoader
|
||||
{
|
||||
if (!physicalVm.TryAllocateAtExact(imageBase, totalImageSize, executable: true, out var allocatedBase))
|
||||
{
|
||||
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
|
||||
throw new InvalidOperationException(
|
||||
$"Could not allocate main image at required base 0x{imageBase:X16} (size=0x{totalImageSize:X}): {reason}.");
|
||||
// Exact allocation failed — the host may have already claimed
|
||||
// part of this range (ASLR, Rosetta 2, or another process).
|
||||
// Try backing the fixed range page by page to claim whatever
|
||||
// free gaps exist. If the whole range is occupied the backfill
|
||||
// returns false and we surface the original failure reason.
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER] Exact allocation at main image base 0x{imageBase:X16} " +
|
||||
$"(size=0x{totalImageSize:X}) failed; attempting fixed-range backfill.");
|
||||
if (!physicalVm.TryBackFixedRange(imageBase, totalImageSize, executable: true))
|
||||
{
|
||||
// TryBackFixedRange may have partially backed pages before
|
||||
// failing. The earlier Clear() already reset all regions, so
|
||||
// this second Clear() is idempotent for everything except the
|
||||
// partial backfill — it frees only those orphaned pages.
|
||||
physicalVm.Clear();
|
||||
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
|
||||
throw new InvalidOperationException(
|
||||
$"Could not allocate main image at required base 0x{imageBase:X16} " +
|
||||
$"(size=0x{totalImageSize:X}): {reason}. " +
|
||||
"Try closing other applications, rebooting, or " +
|
||||
(OperatingSystem.IsWindows()
|
||||
? "setting SHARPEMU_DISABLE_MITIGATION_RELAUNCH=1."
|
||||
: "ensuring no other process maps into this address range."));
|
||||
}
|
||||
|
||||
allocatedBase = imageBase;
|
||||
}
|
||||
|
||||
imageBase = allocatedBase;
|
||||
@@ -714,8 +737,9 @@ public sealed class SelfLoader : ISelfLoader
|
||||
|
||||
importedRelocations = BuildImportedRelocations(descriptors);
|
||||
|
||||
var stubEligibleNids = CollectStubEligibleNids(descriptors, moduleManager);
|
||||
var stubImportNids = orderedImportNids
|
||||
.Where(nid => ShouldCreateImportStub(nid, descriptors, moduleManager))
|
||||
.Where(stubEligibleNids.Contains)
|
||||
.ToArray();
|
||||
var stubsByAddress = CreateImportStubMapping(virtualMemory, stubImportNids);
|
||||
Console.WriteLine($"[LOADER] Created {stubsByAddress.Count} import stubs");
|
||||
@@ -1160,6 +1184,35 @@ public sealed class SelfLoader : ISelfLoader
|
||||
isWeak);
|
||||
}
|
||||
|
||||
// Collects every NID that needs a trap import stub in a single pass over the
|
||||
// descriptors. This mirrors ShouldCreateImportStub applied per NID, but avoids
|
||||
// the O(nids * descriptors) rescan that filtering each unique NID against the
|
||||
// full descriptor list would incur on large modules. A NID qualifies as soon as
|
||||
// one of its descriptors is non-weak, or is weak but resolvable via the module
|
||||
// manager.
|
||||
private static HashSet<string> CollectStubEligibleNids(
|
||||
IReadOnlyList<RelocationDescriptor> descriptors,
|
||||
IModuleManager? moduleManager)
|
||||
{
|
||||
var eligible = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < descriptors.Count; i++)
|
||||
{
|
||||
var descriptor = descriptors[i];
|
||||
var nid = descriptor.ImportNid;
|
||||
if (nid is null || eligible.Contains(nid))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!descriptor.IsWeak || moduleManager?.TryGetExport(nid, out _) == true)
|
||||
{
|
||||
eligible.Add(nid);
|
||||
}
|
||||
}
|
||||
|
||||
return eligible;
|
||||
}
|
||||
|
||||
private static bool ShouldCreateImportStub(
|
||||
string nid,
|
||||
IReadOnlyList<RelocationDescriptor> descriptors,
|
||||
@@ -2431,6 +2484,19 @@ public sealed class SelfLoader : ISelfLoader
|
||||
Debug.Assert(
|
||||
!ShouldCreateImportStub("weak", [weak], moduleManager: null),
|
||||
"An unresolved weak symbol incorrectly received a trap import stub.");
|
||||
|
||||
var strong = new RelocationDescriptor(
|
||||
TargetAddress: 0x3000,
|
||||
Addend: 0,
|
||||
ImportNid: "strong",
|
||||
SymbolValue: 0,
|
||||
RelocationValueKind.Pointer,
|
||||
IsDataImport: false);
|
||||
var mixed = new List<RelocationDescriptor> { weak, strong };
|
||||
var eligible = CollectStubEligibleNids(mixed, moduleManager: null);
|
||||
Debug.Assert(
|
||||
eligible.Contains("strong") && !eligible.Contains("weak"),
|
||||
"CollectStubEligibleNids disagreed with the per-NID stub eligibility rule.");
|
||||
}
|
||||
|
||||
private static ulong AlignUp(ulong value, ulong alignment)
|
||||
|
||||
@@ -20,6 +20,11 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
private readonly Dictionary<(ulong DesiredAddress, ulong Alignment, bool Executable), ulong> _allocationSearchHints = new();
|
||||
private readonly Dictionary<ulong, ProgramHeaderFlags> _pageProtections = new();
|
||||
private bool _disposed;
|
||||
|
||||
[ThreadStatic]
|
||||
private static CommittedRangeCache? _committedRangeCache;
|
||||
|
||||
private long _mappingGeneration;
|
||||
private const ulong PageSize = 0x1000;
|
||||
private const ulong GuestAllocationArenaAddress = 0x00006000_0000_0000;
|
||||
private const ulong GuestAllocationArenaSize = 0x0100_0000;
|
||||
@@ -28,6 +33,77 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
private const ulong FullCommitRegionLimit = 4UL << 30;
|
||||
private const ulong DefaultLazyReservePrimeBytes = 0x0400_0000UL; // 64 MiB
|
||||
private const ulong LazyReservePrimeChunkBytes = 0x0200_0000UL; // 32 MiB
|
||||
private const int CommittedRangeCacheCapacity = 4;
|
||||
|
||||
private sealed class CommittedRangeCache
|
||||
{
|
||||
private readonly CommittedRange[] _ranges = new CommittedRange[CommittedRangeCacheCapacity];
|
||||
private PhysicalVirtualMemory? _owner;
|
||||
private long _generation;
|
||||
private int _count;
|
||||
private int _nextReplacement;
|
||||
|
||||
public bool Contains(
|
||||
PhysicalVirtualMemory owner,
|
||||
long generation,
|
||||
ulong start,
|
||||
ulong end)
|
||||
{
|
||||
if (!ReferenceEquals(_owner, owner) || _generation != generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var index = 0; index < _count; index++)
|
||||
{
|
||||
var range = _ranges[index];
|
||||
if (start >= range.Start && end <= range.End)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Add(
|
||||
PhysicalVirtualMemory owner,
|
||||
long generation,
|
||||
ulong start,
|
||||
ulong end)
|
||||
{
|
||||
if (!ReferenceEquals(_owner, owner) || _generation != generation)
|
||||
{
|
||||
_owner = owner;
|
||||
_generation = generation;
|
||||
_count = 0;
|
||||
_nextReplacement = 0;
|
||||
}
|
||||
|
||||
for (var index = 0; index < _count; index++)
|
||||
{
|
||||
var range = _ranges[index];
|
||||
if (start <= range.End && end >= range.Start)
|
||||
{
|
||||
_ranges[index] = new CommittedRange(
|
||||
Math.Min(start, range.Start),
|
||||
Math.Max(end, range.End));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_count < _ranges.Length)
|
||||
{
|
||||
_ranges[_count++] = new CommittedRange(start, end);
|
||||
return;
|
||||
}
|
||||
|
||||
_ranges[_nextReplacement] = new CommittedRange(start, end);
|
||||
_nextReplacement = (_nextReplacement + 1) % _ranges.Length;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct CommittedRange(ulong Start, ulong End);
|
||||
|
||||
// Raw Windows PAGE_* values retained for the internal region/protection
|
||||
// bookkeeping: regions and saved old-protection values always carry the raw
|
||||
@@ -162,7 +238,22 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
var alignedSize = (size + 0xFFF) & ~0xFFFUL;
|
||||
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
||||
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
|
||||
var allowLazyReserve = !executable &&
|
||||
alignedSize >= LargeDataReserveThreshold &&
|
||||
alignedSize > FullCommitRegionLimit;
|
||||
|
||||
// Commit first so titles that walk guest memory via raw host pointers
|
||||
// (GTA post-RenderThread workers) keep fully backed pages. Fall back to
|
||||
// reserve-only + lazy commit only when a huge non-exec commit fails —
|
||||
// that is the Poppy / large-reservation path #608 was aiming for.
|
||||
var reservedOnly = false;
|
||||
var result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
|
||||
if (result == 0 && allowLazyReserve)
|
||||
{
|
||||
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
|
||||
reservedOnly = result != 0;
|
||||
}
|
||||
|
||||
if (result == 0)
|
||||
{
|
||||
return false;
|
||||
@@ -176,6 +267,8 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return false;
|
||||
}
|
||||
|
||||
var lazyPrimeState = reservedOnly ? PrimeLazyReserveRegion(actualAddress, alignedSize) : "n/a";
|
||||
|
||||
_gate.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
@@ -184,7 +277,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
VirtualAddress = actualAddress,
|
||||
Size = alignedSize,
|
||||
IsExecutable = executable,
|
||||
IsReservedOnly = false,
|
||||
IsReservedOnly = reservedOnly,
|
||||
Protection = protection
|
||||
});
|
||||
}
|
||||
@@ -193,8 +286,12 @@ 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)");
|
||||
var allocationKind = reservedOnly
|
||||
? "reserved data memory (lazy commit)"
|
||||
: (executable ? "executable memory" : "data memory");
|
||||
TraceVmem(
|
||||
$"Allocated exact {allocationKind}: 0x{actualAddress:X16} - 0x{actualAddress + alignedSize:X16} " +
|
||||
$"({alignedSize} bytes) lazy_prime={lazyPrimeState}");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -225,55 +322,44 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
||||
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
|
||||
var reservedOnly = false;
|
||||
var preferReserveOnly = !executable &&
|
||||
var allowLazyReserve = !executable &&
|
||||
alignedSize >= LargeDataReserveThreshold &&
|
||||
alignedSize > FullCommitRegionLimit;
|
||||
var reservedOnly = false;
|
||||
|
||||
ulong result = 0;
|
||||
if (preferReserveOnly)
|
||||
{
|
||||
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
|
||||
if (result == 0 && allowAlternative)
|
||||
{
|
||||
result = _hostMemory.Reserve(0, alignedSize, HostPageProtection.ReadWrite);
|
||||
}
|
||||
|
||||
if (result != 0)
|
||||
{
|
||||
reservedOnly = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (result == 0)
|
||||
{
|
||||
result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
|
||||
}
|
||||
// Prefer a full commit. Only fall back to reserve-only when a large
|
||||
// non-executable commit cannot be satisfied (see TryAllocateAtExact).
|
||||
ulong result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
|
||||
|
||||
if (result == 0)
|
||||
{
|
||||
if (!allowAlternative)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to allocate exact mapping at 0x{desiredAddress:X16} ({alignedSize} bytes)");
|
||||
}
|
||||
|
||||
TraceVmem($"Could not allocate at 0x{desiredAddress:X16}, trying any address...");
|
||||
result = _hostMemory.Allocate(0, alignedSize, hostProtection);
|
||||
|
||||
if (result == 0)
|
||||
{
|
||||
if (!executable)
|
||||
if (allowLazyReserve)
|
||||
{
|
||||
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
|
||||
if (result == 0 && allowAlternative)
|
||||
reservedOnly = result != 0;
|
||||
}
|
||||
|
||||
if (result == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to allocate exact mapping at 0x{desiredAddress:X16} ({alignedSize} bytes)");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
TraceVmem($"Could not allocate at 0x{desiredAddress:X16}, trying any address...");
|
||||
result = _hostMemory.Allocate(0, alignedSize, hostProtection);
|
||||
|
||||
if (result == 0 && allowLazyReserve)
|
||||
{
|
||||
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
|
||||
if (result == 0)
|
||||
{
|
||||
result = _hostMemory.Reserve(0, alignedSize, HostPageProtection.ReadWrite);
|
||||
}
|
||||
|
||||
if (result != 0)
|
||||
{
|
||||
reservedOnly = true;
|
||||
}
|
||||
reservedOnly = result != 0;
|
||||
}
|
||||
|
||||
if (result == 0)
|
||||
@@ -284,45 +370,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
|
||||
var actualAddress = result;
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
var lazyPrimeState = reservedOnly ? PrimeLazyReserveRegion(actualAddress, alignedSize) : "n/a";
|
||||
|
||||
_gate.EnterWriteLock();
|
||||
try
|
||||
@@ -349,6 +397,150 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return actualAddress;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Commits the leading slice of a reserve-only region so early guest touches
|
||||
/// succeed before on-demand <see cref="EnsureRangeCommitted"/> runs.
|
||||
/// </summary>
|
||||
private string PrimeLazyReserveRegion(ulong actualAddress, ulong alignedSize)
|
||||
{
|
||||
var primeBytes = Math.Min(alignedSize, LazyReservePrimeBytes);
|
||||
if (primeBytes == 0)
|
||||
{
|
||||
return "skip:0";
|
||||
}
|
||||
|
||||
ulong committedBytes = 0;
|
||||
while (committedBytes < primeBytes)
|
||||
{
|
||||
var remaining = primeBytes - committedBytes;
|
||||
var chunkBytes = Math.Min(remaining, LazyReservePrimeChunkBytes);
|
||||
var commitAddress = actualAddress + committedBytes;
|
||||
if (!_hostMemory.Commit(commitAddress, chunkBytes, HostPageProtection.ReadWrite))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
committedBytes += chunkBytes;
|
||||
}
|
||||
|
||||
if (committedBytes != 0)
|
||||
{
|
||||
var state = committedBytes == primeBytes
|
||||
? $"ok:{committedBytes:X}"
|
||||
: $"partial:{committedBytes:X}/{primeBytes:X}";
|
||||
TraceVmem($"Primed lazy region: 0x{actualAddress:X16} - 0x{actualAddress + committedBytes:X16} ({committedBytes} bytes)");
|
||||
return state;
|
||||
}
|
||||
|
||||
TraceVmem($"Failed to prime lazy region at 0x{actualAddress:X16} ({primeBytes} bytes), continuing with on-demand commit");
|
||||
return $"fail:{primeBytes:X}";
|
||||
}
|
||||
|
||||
public bool TryBackFixedRange(ulong address, ulong size, bool executable)
|
||||
{
|
||||
if (size == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var start = AlignDown(address, PageSize);
|
||||
var end = AlignUp(address + size, PageSize);
|
||||
if (end <= start)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
|
||||
|
||||
// Walk the range page-run by page-run. VirtualQuery reports the largest run
|
||||
// of same-state pages from the queried address, so a single query advances
|
||||
// us over whole free or occupied stretches. Only free stretches get backed;
|
||||
// stretches already reserved or committed by another allocation are left as
|
||||
// they are, which is exactly what a fixed mapping does on hardware.
|
||||
//
|
||||
// Because backing may span several disjoint free runs, allocations are
|
||||
// staged: host pages are reserved/committed first, and the corresponding
|
||||
// MemoryRegions are inserted only once every gap in the range has been
|
||||
// backed. If any gap fails to back, every earlier host allocation is freed
|
||||
// and no region is inserted, so the address space is left untouched.
|
||||
var stagedAllocations = new List<(ulong Address, ulong Size)>();
|
||||
|
||||
var cursor = start;
|
||||
while (cursor < end)
|
||||
{
|
||||
if (!_hostMemory.Query(cursor, out var info))
|
||||
{
|
||||
goto Rollback;
|
||||
}
|
||||
|
||||
var queriedEnd = info.RegionSize > ulong.MaxValue - info.BaseAddress
|
||||
? ulong.MaxValue
|
||||
: info.BaseAddress + info.RegionSize;
|
||||
var runEnd = Math.Min(end, queriedEnd);
|
||||
if (runEnd <= cursor)
|
||||
{
|
||||
goto Rollback;
|
||||
}
|
||||
|
||||
if (info.State == HostRegionState.Free)
|
||||
{
|
||||
var runSize = runEnd - cursor;
|
||||
var allocated = _hostMemory.Allocate(cursor, runSize, hostProtection);
|
||||
if (allocated != cursor)
|
||||
{
|
||||
if (allocated != 0)
|
||||
{
|
||||
_hostMemory.Free(allocated);
|
||||
}
|
||||
|
||||
goto Rollback;
|
||||
}
|
||||
|
||||
stagedAllocations.Add((cursor, runSize));
|
||||
TraceVmem($"Backed fixed range gap: 0x{cursor:X16} - 0x{runEnd:X16} ({runSize} bytes)");
|
||||
}
|
||||
|
||||
cursor = runEnd;
|
||||
}
|
||||
|
||||
if (stagedAllocations.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// All gaps backed successfully — insert regions in one batch.
|
||||
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
||||
_gate.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
foreach (var (gapAddress, gapSize) in stagedAllocations)
|
||||
{
|
||||
InsertRegionSorted(new MemoryRegion
|
||||
{
|
||||
VirtualAddress = gapAddress,
|
||||
Size = gapSize,
|
||||
IsExecutable = executable,
|
||||
IsReservedOnly = false,
|
||||
Protection = protection
|
||||
});
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.ExitWriteLock();
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
Rollback:
|
||||
foreach (var (gapAddress, _) in stagedAllocations)
|
||||
{
|
||||
_hostMemory.Free(gapAddress);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryAllocateAtOrAbove(
|
||||
ulong desiredAddress,
|
||||
ulong size,
|
||||
@@ -440,6 +632,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
_gate.ExitWriteLock();
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _mappingGeneration);
|
||||
_hostMemory.Free(address);
|
||||
}
|
||||
|
||||
@@ -611,6 +804,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
{
|
||||
_allocationSearchHints.Clear();
|
||||
}
|
||||
Interlocked.Increment(ref _mappingGeneration);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -919,6 +1113,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -944,6 +1139,68 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
}
|
||||
|
||||
private static void NotifyGuestWriteWatch(ulong virtualAddress, ReadOnlySpan<byte> source)
|
||||
{
|
||||
if (GuestWriteWatch.Armed)
|
||||
{
|
||||
GuestWriteWatch.Check(virtualAddress, source);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length)
|
||||
{
|
||||
if (length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (length > int.MaxValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match TryWrite's managed-write notification before touching an
|
||||
// identity-mapped guest page protected by the image tracker.
|
||||
GuestImageWriteTracker.NotifyManagedWrite(destinationAddress, length);
|
||||
|
||||
_gate.EnterReadLock();
|
||||
try
|
||||
{
|
||||
var sourceRegion = FindRegion(sourceAddress, length);
|
||||
var destinationRegion = FindRegion(destinationAddress, length);
|
||||
if (sourceRegion is null || destinationRegion is null ||
|
||||
!TryResolveRegionOffset(sourceAddress, length, sourceRegion, out var sourceOffset) ||
|
||||
!TryResolveRegionOffset(destinationAddress, length, destinationRegion, out var destinationOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sourcePointer = sourceRegion.VirtualAddress + sourceOffset;
|
||||
var destinationPointer = destinationRegion.VirtualAddress + destinationOffset;
|
||||
if ((sourceRegion.IsReservedOnly &&
|
||||
!EnsureRangeCommitted(sourcePointer, length, sourceRegion)) ||
|
||||
(destinationRegion.IsReservedOnly &&
|
||||
!EnsureRangeCommitted(destinationPointer, length, destinationRegion)) ||
|
||||
!CanReadWithoutProtectionChange(sourcePointer, length, sourceRegion) ||
|
||||
!CanWriteWithoutProtectionChange(destinationPointer, length, destinationRegion))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Span.CopyTo has memmove overlap semantics, so this allocation-free
|
||||
// path safely serves both libc memcpy and libc memmove.
|
||||
new ReadOnlySpan<byte>((void*)sourcePointer, checked((int)length)).CopyTo(
|
||||
new Span<byte>((void*)destinationPointer, checked((int)length)));
|
||||
NotifyGuestWriteWatch(
|
||||
destinationAddress,
|
||||
new ReadOnlySpan<byte>((void*)destinationPointer, checked((int)length)));
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryReadExclusive(ulong virtualAddress, Span<byte> destination)
|
||||
{
|
||||
var region = FindRegion(virtualAddress, (ulong)destination.Length);
|
||||
@@ -1016,6 +1273,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1040,6 +1298,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1059,12 +1318,26 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
try
|
||||
{
|
||||
var region = FindRegion(virtualAddress, 1);
|
||||
if (region is null ||
|
||||
(region.IsReservedOnly && !EnsureRangeCommitted(virtualAddress, 1, region)))
|
||||
if (region is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Raw host pointers are walked by native/JIT code without further
|
||||
// EnsureRangeCommitted calls. For reserve-only regions, commit a
|
||||
// leading working-set chunk from this address so the common case
|
||||
// does not immediately AV on the next page.
|
||||
if (region.IsReservedOnly)
|
||||
{
|
||||
var regionEnd = region.VirtualAddress + region.Size;
|
||||
var remaining = regionEnd > virtualAddress ? regionEnd - virtualAddress : 0;
|
||||
var commitBytes = Math.Min(remaining, LazyReservePrimeChunkBytes);
|
||||
if (commitBytes == 0 || !EnsureRangeCommitted(virtualAddress, commitBytes, region))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (void*)virtualAddress;
|
||||
}
|
||||
finally
|
||||
@@ -1281,6 +1554,12 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
var startPage = AlignDown(address, PageSize);
|
||||
var endPage = AlignUp(address + size, PageSize);
|
||||
var mappingGeneration = Volatile.Read(ref _mappingGeneration);
|
||||
var committedRangeCache = _committedRangeCache ??= new CommittedRangeCache();
|
||||
if (committedRangeCache.Contains(this, mappingGeneration, startPage, endPage))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
var commitProtection = GetCommitProtection(region);
|
||||
|
||||
var pageAddress = startPage;
|
||||
@@ -1302,6 +1581,9 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
if (info.State == HostRegionState.Committed)
|
||||
{
|
||||
// The host query proved this whole range is committed. Retain
|
||||
// that result instead of caching only the caller's small span.
|
||||
CacheCommittedRange(info.BaseAddress, queriedEnd, mappingGeneration);
|
||||
pageAddress = rangeEnd;
|
||||
continue;
|
||||
}
|
||||
@@ -1317,12 +1599,23 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return false;
|
||||
}
|
||||
|
||||
CacheCommittedRange(pageAddress, rangeEnd, mappingGeneration);
|
||||
pageAddress = rangeEnd;
|
||||
}
|
||||
|
||||
CacheCommittedRange(startPage, endPage, mappingGeneration);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void CacheCommittedRange(ulong startPage, ulong endPage, long mappingGeneration)
|
||||
{
|
||||
(_committedRangeCache ??= new CommittedRangeCache()).Add(
|
||||
this,
|
||||
mappingGeneration,
|
||||
startPage,
|
||||
endPage);
|
||||
}
|
||||
|
||||
private bool TryTemporarilyProtectForRead(
|
||||
ulong address,
|
||||
ulong size,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Core.Loader;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Core.Memory;
|
||||
|
||||
@@ -93,8 +94,14 @@ public sealed class VirtualMemory : IVirtualMemory
|
||||
}
|
||||
|
||||
CopyToRegions(virtualAddress, source, regionIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (GuestWriteWatch.Armed)
|
||||
{
|
||||
GuestWriteWatch.Check(virtualAddress, source);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryValidateRange(
|
||||
|
||||
@@ -248,7 +248,7 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
{
|
||||
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
|
||||
if (!CreateProcessW(
|
||||
exePath,
|
||||
null,
|
||||
commandLine,
|
||||
0,
|
||||
0,
|
||||
@@ -629,7 +629,7 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
|
||||
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CreateProcessW(string applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
|
||||
private static extern bool CreateProcessW(string? applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern uint WaitForSingleObject(nint handle, uint milliseconds);
|
||||
|
||||
@@ -351,6 +351,13 @@ public sealed class GameSurfaceHost : NativeControlHost
|
||||
var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale));
|
||||
var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale));
|
||||
var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height;
|
||||
if (Environment.GetEnvironmentVariable("SHARPEMU_TRACE_SURFACE_SIZE") == "1")
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[GUI][TRACE] GameSurfaceHost.UpdateSurfaceSize bounds={Bounds.Width}x{Bounds.Height} " +
|
||||
$"scale={renderScale} computed={width}x{height} changed={sizeChanged} " +
|
||||
$"prevSurface={_surface.PixelWidth}x{_surface.PixelHeight}");
|
||||
}
|
||||
_surface.UpdatePixelSize(width, height);
|
||||
|
||||
if (!sizeChanged)
|
||||
|
||||
@@ -53,6 +53,9 @@ public sealed class GuiSettings
|
||||
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
|
||||
public List<string> EnvironmentToggles { get; set; } = new();
|
||||
|
||||
/// <summary>Internal render resolution scale (1.0 = native, 0.5 = half).</summary>
|
||||
public double RenderResolutionScale { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Discord application ID used for Rich Presence; the default is the
|
||||
/// SharpEmu application. Override to rebrand what Discord shows as
|
||||
@@ -71,7 +74,7 @@ public sealed class GuiSettings
|
||||
if (File.Exists(SettingsPath))
|
||||
{
|
||||
var json = File.ReadAllText(SettingsPath);
|
||||
return JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
|
||||
return NormalizeFromJson(json);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -82,6 +85,39 @@ public sealed class GuiSettings
|
||||
return new GuiSettings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes settings and normalizes null references and null or empty list
|
||||
/// entries introduced by JSON. Empty scalar strings remain unchanged.
|
||||
/// </summary>
|
||||
internal static GuiSettings NormalizeFromJson(string json)
|
||||
{
|
||||
var settings = JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
|
||||
|
||||
settings.GameFolders = FilterNullOrEmpty(settings.GameFolders);
|
||||
settings.ExcludedGames = FilterNullOrEmpty(settings.ExcludedGames);
|
||||
settings.EnvironmentToggles = FilterNullOrEmpty(settings.EnvironmentToggles);
|
||||
settings.LogLevel ??= "Info";
|
||||
settings.Language ??= "en";
|
||||
settings.DiscordClientId ??= "1525606762248540221";
|
||||
if (settings.RenderResolutionScale <= 0 || settings.RenderResolutionScale > 2.0)
|
||||
{
|
||||
settings.RenderResolutionScale = 1.0;
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
// JSON can populate non-nullable lists with null references and entries.
|
||||
private static List<string> FilterNullOrEmpty(List<string>? source)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return source.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -400,6 +400,29 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="GraphicsTabItem" Header="Graphics" FontSize="15">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock x:Name="RenderingSectionTitle" Classes="sectionTitle" Text="RENDERING" />
|
||||
|
||||
<local:SettingRow x:Name="RenderResolutionRow" Label="Internal resolution"
|
||||
Description="Render offscreen targets below native resolution and upscale on present. Lower values trade image quality for GPU headroom; takes effect on next launch.">
|
||||
<ComboBox x:Name="RenderResolutionBox" Width="160" SelectedIndex="0"
|
||||
VerticalAlignment="Center" CornerRadius="8">
|
||||
<ComboBoxItem x:Name="RenderResolution100Item" Content="100% (native)" Tag="1.0" />
|
||||
<ComboBoxItem x:Name="RenderResolution75Item" Content="75%" Tag="0.75" />
|
||||
<ComboBoxItem x:Name="RenderResolution50Item" Content="50%" Tag="0.5" />
|
||||
<ComboBoxItem x:Name="RenderResolution25Item" Content="25%" Tag="0.25" />
|
||||
</ComboBox>
|
||||
</local:SettingRow>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="EnvTabItem" Header="Environment" FontSize="15">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
||||
|
||||
@@ -92,6 +92,11 @@ 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;
|
||||
@@ -150,8 +155,18 @@ public partial class MainWindow : Window
|
||||
};
|
||||
_libraryBlurTimer.Tick += (_, _) => AdvanceLibraryBlur();
|
||||
|
||||
Activated += (_, _) => UpdateSessionBarVisibility();
|
||||
Deactivated += (_, _) => SessionBarPopup.IsOpen = false;
|
||||
// Native popups float above every window on the desktop; they must
|
||||
// follow the launcher into the background or a minimized state.
|
||||
Activated += (_, _) =>
|
||||
{
|
||||
UpdateSessionBarVisibility();
|
||||
SessionLoadingPopup.IsOpen = _sessionLoadingActive;
|
||||
};
|
||||
Deactivated += (_, _) =>
|
||||
{
|
||||
SessionBarPopup.IsOpen = false;
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
};
|
||||
|
||||
TitleBar.PointerPressed += OnTitleBarPointerPressed;
|
||||
GameList.SelectionChanged += (_, _) => UpdateSelectedGame();
|
||||
@@ -177,6 +192,18 @@ public partial class MainWindow : Window
|
||||
// it is open already uses the new values.
|
||||
LogLevelBox.SelectionChanged += (_, _) => _settings.LogLevel = SelectedLogLevel();
|
||||
TraceImportsBox.ValueChanged += (_, _) => _settings.ImportTraceLimit = (int)(TraceImportsBox.Value ?? 0);
|
||||
RenderResolutionBox.SelectionChanged += (_, _) =>
|
||||
{
|
||||
if (RenderResolutionBox.SelectedItem is ComboBoxItem { Tag: string tag } &&
|
||||
double.TryParse(
|
||||
tag,
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var scale))
|
||||
{
|
||||
_settings.RenderResolutionScale = scale;
|
||||
}
|
||||
};
|
||||
StrictToggle.IsCheckedChanged += (_, _) => _settings.StrictDynlibResolution = StrictToggle.IsChecked == true;
|
||||
LogToFileToggle.IsCheckedChanged += (_, _) => _settings.LogToFile = LogToFileToggle.IsChecked == true;
|
||||
OverrideLogFileToggle.IsCheckedChanged += (_, _) =>
|
||||
@@ -414,6 +441,15 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isRunning || _isStopping)
|
||||
{
|
||||
// The game renders inside the launcher window, so the launcher
|
||||
// stays active while playing. The controller belongs to the game
|
||||
// then: no navigation, and Circle/B must never stop the session.
|
||||
_previousPadButtons = pad.Buttons;
|
||||
return;
|
||||
}
|
||||
|
||||
var shoulderPressed = pad.Buttons & ~_previousPadButtons;
|
||||
if ((shoulderPressed & HostGamepadButtons.L1) != 0)
|
||||
{
|
||||
@@ -463,11 +499,6 @@ public partial class MainWindow : Window
|
||||
LaunchSelected();
|
||||
}
|
||||
|
||||
if ((pressed & HostGamepadButtons.Circle) != 0)
|
||||
{
|
||||
StopEmulator();
|
||||
}
|
||||
|
||||
_previousPadButtons = pad.Buttons;
|
||||
}
|
||||
|
||||
@@ -850,6 +881,13 @@ public partial class MainWindow : Window
|
||||
_ => 2,
|
||||
};
|
||||
TraceImportsBox.Value = Math.Clamp(_settings.ImportTraceLimit, 0, 4096);
|
||||
RenderResolutionBox.SelectedIndex = _settings.RenderResolutionScale switch
|
||||
{
|
||||
>= 0.875 => 0,
|
||||
>= 0.625 => 1,
|
||||
>= 0.375 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
StrictToggle.IsChecked = _settings.StrictDynlibResolution;
|
||||
LogToFileToggle.IsChecked = _settings.LogToFile;
|
||||
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
|
||||
@@ -1626,13 +1664,23 @@ public partial class MainWindow : Window
|
||||
base.OnPropertyChanged(change);
|
||||
if (change.Property == WindowStateProperty)
|
||||
{
|
||||
// The XAML WindowState="Maximized" assignment raises this change
|
||||
// during InitializeComponent, before named controls are wired up.
|
||||
if (WindowState == WindowState.Minimized)
|
||||
{
|
||||
_sndPreview.Pause();
|
||||
if (SessionLoadingPopup is { } popup)
|
||||
{
|
||||
popup.IsOpen = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_sndPreview.Resume();
|
||||
if (SessionLoadingPopup is { } popup)
|
||||
{
|
||||
popup.IsOpen = _sessionLoadingActive;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1759,6 +1807,12 @@ public partial class MainWindow : Window
|
||||
_appliedEnvironmentVariables.Add(name);
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable(
|
||||
"SHARPEMU_RENDER_SCALE",
|
||||
_settings.RenderResolutionScale.ToString(
|
||||
"0.###",
|
||||
System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
if (SharpEmuLog.TryParseLevel(effective.LogLevel, out var logLevel))
|
||||
{
|
||||
SharpEmuLog.MinimumLevel = logLevel;
|
||||
@@ -2001,16 +2055,27 @@ 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;
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
HideSessionLoading();
|
||||
UpdateSessionBarVisibility();
|
||||
|
||||
// Defer so the layout pass from the margin change above settles first.
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (!_isRunning || _isStopping)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_gameSurfaceHost?.RefreshSurfaceSize();
|
||||
_gameSurfaceHost?.SetPresentationVisible(true);
|
||||
_gameSurfaceHost?.SetCursorAutoHide(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2109,7 +2174,7 @@ public partial class MainWindow : Window
|
||||
GameView.IsVisible = false;
|
||||
GameView.IsHitTestVisible = true;
|
||||
SessionBarPopup.IsOpen = false;
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
HideSessionLoading();
|
||||
AnimateLibraryBlur(0, clearWhenComplete: true);
|
||||
MainContent.Margin = new Thickness(32, 24, 32, 20);
|
||||
ContentToolbar.IsVisible = true;
|
||||
@@ -2193,7 +2258,14 @@ public partial class MainWindow : Window
|
||||
{
|
||||
SessionLoadingTitle.Text = title;
|
||||
SessionLoadingDetail.Text = detail;
|
||||
SessionLoadingPopup.IsOpen = true;
|
||||
_sessionLoadingActive = true;
|
||||
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
|
||||
}
|
||||
|
||||
private void HideSessionLoading()
|
||||
{
|
||||
_sessionLoadingActive = false;
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
}
|
||||
|
||||
private void ReturnToLibraryWhileStopping()
|
||||
|
||||
@@ -49,7 +49,7 @@ public sealed class PerGameSettings
|
||||
var path = PathFor(titleId);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return JsonSerializer.Deserialize<PerGameSettings>(File.ReadAllText(path), SerializerOptions);
|
||||
return NormalizeFromJson(File.ReadAllText(path));
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -59,6 +59,18 @@ public sealed class PerGameSettings
|
||||
return null;
|
||||
}
|
||||
|
||||
// A null list inherits global settings; only entries in a present list are sanitized.
|
||||
internal static PerGameSettings? NormalizeFromJson(string json)
|
||||
{
|
||||
var settings = JsonSerializer.Deserialize<PerGameSettings>(json, SerializerOptions);
|
||||
if (settings?.EnvironmentToggles is { } toggles)
|
||||
{
|
||||
settings.EnvironmentToggles = toggles.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
public void Save(string titleId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(titleId))
|
||||
|
||||
@@ -24,6 +24,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="SharpEmu.Libs.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="Avalonia.Desktop" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
@@ -30,8 +31,15 @@ public static unsafe class GuestImageWriteTracker
|
||||
public ulong End;
|
||||
public int Dirty;
|
||||
public int Armed;
|
||||
/// <summary>
|
||||
/// When false the range is watch-only: managed writes still dirty it via
|
||||
/// <see cref="NotifyManagedWrite"/>, but pages are never write-protected
|
||||
/// so native CPU stores do not fault.
|
||||
/// </summary>
|
||||
public bool Protect;
|
||||
public int FirstCpuWriteSeen;
|
||||
public int PendingFirstCpuWrite;
|
||||
public long WriteGeneration;
|
||||
public bool TraceLifetime;
|
||||
public long SourceSequence;
|
||||
public long FirstCpuWriteTraceSequence;
|
||||
@@ -79,8 +87,17 @@ public static unsafe class GuestImageWriteTracker
|
||||
|
||||
private static RangeSnapshot _rangeSnapshot = RangeSnapshot.Empty;
|
||||
|
||||
private static readonly bool _enabled = !OperatingSystem.IsWindows() &&
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
|
||||
// Windows defaults off: VirtualProtect fault sync still regresses titles
|
||||
// like Dead Cells / Demon's Souls. Opt in with SHARPEMU_GUEST_IMAGE_CPU_SYNC=1
|
||||
// when a title needs CPU-written guest planes (e.g. GTA intro). Linux/macOS
|
||||
// keep the historical opt-out (=0 disables).
|
||||
private static readonly bool _enabled =
|
||||
OperatingSystem.IsWindows()
|
||||
? string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC"),
|
||||
"1",
|
||||
StringComparison.Ordinal)
|
||||
: Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
|
||||
private static readonly (bool Wildcard, ulong[] Addresses) _lifetimeTraceFilter =
|
||||
ParseAddressList(Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_ADDRS"));
|
||||
private static readonly (bool Wildcard, string[] Sources) _lifetimeSourceTraceFilter =
|
||||
@@ -94,14 +111,67 @@ public static unsafe class GuestImageWriteTracker
|
||||
_enabled && _lifetimeTraceEnabled ? GetMonotonicNanoseconds() : 0;
|
||||
private static long _lifetimeTraceSequence;
|
||||
|
||||
private const uint PageReadonly = 0x02;
|
||||
private const uint PageReadWrite = 0x04;
|
||||
|
||||
[DllImport("libc", EntryPoint = "mprotect", SetLastError = true)]
|
||||
private static extern int Mprotect(nint address, nuint length, int protection);
|
||||
|
||||
[DllImport("libc", EntryPoint = "clock_gettime", SetLastError = false)]
|
||||
private static extern int ClockGetTime(int clockId, Timespec* time);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern int VirtualProtect(
|
||||
nint lpAddress,
|
||||
nuint dwSize,
|
||||
uint flNewProtect,
|
||||
out uint lpflOldProtect);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern nint VirtualAlloc(
|
||||
nint lpAddress,
|
||||
nuint dwSize,
|
||||
uint flAllocationType,
|
||||
uint flProtect);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern int VirtualFree(nint lpAddress, nuint dwSize, uint dwFreeType);
|
||||
|
||||
private const uint MemCommit = 0x1000;
|
||||
private const uint MemReserve = 0x2000;
|
||||
private const uint MemRelease = 0x8000;
|
||||
|
||||
public static bool Enabled => _enabled;
|
||||
|
||||
/// <summary>
|
||||
/// Test/diagnostics helper: whether <paramref name="address"/> is tracked
|
||||
/// with write protection armed (watch-only ranges report protect=false).
|
||||
/// </summary>
|
||||
public static bool TryGetProtectionState(
|
||||
ulong address,
|
||||
out bool protect,
|
||||
out bool armed)
|
||||
{
|
||||
protect = false;
|
||||
armed = false;
|
||||
if (!_enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_rangesByAddress.TryGetValue(address, out var range))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
protect = range.Protect;
|
||||
armed = Volatile.Read(ref range.Armed) != 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the fault-handling path once outside signal context so every
|
||||
/// branch is JIT-compiled (and, under Rosetta 2, translated) before a real
|
||||
@@ -114,7 +184,17 @@ public static unsafe class GuestImageWriteTracker
|
||||
return;
|
||||
}
|
||||
|
||||
var scratch = NativeMemory.AllocZeroed(4096);
|
||||
// VirtualProtect only belongs on VirtualAlloc/mmap pages. Warming on
|
||||
// CRT heap memory makes neighbouring heap metadata read-only and
|
||||
// crashes the process on Windows.
|
||||
var scratch = OperatingSystem.IsWindows()
|
||||
? VirtualAlloc(0, 4096, MemCommit | MemReserve, PageReadWrite)
|
||||
: (nint)NativeMemory.AllocZeroed(4096);
|
||||
if (scratch == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Warm the timestamp P/Invoke used by the signal-safe scalar
|
||||
@@ -128,16 +208,29 @@ public static unsafe class GuestImageWriteTracker
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMemory.Free(scratch);
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
_ = VirtualFree(scratch, 0, MemRelease);
|
||||
}
|
||||
else
|
||||
{
|
||||
NativeMemory.Free((void*)scratch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Registers a range and arms write protection on it.</summary>
|
||||
/// <summary>
|
||||
/// Registers a range. When <paramref name="protect"/> is true, arms write
|
||||
/// protection so native stores fault and mark the range dirty. When false,
|
||||
/// the range is watch-only (managed HLE writes still dirty via
|
||||
/// <see cref="NotifyManagedWrite"/>) and never <c>VirtualProtect</c>'d.
|
||||
/// </summary>
|
||||
public static void Track(
|
||||
ulong address,
|
||||
ulong byteCount,
|
||||
long sourceSequence = 0,
|
||||
string source = "unspecified")
|
||||
string source = "unspecified",
|
||||
bool protect = true)
|
||||
{
|
||||
if (!_enabled || address == 0 || byteCount == 0)
|
||||
{
|
||||
@@ -155,10 +248,23 @@ public static unsafe class GuestImageWriteTracker
|
||||
{
|
||||
// Never resize an object that is still reachable from the
|
||||
// signal handler's lock-free snapshot. Retire it and publish
|
||||
// a fresh immutable range.
|
||||
// a fresh immutable range, carrying the write generation so
|
||||
// resizes do not hide guest CPU rewrites from cache owners.
|
||||
var writeGeneration = Volatile.Read(ref range.WriteGeneration);
|
||||
var keepProtect = range.Protect || protect;
|
||||
DisarmLocked(range, "replace-range");
|
||||
_rangesByAddress.Remove(address);
|
||||
range = null;
|
||||
range = new TrackedRange
|
||||
{
|
||||
Address = address,
|
||||
ByteCount = byteCount,
|
||||
Start = start,
|
||||
End = start + length,
|
||||
Protect = keepProtect,
|
||||
WriteGeneration = writeGeneration,
|
||||
};
|
||||
_rangesByAddress[address] = range;
|
||||
RebuildSnapshotLocked();
|
||||
}
|
||||
|
||||
if (range is null)
|
||||
@@ -169,6 +275,7 @@ public static unsafe class GuestImageWriteTracker
|
||||
ByteCount = byteCount,
|
||||
Start = start,
|
||||
End = start + length,
|
||||
Protect = protect,
|
||||
TraceLifetime =
|
||||
ShouldTraceRange(start, start + length) || ShouldTraceSource(source),
|
||||
SourceSequence = sourceSequence,
|
||||
@@ -180,13 +287,22 @@ public static unsafe class GuestImageWriteTracker
|
||||
else
|
||||
{
|
||||
FlushPendingFirstCpuWrite(range);
|
||||
// Protect is sticky: a later watch-only Track (texture cache)
|
||||
// must not disarm an RT that already needs page faults.
|
||||
if (protect && !range.Protect)
|
||||
{
|
||||
range.Protect = true;
|
||||
}
|
||||
}
|
||||
|
||||
range.SourceSequence = sourceSequence;
|
||||
range.Source = source;
|
||||
range.TraceLifetime =
|
||||
ShouldTraceRange(range.Start, range.End) || ShouldTraceSource(source);
|
||||
ArmLocked(range, "arm");
|
||||
if (range.Protect)
|
||||
{
|
||||
ArmLocked(range, "arm");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,13 +381,39 @@ public static unsafe class GuestImageWriteTracker
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_rangesByAddress.TryGetValue(address, out var range))
|
||||
if (_rangesByAddress.TryGetValue(address, out var range) &&
|
||||
range.Protect)
|
||||
{
|
||||
ArmLocked(range, "rearm");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the monotonic first-write generation for a tracked allocation.
|
||||
/// Unlike the consuming dirty flag, this remains changed after another
|
||||
/// cache owner consumes and re-arms the range.
|
||||
/// </summary>
|
||||
public static bool TryGetWriteGeneration(ulong address, out long generation)
|
||||
{
|
||||
generation = 0;
|
||||
if (!_enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_rangesByAddress.TryGetValue(address, out var range))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
generation = Volatile.Read(ref range.WriteGeneration);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prepares pages touched by a managed HLE memory write. Native guest
|
||||
/// stores fault and enter <see cref="TryHandleWriteFault"/> through the
|
||||
@@ -408,10 +550,7 @@ public static unsafe class GuestImageWriteTracker
|
||||
}
|
||||
|
||||
if (needsUnprotect &&
|
||||
Mprotect(
|
||||
(nint)writableStart,
|
||||
(nuint)(writableEnd - writableStart),
|
||||
ProtRead | ProtWrite) != 0)
|
||||
!TrySetProtection(writableStart, writableEnd - writableStart, writable: true))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -425,6 +564,14 @@ public static unsafe class GuestImageWriteTracker
|
||||
}
|
||||
|
||||
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) != 0;
|
||||
var wasDirty = Interlocked.Exchange(ref range.Dirty, 1) != 0;
|
||||
// Protected ranges bump generation once per arm/fault cycle.
|
||||
// Watch-only ranges never arm, so bump on the first dirty mark
|
||||
// (NotifyManagedWrite) so cache owners still see a rewrite.
|
||||
if (wasArmed || (!range.Protect && !wasDirty))
|
||||
{
|
||||
Interlocked.Increment(ref range.WriteGeneration);
|
||||
}
|
||||
if (wasArmed &&
|
||||
range.TraceLifetime &&
|
||||
Interlocked.CompareExchange(ref range.FirstCpuWriteSeen, 1, 0) == 0)
|
||||
@@ -439,8 +586,6 @@ public static unsafe class GuestImageWriteTracker
|
||||
Volatile.Write(ref range.PendingFirstCpuWrite, 1);
|
||||
Volatile.Write(ref range.FirstCpuWriteSeen, 2);
|
||||
}
|
||||
|
||||
Volatile.Write(ref range.Dirty, 1);
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -456,10 +601,7 @@ public static unsafe class GuestImageWriteTracker
|
||||
|
||||
// A new publication/rearm starts a new first-write lifetime.
|
||||
Volatile.Write(ref range.FirstCpuWriteSeen, 0);
|
||||
var failed = Mprotect(
|
||||
(nint)range.Start,
|
||||
(nuint)(range.End - range.Start),
|
||||
ProtRead) != 0;
|
||||
var failed = !TrySetProtection(range.Start, range.End - range.Start, writable: false);
|
||||
if (failed)
|
||||
{
|
||||
Volatile.Write(ref range.Armed, 0);
|
||||
@@ -479,10 +621,7 @@ public static unsafe class GuestImageWriteTracker
|
||||
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) == 1;
|
||||
if (wasArmed)
|
||||
{
|
||||
_ = Mprotect(
|
||||
(nint)range.Start,
|
||||
(nuint)(range.End - range.Start),
|
||||
ProtRead | ProtWrite);
|
||||
_ = TrySetProtection(range.Start, range.End - range.Start, writable: true);
|
||||
}
|
||||
|
||||
if (range.TraceLifetime)
|
||||
@@ -493,7 +632,13 @@ public static unsafe class GuestImageWriteTracker
|
||||
|
||||
private static void RebuildSnapshotLocked()
|
||||
{
|
||||
Volatile.Write(ref _rangeSnapshot, new RangeSnapshot(_rangesByAddress.Values.ToArray()));
|
||||
// Fault / NotifyManagedWrite hot paths must only see protected ranges.
|
||||
// Watch-only texture-cache registrations used to widen Start..End across
|
||||
// nearly all GPU memory so every managed guest write walked this path.
|
||||
var protectedRanges = _rangesByAddress.Values
|
||||
.Where(static range => range.Protect)
|
||||
.ToArray();
|
||||
Volatile.Write(ref _rangeSnapshot, new RangeSnapshot(protectedRanges));
|
||||
}
|
||||
|
||||
private static (ulong Start, ulong Length) PageAlign(ulong address, ulong byteCount)
|
||||
@@ -638,8 +783,35 @@ public static unsafe class GuestImageWriteTracker
|
||||
$"fault=0x{faultAddress:X16} page=0x{faultPage:X16}");
|
||||
}
|
||||
|
||||
private static bool TrySetProtection(ulong start, ulong length, bool writable)
|
||||
{
|
||||
if (length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return VirtualProtect(
|
||||
(nint)start,
|
||||
(nuint)length,
|
||||
writable ? PageReadWrite : PageReadonly,
|
||||
out _) != 0;
|
||||
}
|
||||
|
||||
return Mprotect(
|
||||
(nint)start,
|
||||
(nuint)length,
|
||||
writable ? ProtRead | ProtWrite : ProtRead) == 0;
|
||||
}
|
||||
|
||||
private static long GetMonotonicNanoseconds()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return Stopwatch.GetTimestamp() * 1_000_000_000L / Stopwatch.Frequency;
|
||||
}
|
||||
|
||||
Timespec time;
|
||||
return ClockGetTime(ClockMonotonicRaw, &time) == 0
|
||||
? unchecked((time.Seconds * 1_000_000_000L) + time.Nanoseconds)
|
||||
|
||||
@@ -221,6 +221,29 @@ 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 = 0x10000UL;
|
||||
public const ulong StartupStaticTlsReservation = 0x20000UL; // Was 0x10000UL, but thats too small for GTA V
|
||||
private static readonly object _gate = new();
|
||||
private static readonly SortedDictionary<ulong, ModuleTemplate> _modules = new();
|
||||
private static readonly Dictionary<ulong, ThreadDtv> _threadDtvs = new();
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
|
||||
namespace SharpEmu.HLE;
|
||||
|
||||
// This tool monitors guest-memory writes only when a watch mode is active.
|
||||
public static class GuestWriteWatch
|
||||
{
|
||||
private const ulong WatchBytes = 8;
|
||||
private const int MaxBulkReports = 64;
|
||||
|
||||
private static readonly ulong WatchBase = Parse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_WATCH_WRITE"));
|
||||
|
||||
private static readonly bool WatchPoolHeaders = IsEnabled("SHARPEMU_WATCH_POOL_HEADER");
|
||||
|
||||
private static readonly ulong[] PoolSlots = new ulong[64];
|
||||
private static int _poolSlotCount;
|
||||
|
||||
private static readonly bool WatchValuePattern = IsEnabled("SHARPEMU_WATCH_VALUE_PATTERN");
|
||||
|
||||
private static readonly bool WatchValue1 = IsEnabled("SHARPEMU_WATCH_VALUE1");
|
||||
|
||||
private const ulong DirectBandLow = 0x100_0000_0000;
|
||||
private const ulong DirectBandHigh = 0x1000_0000_0000;
|
||||
private static int _value1Reports;
|
||||
|
||||
private static readonly bool WatchBulkTorn = IsEnabled("SHARPEMU_WATCH_BULK_TORN");
|
||||
|
||||
private static readonly ulong BulkDestHigh = Parse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_WATCH_BULK_DEST_HI"));
|
||||
private static int _bulkTornReports;
|
||||
private static int _bulkShiftReports;
|
||||
|
||||
public static bool Armed =>
|
||||
WatchBase != 0 || WatchPoolHeaders || WatchValuePattern || WatchValue1 || WatchBulkTorn;
|
||||
|
||||
public static void OnDirectMapping(ulong mappedAddress, ulong length, int protection)
|
||||
{
|
||||
if (!WatchPoolHeaders || !IsPoolMapping(length, protection))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var index = Interlocked.Increment(ref _poolSlotCount) - 1;
|
||||
if (index < PoolSlots.Length)
|
||||
{
|
||||
Volatile.Write(ref PoolSlots[index], mappedAddress + 0x40);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] watch_write armed on pool header slot 0x{mappedAddress + 0x40:X16}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void Check(ulong address, ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (WatchBulkTorn &&
|
||||
data.Length >= 8 &&
|
||||
(BulkDestHigh != 0
|
||||
? (address >> 32) == BulkDestHigh
|
||||
: address >= DirectBandLow && address < DirectBandHigh))
|
||||
{
|
||||
for (var offset = FirstAlignedOffset(address); offset + 8 <= data.Length; offset += 8)
|
||||
{
|
||||
var qword = BinaryPrimitives.ReadUInt64LittleEndian(data.Slice(offset, 8));
|
||||
var kind = ClassifyBulkValue(qword);
|
||||
if (kind is not null && ReserveBulkReport(kind))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] watch_bulk_torn HIT ({kind}) " +
|
||||
$"dest=0x{address + (ulong)offset:X16} (base=0x{address:X16}+0x{offset:X}) " +
|
||||
$"len={data.Length} qword=0x{qword:X16}{Environment.NewLine}{Environment.StackTrace}");
|
||||
Console.Error.Flush();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (WatchValue1 &&
|
||||
address >= DirectBandLow && address < DirectBandHigh &&
|
||||
data.Length is >= 1 and <= 8 &&
|
||||
LittleEndianValue(data) == 1 &&
|
||||
Interlocked.Increment(ref _value1Reports) <= 128)
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (WatchValuePattern && data.Length == 8)
|
||||
{
|
||||
var value = BinaryPrimitives.ReadUInt64LittleEndian(data);
|
||||
if ((value & 0xFFFFFFFF) == 1 && value >> 32 is > 0 and <= 0xFFFF)
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (WatchBase != 0 && Overlaps(address, data.Length, WatchBase))
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
|
||||
var slots = Math.Min(Volatile.Read(ref _poolSlotCount), PoolSlots.Length);
|
||||
for (var i = 0; i < slots; i++)
|
||||
{
|
||||
var slot = Volatile.Read(ref PoolSlots[i]);
|
||||
if (slot != 0 && Overlaps(address, data.Length, slot))
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static string? ClassifyBulkValue(ulong qword)
|
||||
{
|
||||
var low32 = qword & 0xFFFFFFFF;
|
||||
var high32 = qword >> 32;
|
||||
if (low32 == 1 && high32 is > 0 and <= 0xFFFF)
|
||||
{
|
||||
return "torn";
|
||||
}
|
||||
|
||||
var prefix = low32 & 0xFF00_0000;
|
||||
var hasShiftedPointerPrefix = prefix is 0x0800_0000 or 0x8000_0000;
|
||||
return high32 == 0 && hasShiftedPointerPrefix && (low32 & 0xFF) == 0
|
||||
? "shift"
|
||||
: null;
|
||||
}
|
||||
|
||||
internal static int FirstAlignedOffset(ulong address) =>
|
||||
(int)((8 - (address & 7)) & 7);
|
||||
|
||||
internal static bool IsPoolMapping(ulong length, int protection) =>
|
||||
length == 0x10000 && protection == 0xF2;
|
||||
|
||||
internal static bool Overlaps(ulong address, int length, ulong slot)
|
||||
{
|
||||
if (length <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var writeLength = (ulong)length - 1;
|
||||
var writeEnd = address > ulong.MaxValue - writeLength
|
||||
? ulong.MaxValue
|
||||
: address + writeLength;
|
||||
var slotEnd = slot > ulong.MaxValue - (WatchBytes - 1)
|
||||
? ulong.MaxValue
|
||||
: slot + WatchBytes - 1;
|
||||
return address <= slotEnd && slot <= writeEnd;
|
||||
}
|
||||
|
||||
private static ulong LittleEndianValue(ReadOnlySpan<byte> data)
|
||||
{
|
||||
ulong value = 0;
|
||||
for (var i = 0; i < data.Length; i++)
|
||||
{
|
||||
value |= (ulong)data[i] << (i * 8);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void Report(ulong address, ReadOnlySpan<byte> data)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] watch_write HIT addr=0x{address:X16} len={data.Length} " +
|
||||
$"first_qword=0x{LittleEndianValue(data):X16}{Environment.NewLine}{Environment.StackTrace}");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
|
||||
private static bool IsEnabled(string name) =>
|
||||
string.Equals(Environment.GetEnvironmentVariable(name), "1", StringComparison.Ordinal);
|
||||
|
||||
private static bool ReserveBulkReport(string kind) =>
|
||||
kind == "torn"
|
||||
? Interlocked.Increment(ref _bulkTornReports) <= MaxBulkReports
|
||||
: Interlocked.Increment(ref _bulkShiftReports) <= MaxBulkReports;
|
||||
|
||||
internal static ulong Parse(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
text = text.Trim();
|
||||
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
text = text[2..];
|
||||
}
|
||||
|
||||
return ulong.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value)
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
@@ -19,5 +19,11 @@ public interface IHostAudioOutput
|
||||
/// Throws when the host has no usable output device; callers degrade to a silent
|
||||
/// port and pace the guest instead.
|
||||
/// </summary>
|
||||
IHostAudioStream OpenStereoPcm16Stream(uint sampleRate);
|
||||
/// <param name="sampleRate">Host stream sample rate in Hz.</param>
|
||||
/// <param name="maxQueuedPcmBytes">
|
||||
/// Soft backpressure cap for queued stereo PCM16. Default 32 KiB (~171 ms at
|
||||
/// 48 kHz) matches classic AudioOut latency. Bursty AudioOut2 / FMOD feeders
|
||||
/// may pass a deeper cap to avoid underruns.
|
||||
/// </param>
|
||||
IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024);
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@ namespace SharpEmu.HLE.Host.Posix;
|
||||
/// </summary>
|
||||
internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
|
||||
{
|
||||
// 32KB of stereo PCM16 at 48kHz is ~170ms; keep the same device-side
|
||||
// queue depth the WinMM/CoreAudio ports enforce in managed code.
|
||||
private const uint DeviceLatencyMicroseconds = 170_000;
|
||||
private const int StreamPlayback = 0;
|
||||
private const int FormatS16LittleEndian = 2;
|
||||
private const int AccessReadWriteInterleaved = 3;
|
||||
@@ -27,7 +24,7 @@ internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
|
||||
private nint _pcm;
|
||||
private bool _disposed;
|
||||
|
||||
public PosixAlsaAudioStream(uint sampleRate)
|
||||
public PosixAlsaAudioStream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
@@ -47,6 +44,14 @@ internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
|
||||
$"snd_pcm_open(\"{device}\") failed: {DescribeError(status)}.");
|
||||
}
|
||||
|
||||
// Match WinMM/CoreAudio soft queue depth: 32 KiB stereo PCM16 @ 48 kHz
|
||||
// is ~170 ms. AudioOut2 may request a deeper bed.
|
||||
var queuedBytes = Math.Max(maxQueuedPcmBytes, 4 * 1024);
|
||||
var latencyMicroseconds = (uint)Math.Clamp(
|
||||
(long)queuedBytes * 1_000_000L / Math.Max(sampleRate * 4u, 1u),
|
||||
20_000L,
|
||||
2_000_000L);
|
||||
|
||||
status = snd_pcm_set_params(
|
||||
_pcm,
|
||||
FormatS16LittleEndian,
|
||||
@@ -54,7 +59,7 @@ internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream
|
||||
2,
|
||||
sampleRate,
|
||||
1,
|
||||
DeviceLatencyMicroseconds);
|
||||
latencyMicroseconds);
|
||||
if (status != 0)
|
||||
{
|
||||
_ = snd_pcm_close(_pcm);
|
||||
|
||||
@@ -13,11 +13,11 @@ namespace SharpEmu.HLE.Host.Posix;
|
||||
/// </summary>
|
||||
internal sealed unsafe class PosixCoreAudioStream : IHostAudioStream
|
||||
{
|
||||
private const int MaximumQueuedPcmBytes = 32 * 1024;
|
||||
private const uint FormatLinearPcm = 0x6C70636D; // 'lpcm'
|
||||
private const uint FlagIsSignedInteger = 0x4;
|
||||
private const uint FlagIsPacked = 0x8;
|
||||
|
||||
private readonly int _maximumQueuedPcmBytes;
|
||||
private readonly object _gate = new();
|
||||
private readonly AutoResetEvent _completion = new(false);
|
||||
private readonly Queue<nint> _freeBuffers = new();
|
||||
@@ -27,13 +27,15 @@ internal sealed unsafe class PosixCoreAudioStream : IHostAudioStream
|
||||
private bool _started;
|
||||
private bool _disposed;
|
||||
|
||||
public PosixCoreAudioStream(uint sampleRate)
|
||||
public PosixCoreAudioStream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
|
||||
{
|
||||
if (!OperatingSystem.IsMacOS())
|
||||
{
|
||||
throw new PlatformNotSupportedException("CoreAudio is only available on macOS.");
|
||||
}
|
||||
|
||||
_maximumQueuedPcmBytes = Math.Max(maxQueuedPcmBytes, 4 * 1024);
|
||||
|
||||
var format = new AudioStreamBasicDescription
|
||||
{
|
||||
SampleRate = sampleRate,
|
||||
@@ -73,7 +75,7 @@ internal sealed unsafe class PosixCoreAudioStream : IHostAudioStream
|
||||
|
||||
var outputLength = stereoPcm16.Length;
|
||||
while (_queuedPcmBytes != 0 &&
|
||||
_queuedPcmBytes + outputLength > MaximumQueuedPcmBytes)
|
||||
_queuedPcmBytes + outputLength > _maximumQueuedPcmBytes)
|
||||
{
|
||||
Monitor.Exit(_gate);
|
||||
try
|
||||
|
||||
@@ -12,10 +12,10 @@ internal sealed class PosixHostAudio : IHostAudioOutput
|
||||
{
|
||||
public string BackendName => OperatingSystem.IsMacOS() ? "coreaudio" : "alsa";
|
||||
|
||||
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate)
|
||||
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
|
||||
{
|
||||
return OperatingSystem.IsMacOS()
|
||||
? new PosixCoreAudioStream(sampleRate)
|
||||
: new PosixAlsaAudioStream(sampleRate);
|
||||
? new PosixCoreAudioStream(sampleRate, maxQueuedPcmBytes)
|
||||
: new PosixAlsaAudioStream(sampleRate, maxQueuedPcmBytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ internal sealed partial class WindowsWaveOutAudio : IHostAudioOutput
|
||||
{
|
||||
public string BackendName => "winmm";
|
||||
|
||||
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate) => new WaveOutStream(sampleRate);
|
||||
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024) =>
|
||||
new WaveOutStream(sampleRate, maxQueuedPcmBytes);
|
||||
|
||||
private sealed partial class WaveOutStream : IHostAudioStream
|
||||
{
|
||||
@@ -17,8 +18,8 @@ internal sealed partial class WindowsWaveOutAudio : IHostAudioOutput
|
||||
private const uint CallbackEvent = 0x0005_0000;
|
||||
private const ushort WaveFormatPcm = 1;
|
||||
private const uint WaveHeaderDone = 0x0000_0001;
|
||||
private const int MaximumQueuedPcmBytes = 32 * 1024;
|
||||
|
||||
private readonly int _maximumQueuedPcmBytes;
|
||||
private readonly object _gate = new();
|
||||
private readonly AutoResetEvent _completion = new(false);
|
||||
private readonly Queue<NativeBuffer> _buffers = new();
|
||||
@@ -26,8 +27,9 @@ internal sealed partial class WindowsWaveOutAudio : IHostAudioOutput
|
||||
private int _queuedPcmBytes;
|
||||
private bool _disposed;
|
||||
|
||||
public WaveOutStream(uint sampleRate)
|
||||
public WaveOutStream(uint sampleRate, int maxQueuedPcmBytes)
|
||||
{
|
||||
_maximumQueuedPcmBytes = Math.Max(maxQueuedPcmBytes, 4 * 1024);
|
||||
var format = new WaveFormat
|
||||
{
|
||||
FormatTag = WaveFormatPcm,
|
||||
@@ -62,7 +64,7 @@ internal sealed partial class WindowsWaveOutAudio : IHostAudioOutput
|
||||
|
||||
ReapCompletedBuffers();
|
||||
while (_queuedPcmBytes != 0 &&
|
||||
_queuedPcmBytes + stereoPcm16.Length > MaximumQueuedPcmBytes)
|
||||
_queuedPcmBytes + stereoPcm16.Length > _maximumQueuedPcmBytes)
|
||||
{
|
||||
if (!_completion.WaitOne(TimeSpan.FromSeconds(1)))
|
||||
{
|
||||
|
||||
@@ -10,4 +10,6 @@ public interface ICpuMemory
|
||||
bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source);
|
||||
|
||||
bool TryCompare(ulong virtualAddress, ReadOnlySpan<byte> expected) => false;
|
||||
|
||||
bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length) => false;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,17 @@ public interface IGuestAddressSpace : IGuestMemoryAllocator
|
||||
{
|
||||
ulong AllocateAt(ulong desiredAddress, ulong size, bool executable = true, bool allowAlternative = true);
|
||||
|
||||
/// <summary>
|
||||
/// Backs an entire fixed-address range, matching the guest's
|
||||
/// <c>SCE_KERNEL_MAP_FIXED</c> contract. Unlike <see cref="AllocateAt"/>, which
|
||||
/// reserves the range in one all-or-nothing host call, this walks the range and
|
||||
/// fills only the sub-ranges that are not already backed. That keeps a fixed
|
||||
/// mapping whole when part of the requested window is already occupied — the
|
||||
/// partial-overlap case where the single-call reservation fails outright and
|
||||
/// leaves the remainder unmapped for the guest to fault into.
|
||||
/// </summary>
|
||||
bool TryBackFixedRange(ulong address, ulong size, bool executable);
|
||||
|
||||
bool TryAllocateAtOrAbove(ulong desiredAddress, ulong size, bool executable, ulong alignment, out ulong actualAddress);
|
||||
|
||||
bool TryProtect(ulong address, ulong size, GuestPageProtection protection);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Libs.Acm;
|
||||
|
||||
public static class AcmExports
|
||||
{
|
||||
private static int _nextContextHandle;
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "ZIXln2K3XMk",
|
||||
ExportName = "sceAcmContextCreate",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAcm")]
|
||||
public static int AcmContextCreate(CpuContext ctx)
|
||||
{
|
||||
var outContextAddress = ctx[CpuRegister.Rdi];
|
||||
if (outContextAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
var handle = (ulong)Interlocked.Increment(ref _nextContextHandle);
|
||||
Span<byte> handleBytes = stackalloc byte[sizeof(ulong)];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(handleBytes, handle);
|
||||
return ctx.Memory.TryWrite(outContextAddress, handleBytes)
|
||||
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "jBgBjAj02R8",
|
||||
ExportName = "sceAcmContextDestroy",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAcm")]
|
||||
public static int AcmContextDestroy(CpuContext ctx)
|
||||
{
|
||||
_ = ctx;
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace SharpEmu.Libs.Agc;
|
||||
|
||||
/// <summary>Prospero/AGC IndexType helpers (gpu_defs / renderDraw index8 expand).</summary>
|
||||
internal static class AgcIndexHelpers
|
||||
{
|
||||
internal enum ProsperoIndexType : uint
|
||||
{
|
||||
Index16 = 0,
|
||||
Index32 = 1,
|
||||
Index8 = 2,
|
||||
}
|
||||
|
||||
internal static ProsperoIndexType Decode(uint raw) =>
|
||||
(raw & 0x3u) switch
|
||||
{
|
||||
1 => ProsperoIndexType.Index32,
|
||||
2 => ProsperoIndexType.Index8,
|
||||
_ => ProsperoIndexType.Index16,
|
||||
};
|
||||
|
||||
internal static int GetGuestStrideBytes(ProsperoIndexType indexType) =>
|
||||
indexType switch
|
||||
{
|
||||
ProsperoIndexType.Index32 => sizeof(uint),
|
||||
ProsperoIndexType.Index8 => sizeof(byte),
|
||||
_ => sizeof(ushort),
|
||||
};
|
||||
|
||||
/// <summary>Expand guest u8 indices to host u16 (Vulkan/Metal bindable).</summary>
|
||||
internal static void ExpandIndex8ToU16(ReadOnlySpan<byte> source, Span<byte> destination)
|
||||
{
|
||||
if (destination.Length < source.Length * sizeof(ushort))
|
||||
{
|
||||
throw new ArgumentException("destination too small for u8->u16 expansion.");
|
||||
}
|
||||
|
||||
for (var index = 0; index < source.Length; index++)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(
|
||||
destination.Slice(index * sizeof(ushort), sizeof(ushort)),
|
||||
source[index]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Libs.Agc;
|
||||
|
||||
/// <summary>
|
||||
/// Shared Prospero/AGC primitive-type helpers (renderDraw / gpu_defs).
|
||||
/// </summary>
|
||||
internal static class AgcPrimitiveHelpers
|
||||
{
|
||||
internal const uint PrimitiveRectList = 7;
|
||||
internal const uint PrimitiveRectListLegacy = 0x11;
|
||||
|
||||
internal enum GsOutputPrimitiveType : uint
|
||||
{
|
||||
Points = 0,
|
||||
Lines = 1,
|
||||
Triangles = 2,
|
||||
Rectangle2D = 3,
|
||||
RectList = 4,
|
||||
}
|
||||
|
||||
internal static bool IsRectListPrimitive(uint primitiveType) =>
|
||||
primitiveType is PrimitiveRectList or PrimitiveRectListLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Maps draw prim type to VGT_GS_OUT_PRIM_TYPE when NGG is not enabled
|
||||
/// on the GS (GraphicsPrimitiveTypeToGsOut).
|
||||
/// </summary>
|
||||
internal static uint PrimitiveTypeToGsOut(uint primitiveType) =>
|
||||
primitiveType switch
|
||||
{
|
||||
1 => (uint)GsOutputPrimitiveType.Points, // PointList
|
||||
2 or 3 or 10 or 11 or 18 => (uint)GsOutputPrimitiveType.Lines,
|
||||
PrimitiveRectList => (uint)GsOutputPrimitiveType.Rectangle2D,
|
||||
PrimitiveRectListLegacy => (uint)GsOutputPrimitiveType.RectList,
|
||||
_ => (uint)GsOutputPrimitiveType.Triangles,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Rect-list auto-draw topology selection.
|
||||
/// NGG single-rect UI quads (DualSense prompts) submit count 1/3/4 and
|
||||
/// must become a 4-vert triangle strip — even when the VS has embedded
|
||||
/// vertex-buffer fetches (those still show up as host VBs). Indexed and
|
||||
/// larger auto counts stay triangle list so the loading video is safe.
|
||||
/// </summary>
|
||||
internal static bool ShouldDrawRectListAsTriangleStrip(
|
||||
uint primitiveType,
|
||||
bool indexed,
|
||||
uint vertexCount,
|
||||
bool hasVertexBuffers = false)
|
||||
{
|
||||
_ = hasVertexBuffers;
|
||||
if (indexed || !IsRectListPrimitive(primitiveType))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (primitiveType == PrimitiveRectListLegacy)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// NGG kRectList: strip for auto + ngg_rectlist_draw.
|
||||
// Restrict to the single-rect counts GTA UI actually submits.
|
||||
return vertexCount is 1 or 3 or 4;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Host vertex count for auto rect-list draws that expand to a strip.
|
||||
/// NGG single-rect: always 4. Legacy 0x11: 3 -> 4.
|
||||
/// </summary>
|
||||
internal static uint GetRectListDrawVertexCount(
|
||||
uint primitiveType,
|
||||
uint vertexCount,
|
||||
bool indexed,
|
||||
bool hasVertexBuffers = false)
|
||||
{
|
||||
if (!ShouldDrawRectListAsTriangleStrip(
|
||||
primitiveType,
|
||||
indexed,
|
||||
vertexCount,
|
||||
hasVertexBuffers))
|
||||
{
|
||||
return vertexCount;
|
||||
}
|
||||
|
||||
if (primitiveType == PrimitiveRectList)
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
|
||||
if (primitiveType == PrimitiveRectListLegacy && vertexCount == 3)
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
|
||||
return vertexCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy helper — prefer
|
||||
/// <see cref="ShouldDrawRectListAsTriangleStrip"/>.
|
||||
/// </summary>
|
||||
internal static bool IsRectListTriangleStrip(uint primitiveType) =>
|
||||
IsRectListPrimitive(primitiveType);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ internal static class AgcShaderCompilerHooks
|
||||
internal static void Install()
|
||||
{
|
||||
Gen5ShaderScalarEvaluator.FallbackMemoryReader =
|
||||
KernelMemoryCompatExports.TryReadTrackedLibcHeap;
|
||||
KernelMemoryCompatExports.TryReadShaderGuestMemory;
|
||||
Gen5ShaderScalarEvaluator.GlobalMemoryPool =
|
||||
GuestDataPool.Shared;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,788 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
|
||||
namespace SharpEmu.Libs.Agc;
|
||||
|
||||
/// <summary>
|
||||
/// AGC embedded vertex metadata. Locates
|
||||
/// PtrVertexBufferTable / PtrVertexAttribDescTable and builds authoritative
|
||||
/// attribute layouts that draw translation merges onto IR-discovered fetches.
|
||||
/// </summary>
|
||||
internal static class AgcVertexMetadata
|
||||
{
|
||||
private const ushort IllegalDirectOffset = 0xFFFF;
|
||||
private const ulong ShaderUserDataOffset = 0x08;
|
||||
private const ulong ShaderInputSemanticsOffset = 0x30;
|
||||
private const ulong ShaderNumInputSemanticsOffset = 0x50;
|
||||
|
||||
internal enum AgcDirectResourceType : uint
|
||||
{
|
||||
PtrVertexBufferTable = 8,
|
||||
PtrVertexAttribDescTable = 10,
|
||||
Last = PtrVertexAttribDescTable,
|
||||
}
|
||||
|
||||
internal readonly record struct VertexTableRegisters(
|
||||
int VertexBufferReg,
|
||||
int VertexAttribReg,
|
||||
uint InputSemanticsCount,
|
||||
ulong InputSemanticsAddress);
|
||||
|
||||
/// <summary>
|
||||
/// One AGC attrib-table resource.
|
||||
/// Representation: <see cref="SharpBase"/> is the V# base; attribute byte
|
||||
/// offset is applied as <see cref="OffsetBytes"/> (Vulkan bind offset),
|
||||
/// not folded into the base — avoids double-counting when the IR prolog
|
||||
/// already bumped the sharp address.
|
||||
/// </summary>
|
||||
internal readonly record struct MetadataVertexResource(
|
||||
uint Location,
|
||||
uint Semantic,
|
||||
uint HardwareMapping,
|
||||
uint SizeInElements,
|
||||
ulong SharpBase,
|
||||
uint Stride,
|
||||
uint OffsetBytes,
|
||||
uint DataFormat,
|
||||
uint NumberFormat,
|
||||
uint ComponentCount,
|
||||
bool PerInstance);
|
||||
|
||||
/// <summary>
|
||||
/// Reads AGC user-data direct-resource offsets for the ES header mapped to
|
||||
/// <paramref name="shaderCodeAddress"/>. Returns false when the header is
|
||||
/// unknown or the tables are absent (attribute-less clears).
|
||||
/// </summary>
|
||||
internal static bool TryGetVertexTableRegisters(
|
||||
CpuContext ctx,
|
||||
ulong shaderCodeAddress,
|
||||
ulong shaderHeaderAddress,
|
||||
out VertexTableRegisters registers)
|
||||
{
|
||||
registers = new VertexTableRegisters(-1, -1, 0, 0);
|
||||
if (shaderHeaderAddress == 0 ||
|
||||
!TryReadUInt64(ctx, shaderHeaderAddress + ShaderUserDataOffset, out var userDataAddress) ||
|
||||
userDataAddress == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// ShaderUserData layout:
|
||||
// 0x00: uint16_t* direct_resource_offset
|
||||
// 0x08: sharp_resource_offset[4]
|
||||
// 0x28: eud_size_dw, srt_size_dw
|
||||
// 0x2C: direct_resource_count
|
||||
if (!TryReadUInt64(ctx, userDataAddress, out var directResourceOffset) ||
|
||||
!TryReadUInt16(ctx, userDataAddress + 0x2C, out var directResourceCount))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var maxTypes = (uint)AgcDirectResourceType.Last + 1u;
|
||||
if (directResourceCount > maxTypes || directResourceOffset == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var vertexBufferReg = -1;
|
||||
var vertexAttribReg = -1;
|
||||
for (uint type = 0; type < directResourceCount; type++)
|
||||
{
|
||||
if (!TryReadUInt16(
|
||||
ctx,
|
||||
directResourceOffset + (type * sizeof(ushort)),
|
||||
out var reg) ||
|
||||
reg == IllegalDirectOffset)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch ((AgcDirectResourceType)type)
|
||||
{
|
||||
case AgcDirectResourceType.PtrVertexBufferTable:
|
||||
vertexBufferReg = reg;
|
||||
break;
|
||||
case AgcDirectResourceType.PtrVertexAttribDescTable:
|
||||
vertexAttribReg = reg;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (vertexBufferReg < 0 || vertexAttribReg < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryReadUInt64(
|
||||
ctx,
|
||||
shaderHeaderAddress + ShaderInputSemanticsOffset,
|
||||
out var inputSemanticsAddress) ||
|
||||
!TryReadUInt32(
|
||||
ctx,
|
||||
shaderHeaderAddress + ShaderNumInputSemanticsOffset,
|
||||
out var inputSemanticsCount) ||
|
||||
inputSemanticsCount == 0 ||
|
||||
inputSemanticsAddress == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
registers = new VertexTableRegisters(
|
||||
vertexBufferReg,
|
||||
vertexAttribReg,
|
||||
inputSemanticsCount,
|
||||
inputSemanticsAddress);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds attrib resources from AGC input_semantics + tables.
|
||||
/// ShaderSemantic packing:
|
||||
/// bits [7:0] semantic → attrib table index
|
||||
/// bits [15:8] hardware_mapping → VGPR destination
|
||||
/// bits [19:16] size_in_elements
|
||||
/// </summary>
|
||||
internal static bool TryBuildVertexResourcesFromMetadata(
|
||||
CpuContext ctx,
|
||||
IReadOnlyList<uint> scalarRegisters,
|
||||
VertexTableRegisters tables,
|
||||
out IReadOnlyList<MetadataVertexResource> resources)
|
||||
{
|
||||
resources = Array.Empty<MetadataVertexResource>();
|
||||
if (tables.VertexAttribReg < 0 ||
|
||||
tables.VertexBufferReg < 0 ||
|
||||
tables.VertexAttribReg + 1 >= scalarRegisters.Count ||
|
||||
tables.VertexBufferReg + 1 >= scalarRegisters.Count ||
|
||||
tables.InputSemanticsCount == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var attribTable =
|
||||
((ulong)scalarRegisters[tables.VertexAttribReg + 1] << 32) |
|
||||
scalarRegisters[tables.VertexAttribReg];
|
||||
var bufferTable =
|
||||
((ulong)scalarRegisters[tables.VertexBufferReg + 1] << 32) |
|
||||
scalarRegisters[tables.VertexBufferReg];
|
||||
if (attribTable == 0 || bufferTable == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var built = new List<MetadataVertexResource>((int)tables.InputSemanticsCount);
|
||||
for (uint i = 0; i < tables.InputSemanticsCount; i++)
|
||||
{
|
||||
if (!TryReadUInt32(
|
||||
ctx,
|
||||
tables.InputSemanticsAddress + (i * sizeof(uint)),
|
||||
out var semanticWord))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Attrib index is semantic bits [7:0], not hardware_mapping.
|
||||
var semantic = semanticWord & 0xFFu;
|
||||
var hardwareMapping = (semanticWord >> 8) & 0xFFu;
|
||||
var sizeInElements = (semanticWord >> 16) & 0xFu;
|
||||
if (!TryReadUInt32(ctx, attribTable + (semantic * sizeof(uint)), out var attribWord))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Attrib dword: buffer index [4:0], format [13:5], offset [25:14], fetch [26].
|
||||
var bufferIndex = attribWord & 0x1Fu;
|
||||
var format = (attribWord >> 5) & 0x1FFu;
|
||||
var offset = (attribWord >> 14) & 0xFFFu;
|
||||
var fetchIndex = (attribWord >> 26) & 0x1u;
|
||||
var sharpAddress = bufferTable + (bufferIndex * 16u);
|
||||
if (!TryReadUInt32(ctx, sharpAddress, out var sharp0) ||
|
||||
!TryReadUInt32(ctx, sharpAddress + 4, out var sharp1))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sharpBase = sharp0 | ((ulong)(sharp1 & 0xFFFFu) << 32);
|
||||
var stride = (sharp1 >> 16) & 0x3FFFu;
|
||||
if (sharpBase == 0 || stride == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fallbackComponents = sizeInElements != 0 ? sizeInElements : 4u;
|
||||
var (dataFormat, numberFormat, components) =
|
||||
MapAttribFormat(format, fallbackComponents);
|
||||
built.Add(new MetadataVertexResource(
|
||||
Location: i,
|
||||
Semantic: semantic,
|
||||
HardwareMapping: hardwareMapping,
|
||||
SizeInElements: sizeInElements,
|
||||
SharpBase: sharpBase,
|
||||
Stride: stride,
|
||||
OffsetBytes: offset,
|
||||
DataFormat: dataFormat,
|
||||
NumberFormat: numberFormat,
|
||||
ComponentCount: components,
|
||||
PerInstance: fetchIndex != 0));
|
||||
}
|
||||
|
||||
if (built.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
resources = built;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Patch IR-discovered fetches from the attrib table onto the V# format/offset.
|
||||
/// Prefer 1:1 Location pairing when counts match on one interleaved stream
|
||||
/// (GTA UI glyphs). Otherwise match by stride + byte offset. Never rebases
|
||||
/// BaseAddress/Data/Location/Pc/PerInstance.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<Gen5VertexInputBinding> MergeVertexInputsFromMetadata(
|
||||
CpuContext ctx,
|
||||
IReadOnlyList<uint> scalarRegisters,
|
||||
VertexTableRegisters tables,
|
||||
IReadOnlyList<Gen5VertexInputBinding> discovered)
|
||||
{
|
||||
if (discovered.Count == 0 ||
|
||||
!TryBuildVertexResourcesFromMetadata(
|
||||
ctx,
|
||||
scalarRegisters,
|
||||
tables,
|
||||
out var resources))
|
||||
{
|
||||
return discovered;
|
||||
}
|
||||
|
||||
if (TryMergeByLocationPairing(discovered, resources, out var paired))
|
||||
{
|
||||
return paired;
|
||||
}
|
||||
|
||||
var merged = new List<Gen5VertexInputBinding>(discovered.Count);
|
||||
var usedResources = new bool[resources.Count];
|
||||
var changed = false;
|
||||
foreach (var input in discovered)
|
||||
{
|
||||
if (!TryMatchMetadataResource(input, resources, usedResources, out var resource, out var fillOffset))
|
||||
{
|
||||
merged.Add(input);
|
||||
continue;
|
||||
}
|
||||
|
||||
var refined = ApplyMetadataFormat(input, resource, fillOffset);
|
||||
changed |= refined != input;
|
||||
merged.Add(refined);
|
||||
}
|
||||
|
||||
return changed ? merged : discovered;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When discovery and metadata describe the same interleaved stream with
|
||||
/// equal attribute counts, pair by sorted Location (semantic order).
|
||||
/// Keeps each binding's Pc/Location for SPIR-V; overlays format + offset.
|
||||
/// </summary>
|
||||
private static bool TryMergeByLocationPairing(
|
||||
IReadOnlyList<Gen5VertexInputBinding> discovered,
|
||||
IReadOnlyList<MetadataVertexResource> resources,
|
||||
out IReadOnlyList<Gen5VertexInputBinding> merged)
|
||||
{
|
||||
merged = discovered;
|
||||
if (discovered.Count != resources.Count || discovered.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var orderedInputs = discovered.OrderBy(static input => input.Location).ToArray();
|
||||
var orderedResources = resources.OrderBy(static resource => resource.Location).ToArray();
|
||||
var streamBase = orderedResources[0].SharpBase;
|
||||
var streamStride = orderedResources[0].Stride;
|
||||
for (var index = 0; index < orderedResources.Length; index++)
|
||||
{
|
||||
var resource = orderedResources[index];
|
||||
var input = orderedInputs[index];
|
||||
if (resource.SharpBase != streamBase ||
|
||||
resource.Stride != streamStride ||
|
||||
(input.Stride != 0 && input.Stride != streamStride) ||
|
||||
!IsSameVertexStream(input, resource))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var byPc = new Dictionary<uint, Gen5VertexInputBinding>(discovered.Count);
|
||||
var changed = false;
|
||||
for (var index = 0; index < orderedInputs.Length; index++)
|
||||
{
|
||||
var input = orderedInputs[index];
|
||||
var resource = orderedResources[index];
|
||||
var fillOffset = input.BaseAddress == resource.SharpBase ||
|
||||
IsAddressInsideCapturedSpan(input, resource.SharpBase);
|
||||
var refined = ApplyMetadataFormat(input, resource, fillOffset);
|
||||
changed |= refined != input;
|
||||
byPc[input.Pc] = refined;
|
||||
}
|
||||
|
||||
if (!changed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = new Gen5VertexInputBinding[discovered.Count];
|
||||
for (var index = 0; index < discovered.Count; index++)
|
||||
{
|
||||
result[index] = byPc[discovered[index].Pc];
|
||||
}
|
||||
|
||||
merged = result;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Gen5VertexInputBinding ApplyMetadataFormat(
|
||||
Gen5VertexInputBinding input,
|
||||
MetadataVertexResource resource,
|
||||
bool fillOffsetBytes)
|
||||
{
|
||||
var components = input.ComponentCount != 0 &&
|
||||
input.ComponentCount < resource.ComponentCount
|
||||
? input.ComponentCount
|
||||
: resource.ComponentCount;
|
||||
|
||||
return input with
|
||||
{
|
||||
DataFormat = resource.DataFormat,
|
||||
NumberFormat = resource.NumberFormat,
|
||||
ComponentCount = components,
|
||||
OffsetBytes = fillOffsetBytes ? resource.OffsetBytes : input.OffsetBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy entry point — forwards to <see cref="MergeVertexInputsFromMetadata"/>.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<Gen5VertexInputBinding> RefineVertexInputs(
|
||||
CpuContext ctx,
|
||||
IReadOnlyList<uint> scalarRegisters,
|
||||
VertexTableRegisters tables,
|
||||
IReadOnlyList<Gen5VertexInputBinding> discovered) =>
|
||||
MergeVertexInputsFromMetadata(ctx, scalarRegisters, tables, discovered);
|
||||
|
||||
/// <summary>
|
||||
/// Collects SBufferLoad / SLoad PCs that read the AGC attrib or buffer
|
||||
/// tables (embedded-fetch prolog). Those loads are executed on the
|
||||
/// CPU during scalar evaluation; once vertex inputs are bound they must
|
||||
/// not run again as live SSBOs on the GPU.
|
||||
/// </summary>
|
||||
internal static HashSet<uint> CollectFetchPrologPcs(
|
||||
Gen5ShaderProgram program,
|
||||
VertexTableRegisters tables)
|
||||
{
|
||||
var pcs = new HashSet<uint>();
|
||||
if (tables.VertexAttribReg < 0 || tables.VertexBufferReg < 0)
|
||||
{
|
||||
return pcs;
|
||||
}
|
||||
|
||||
var tableRegs = new HashSet<uint>
|
||||
{
|
||||
(uint)tables.VertexAttribReg,
|
||||
(uint)tables.VertexAttribReg + 1u,
|
||||
(uint)tables.VertexBufferReg,
|
||||
(uint)tables.VertexBufferReg + 1u,
|
||||
};
|
||||
|
||||
foreach (var instruction in program.Instructions)
|
||||
{
|
||||
var isScalarLoad =
|
||||
instruction.Opcode.StartsWith("SBufferLoad", StringComparison.Ordinal) ||
|
||||
instruction.Opcode.StartsWith("SLoad", StringComparison.Ordinal);
|
||||
if (!isScalarLoad)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// SMEM loads encode the scalar base pointer in Sources[0].
|
||||
if (instruction.Sources.Count > 0 &&
|
||||
instruction.Sources[0] is
|
||||
{
|
||||
Kind: Gen5OperandKind.ScalarRegister,
|
||||
Value: var scalarBase,
|
||||
} &&
|
||||
tableRegs.Contains(scalarBase))
|
||||
{
|
||||
pcs.Add(instruction.Pc);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (instruction.Control is Gen5BufferMemoryControl buffer &&
|
||||
tableRegs.Contains(buffer.ScalarResource))
|
||||
{
|
||||
pcs.Add(instruction.Pc);
|
||||
}
|
||||
}
|
||||
|
||||
return pcs;
|
||||
}
|
||||
|
||||
private static bool TryMatchMetadataResource(
|
||||
Gen5VertexInputBinding input,
|
||||
IReadOnlyList<MetadataVertexResource> resources,
|
||||
bool[] usedResources,
|
||||
out MetadataVertexResource resource,
|
||||
out bool fillOffsetBytes)
|
||||
{
|
||||
resource = default;
|
||||
fillOffsetBytes = false;
|
||||
var bestScore = int.MinValue;
|
||||
var bestIndex = -1;
|
||||
var bestFillOffset = false;
|
||||
for (var index = 0; index < resources.Count; index++)
|
||||
{
|
||||
if (usedResources[index])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidate = resources[index];
|
||||
if (candidate.Stride != 0 &&
|
||||
input.Stride != 0 &&
|
||||
candidate.Stride != input.Stride)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsSameVertexStream(input, candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var attrAddress = candidate.SharpBase + candidate.OffsetBytes;
|
||||
var score = int.MinValue;
|
||||
var fillOffset = false;
|
||||
|
||||
// Post-capture interleaved: shared BaseAddress, distinct OffsetBytes.
|
||||
if (input.OffsetBytes == candidate.OffsetBytes &&
|
||||
(input.BaseAddress == candidate.SharpBase ||
|
||||
IsAddressInsideCapturedSpan(input, candidate.SharpBase)))
|
||||
{
|
||||
score = 400;
|
||||
}
|
||||
// IR prolog baked attrib offset into the V# base.
|
||||
else if (input.BaseAddress == attrAddress)
|
||||
{
|
||||
score = 350;
|
||||
}
|
||||
// Discovery never saw the attrib offset — only safe when this
|
||||
// resource's offset uniquely identifies it among unused entries.
|
||||
else if (input.BaseAddress == candidate.SharpBase &&
|
||||
input.OffsetBytes == 0 &&
|
||||
candidate.OffsetBytes != 0 &&
|
||||
IsUniqueUnusedOffset(resources, usedResources, candidate.OffsetBytes, index))
|
||||
{
|
||||
score = 300;
|
||||
fillOffset = true;
|
||||
}
|
||||
else if (input.BaseAddress == candidate.SharpBase &&
|
||||
input.OffsetBytes == 0 &&
|
||||
candidate.OffsetBytes == 0)
|
||||
{
|
||||
score = 250;
|
||||
}
|
||||
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestIndex = index;
|
||||
bestFillOffset = fillOffset;
|
||||
}
|
||||
}
|
||||
|
||||
// Require an offset-aware match. Bare SharpBase ties (score 250) are
|
||||
// only accepted when a single unused resource remains for that stream.
|
||||
if (bestIndex < 0 || bestScore < 300)
|
||||
{
|
||||
if (bestIndex < 0 || bestScore < 250)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var unusedSameStream = 0;
|
||||
for (var index = 0; index < resources.Count; index++)
|
||||
{
|
||||
if (!usedResources[index] && IsSameVertexStream(input, resources[index]))
|
||||
{
|
||||
unusedSameStream++;
|
||||
}
|
||||
}
|
||||
|
||||
if (unusedSameStream != 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
usedResources[bestIndex] = true;
|
||||
resource = resources[bestIndex];
|
||||
fillOffsetBytes = bestFillOffset;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsSameVertexStream(
|
||||
Gen5VertexInputBinding input,
|
||||
MetadataVertexResource resource)
|
||||
{
|
||||
if (input.BaseAddress == resource.SharpBase ||
|
||||
input.BaseAddress == resource.SharpBase + resource.OffsetBytes)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return IsAddressInsideCapturedSpan(input, resource.SharpBase);
|
||||
}
|
||||
|
||||
private static bool IsAddressInsideCapturedSpan(
|
||||
Gen5VertexInputBinding input,
|
||||
ulong address) =>
|
||||
input.DataLength > 0 &&
|
||||
address >= input.BaseAddress &&
|
||||
address < input.BaseAddress + (ulong)input.DataLength;
|
||||
|
||||
private static bool IsUniqueUnusedOffset(
|
||||
IReadOnlyList<MetadataVertexResource> resources,
|
||||
bool[] usedResources,
|
||||
uint offsetBytes,
|
||||
int candidateIndex)
|
||||
{
|
||||
for (var index = 0; index < resources.Count; index++)
|
||||
{
|
||||
if (index == candidateIndex || usedResources[index])
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resources[index].OffsetBytes == offsetBytes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attrib-table format
|
||||
/// fields are VertexAttribFormat; V# / Vulkan paths need BufferFormat.
|
||||
/// Unknown values pass through (already BufferFormat).
|
||||
/// </summary>
|
||||
private static uint VertexAttribFormatToBufferFormat(uint format) =>
|
||||
format switch
|
||||
{
|
||||
0 => 0, // Invalid
|
||||
4 => 1, // k8UNorm
|
||||
8 => 2, // k8SNorm
|
||||
12 => 3, // k8UScaled
|
||||
16 => 4, // k8SScaled
|
||||
20 => 5, // k8UInt
|
||||
24 => 6, // k8SInt
|
||||
28 => 7, // k16UNorm
|
||||
32 => 8, // k16SNorm
|
||||
36 => 9, // k16UScaled
|
||||
40 => 10, // k16SScaled
|
||||
44 => 11, // k16UInt
|
||||
48 => 12, // k16SInt
|
||||
52 => 13, // k16Float
|
||||
57 => 14, // k8_8UNorm
|
||||
61 => 15, // k8_8SNorm
|
||||
65 => 16, // k8_8UScaled
|
||||
69 => 17, // k8_8SScaled
|
||||
73 => 18, // k8_8UInt
|
||||
77 => 19, // k8_8SInt
|
||||
80 => 20, // k32UInt
|
||||
84 => 21, // k32SInt
|
||||
88 => 22, // k32Float
|
||||
93 => 23, // k16_16UNorm
|
||||
97 => 24, // k16_16SNorm
|
||||
101 => 25, // k16_16UScaled
|
||||
105 => 26, // k16_16SScaled
|
||||
109 => 27, // k16_16UInt
|
||||
113 => 28, // k16_16SInt
|
||||
117 => 29, // k16_16Float
|
||||
122 => 30, // k11_11_10UNorm
|
||||
126 => 31,
|
||||
130 => 32,
|
||||
134 => 33,
|
||||
138 => 34,
|
||||
142 => 35,
|
||||
146 => 36,
|
||||
150 => 37, // k10_11_11UNorm
|
||||
154 => 38,
|
||||
158 => 39,
|
||||
162 => 40,
|
||||
166 => 41,
|
||||
170 => 42,
|
||||
174 => 43,
|
||||
179 => 44, // k2_10_10_10UNorm
|
||||
183 => 45,
|
||||
187 => 46,
|
||||
191 => 47,
|
||||
195 => 48,
|
||||
199 => 49,
|
||||
203 => 50, // k10_10_10_2UNorm
|
||||
207 => 51,
|
||||
211 => 52,
|
||||
215 => 53,
|
||||
219 => 54,
|
||||
223 => 55,
|
||||
227 => 56, // k8_8_8_8UNorm
|
||||
231 => 57,
|
||||
235 => 58,
|
||||
239 => 59,
|
||||
243 => 60,
|
||||
247 => 61,
|
||||
249 => 62, // k32_32UInt
|
||||
253 => 63,
|
||||
257 => 64, // k32_32Float
|
||||
263 => 65, // k16_16_16_16UNorm
|
||||
267 => 66,
|
||||
271 => 67,
|
||||
275 => 68,
|
||||
279 => 69,
|
||||
283 => 70,
|
||||
287 => 71, // k16_16_16_16Float
|
||||
290 => 72, // k32_32_32UInt
|
||||
294 => 73,
|
||||
298 => 74,
|
||||
303 => 75, // k32_32_32_32UInt
|
||||
307 => 76,
|
||||
311 => 77, // k32_32_32_32Float
|
||||
_ => format,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Maps Prospero attrib-table formats onto GNM (DataFormat, NumberFormat,
|
||||
/// Components) for <c>ToVkVertexFormat</c>. Accepts VertexAttribFormat
|
||||
/// or BufferFormat (pass-through). NumberFormat: 0 Unorm, 1 SNorm,
|
||||
/// 2 UScaled, 3 SScaled, 4 UInt, 5 SInt, 7 Float.
|
||||
/// </summary>
|
||||
private static (uint DataFormat, uint NumberFormat, uint Components) MapAttribFormat(
|
||||
uint attribFormat,
|
||||
uint fallbackComponents)
|
||||
{
|
||||
// Prospero VertexAttribFormat quirks before BufferFormat conversion.
|
||||
if (attribFormat == 113)
|
||||
{
|
||||
return (14, 7, 4); // R32G32B32A32_SFLOAT
|
||||
}
|
||||
|
||||
if (attribFormat == 121)
|
||||
{
|
||||
return (5, 7, 2); // R16G16_SFLOAT
|
||||
}
|
||||
|
||||
var bufferFormat = VertexAttribFormatToBufferFormat(attribFormat);
|
||||
|
||||
// Prospero::BufferFormat numeric values (gpu_defs.h).
|
||||
return bufferFormat switch
|
||||
{
|
||||
1 => (1, 0, 1), // k8UNorm
|
||||
2 => (1, 1, 1), // k8SNorm
|
||||
3 => (1, 2, 1), // k8UScaled
|
||||
4 => (1, 3, 1), // k8SScaled
|
||||
5 => (1, 4, 1), // k8UInt
|
||||
6 => (1, 5, 1), // k8SInt
|
||||
7 => (2, 0, 1), // k16UNorm
|
||||
8 => (2, 1, 1), // k16SNorm
|
||||
9 => (2, 2, 1), // k16UScaled
|
||||
10 => (2, 3, 1), // k16SScaled
|
||||
11 => (2, 4, 1), // k16UInt
|
||||
12 => (2, 5, 1), // k16SInt
|
||||
13 => (2, 7, 1), // k16Float
|
||||
14 => (3, 0, 2), // k8_8UNorm
|
||||
15 => (3, 1, 2), // k8_8SNorm
|
||||
16 => (3, 2, 2), // k8_8UScaled
|
||||
17 => (3, 3, 2), // k8_8SScaled
|
||||
18 => (3, 4, 2), // k8_8UInt
|
||||
19 => (3, 5, 2), // k8_8SInt
|
||||
20 => (4, 4, 1), // k32UInt
|
||||
21 => (4, 5, 1), // k32SInt
|
||||
22 => (4, 7, 1), // k32Float
|
||||
23 => (5, 0, 2), // k16_16UNorm
|
||||
24 => (5, 1, 2), // k16_16SNorm
|
||||
25 => (5, 2, 2), // k16_16UScaled
|
||||
26 => (5, 3, 2), // k16_16SScaled
|
||||
27 => (5, 4, 2), // k16_16UInt
|
||||
28 => (5, 5, 2), // k16_16SInt
|
||||
29 => (5, 7, 2), // k16_16Float
|
||||
50 => (9, 0, 4), // k10_10_10_2UNorm
|
||||
51 => (9, 1, 4), // k10_10_10_2SNorm
|
||||
56 => (10, 0, 4), // k8_8_8_8UNorm
|
||||
57 => (10, 1, 4), // k8_8_8_8SNorm
|
||||
58 => (10, 2, 4), // k8_8_8_8UScaled
|
||||
59 => (10, 3, 4), // k8_8_8_8SScaled
|
||||
60 => (10, 4, 4), // k8_8_8_8UInt
|
||||
61 => (10, 5, 4), // k8_8_8_8SInt
|
||||
62 => (11, 4, 2), // k32_32UInt
|
||||
63 => (11, 5, 2), // k32_32SInt
|
||||
64 => (11, 7, 2), // k32_32Float
|
||||
65 => (12, 0, 4), // k16_16_16_16UNorm
|
||||
66 => (12, 1, 4), // k16_16_16_16SNorm
|
||||
67 => (12, 2, 4), // k16_16_16_16UScaled
|
||||
68 => (12, 3, 4), // k16_16_16_16SScaled
|
||||
69 => (12, 4, 4), // k16_16_16_16UInt
|
||||
70 => (12, 5, 4), // k16_16_16_16SInt
|
||||
71 => (12, 7, 4), // k16_16_16_16Float
|
||||
72 => (13, 4, 3), // k32_32_32UInt
|
||||
73 => (13, 5, 3), // k32_32_32SInt
|
||||
74 => (13, 7, 3), // k32_32_32Float
|
||||
75 => (14, 4, 4), // k32_32_32_32UInt
|
||||
76 => (14, 5, 4), // k32_32_32_32SInt
|
||||
77 => (14, 7, 4), // k32_32_32_32Float
|
||||
_ => (14, 7, Math.Clamp(fallbackComponents, 1u, 4u)),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryReadUInt16(CpuContext ctx, ulong address, out ushort value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[2];
|
||||
if (!ctx.Memory.TryRead(address, buffer))
|
||||
{
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(buffer);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryReadUInt32(CpuContext ctx, ulong address, out uint value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
if (!ctx.Memory.TryRead(address, buffer))
|
||||
{
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buffer);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryReadUInt64(CpuContext ctx, ulong address, out ulong value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
if (!ctx.Memory.TryRead(address, buffer))
|
||||
{
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(buffer);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,60 @@
|
||||
// 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 1/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
|
||||
@@ -16,8 +68,11 @@ namespace SharpEmu.Libs.Agc;
|
||||
/// other D/R and pipe/bank-XOR modes stay opt-in while their complete AddrLib
|
||||
/// equations are being ported.
|
||||
/// </summary>
|
||||
internal static class GnmTiling
|
||||
internal static unsafe class GnmTiling
|
||||
{
|
||||
private const int ParallelDetileElementThreshold = 512 * 512;
|
||||
private const int MaxDetileWorkers = 4;
|
||||
|
||||
// Oberon uses the 16-pipe / 8-pixel-packer RB+ topology. These are the
|
||||
// single-sample 64 KiB equations generated by AMD AddrLib for that exact
|
||||
// topology. Each entry describes one address bit as an XOR of X/Y bits.
|
||||
@@ -90,6 +145,18 @@ internal static class GnmTiling
|
||||
Y(2), X(2), Y(3), X(3), Y(4), X(4), Y(5), X(5)],
|
||||
];
|
||||
|
||||
// GFX10 256B_S: 8-bit micro-tile equation (low octet of the 4K_S pattern).
|
||||
// The generic StandardSwizzle bit-interleave is a different layout and leaves
|
||||
// a broken grid on Gen5 UI atlases that ship as Standard256B.
|
||||
private static readonly AddressBit[][] Standard256 =
|
||||
[
|
||||
[X(0), X(1), X(2), X(3), Y(0), Y(1), Y(2), Y(3)],
|
||||
[Zero, X(0), X(1), X(2), Y(0), Y(1), Y(2), X(3)],
|
||||
[Zero, Zero, X(0), X(1), Y(0), Y(1), Y(2), X(2)],
|
||||
[Zero, Zero, Zero, X(0), Y(0), Y(1), X(1), X(2)],
|
||||
[Zero, Zero, Zero, Zero, Y(0), Y(1), X(0), X(1)],
|
||||
];
|
||||
|
||||
// GFX10 4K_S has a separate 12-bit micro-tile equation. It is not the
|
||||
// generic x/y interleave used by the 64K standard block; using that larger
|
||||
// equation leaves a regular grid in linearized atlases.
|
||||
@@ -118,6 +185,14 @@ internal static class GnmTiling
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static readonly HashSet<uint> _reportedModes = new();
|
||||
private static readonly ConcurrentDictionary<(uint SwizzleMode, int BppLog2), PatternTerms>
|
||||
_patternTermCache = new();
|
||||
private static readonly ConcurrentDictionary<(SwizzleKind Kind, int Width, int Height), int[]>
|
||||
_blockTableCache = new();
|
||||
private static readonly ParallelOptions _parallelDetileOptions = new()
|
||||
{
|
||||
MaxDegreeOfParallelism = Math.Min(MaxDetileWorkers, Environment.ProcessorCount),
|
||||
};
|
||||
|
||||
public static bool Enabled => _enabled || !_disabled;
|
||||
|
||||
@@ -194,6 +269,164 @@ internal static class GnmTiling
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetBlockElementDimensions(
|
||||
uint swizzleMode,
|
||||
int bytesPerElement,
|
||||
out int blockWidth,
|
||||
out int blockHeight)
|
||||
{
|
||||
blockWidth = 0;
|
||||
blockHeight = 0;
|
||||
if (bytesPerElement <= 0 ||
|
||||
!TryGetSwizzleKind(swizzleMode, out _, out var blockBytes))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var bppLog2 = BitLog2((uint)bytesPerElement);
|
||||
if (bppLog2 < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
(blockWidth, blockHeight) = SquareBlockDimensions(blockBytes >> bppLog2);
|
||||
return blockWidth != 0 && blockHeight != 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locates mip 0 in a GFX10 mip chain, which AddrLib stores smallest-first
|
||||
/// (Gfx10Lib::ComputeSurfaceInfoMacroTiled/MicroTiled).
|
||||
/// </summary>
|
||||
public static bool TryGetBaseMipPlacement(
|
||||
uint swizzleMode,
|
||||
int elementsWide,
|
||||
int elementsHigh,
|
||||
int bytesPerElement,
|
||||
uint resourceMipLevels,
|
||||
out ulong byteOffset,
|
||||
out bool inMipTail,
|
||||
out int tailElementX,
|
||||
out int tailElementY,
|
||||
out ulong chainSliceBytes)
|
||||
{
|
||||
byteOffset = 0;
|
||||
inMipTail = false;
|
||||
tailElementX = 0;
|
||||
tailElementY = 0;
|
||||
chainSliceBytes = 0;
|
||||
if (resourceMipLevels <= 1 ||
|
||||
!ShouldDetile(swizzleMode) ||
|
||||
elementsWide <= 0 ||
|
||||
elementsHigh <= 0 ||
|
||||
bytesPerElement <= 0 ||
|
||||
!TryGetSwizzleKind(swizzleMode, out _, out var blockBytes))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var bppLog2 = BitLog2((uint)bytesPerElement);
|
||||
if (bppLog2 < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var (blockWidth, blockHeight) = SquareBlockDimensions(blockBytes >> bppLog2);
|
||||
var blockSizeLog2 = BitLog2((uint)blockBytes);
|
||||
if (blockWidth == 0 || blockHeight == 0 || blockSizeLog2 < 8)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var mipLevels = (int)Math.Min(resourceMipLevels, 16u);
|
||||
var maxMipsInTail = blockSizeLog2 <= 8 ? 0
|
||||
: blockSizeLog2 <= 11
|
||||
? 1 + (1 << (blockSizeLog2 - 9))
|
||||
: blockSizeLog2 - 4;
|
||||
var tailWidth = (blockSizeLog2 & 1) != 0 ? blockWidth >> 1 : blockWidth;
|
||||
var tailHeight = (blockSizeLog2 & 1) != 0 ? blockHeight : blockHeight >> 1;
|
||||
|
||||
var firstMipInTail = mipLevels;
|
||||
var mipSizes = new ulong[mipLevels];
|
||||
for (var i = 0; i < mipLevels; i++)
|
||||
{
|
||||
var mipWidth = Math.Max(elementsWide >> i, 1);
|
||||
var mipHeight = Math.Max(elementsHigh >> i, 1);
|
||||
if (maxMipsInTail > 0 &&
|
||||
mipWidth <= tailWidth &&
|
||||
mipHeight <= tailHeight &&
|
||||
mipLevels - i <= maxMipsInTail)
|
||||
{
|
||||
firstMipInTail = i;
|
||||
break;
|
||||
}
|
||||
|
||||
var alignedWidth = (ulong)(mipWidth + blockWidth - 1) / (ulong)blockWidth * (ulong)blockWidth;
|
||||
var alignedHeight = (ulong)(mipHeight + blockHeight - 1) / (ulong)blockHeight * (ulong)blockHeight;
|
||||
mipSizes[i] = alignedWidth * alignedHeight * (ulong)bytesPerElement;
|
||||
}
|
||||
|
||||
if (firstMipInTail == 0)
|
||||
{
|
||||
var m = maxMipsInTail - 1;
|
||||
var mipOffset = m > 6 ? 16 << m : m << 8;
|
||||
var mipX = ((mipOffset >> 9) & 1) |
|
||||
((mipOffset >> 10) & 2) |
|
||||
((mipOffset >> 11) & 4) |
|
||||
((mipOffset >> 12) & 8) |
|
||||
((mipOffset >> 13) & 16) |
|
||||
((mipOffset >> 14) & 32);
|
||||
var mipY = ((mipOffset >> 8) & 1) |
|
||||
((mipOffset >> 9) & 2) |
|
||||
((mipOffset >> 10) & 4) |
|
||||
((mipOffset >> 11) & 8) |
|
||||
((mipOffset >> 12) & 16) |
|
||||
((mipOffset >> 13) & 32);
|
||||
if ((blockSizeLog2 & 1) != 0)
|
||||
{
|
||||
(mipX, mipY) = (mipY, mipX);
|
||||
if ((bppLog2 & 1) != 0)
|
||||
{
|
||||
mipY = (mipY << 1) | (mipX & 1);
|
||||
mipX >>= 1;
|
||||
}
|
||||
}
|
||||
|
||||
var (microWidth, microHeight) = SquareBlockDimensions(256 >> bppLog2);
|
||||
if (microWidth == 0 || microHeight == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
tailElementX = mipX * microWidth;
|
||||
tailElementY = mipY * microHeight;
|
||||
if (tailElementX + elementsWide > blockWidth ||
|
||||
tailElementY + elementsHigh > blockHeight)
|
||||
{
|
||||
tailElementX = 0;
|
||||
tailElementY = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
inMipTail = true;
|
||||
chainSliceBytes = (ulong)blockBytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
byteOffset = firstMipInTail < mipLevels ? (ulong)blockBytes : 0;
|
||||
chainSliceBytes = byteOffset;
|
||||
for (var i = firstMipInTail - 1; i >= 1; i--)
|
||||
{
|
||||
byteOffset += mipSizes[i];
|
||||
}
|
||||
|
||||
for (var i = 0; i < firstMipInTail; i++)
|
||||
{
|
||||
chainSliceBytes += mipSizes[i];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deswizzles <paramref name="tiled"/> into linear row-major order.
|
||||
/// Elements are pixels for uncompressed formats and 4x4 blocks for
|
||||
@@ -246,50 +479,218 @@ internal static class GnmTiling
|
||||
return false;
|
||||
}
|
||||
|
||||
// Precompute the within-block element offset for each (x, y) inside a
|
||||
// single block. The swizzle equation only depends on the in-block
|
||||
// coordinates, so this table is reused for every block — turning the
|
||||
// per-pixel bit-interleave (a loop + calls) into a single array lookup.
|
||||
// Detiling a 2048x2048 texture is millions of elements; without this the
|
||||
// per-pixel math makes DETILE unusably slow during asset streaming.
|
||||
// Address tables depend only on the swizzle equation and element size,
|
||||
// so retain them across textures instead of rebuilding them per upload.
|
||||
var hasExactXorPattern = TryGetExactXorPattern(swizzleMode, bppLog2, out var xorPattern);
|
||||
var blockTable = hasExactXorPattern ? [] : new int[blockWidth * blockHeight];
|
||||
for (var by = 0; !hasExactXorPattern && by < blockHeight; by++)
|
||||
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)
|
||||
{
|
||||
for (var bx = 0; bx < blockWidth; bx++)
|
||||
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) =>
|
||||
{
|
||||
blockTable[by * blockWidth + bx] = (int)(kind == SwizzleKind.ZOrder
|
||||
? MortonInterleave((uint)bx, (uint)by, blockWidth, blockHeight)
|
||||
: StandardSwizzleOffset((uint)bx, (uint)by, blockWidth, blockHeight));
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var y = 0; y < elementsHigh; y++)
|
||||
{
|
||||
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 / blockWidth;
|
||||
var inBlockX = x % blockWidth;
|
||||
return true;
|
||||
}
|
||||
|
||||
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)
|
||||
/// <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))
|
||||
{
|
||||
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 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)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
tiled.Slice((int)sourceByte, bytesPerElement)
|
||||
.CopyTo(linear.Slice((int)destByte, bytesPerElement));
|
||||
tiled.Slice((int)srcByte, bpp).CopyTo(linear.Slice((int)dstByte, bpp));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +703,80 @@ internal static class GnmTiling
|
||||
ZOrder,
|
||||
}
|
||||
|
||||
private readonly record struct PatternTerms(int[] X, int XMask, int[] Y, int YMask);
|
||||
|
||||
private static PatternTerms CreatePatternTerms(AddressBit[] pattern)
|
||||
{
|
||||
uint xMask = 0;
|
||||
uint yMask = 0;
|
||||
foreach (var bit in pattern)
|
||||
{
|
||||
xMask |= bit.XMask;
|
||||
yMask |= bit.YMask;
|
||||
}
|
||||
|
||||
var xLength = AxisTermPeriod(xMask);
|
||||
var yLength = AxisTermPeriod(yMask);
|
||||
var xTerms = new int[xLength];
|
||||
var yTerms = new int[yLength];
|
||||
for (var x = 0; x < xTerms.Length; x++)
|
||||
{
|
||||
xTerms[x] = (int)PatternAxisTerm((uint)x, pattern, useX: true);
|
||||
}
|
||||
|
||||
for (var y = 0; y < yTerms.Length; y++)
|
||||
{
|
||||
yTerms[y] = (int)PatternAxisTerm((uint)y, pattern, useX: false);
|
||||
}
|
||||
|
||||
return new PatternTerms(xTerms, xLength - 1, yTerms, yLength - 1);
|
||||
}
|
||||
|
||||
private static int AxisTermPeriod(uint mask) =>
|
||||
mask == 0 ? 1 : 1 << (32 - System.Numerics.BitOperations.LeadingZeroCount(mask));
|
||||
|
||||
private static int[] CreateBlockTable(SwizzleKind kind, int blockWidth, int blockHeight)
|
||||
{
|
||||
var table = new int[blockWidth * blockHeight];
|
||||
for (var y = 0; y < blockHeight; y++)
|
||||
{
|
||||
for (var x = 0; x < blockWidth; x++)
|
||||
{
|
||||
table[y * blockWidth + x] = (int)(kind == SwizzleKind.ZOrder
|
||||
? MortonInterleave((uint)x, (uint)y, blockWidth, blockHeight)
|
||||
: StandardSwizzleOffset((uint)x, (uint)y, blockWidth, blockHeight));
|
||||
}
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void CopyElement(byte* source, byte* destination, int bytesPerElement)
|
||||
{
|
||||
switch (bytesPerElement)
|
||||
{
|
||||
case 1:
|
||||
*destination = *source;
|
||||
break;
|
||||
case 2:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<ushort>(source));
|
||||
break;
|
||||
case 4:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<uint>(source));
|
||||
break;
|
||||
case 8:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<ulong>(source));
|
||||
break;
|
||||
case 16:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<UInt128>(source));
|
||||
break;
|
||||
default:
|
||||
Unsafe.CopyBlockUnaligned(destination, source, (uint)bytesPerElement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly AddressBit Zero = new(0, 0);
|
||||
|
||||
private static AddressBit X(int bit) => new(1u << bit, 0);
|
||||
@@ -326,6 +801,7 @@ internal static class GnmTiling
|
||||
|
||||
pattern = swizzleMode switch
|
||||
{
|
||||
1 => Standard256[bytesPerElementLog2],
|
||||
5 => Standard4K[bytesPerElementLog2],
|
||||
9 => RbPlus64KStandard[bytesPerElementLog2],
|
||||
24 => RbPlus64KDepthX[bytesPerElementLog2],
|
||||
@@ -335,14 +811,20 @@ internal static class GnmTiling
|
||||
return pattern.Length != 0;
|
||||
}
|
||||
|
||||
private static long ComputePatternOffset(uint x, uint y, AddressBit[] pattern)
|
||||
// The AddrLib within-block byte offset is a per-bit XOR equation:
|
||||
// offset = OR over bits of ( parity(x & XMask) XOR parity(y & YMask) ) << bit
|
||||
// Because parity distributes over XOR, that whole offset factors into two
|
||||
// independent axis terms: PatternAxisTerm(x, useX: true) ^
|
||||
// PatternAxisTerm(y, useX: false). Splitting the axes lets TryDetile cache
|
||||
// the X term per column and hoist the Y term per row instead of recomputing
|
||||
// the full 16-bit interleave (32 PopCounts) for every element.
|
||||
private static uint PatternAxisTerm(uint coordinate, AddressBit[] pattern, bool useX)
|
||||
{
|
||||
uint offset = 0;
|
||||
for (var bit = 0; bit < pattern.Length; bit++)
|
||||
{
|
||||
var equation = pattern[bit];
|
||||
var parity = (System.Numerics.BitOperations.PopCount(x & equation.XMask) +
|
||||
System.Numerics.BitOperations.PopCount(y & equation.YMask)) & 1;
|
||||
var mask = useX ? pattern[bit].XMask : pattern[bit].YMask;
|
||||
var parity = System.Numerics.BitOperations.PopCount(coordinate & mask) & 1;
|
||||
offset |= (uint)parity << bit;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace SharpEmu.Libs.Agc;
|
||||
|
||||
/// <summary>
|
||||
@@ -92,6 +94,64 @@ internal static class GpuWaitRegistry
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct OutstandingSnapshot(
|
||||
int Outstanding,
|
||||
int Latched,
|
||||
long OldestAgeMs,
|
||||
ulong SampleWaitAddress,
|
||||
string? SampleQueueName);
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostics snapshot of suspended WAIT_REG_MEM / dims waiters.
|
||||
/// </summary>
|
||||
public static OutstandingSnapshot SnapshotOutstanding(object? memory = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var outstanding = 0;
|
||||
var latched = 0;
|
||||
var oldestTicks = long.MaxValue;
|
||||
ulong sampleAddress = 0;
|
||||
string? sampleQueue = null;
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
foreach (var (_, list) in _waiters)
|
||||
{
|
||||
foreach (var waiter in list)
|
||||
{
|
||||
if (memory is not null &&
|
||||
!ReferenceEquals(waiter.Memory, memory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
outstanding++;
|
||||
if (waiter.Latched)
|
||||
{
|
||||
latched++;
|
||||
}
|
||||
|
||||
if (waiter.RegisteredTicks != 0 &&
|
||||
waiter.RegisteredTicks < oldestTicks)
|
||||
{
|
||||
oldestTicks = waiter.RegisteredTicks;
|
||||
sampleAddress = waiter.WaitAddress;
|
||||
sampleQueue = waiter.QueueName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var oldestAgeMs = oldestTicks == long.MaxValue || oldestTicks == 0
|
||||
? 0L
|
||||
: (now - oldestTicks) * 1000L / Stopwatch.Frequency;
|
||||
return new OutstandingSnapshot(
|
||||
outstanding,
|
||||
latched,
|
||||
oldestAgeMs,
|
||||
sampleAddress,
|
||||
sampleQueue);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Register(ulong address, WaitingDcb waiter)
|
||||
{
|
||||
waiter.WaitAddress = address;
|
||||
@@ -343,6 +403,45 @@ internal static class GpuWaitRegistry
|
||||
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)
|
||||
|
||||
@@ -6,6 +6,7 @@ using SharpEmu.Libs.Kernel;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace SharpEmu.Libs.Ampr;
|
||||
|
||||
@@ -43,17 +44,17 @@ public static class AmprExports
|
||||
{
|
||||
public CachedHostFile(string path)
|
||||
{
|
||||
Stream = new FileStream(
|
||||
Handle = File.OpenHandle(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite | FileShare.Delete,
|
||||
bufferSize: 1024 * 1024,
|
||||
FileOptions.RandomAccess);
|
||||
Length = RandomAccess.GetLength(Handle);
|
||||
}
|
||||
|
||||
public object Gate { get; } = new();
|
||||
public FileStream Stream { get; }
|
||||
public SafeFileHandle Handle { get; }
|
||||
public long Length { get; }
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
@@ -274,6 +275,19 @@ public static class AmprExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
// Offset -1 means "continue after the previous read of this file id".
|
||||
// #216 dropped this wiring; without it sequential pack/streamer reads
|
||||
// fail as INVALID_ARGUMENT and RAGE load jobs never complete while the
|
||||
// North Yankton UI keeps flipping.
|
||||
if (fileOffset == unchecked((ulong)(long)-1))
|
||||
{
|
||||
fileOffset = PakDirectoryTracker.ResolveSequentialOffset(fileId, size);
|
||||
}
|
||||
else if (fileOffset > long.MaxValue)
|
||||
{
|
||||
fileOffset = 0;
|
||||
}
|
||||
|
||||
var result = TryReadFileToGuestMemory(ctx, hostPath, fileOffset, destination, size, out var bytesRead);
|
||||
if (result != (int)OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
{
|
||||
@@ -281,6 +295,8 @@ public static class AmprExports
|
||||
return result;
|
||||
}
|
||||
|
||||
PakDirectoryTracker.OnReadCompleted(ctx, fileId, destination, fileOffset, bytesRead);
|
||||
|
||||
if (!AppendReadFileRecord(ctx, commandBuffer, fileId, destination, size, fileOffset, bytesRead))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
@@ -339,6 +355,18 @@ public static class AmprExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "4fgtGfXDrFc",
|
||||
ExportName = "sceAmprMeasureCommandSizeWriteAddress_04_00",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAmpr")]
|
||||
public static int MeasureCommandSizeWriteAddress0400(CpuContext ctx)
|
||||
{
|
||||
TraceAmpr(ctx, "measure_write_address", 0, WriteAddressRecordSize, 0);
|
||||
ctx[CpuRegister.Rax] = WriteAddressRecordSize;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "tZDDEo2tE5k",
|
||||
ExportName = "sceAmprCommandBufferGetSize",
|
||||
@@ -508,6 +536,32 @@ public static class AmprExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "j0+3uJMxYJY",
|
||||
ExportName = "sceAmprCommandBufferWriteAddress_04_00",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAmpr")]
|
||||
public static int CommandBufferWriteAddress0400(CpuContext ctx)
|
||||
{
|
||||
var commandBuffer = ctx[CpuRegister.Rdi];
|
||||
var address = ctx[CpuRegister.Rsi];
|
||||
var value = ctx[CpuRegister.Rdx];
|
||||
|
||||
if (commandBuffer == 0 || address == 0)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (!AppendWriteAddressRecord(ctx, commandBuffer, address, value))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
TraceAmpr(ctx, "write_address", commandBuffer, address, value);
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
public static int CompleteCommandBuffer(CpuContext ctx, ulong commandBuffer)
|
||||
{
|
||||
if (commandBuffer == 0)
|
||||
@@ -735,13 +789,7 @@ public static class AmprExports
|
||||
return openResult;
|
||||
}
|
||||
|
||||
long fileLength;
|
||||
lock (cachedFile.Gate)
|
||||
{
|
||||
fileLength = cachedFile.Stream.Length;
|
||||
}
|
||||
|
||||
if (fileOffset >= (ulong)fileLength)
|
||||
if (fileOffset >= (ulong)cachedFile.Length)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
@@ -760,12 +808,10 @@ public static class AmprExports
|
||||
}
|
||||
|
||||
var request = (int)Math.Min((ulong)buffer.Length, size - bytesRead);
|
||||
int read;
|
||||
lock (cachedFile.Gate)
|
||||
{
|
||||
cachedFile.Stream.Position = unchecked((long)absoluteOffset);
|
||||
read = cachedFile.Stream.Read(buffer, 0, request);
|
||||
}
|
||||
var read = RandomAccess.Read(
|
||||
cachedFile.Handle,
|
||||
buffer.AsSpan(0, request),
|
||||
unchecked((long)absoluteOffset));
|
||||
|
||||
if (read <= 0)
|
||||
{
|
||||
|
||||
@@ -17,10 +17,30 @@ public static class AjmExports
|
||||
private const int OrbisAjmErrorCodecAlreadyRegistered = unchecked((int)0x80930009);
|
||||
private const int OrbisAjmErrorCodecNotRegistered = unchecked((int)0x8093000A);
|
||||
private const int OrbisAjmErrorWrongRevisionFlag = unchecked((int)0x8093000B);
|
||||
private const uint MaxCodecType = 23;
|
||||
private const uint MaxCodecType = 25;
|
||||
private const int MaxInstanceIndex = 0x2FFF;
|
||||
private static readonly ConcurrentDictionary<uint, AjmContextState> Contexts = new();
|
||||
private static int _nextContextId;
|
||||
private static int _nextBatchId;
|
||||
|
||||
private const uint AjmCodecMp3 = 0;
|
||||
|
||||
private sealed class AjmInstanceState
|
||||
{
|
||||
public required uint Codec { get; init; }
|
||||
public required ulong Flags { get; init; }
|
||||
public AjmMp3Decoder? Mp3 { get; init; }
|
||||
|
||||
public bool PreferPcm16
|
||||
{
|
||||
get
|
||||
{
|
||||
// AjmInstanceFlags: version:3, channels:4, format:3
|
||||
var encoding = (Flags >> 7) & 0x7;
|
||||
return encoding is 0 or 1; // S16 / S32 — we emit S16 for both
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AjmContextState
|
||||
{
|
||||
@@ -28,7 +48,7 @@ public static class AjmExports
|
||||
|
||||
public HashSet<uint> RegisteredCodecs { get; } = new();
|
||||
|
||||
public Dictionary<uint, uint> InstancesBySlot { get; } = new();
|
||||
public Dictionary<uint, AjmInstanceState> InstancesBySlot { get; } = new();
|
||||
|
||||
public int NextInstanceIndex { get; set; }
|
||||
}
|
||||
@@ -168,7 +188,14 @@ public static class AjmExports
|
||||
}
|
||||
|
||||
state.NextInstanceIndex = nextInstanceIndex;
|
||||
state.InstancesBySlot.Add(instanceSlot, instanceId);
|
||||
state.InstancesBySlot.Add(
|
||||
instanceSlot,
|
||||
new AjmInstanceState
|
||||
{
|
||||
Codec = codecType,
|
||||
Flags = flags,
|
||||
Mp3 = codecType == AjmCodecMp3 ? new AjmMp3Decoder() : null,
|
||||
});
|
||||
}
|
||||
|
||||
Trace($"instance_create context={contextId} codec={codecType} flags=0x{flags:X} instance=0x{instanceId:X8}");
|
||||
@@ -227,10 +254,332 @@ public static class AjmExports
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueues a decode job on a batch. GTA V Enhanced streams menu music
|
||||
/// through AJM MP3 (codec 0); we decode eagerly here so BatchStart/Wait
|
||||
/// stay synchronous no-ops.
|
||||
/// </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);
|
||||
}
|
||||
|
||||
_ = TryAppendBatchJob(ctx, infoAddress, AjmJobRunSize);
|
||||
|
||||
var inputConsumed = 0;
|
||||
var outputWritten = 0;
|
||||
ulong totalSamples = 0;
|
||||
var frames = 0u;
|
||||
var decoded = false;
|
||||
|
||||
if (TryGetInstance(instanceId, out var instance) &&
|
||||
instance.Mp3 is not null &&
|
||||
inputAddress != 0 &&
|
||||
inputSize is > 0 and <= MaxSilentPcmBytes &&
|
||||
outputAddress != 0 &&
|
||||
outputSize is > 0 and <= MaxSilentPcmBytes)
|
||||
{
|
||||
var input = new byte[inputSize];
|
||||
var output = new byte[outputSize];
|
||||
if (ctx.Memory.TryRead(inputAddress, input))
|
||||
{
|
||||
var result = instance.Mp3.Decode(input, output, pcm16: instance.PreferPcm16);
|
||||
if (result.OutputWritten > 0)
|
||||
{
|
||||
if (!ctx.Memory.TryWrite(outputAddress, output.AsSpan(0, result.OutputWritten)))
|
||||
{
|
||||
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
|
||||
}
|
||||
|
||||
// Zero any remainder so stale PCM does not leak into FMOD.
|
||||
if ((ulong)result.OutputWritten < outputSize)
|
||||
{
|
||||
ClearGuestMemory(
|
||||
ctx,
|
||||
outputAddress + (ulong)result.OutputWritten,
|
||||
outputSize - (ulong)result.OutputWritten);
|
||||
}
|
||||
|
||||
decoded = true;
|
||||
inputConsumed = result.InputConsumed;
|
||||
outputWritten = result.OutputWritten;
|
||||
frames = result.Frames;
|
||||
totalSamples = instance.Mp3.TotalDecodedSamples;
|
||||
}
|
||||
else
|
||||
{
|
||||
inputConsumed = result.InputConsumed;
|
||||
totalSamples = instance.Mp3.TotalDecodedSamples;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!decoded)
|
||||
{
|
||||
// Fallback: silence + consume input so the guest does not spin.
|
||||
if (outputAddress != 0 && outputSize != 0 && outputSize <= MaxSilentPcmBytes)
|
||||
{
|
||||
ClearGuestMemory(ctx, outputAddress, outputSize);
|
||||
}
|
||||
|
||||
if (inputConsumed == 0)
|
||||
{
|
||||
inputConsumed = inputSize > int.MaxValue ? int.MaxValue : (int)inputSize;
|
||||
}
|
||||
|
||||
if (frames == 0 && (inputSize != 0 || outputSize != 0))
|
||||
{
|
||||
frames = 1;
|
||||
}
|
||||
}
|
||||
|
||||
WriteDecodeStreamResult(
|
||||
ctx,
|
||||
resultAddress,
|
||||
inputConsumed,
|
||||
outputWritten,
|
||||
totalSamples,
|
||||
frames);
|
||||
|
||||
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} " +
|
||||
$"written={outputWritten} frames={frames} result=0x{resultAddress:X16}");
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
private static bool TryGetInstance(uint instanceId, out AjmInstanceState instance)
|
||||
{
|
||||
instance = null!;
|
||||
var codec = instanceId >> 14;
|
||||
var slot = instanceId & 0x3FFF;
|
||||
if (slot == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var context in Contexts.Values)
|
||||
{
|
||||
lock (context.Gate)
|
||||
{
|
||||
if (context.InstancesBySlot.TryGetValue(slot, out var found) &&
|
||||
found.Codec == codec)
|
||||
{
|
||||
instance = found;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Submits a built batch. Hot path after BatchJobDecode; unresolved WARNs
|
||||
/// dominate the log. Instant-complete: 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 int OrbisAjmErrorJobCreation = unchecked((int)0x80930012);
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using NLayer;
|
||||
using System.Buffers.Binary;
|
||||
using System.Reflection;
|
||||
|
||||
namespace SharpEmu.Libs.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Stateful AJM MP3 (codec 0) decoder. GTA menu music arrives as ~960-byte
|
||||
/// packets that NLayer must decode with a persistent bit-reservoir.
|
||||
/// </summary>
|
||||
internal sealed class AjmMp3Decoder
|
||||
{
|
||||
private static readonly Type? MpegStreamReaderType =
|
||||
typeof(MpegFrameDecoder).Assembly.GetType("NLayer.Decoder.MpegStreamReader");
|
||||
|
||||
private static readonly MethodInfo? NextFrameMethod =
|
||||
MpegStreamReaderType?.GetMethod(
|
||||
"NextFrame",
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
|
||||
private static readonly MethodInfo? ClearBufferMethod =
|
||||
typeof(MpegFrameDecoder).Assembly.GetType("NLayer.Decoder.FrameBase")
|
||||
?.GetMethod("ClearBuffer", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
|
||||
private readonly MpegFrameDecoder _decoder = new();
|
||||
private readonly object _gate = new();
|
||||
private byte[] _pending = Array.Empty<byte>();
|
||||
private readonly float[] _floatScratch = new float[1152 * 2];
|
||||
|
||||
public ulong TotalDecodedSamples { get; private set; }
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_decoder.Reset();
|
||||
_pending = Array.Empty<byte>();
|
||||
TotalDecodedSamples = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public DecodeResult Decode(ReadOnlySpan<byte> input, Span<byte> output, bool pcm16)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (MpegStreamReaderType is null || NextFrameMethod is null)
|
||||
{
|
||||
return DecodeResult.Failed;
|
||||
}
|
||||
|
||||
var merged = new byte[_pending.Length + input.Length];
|
||||
if (_pending.Length != 0)
|
||||
{
|
||||
_pending.CopyTo(merged, 0);
|
||||
}
|
||||
|
||||
input.CopyTo(merged.AsSpan(_pending.Length));
|
||||
|
||||
using var stream = new MemoryStream(merged, writable: false);
|
||||
object? reader;
|
||||
try
|
||||
{
|
||||
reader = Activator.CreateInstance(
|
||||
MpegStreamReaderType,
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
|
||||
binder: null,
|
||||
args: [stream],
|
||||
culture: null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return DecodeResult.Failed;
|
||||
}
|
||||
|
||||
if (reader is null)
|
||||
{
|
||||
return DecodeResult.Failed;
|
||||
}
|
||||
|
||||
var outputOffset = 0;
|
||||
var inputConsumed = 0;
|
||||
var frames = 0u;
|
||||
var samplesThisCall = 0u;
|
||||
|
||||
while (outputOffset < output.Length)
|
||||
{
|
||||
object? frameObj;
|
||||
try
|
||||
{
|
||||
frameObj = NextFrameMethod.Invoke(reader, null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (frameObj is not IMpegFrame frame)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var frameOffset = GetFrameOffset(frameObj);
|
||||
var frameLength = frame.FrameLength;
|
||||
if (frameLength <= 0 || frameOffset + frameLength > merged.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
int sampleCount;
|
||||
try
|
||||
{
|
||||
sampleCount = _decoder.DecodeFrame(frame, _floatScratch, 0);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_decoder.Reset();
|
||||
inputConsumed = frameOffset + frameLength;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sampleCount <= 0)
|
||||
{
|
||||
inputConsumed = frameOffset + frameLength;
|
||||
continue;
|
||||
}
|
||||
|
||||
var channels = frame.ChannelMode == MpegChannelMode.Mono ? 1 : 2;
|
||||
var bytesPerSample = pcm16 ? 2 : 4;
|
||||
var byteCount = sampleCount * bytesPerSample;
|
||||
if (outputOffset + byteCount > output.Length)
|
||||
{
|
||||
// Not enough room for this frame — leave it for next job.
|
||||
break;
|
||||
}
|
||||
|
||||
if (pcm16)
|
||||
{
|
||||
WritePcm16(_floatScratch.AsSpan(0, sampleCount), output[outputOffset..]);
|
||||
}
|
||||
else
|
||||
{
|
||||
WriteFloat(_floatScratch.AsSpan(0, sampleCount), output[outputOffset..]);
|
||||
}
|
||||
|
||||
outputOffset += byteCount;
|
||||
inputConsumed = frameOffset + frameLength;
|
||||
frames++;
|
||||
samplesThisCall += (uint)(sampleCount / Math.Max(channels, 1));
|
||||
TotalDecodedSamples += (ulong)(sampleCount / Math.Max(channels, 1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
ClearBufferMethod?.Invoke(frameObj, null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_pending = inputConsumed < merged.Length
|
||||
? merged[inputConsumed..]
|
||||
: Array.Empty<byte>();
|
||||
|
||||
// Consume the portion of *this* input that left the pending window.
|
||||
var pendingBefore = merged.Length - input.Length;
|
||||
var consumedFromInput = Math.Clamp(inputConsumed - pendingBefore, 0, input.Length);
|
||||
|
||||
return new DecodeResult(
|
||||
Success: frames > 0 || consumedFromInput > 0,
|
||||
InputConsumed: consumedFromInput,
|
||||
OutputWritten: outputOffset,
|
||||
Frames: frames,
|
||||
SamplesThisCall: samplesThisCall);
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetFrameOffset(object frameObj)
|
||||
{
|
||||
for (var type = frameObj.GetType(); type is not null; type = type.BaseType)
|
||||
{
|
||||
var prop = type.GetProperty(
|
||||
"Offset",
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly);
|
||||
if (prop?.GetValue(frameObj) is long offset)
|
||||
{
|
||||
return checked((int)offset);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static void WritePcm16(ReadOnlySpan<float> samples, Span<byte> destination)
|
||||
{
|
||||
for (var i = 0; i < samples.Length; i++)
|
||||
{
|
||||
var sample = samples[i];
|
||||
var scaled = sample < 0f ? sample * 32768f : sample * 32767f;
|
||||
var value = (short)Math.Clamp(MathF.Round(scaled), short.MinValue, short.MaxValue);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(destination[(i * 2)..], value);
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteFloat(ReadOnlySpan<float> samples, Span<byte> destination)
|
||||
{
|
||||
for (var i = 0; i < samples.Length; i++)
|
||||
{
|
||||
var bits = BitConverter.SingleToInt32Bits(samples[i]);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(destination[(i * 4)..], bits);
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct DecodeResult(
|
||||
bool Success,
|
||||
int InputConsumed,
|
||||
int OutputWritten,
|
||||
uint Frames,
|
||||
uint SamplesThisCall)
|
||||
{
|
||||
public static DecodeResult Failed { get; } = new(false, 0, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.HLE.Host;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
|
||||
@@ -11,8 +12,17 @@ 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.
|
||||
@@ -56,6 +66,7 @@ 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);
|
||||
@@ -83,7 +94,24 @@ public static class AudioOutExports
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => Backend?.Dispose();
|
||||
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;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
@@ -115,9 +143,18 @@ public static class AudioOutExports
|
||||
string backendName;
|
||||
try
|
||||
{
|
||||
var audio = HostPlatform.Current.Audio;
|
||||
backend = audio.OpenStereoPcm16Stream(frequency);
|
||||
backendName = audio.BackendName;
|
||||
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;
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -175,6 +212,14 @@ public static class AudioOutExports
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
// Same rule as AudioOut2 PortGetState: never bulk-write onto the caller
|
||||
// stack. Some titles place small locals next to the canary; a full
|
||||
// SceAudioOutPortState write smashes it.
|
||||
if (IsGuestStackAddress(stateAddress))
|
||||
{
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -192,6 +237,49 @@ public static class AudioOutExports
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
private static bool IsGuestStackAddress(ulong value) =>
|
||||
value >= 0x0000_7FF0_0000_0000UL && value <= 0x0000_7FFF_FFFF_FFFFUL;
|
||||
|
||||
[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",
|
||||
@@ -225,16 +313,7 @@ public static class AudioOutExports
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
if (_traceOutput)
|
||||
{
|
||||
var n = Interlocked.Increment(ref _outputCount);
|
||||
if (n <= 8 || n % 200 == 0)
|
||||
{
|
||||
var peak = PeakAmplitude(source, port.IsFloat, port.BytesPerSample);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] audioout.output#{n} handle={handle} bytes={source.Length} ch={port.Channels} float={port.IsFloat} vol={port.Volume:F2} peak={peak:F4} backend={(port.Backend is null ? "none" : "coreaudio")}");
|
||||
}
|
||||
}
|
||||
TraceOutput(handle, port, source);
|
||||
|
||||
if (port.Backend is null)
|
||||
{
|
||||
@@ -272,6 +351,184 @@ 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",
|
||||
@@ -362,6 +619,25 @@ 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(
|
||||
|
||||
@@ -25,6 +25,10 @@ internal static class AudioPcmConversion
|
||||
float volume)
|
||||
{
|
||||
var sourceFrameSize = checked(channels * bytesPerSample);
|
||||
// Volume is constant for the whole submission, so clamp it once here
|
||||
// rather than per sample inside the loop (this runs on every real-time
|
||||
// audio buffer, hundreds of frames at a time).
|
||||
var clampedVolume = Math.Clamp(volume, 0.0f, 1.0f);
|
||||
for (var frame = 0; frame < frames; frame++)
|
||||
{
|
||||
var sourceFrame = source.Slice(frame * sourceFrameSize, sourceFrameSize);
|
||||
@@ -32,8 +36,8 @@ internal static class AudioPcmConversion
|
||||
var right = channels == 1
|
||||
? left
|
||||
: ReadSample(sourceFrame, 1, bytesPerSample, isFloat);
|
||||
left = ApplyVolume(left, volume);
|
||||
right = ApplyVolume(right, volume);
|
||||
left = ApplyVolume(left, clampedVolume);
|
||||
right = ApplyVolume(right, clampedVolume);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(destination[(frame * OutputFrameSize)..], left);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(destination[((frame * OutputFrameSize) + 2)..], right);
|
||||
}
|
||||
@@ -67,9 +71,10 @@ internal static class AudioPcmConversion
|
||||
return checked((short)MathF.Round(value * scale));
|
||||
}
|
||||
|
||||
// <paramref name="volume"/> is expected pre-clamped to [0, 1] by the caller.
|
||||
private static short ApplyVolume(short sample, float volume)
|
||||
{
|
||||
var scaled = MathF.Round(sample * Math.Clamp(volume, 0.0f, 1.0f));
|
||||
var scaled = MathF.Round(sample * volume);
|
||||
return (short)Math.Clamp(scaled, short.MinValue, short.MaxValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ public static class AvPlayerExports
|
||||
private const int FrameBufferCount = 3;
|
||||
private const int FrameInfoSize = 40;
|
||||
private const int FrameInfoExSize = 104;
|
||||
private const int StreamInfoSize = 40;
|
||||
// This structure is 32 bytes. A larger write can damage the guest stack.
|
||||
private const int StreamInfoSize = 32;
|
||||
private const int StreamInfoExSize = 32;
|
||||
private const int MaxGuestPathLength = 4096;
|
||||
private static readonly object StateGate = new();
|
||||
private static readonly Dictionary<ulong, PlayerState> Players = new();
|
||||
@@ -404,7 +406,8 @@ public static class AvPlayerExports
|
||||
ExportName = "sceAvPlayerGetStreamInfoEx",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAvPlayer")]
|
||||
public static int AvPlayerSetDecoderMode(CpuContext ctx) => ValidatePlayer(ctx);
|
||||
public static int AvPlayerGetStreamInfoEx(CpuContext ctx) =>
|
||||
GetStreamInfoCore(ctx, StreamInfoExSize);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "XC9wM+xULz8",
|
||||
@@ -561,12 +564,48 @@ public static class AvPlayerExports
|
||||
}
|
||||
}
|
||||
|
||||
internal static void RegisterPlayerForTest(
|
||||
ulong handle,
|
||||
int width,
|
||||
int height,
|
||||
ulong durationMilliseconds)
|
||||
{
|
||||
PlayerState? previous;
|
||||
lock (StateGate)
|
||||
{
|
||||
Players.Remove(handle, out previous);
|
||||
Players[handle] = new PlayerState
|
||||
{
|
||||
Handle = handle,
|
||||
Width = width,
|
||||
Height = height,
|
||||
DurationMilliseconds = durationMilliseconds,
|
||||
};
|
||||
}
|
||||
|
||||
previous?.Dispose();
|
||||
}
|
||||
|
||||
internal static void RemovePlayerForTest(ulong handle)
|
||||
{
|
||||
PlayerState? player;
|
||||
lock (StateGate)
|
||||
{
|
||||
Players.Remove(handle, out player);
|
||||
}
|
||||
|
||||
player?.Dispose();
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "d8FcbzfAdQw",
|
||||
ExportName = "sceAvPlayerGetStreamInfo",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAvPlayer")]
|
||||
public static int AvPlayerGetStreamInfo(CpuContext ctx)
|
||||
public static int AvPlayerGetStreamInfo(CpuContext ctx) =>
|
||||
GetStreamInfoCore(ctx, StreamInfoSize);
|
||||
|
||||
private static int GetStreamInfoCore(CpuContext ctx, int infoSize)
|
||||
{
|
||||
var streamIndex = unchecked((uint)ctx[CpuRegister.Rsi]);
|
||||
var infoAddress = ctx[CpuRegister.Rdx];
|
||||
@@ -578,7 +617,7 @@ public static class AvPlayerExports
|
||||
return SetReturn(ctx, InvalidParameters);
|
||||
}
|
||||
|
||||
Span<byte> info = stackalloc byte[StreamInfoSize];
|
||||
Span<byte> info = stackalloc byte[infoSize];
|
||||
info.Clear();
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(info[0..], streamIndex); // 0=video, 1=audio
|
||||
if (streamIndex == 0)
|
||||
@@ -1009,7 +1048,7 @@ public static class AvPlayerExports
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var ffprobe = Path.Combine(Path.GetDirectoryName(ffmpeg) ?? string.Empty, "ffprobe");
|
||||
var ffprobe = GetFfprobePath(ffmpeg, OperatingSystem.IsWindows());
|
||||
if (!File.Exists(ffprobe))
|
||||
{
|
||||
return false;
|
||||
@@ -1092,13 +1131,50 @@ public static class AvPlayerExports
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindFfmpeg()
|
||||
internal static string? FindFfmpeg() =>
|
||||
FindFfmpeg(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH"),
|
||||
Environment.GetEnvironmentVariable("PATH"),
|
||||
OperatingSystem.IsWindows(),
|
||||
AppContext.BaseDirectory);
|
||||
|
||||
internal static string? FindFfmpeg(
|
||||
string? configured,
|
||||
string? searchPath,
|
||||
bool isWindows,
|
||||
string? baseDirectory = null)
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH");
|
||||
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
|
||||
{
|
||||
return configured;
|
||||
}
|
||||
|
||||
var executable = isWindows ? "ffmpeg.exe" : "ffmpeg";
|
||||
if (!string.IsNullOrWhiteSpace(baseDirectory))
|
||||
{
|
||||
foreach (var candidate in new[]
|
||||
{
|
||||
Path.Combine(baseDirectory, executable),
|
||||
Path.Combine(baseDirectory, "ffmpeg", executable),
|
||||
})
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var directory in (searchPath ?? string.Empty)
|
||||
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var candidate = Path.Combine(RemovePathQuotes(directory), executable);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var candidate in new[] { "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg" })
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
@@ -1109,6 +1185,16 @@ public static class AvPlayerExports
|
||||
return null;
|
||||
}
|
||||
|
||||
internal static string GetFfprobePath(string ffmpeg, bool isWindows) =>
|
||||
Path.Combine(
|
||||
Path.GetDirectoryName(ffmpeg) ?? string.Empty,
|
||||
isWindows ? "ffprobe.exe" : "ffprobe");
|
||||
|
||||
private static string RemovePathQuotes(string directory) =>
|
||||
directory.Length >= 2 && directory[0] == '"' && directory[^1] == '"'
|
||||
? directory[1..^1]
|
||||
: directory;
|
||||
|
||||
internal static string? ResolveGuestPath(string guestPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(guestPath))
|
||||
@@ -1118,7 +1204,9 @@ public static class AvPlayerExports
|
||||
|
||||
var normalized = guestPath.Replace('\\', '/');
|
||||
var fileReference = normalized.StartsWith("file:", StringComparison.OrdinalIgnoreCase);
|
||||
var unrealProjectRelative = false;
|
||||
var unrealProjectRelative =
|
||||
normalized.StartsWith("../", StringComparison.Ordinal) ||
|
||||
normalized.StartsWith("./", StringComparison.Ordinal);
|
||||
if (normalized.StartsWith("file://", StringComparison.OrdinalIgnoreCase) &&
|
||||
Uri.TryCreate(normalized, UriKind.Absolute, out var uri) &&
|
||||
uri.IsFile)
|
||||
@@ -1149,7 +1237,10 @@ public static class AvPlayerExports
|
||||
|
||||
if (unrealProjectRelative)
|
||||
{
|
||||
normalized = RemoveUnrealLeadingDotSegments(normalized);
|
||||
if (!TryRemoveUnrealLeadingDotSegments(normalized, out normalized))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var app0 = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
|
||||
@@ -1233,15 +1324,20 @@ public static class AvPlayerExports
|
||||
}
|
||||
}
|
||||
|
||||
private static string RemoveUnrealLeadingDotSegments(string guestPath)
|
||||
private static bool TryRemoveUnrealLeadingDotSegments(
|
||||
string guestPath,
|
||||
out string normalized)
|
||||
{
|
||||
var removedParent = false;
|
||||
while (guestPath.StartsWith("../", StringComparison.Ordinal) ||
|
||||
guestPath.StartsWith("./", StringComparison.Ordinal))
|
||||
{
|
||||
removedParent |= guestPath.StartsWith("../", StringComparison.Ordinal);
|
||||
guestPath = guestPath[(guestPath.IndexOf('/') + 1)..];
|
||||
}
|
||||
|
||||
return guestPath;
|
||||
normalized = guestPath;
|
||||
return !removedParent || guestPath.Contains('/');
|
||||
}
|
||||
|
||||
private static bool TryDecodeFileReference(string encoded, out string decoded)
|
||||
|
||||
@@ -18,123 +18,153 @@ namespace SharpEmu.Libs.Bink;
|
||||
internal static class Bink2MovieBridge
|
||||
{
|
||||
private const uint MaxDimension = 16384;
|
||||
private const uint MaxHostVideoWidth = 1920;
|
||||
private const uint MaxHostVideoHeight = 1080;
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static NativeAdapter? _adapter;
|
||||
private static string? _activePath;
|
||||
private static IntPtr _activeMovie;
|
||||
private static Bink2MovieInfo _activeInfo;
|
||||
private static byte[]? _frameBuffer;
|
||||
private static bool _usingDummyMovie;
|
||||
private static bool _loadAttempted;
|
||||
private static bool _availabilityReported;
|
||||
private static bool _frameBufferPresented;
|
||||
private static BinkFramePlayback? _playback;
|
||||
private static long _frameSerial;
|
||||
private static uint _presentationWidth = MaxHostVideoWidth;
|
||||
private static uint _presentationHeight = MaxHostVideoHeight;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the guest should receive a normal "file not found"
|
||||
/// result for a Bink movie. This is the safe default without a decoder:
|
||||
/// games that treat movies as optional fall through to their next state
|
||||
/// rather than submitting an empty Bink GPU texture forever.
|
||||
/// </summary>
|
||||
internal static bool ShouldSkipGuestMovie(string hostPath) =>
|
||||
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
|
||||
ResolveMode() == MovieMode.Skip;
|
||||
|
||||
internal static void ObserveGuestMovie(string hostPath)
|
||||
internal static bool IsHostPlaybackActive
|
||||
{
|
||||
if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
|
||||
!File.Exists(hostPath))
|
||||
get
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
return _playback is not null || _frameBuffer is not null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static void SetPresentationSize(uint width, uint height)
|
||||
{
|
||||
if (width == 0 || height == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
_presentationWidth = Math.Min(width, MaxHostVideoWidth);
|
||||
_presentationHeight = Math.Min(height, MaxHostVideoHeight);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true only when movie skipping was explicitly requested. Without
|
||||
/// a host adapter the guest must be allowed to run the Bink implementation
|
||||
/// statically linked into its executable.
|
||||
/// </summary>
|
||||
internal static bool ShouldSkipGuestMovie(string hostPath) =>
|
||||
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
|
||||
ResolveMode() == MovieMode.Skip;
|
||||
|
||||
/// <summary>
|
||||
/// Starts or queues host decoding. Decoded frames are only exposed as a
|
||||
/// sampled guest texture; presentation and UI composition remain guest-owned.
|
||||
/// </summary>
|
||||
internal static bool ObserveGuestMovie(string hostPath)
|
||||
{
|
||||
if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
|
||||
!File.Exists(hostPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
if (string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
return _playback is not null || _frameBuffer is not null;
|
||||
}
|
||||
|
||||
if (ResolveMode() == MovieMode.Dummy)
|
||||
var mode = ResolveMode();
|
||||
if (mode is MovieMode.Guest or MovieMode.Skip)
|
||||
{
|
||||
AttachDummyMovieLocked(hostPath);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
var adapter = GetAdapterLocked();
|
||||
if (adapter is null)
|
||||
if (_playback is not null || _frameBuffer is not null)
|
||||
{
|
||||
return;
|
||||
if (PendingMoviePathSet.Add(hostPath))
|
||||
{
|
||||
PendingMoviePaths.Enqueue(hostPath);
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge queued: " +
|
||||
Path.GetFileName(hostPath));
|
||||
}
|
||||
return PendingMoviePathSet.Contains(hostPath);
|
||||
}
|
||||
|
||||
CloseActiveLocked();
|
||||
if (!adapter.TryOpen(hostPath, out var movie, out var info))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink2 bridge could not open movie '" +
|
||||
Path.GetFileName(hostPath) + "'.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsValid(info))
|
||||
{
|
||||
adapter.Close(movie);
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink2 bridge rejected invalid movie dimensions for '" +
|
||||
Path.GetFileName(hostPath) + "'.");
|
||||
return;
|
||||
}
|
||||
|
||||
_activePath = hostPath;
|
||||
_activeMovie = movie;
|
||||
_activeInfo = info;
|
||||
_frameBuffer = GC.AllocateUninitializedArray<byte>(GetFrameBufferLength(info));
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge attached: " + Path.GetFileName(hostPath) + " " +
|
||||
info.Width + "x" + info.Height + " @ " +
|
||||
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
|
||||
AttachMovieLocked(hostPath, mode);
|
||||
return string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase) &&
|
||||
(_playback is not null || _frameBuffer is not null);
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryDecodeNextFrame(
|
||||
bool advanceClock,
|
||||
out byte[] pixels,
|
||||
out uint width,
|
||||
out uint height)
|
||||
out uint height,
|
||||
out bool advanced,
|
||||
out long frameSerial,
|
||||
out string hostPath)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
pixels = [];
|
||||
width = 0;
|
||||
height = 0;
|
||||
if (_adapter is null || _activeMovie == IntPtr.Zero || _frameBuffer is null)
|
||||
advanced = false;
|
||||
frameSerial = _frameSerial;
|
||||
hostPath = _activePath ?? string.Empty;
|
||||
|
||||
if (_playback is not null)
|
||||
{
|
||||
if (_usingDummyMovie && _frameBuffer is not null)
|
||||
if (!_playback.TryGetFrame(advanceClock, out pixels, out advanced))
|
||||
{
|
||||
pixels = _frameBuffer;
|
||||
width = _activeInfo.Width;
|
||||
height = _activeInfo.Height;
|
||||
return true;
|
||||
if (_playback.IsFinished)
|
||||
{
|
||||
var completedPath = _activePath;
|
||||
CloseActiveLocked();
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge completed: " +
|
||||
Path.GetFileName(completedPath));
|
||||
AttachNextQueuedMovieLocked();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
width = _activeInfo.Width;
|
||||
height = _activeInfo.Height;
|
||||
if (advanced)
|
||||
{
|
||||
frameSerial = ++_frameSerial;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
unsafe
|
||||
if (_frameBuffer is null)
|
||||
{
|
||||
fixed (byte* destination = _frameBuffer)
|
||||
{
|
||||
if (!_adapter.DecodeNextBgra(
|
||||
_activeMovie,
|
||||
(IntPtr)destination,
|
||||
_activeInfo.Width * 4,
|
||||
(uint)_frameBuffer.Length))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
pixels = _frameBuffer;
|
||||
width = _activeInfo.Width;
|
||||
height = _activeInfo.Height;
|
||||
advanced = !_frameBufferPresented;
|
||||
_frameBufferPresented = true;
|
||||
if (advanced)
|
||||
{
|
||||
frameSerial = ++_frameSerial;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -147,6 +177,52 @@ internal static class Bink2MovieBridge
|
||||
private static int GetFrameBufferLength(Bink2MovieInfo info) =>
|
||||
checked((int)((ulong)info.Width * info.Height * 4));
|
||||
|
||||
private static void AttachMovieLocked(string hostPath, MovieMode mode)
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case MovieMode.Dummy:
|
||||
AttachDummyMovieLocked(hostPath);
|
||||
return;
|
||||
case MovieMode.Ffmpeg:
|
||||
AttachFfmpegMovieLocked(hostPath);
|
||||
return;
|
||||
case MovieMode.Native:
|
||||
AttachNativeMovieLocked(hostPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AttachNativeMovieLocked(string hostPath)
|
||||
{
|
||||
if (!FfmpegNativeBinkFrameSource.TryOpen(
|
||||
hostPath, _presentationWidth, _presentationHeight, out var source) ||
|
||||
source is null)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink2 bridge could not open movie '" +
|
||||
Path.GetFileName(hostPath) + "'.");
|
||||
return;
|
||||
}
|
||||
|
||||
var info = new Bink2MovieInfo(
|
||||
source.Width, source.Height, source.FramesPerSecondNumerator, source.FramesPerSecondDenominator);
|
||||
if (!IsValid(info))
|
||||
{
|
||||
source.Dispose();
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink2 bridge rejected invalid movie dimensions for '" +
|
||||
Path.GetFileName(hostPath) + "'.");
|
||||
return;
|
||||
}
|
||||
|
||||
AttachPlaybackLocked(hostPath, info, source);
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge attached: " + Path.GetFileName(hostPath) + " " +
|
||||
info.Width + "x" + info.Height + " @ " +
|
||||
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
|
||||
}
|
||||
|
||||
private static MovieMode ResolveMode()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("SHARPEMU_BINK_MODE");
|
||||
@@ -165,16 +241,22 @@ internal static class Bink2MovieBridge
|
||||
return MovieMode.Skip;
|
||||
}
|
||||
|
||||
// With no SDK adapter present, returning "not found" makes optional
|
||||
// cinematics advance. Supplying either an explicit path or the normal
|
||||
// side-by-side adapter enables native playback automatically.
|
||||
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE")) ||
|
||||
EnumerateAdapterCandidates().Any(File.Exists))
|
||||
if (string.Equals(configured, "guest", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MovieMode.Native;
|
||||
return MovieMode.Guest;
|
||||
}
|
||||
|
||||
return MovieMode.Skip;
|
||||
if (string.Equals(configured, "ffmpeg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MovieMode.Ffmpeg;
|
||||
}
|
||||
|
||||
// Native is the default: FfmpegNativeBinkFrameSource.TryOpen degrades
|
||||
// gracefully (falls back to the guest's own decode, logging one
|
||||
// informational line) if the FFmpeg libraries SharpEmu.CLI.csproj
|
||||
// downloads next to the executable are genuinely unavailable, so
|
||||
// defaulting to Native unconditionally is safe.
|
||||
return MovieMode.Native;
|
||||
}
|
||||
|
||||
private static void AttachDummyMovieLocked(string hostPath)
|
||||
@@ -191,22 +273,61 @@ internal static class Bink2MovieBridge
|
||||
_activePath = hostPath;
|
||||
_activeInfo = info;
|
||||
_frameBuffer = GC.AllocateUninitializedArray<byte>(GetFrameBufferLength(info));
|
||||
_frameBufferPresented = false;
|
||||
FillDummyFrame(_frameBuffer, info.Width, info.Height);
|
||||
_usingDummyMovie = true;
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink dummy attached: " + Path.GetFileName(hostPath) + " " +
|
||||
info.Width + "x" + info.Height + ".");
|
||||
}
|
||||
|
||||
private static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
|
||||
private static void AttachFfmpegMovieLocked(string hostPath)
|
||||
{
|
||||
if (!TryReadBinkInfo(hostPath, out var info) || !IsValid(info))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink FFmpeg source has an invalid header: " +
|
||||
Path.GetFileName(hostPath));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!FfmpegBinkFrameSource.TryOpen(
|
||||
hostPath,
|
||||
info.Width,
|
||||
info.Height,
|
||||
info.FramesPerSecondNumerator,
|
||||
info.FramesPerSecondDenominator,
|
||||
out var source) || source is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AttachPlaybackLocked(hostPath, info, source);
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink FFmpeg source attached: " +
|
||||
Path.GetFileName(hostPath) + " " + info.Width + "x" + info.Height + " @ " +
|
||||
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
|
||||
}
|
||||
|
||||
private static void AttachPlaybackLocked(
|
||||
string hostPath,
|
||||
Bink2MovieInfo info,
|
||||
IBinkFrameDecoder decoder)
|
||||
{
|
||||
CloseActiveLocked();
|
||||
_activePath = hostPath;
|
||||
_activeInfo = info;
|
||||
_playback = new BinkFramePlayback(decoder);
|
||||
}
|
||||
|
||||
internal static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
|
||||
{
|
||||
info = default;
|
||||
Span<byte> header = stackalloc byte[32];
|
||||
Span<byte> header = stackalloc byte[36];
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
if (stream.Read(header) != header.Length ||
|
||||
!header[..4].SequenceEqual("KB2j"u8))
|
||||
stream.ReadExactly(header);
|
||||
if (!header[..3].SequenceEqual("KB2"u8))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -215,10 +336,11 @@ internal static class Bink2MovieBridge
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x14, 4)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x18, 4)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x1C, 4)),
|
||||
1);
|
||||
return true;
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x20, 4)));
|
||||
return info.FramesPerSecondNumerator != 0 &&
|
||||
info.FramesPerSecondDenominator != 0;
|
||||
}
|
||||
catch (IOException)
|
||||
catch (Exception exception) when (exception is IOException or EndOfStreamException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -240,80 +362,23 @@ internal static class Bink2MovieBridge
|
||||
}
|
||||
}
|
||||
|
||||
private static NativeAdapter? GetAdapterLocked()
|
||||
{
|
||||
if (_loadAttempted)
|
||||
{
|
||||
return _adapter;
|
||||
}
|
||||
|
||||
_loadAttempted = true;
|
||||
foreach (var candidate in EnumerateAdapterCandidates())
|
||||
{
|
||||
if (!NativeLibrary.TryLoad(candidate, out var library))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (NativeAdapter.TryCreate(library, out var adapter))
|
||||
{
|
||||
_adapter = adapter;
|
||||
Console.Error.WriteLine("[LOADER][INFO] Bink2 bridge loaded: " + candidate);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
NativeLibrary.Free(library);
|
||||
}
|
||||
|
||||
if (!_availabilityReported)
|
||||
{
|
||||
_availabilityReported = true;
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge unavailable; install the licensed adapter and set SHARPEMU_BINK2_BRIDGE.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateAdapterCandidates()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE");
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
yield return configured;
|
||||
}
|
||||
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
yield return Path.Combine(baseDirectory, "libsharpemu_bink2_bridge.dylib");
|
||||
}
|
||||
else if (OperatingSystem.IsWindows())
|
||||
{
|
||||
yield return Path.Combine(baseDirectory, "sharpemu_bink2_bridge.dll");
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return Path.Combine(baseDirectory, "libsharpemu_bink2_bridge.so");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CloseActiveLocked()
|
||||
{
|
||||
if (_activeMovie != IntPtr.Zero)
|
||||
{
|
||||
_adapter?.Close(_activeMovie);
|
||||
}
|
||||
|
||||
_playback?.Dispose();
|
||||
_playback = null;
|
||||
_activePath = null;
|
||||
_activeMovie = IntPtr.Zero;
|
||||
_activeInfo = default;
|
||||
_frameBuffer = null;
|
||||
_usingDummyMovie = false;
|
||||
_frameBufferPresented = false;
|
||||
|
||||
// Wake any guest _read() blocked in WaitForHostPlaybackToFinish: its
|
||||
// movie either just finished or is being pre-empted by a new attach.
|
||||
Monitor.PulseAll(Gate);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private readonly struct Bink2MovieInfo
|
||||
internal readonly struct Bink2MovieInfo
|
||||
{
|
||||
public readonly uint Width;
|
||||
public readonly uint Height;
|
||||
@@ -335,69 +400,226 @@ internal static class Bink2MovieBridge
|
||||
|
||||
private enum MovieMode
|
||||
{
|
||||
Guest,
|
||||
Skip,
|
||||
Dummy,
|
||||
Native,
|
||||
Ffmpeg,
|
||||
}
|
||||
|
||||
private sealed class NativeAdapter
|
||||
private static readonly Queue<string> PendingMoviePaths = new();
|
||||
private static readonly HashSet<string> PendingMoviePathSet =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private static void AttachNextQueuedMovieLocked()
|
||||
{
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int OpenUtf8Delegate(IntPtr pathUtf8, out IntPtr movie, out Bink2MovieInfo info);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int DecodeNextBgraDelegate(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void CloseDelegate(IntPtr movie);
|
||||
|
||||
private readonly OpenUtf8Delegate _openUtf8;
|
||||
private readonly DecodeNextBgraDelegate _decodeNextBgra;
|
||||
private readonly CloseDelegate _close;
|
||||
|
||||
private NativeAdapter(
|
||||
OpenUtf8Delegate openUtf8,
|
||||
DecodeNextBgraDelegate decodeNextBgra,
|
||||
CloseDelegate close)
|
||||
while (PendingMoviePaths.Count > 0)
|
||||
{
|
||||
_openUtf8 = openUtf8;
|
||||
_decodeNextBgra = decodeNextBgra;
|
||||
_close = close;
|
||||
var path = PendingMoviePaths.Dequeue();
|
||||
PendingMoviePathSet.Remove(path);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AttachMovieLocked(path, ResolveMode());
|
||||
if (_playback is not null || _frameBuffer is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryCreate(IntPtr library, out NativeAdapter? adapter)
|
||||
}
|
||||
// Longest a guest _read() will block waiting for real host playback to
|
||||
// finish. A safety net, not a target: real movies finish well under
|
||||
// this. Bounds the damage if a movie fails to attach/decode after being
|
||||
// queued, so the guest thread doesn't hang forever.
|
||||
private const long MaxCompletionWaitMilliseconds = 5 * 60 * 1000;
|
||||
/// <summary>
|
||||
/// Blocks the calling (guest I/O) thread until the host has actually
|
||||
/// finished presenting <paramref name="hostPath"/> — either because it
|
||||
/// played through, or because something else took over the timeline.
|
||||
///
|
||||
/// The completion shim tells the guest's own Bink header parse "this
|
||||
/// movie is one frame and already done" so its native decoder never
|
||||
/// blocks the guest on real per-frame work. Without this wait, that lie
|
||||
/// lands the instant the guest reads the header, so guest-side game
|
||||
/// logic races far ahead of whatever the host is still showing on
|
||||
/// screen: pressing a button lands on the (already-advanced) guest
|
||||
/// state, but the video visibly keeps playing, and any real-time-gated
|
||||
/// trigger later in the guest's own flow can fire against a clock that
|
||||
/// no longer matches wall time. Gating the "done" read on real host
|
||||
/// completion keeps guest pacing and on-screen playback in lockstep.
|
||||
/// </summary>
|
||||
internal static void WaitForHostPlaybackToFinish(string hostPath)
|
||||
{
|
||||
var deadline = Environment.TickCount64 + MaxCompletionWaitMilliseconds;
|
||||
lock (Gate)
|
||||
{
|
||||
adapter = null;
|
||||
if (!NativeLibrary.TryGetExport(library, "sharpemu_bink2_open_utf8", out var open) ||
|
||||
!NativeLibrary.TryGetExport(library, "sharpemu_bink2_decode_next_bgra", out var decode) ||
|
||||
!NativeLibrary.TryGetExport(library, "sharpemu_bink2_close", out var close))
|
||||
while (IsTrackedLocked(hostPath))
|
||||
{
|
||||
var remaining = deadline - Environment.TickCount64;
|
||||
if (remaining <= 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink2 bridge completion wait timed out for '" +
|
||||
Path.GetFileName(hostPath) + "'.");
|
||||
return;
|
||||
}
|
||||
|
||||
Monitor.Wait(Gate, (int)Math.Min(remaining, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTrackedLocked(string hostPath) =>
|
||||
string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
PendingMoviePathSet.Contains(hostPath);
|
||||
|
||||
internal static bool TryTakeOverGuestMovie(
|
||||
string hostPath,
|
||||
out BinkGuestCompletionShim completionShim,
|
||||
out bool observed)
|
||||
{
|
||||
completionShim = default;
|
||||
observed = ObserveGuestMovie(hostPath);
|
||||
|
||||
// Keep the real header visible so the guest creates its movie surface
|
||||
// and draw. Host-decoded pixels replace that sampled image later; a
|
||||
// one-frame completion shim would finish before the descriptor exists.
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static void NotifyGuestMovieClosed(string hostPath)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (PendingMoviePathSet.Remove(hostPath))
|
||||
{
|
||||
var retained = PendingMoviePaths
|
||||
.Where(path => !string.Equals(
|
||||
path,
|
||||
hostPath,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
PendingMoviePaths.Clear();
|
||||
foreach (var path in retained)
|
||||
{
|
||||
PendingMoviePaths.Enqueue(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Monitor.PulseAll(Gate);
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge stopped by guest close: " +
|
||||
Path.GetFileName(hostPath));
|
||||
CloseActiveLocked();
|
||||
AttachNextQueuedMovieLocked();
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryReadGuestCompletionShim(
|
||||
string hostPath,
|
||||
out BinkGuestCompletionShim completionShim)
|
||||
{
|
||||
completionShim = default;
|
||||
Span<byte> header = stackalloc byte[48];
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(hostPath);
|
||||
stream.ReadExactly(header);
|
||||
if (!header[..3].SequenceEqual("KB2"u8))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
adapter = new NativeAdapter(
|
||||
Marshal.GetDelegateForFunctionPointer<OpenUtf8Delegate>(open),
|
||||
Marshal.GetDelegateForFunctionPointer<DecodeNextBgraDelegate>(decode),
|
||||
Marshal.GetDelegateForFunctionPointer<CloseDelegate>(close));
|
||||
var frameCount = BinaryPrimitives.ReadUInt32LittleEndian(header[8..12]);
|
||||
var audioTrackCount = BinaryPrimitives.ReadUInt32LittleEndian(header[40..44]);
|
||||
if (frameCount < 2 || audioTrackCount > 256)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var revision = header[3];
|
||||
var frameIndexOffset = 44L + checked(12L * audioTrackCount);
|
||||
if (revision == (byte)'m')
|
||||
{
|
||||
frameIndexOffset += 16;
|
||||
}
|
||||
else if (revision is (byte)'i' or (byte)'j' or (byte)'k' or (byte)'n')
|
||||
{
|
||||
frameIndexOffset += 4;
|
||||
}
|
||||
|
||||
Span<byte> frameOffsets = stackalloc byte[8];
|
||||
stream.Position = frameIndexOffset;
|
||||
stream.ReadExactly(frameOffsets);
|
||||
var firstFrameOffset = BinaryPrimitives.ReadUInt32LittleEndian(frameOffsets[..4]) & ~1u;
|
||||
var secondFrameOffset = BinaryPrimitives.ReadUInt32LittleEndian(frameOffsets[4..]) & ~1u;
|
||||
if (firstFrameOffset < frameIndexOffset + 8 ||
|
||||
secondFrameOffset <= firstFrameOffset ||
|
||||
secondFrameOffset > stream.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
completionShim = new BinkGuestCompletionShim(
|
||||
secondFrameOffset - 8,
|
||||
secondFrameOffset - firstFrameOffset);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool TryOpen(string path, out IntPtr movie, out Bink2MovieInfo info)
|
||||
catch (Exception exception) when (
|
||||
exception is IOException or EndOfStreamException or OverflowException)
|
||||
{
|
||||
var utf8 = Marshal.StringToCoTaskMemUTF8(path);
|
||||
try
|
||||
{
|
||||
return _openUtf8(utf8, out movie, out info) != 0 && movie != IntPtr.Zero;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeCoTaskMem(utf8);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly struct BinkGuestCompletionShim
|
||||
{
|
||||
private readonly uint _fileSizeMinusHeader;
|
||||
private readonly uint _largestFrameSize;
|
||||
|
||||
internal BinkGuestCompletionShim(uint fileSizeMinusHeader, uint largestFrameSize)
|
||||
{
|
||||
_fileSizeMinusHeader = fileSizeMinusHeader;
|
||||
_largestFrameSize = largestFrameSize;
|
||||
}
|
||||
|
||||
internal bool DecodeNextBgra(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes) =>
|
||||
_decodeNextBgra(movie, destination, stride, destinationBytes) != 0;
|
||||
/// <summary>
|
||||
/// Rewrites the frame-count/size fields the guest's own Bink header
|
||||
/// parse reads, if this read covers them. Returns true when the
|
||||
/// NumFrames field (the field that tells the guest "this movie is
|
||||
/// done") was in range, so the caller can gate that specific read on
|
||||
/// the host's real playback actually finishing first.
|
||||
/// </summary>
|
||||
internal bool Patch(long fileOffset, Span<byte> bytes)
|
||||
{
|
||||
PatchUInt32(fileOffset, bytes, 4, _fileSizeMinusHeader);
|
||||
var touchedCompletionField = PatchUInt32(fileOffset, bytes, 8, 1);
|
||||
PatchUInt32(fileOffset, bytes, 12, _largestFrameSize);
|
||||
return touchedCompletionField;
|
||||
}
|
||||
|
||||
internal void Close(IntPtr movie) => _close(movie);
|
||||
private static bool PatchUInt32(
|
||||
long fileOffset,
|
||||
Span<byte> bytes,
|
||||
long fieldOffset,
|
||||
uint value)
|
||||
{
|
||||
var relativeOffset = fieldOffset - fileOffset;
|
||||
if (relativeOffset < 0 || relativeOffset + sizeof(uint) > bytes.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
bytes.Slice((int)relativeOffset, sizeof(uint)),
|
||||
value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace SharpEmu.Libs.Bink;
|
||||
|
||||
internal interface IBinkFrameDecoder : IDisposable
|
||||
{
|
||||
uint Width { get; }
|
||||
|
||||
uint Height { get; }
|
||||
|
||||
uint FramesPerSecondNumerator { get; }
|
||||
|
||||
uint FramesPerSecondDenominator { get; }
|
||||
|
||||
bool TryDecodeNextFrame(Span<byte> destination);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps blocking codec work away from the Vulkan presentation thread and
|
||||
/// releases decoded frames according to the movie time base.
|
||||
/// </summary>
|
||||
internal sealed class BinkFramePlayback : IDisposable
|
||||
{
|
||||
private const int BufferCount = 5;
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly IBinkFrameDecoder _decoder;
|
||||
private readonly Queue<byte[]> _freeBuffers = new();
|
||||
private readonly Queue<DecodedFrame> _decodedFrames = new();
|
||||
private readonly Thread _decoderThread;
|
||||
private byte[]? _currentFrame;
|
||||
private byte[]? _retiredFrame;
|
||||
private long _currentFrameIndex = -1;
|
||||
private long _nextDecodedFrameIndex;
|
||||
private long _playbackStartTimestamp;
|
||||
private bool _playbackClockStarted;
|
||||
private bool _decoderCompleted;
|
||||
private bool _stopRequested;
|
||||
private bool _finished;
|
||||
private int _disposed;
|
||||
|
||||
internal BinkFramePlayback(IBinkFrameDecoder decoder)
|
||||
{
|
||||
_decoder = decoder;
|
||||
Width = decoder.Width;
|
||||
Height = decoder.Height;
|
||||
FramesPerSecondNumerator = decoder.FramesPerSecondNumerator;
|
||||
FramesPerSecondDenominator = decoder.FramesPerSecondDenominator;
|
||||
|
||||
var frameBytes = checked((int)((ulong)Width * Height * 4));
|
||||
for (var index = 0; index < BufferCount; index++)
|
||||
{
|
||||
_freeBuffers.Enqueue(GC.AllocateUninitializedArray<byte>(frameBytes));
|
||||
}
|
||||
|
||||
_decoderThread = new Thread(DecodeLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "SharpEmu Bink video decoder",
|
||||
};
|
||||
_decoderThread.Start();
|
||||
}
|
||||
|
||||
internal uint Width { get; }
|
||||
|
||||
internal uint Height { get; }
|
||||
|
||||
internal uint FramesPerSecondNumerator { get; }
|
||||
|
||||
internal uint FramesPerSecondDenominator { get; }
|
||||
|
||||
internal bool IsFinished
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _finished;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal bool TryGetFrame(
|
||||
bool advanceClock,
|
||||
out byte[] pixels,
|
||||
out bool advanced)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
pixels = [];
|
||||
advanced = false;
|
||||
if (_finished)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_currentFrame is null)
|
||||
{
|
||||
if (_decodedFrames.Count == 0)
|
||||
{
|
||||
if (_decoderCompleted)
|
||||
{
|
||||
_finished = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var first = _decodedFrames.Dequeue();
|
||||
_currentFrame = first.Pixels;
|
||||
_currentFrameIndex = first.Index;
|
||||
advanced = true;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
|
||||
if (advanceClock && !_playbackClockStarted)
|
||||
{
|
||||
_playbackStartTimestamp = Stopwatch.GetTimestamp();
|
||||
_playbackClockStarted = true;
|
||||
}
|
||||
|
||||
var elapsedSeconds = _playbackClockStarted
|
||||
? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds
|
||||
: 0;
|
||||
var targetFrameIndex = (long)Math.Floor(
|
||||
elapsedSeconds * FramesPerSecondNumerator / FramesPerSecondDenominator);
|
||||
DecodedFrame? replacement = null;
|
||||
while (_decodedFrames.Count > 0 &&
|
||||
_decodedFrames.Peek().Index <= targetFrameIndex)
|
||||
{
|
||||
if (replacement is { } skipped)
|
||||
{
|
||||
_freeBuffers.Enqueue(skipped.Pixels);
|
||||
}
|
||||
replacement = _decodedFrames.Dequeue();
|
||||
}
|
||||
|
||||
if (replacement is { } next)
|
||||
{
|
||||
if (_retiredFrame is not null)
|
||||
{
|
||||
_freeBuffers.Enqueue(_retiredFrame);
|
||||
}
|
||||
_retiredFrame = _currentFrame;
|
||||
_currentFrame = next.Pixels;
|
||||
_currentFrameIndex = next.Index;
|
||||
advanced = true;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
|
||||
var frameDurationSeconds =
|
||||
(double)FramesPerSecondDenominator / FramesPerSecondNumerator;
|
||||
if (_playbackClockStarted &&
|
||||
_decoderCompleted &&
|
||||
_decodedFrames.Count == 0 &&
|
||||
elapsedSeconds >= (_currentFrameIndex + 1) * frameDurationSeconds)
|
||||
{
|
||||
_finished = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
pixels = _currentFrame;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void DecodeLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
byte[] destination;
|
||||
lock (_gate)
|
||||
{
|
||||
while (!_stopRequested && _freeBuffers.Count == 0)
|
||||
{
|
||||
Monitor.Wait(_gate);
|
||||
}
|
||||
if (_stopRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
destination = _freeBuffers.Dequeue();
|
||||
}
|
||||
|
||||
if (!_decoder.TryDecodeNextFrame(destination))
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_freeBuffers.Enqueue(destination);
|
||||
_decoderCompleted = true;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_decodedFrames.Enqueue(new DecodedFrame(
|
||||
_nextDecodedFrameIndex++, destination));
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or
|
||||
InvalidOperationException)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Bink decoder stopped: {exception.Message}");
|
||||
lock (_gate)
|
||||
{
|
||||
_decoderCompleted = true;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_stopRequested = true;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
if (Thread.CurrentThread != _decoderThread &&
|
||||
!_decoderThread.Join(TimeSpan.FromMilliseconds(100)))
|
||||
{
|
||||
_decoder.Dispose();
|
||||
_decoderThread.Join(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
else
|
||||
{
|
||||
_decoder.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct DecodedFrame(long Index, byte[] Pixels);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Diagnostics;
|
||||
using SharpEmu.Libs.AvPlayer;
|
||||
|
||||
namespace SharpEmu.Libs.Bink;
|
||||
|
||||
internal sealed class FfmpegBinkFrameSource : IBinkFrameDecoder
|
||||
{
|
||||
private readonly Process _process;
|
||||
private readonly Stream _output;
|
||||
private int _errorLines;
|
||||
private int _disposed;
|
||||
|
||||
private FfmpegBinkFrameSource(
|
||||
Process process,
|
||||
uint width,
|
||||
uint height,
|
||||
uint framesPerSecondNumerator,
|
||||
uint framesPerSecondDenominator)
|
||||
{
|
||||
_process = process;
|
||||
_output = process.StandardOutput.BaseStream;
|
||||
Width = width;
|
||||
Height = height;
|
||||
FramesPerSecondNumerator = framesPerSecondNumerator;
|
||||
FramesPerSecondDenominator = framesPerSecondDenominator;
|
||||
}
|
||||
|
||||
public uint Width { get; }
|
||||
|
||||
public uint Height { get; }
|
||||
|
||||
public uint FramesPerSecondNumerator { get; }
|
||||
|
||||
public uint FramesPerSecondDenominator { get; }
|
||||
|
||||
internal static bool IsAvailable => AvPlayerExports.FindFfmpeg() is not null;
|
||||
|
||||
internal static bool TryOpen(
|
||||
string path,
|
||||
uint width,
|
||||
uint height,
|
||||
uint framesPerSecondNumerator,
|
||||
uint framesPerSecondDenominator,
|
||||
out FfmpegBinkFrameSource? source)
|
||||
{
|
||||
source = null;
|
||||
var ffmpeg = AvPlayerExports.FindFfmpeg();
|
||||
if (ffmpeg is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo(ffmpeg)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
startInfo.ArgumentList.Add("-nostdin");
|
||||
startInfo.ArgumentList.Add("-hide_banner");
|
||||
startInfo.ArgumentList.Add("-loglevel");
|
||||
startInfo.ArgumentList.Add("error");
|
||||
startInfo.ArgumentList.Add("-i");
|
||||
startInfo.ArgumentList.Add(path);
|
||||
startInfo.ArgumentList.Add("-map");
|
||||
startInfo.ArgumentList.Add("0:v:0");
|
||||
startInfo.ArgumentList.Add("-an");
|
||||
startInfo.ArgumentList.Add("-pix_fmt");
|
||||
startInfo.ArgumentList.Add("bgra");
|
||||
startInfo.ArgumentList.Add("-f");
|
||||
startInfo.ArgumentList.Add("rawvideo");
|
||||
startInfo.ArgumentList.Add("pipe:1");
|
||||
|
||||
try
|
||||
{
|
||||
var process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
source = new FfmpegBinkFrameSource(
|
||||
process,
|
||||
width,
|
||||
height,
|
||||
framesPerSecondNumerator,
|
||||
framesPerSecondDenominator);
|
||||
process.ErrorDataReceived += source.OnErrorData;
|
||||
process.BeginErrorReadLine();
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or
|
||||
InvalidOperationException or
|
||||
System.ComponentModel.Win32Exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Bink FFmpeg decoder could not start: {exception.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDecodeNextFrame(Span<byte> destination)
|
||||
{
|
||||
try
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < destination.Length)
|
||||
{
|
||||
var read = _output.Read(destination[offset..]);
|
||||
if (read == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
offset += read;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or ObjectDisposedException)
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Bink FFmpeg stream failed: {exception.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnErrorData(object sender, DataReceivedEventArgs eventArgs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(eventArgs.Data) ||
|
||||
Interlocked.Increment(ref _errorLines) > 20)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Console.Error.WriteLine($"[LOADER][FFMPEG-BINK] {eventArgs.Data}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_output.Dispose();
|
||||
try
|
||||
{
|
||||
if (!_process.HasExited)
|
||||
{
|
||||
_process.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
_process.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using FFmpeg.AutoGen;
|
||||
|
||||
namespace SharpEmu.Libs.Bink;
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a .bk2 (or any FFmpeg-readable movie) directly via FFmpeg's C API
|
||||
/// through FFmpeg.AutoGen P/Invoke bindings against the dynamically linked
|
||||
/// libraries published by github.com/sharpemu/ffmpeg-core -- no native C
|
||||
/// bridge of our own to build. See docs/bink2-bridge.md.
|
||||
/// </summary>
|
||||
internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
||||
{
|
||||
private AVFormatContext* _formatContext;
|
||||
private AVCodecContext* _codecContext;
|
||||
private SwsContext* _swsContext;
|
||||
private AVFrame* _frame;
|
||||
private AVPacket* _packet;
|
||||
private readonly int _videoStreamIndex;
|
||||
private bool _draining;
|
||||
private int _disposed;
|
||||
|
||||
public uint Width { get; }
|
||||
|
||||
public uint Height { get; }
|
||||
|
||||
public uint FramesPerSecondNumerator { get; }
|
||||
|
||||
public uint FramesPerSecondDenominator { get; }
|
||||
|
||||
private FfmpegNativeBinkFrameSource(
|
||||
AVFormatContext* formatContext,
|
||||
AVCodecContext* codecContext,
|
||||
int videoStreamIndex,
|
||||
uint width,
|
||||
uint height,
|
||||
uint framesPerSecondNumerator,
|
||||
uint framesPerSecondDenominator)
|
||||
{
|
||||
_formatContext = formatContext;
|
||||
_codecContext = codecContext;
|
||||
_videoStreamIndex = videoStreamIndex;
|
||||
Width = width;
|
||||
Height = height;
|
||||
FramesPerSecondNumerator = framesPerSecondNumerator;
|
||||
FramesPerSecondDenominator = framesPerSecondDenominator;
|
||||
_frame = ffmpeg.av_frame_alloc();
|
||||
_packet = ffmpeg.av_packet_alloc();
|
||||
}
|
||||
|
||||
private static bool _rootPathInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// Points FFmpeg.AutoGen at the FFmpeg shared libraries SharpEmu.CLI
|
||||
/// downloads next to the executable (see SharpEmu.CLI.csproj's
|
||||
/// FetchFfmpegRuntime target); kept as loose files rather than embedded
|
||||
/// in the single-file bundle so the OS loader can resolve the normal
|
||||
/// inter-library dependencies (avcodec depends on avutil, etc.) itself.
|
||||
/// </summary>
|
||||
private static void EnsureRootPathInitialized()
|
||||
{
|
||||
if (_rootPathInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_rootPathInitialized = true;
|
||||
// SharpEmu.CLI.csproj publishes FFmpeg's shared libraries into a
|
||||
// "plugins" subfolder next to the executable rather than flat beside
|
||||
// it (see NativeLibraryFolderName in SharpEmu.CLI.csproj).
|
||||
ffmpeg.RootPath = Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||
|
||||
// ffmpeg's static constructor runs DynamicallyLoadedBindings.Initialize()
|
||||
// itself, but that constructor fires on first touch of the ffmpeg type --
|
||||
// which is the RootPath assignment above -- so it binds against the
|
||||
// default (empty) RootPath before the assignment's own setter body runs.
|
||||
// Every function resolved during that first pass permanently throws
|
||||
// NotSupportedException. Re-running Initialize() now, with RootPath
|
||||
// actually set, rebinds everything against the real search path.
|
||||
DynamicallyLoadedBindings.Initialize();
|
||||
}
|
||||
|
||||
internal static bool TryOpen(
|
||||
string path,
|
||||
uint maximumWidth,
|
||||
uint maximumHeight,
|
||||
out FfmpegNativeBinkFrameSource? source)
|
||||
{
|
||||
source = null;
|
||||
EnsureRootPathInitialized();
|
||||
|
||||
AVFormatContext* formatContext = null;
|
||||
AVCodecContext* codecContext = null;
|
||||
try
|
||||
{
|
||||
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ffmpeg.avformat_find_stream_info(formatContext, null) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
AVCodec* decoder = null;
|
||||
var videoStreamIndex = ffmpeg.av_find_best_stream(
|
||||
formatContext, AVMediaType.AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0);
|
||||
if (videoStreamIndex < 0 || decoder is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var stream = formatContext->streams[videoStreamIndex];
|
||||
codecContext = ffmpeg.avcodec_alloc_context3(decoder);
|
||||
if (codecContext is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ffmpeg.avcodec_parameters_to_context(codecContext, stream->codecpar) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
codecContext->thread_count = 0;
|
||||
codecContext->thread_type = ffmpeg.FF_THREAD_FRAME | ffmpeg.FF_THREAD_SLICE;
|
||||
if (ffmpeg.avcodec_open2(codecContext, decoder, null) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (codecContext->width <= 0 || codecContext->height <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var frameRate = ffmpeg.av_guess_frame_rate(formatContext, stream, null);
|
||||
if (frameRate.num <= 0 || frameRate.den <= 0)
|
||||
{
|
||||
frameRate = stream->avg_frame_rate;
|
||||
}
|
||||
if (frameRate.num <= 0 || frameRate.den <= 0)
|
||||
{
|
||||
frameRate = stream->r_frame_rate;
|
||||
}
|
||||
if (frameRate.num <= 0 || frameRate.den <= 0)
|
||||
{
|
||||
frameRate = new AVRational { num = 30, den = 1 };
|
||||
}
|
||||
|
||||
var outputWidth = (uint)codecContext->width;
|
||||
var outputHeight = (uint)codecContext->height;
|
||||
if (maximumWidth > 0 && maximumHeight > 0 &&
|
||||
(outputWidth > maximumWidth || outputHeight > maximumHeight))
|
||||
{
|
||||
if ((ulong)outputWidth * maximumHeight > (ulong)outputHeight * maximumWidth)
|
||||
{
|
||||
outputHeight = (uint)((ulong)outputHeight * maximumWidth / outputWidth);
|
||||
outputWidth = maximumWidth;
|
||||
}
|
||||
else
|
||||
{
|
||||
outputWidth = (uint)((ulong)outputWidth * maximumHeight / outputHeight);
|
||||
outputHeight = maximumHeight;
|
||||
}
|
||||
|
||||
outputWidth = Math.Max(1, outputWidth);
|
||||
outputHeight = Math.Max(1, outputHeight);
|
||||
}
|
||||
|
||||
source = new FfmpegNativeBinkFrameSource(
|
||||
formatContext,
|
||||
codecContext,
|
||||
videoStreamIndex,
|
||||
outputWidth,
|
||||
outputHeight,
|
||||
(uint)frameRate.num,
|
||||
(uint)frameRate.den);
|
||||
formatContext = null;
|
||||
codecContext = null;
|
||||
return true;
|
||||
}
|
||||
catch (DllNotFoundException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (codecContext is not null)
|
||||
{
|
||||
ffmpeg.avcodec_free_context(&codecContext);
|
||||
}
|
||||
|
||||
if (formatContext is not null)
|
||||
{
|
||||
ffmpeg.avformat_close_input(&formatContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDecodeNextFrame(Span<byte> destination)
|
||||
{
|
||||
var stride = checked((int)(Width * 4));
|
||||
var required = (long)stride * Height;
|
||||
if (destination.Length < required)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryReceiveFrame())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_swsContext = ffmpeg.sws_getCachedContext(
|
||||
_swsContext,
|
||||
_frame->width,
|
||||
_frame->height,
|
||||
(AVPixelFormat)_frame->format,
|
||||
(int)Width,
|
||||
(int)Height,
|
||||
AVPixelFormat.AV_PIX_FMT_BGRA,
|
||||
ffmpeg.SWS_FAST_BILINEAR,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
if (_swsContext is null)
|
||||
{
|
||||
ffmpeg.av_frame_unref(_frame);
|
||||
return false;
|
||||
}
|
||||
|
||||
fixed (byte* destinationPointer = destination)
|
||||
{
|
||||
var destinationPlanes = new byte*[4] { destinationPointer, null, null, null };
|
||||
var destinationStrides = new int[4] { stride, 0, 0, 0 };
|
||||
var convertedRows = ffmpeg.sws_scale(
|
||||
_swsContext,
|
||||
_frame->data,
|
||||
_frame->linesize,
|
||||
0,
|
||||
_frame->height,
|
||||
destinationPlanes,
|
||||
destinationStrides);
|
||||
ffmpeg.av_frame_unref(_frame);
|
||||
return convertedRows == (int)Height;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryReceiveFrame()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var receiveResult = ffmpeg.avcodec_receive_frame(_codecContext, _frame);
|
||||
if (receiveResult >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (receiveResult == ffmpeg.AVERROR_EOF)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (receiveResult != ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_draining)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryFeedPacket())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryFeedPacket()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var readResult = ffmpeg.av_read_frame(_formatContext, _packet);
|
||||
if (readResult < 0)
|
||||
{
|
||||
_draining = true;
|
||||
ffmpeg.avcodec_send_packet(_codecContext, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_packet->stream_index != _videoStreamIndex)
|
||||
{
|
||||
ffmpeg.av_packet_unref(_packet);
|
||||
continue;
|
||||
}
|
||||
|
||||
var sendResult = ffmpeg.avcodec_send_packet(_codecContext, _packet);
|
||||
ffmpeg.av_packet_unref(_packet);
|
||||
if (sendResult < 0 && sendResult != ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_swsContext is not null)
|
||||
{
|
||||
ffmpeg.sws_freeContext(_swsContext);
|
||||
_swsContext = null;
|
||||
}
|
||||
|
||||
if (_packet is not null)
|
||||
{
|
||||
var packet = _packet;
|
||||
ffmpeg.av_packet_free(&packet);
|
||||
_packet = null;
|
||||
}
|
||||
|
||||
if (_frame is not null)
|
||||
{
|
||||
var frame = _frame;
|
||||
ffmpeg.av_frame_free(&frame);
|
||||
_frame = null;
|
||||
}
|
||||
|
||||
if (_codecContext is not null)
|
||||
{
|
||||
var codecContext = _codecContext;
|
||||
ffmpeg.avcodec_free_context(&codecContext);
|
||||
_codecContext = null;
|
||||
}
|
||||
|
||||
if (_formatContext is not null)
|
||||
{
|
||||
var formatContext = _formatContext;
|
||||
ffmpeg.avformat_close_input(&formatContext);
|
||||
_formatContext = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using SharpEmu.Libs.Agc;
|
||||
|
||||
namespace SharpEmu.Libs.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Rate-limited progress probes armed when GTA's 'North Audio Update' thread
|
||||
/// starts. Used to classify North Yankton freezes (flip vs present vs GPU wait)
|
||||
/// without enabling full AGC/VideoOut trace.
|
||||
/// </summary>
|
||||
public static class LoadProgressDiagnostics
|
||||
{
|
||||
// Keep probes live long enough to cover a stuck Yankton session.
|
||||
private const long ActiveWindowMs = 120_000;
|
||||
|
||||
private static long _armedTicks;
|
||||
private static long _flipSubmitTraceCount;
|
||||
private static long _orderedFlipEnqueueTraceCount;
|
||||
private static long _presentTakenTraceCount;
|
||||
private static long _presentNotTakenTraceCount;
|
||||
private static long _gpuWaitSnapshotTraceCount;
|
||||
|
||||
public static void ArmIfNorthAudioThread(string? threadName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(threadName) ||
|
||||
threadName.IndexOf("North Audio", StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(
|
||||
ref _armedTicks,
|
||||
Stopwatch.GetTimestamp(),
|
||||
0) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][TRACE] load_progress.armed reason=north_audio " +
|
||||
$"window_ms={ActiveWindowMs}");
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsActive
|
||||
{
|
||||
get
|
||||
{
|
||||
var armed = Volatile.Read(ref _armedTicks);
|
||||
if (armed == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var elapsedMs = (Stopwatch.GetTimestamp() - armed) * 1000L /
|
||||
Stopwatch.Frequency;
|
||||
return elapsedMs <= ActiveWindowMs;
|
||||
}
|
||||
}
|
||||
|
||||
public static void TraceFlipSubmit(
|
||||
int handle,
|
||||
int bufferIndex,
|
||||
int flipMode,
|
||||
bool submitGpuImage,
|
||||
bool guestImageSubmitted,
|
||||
ulong guestImageAddress,
|
||||
int flipEventCount)
|
||||
{
|
||||
if (!IsActive || !ShouldTrace(ref _flipSubmitTraceCount, out var count))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] load_progress.flip_submit count={count} " +
|
||||
$"handle={handle} index={bufferIndex} mode={flipMode} " +
|
||||
$"gpu_image={submitGpuImage} submitted={guestImageSubmitted} " +
|
||||
$"addr=0x{guestImageAddress:X16} events={flipEventCount}");
|
||||
}
|
||||
|
||||
public static void TraceOrderedFlipEnqueue(
|
||||
int videoOutHandle,
|
||||
int displayBufferIndex,
|
||||
ulong address,
|
||||
long version,
|
||||
bool enqueued)
|
||||
{
|
||||
if (!IsActive ||
|
||||
!ShouldTrace(ref _orderedFlipEnqueueTraceCount, out var count))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] load_progress.ordered_flip count={count} " +
|
||||
$"handle={videoOutHandle} index={displayBufferIndex} " +
|
||||
$"addr=0x{address:X16} version={version} enqueued={enqueued}");
|
||||
}
|
||||
|
||||
public static void TracePresentTaken(
|
||||
long presentedSequence,
|
||||
ulong guestImageAddress,
|
||||
long guestImageVersion)
|
||||
{
|
||||
if (!IsActive || !ShouldTrace(ref _presentTakenTraceCount, out var count))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] load_progress.present_taken count={count} " +
|
||||
$"seq={presentedSequence} addr=0x{guestImageAddress:X16} " +
|
||||
$"version={guestImageVersion}");
|
||||
}
|
||||
|
||||
public static void TracePresentNotTaken(
|
||||
long presentedSequence,
|
||||
bool hasPendingPresentation)
|
||||
{
|
||||
if (!IsActive ||
|
||||
!ShouldTrace(ref _presentNotTakenTraceCount, out var count))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] load_progress.present_not_taken count={count} " +
|
||||
$"seq={presentedSequence} pending={hasPendingPresentation}");
|
||||
}
|
||||
|
||||
public static void TraceGpuWaitSnapshot(object? memory = null)
|
||||
{
|
||||
if (!IsActive ||
|
||||
!ShouldTrace(ref _gpuWaitSnapshotTraceCount, out var count))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = GpuWaitRegistry.SnapshotOutstanding(memory);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] load_progress.gpu_waits count={count} " +
|
||||
$"outstanding={snapshot.Outstanding} latched={snapshot.Latched} " +
|
||||
$"oldest_ms={snapshot.OldestAgeMs} " +
|
||||
$"sample_addr=0x{snapshot.SampleWaitAddress:X16} " +
|
||||
$"sample_queue={snapshot.SampleQueueName ?? "-"}");
|
||||
}
|
||||
|
||||
private static bool ShouldTrace(ref long counter, out long count)
|
||||
{
|
||||
count = Interlocked.Increment(ref counter);
|
||||
return count <= 16 || (count & (count - 1)) == 0;
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,37 @@ public static class FontExports
|
||||
return SetSuccess(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "3BrWWFU+4ts",
|
||||
ExportName = "sceFontGetVerticalLayout",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int GetVerticalLayout(CpuContext ctx)
|
||||
{
|
||||
var layoutAddress = ctx[CpuRegister.Rsi];
|
||||
if (layoutAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
// Baseline (horizontal offset), line advance, decoration extent.
|
||||
// Mirrors the same three-float layout as GetHorizontalLayout, but
|
||||
// interpreted for vertical writing (e.g. CJK text rendered top-to-bottom).
|
||||
var values = new[] { 8.0f, 16.0f, 0.0f };
|
||||
for (var index = 0; index < values.Length; index++)
|
||||
{
|
||||
if (!TryWriteUInt32(
|
||||
ctx,
|
||||
layoutAddress + (ulong)(index * sizeof(float)),
|
||||
BitConverter.SingleToUInt32Bits(values[index])))
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
}
|
||||
|
||||
return SetSuccess(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "cKYtVmeSTcw",
|
||||
ExportName = "sceFontOpenFontSet",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers;
|
||||
using System.Numerics;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu;
|
||||
|
||||
@@ -15,7 +16,125 @@ namespace SharpEmu.Libs.Gpu;
|
||||
/// </summary>
|
||||
internal static class GuestDataPool
|
||||
{
|
||||
public static ArrayPool<byte> Shared { get; } = ArrayPool<byte>.Create(
|
||||
public static ArrayPool<byte> Shared { get; } = new BoundedByteArrayPool(
|
||||
maxArrayLength: 16 * 1024 * 1024,
|
||||
maxArraysPerBucket: 96);
|
||||
maxCachedBytes: 256UL * 1024 * 1024,
|
||||
maxArraysPerBucket: 8);
|
||||
|
||||
public static void Trim() => ((BoundedByteArrayPool)Shared).Trim();
|
||||
|
||||
private sealed class BoundedByteArrayPool : ArrayPool<byte>
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly int _maxArrayLength;
|
||||
private readonly ulong _maxCachedBytes;
|
||||
private readonly int _maxArraysPerBucket;
|
||||
private readonly Dictionary<int, Stack<byte[]>> _cachedByBucket = [];
|
||||
private readonly HashSet<byte[]> _leases =
|
||||
new(System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
||||
private ulong _cachedBytes;
|
||||
|
||||
public BoundedByteArrayPool(
|
||||
int maxArrayLength,
|
||||
ulong maxCachedBytes,
|
||||
int maxArraysPerBucket)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArrayLength);
|
||||
ArgumentOutOfRangeException.ThrowIfZero(maxCachedBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArraysPerBucket);
|
||||
_maxArrayLength = maxArrayLength;
|
||||
_maxCachedBytes = maxCachedBytes;
|
||||
_maxArraysPerBucket = maxArraysPerBucket;
|
||||
}
|
||||
|
||||
public override byte[] Rent(int minimumLength)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(minimumLength);
|
||||
var length = GetAllocationLength(minimumLength);
|
||||
byte[]? array = null;
|
||||
lock (_gate)
|
||||
{
|
||||
if (length <= _maxArrayLength &&
|
||||
_cachedByBucket.TryGetValue(length, out var bucket) &&
|
||||
bucket.TryPop(out array))
|
||||
{
|
||||
_cachedBytes -= (ulong)array.LongLength;
|
||||
}
|
||||
|
||||
array ??= new byte[length];
|
||||
_leases.Add(array);
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
public override void Return(byte[] array, bool clearArray = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(array);
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_leases.Remove(array))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (clearArray)
|
||||
{
|
||||
Array.Clear(array);
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (array.Length > _maxArrayLength ||
|
||||
!IsBucketLength(array.Length) ||
|
||||
(ulong)array.LongLength > _maxCachedBytes -
|
||||
Math.Min(_cachedBytes, _maxCachedBytes))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_cachedByBucket.TryGetValue(array.Length, out var bucket))
|
||||
{
|
||||
bucket = new Stack<byte[]>();
|
||||
_cachedByBucket.Add(array.Length, bucket);
|
||||
}
|
||||
|
||||
if (bucket.Count >= _maxArraysPerBucket)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bucket.Push(array);
|
||||
_cachedBytes += (ulong)array.LongLength;
|
||||
}
|
||||
}
|
||||
|
||||
public void Trim()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_cachedByBucket.Clear();
|
||||
_cachedBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private int GetAllocationLength(int minimumLength)
|
||||
{
|
||||
if (minimumLength <= 16)
|
||||
{
|
||||
return 16;
|
||||
}
|
||||
|
||||
if (minimumLength > _maxArrayLength)
|
||||
{
|
||||
return minimumLength;
|
||||
}
|
||||
|
||||
return checked((int)BitOperations.RoundUpToPowerOf2((uint)minimumLength));
|
||||
}
|
||||
|
||||
private static bool IsBucketLength(int length) =>
|
||||
length >= 16 && (length & (length - 1)) == 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// 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
|
||||
@@ -10,7 +12,8 @@ namespace SharpEmu.Libs.Gpu;
|
||||
// translation for its API.
|
||||
|
||||
/// <summary>A guest texture referenced by a draw or dispatch. Format/NumberType/
|
||||
/// TileMode/DstSelect are raw guest descriptor codes.</summary>
|
||||
/// TileMode/DstSelect/Type are raw guest descriptor codes. Depth is the
|
||||
/// normalized volume depth (one for non-3D resources).</summary>
|
||||
internal sealed record GuestDrawTexture(
|
||||
ulong Address,
|
||||
uint Width,
|
||||
@@ -27,7 +30,20 @@ internal sealed record GuestDrawTexture(
|
||||
uint Pitch = 0,
|
||||
uint TileMode = 0,
|
||||
uint DstSelect = 0xFAC,
|
||||
GuestSampler Sampler = default);
|
||||
GuestSampler Sampler = default,
|
||||
// Guest CPU write-tracker generation of the memory RgbaPixels was read
|
||||
// from; -1 when the range is untracked or the pixels were not read here.
|
||||
long WriteGeneration = -1,
|
||||
bool ArrayedView = false,
|
||||
uint ArrayLayers = 1,
|
||||
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);
|
||||
|
||||
/// <summary>Raw guest sampler descriptor dwords, copied verbatim from guest memory.</summary>
|
||||
internal readonly record struct GuestSampler(
|
||||
@@ -48,7 +64,11 @@ internal readonly record struct TextureContentIdentity(
|
||||
uint DstSelect,
|
||||
uint TileMode,
|
||||
uint Pitch,
|
||||
GuestSampler Sampler);
|
||||
GuestSampler Sampler,
|
||||
bool Arrayed = false,
|
||||
uint ArrayLayers = 1,
|
||||
uint Type = 9,
|
||||
uint Depth = 1);
|
||||
|
||||
internal sealed record GuestMemoryBuffer(
|
||||
ulong BaseAddress,
|
||||
@@ -69,7 +89,8 @@ internal sealed record GuestVertexBuffer(
|
||||
uint OffsetBytes,
|
||||
byte[] Data,
|
||||
int Length,
|
||||
bool Pooled);
|
||||
bool Pooled,
|
||||
bool PerInstance = false);
|
||||
|
||||
internal sealed record GuestIndexBuffer(
|
||||
byte[] Data,
|
||||
|
||||
@@ -54,6 +54,7 @@ internal interface IGuestGpuBackend
|
||||
int scalarRegisterBufferIndex = -1,
|
||||
uint pixelInputEnable = 0,
|
||||
uint pixelInputAddress = 0,
|
||||
IReadOnlyList<uint>? pixelInputCntl = null,
|
||||
ulong storageBufferOffsetAlignment = 1);
|
||||
|
||||
bool TryCompileComputeShader(
|
||||
@@ -108,7 +109,8 @@ internal interface IGuestGpuBackend
|
||||
GuestIndexBuffer? indexBuffer = null,
|
||||
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
||||
GuestRenderState? renderState = null,
|
||||
ulong shaderAddress = 0);
|
||||
ulong shaderAddress = 0,
|
||||
int baseVertex = 0);
|
||||
|
||||
void SubmitOffscreenTranslatedDraw(
|
||||
IGuestCompiledShader pixelShader,
|
||||
@@ -124,7 +126,8 @@ internal interface IGuestGpuBackend
|
||||
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
||||
GuestRenderState? renderState = null,
|
||||
GuestDepthTarget? depthTarget = null,
|
||||
ulong shaderAddress = 0);
|
||||
ulong shaderAddress = 0,
|
||||
int baseVertex = 0);
|
||||
|
||||
void SubmitStorageTranslatedDraw(
|
||||
IGuestCompiledShader pixelShader,
|
||||
@@ -236,6 +239,12 @@ internal interface IGuestGpuBackend
|
||||
|
||||
void SubmitGuestImageWrite(ulong address, byte[] pixels);
|
||||
|
||||
/// <summary>
|
||||
/// Asks the presenter to refresh CPU-dirty guest images on its render/present
|
||||
/// drain. Must not enqueue retained plane copies on the producer path.
|
||||
/// </summary>
|
||||
void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue);
|
||||
|
||||
bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount);
|
||||
|
||||
IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents();
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,6 +218,13 @@ internal static class MetalGuestFormats
|
||||
{
|
||||
var format = (dataFormat, numberType) switch
|
||||
{
|
||||
// Early G-buffer / scene targets (R16 + RG32). Keep in sync with
|
||||
// VulkanVideoPresenter.TryDecodeRenderTargetFormat.
|
||||
(2, 0) => MtlPixelFormat.R16Unorm,
|
||||
(2, 1) => MtlPixelFormat.R16Snorm,
|
||||
(2, 4) => MtlPixelFormat.R16Uint,
|
||||
(2, 5) => MtlPixelFormat.R16Sint,
|
||||
(2, 7) => MtlPixelFormat.R16Float,
|
||||
(4, 4) => MtlPixelFormat.R32Uint,
|
||||
(4, 5) => MtlPixelFormat.R32Sint,
|
||||
(4, 7) => MtlPixelFormat.R32Float,
|
||||
@@ -230,6 +237,8 @@ internal static class MetalGuestFormats
|
||||
(10, 5) => MtlPixelFormat.Rgba8Sint,
|
||||
(10, 9) => MtlPixelFormat.Rgba8UnormSrgb,
|
||||
(10, _) => MtlPixelFormat.Rgba8Unorm,
|
||||
(11, 4) => MtlPixelFormat.Rg32Uint,
|
||||
(11, 5) => MtlPixelFormat.Rg32Sint,
|
||||
(11, 7) => MtlPixelFormat.Rg32Float,
|
||||
(12, 4) => MtlPixelFormat.Rgba16Uint,
|
||||
(12, 5) => MtlPixelFormat.Rgba16Sint,
|
||||
@@ -258,10 +267,12 @@ internal static class MetalGuestFormats
|
||||
|
||||
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,
|
||||
MtlPixelFormat.R8Uint or MtlPixelFormat.R16Uint or MtlPixelFormat.R32Uint or
|
||||
MtlPixelFormat.Rg16Uint or MtlPixelFormat.Rg32Uint or MtlPixelFormat.Rgba8Uint or
|
||||
MtlPixelFormat.Rgba16Uint => Gen5PixelOutputKind.Uint,
|
||||
MtlPixelFormat.R16Sint or MtlPixelFormat.R32Sint or MtlPixelFormat.Rg16Sint or
|
||||
MtlPixelFormat.Rg32Sint or MtlPixelFormat.Rgba8Sint or MtlPixelFormat.Rgba16Sint =>
|
||||
Gen5PixelOutputKind.Sint,
|
||||
_ => Gen5PixelOutputKind.Float,
|
||||
};
|
||||
result = new MetalRenderTargetFormat(format, outputKind);
|
||||
|
||||
@@ -70,6 +70,7 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
|
||||
int scalarRegisterBufferIndex = -1,
|
||||
uint pixelInputEnable = 0,
|
||||
uint pixelInputAddress = 0,
|
||||
IReadOnlyList<uint>? pixelInputCntl = null,
|
||||
ulong storageBufferOffsetAlignment = 1)
|
||||
{
|
||||
shader = null;
|
||||
@@ -85,6 +86,7 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
|
||||
scalarRegisterBufferIndex,
|
||||
pixelInputEnable,
|
||||
pixelInputAddress,
|
||||
pixelInputCntl,
|
||||
storageBufferOffsetAlignment))
|
||||
{
|
||||
return false;
|
||||
@@ -251,7 +253,8 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
|
||||
GuestIndexBuffer? indexBuffer = null,
|
||||
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
||||
GuestRenderState? renderState = null,
|
||||
ulong shaderAddress = 0) =>
|
||||
ulong shaderAddress = 0,
|
||||
int baseVertex = 0) =>
|
||||
MetalVideoPresenter.SubmitDepthOnlyTranslatedDraw(
|
||||
Msl(pixelShader),
|
||||
textures,
|
||||
@@ -265,7 +268,8 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
|
||||
indexBuffer,
|
||||
vertexBuffers,
|
||||
renderState,
|
||||
shaderAddress);
|
||||
shaderAddress,
|
||||
baseVertex);
|
||||
|
||||
public void SubmitOffscreenTranslatedDraw(
|
||||
IGuestCompiledShader pixelShader,
|
||||
@@ -281,7 +285,8 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
|
||||
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
||||
GuestRenderState? renderState = null,
|
||||
GuestDepthTarget? depthTarget = null,
|
||||
ulong shaderAddress = 0) =>
|
||||
ulong shaderAddress = 0,
|
||||
int baseVertex = 0) =>
|
||||
MetalVideoPresenter.SubmitOffscreenTranslatedDraw(
|
||||
Msl(pixelShader),
|
||||
textures,
|
||||
@@ -296,7 +301,8 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
|
||||
vertexBuffers,
|
||||
renderState,
|
||||
depthTarget,
|
||||
shaderAddress);
|
||||
shaderAddress,
|
||||
baseVertex);
|
||||
|
||||
public void SubmitStorageTranslatedDraw(
|
||||
IGuestCompiledShader pixelShader,
|
||||
@@ -395,6 +401,9 @@ internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
|
||||
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
|
||||
MetalVideoPresenter.SubmitGuestImageWrite(address, pixels);
|
||||
|
||||
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
|
||||
MetalVideoPresenter.RequestCpuWrittenGuestImageSync(scopeAddress, scopeByteCount);
|
||||
|
||||
public bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) =>
|
||||
MetalVideoPresenter.TryGetGuestImageExtent(address, out width, out height, out byteCount);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.Libs.Agc;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
using SharpEmu.ShaderCompiler.Metal;
|
||||
|
||||
@@ -86,7 +88,8 @@ internal static partial class MetalVideoPresenter
|
||||
uint InstanceCount,
|
||||
uint PrimitiveType,
|
||||
GuestIndexBuffer? IndexBuffer,
|
||||
GuestRenderState RenderState);
|
||||
GuestRenderState RenderState,
|
||||
int BaseVertex = 0);
|
||||
|
||||
private sealed record OffscreenGuestDraw(
|
||||
TranslatedGuestDraw Draw,
|
||||
@@ -244,7 +247,8 @@ internal static partial class MetalVideoPresenter
|
||||
IReadOnlyList<GuestVertexBuffer>? vertexBuffers,
|
||||
GuestRenderState? renderState,
|
||||
GuestDepthTarget? depthTarget,
|
||||
ulong shaderAddress)
|
||||
ulong shaderAddress,
|
||||
int baseVertex = 0)
|
||||
{
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
@@ -292,7 +296,8 @@ internal static partial class MetalVideoPresenter
|
||||
instanceCount,
|
||||
primitiveType,
|
||||
indexBuffer,
|
||||
effectiveRenderState),
|
||||
effectiveRenderState,
|
||||
baseVertex),
|
||||
ToArray(targets),
|
||||
depthTarget,
|
||||
PublishTarget: true,
|
||||
@@ -320,7 +325,8 @@ internal static partial class MetalVideoPresenter
|
||||
GuestIndexBuffer? indexBuffer,
|
||||
IReadOnlyList<GuestVertexBuffer>? vertexBuffers,
|
||||
GuestRenderState? renderState,
|
||||
ulong shaderAddress)
|
||||
ulong shaderAddress,
|
||||
int baseVertex = 0)
|
||||
{
|
||||
if (depthTarget.Address == 0 || depthTarget.Width == 0 || depthTarget.Height == 0)
|
||||
{
|
||||
@@ -347,7 +353,8 @@ internal static partial class MetalVideoPresenter
|
||||
instanceCount,
|
||||
primitiveType,
|
||||
indexBuffer,
|
||||
renderState ?? GuestRenderState.Default),
|
||||
renderState ?? GuestRenderState.Default,
|
||||
baseVertex),
|
||||
[new GuestRenderTarget(Address: 0, depthTarget.Width, depthTarget.Height, Format: 10, NumberType: 0)],
|
||||
depthTarget,
|
||||
PublishTarget: false,
|
||||
@@ -980,17 +987,38 @@ internal static partial class MetalVideoPresenter
|
||||
|
||||
private static void EncodeDrawCall(nint encoder, TranslatedGuestDraw draw)
|
||||
{
|
||||
var primitive = GetPrimitiveType(draw.PrimitiveType);
|
||||
var vertexCount = draw.PrimitiveType == 0x11 && draw.IndexBuffer is null
|
||||
? 4u
|
||||
: draw.VertexCount;
|
||||
var indexed = draw.IndexBuffer is not null;
|
||||
var hasVertexBuffers = draw.VertexBuffers.Length > 0;
|
||||
var primitive = GetPrimitiveType(
|
||||
draw.PrimitiveType,
|
||||
indexed,
|
||||
draw.VertexCount,
|
||||
hasVertexBuffers);
|
||||
var vertexCount = AgcPrimitiveHelpers.GetRectListDrawVertexCount(
|
||||
draw.PrimitiveType,
|
||||
draw.VertexCount,
|
||||
indexed,
|
||||
hasVertexBuffers);
|
||||
var baseVertex = (nuint)Math.Max(draw.BaseVertex, 0);
|
||||
if (draw.IndexBuffer is { } indexBuffer)
|
||||
{
|
||||
var device = MetalNative.Send(encoder, MetalNative.Selector("device"));
|
||||
var slice = AllocateUpload(
|
||||
device, Math.Max(indexBuffer.Length, 1), out var buffer, out var offset);
|
||||
indexBuffer.Data.AsSpan(0, Math.Min(indexBuffer.Length, indexBuffer.Data.Length))
|
||||
.CopyTo(slice);
|
||||
var source = indexBuffer.Data.AsSpan(
|
||||
0,
|
||||
Math.Min(indexBuffer.Length, indexBuffer.Data.Length));
|
||||
// Metal drawIndexed without baseVertex: bake GE_INDX_OFFSET into
|
||||
// the uploaded indices so glyph batches still hit the right verts.
|
||||
if (draw.BaseVertex != 0)
|
||||
{
|
||||
BakeBaseVertexIntoIndices(source, slice, indexBuffer.Is32Bit, draw.BaseVertex);
|
||||
}
|
||||
else
|
||||
{
|
||||
source.CopyTo(slice);
|
||||
}
|
||||
|
||||
MetalNative.SendDrawIndexedPrimitives(
|
||||
encoder,
|
||||
MetalNative.Selector("drawIndexedPrimitives:indexCount:indexType:indexBuffer:indexBufferOffset:instanceCount:"),
|
||||
@@ -1011,12 +1039,46 @@ internal static partial class MetalVideoPresenter
|
||||
encoder,
|
||||
MetalNative.Selector("drawPrimitives:vertexStart:vertexCount:instanceCount:"),
|
||||
primitive,
|
||||
0,
|
||||
baseVertex,
|
||||
vertexCount,
|
||||
Math.Max(draw.InstanceCount, 1));
|
||||
}
|
||||
}
|
||||
|
||||
private static void BakeBaseVertexIntoIndices(
|
||||
ReadOnlySpan<byte> source,
|
||||
Span<byte> destination,
|
||||
bool is32Bit,
|
||||
int baseVertex)
|
||||
{
|
||||
if (is32Bit)
|
||||
{
|
||||
var count = source.Length / sizeof(uint);
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
var value = BinaryPrimitives.ReadUInt32LittleEndian(
|
||||
source.Slice(index * sizeof(uint), sizeof(uint)));
|
||||
var adjusted = unchecked((uint)(value + baseVertex));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
destination.Slice(index * sizeof(uint), sizeof(uint)),
|
||||
adjusted);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var shortCount = source.Length / sizeof(ushort);
|
||||
for (var index = 0; index < shortCount; index++)
|
||||
{
|
||||
var value = BinaryPrimitives.ReadUInt16LittleEndian(
|
||||
source.Slice(index * sizeof(ushort), sizeof(ushort)));
|
||||
var adjusted = unchecked((ushort)(value + baseVertex));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(
|
||||
destination.Slice(index * sizeof(ushort), sizeof(ushort)),
|
||||
adjusted);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetDrawPipeline(
|
||||
nint device,
|
||||
TranslatedGuestDraw draw,
|
||||
@@ -1225,8 +1287,11 @@ internal static partial class MetalVideoPresenter
|
||||
? vertexBuffer.Stride
|
||||
: Math.Max(vertexBuffer.ComponentCount, 1) * 4;
|
||||
MetalNative.Send(layout, MetalNative.Selector("setStride:"), (nint)stride);
|
||||
// MTLVertexStepFunction.PerVertex = 1.
|
||||
MetalNative.Send(layout, MetalNative.Selector("setStepFunction:"), 1);
|
||||
// MTLVertexStepFunction: PerVertex = 1, PerInstance = 2.
|
||||
MetalNative.Send(
|
||||
layout,
|
||||
MetalNative.Selector("setStepFunction:"),
|
||||
vertexBuffer.PerInstance ? 2 : 1);
|
||||
}
|
||||
|
||||
return descriptor;
|
||||
@@ -1583,6 +1648,45 @@ internal static partial class MetalVideoPresenter
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Default-on GPU detile packages the tiled source + resolved DetileParams
|
||||
// with empty RgbaPixels so a backend can deswizzle on the GPU. The Metal
|
||||
// GPU compute pass (MetalDetilePass / detile_compute.msl) is the intended
|
||||
// equivalent of VulkanDetilePass, but it is Mac-untested, so the active
|
||||
// Metal path CPU-detiles here via GnmTiling.DetileWithParams — the exact
|
||||
// same DetileParams addressing the kernel runs — restoring the linear-upload
|
||||
// behavior Metal had before GPU detile existed, with no regression.
|
||||
if (texture.RgbaPixels.Length == 0 &&
|
||||
texture.TiledSource is { } tiledSource &&
|
||||
texture.Detile is { } detileParameters)
|
||||
{
|
||||
// The tiled source packs the array slices contiguously (one per layer);
|
||||
// detile each into its layer-major linear region so the reconstructed
|
||||
// pixels match what the CPU array-upload path produced pre-GPU-detile.
|
||||
// A plain 2D texture is just one layer.
|
||||
var layers = Math.Max((int)texture.ArrayLayers, 1);
|
||||
var sliceLinearBytes =
|
||||
detileParameters.ElementsWide * detileParameters.ElementsHigh * detileParameters.BytesPerElement;
|
||||
var sliceTiledBytes = tiledSource.Length / layers;
|
||||
var linear = new byte[sliceLinearBytes * layers];
|
||||
var detiledAll = true;
|
||||
for (var layer = 0; layer < layers; layer++)
|
||||
{
|
||||
if (!GnmTiling.DetileWithParams(
|
||||
detileParameters,
|
||||
tiledSource.AsSpan(layer * sliceTiledBytes, sliceTiledBytes),
|
||||
linear.AsSpan(layer * sliceLinearBytes, sliceLinearBytes)))
|
||||
{
|
||||
detiledAll = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (detiledAll)
|
||||
{
|
||||
texture = texture with { RgbaPixels = linear };
|
||||
}
|
||||
}
|
||||
|
||||
// AGC ships the raw (detiled) source texels; create the texture in the
|
||||
// guest's native format — Mac-family GPUs sample BC blocks directly —
|
||||
// and size expectations with the same block-aware math AGC used.
|
||||
@@ -2019,13 +2123,30 @@ internal static partial class MetalVideoPresenter
|
||||
|
||||
return 3;
|
||||
case 6:
|
||||
case 0x11:
|
||||
return 4;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
private static nuint GetPrimitiveType(
|
||||
uint guestPrimitiveType,
|
||||
bool indexed,
|
||||
uint vertexCount,
|
||||
bool hasVertexBuffers)
|
||||
{
|
||||
if (AgcPrimitiveHelpers.ShouldDrawRectListAsTriangleStrip(
|
||||
guestPrimitiveType,
|
||||
indexed,
|
||||
vertexCount,
|
||||
hasVertexBuffers))
|
||||
{
|
||||
return 4; // MTLPrimitiveTypeTriangleStrip
|
||||
}
|
||||
|
||||
return GetPrimitiveType(guestPrimitiveType);
|
||||
}
|
||||
|
||||
private static bool IsIntegerFormat(Gen5PixelOutputKind kind) =>
|
||||
kind is Gen5PixelOutputKind.Uint or Gen5PixelOutputKind.Sint;
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ internal static partial class MetalVideoPresenter
|
||||
private static readonly Dictionary<long, GuestImage> _guestImageVersions = new();
|
||||
private static readonly Dictionary<(int Handle, int BufferIndex), long>
|
||||
_lastOrderedGuestFlipVersions = new();
|
||||
private static readonly Dictionary<ulong, ulong> _untrackedGuestImageContentProbes = new();
|
||||
private static long _orderedGuestFlipVersionSequence;
|
||||
private static volatile ICpuMemory? _guestMemory;
|
||||
|
||||
@@ -162,6 +163,22 @@ internal static partial class MetalVideoPresenter
|
||||
public static void AttachGuestMemory(ICpuMemory memory) =>
|
||||
_guestMemory = memory;
|
||||
|
||||
private static int _cpuWrittenGuestImageSyncRequested;
|
||||
private static long _guestImageCpuSyncTraceCount;
|
||||
|
||||
public static void RequestCpuWrittenGuestImageSync(
|
||||
ulong scopeAddress = 0,
|
||||
ulong scopeByteCount = ulong.MaxValue)
|
||||
{
|
||||
_ = scopeAddress;
|
||||
if (scopeByteCount == 0 || !GuestImageWriteTracker.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _cpuWrittenGuestImageSyncRequested, 1);
|
||||
}
|
||||
|
||||
public static long SubmitOrderedGuestAction(Action action, string debugName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
@@ -253,8 +270,110 @@ internal static partial class MetalVideoPresenter
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType) =>
|
||||
IsGuestImageAvailable(address, format, numberType);
|
||||
public static bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType)
|
||||
{
|
||||
var guestFormat = GetGuestTextureFormat(format, numberType);
|
||||
if (address == 0 || guestFormat == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ulong probeByteCount = 0;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_availableGuestImages.TryGetValue(address, out var availableFormat) ||
|
||||
availableFormat != guestFormat)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (GuestImageWriteTracker.Enabled)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_guestImageExtents.TryGetValue(address, out var extent))
|
||||
{
|
||||
probeByteCount = extent.ByteCount;
|
||||
}
|
||||
}
|
||||
|
||||
return IsUntrackedGuestImageContentUnchanged(address, probeByteCount);
|
||||
}
|
||||
|
||||
private static bool IsUntrackedGuestImageContentUnchanged(ulong address, ulong byteCount)
|
||||
{
|
||||
var memory = _guestMemory;
|
||||
if (memory is null || byteCount == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var probe = ComputeSparseGuestContentProbe(memory, address, byteCount);
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_untrackedGuestImageContentProbes.TryGetValue(address, out var previous))
|
||||
{
|
||||
_untrackedGuestImageContentProbes[address] = probe;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (previous == probe)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_untrackedGuestImageContentProbes[address] = probe;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static ulong ComputeSparseGuestContentProbe(
|
||||
ICpuMemory memory,
|
||||
ulong address,
|
||||
ulong byteCount)
|
||||
{
|
||||
Span<byte> sample = stackalloc byte[64];
|
||||
ulong hash = 14695981039346656037UL;
|
||||
Span<ulong> offsets = stackalloc ulong[3];
|
||||
var offsetCount = 0;
|
||||
offsets[offsetCount++] = 0;
|
||||
if (byteCount > 128)
|
||||
{
|
||||
offsets[offsetCount++] = byteCount / 2;
|
||||
}
|
||||
|
||||
if (byteCount > 64)
|
||||
{
|
||||
offsets[offsetCount++] = byteCount - 64;
|
||||
}
|
||||
|
||||
for (var o = 0; o < offsetCount; o++)
|
||||
{
|
||||
var offset = offsets[o];
|
||||
if (offset >= byteCount)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var length = (int)Math.Min(64UL, byteCount - offset);
|
||||
if (!memory.TryRead(address + offset, sample[..length]))
|
||||
{
|
||||
hash ^= 0x9E3779B97F4A7C15UL + offset;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var i = 0; i < length; i++)
|
||||
{
|
||||
hash ^= sample[i];
|
||||
hash *= 1099511628211UL;
|
||||
}
|
||||
|
||||
hash ^= (ulong)length + offset;
|
||||
}
|
||||
|
||||
return hash ^ byteCount;
|
||||
}
|
||||
|
||||
public static bool GuestImageWantsInitialData(ulong address)
|
||||
{
|
||||
@@ -599,7 +718,7 @@ internal static partial class MetalVideoPresenter
|
||||
var completedWork = 0;
|
||||
RecycleCompletedUploadPages();
|
||||
RecycleCompletedSnapshotResources();
|
||||
EvictDirtyCachedDrawTextures();
|
||||
DrainGuestImageCpuSync(device);
|
||||
try
|
||||
{
|
||||
while (completedWork < MaxGuestWorkPerRender)
|
||||
|
||||
@@ -45,12 +45,11 @@ internal static partial class MetalVideoPresenter
|
||||
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>
|
||||
/// <summary>Storage textures are shader-writable on the GPU, so their
|
||||
/// content identity is not stable. CPU rewrites of protected/CPU-backed
|
||||
/// images still evict via DrainGuestImageCpuSync when those addresses
|
||||
/// are dirty.</summary>
|
||||
private static bool IsCacheableDrawTexture(GuestDrawTexture texture) =>
|
||||
GuestImageWriteTracker.Enabled &&
|
||||
texture.Address != 0 &&
|
||||
!texture.IsStorage &&
|
||||
!texture.IsFallback;
|
||||
@@ -69,20 +68,100 @@ internal static partial class MetalVideoPresenter
|
||||
_ = 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");
|
||||
// No GuestImageWriteTracker.Track: watch-only cache registrations
|
||||
// widened the managed-write hot path. CPU-backed / protected images
|
||||
// own dirty notifications used for eviction.
|
||||
}
|
||||
|
||||
/// <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()
|
||||
/// <summary>
|
||||
/// Single dirty consumer per drain: re-upload CPU-written guest images,
|
||||
/// evict matching draw-texture cache entries, then re-arm once per address.
|
||||
/// </summary>
|
||||
private static void DrainGuestImageCpuSync(nint device)
|
||||
{
|
||||
if (!GuestImageWriteTracker.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Interlocked.Exchange(ref _cpuWrittenGuestImageSyncRequested, 0);
|
||||
|
||||
HashSet<ulong>? dirtyAddresses = null;
|
||||
List<(ulong Address, uint Width, uint Height, ulong ByteCount)>? extents = null;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_guestImageExtents.Count > 0)
|
||||
{
|
||||
extents = new(_guestImageExtents.Count);
|
||||
foreach (var entry in _guestImageExtents)
|
||||
{
|
||||
extents.Add((
|
||||
entry.Key,
|
||||
entry.Value.Width,
|
||||
entry.Value.Height,
|
||||
entry.Value.ByteCount));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var memory = _guestMemory;
|
||||
if (extents is not null)
|
||||
{
|
||||
foreach (var (address, width, height, byteCount) in extents)
|
||||
{
|
||||
if (!GuestImageWriteTracker.ConsumeDirty(address))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
(dirtyAddresses ??= []).Add(address);
|
||||
if (memory is null ||
|
||||
byteCount == 0 ||
|
||||
byteCount > 128UL * 1024UL * 1024UL)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
GuestImage? image;
|
||||
lock (_gate)
|
||||
{
|
||||
_guestImages.TryGetValue(address, out image);
|
||||
}
|
||||
|
||||
if (image is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var pixels = new byte[byteCount];
|
||||
if (!memory.TryRead(address, pixels) ||
|
||||
pixels.AsSpan().IndexOfAnyExcept((byte)0) < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ExecuteGuestImageWrite(
|
||||
device,
|
||||
queue: 0,
|
||||
new GuestImageWrite(address, pixels, 0));
|
||||
if (Interlocked.Increment(ref _guestImageCpuSyncTraceCount) <= 64)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[SYNC] cpu-write-drain addr=0x{address:X} {width}x{height}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_drawTextureCache.Count == 0)
|
||||
{
|
||||
if (dirtyAddresses is not null)
|
||||
{
|
||||
foreach (var address in dirtyAddresses)
|
||||
{
|
||||
GuestImageWriteTracker.Rearm(address);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -90,17 +169,17 @@ internal static partial class MetalVideoPresenter
|
||||
// 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))
|
||||
var address = entry.Key.Address;
|
||||
if (dirtyAddresses is not null && dirtyAddresses.Contains(address))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (GuestImageWriteTracker.ConsumeDirty(entry.Key.Address))
|
||||
if (GuestImageWriteTracker.ConsumeDirty(address))
|
||||
{
|
||||
(dirtyAddresses ??= []).Add(entry.Key.Address);
|
||||
(dirtyAddresses ??= []).Add(address);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +197,14 @@ internal static partial class MetalVideoPresenter
|
||||
|
||||
_drawTextureCache.Clear();
|
||||
_cachedDrawTextureIdentities.Clear();
|
||||
if (dirtyAddresses is not null)
|
||||
{
|
||||
foreach (var address in dirtyAddresses)
|
||||
{
|
||||
GuestImageWriteTracker.Rearm(address);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
|
||||
int scalarRegisterBufferIndex = -1,
|
||||
uint pixelInputEnable = 0,
|
||||
uint pixelInputAddress = 0,
|
||||
IReadOnlyList<uint>? pixelInputCntl = null,
|
||||
ulong storageBufferOffsetAlignment = 1)
|
||||
{
|
||||
shader = null;
|
||||
@@ -79,6 +80,7 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
|
||||
scalarRegisterBufferIndex,
|
||||
pixelInputEnable,
|
||||
pixelInputAddress,
|
||||
pixelInputCntl,
|
||||
storageBufferOffsetAlignment))
|
||||
{
|
||||
return false;
|
||||
@@ -179,7 +181,8 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
|
||||
GuestIndexBuffer? indexBuffer = null,
|
||||
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
||||
GuestRenderState? renderState = null,
|
||||
ulong shaderAddress = 0) =>
|
||||
ulong shaderAddress = 0,
|
||||
int baseVertex = 0) =>
|
||||
VulkanVideoPresenter.SubmitDepthOnlyTranslatedDraw(
|
||||
Spirv(pixelShader),
|
||||
textures,
|
||||
@@ -193,7 +196,8 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
|
||||
indexBuffer,
|
||||
vertexBuffers,
|
||||
renderState,
|
||||
shaderAddress);
|
||||
shaderAddress,
|
||||
baseVertex);
|
||||
|
||||
public void SubmitOffscreenTranslatedDraw(
|
||||
IGuestCompiledShader pixelShader,
|
||||
@@ -209,7 +213,8 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
|
||||
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
|
||||
GuestRenderState? renderState = null,
|
||||
GuestDepthTarget? depthTarget = null,
|
||||
ulong shaderAddress = 0) =>
|
||||
ulong shaderAddress = 0,
|
||||
int baseVertex = 0) =>
|
||||
VulkanVideoPresenter.SubmitOffscreenTranslatedDraw(
|
||||
Spirv(pixelShader),
|
||||
textures,
|
||||
@@ -224,7 +229,8 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
|
||||
vertexBuffers,
|
||||
renderState,
|
||||
depthTarget,
|
||||
shaderAddress);
|
||||
shaderAddress,
|
||||
baseVertex);
|
||||
|
||||
public void SubmitStorageTranslatedDraw(
|
||||
IGuestCompiledShader pixelShader,
|
||||
@@ -375,6 +381,9 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
|
||||
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
|
||||
VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels);
|
||||
|
||||
public void RequestCpuWrittenGuestImageSync(ulong scopeAddress = 0, ulong scopeByteCount = ulong.MaxValue) =>
|
||||
VulkanVideoPresenter.RequestCpuWrittenGuestImageSync(scopeAddress, scopeByteCount);
|
||||
|
||||
public bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) =>
|
||||
VulkanVideoPresenter.TryGetGuestImageExtent(address, out width, out height, out byteCount);
|
||||
|
||||
|
||||
@@ -80,6 +80,18 @@ 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",
|
||||
@@ -119,6 +131,17 @@ public static class JsonExports
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
// Catalog alias NID for the same callback setter.
|
||||
#pragma warning disable SHEM004
|
||||
[SysAbiExport(
|
||||
Nid = "00oCq0RwSAY",
|
||||
ExportName = "_ZN3sce4Json11Initializer27setGlobalNullAccessCallbackEPFRKNS0_5ValueENS0_9ValueTypeEPS3_PvES7_",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceJson")]
|
||||
public static int InitializerSetGlobalNullAccessCallbackAlt(CpuContext ctx) =>
|
||||
InitializerSetGlobalNullAccessCallback(ctx);
|
||||
#pragma warning restore SHEM004
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "WSOuge5IsCg",
|
||||
ExportName = "_ZN3sce4Json14InitParameter2C1Ev",
|
||||
|
||||
@@ -362,7 +362,7 @@ public static class KernelExports
|
||||
ExportName = "open",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int Open(CpuContext ctx) => KernelMemoryCompatExports.KernelOpenUnderscore(ctx);
|
||||
public static int Open(CpuContext ctx) => KernelMemoryCompatExports.PosixOpen(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "1G3lF1Gg1k8",
|
||||
@@ -376,7 +376,7 @@ public static class KernelExports
|
||||
ExportName = "fstat",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libc")]
|
||||
public static int Fstat(CpuContext ctx) => KernelMemoryCompatExports.KernelFstat(ctx);
|
||||
public static int Fstat(CpuContext ctx) => KernelMemoryCompatExports.PosixFstat(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "hcuQgD53UxM",
|
||||
|
||||
@@ -267,6 +267,11 @@ public static partial class KernelMemoryCompatExports
|
||||
}
|
||||
|
||||
var hostPath = ResolveGuestPath(guestPath);
|
||||
if (string.IsNullOrEmpty(hostPath))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(hostPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite);
|
||||
@@ -310,6 +315,11 @@ public static partial class KernelMemoryCompatExports
|
||||
|
||||
var fromHost = ResolveGuestPath(fromGuest);
|
||||
var toHost = ResolveGuestPath(toGuest);
|
||||
if (string.IsNullOrEmpty(fromHost) || string.IsNullOrEmpty(toHost))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(fromHost))
|
||||
|
||||
@@ -41,18 +41,87 @@ public static class KernelPthreadCompatExports
|
||||
|
||||
private sealed class PthreadMutexState
|
||||
{
|
||||
public ulong OwnerThreadId { get; set; }
|
||||
public int RecursionCount { get; set; }
|
||||
private long _ownerThreadId;
|
||||
private int _recursionCount;
|
||||
private int _queuedWaiterCount;
|
||||
|
||||
public Lock SyncRoot { get; } = new();
|
||||
public ulong OwnerThreadId
|
||||
{
|
||||
get => unchecked((ulong)Volatile.Read(ref _ownerThreadId));
|
||||
set => Volatile.Write(ref _ownerThreadId, unchecked((long)value));
|
||||
}
|
||||
|
||||
public int RecursionCount
|
||||
{
|
||||
get => Volatile.Read(ref _recursionCount);
|
||||
set => Volatile.Write(ref _recursionCount, value);
|
||||
}
|
||||
|
||||
public int QueuedWaiterCount => Volatile.Read(ref _queuedWaiterCount);
|
||||
public int Type { get; set; } = MutexTypeErrorCheck;
|
||||
public int Protocol { get; set; }
|
||||
public LinkedList<PthreadMutexWaiter> Waiters { get; } = new();
|
||||
|
||||
public bool TryAcquireUncontended(ulong threadId, bool allowWaiterBarge)
|
||||
{
|
||||
if (!allowWaiterBarge && QueuedWaiterCount != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryAcquireOwner(threadId);
|
||||
}
|
||||
|
||||
public bool TryAcquireOwner(ulong threadId)
|
||||
{
|
||||
if (Interlocked.CompareExchange(
|
||||
ref _ownerThreadId,
|
||||
unchecked((long)threadId),
|
||||
0) != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _recursionCount, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryReleaseUncontended(ulong threadId)
|
||||
{
|
||||
if (QueuedWaiterCount != 0 || RecursionCount != 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _recursionCount, 0);
|
||||
if (Interlocked.CompareExchange(
|
||||
ref _ownerThreadId,
|
||||
0,
|
||||
unchecked((long)threadId)) == unchecked((long)threadId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _recursionCount, 1);
|
||||
return false;
|
||||
}
|
||||
|
||||
public int IncrementRecursion() => Interlocked.Increment(ref _recursionCount);
|
||||
|
||||
public int DecrementRecursion() => Interlocked.Decrement(ref _recursionCount);
|
||||
|
||||
public void WaiterAddedLocked() => Interlocked.Increment(ref _queuedWaiterCount);
|
||||
|
||||
public void WaiterRemovedLocked() => Interlocked.Decrement(ref _queuedWaiterCount);
|
||||
}
|
||||
|
||||
private sealed class PthreadMutexWaiter
|
||||
{
|
||||
public required ulong ThreadId { get; init; }
|
||||
public required string WakeKey { get; init; }
|
||||
public required bool Cooperative { get; init; }
|
||||
public required bool Cooperative { get; set; }
|
||||
public ManualResetEventSlim? HostSignal { get; set; }
|
||||
public LinkedListNode<PthreadMutexWaiter>? Node { get; set; }
|
||||
public int Granted;
|
||||
}
|
||||
@@ -84,6 +153,64 @@ 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(
|
||||
@@ -94,7 +221,10 @@ public static class KernelPthreadCompatExports
|
||||
public static int PthreadSelf(CpuContext ctx)
|
||||
{
|
||||
var currentThreadHandle = KernelPthreadState.GetCurrentThreadHandle();
|
||||
GuestThreadExecution.Scheduler?.RegisterGuestThreadContext(currentThreadHandle, ctx);
|
||||
if (GuestThreadExecution.CurrentGuestThreadHandle != currentThreadHandle)
|
||||
{
|
||||
GuestThreadExecution.Scheduler?.RegisterGuestThreadContext(currentThreadHandle, ctx);
|
||||
}
|
||||
ctx[CpuRegister.Rax] = currentThreadHandle;
|
||||
TracePthreadSelf(ctx, currentThreadHandle);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
@@ -139,6 +269,13 @@ public static class KernelPthreadCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "B5GmVDKwpn0",
|
||||
ExportName = "pthread_yield",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixPthreadYield(CpuContext ctx) => PthreadYield(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "GBUY7ywdULE",
|
||||
ExportName = "scePthreadRename",
|
||||
@@ -571,6 +708,30 @@ public static class KernelPthreadCompatExports
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The POSIX-named alias of <see cref="PthreadOnce"/>. libKernel exports the
|
||||
/// same routine under two NIDs, and shipped middleware links the plain name:
|
||||
/// DOOM's libcohtml, PlayFab and party modules all import this one rather
|
||||
/// than scePthreadOnce.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "Z4QosVuAsA0",
|
||||
ExportName = "pthread_once",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadOncePOSIX(CpuContext ctx) => PthreadOnce(ctx);
|
||||
|
||||
/// <summary>
|
||||
/// The POSIX-named alias of <see cref="PthreadRename"/>, following the same
|
||||
/// two-NID pattern as <see cref="PthreadOncePOSIX"/>.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "9vyP6Z7bqzc",
|
||||
ExportName = "pthread_rename_np",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadRenameNpPOSIX(CpuContext ctx) => PthreadRename(ctx);
|
||||
|
||||
private static int PthreadMutexInitCore(CpuContext ctx, ulong mutexAddress, ulong attrAddress)
|
||||
{
|
||||
if (mutexAddress == 0)
|
||||
@@ -621,7 +782,7 @@ public static class KernelPthreadCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
lock (state)
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (state.OwnerThreadId != 0 || state.RecursionCount != 0 || state.Waiters.Count != 0)
|
||||
{
|
||||
@@ -653,11 +814,63 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
|
||||
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
|
||||
if (state.TryAcquireUncontended(currentThreadId, allowWaiterBarge: tryOnly))
|
||||
{
|
||||
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (state.OwnerThreadId == currentThreadId)
|
||||
{
|
||||
if (state.Type == MutexTypeRecursive)
|
||||
{
|
||||
state.IncrementRecursion();
|
||||
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (!tryOnly && state.Type == MutexTypeAdaptiveNp &&
|
||||
IsGuestTrackedSelfLock(ctx, mutexAddress, currentThreadId))
|
||||
{
|
||||
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
|
||||
}
|
||||
|
||||
if (state.Type == MutexTypeAdaptiveNp)
|
||||
{
|
||||
var adaptiveResult = tryOnly
|
||||
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
|
||||
: (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock-idempotent", mutexAddress, resolvedAddress, state, currentThreadId, adaptiveResult);
|
||||
return adaptiveResult;
|
||||
}
|
||||
|
||||
if (state.Type == MutexTypeNormal)
|
||||
{
|
||||
if (tryOnly)
|
||||
{
|
||||
TracePthreadMutex(ctx, "trylock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
}
|
||||
|
||||
state.IncrementRecursion();
|
||||
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
var ownedResult = tryOnly
|
||||
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
|
||||
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
|
||||
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, ownedResult);
|
||||
return ownedResult;
|
||||
}
|
||||
|
||||
var canCooperativelyBlock = !tryOnly &&
|
||||
GuestThreadExecution.IsGuestThread &&
|
||||
GuestThreadExecution.TryGetCurrentImportCallFrame(out _);
|
||||
PthreadMutexWaiter? waiter = null;
|
||||
lock (state)
|
||||
var acquiredWhileQueueing = false;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (state.OwnerThreadId == currentThreadId)
|
||||
{
|
||||
@@ -668,7 +881,30 @@ public static class KernelPthreadCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (state.Type is MutexTypeNormal or MutexTypeAdaptiveNp)
|
||||
if (!tryOnly && state.Type == MutexTypeAdaptiveNp &&
|
||||
IsGuestTrackedSelfLock(ctx, mutexAddress, currentThreadId))
|
||||
{
|
||||
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
|
||||
}
|
||||
|
||||
if (state.Type == MutexTypeAdaptiveNp)
|
||||
{
|
||||
if (tryOnly)
|
||||
{
|
||||
TracePthreadMutex(ctx, "trylock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
}
|
||||
|
||||
// Gen5 runtime wrappers can layer an adaptive lock call over
|
||||
// scePthreadMutexLock for one logical acquisition, followed by
|
||||
// only one unlock. Keep the duplicate acquisition idempotent so
|
||||
// the matching unlock fully releases the HLE mutex.
|
||||
TracePthreadMutex(ctx, "lock-idempotent", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (state.Type == MutexTypeNormal)
|
||||
{
|
||||
if (tryOnly)
|
||||
{
|
||||
@@ -677,7 +913,7 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
|
||||
// Several Gen5 runtimes layer their own owner/count bookkeeping
|
||||
// over a NORMAL or ADAPTIVE kernel mutex. Returning EDEADLK here
|
||||
// over a NORMAL kernel mutex. Returning EDEADLK here
|
||||
// leaves that guest bookkeeping out of sync with the HLE owner and
|
||||
// turns the wrapper into a permanent lock/unlock retry loop. Keep
|
||||
// the compatibility recursion used by the original implementation;
|
||||
@@ -703,10 +939,10 @@ public static class KernelPthreadCompatExports
|
||||
// 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))
|
||||
if (state.OwnerThreadId == 0 &&
|
||||
(tryOnly || state.Waiters.Count == 0) &&
|
||||
state.TryAcquireOwner(currentThreadId))
|
||||
{
|
||||
state.OwnerThreadId = currentThreadId;
|
||||
state.RecursionCount = 1;
|
||||
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
@@ -718,6 +954,14 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
|
||||
waiter = EnqueueMutexWaiterLocked(state, currentThreadId, canCooperativelyBlock);
|
||||
acquiredWhileQueueing = TryGrantMutexWaiterLocked(state, waiter);
|
||||
}
|
||||
|
||||
if (acquiredWhileQueueing)
|
||||
{
|
||||
waiter!.HostSignal?.Dispose();
|
||||
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (canCooperativelyBlock && waiter is not null &&
|
||||
@@ -751,8 +995,29 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
|
||||
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
|
||||
string? nextWakeKey = null;
|
||||
lock (state)
|
||||
if (state.OwnerThreadId == currentThreadId)
|
||||
{
|
||||
if (state.RecursionCount > 1)
|
||||
{
|
||||
state.DecrementRecursion();
|
||||
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (state.TryReleaseUncontended(currentThreadId))
|
||||
{
|
||||
if (state.QueuedWaiterCount != 0)
|
||||
{
|
||||
WakeFirstMutexWaiter(state);
|
||||
}
|
||||
|
||||
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
}
|
||||
|
||||
PthreadMutexWaiter? nextWaiter = null;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (state.RecursionCount <= 0)
|
||||
{
|
||||
@@ -770,16 +1035,29 @@ public static class KernelPthreadCompatExports
|
||||
if (state.RecursionCount == 0)
|
||||
{
|
||||
state.OwnerThreadId = 0;
|
||||
nextWakeKey = state.Waiters.First?.Value.Cooperative == true
|
||||
? state.Waiters.First.Value.WakeKey
|
||||
: null;
|
||||
Monitor.PulseAll(state);
|
||||
|
||||
// Hand the mutex directly to the head waiter instead of only
|
||||
// waking it and relying on it to re-acquire. A woken waiter that
|
||||
// fails to self-grant (its wake races or is lost) would leave the
|
||||
// mutex "free with a queued waiter"; the fast-acquire path refuses
|
||||
// such a mutex (OwnerThreadId == 0 && Waiters.Count == 0), so every
|
||||
// later locker — including the game's main thread — then queues
|
||||
// behind a head that never advances and the process wedges.
|
||||
if (state.Waiters.First is { } headNode &&
|
||||
TryGrantMutexWaiterLocked(state, headNode.Value))
|
||||
{
|
||||
nextWaiter = headNode.Value;
|
||||
if (!nextWaiter.Cooperative)
|
||||
{
|
||||
nextWaiter.HostSignal!.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nextWakeKey is not null)
|
||||
if (nextWaiter is { Cooperative: true })
|
||||
{
|
||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWakeKey, 1);
|
||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWaiter.WakeKey, 1);
|
||||
}
|
||||
|
||||
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
@@ -1239,7 +1517,7 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
|
||||
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
|
||||
lock (mutexState)
|
||||
lock (mutexState.SyncRoot)
|
||||
{
|
||||
if (mutexState.OwnerThreadId == 0 && mutexState.RecursionCount == 0)
|
||||
{
|
||||
@@ -1252,10 +1530,10 @@ public static class KernelPthreadCompatExports
|
||||
// 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.OwnerThreadId = currentThreadId;
|
||||
mutexState.RecursionCount = 1;
|
||||
_ = mutexState.TryAcquireOwner(currentThreadId);
|
||||
}
|
||||
else if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
|
||||
|
||||
if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
|
||||
{
|
||||
return mutexState.OwnerThreadId == currentThreadId
|
||||
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT
|
||||
@@ -1424,6 +1702,7 @@ public static class KernelPthreadCompatExports
|
||||
if (node.Value.ThreadId == threadId)
|
||||
{
|
||||
state.Waiters.Remove(node);
|
||||
state.WaiterRemovedLocked();
|
||||
node.Value.Node = null;
|
||||
}
|
||||
|
||||
@@ -1438,8 +1717,10 @@ public static class KernelPthreadCompatExports
|
||||
WakeKey = cooperative
|
||||
? wakeKey ?? $"pthread_mutex_waiter:{Interlocked.Increment(ref _nextSynchronizationWaiterId)}"
|
||||
: string.Empty,
|
||||
HostSignal = cooperative ? null : new ManualResetEventSlim(initialState: false),
|
||||
};
|
||||
waiter.Node = state.Waiters.AddLast(waiter);
|
||||
state.WaiterAddedLocked();
|
||||
return waiter;
|
||||
}
|
||||
|
||||
@@ -1449,7 +1730,7 @@ public static class KernelPthreadCompatExports
|
||||
var mutex = new PthreadMutexState();
|
||||
PthreadMutexWaiter first;
|
||||
PthreadMutexWaiter second;
|
||||
lock (mutex)
|
||||
lock (mutex.SyncRoot)
|
||||
{
|
||||
first = EnqueueMutexWaiterLocked(mutex, 0x101, cooperative: false);
|
||||
second = EnqueueMutexWaiterLocked(mutex, 0x202, cooperative: false);
|
||||
@@ -1493,26 +1774,72 @@ public static class KernelPthreadCompatExports
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!state.TryAcquireOwner(waiter.ThreadId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
state.Waiters.Remove(waiter.Node);
|
||||
state.WaiterRemovedLocked();
|
||||
waiter.Node = null;
|
||||
state.OwnerThreadId = waiter.ThreadId;
|
||||
state.RecursionCount = 1;
|
||||
Volatile.Write(ref waiter.Granted, 1);
|
||||
Monitor.PulseAll(state);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void WakeFirstMutexWaiter(PthreadMutexState state)
|
||||
{
|
||||
PthreadMutexWaiter? nextWaiter;
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (state.OwnerThreadId != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
nextWaiter = state.Waiters.First?.Value;
|
||||
if (nextWaiter is { Cooperative: false })
|
||||
{
|
||||
nextWaiter.HostSignal!.Set();
|
||||
}
|
||||
}
|
||||
|
||||
if (nextWaiter is { Cooperative: true })
|
||||
{
|
||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWaiter.WakeKey, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static int WaitForHostMutexLock(PthreadMutexState state, PthreadMutexWaiter waiter)
|
||||
{
|
||||
lock (state)
|
||||
ManualResetEventSlim? hostSignal = null;
|
||||
try
|
||||
{
|
||||
while (!TryGrantMutexWaiterLocked(state, waiter))
|
||||
while (true)
|
||||
{
|
||||
Monitor.Wait(state);
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
if (waiter.HostSignal is null)
|
||||
{
|
||||
waiter.Cooperative = false;
|
||||
waiter.HostSignal = new ManualResetEventSlim(initialState: false);
|
||||
}
|
||||
|
||||
hostSignal = waiter.HostSignal;
|
||||
if (TryGrantMutexWaiterLocked(state, waiter))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
hostSignal.Reset();
|
||||
}
|
||||
|
||||
hostSignal.Wait();
|
||||
}
|
||||
}
|
||||
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
finally
|
||||
{
|
||||
hostSignal?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGrantBlockedMutexLock(
|
||||
@@ -1523,7 +1850,7 @@ public static class KernelPthreadCompatExports
|
||||
PthreadMutexWaiter waiter)
|
||||
{
|
||||
var granted = false;
|
||||
lock (state)
|
||||
lock (state.SyncRoot)
|
||||
{
|
||||
granted = TryGrantMutexWaiterLocked(state, waiter);
|
||||
}
|
||||
@@ -1557,6 +1884,10 @@ public static class KernelPthreadCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
}
|
||||
|
||||
private static bool IsGuestTrackedSelfLock(CpuContext ctx, ulong mutexAddress, ulong currentThreadId) =>
|
||||
KernelMemoryCompatExports.TryReadUInt64Compat(ctx, mutexAddress + 8, out var guestOwner) &&
|
||||
guestOwner == currentThreadId;
|
||||
|
||||
private static bool CompleteCondWaiterLocked(
|
||||
PthreadCondState state,
|
||||
PthreadCondWaiter waiter,
|
||||
@@ -1572,7 +1903,7 @@ public static class KernelPthreadCompatExports
|
||||
waiter.TimeoutTimer?.Dispose();
|
||||
waiter.TimeoutTimer = null;
|
||||
|
||||
lock (waiter.MutexState)
|
||||
lock (waiter.MutexState.SyncRoot)
|
||||
{
|
||||
waiter.MutexWaiter = EnqueueMutexWaiterLocked(
|
||||
waiter.MutexState,
|
||||
@@ -1620,7 +1951,7 @@ public static class KernelPthreadCompatExports
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (waiter.MutexState)
|
||||
lock (waiter.MutexState.SyncRoot)
|
||||
{
|
||||
return TryGrantMutexWaiterLocked(waiter.MutexState, mutexWaiter);
|
||||
}
|
||||
|
||||
@@ -860,6 +860,18 @@ public static class KernelPthreadExtendedCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The POSIX-named alias of <see cref="PthreadAttrGetschedparam"/>. libKernel
|
||||
/// exports the same routine under two NIDs; middleware compiled against the
|
||||
/// plain POSIX headers links this one rather than scePthreadAttrGetschedparam.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "qlk9pSLsUmM",
|
||||
ExportName = "pthread_attr_getschedparam",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrGetschedparamPOSIX(CpuContext ctx) => PthreadAttrGetschedparam(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "FXPWHNk8Of0",
|
||||
ExportName = "scePthreadAttrGetschedparam",
|
||||
@@ -1133,6 +1145,90 @@ public static class KernelPthreadExtendedCompatExports
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixPthreadRwlockWrlock(CpuContext ctx) => PthreadRwlockWrlock(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "SFxTMOfuCkE",
|
||||
ExportName = "pthread_rwlock_tryrdlock",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixPthreadRwlockTryrdlock(CpuContext ctx) =>
|
||||
PthreadRwlockTryLockCore(ctx, ctx[CpuRegister.Rdi], write: false);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "XhWHn6P5R7U",
|
||||
ExportName = "pthread_rwlock_trywrlock",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixPthreadRwlockTrywrlock(CpuContext ctx) =>
|
||||
PthreadRwlockTryLockCore(ctx, ctx[CpuRegister.Rdi], write: true);
|
||||
|
||||
/// <summary>
|
||||
/// Non-blocking counterpart of <see cref="PthreadRwlockLockCore"/>: acquires
|
||||
/// only if the lock is free right now, otherwise reports BUSY.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately not routed through TryAcquireBlockedRwlock. That helper exists
|
||||
/// for the scheduler resume path and decrements WaitingWriters on success,
|
||||
/// which is correct only for a thread that previously incremented it. A fresh
|
||||
/// try never did, so reusing it would silently consume another thread's
|
||||
/// waiter count and let a queued writer be skipped.
|
||||
/// </remarks>
|
||||
private static int PthreadRwlockTryLockCore(CpuContext ctx, ulong rwlockAddress, bool write)
|
||||
{
|
||||
if (rwlockAddress == 0)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (!TryResolveRwlockState(ctx, rwlockAddress, createIfZero: true, out var resolvedAddress, out var rwlock))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
|
||||
lock (rwlock.SyncRoot)
|
||||
{
|
||||
if (write)
|
||||
{
|
||||
if (rwlock.WriterThreadId == currentThreadId || rwlock.GetReaderCount(currentThreadId) > 0)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
|
||||
}
|
||||
|
||||
// Mirrors the blocking path's re-entrant compat-writer grant so the
|
||||
// two agree on what counts as already owning the lock.
|
||||
if (rwlock.CompatWriterCounts.GetValueOrDefault(currentThreadId) > 0)
|
||||
{
|
||||
rwlock.AddCompatWriter(currentThreadId);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (rwlock.WriterThreadId != 0 ||
|
||||
rwlock.ReaderTotalCount != 0 ||
|
||||
rwlock.CompatWriterTotalCount != 0)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
}
|
||||
|
||||
DetectRwlockWriterConflict(resolvedAddress, rwlock, currentThreadId, "trywrlock");
|
||||
rwlock.WriterThreadId = currentThreadId;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (rwlock.WriterThreadId == currentThreadId)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
|
||||
}
|
||||
|
||||
if (ReaderMustWaitForRwlock(rwlock, currentThreadId))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
|
||||
}
|
||||
|
||||
rwlock.AddReader(currentThreadId);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "+L98PIbGttk",
|
||||
ExportName = "scePthreadRwlockUnlock",
|
||||
@@ -1819,4 +1915,94 @@ public static class KernelPthreadExtendedCompatExports
|
||||
BinaryPrimitives.WriteInt32LittleEndian(bytes, value);
|
||||
return ctx.Memory.TryWrite(address, bytes);
|
||||
}
|
||||
|
||||
// POSIX-named aliases. libKernel exports each of these routines under two
|
||||
// NIDs -- a scePthread* name and the plain POSIX name -- and middleware
|
||||
// compiled against POSIX headers links the latter. Both take identical
|
||||
// arguments and, per the convention already used by scePthreadOnce's alias,
|
||||
// return the same OrbisGen2Result rather than translating to errno.
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "a2P9wYGeZvc",
|
||||
ExportName = "pthread_setprio",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadSetprioPOSIX(CpuContext ctx) => PthreadSetprio(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "FIs3-UQT9sg",
|
||||
ExportName = "pthread_getschedparam",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadGetschedparamPOSIX(CpuContext ctx) => PthreadGetschedparam(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "vQm4fDEsWi8",
|
||||
ExportName = "pthread_attr_getstack",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrGetstackPOSIX(CpuContext ctx) => PthreadAttrGetstack(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "Ucsu-OK+els",
|
||||
ExportName = "pthread_attr_get_np",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrGetNpPOSIX(CpuContext ctx) => PthreadAttrGet(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "JarMIy8kKEY",
|
||||
ExportName = "pthread_attr_setschedpolicy",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrSetschedpolicyPOSIX(CpuContext ctx) => PthreadAttrSetschedpolicy(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "E+tyo3lp5Lw",
|
||||
ExportName = "pthread_attr_setdetachstate",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrSetdetachstatePOSIX(CpuContext ctx) => PthreadAttrSetdetachstate(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "euKRgm0Vn2M",
|
||||
ExportName = "pthread_attr_setschedparam",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrSetschedparamPOSIX(CpuContext ctx) => PthreadAttrSetschedparam(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "7ZlAakEf0Qg",
|
||||
ExportName = "pthread_attr_setinheritsched",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrSetinheritschedPOSIX(CpuContext ctx) => PthreadAttrSetinheritsched(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "0qOtCR-ZHck",
|
||||
ExportName = "pthread_attr_getstacksize",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrGetstacksizePOSIX(CpuContext ctx) => PthreadAttrGetstacksize(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "VUT1ZSrHT0I",
|
||||
ExportName = "pthread_attr_getdetachstate",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrGetdetachstatePOSIX(CpuContext ctx) => PthreadAttrGetdetachstate(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "JKyG3SWyA10",
|
||||
ExportName = "pthread_attr_setguardsize",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrSetguardsizePOSIX(CpuContext ctx) => PthreadAttrSetguardsize(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "JNkVVsVDmOk",
|
||||
ExportName = "pthread_attr_getguardsize",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadAttrGetguardsizePOSIX(CpuContext ctx) => PthreadAttrGetguardsize(ctx);
|
||||
}
|
||||
|
||||
@@ -27,8 +27,12 @@ internal static class KernelPthreadState
|
||||
internal static ulong GetCurrentThreadHandle()
|
||||
{
|
||||
var guestThreadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
|
||||
if (guestThreadHandle != 0 && TryGetThreadIdentity(guestThreadHandle, out _))
|
||||
// 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)
|
||||
{
|
||||
EnsureGuestThreadIdentity(guestThreadHandle);
|
||||
return guestThreadHandle;
|
||||
}
|
||||
|
||||
@@ -39,15 +43,27 @@ internal static class KernelPthreadState
|
||||
internal static ulong GetCurrentThreadUniqueId()
|
||||
{
|
||||
var guestThreadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
|
||||
if (guestThreadHandle != 0 && TryGetThreadIdentity(guestThreadHandle, out var identity))
|
||||
if (guestThreadHandle != 0)
|
||||
{
|
||||
return identity.UniqueId;
|
||||
return EnsureGuestThreadIdentity(guestThreadHandle).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));
|
||||
@@ -59,6 +75,18 @@ 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,6 +2058,13 @@ public static class KernelRuntimeCompatExports
|
||||
LibraryName = "libKernel")]
|
||||
public static int KernelNanosleep(CpuContext ctx) => NanosleepCore(ctx, posix: false);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "NhpspxdjEKU",
|
||||
ExportName = "_nanosleep",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PosixNanosleepUnderscore(CpuContext ctx) => NanosleepCore(ctx, posix: true);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "yS8U2TGCe1A",
|
||||
ExportName = "nanosleep",
|
||||
|
||||
@@ -191,6 +191,27 @@ public static class KernelSemaphoreCompatExports
|
||||
WakePredicate,
|
||||
deadline))
|
||||
{
|
||||
// A signal may have arrived between releasing the semaphore gate
|
||||
// (after incrementing WaitingThreads) and the scheduler registering
|
||||
// this block. When that happens WakeBlockedThreads cannot find the
|
||||
// waiter yet and the exit-handler re-check runs later; a re-check
|
||||
// here keeps the thread from yielding to the scheduler at all when
|
||||
// the count is already sufficient.
|
||||
lock (semaphore.Gate)
|
||||
{
|
||||
if (semaphore.Count >= needCount)
|
||||
{
|
||||
semaphore.Count -= needCount;
|
||||
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
|
||||
GuestThreadExecution.TryConsumeCurrentThreadBlock(out _);
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"wait-recheck handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
|
||||
}
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
}
|
||||
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
|
||||
@@ -428,6 +449,22 @@ public static class KernelSemaphoreCompatExports
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "GEnUkDZoUwY",
|
||||
ExportName = "scePthreadSemInit",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadSemInit(CpuContext ctx)
|
||||
{
|
||||
// scePthreadSemInit(sem, flag, value, name) seems to only support private semaphores
|
||||
if (ctx[CpuRegister.Rsi] != 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
return PosixSemInit(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "YCV5dGGBcCo",
|
||||
ExportName = "sem_wait",
|
||||
@@ -446,6 +483,13 @@ public static class KernelSemaphoreCompatExports
|
||||
return KernelWaitSema(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "C36iRE0F5sE",
|
||||
ExportName = "scePthreadSemWait",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadSemWait(CpuContext ctx) => PosixSemWait(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "WBWzsRifCEA",
|
||||
ExportName = "sem_trywait",
|
||||
@@ -463,6 +507,19 @@ public static class KernelSemaphoreCompatExports
|
||||
return KernelPollSema(ctx, handle, 1);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "H2a+IN9TP0E",
|
||||
ExportName = "scePthreadSemTrywait",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadSemTryWait(CpuContext ctx)
|
||||
{
|
||||
var result = PosixSemTryWait(ctx);
|
||||
return result == (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
|
||||
? SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN)
|
||||
: result;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "w5IHyvahg-o",
|
||||
ExportName = "sem_timedwait",
|
||||
@@ -499,6 +556,13 @@ public static class KernelSemaphoreCompatExports
|
||||
return KernelSignalSema(ctx, handle, 1);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "aishVAiFaYM",
|
||||
ExportName = "scePthreadSemPost",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadSemPost(CpuContext ctx) => PosixSemPost(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "Bq+LRV-N6Hk",
|
||||
ExportName = "sem_getvalue",
|
||||
@@ -549,6 +613,13 @@ public static class KernelSemaphoreCompatExports
|
||||
return result;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "Vwc+L05e6oE",
|
||||
ExportName = "scePthreadSemDestroy",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int PthreadSemDestroy(CpuContext ctx) => PosixSemDestroy(ctx);
|
||||
|
||||
private static bool TryGetPosixSemaphoreHandle(CpuContext ctx, ulong semaphoreAddress, out uint handle)
|
||||
{
|
||||
handle = 0;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Libs.Kernel;
|
||||
|
||||
// libKernel's address-wait primitives (sceKernelSyncOnAddress*) are the PS5's
|
||||
// futex-style wait/wake: a thread parks on a guest address until another thread
|
||||
// wakes that address. Guest runtimes (seen driving Juicy Realm, PPSA19268)
|
||||
// build their own spinlocks/queues on top of it and call the wait in a hot
|
||||
// loop; left unimplemented, every wait returns immediately and the runtime
|
||||
// busy-spins forever (millions of calls, no forward progress).
|
||||
//
|
||||
// This implements wait/wake over the existing cooperative-block scheduler,
|
||||
// keyed on the address. The real primitive takes a compare value so the wait
|
||||
// only sleeps while the address still holds the expected value; that exact
|
||||
// value is not recovered here, so each wait is given a bounded deadline and
|
||||
// treated as a spurious-wakeup-tolerant park: a genuinely missed wake
|
||||
// self-heals when the deadline expires and the guest re-checks its own
|
||||
// condition, which futex callers already tolerate. A matching wake releases
|
||||
// waiters immediately through the same key.
|
||||
public static class KernelSyncOnAddressCompatExports
|
||||
{
|
||||
// Safety-net poll interval. Real releases come from the wake side (generation
|
||||
// bump + WakeBlockedThreads); this only bounds how long a wait that genuinely
|
||||
// raced/missed its wake stays parked before the guest re-evaluates. Kept
|
||||
// large: a short interval turns every parked waiter into a hot re-poll that
|
||||
// steals scheduler bandwidth from the threads that actually make progress
|
||||
// (including the ones that would issue the wake), so it must be a rare last
|
||||
// resort, not a spin substitute.
|
||||
private static readonly TimeSpan WaitSelfHealTimeout = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
// Per-address host gate for the non-cooperative (host main thread) fallback,
|
||||
// which cannot use the guest-thread scheduler's block mechanism.
|
||||
private static readonly ConcurrentDictionary<ulong, object> _hostAddressGates = new();
|
||||
|
||||
// Per-address wake generation. A wait captures the current generation and
|
||||
// its wake predicate stays unsatisfied (keeps the thread parked) until a
|
||||
// wake bumps it. This is what actually holds the thread blocked: a bare
|
||||
// "always satisfied" predicate is treated as an immediate late-arrival by
|
||||
// the dispatcher's race guard and never yields, leaving the guest to
|
||||
// busy-spin. The generation also closes the register-vs-park race for free:
|
||||
// a wake landing in that window bumps the generation, so the predicate is
|
||||
// already satisfied and the guest correctly resumes at once.
|
||||
private static readonly ConcurrentDictionary<ulong, long> _wakeGenerations = new();
|
||||
|
||||
private static long CurrentGeneration(ulong address) =>
|
||||
_wakeGenerations.TryGetValue(address, out var generation) ? generation : 0;
|
||||
|
||||
private static string WakeKey(ulong address) => $"sceKernelSyncOnAddress:{address:X16}";
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "Hc4CaR6JBL0",
|
||||
ExportName = "sceKernelSyncOnAddressWait",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int SyncOnAddressWait(CpuContext ctx)
|
||||
{
|
||||
var address = ctx[CpuRegister.Rdi];
|
||||
if (address == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
var observedGeneration = CurrentGeneration(address);
|
||||
var deadline = GuestThreadExecution.ComputeDeadlineTimestamp(WaitSelfHealTimeout);
|
||||
|
||||
// Cooperative path: stay parked until a wake bumps this address's
|
||||
// generation (or the deadline expires as a self-heal). The guest
|
||||
// re-evaluates its own condition after resuming.
|
||||
if (GuestThreadExecution.RequestCurrentThreadBlock(
|
||||
ctx,
|
||||
"sceKernelSyncOnAddressWait",
|
||||
WakeKey(address),
|
||||
resumeHandler: () => (int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
wakeHandler: () => CurrentGeneration(address) != observedGeneration,
|
||||
deadline))
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
// Non-cooperative caller (host main thread): bounded host wait so a
|
||||
// missed wake self-heals instead of hanging.
|
||||
var gate = _hostAddressGates.GetOrAdd(address, static _ => new object());
|
||||
lock (gate)
|
||||
{
|
||||
if (CurrentGeneration(address) == observedGeneration)
|
||||
{
|
||||
Monitor.Wait(gate, WaitSelfHealTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "q2y-wDIVWZA",
|
||||
ExportName = "sceKernelSyncOnAddressWake",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int SyncOnAddressWake(CpuContext ctx)
|
||||
{
|
||||
var address = ctx[CpuRegister.Rdi];
|
||||
if (address == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
// rsi carries the number of waiters to release (1 = wake-one, a large
|
||||
// value = wake-all); default to all if it looks unset.
|
||||
var requested = unchecked((long)ctx[CpuRegister.Rsi]);
|
||||
var wakeCount = requested is > 0 and < int.MaxValue ? (int)requested : int.MaxValue;
|
||||
|
||||
// Bump the generation first so a wait that has registered but not yet
|
||||
// parked sees the change and resumes instead of missing this wake.
|
||||
_wakeGenerations.AddOrUpdate(address, 1, static (_, current) => current + 1);
|
||||
|
||||
GuestThreadExecution.Scheduler?.WakeBlockedThreads(WakeKey(address), wakeCount);
|
||||
|
||||
if (_hostAddressGates.TryGetValue(address, out var gate))
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
Monitor.PulseAll(gate);
|
||||
}
|
||||
}
|
||||
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
private static int SetReturn(CpuContext ctx, OrbisGen2Result result)
|
||||
{
|
||||
var value = (int)result;
|
||||
ctx[CpuRegister.Rax] = unchecked((ulong)value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,8 @@ internal static class KernelVirtualRangeAllocator
|
||||
bool allowSearch,
|
||||
bool allowAllocateAtAlternative,
|
||||
string traceName,
|
||||
out ulong mappedAddress)
|
||||
out ulong mappedAddress,
|
||||
bool backPartialOverlap = false)
|
||||
{
|
||||
mappedAddress = 0;
|
||||
if (length == 0)
|
||||
@@ -42,6 +43,18 @@ 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,6 +184,212 @@ 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",
|
||||
@@ -549,6 +755,52 @@ 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))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Libs.Np;
|
||||
@@ -8,7 +10,34 @@ namespace SharpEmu.Libs.Np;
|
||||
public static class NpEntitlementAccessExports
|
||||
{
|
||||
private const int BootParamClearSize = 0x20;
|
||||
private const int EmptyAddcontInfoListSize = 0x10;
|
||||
private const int EntitlementLabelSize = 17;
|
||||
private const int EntitlementLabelPadding = 3;
|
||||
private const int AddcontEntitlementInfoSize =
|
||||
EntitlementLabelSize + EntitlementLabelPadding + sizeof(uint) + sizeof(uint);
|
||||
|
||||
// libSceNpEntitlementAccess package / download codes observed on Prospero.
|
||||
// package_type 3 = PSAL (license-style add-on); download_status 4 = INSTALLED.
|
||||
private const uint PackageTypePsal = 3;
|
||||
private const uint DownloadStatusInstalled = 4;
|
||||
private const uint SkuFlagFull = 3;
|
||||
|
||||
private const int NpEntitlementAccessErrorParameter = unchecked((int)0x817D0002);
|
||||
private const int NpEntitlementAccessErrorNoEntitlement = unchecked((int)0x817D0007);
|
||||
|
||||
// Offline add-on entitlements titles query through NpEntitlementAccess.
|
||||
// GTA V Enhanced (PPSA04264) gates Story Mode on these three labels; without
|
||||
// them the frontend offers "Buy GTAV Story Mode" despite a full dump.
|
||||
private static readonly AddcontEntitlement[] OwnedAddcontEntitlements =
|
||||
[
|
||||
new("85y-je", PackageTypePsal, DownloadStatusInstalled),
|
||||
new("5d5c48", PackageTypePsal, DownloadStatusInstalled),
|
||||
new("_mtqu6", PackageTypePsal, DownloadStatusInstalled),
|
||||
];
|
||||
|
||||
private readonly record struct AddcontEntitlement(
|
||||
string Label,
|
||||
uint PackageType,
|
||||
uint DownloadStatus);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "jO8DM8oyego",
|
||||
@@ -19,21 +48,46 @@ public static class NpEntitlementAccessExports
|
||||
{
|
||||
var initParam = ctx[CpuRegister.Rdi];
|
||||
var bootParam = ctx[CpuRegister.Rsi];
|
||||
|
||||
if (bootParam != 0)
|
||||
if (initParam == 0 || bootParam == 0)
|
||||
{
|
||||
Span<byte> clear = stackalloc byte[BootParamClearSize];
|
||||
clear.Clear();
|
||||
if (!ctx.Memory.TryWrite(bootParam, clear))
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
return ctx.SetReturn(NpEntitlementAccessErrorParameter);
|
||||
}
|
||||
|
||||
Span<byte> clear = stackalloc byte[BootParamClearSize];
|
||||
clear.Clear();
|
||||
if (!ctx.Memory.TryWrite(bootParam, clear))
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
TraceNpEntitlementAccess($"initialize init=0x{initParam:X16} boot=0x{bootParam:X16}");
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "lPDO62PpJIA",
|
||||
ExportName = "sceNpEntitlementAccessGetSkuFlag",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNpEntitlementAccess")]
|
||||
public static int NpEntitlementAccessGetSkuFlag(CpuContext ctx)
|
||||
{
|
||||
var skuFlagAddress = ctx[CpuRegister.Rdi];
|
||||
if (skuFlagAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn(NpEntitlementAccessErrorParameter);
|
||||
}
|
||||
|
||||
Span<byte> skuFlagBytes = stackalloc byte[sizeof(uint)];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(skuFlagBytes, SkuFlagFull);
|
||||
if (!ctx.Memory.TryWrite(skuFlagAddress, skuFlagBytes))
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
TraceNpEntitlementAccess($"get_sku_flag -> {SkuFlagFull}");
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "TFyU+KFBv54",
|
||||
ExportName = "sceNpEntitlementAccessGetAddcontEntitlementInfoList",
|
||||
@@ -42,28 +96,53 @@ public static class NpEntitlementAccessExports
|
||||
public static int NpEntitlementAccessGetAddcontEntitlementInfoList(CpuContext ctx)
|
||||
{
|
||||
var listAddress = ctx[CpuRegister.Rsi];
|
||||
if (listAddress != 0)
|
||||
var listNum = (uint)ctx[CpuRegister.Rdx];
|
||||
var hitNumAddress = ctx[CpuRegister.Rcx];
|
||||
|
||||
if (hitNumAddress == 0 || (listAddress == 0 && listNum != 0))
|
||||
{
|
||||
Span<byte> emptyList = stackalloc byte[EmptyAddcontInfoListSize];
|
||||
emptyList.Clear();
|
||||
if (!ctx.Memory.TryWrite(listAddress, emptyList))
|
||||
return ctx.SetReturn(NpEntitlementAccessErrorParameter);
|
||||
}
|
||||
|
||||
var hitNum = (uint)OwnedAddcontEntitlements.Length;
|
||||
Span<byte> hitNumBytes = stackalloc byte[sizeof(uint)];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(hitNumBytes, hitNum);
|
||||
if (!ctx.Memory.TryWrite(hitNumAddress, hitNumBytes))
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
if (listAddress != 0 && listNum != 0)
|
||||
{
|
||||
var clearBytes = checked((int)listNum * AddcontEntitlementInfoSize);
|
||||
Span<byte> clear = clearBytes <= 512
|
||||
? stackalloc byte[clearBytes]
|
||||
: new byte[clearBytes];
|
||||
clear.Clear();
|
||||
if (!ctx.Memory.TryWrite(listAddress, clear))
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
var copyNum = Math.Min(listNum, hitNum);
|
||||
for (var index = 0u; index < copyNum; index++)
|
||||
{
|
||||
if (!TryWriteAddcontEntitlementInfo(
|
||||
ctx,
|
||||
listAddress + index * (ulong)AddcontEntitlementInfoSize,
|
||||
OwnedAddcontEntitlements[(int)index]))
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TraceNpEntitlementAccess(
|
||||
$"get_addcont_info_list service=0x{ctx[CpuRegister.Rdi]:X16} list=0x{listAddress:X16} " +
|
||||
$"max={ctx[CpuRegister.Rdx]} flags=0x{ctx[CpuRegister.Rcx]:X16} -> empty");
|
||||
$"get_addcont_info_list service={ctx[CpuRegister.Rdi]} list=0x{listAddress:X16} " +
|
||||
$"list_num={listNum} hit_num={hitNum}");
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
private const int EmptyAddcontInfoSize = 0x30;
|
||||
|
||||
// Singular lookup of one add-on-content entitlement (rdx = info out). We own
|
||||
// no DLC, so report an empty/zeroed info and success — matching the list
|
||||
// variant's "no entitlements" answer. Dead Cells calls this while loading a
|
||||
// level; leaving it unresolved left the info struct uninitialized.
|
||||
[SysAbiExport(
|
||||
Nid = "xddD23+8TfQ",
|
||||
ExportName = "sceNpEntitlementAccessGetAddcontEntitlementInfo",
|
||||
@@ -71,21 +150,84 @@ public static class NpEntitlementAccessExports
|
||||
LibraryName = "libSceNpEntitlementAccess")]
|
||||
public static int NpEntitlementAccessGetAddcontEntitlementInfo(CpuContext ctx)
|
||||
{
|
||||
var labelAddress = ctx[CpuRegister.Rsi];
|
||||
var infoAddress = ctx[CpuRegister.Rdx];
|
||||
if (infoAddress != 0)
|
||||
if (labelAddress == 0 || infoAddress == 0)
|
||||
{
|
||||
Span<byte> info = stackalloc byte[EmptyAddcontInfoSize];
|
||||
info.Clear();
|
||||
if (!ctx.Memory.TryWrite(infoAddress, info))
|
||||
return ctx.SetReturn(NpEntitlementAccessErrorParameter);
|
||||
}
|
||||
|
||||
Span<byte> labelBytes = stackalloc byte[EntitlementLabelSize];
|
||||
if (!ctx.Memory.TryRead(labelAddress, labelBytes))
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
var label = ReadEntitlementLabel(labelBytes);
|
||||
Span<byte> info = stackalloc byte[AddcontEntitlementInfoSize];
|
||||
info.Clear();
|
||||
if (!ctx.Memory.TryWrite(infoAddress, info))
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
foreach (var entitlement in OwnedAddcontEntitlements)
|
||||
{
|
||||
if (!string.Equals(label, entitlement.Label, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryWriteAddcontEntitlementInfo(ctx, infoAddress, entitlement))
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
TraceNpEntitlementAccess(
|
||||
$"get_addcont_info service={ctx[CpuRegister.Rdi]} label='{label}' -> owned");
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
TraceNpEntitlementAccess(
|
||||
$"get_addcont_info service=0x{ctx[CpuRegister.Rdi]:X16} label=0x{ctx[CpuRegister.Rsi]:X16} " +
|
||||
$"info=0x{infoAddress:X16} -> empty");
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
$"get_addcont_info service={ctx[CpuRegister.Rdi]} label='{label}' -> no entitlement");
|
||||
return ctx.SetReturn(NpEntitlementAccessErrorNoEntitlement);
|
||||
}
|
||||
|
||||
private static bool TryWriteAddcontEntitlementInfo(
|
||||
CpuContext ctx,
|
||||
ulong address,
|
||||
AddcontEntitlement entitlement)
|
||||
{
|
||||
Span<byte> info = stackalloc byte[AddcontEntitlementInfoSize];
|
||||
info.Clear();
|
||||
|
||||
var labelBytes = Encoding.ASCII.GetBytes(entitlement.Label);
|
||||
var labelLength = Math.Min(labelBytes.Length, EntitlementLabelSize - 1);
|
||||
labelBytes.AsSpan(0, labelLength).CopyTo(info);
|
||||
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
info.Slice(EntitlementLabelSize + EntitlementLabelPadding, sizeof(uint)),
|
||||
entitlement.PackageType);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
info.Slice(
|
||||
EntitlementLabelSize + EntitlementLabelPadding + sizeof(uint),
|
||||
sizeof(uint)),
|
||||
entitlement.DownloadStatus);
|
||||
|
||||
return ctx.Memory.TryWrite(address, info);
|
||||
}
|
||||
|
||||
private static string ReadEntitlementLabel(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
var length = 0;
|
||||
while (length < bytes.Length && bytes[length] != 0)
|
||||
{
|
||||
length++;
|
||||
}
|
||||
|
||||
return length == 0
|
||||
? string.Empty
|
||||
: Encoding.ASCII.GetString(bytes[..length]);
|
||||
}
|
||||
|
||||
private static void TraceNpEntitlementAccess(string message)
|
||||
|
||||
@@ -69,6 +69,42 @@ public static class NpManagerExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts the reachability callback and never invokes it. Reachability
|
||||
/// transitions only ever fire on a real PSN connection, which an offline
|
||||
/// session does not have, so registering successfully and staying silent is
|
||||
/// the accurate emulation of a signed-out console rather than a stub.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "hw5KNqAAels",
|
||||
ExportName = "sceNpRegisterNpReachabilityStateCallback",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNpManager")]
|
||||
public static int NpRegisterNpReachabilityStateCallback(CpuContext ctx)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Accepts the premium-event callback and never invokes it. Offline sessions
|
||||
/// have no PS Plus / premium transitions to deliver, and leaving this NID
|
||||
/// unresolved returns NOT_FOUND which soft-locks titles that register it
|
||||
/// during settings / store probes (GTA V Enhanced).
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "+yqjab2fUJA",
|
||||
ExportName = "sceNpRegisterPremiumEventCallback",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNpManager")]
|
||||
public static int NpRegisterPremiumEventCallback(CpuContext ctx)
|
||||
{
|
||||
TraceNp(
|
||||
$"register_premium_event_callback cb=0x{ctx[CpuRegister.Rdi]:X16} " +
|
||||
$"userdata=0x{ctx[CpuRegister.Rsi]:X16}");
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "qQJfO8HAiaY",
|
||||
ExportName = "sceNpRegisterStateCallbackA",
|
||||
|
||||
@@ -80,6 +80,25 @@ public static class NpTrophy2Exports
|
||||
LibraryName = "libSceNpTrophy2")]
|
||||
public static int NpTrophy2ShowTrophyList(CpuContext ctx) => ReturnOk(ctx);
|
||||
|
||||
/// <summary>
|
||||
/// Gen5 ABI: context, handle, trophy id, then SceNpTrophy2Details and
|
||||
/// SceNpTrophy2Data output pointers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reports "no such trophy" rather than succeeding. Succeeding would require
|
||||
/// filling both output structures, and their exact layouts are not confirmed
|
||||
/// here — a title that trusted zeroed details would read an empty name and a
|
||||
/// grade of zero as real data. NOT_FOUND is a documented outcome that callers
|
||||
/// must already handle, so it degrades along a path the game tests.
|
||||
/// </remarks>
|
||||
[SysAbiExport(
|
||||
Nid = "EwNylPdWUTM",
|
||||
ExportName = "sceNpTrophy2GetTrophyInfo",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceNpTrophy2")]
|
||||
public static int NpTrophy2GetTrophyInfo(CpuContext ctx) =>
|
||||
SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
|
||||
|
||||
private static int WriteIdAndReturn(CpuContext ctx, ulong outAddress, ref int nextId)
|
||||
{
|
||||
if (outAddress == 0)
|
||||
|
||||