mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-25 20:28:48 +08:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 77fe07efc0 | |||
| 5f37dd85e0 | |||
| 38621e7be9 | |||
| c9c0793059 | |||
| d3600c9255 | |||
| 5f97031df5 | |||
| 2a4da8c0a9 | |||
| 4c37e64c66 | |||
| fc9e3ff393 | |||
| eb47d753f6 | |||
| 6aa78bb55b | |||
| 9be6f85ef0 | |||
| 4c8c67a3dd | |||
| ada67a1924 | |||
| 2379e8988c | |||
| 105c58b380 | |||
| da35f0db47 | |||
| 1f3963c543 | |||
| 4bb1af93d7 | |||
| 0ae785c617 | |||
| e01092aa38 | |||
| 224a36eba7 | |||
| ac883e44fa | |||
| 25d741b35b | |||
| dce7c87c4d | |||
| 6ee445f0c2 | |||
| 9d187dec55 | |||
| 3574a3b145 | |||
| a1cbff8a9c | |||
| db9b20481c | |||
| 8cd46243ab | |||
| 3334707f7c | |||
| 20eda4443c | |||
| bb3318a503 | |||
| d151e151c2 | |||
| 472fc96a37 | |||
| 33be88bdf9 | |||
| 184e24fbb6 | |||
| 327018e80a | |||
| 04557fd250 | |||
| 90c72ebecf | |||
| 8ef5a54ee4 | |||
| 0c467e8c57 | |||
| 9ff60abb9b | |||
| 3ebfc56d4c | |||
| 73e8821d5b | |||
| bc51cc2c4d | |||
| d7f6e3f578 | |||
| e56e74f960 | |||
| 0f224ec036 | |||
| 85dc98dedc | |||
| 5d7d8e0edd |
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -42,4 +42,4 @@ ehthumbs.db
|
||||
|
||||
.vs/
|
||||
.idea/
|
||||
packages.lock.json
|
||||
.vscode/
|
||||
@@ -14,6 +14,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
|
||||
|
||||
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And $([MSBuild]::IsOSPlatform('Windows'))">win</_HostRidOSPrefix>
|
||||
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And '$(_HostRidOSPrefix)' == '' And $([MSBuild]::IsOSPlatform('Linux'))">linux</_HostRidOSPrefix>
|
||||
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And '$(_HostRidOSPrefix)' == '' And $([MSBuild]::IsOSPlatform('OSX'))">osx</_HostRidOSPrefix>
|
||||
<_HostRidArch Condition="'$(_HostRidOSPrefix)' != '' And '$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)' == 'Arm64'">arm64</_HostRidArch>
|
||||
<_HostRidArch Condition="'$(_HostRidOSPrefix)' != '' And '$(_HostRidArch)' == ''">x64</_HostRidArch>
|
||||
<RuntimeIdentifier Condition="'$(_HostRidOSPrefix)' != ''">$(_HostRidOSPrefix)-$(_HostRidArch)</RuntimeIdentifier>
|
||||
|
||||
<BaseIntermediateOutputPath>$(RepoRoot)artifacts/obj/$(MSBuildProjectName)/</BaseIntermediateOutputPath>
|
||||
<BaseOutputPath>$(RepoRoot)artifacts/bin/</BaseOutputPath>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="11.3.18" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.18" />
|
||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.18" />
|
||||
<PackageVersion Include="FFmpeg.AutoGen" Version="7.1.1" />
|
||||
<PackageVersion Include="Iced" Version="1.21.0" />
|
||||
<PackageVersion Include="Microsoft.Build.Framework" Version="17.14.8" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||
|
||||
+51
-22
@@ -9,38 +9,67 @@ Demon's Souls plays Bink 2 (.bk2) files through a Bink implementation linked
|
||||
directly into eboot.bin. It does not use libSceVideodec, therefore an HLE video
|
||||
decoder cannot observe or replace those frames.
|
||||
|
||||
SharpEmu observes successful guest .bk2 opens and, when a Bink bridge is
|
||||
SharpEmu observes successful guest .bk2 opens and, when a Bink decoder is
|
||||
available, presents its decoded BGRA frames at the normal guest-flip boundary.
|
||||
This preserves the game's own timing and lets the host Vulkan presenter display
|
||||
the movie without trying to execute the PS5-specific Bink GPU decode path.
|
||||
|
||||
Without an adapter, Bink files remain visible to the guest and the game's
|
||||
statically linked decoder runs normally. Set SHARPEMU_BINK_MODE=skip only when
|
||||
explicitly testing a title whose cinematics are optional.
|
||||
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,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);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ internal static partial class Program
|
||||
}
|
||||
|
||||
args = NormalizeInternalArguments(args, out var isMitigatedChild);
|
||||
PreloadGlfw();
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
@@ -213,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");
|
||||
@@ -607,7 +629,7 @@ internal static partial class Program
|
||||
nint jobHandle = 0;
|
||||
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
|
||||
var created = CreateProcessW(
|
||||
processPath,
|
||||
null,
|
||||
cmdLineBuilder,
|
||||
0,
|
||||
0,
|
||||
@@ -1433,7 +1455,7 @@ internal static partial class Program
|
||||
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CreateProcessW(
|
||||
string applicationName,
|
||||
string? applicationName,
|
||||
StringBuilder commandLine,
|
||||
nint processAttributes,
|
||||
nint threadAttributes,
|
||||
|
||||
@@ -20,6 +20,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<!-- osx-x64 is the macOS target: the CPU backend executes guest x86-64
|
||||
natively, so on Apple Silicon it runs under Rosetta 2. -->
|
||||
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
|
||||
<!-- A plain "dotnet publish" with no -r defaults $(RuntimeIdentifier) to
|
||||
the host's own RID; see Directory.Build.props, which is where that
|
||||
default actually has to live (PublishDir's RID suffix is decided
|
||||
there, evaluated before this file, so a default set only here would
|
||||
be too late for it). -->
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
@@ -60,7 +65,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\LICENSE.txt">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
@@ -74,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,81 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Numerics;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Emulation;
|
||||
|
||||
/// <summary>
|
||||
/// The four Intel SHA-extension operations used by SHA-1 code. This is the arithmetic half of
|
||||
/// the direct-execution SIGILL fallback used when an x86-64 guest runs under Rosetta 2, which
|
||||
/// does not expose Intel SHA instructions to translated processes.
|
||||
/// </summary>
|
||||
public static class Sha1InstructionEmulator
|
||||
{
|
||||
public static Sha1Vector MessageSchedule1(Sha1Vector destination, Sha1Vector source) => new(
|
||||
destination.Lane0 ^ source.Lane2,
|
||||
destination.Lane1 ^ source.Lane3,
|
||||
destination.Lane2 ^ destination.Lane0,
|
||||
destination.Lane3 ^ destination.Lane1);
|
||||
|
||||
public static Sha1Vector MessageSchedule2(Sha1Vector destination, Sha1Vector source)
|
||||
{
|
||||
var lane3 = BitOperations.RotateLeft(destination.Lane3 ^ source.Lane2, 1);
|
||||
return new Sha1Vector(
|
||||
BitOperations.RotateLeft(destination.Lane0 ^ lane3, 1),
|
||||
BitOperations.RotateLeft(destination.Lane1 ^ source.Lane0, 1),
|
||||
BitOperations.RotateLeft(destination.Lane2 ^ source.Lane1, 1),
|
||||
lane3);
|
||||
}
|
||||
|
||||
public static Sha1Vector NextE(Sha1Vector destination, Sha1Vector source) => new(
|
||||
source.Lane0,
|
||||
source.Lane1,
|
||||
source.Lane2,
|
||||
unchecked(source.Lane3 + BitOperations.RotateLeft(destination.Lane3, 30)));
|
||||
|
||||
public static Sha1Vector FourRounds(Sha1Vector destination, Sha1Vector source, byte function)
|
||||
{
|
||||
uint a = destination.Lane3;
|
||||
uint b = destination.Lane2;
|
||||
uint c = destination.Lane1;
|
||||
uint d = destination.Lane0;
|
||||
uint e = 0;
|
||||
|
||||
uint constant = (function & 3) switch
|
||||
{
|
||||
0 => 0x5A82_7999u,
|
||||
1 => 0x6ED9_EBA1u,
|
||||
2 => 0x8F1B_BCDCu,
|
||||
_ => 0xCA62_C1D6u,
|
||||
};
|
||||
|
||||
for (var round = 0; round < 4; round++)
|
||||
{
|
||||
uint choose = (function & 3) switch
|
||||
{
|
||||
0 => (b & c) ^ (~b & d),
|
||||
2 => (b & c) ^ (b & d) ^ (c & d),
|
||||
_ => b ^ c ^ d,
|
||||
};
|
||||
uint word = round switch
|
||||
{
|
||||
0 => source.Lane3,
|
||||
1 => source.Lane2,
|
||||
2 => source.Lane1,
|
||||
_ => source.Lane0,
|
||||
};
|
||||
uint next = unchecked(choose + BitOperations.RotateLeft(a, 5) + word + e + constant);
|
||||
e = d;
|
||||
d = c;
|
||||
c = BitOperations.RotateLeft(b, 30);
|
||||
b = a;
|
||||
a = next;
|
||||
}
|
||||
|
||||
return new Sha1Vector(d, c, b, a);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The four little-endian 32-bit lanes of an XMM register.</summary>
|
||||
public readonly record struct Sha1Vector(uint Lane0, uint Lane1, uint Lane2, uint Lane3);
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Emulation;
|
||||
|
||||
/// <summary>
|
||||
/// Pure software implementation of the bit-field math behind AMD's SSE4a EXTRQ/INSERTQ
|
||||
/// (immediate-form) instructions.
|
||||
///
|
||||
/// The direct-execution backend runs guest PS5 code natively on the host CPU. The PS5's Zen 2
|
||||
/// cores implement AMD-only SSE4a (EXTRQ/INSERTQ), but Intel hosts - and Rosetta 2 on Apple
|
||||
/// Silicon - do not, so they raise #UD (STATUS_ILLEGAL_INSTRUCTION) instead of executing the
|
||||
/// opcode. SharpEmu already rewrites one specific compiled EXTRQ+VPBLENDD idiom at load time
|
||||
/// (see <see cref="Native.Sse4aExtrqBlendPatch"/>), but any other occurrence of EXTRQ/INSERTQ -
|
||||
/// a different register allocation, a title built with a different compiler version, and so on
|
||||
/// - still aborts the title. This class ported from Kyty's
|
||||
/// <c>Loader::X64InstructionEmulator::TryEmulateSse4a</c> provides the general bit-field
|
||||
/// extract/insert so the illegal-instruction handler can finish *any* immediate-form
|
||||
/// EXTRQ/INSERTQ in software and resume, instead of relying on a single hard-coded byte pattern.
|
||||
///
|
||||
/// The methods operate on plain 64-bit integers rather than the OS CONTEXT record so the bit
|
||||
/// math can be unit-tested in isolation; the unsafe CONTEXT/XMM plumbing lives in the backend
|
||||
/// adapter (<see cref="Native.DirectExecutionBackend"/>).
|
||||
/// </summary>
|
||||
public static class Sse4aBitFieldEmulator
|
||||
{
|
||||
public static bool IsValidBitField(int length, int index)
|
||||
{
|
||||
var len = length & 0x3F;
|
||||
var idx = index & 0x3F;
|
||||
return (len != 0 || idx == 0) && (len == 0 ? idx == 0 : idx + len <= 64);
|
||||
}
|
||||
|
||||
public static ulong ExtractBitField(ulong value, int length, int index)
|
||||
{
|
||||
var len = length & 0x3F;
|
||||
var idx = index & 0x3F;
|
||||
if (!IsValidBitField(length, index))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (len == 0)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var mask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
|
||||
return (value >> idx) & mask;
|
||||
}
|
||||
|
||||
public static ulong InsertBitField(ulong destination, ulong source, int length, int index)
|
||||
{
|
||||
var len = length & 0x3F;
|
||||
var idx = index & 0x3F;
|
||||
if (!IsValidBitField(length, index))
|
||||
{
|
||||
return destination;
|
||||
}
|
||||
|
||||
if (len == 0)
|
||||
{
|
||||
return source;
|
||||
}
|
||||
|
||||
var fieldMask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
|
||||
var destinationClearMask = fieldMask << idx;
|
||||
var sourceField = (source & fieldMask) << idx;
|
||||
return (destination & ~destinationClearMask) | sourceField;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Threading;
|
||||
using Iced.Intel;
|
||||
using SharpEmu.Core.Cpu.Emulation;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native;
|
||||
|
||||
// General software fallback for the AMD-only instructions PS5 titles occasionally emit that a
|
||||
// Zen 2-only host implements but Intel hosts (and Rosetta 2 on Apple Silicon) do not:
|
||||
// - SSE4a EXTRQ/INSERTQ, immediate form
|
||||
// - MONITORX/MWAITX
|
||||
//
|
||||
// This is a direct port of Kyty's Loader::X64InstructionEmulator (TryEmulateSse4a /
|
||||
// TryEmulateMonitorxMwaitx). SharpEmu already special-cases exactly one compiled EXTRQ+VPBLENDD
|
||||
// byte sequence at load time (Sse4aExtrqBlendPatch), which only helps the one idiom it was
|
||||
// reverse-engineered from. This file is a general, fault-time fallback that engages for any
|
||||
// immediate-form EXTRQ/INSERTQ or MONITORX/MWAITX the narrower patch (or a title using a
|
||||
// different compiler/register allocation) does not cover, complementing rather than replacing
|
||||
// it: the load-time patch still avoids paying the fault-and-recover cost on the hot path it was
|
||||
// built for, while this method is the safety net for everything else.
|
||||
//
|
||||
// This is deliberately additive: DirectExecutionBackend.IllegalInstruction.cs (the BMI1/BMI2/ABM
|
||||
// fallback) is untouched, and this method is only reached from VectoredHandler after that one
|
||||
// has already declined to handle the fault.
|
||||
public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
// Byte offset of Xmm0 within the Win64 CONTEXT record: FltSave (the XMM_SAVE_AREA32/FXSAVE
|
||||
// image) starts right after Rip at offset 256, and XmmRegisters[0] sits 160 bytes into that
|
||||
// area (32-byte header + 8 legacy x87/MMX slots x 16 bytes). 256 + 160 = 416 (0x1A0). Cross-
|
||||
// checked against this file's own Win64ContextSize (0x4D0): rebuilding the whole CONTEXT
|
||||
// layout field-by-field from offset 0 lands on the same 0x4D0 total, which would not happen
|
||||
// if this offset (or anything before it) were wrong.
|
||||
private const int Win64ContextXmm0Offset = 0x1A0;
|
||||
|
||||
private static int _sse4aSoftwareFallbackAnnounced;
|
||||
private static long _sse4aInstructionsEmulated;
|
||||
private static int _monitorxSoftwareFallbackAnnounced;
|
||||
private static long _monitorxInstructionsEmulated;
|
||||
|
||||
private unsafe bool TryRecoverAmdCompatInstruction(void* contextRecord, ulong rip)
|
||||
{
|
||||
if (TryRecoverMonitorxMwaitx(contextRecord, rip))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// MONITORX/MWAITX above only ever reads guest code memory and rewrites RIP, both of
|
||||
// which the POSIX signal bridge (DirectExecutionBackend.PosixSignals.cs) faithfully
|
||||
// round-trips through the real ucontext, so it works on every supported OS. EXTRQ/
|
||||
// INSERTQ additionally read and write an XMM register: on Windows contextRecord is the
|
||||
// live CONTEXT the OS resumes the thread from, so touching the Xmm0.. slots is visible
|
||||
// to the guest, and on Linux the bridge copies the mcontext's FXSAVE image into the
|
||||
// Xmm0.. slots and writes them back through sigreturn (_posixXmmContextBridged). On
|
||||
// Darwin the XMM area is still a zeroed scratch buffer - running this there would
|
||||
// silently compute a result from stale bytes and then discard whatever it "wrote", so
|
||||
// the recovery declines until that bridge exists.
|
||||
return (OperatingSystem.IsWindows() || _posixXmmContextBridged) &&
|
||||
TryRecoverSse4aExtractInsert(contextRecord, rip);
|
||||
}
|
||||
|
||||
private unsafe bool TryRecoverMonitorxMwaitx(void* contextRecord, ulong rip)
|
||||
{
|
||||
// MONITORX (0F 01 FA) and MWAITX (0F 01 FB) are fixed 3-byte encodings with no
|
||||
// ModRM/SIB/displacement/immediate, so a raw byte compare is sufficient and unambiguous.
|
||||
var opcode = new byte[3];
|
||||
if (!TryReadHostBytes(rip, opcode) ||
|
||||
opcode[0] != 0x0F || opcode[1] != 0x01 || (opcode[2] != 0xFA && opcode[2] != 0xFB))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// PS5 titles use this pair in idle/wait loops: MONITORX arms a monitor on a cache line
|
||||
// and MWAITX blocks until that line is written (or a timeout elapses). Hosts without
|
||||
// the extension raise #UD on either one. We do not model the monitor itself, only its
|
||||
// observable effect on guest forward progress: MONITORX becomes a no-op (arming a
|
||||
// watch we never honour has no side effect of its own) and MWAITX becomes a plain
|
||||
// thread yield, i.e. treat the awaited condition as already satisfied so the guest
|
||||
// loop keeps making progress instead of executing an illegal opcode forever.
|
||||
if (opcode[2] == 0xFB)
|
||||
{
|
||||
Thread.Yield();
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, CTX_RIP, rip + 3);
|
||||
|
||||
Interlocked.Increment(ref _monitorxInstructionsEmulated);
|
||||
if (Interlocked.Exchange(ref _monitorxSoftwareFallbackAnnounced, 1) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Host lacks AMD MONITORX/MWAITX used by the guest; " +
|
||||
"emulating those instructions in software.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private unsafe bool TryRecoverSse4aExtractInsert(void* contextRecord, ulong rip)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() && !_posixXmmContextBridged ||
|
||||
!TryReadFaultingInstruction(rip, out var instruction))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var isExtrq = instruction.Mnemonic == Mnemonic.Extrq;
|
||||
var isInsertq = instruction.Mnemonic == Mnemonic.Insertq;
|
||||
if (!isExtrq && !isInsertq)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isExtrq && instruction.OpCount != 3 || isInsertq && instruction.OpCount != 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (instruction.GetOpKind(0) != OpKind.Register ||
|
||||
!TryGetXmmOffset(instruction.GetOpRegister(0), out var destOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var destLow = ReadCtxU64(contextRecord, destOffset);
|
||||
if (isExtrq)
|
||||
{
|
||||
var length = (int)instruction.GetImmediate(1);
|
||||
var index = (int)instruction.GetImmediate(2);
|
||||
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.ExtractBitField(destLow, length, index));
|
||||
WriteCtxU64(contextRecord, destOffset + 8, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (instruction.GetOpKind(1) != OpKind.Register ||
|
||||
!TryGetXmmOffset(instruction.GetOpRegister(1), out var srcOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var length = (int)instruction.GetImmediate(2);
|
||||
var index = (int)instruction.GetImmediate(3);
|
||||
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.InsertBitField(
|
||||
destLow, ReadCtxU64(contextRecord, srcOffset), length, index));
|
||||
WriteCtxU64(contextRecord, destOffset + 8, 0);
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, CTX_RIP, rip + (ulong)instruction.Length);
|
||||
|
||||
Interlocked.Increment(ref _sse4aInstructionsEmulated);
|
||||
if (Interlocked.Exchange(ref _sse4aSoftwareFallbackAnnounced, 1) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Host lacks SSE4a EXTRQ/INSERTQ used by the guest; " +
|
||||
"emulating those instructions in software.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Maps an Iced XMM register to its byte offset in the Win64 CONTEXT record. Written as an
|
||||
// explicit switch (rather than arithmetic on the Register enum) to match the style already
|
||||
// used by TryGetGprSlot/TryGetGpr64Offset in DirectExecutionBackend.IllegalInstruction.cs.
|
||||
private static bool TryGetXmmOffset(Register register, out int offset)
|
||||
{
|
||||
switch (register)
|
||||
{
|
||||
case Register.XMM0: offset = Win64ContextXmm0Offset + 16 * 0; return true;
|
||||
case Register.XMM1: offset = Win64ContextXmm0Offset + 16 * 1; return true;
|
||||
case Register.XMM2: offset = Win64ContextXmm0Offset + 16 * 2; return true;
|
||||
case Register.XMM3: offset = Win64ContextXmm0Offset + 16 * 3; return true;
|
||||
case Register.XMM4: offset = Win64ContextXmm0Offset + 16 * 4; return true;
|
||||
case Register.XMM5: offset = Win64ContextXmm0Offset + 16 * 5; return true;
|
||||
case Register.XMM6: offset = Win64ContextXmm0Offset + 16 * 6; return true;
|
||||
case Register.XMM7: offset = Win64ContextXmm0Offset + 16 * 7; return true;
|
||||
case Register.XMM8: offset = Win64ContextXmm0Offset + 16 * 8; return true;
|
||||
case Register.XMM9: offset = Win64ContextXmm0Offset + 16 * 9; return true;
|
||||
case Register.XMM10: offset = Win64ContextXmm0Offset + 16 * 10; return true;
|
||||
case Register.XMM11: offset = Win64ContextXmm0Offset + 16 * 11; return true;
|
||||
case Register.XMM12: offset = Win64ContextXmm0Offset + 16 * 12; return true;
|
||||
case Register.XMM13: offset = Win64ContextXmm0Offset + 16 * 13; return true;
|
||||
case Register.XMM14: offset = Win64ContextXmm0Offset + 16 * 14; return true;
|
||||
case Register.XMM15: offset = Win64ContextXmm0Offset + 16 * 15; return true;
|
||||
default:
|
||||
offset = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,11 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (exceptionCode == StatusIllegalInstruction &&
|
||||
TryRecoverAmdCompatInstruction(contextRecord, rip))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (IsBenignHostDebugException(exceptionCode))
|
||||
{
|
||||
return -1;
|
||||
@@ -478,7 +483,7 @@ public sealed partial class DirectExecutionBackend
|
||||
if (count <= 16 || count % 65536 == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (SHARPEMU_IGNORE_INT41=1)");
|
||||
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (default-on; set SHARPEMU_IGNORE_INT41=0 to disable)");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
return true;
|
||||
@@ -1113,12 +1118,7 @@ public sealed partial class DirectExecutionBackend
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe static bool TryReadHostBytes(ulong address, byte[] buffer) =>
|
||||
TryReadHostBytes(address, buffer.AsSpan());
|
||||
|
||||
// Span overload so signal-handler recovery paths can read into a stackalloc
|
||||
// buffer instead of allocating managed arrays inside the handler.
|
||||
private unsafe static bool TryReadHostBytes(ulong address, Span<byte> buffer)
|
||||
private unsafe static bool TryReadHostBytes(ulong address, byte[] buffer)
|
||||
{
|
||||
if (address < 65536)
|
||||
{
|
||||
@@ -1142,7 +1142,7 @@ public sealed partial class DirectExecutionBackend
|
||||
|
||||
try
|
||||
{
|
||||
new ReadOnlySpan<byte>((void*)address, buffer.Length).CopyTo(buffer);
|
||||
Marshal.Copy((nint)address, buffer, 0, buffer.Length);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -8,21 +8,21 @@ using SharpEmu.Core.Cpu.Emulation;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native;
|
||||
|
||||
// Software fallback for unsupported guest CPU instructions.
|
||||
// Software fallback for the BMI1/BMI2/ABM general-purpose-register instructions.
|
||||
//
|
||||
// Guest code runs natively, so the guest and host share the same virtual address space and the
|
||||
// same registers (the OS delivers them in the CONTEXT record on a fault). When the host CPU lacks
|
||||
// one of these extensions it raises #UD instead of executing the opcode; without this the title
|
||||
// simply aborts. Here we decode the faulting instruction, evaluate it against the trapped register
|
||||
// and memory state, write the result back into the CONTEXT, step RIP past the instruction and ask
|
||||
// the OS to continue. BMI1/BMI2/ABM GPR operations and the Intel SHA-1 XMM operations are handled;
|
||||
// anything else falls through to the existing diagnostics unchanged.
|
||||
// the OS to continue. Only the register-only BMI/ABM forms are handled; anything else returns false
|
||||
// and falls through to the existing diagnostics unchanged, so this can never mis-handle an opcode it
|
||||
// does not fully model.
|
||||
public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
// Windows x64 CONTEXT.EFlags lives just past the segment selectors. The GPR offsets it shares
|
||||
// with the rest of the backend are the CTX_* constants declared in DirectExecutionBackend.cs.
|
||||
private const int CTX_EFLAGS = 68;
|
||||
private const int CTX_XMM0 = 0x1A0;
|
||||
|
||||
// STATUS_ILLEGAL_INSTRUCTION (#UD surfaced by the Windows vectored handler).
|
||||
private const uint StatusIllegalInstruction = 0xC000001Du;
|
||||
@@ -34,8 +34,6 @@ public sealed partial class DirectExecutionBackend
|
||||
|
||||
private static int _bmiSoftwareFallbackAnnounced;
|
||||
private static long _bmiInstructionsEmulated;
|
||||
private static int _sha1SoftwareFallbackAnnounced;
|
||||
private static long _sha1InstructionsEmulated;
|
||||
|
||||
private unsafe bool TryRecoverIllegalInstruction(void* contextRecord, ulong rip)
|
||||
{
|
||||
@@ -44,11 +42,6 @@ public sealed partial class DirectExecutionBackend
|
||||
return false;
|
||||
}
|
||||
|
||||
if (TryRecoverSha1Instruction(contextRecord, rip, in instruction))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (instruction.Op0Kind != OpKind.Register ||
|
||||
!TryGetGprSlot(instruction.Op0Register, out var destOffset, out var size))
|
||||
{
|
||||
@@ -79,139 +72,6 @@ public sealed partial class DirectExecutionBackend
|
||||
return true;
|
||||
}
|
||||
|
||||
private unsafe bool TryRecoverSha1Instruction(
|
||||
void* contextRecord,
|
||||
ulong rip,
|
||||
in Instruction instruction)
|
||||
{
|
||||
if ((!OperatingSystem.IsWindows() && !_posixVectorContextAvailable) ||
|
||||
instruction.Mnemonic is not (Mnemonic.Sha1msg1 or Mnemonic.Sha1msg2 or
|
||||
Mnemonic.Sha1nexte or Mnemonic.Sha1rnds4) ||
|
||||
instruction.Op0Kind != OpKind.Register ||
|
||||
!TryGetXmmIndex(instruction.Op0Register, out var destinationIndex) ||
|
||||
!TryReadSha1VectorOperand(contextRecord, in instruction, 1, out var source))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var destination = ReadXmm(contextRecord, destinationIndex);
|
||||
Sha1Vector result;
|
||||
switch (instruction.Mnemonic)
|
||||
{
|
||||
case Mnemonic.Sha1msg1:
|
||||
result = Sha1InstructionEmulator.MessageSchedule1(destination, source);
|
||||
break;
|
||||
case Mnemonic.Sha1msg2:
|
||||
result = Sha1InstructionEmulator.MessageSchedule2(destination, source);
|
||||
break;
|
||||
case Mnemonic.Sha1nexte:
|
||||
result = Sha1InstructionEmulator.NextE(destination, source);
|
||||
break;
|
||||
case Mnemonic.Sha1rnds4:
|
||||
if (instruction.Op2Kind != OpKind.Immediate8)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = Sha1InstructionEmulator.FourRounds(destination, source, instruction.Immediate8);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
WriteXmm(contextRecord, destinationIndex, result);
|
||||
WriteCtxU64(contextRecord, CTX_RIP, rip + (ulong)instruction.Length);
|
||||
|
||||
if (!_posixSignalWarmup)
|
||||
{
|
||||
Interlocked.Increment(ref _sha1InstructionsEmulated);
|
||||
if (Interlocked.Exchange(ref _sha1SoftwareFallbackAnnounced, 1) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Host lacks Intel SHA instructions used by the guest; " +
|
||||
"emulating SHA-1 instructions in software.");
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private unsafe bool TryReadSha1VectorOperand(
|
||||
void* contextRecord,
|
||||
in Instruction instruction,
|
||||
int operandIndex,
|
||||
out Sha1Vector value)
|
||||
{
|
||||
switch (instruction.GetOpKind(operandIndex))
|
||||
{
|
||||
case OpKind.Register:
|
||||
if (TryGetXmmIndex(instruction.GetOpRegister(operandIndex), out var sourceIndex))
|
||||
{
|
||||
value = ReadXmm(contextRecord, sourceIndex);
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
|
||||
case OpKind.Memory:
|
||||
if (TryComputeMemoryAddress(contextRecord, in instruction, out var address))
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[16];
|
||||
if (TryReadHostBytes(address, bytes))
|
||||
{
|
||||
value = new Sha1Vector(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(0, 4)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(4, 4)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(8, 4)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(12, 4)));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static unsafe Sha1Vector ReadXmm(void* contextRecord, int index)
|
||||
{
|
||||
uint* lanes = (uint*)((byte*)contextRecord + CTX_XMM0 + index * 16);
|
||||
return new Sha1Vector(lanes[0], lanes[1], lanes[2], lanes[3]);
|
||||
}
|
||||
|
||||
private static unsafe void WriteXmm(void* contextRecord, int index, Sha1Vector value)
|
||||
{
|
||||
uint* lanes = (uint*)((byte*)contextRecord + CTX_XMM0 + index * 16);
|
||||
lanes[0] = value.Lane0;
|
||||
lanes[1] = value.Lane1;
|
||||
lanes[2] = value.Lane2;
|
||||
lanes[3] = value.Lane3;
|
||||
}
|
||||
|
||||
private static bool TryGetXmmIndex(Register register, out int index)
|
||||
{
|
||||
switch (register)
|
||||
{
|
||||
case Register.XMM0: index = 0; return true;
|
||||
case Register.XMM1: index = 1; return true;
|
||||
case Register.XMM2: index = 2; return true;
|
||||
case Register.XMM3: index = 3; return true;
|
||||
case Register.XMM4: index = 4; return true;
|
||||
case Register.XMM5: index = 5; return true;
|
||||
case Register.XMM6: index = 6; return true;
|
||||
case Register.XMM7: index = 7; return true;
|
||||
case Register.XMM8: index = 8; return true;
|
||||
case Register.XMM9: index = 9; return true;
|
||||
case Register.XMM10: index = 10; return true;
|
||||
case Register.XMM11: index = 11; return true;
|
||||
case Register.XMM12: index = 12; return true;
|
||||
case Register.XMM13: index = 13; return true;
|
||||
case Register.XMM14: index = 14; return true;
|
||||
case Register.XMM15: index = 15; return true;
|
||||
default: index = 0; return false;
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe bool TryEvaluate(
|
||||
void* contextRecord,
|
||||
in Instruction instruction,
|
||||
@@ -334,37 +194,19 @@ public sealed partial class DirectExecutionBackend
|
||||
}
|
||||
}
|
||||
|
||||
// A managed allocation inside a signal handler is unsafe: the fault can
|
||||
// interrupt the GC mid-operation, and re-entering the allocator corrupts
|
||||
// the thread's allocation context (observed as a hard "Invalid Program" at
|
||||
// the next managed alloc, amplified by tight SHA-1 recovery loops). These
|
||||
// thread-static objects are created once per thread and reused so the
|
||||
// recovery path never allocates on the hot path.
|
||||
[ThreadStatic]
|
||||
private static byte[]? _decodeBuffer;
|
||||
[ThreadStatic]
|
||||
private static ByteArrayCodeReader? _decodeReader;
|
||||
[ThreadStatic]
|
||||
private static Decoder? _decoder;
|
||||
|
||||
private unsafe bool TryReadFaultingInstruction(ulong rip, out Instruction instruction)
|
||||
{
|
||||
var buffer = _decodeBuffer ??= new byte[MaxInstructionBytes];
|
||||
var reader = _decodeReader ??= new ByteArrayCodeReader(buffer);
|
||||
var decoder = _decoder ??= Decoder.Create(64, reader);
|
||||
|
||||
// Try the full instruction window first, then shrink so a fault near the end of a mapped
|
||||
// page (where fewer than 15 bytes are readable) still decodes. The buffer is reused, so
|
||||
// clear the tail past the readable window to keep decoding deterministic.
|
||||
// page (where fewer than 15 bytes are readable) still decodes.
|
||||
foreach (var attempt in DecodeWindowSizes)
|
||||
{
|
||||
if (!TryReadHostBytes(rip, buffer.AsSpan(0, attempt)))
|
||||
var buffer = new byte[attempt];
|
||||
if (!TryReadHostBytes(rip, buffer))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer.AsSpan(attempt).Clear();
|
||||
reader.Position = 0;
|
||||
var decoder = Decoder.Create(64, new ByteArrayCodeReader(buffer));
|
||||
decoder.IP = rip;
|
||||
decoder.Decode(out instruction);
|
||||
if (instruction.Code != Code.INVALID && instruction.Length > 0 && instruction.Length <= attempt)
|
||||
@@ -404,8 +246,8 @@ public sealed partial class DirectExecutionBackend
|
||||
}
|
||||
|
||||
var byteCount = size == GprOperandSize.Bits64 ? 8 : 4;
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
if (!TryReadHostBytes(address, buffer[..byteCount]))
|
||||
var buffer = new byte[byteCount];
|
||||
if (!TryReadHostBytes(address, buffer))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -530,9 +530,12 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
|
||||
}
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
|
||||
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
|
||||
{
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
|
||||
}
|
||||
StoreImportVectorReturn(cpuContext, argPackPtr);
|
||||
if (dispatchResolved &&
|
||||
orbisGen2Result == OrbisGen2Result.ORBIS_GEN2_OK &&
|
||||
@@ -630,6 +633,27 @@ public sealed partial class DirectExecutionBackend
|
||||
cpuContext[CpuRegister.Rax] = 18446744071562199298uL;
|
||||
}
|
||||
}
|
||||
if (GuestThreadExecution.TryConsumeCurrentThreadBlock(
|
||||
out var blockReason,
|
||||
out var blockContinuation,
|
||||
out var hasBlockContinuation,
|
||||
out var blockWakeKey,
|
||||
out var blockWaiter,
|
||||
out var blockDeadlineTimestamp) &&
|
||||
TryYieldGuestThreadToHostStub(argPackPtr, num, num7, importStubEntry.Nid, blockReason))
|
||||
{
|
||||
if (hasBlockContinuation)
|
||||
{
|
||||
RegisterBlockedGuestThreadContinuation(
|
||||
GuestThreadExecution.CurrentGuestThreadHandle,
|
||||
blockContinuation,
|
||||
blockWakeKey,
|
||||
blockWaiter,
|
||||
blockDeadlineTimestamp);
|
||||
}
|
||||
|
||||
cpuContext[CpuRegister.Rax] = 0uL;
|
||||
}
|
||||
if (flag || flag2 || flag3)
|
||||
{
|
||||
Console.Error.WriteLine($"[LOADER][TRACE] ImportRet#{num}: nid={importStubEntry.Nid} result={orbisGen2Result} rax=0x{cpuContext[CpuRegister.Rax]:X16}");
|
||||
@@ -1305,9 +1329,12 @@ public sealed partial class DirectExecutionBackend
|
||||
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
|
||||
}
|
||||
}
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
|
||||
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
|
||||
{
|
||||
DeliverPendingGuestExceptionAtSafePoint(
|
||||
cpuContext,
|
||||
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
|
||||
}
|
||||
StoreImportVectorReturn(cpuContext, argPackPtr);
|
||||
|
||||
if (returnValue != (int)OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
@@ -1324,13 +1351,35 @@ public sealed partial class DirectExecutionBackend
|
||||
}
|
||||
}
|
||||
|
||||
var consumedThreadBlock = GuestThreadExecution.TryConsumeCurrentThreadBlock(
|
||||
out var blockReason,
|
||||
out var blockContinuation,
|
||||
out var hasBlockContinuation,
|
||||
out var blockWakeKey,
|
||||
out var blockWaiter,
|
||||
out var blockDeadlineTimestamp);
|
||||
if (consumedThreadBlock &&
|
||||
TryYieldGuestThreadToHostStub(argPackPtr, dispatchIndex, returnRip, importStubEntry.Nid, blockReason))
|
||||
{
|
||||
if (hasBlockContinuation)
|
||||
{
|
||||
RegisterBlockedGuestThreadContinuation(
|
||||
GuestThreadExecution.CurrentGuestThreadHandle,
|
||||
blockContinuation,
|
||||
blockWakeKey,
|
||||
blockWaiter,
|
||||
blockDeadlineTimestamp);
|
||||
}
|
||||
|
||||
cpuContext[CpuRegister.Rax] = 0uL;
|
||||
}
|
||||
if (probeLeafReturn)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] leaf-return-probe-exit nid={importStubEntry.Nid} " +
|
||||
$"original=0x{returnRip:X16} final=0x{*(ulong*)(argPackPtr + 96):X16} " +
|
||||
$"rsp=0x{leafStackPointer:X16} active_slot=0x{ActiveGuestReturnSlotAddress:X16} " +
|
||||
$"yield={ActiveGuestThreadYieldRequested}");
|
||||
$"block={consumedThreadBlock} yield={ActiveGuestThreadYieldRequested}");
|
||||
}
|
||||
|
||||
result = cpuContext[CpuRegister.Rax];
|
||||
@@ -1355,11 +1404,13 @@ public sealed partial class DirectExecutionBackend
|
||||
"vWU-odnS+fU" or // sceAmprMeasureCommandSizeReadFile
|
||||
"sSAUCCU1dv4" or // sceAmprMeasureCommandSizeWriteKernelEventQueue_04_00
|
||||
"C+IEj+BsAFM" or // sceAmprMeasureCommandSizeWriteAddressOnCompletion
|
||||
"4fgtGfXDrFc" or // sceAmprMeasureCommandSizeWriteAddress_04_00
|
||||
"tZDDEo2tE5k" or // sceAmprCommandBufferGetSize
|
||||
"GnxKOHEawhk" or // sceAmprCommandBufferGetCurrentOffset
|
||||
"gzndltBEzWc" or // sceAmprCommandBufferGetNumCommands
|
||||
"H896Pt-yB4I" or // sceAmprCommandBufferWriteKernelEventQueue_04_00
|
||||
"sJXyWHjP-F8" or // sceAmprCommandBufferWriteAddressOnCompletion
|
||||
"j0+3uJMxYJY" or // sceAmprCommandBufferWriteAddress_04_00
|
||||
"mPpPxv5CZt4" or // sceSystemServiceGetHdrToneMapLuminance
|
||||
"1FZBKy8HeNU" or // sceVideoOutGetVblankStatus
|
||||
"ASoW5WE-UPo" or // sceKernelAprSubmitCommandBufferAndGetResult
|
||||
@@ -1505,11 +1556,13 @@ public sealed partial class DirectExecutionBackend
|
||||
"vWU-odnS+fU" or
|
||||
"sSAUCCU1dv4" or
|
||||
"C+IEj+BsAFM" or
|
||||
"4fgtGfXDrFc" or
|
||||
"tZDDEo2tE5k" or
|
||||
"GnxKOHEawhk" or
|
||||
"gzndltBEzWc" or
|
||||
"H896Pt-yB4I" or
|
||||
"sJXyWHjP-F8" or
|
||||
"j0+3uJMxYJY" or
|
||||
"mPpPxv5CZt4" or
|
||||
"1FZBKy8HeNU" or
|
||||
"ASoW5WE-UPo" or
|
||||
|
||||
@@ -50,14 +50,18 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
private const int LinuxUcontextGregsOffset = 40;
|
||||
private const int LinuxGregsErrOffset = 19 * 8;
|
||||
|
||||
// XMM0 starts after the exception/thread states and the floating-point header on Darwin.
|
||||
// Linux mcontext_t instead stores a pointer to an FXSAVE-compatible _libc_fpstate after
|
||||
// gregs[23], where XMM0 begins at byte 160.
|
||||
private const int DarwinMcontextFloatStateOffset = 16 + 21 * 8;
|
||||
private const int DarwinFloatStateXmmOffset = 168;
|
||||
private const int LinuxFpregsPointerOffset = 23 * 8;
|
||||
private const int LinuxFxsaveXmmOffset = 160;
|
||||
private const int XmmRegisterBlockSize = 16 * 16;
|
||||
// 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
|
||||
@@ -79,8 +83,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 _posixVectorContextAvailable;
|
||||
private static bool _posixXmmContextBridged;
|
||||
|
||||
private void SetupPosixExceptionHandler()
|
||||
{
|
||||
@@ -132,10 +143,8 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
{
|
||||
byte* fakeUcontext = stackalloc byte[512];
|
||||
new Span<byte>(fakeUcontext, 512).Clear();
|
||||
// Large enough for the Darwin mcontext64 XMM block the handler now
|
||||
// round-trips (exception+thread state + float state = 708 bytes).
|
||||
byte* fakeMcontext = stackalloc byte[768];
|
||||
new Span<byte>(fakeMcontext, 768).Clear();
|
||||
byte* fakeMcontext = stackalloc byte[512];
|
||||
new Span<byte>(fakeMcontext, 512).Clear();
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
*(byte**)(fakeUcontext + DarwinUcontextMcontextOffset) = fakeMcontext;
|
||||
@@ -167,33 +176,6 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
record.ExceptionInformation[1] = 0x70000;
|
||||
_ = TryHandleLazyCommittedPage(&record, 0, 0);
|
||||
ChainPreviousPosixAction(0, 0, 0);
|
||||
|
||||
// Rosetta can deliver the first unsupported SHA instruction before the
|
||||
// runtime is in a safe state to JIT the recovery path. Decode and execute
|
||||
// each supported form once now, while signal handlers are not installed.
|
||||
byte[][] shaWarmupOpcodes =
|
||||
{
|
||||
new byte[] { 0x0F, 0x38, 0xC9, 0xC1 }, // sha1msg1 xmm0, xmm1
|
||||
new byte[] { 0x0F, 0x38, 0xCA, 0xC1 }, // sha1msg2 xmm0, xmm1
|
||||
new byte[] { 0x0F, 0x38, 0xC8, 0xC1 }, // sha1nexte xmm0, xmm1
|
||||
new byte[] { 0x0F, 0x3A, 0xCC, 0xC1, 0x00 }, // sha1rnds4 xmm0, xmm1, 0
|
||||
};
|
||||
_posixVectorContextAvailable = true;
|
||||
try
|
||||
{
|
||||
foreach (byte[] opcode in shaWarmupOpcodes)
|
||||
{
|
||||
fixed (byte* instructionBytes = opcode)
|
||||
{
|
||||
WriteCtxU64(contextRecord, CTX_RIP, (ulong)instructionBytes);
|
||||
_ = TryRecoverIllegalInstruction(contextRecord, (ulong)instructionBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_posixVectorContextAvailable = false;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -291,15 +273,26 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
{
|
||||
WriteCtxU64(contextRecord, CTX_RAX + i * 8, *(ulong*)(registers + offsets[i]));
|
||||
}
|
||||
// Only bridge the XMM block for SIGILL, the sole signal whose recovery
|
||||
// may emulate an XMM instruction (Intel SHA). Copying 256 bytes in and
|
||||
// out of the mcontext on every SIGSEGV (the hot demand-paging path) is
|
||||
// pure overhead and needless surface for corruption.
|
||||
byte* xmmRegisters = signal == PosixSigIll ? GetPosixXmmBase(registers) : null;
|
||||
if (xmmRegisters != null)
|
||||
|
||||
// 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())
|
||||
{
|
||||
CopyXmmRegisterBlock(contextRecord + CTX_XMM0, xmmRegisters);
|
||||
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);
|
||||
@@ -343,21 +336,13 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
// every fault anyway, and recovering here avoids dumping the full
|
||||
// VectoredHandler diagnostics for each recoverable trap.
|
||||
int disposition = 0;
|
||||
_posixVectorContextAvailable = xmmRegisters != null;
|
||||
try
|
||||
if (_posixRawRecoveryEnabled)
|
||||
{
|
||||
if (_posixRawRecoveryEnabled)
|
||||
{
|
||||
disposition = TryRecoverUnresolvedSentinel(&pointers);
|
||||
}
|
||||
if (disposition != -1 && !_posixSignalWarmup && _posixSignalBackend is { } backend)
|
||||
{
|
||||
disposition = backend.VectoredHandler(&pointers);
|
||||
}
|
||||
disposition = TryRecoverUnresolvedSentinel(&pointers);
|
||||
}
|
||||
finally
|
||||
if (disposition != -1 && !_posixSignalWarmup && _posixSignalBackend is { } backend)
|
||||
{
|
||||
_posixVectorContextAvailable = false;
|
||||
disposition = backend.VectoredHandler(&pointers);
|
||||
}
|
||||
if (traceSignal)
|
||||
{
|
||||
@@ -374,9 +359,13 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
{
|
||||
*(ulong*)(registers + offsets[i]) = ReadCtxU64(contextRecord, CTX_RAX + i * 8);
|
||||
}
|
||||
if (xmmRegisters != null)
|
||||
if (fpstate != null)
|
||||
{
|
||||
CopyXmmRegisterBlock(xmmRegisters, contextRecord + CTX_XMM0);
|
||||
Buffer.MemoryCopy(
|
||||
contextRecord + Win64ContextXmm0Offset,
|
||||
fpstate + FxsaveXmmOffset,
|
||||
XmmBlockSize,
|
||||
XmmBlockSize);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -396,25 +385,6 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
return (byte*)ucontext + LinuxUcontextGregsOffset;
|
||||
}
|
||||
|
||||
private static byte* GetPosixXmmBase(byte* registers)
|
||||
{
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return registers + DarwinMcontextFloatStateOffset + DarwinFloatStateXmmOffset;
|
||||
}
|
||||
|
||||
byte* fpregs = *(byte**)(registers + LinuxFpregsPointerOffset);
|
||||
return fpregs == null ? null : fpregs + LinuxFxsaveXmmOffset;
|
||||
}
|
||||
|
||||
private static void CopyXmmRegisterBlock(byte* destination, byte* source)
|
||||
{
|
||||
for (var offset = 0; offset < XmmRegisterBlockSize; offset += sizeof(ulong))
|
||||
{
|
||||
*(ulong*)(destination + offset) = *(ulong*)(source + offset);
|
||||
}
|
||||
}
|
||||
|
||||
private static ulong GetPosixFaultAddress(nint siginfo, byte* registers)
|
||||
{
|
||||
ulong address = siginfo != 0 ? *(ulong*)((byte*)siginfo + PosixSigInfoAddressOffset) : 0;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,9 @@ public sealed class TrackedCpuMemory : ICpuMemory, ITrackedCpuMemory, IGuestMemo
|
||||
return result;
|
||||
}
|
||||
|
||||
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length) =>
|
||||
_inner.TryCopy(destinationAddress, sourceAddress, length);
|
||||
|
||||
public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address)
|
||||
{
|
||||
if (_inner is IGuestMemoryAllocator allocator)
|
||||
|
||||
@@ -199,9 +199,32 @@ public sealed class SelfLoader : ISelfLoader
|
||||
{
|
||||
if (!physicalVm.TryAllocateAtExact(imageBase, totalImageSize, executable: true, out var allocatedBase))
|
||||
{
|
||||
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
|
||||
throw new InvalidOperationException(
|
||||
$"Could not allocate main image at required base 0x{imageBase:X16} (size=0x{totalImageSize:X}): {reason}.");
|
||||
// Exact allocation failed — the host may have already claimed
|
||||
// part of this range (ASLR, Rosetta 2, or another process).
|
||||
// Try backing the fixed range page by page to claim whatever
|
||||
// free gaps exist. If the whole range is occupied the backfill
|
||||
// returns false and we surface the original failure reason.
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER] Exact allocation at main image base 0x{imageBase:X16} " +
|
||||
$"(size=0x{totalImageSize:X}) failed; attempting fixed-range backfill.");
|
||||
if (!physicalVm.TryBackFixedRange(imageBase, totalImageSize, executable: true))
|
||||
{
|
||||
// TryBackFixedRange may have partially backed pages before
|
||||
// failing. The earlier Clear() already reset all regions, so
|
||||
// this second Clear() is idempotent for everything except the
|
||||
// partial backfill — it frees only those orphaned pages.
|
||||
physicalVm.Clear();
|
||||
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
|
||||
throw new InvalidOperationException(
|
||||
$"Could not allocate main image at required base 0x{imageBase:X16} " +
|
||||
$"(size=0x{totalImageSize:X}): {reason}. " +
|
||||
"Try closing other applications, rebooting, or " +
|
||||
(OperatingSystem.IsWindows()
|
||||
? "setting SHARPEMU_DISABLE_MITIGATION_RELAUNCH=1."
|
||||
: "ensuring no other process maps into this address range."));
|
||||
}
|
||||
|
||||
allocatedBase = imageBase;
|
||||
}
|
||||
|
||||
imageBase = allocatedBase;
|
||||
@@ -714,8 +737,9 @@ public sealed class SelfLoader : ISelfLoader
|
||||
|
||||
importedRelocations = BuildImportedRelocations(descriptors);
|
||||
|
||||
var stubEligibleNids = CollectStubEligibleNids(descriptors, moduleManager);
|
||||
var stubImportNids = orderedImportNids
|
||||
.Where(nid => ShouldCreateImportStub(nid, descriptors, moduleManager))
|
||||
.Where(stubEligibleNids.Contains)
|
||||
.ToArray();
|
||||
var stubsByAddress = CreateImportStubMapping(virtualMemory, stubImportNids);
|
||||
Console.WriteLine($"[LOADER] Created {stubsByAddress.Count} import stubs");
|
||||
@@ -1160,6 +1184,35 @@ public sealed class SelfLoader : ISelfLoader
|
||||
isWeak);
|
||||
}
|
||||
|
||||
// Collects every NID that needs a trap import stub in a single pass over the
|
||||
// descriptors. This mirrors ShouldCreateImportStub applied per NID, but avoids
|
||||
// the O(nids * descriptors) rescan that filtering each unique NID against the
|
||||
// full descriptor list would incur on large modules. A NID qualifies as soon as
|
||||
// one of its descriptors is non-weak, or is weak but resolvable via the module
|
||||
// manager.
|
||||
private static HashSet<string> CollectStubEligibleNids(
|
||||
IReadOnlyList<RelocationDescriptor> descriptors,
|
||||
IModuleManager? moduleManager)
|
||||
{
|
||||
var eligible = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < descriptors.Count; i++)
|
||||
{
|
||||
var descriptor = descriptors[i];
|
||||
var nid = descriptor.ImportNid;
|
||||
if (nid is null || eligible.Contains(nid))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!descriptor.IsWeak || moduleManager?.TryGetExport(nid, out _) == true)
|
||||
{
|
||||
eligible.Add(nid);
|
||||
}
|
||||
}
|
||||
|
||||
return eligible;
|
||||
}
|
||||
|
||||
private static bool ShouldCreateImportStub(
|
||||
string nid,
|
||||
IReadOnlyList<RelocationDescriptor> descriptors,
|
||||
@@ -2431,6 +2484,19 @@ public sealed class SelfLoader : ISelfLoader
|
||||
Debug.Assert(
|
||||
!ShouldCreateImportStub("weak", [weak], moduleManager: null),
|
||||
"An unresolved weak symbol incorrectly received a trap import stub.");
|
||||
|
||||
var strong = new RelocationDescriptor(
|
||||
TargetAddress: 0x3000,
|
||||
Addend: 0,
|
||||
ImportNid: "strong",
|
||||
SymbolValue: 0,
|
||||
RelocationValueKind.Pointer,
|
||||
IsDataImport: false);
|
||||
var mixed = new List<RelocationDescriptor> { weak, strong };
|
||||
var eligible = CollectStubEligibleNids(mixed, moduleManager: null);
|
||||
Debug.Assert(
|
||||
eligible.Contains("strong") && !eligible.Contains("weak"),
|
||||
"CollectStubEligibleNids disagreed with the per-NID stub eligibility rule.");
|
||||
}
|
||||
|
||||
private static ulong AlignUp(ulong value, ulong alignment)
|
||||
|
||||
@@ -20,6 +20,11 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
private readonly Dictionary<(ulong DesiredAddress, ulong Alignment, bool Executable), ulong> _allocationSearchHints = new();
|
||||
private readonly Dictionary<ulong, ProgramHeaderFlags> _pageProtections = new();
|
||||
private bool _disposed;
|
||||
|
||||
[ThreadStatic]
|
||||
private static CommittedRangeCache? _committedRangeCache;
|
||||
|
||||
private long _mappingGeneration;
|
||||
private const ulong PageSize = 0x1000;
|
||||
private const ulong GuestAllocationArenaAddress = 0x00006000_0000_0000;
|
||||
private const ulong GuestAllocationArenaSize = 0x0100_0000;
|
||||
@@ -28,6 +33,77 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
private const ulong FullCommitRegionLimit = 4UL << 30;
|
||||
private const ulong DefaultLazyReservePrimeBytes = 0x0400_0000UL; // 64 MiB
|
||||
private const ulong LazyReservePrimeChunkBytes = 0x0200_0000UL; // 32 MiB
|
||||
private const int CommittedRangeCacheCapacity = 4;
|
||||
|
||||
private sealed class CommittedRangeCache
|
||||
{
|
||||
private readonly CommittedRange[] _ranges = new CommittedRange[CommittedRangeCacheCapacity];
|
||||
private PhysicalVirtualMemory? _owner;
|
||||
private long _generation;
|
||||
private int _count;
|
||||
private int _nextReplacement;
|
||||
|
||||
public bool Contains(
|
||||
PhysicalVirtualMemory owner,
|
||||
long generation,
|
||||
ulong start,
|
||||
ulong end)
|
||||
{
|
||||
if (!ReferenceEquals(_owner, owner) || _generation != generation)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var index = 0; index < _count; index++)
|
||||
{
|
||||
var range = _ranges[index];
|
||||
if (start >= range.Start && end <= range.End)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Add(
|
||||
PhysicalVirtualMemory owner,
|
||||
long generation,
|
||||
ulong start,
|
||||
ulong end)
|
||||
{
|
||||
if (!ReferenceEquals(_owner, owner) || _generation != generation)
|
||||
{
|
||||
_owner = owner;
|
||||
_generation = generation;
|
||||
_count = 0;
|
||||
_nextReplacement = 0;
|
||||
}
|
||||
|
||||
for (var index = 0; index < _count; index++)
|
||||
{
|
||||
var range = _ranges[index];
|
||||
if (start <= range.End && end >= range.Start)
|
||||
{
|
||||
_ranges[index] = new CommittedRange(
|
||||
Math.Min(start, range.Start),
|
||||
Math.Max(end, range.End));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_count < _ranges.Length)
|
||||
{
|
||||
_ranges[_count++] = new CommittedRange(start, end);
|
||||
return;
|
||||
}
|
||||
|
||||
_ranges[_nextReplacement] = new CommittedRange(start, end);
|
||||
_nextReplacement = (_nextReplacement + 1) % _ranges.Length;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct CommittedRange(ulong Start, ulong End);
|
||||
|
||||
// Raw Windows PAGE_* values retained for the internal region/protection
|
||||
// bookkeeping: regions and saved old-protection values always carry the raw
|
||||
@@ -349,6 +425,111 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return actualAddress;
|
||||
}
|
||||
|
||||
public bool TryBackFixedRange(ulong address, ulong size, bool executable)
|
||||
{
|
||||
if (size == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var start = AlignDown(address, PageSize);
|
||||
var end = AlignUp(address + size, PageSize);
|
||||
if (end <= start)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
|
||||
|
||||
// Walk the range page-run by page-run. VirtualQuery reports the largest run
|
||||
// of same-state pages from the queried address, so a single query advances
|
||||
// us over whole free or occupied stretches. Only free stretches get backed;
|
||||
// stretches already reserved or committed by another allocation are left as
|
||||
// they are, which is exactly what a fixed mapping does on hardware.
|
||||
//
|
||||
// Because backing may span several disjoint free runs, allocations are
|
||||
// staged: host pages are reserved/committed first, and the corresponding
|
||||
// MemoryRegions are inserted only once every gap in the range has been
|
||||
// backed. If any gap fails to back, every earlier host allocation is freed
|
||||
// and no region is inserted, so the address space is left untouched.
|
||||
var stagedAllocations = new List<(ulong Address, ulong Size)>();
|
||||
|
||||
var cursor = start;
|
||||
while (cursor < end)
|
||||
{
|
||||
if (!_hostMemory.Query(cursor, out var info))
|
||||
{
|
||||
goto Rollback;
|
||||
}
|
||||
|
||||
var queriedEnd = info.RegionSize > ulong.MaxValue - info.BaseAddress
|
||||
? ulong.MaxValue
|
||||
: info.BaseAddress + info.RegionSize;
|
||||
var runEnd = Math.Min(end, queriedEnd);
|
||||
if (runEnd <= cursor)
|
||||
{
|
||||
goto Rollback;
|
||||
}
|
||||
|
||||
if (info.State == HostRegionState.Free)
|
||||
{
|
||||
var runSize = runEnd - cursor;
|
||||
var allocated = _hostMemory.Allocate(cursor, runSize, hostProtection);
|
||||
if (allocated != cursor)
|
||||
{
|
||||
if (allocated != 0)
|
||||
{
|
||||
_hostMemory.Free(allocated);
|
||||
}
|
||||
|
||||
goto Rollback;
|
||||
}
|
||||
|
||||
stagedAllocations.Add((cursor, runSize));
|
||||
TraceVmem($"Backed fixed range gap: 0x{cursor:X16} - 0x{runEnd:X16} ({runSize} bytes)");
|
||||
}
|
||||
|
||||
cursor = runEnd;
|
||||
}
|
||||
|
||||
if (stagedAllocations.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// All gaps backed successfully — insert regions in one batch.
|
||||
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
||||
_gate.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
foreach (var (gapAddress, gapSize) in stagedAllocations)
|
||||
{
|
||||
InsertRegionSorted(new MemoryRegion
|
||||
{
|
||||
VirtualAddress = gapAddress,
|
||||
Size = gapSize,
|
||||
IsExecutable = executable,
|
||||
IsReservedOnly = false,
|
||||
Protection = protection
|
||||
});
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.ExitWriteLock();
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
Rollback:
|
||||
foreach (var (gapAddress, _) in stagedAllocations)
|
||||
{
|
||||
_hostMemory.Free(gapAddress);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryAllocateAtOrAbove(
|
||||
ulong desiredAddress,
|
||||
ulong size,
|
||||
@@ -440,6 +621,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
_gate.ExitWriteLock();
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _mappingGeneration);
|
||||
_hostMemory.Free(address);
|
||||
}
|
||||
|
||||
@@ -611,6 +793,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
{
|
||||
_allocationSearchHints.Clear();
|
||||
}
|
||||
Interlocked.Increment(ref _mappingGeneration);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -919,6 +1102,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -944,6 +1128,68 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
}
|
||||
|
||||
private static void NotifyGuestWriteWatch(ulong virtualAddress, ReadOnlySpan<byte> source)
|
||||
{
|
||||
if (GuestWriteWatch.Armed)
|
||||
{
|
||||
GuestWriteWatch.Check(virtualAddress, source);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length)
|
||||
{
|
||||
if (length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (length > int.MaxValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Match TryWrite's managed-write notification before touching an
|
||||
// identity-mapped guest page protected by the image tracker.
|
||||
GuestImageWriteTracker.NotifyManagedWrite(destinationAddress, length);
|
||||
|
||||
_gate.EnterReadLock();
|
||||
try
|
||||
{
|
||||
var sourceRegion = FindRegion(sourceAddress, length);
|
||||
var destinationRegion = FindRegion(destinationAddress, length);
|
||||
if (sourceRegion is null || destinationRegion is null ||
|
||||
!TryResolveRegionOffset(sourceAddress, length, sourceRegion, out var sourceOffset) ||
|
||||
!TryResolveRegionOffset(destinationAddress, length, destinationRegion, out var destinationOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sourcePointer = sourceRegion.VirtualAddress + sourceOffset;
|
||||
var destinationPointer = destinationRegion.VirtualAddress + destinationOffset;
|
||||
if ((sourceRegion.IsReservedOnly &&
|
||||
!EnsureRangeCommitted(sourcePointer, length, sourceRegion)) ||
|
||||
(destinationRegion.IsReservedOnly &&
|
||||
!EnsureRangeCommitted(destinationPointer, length, destinationRegion)) ||
|
||||
!CanReadWithoutProtectionChange(sourcePointer, length, sourceRegion) ||
|
||||
!CanWriteWithoutProtectionChange(destinationPointer, length, destinationRegion))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Span.CopyTo has memmove overlap semantics, so this allocation-free
|
||||
// path safely serves both libc memcpy and libc memmove.
|
||||
new ReadOnlySpan<byte>((void*)sourcePointer, checked((int)length)).CopyTo(
|
||||
new Span<byte>((void*)destinationPointer, checked((int)length)));
|
||||
NotifyGuestWriteWatch(
|
||||
destinationAddress,
|
||||
new ReadOnlySpan<byte>((void*)destinationPointer, checked((int)length)));
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryReadExclusive(ulong virtualAddress, Span<byte> destination)
|
||||
{
|
||||
var region = FindRegion(virtualAddress, (ulong)destination.Length);
|
||||
@@ -1016,6 +1262,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1040,6 +1287,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
}
|
||||
|
||||
NotifyGuestWriteWatch(virtualAddress, source);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1281,6 +1529,12 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
var startPage = AlignDown(address, PageSize);
|
||||
var endPage = AlignUp(address + size, PageSize);
|
||||
var mappingGeneration = Volatile.Read(ref _mappingGeneration);
|
||||
var committedRangeCache = _committedRangeCache ??= new CommittedRangeCache();
|
||||
if (committedRangeCache.Contains(this, mappingGeneration, startPage, endPage))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
var commitProtection = GetCommitProtection(region);
|
||||
|
||||
var pageAddress = startPage;
|
||||
@@ -1302,6 +1556,9 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
if (info.State == HostRegionState.Committed)
|
||||
{
|
||||
// The host query proved this whole range is committed. Retain
|
||||
// that result instead of caching only the caller's small span.
|
||||
CacheCommittedRange(info.BaseAddress, queriedEnd, mappingGeneration);
|
||||
pageAddress = rangeEnd;
|
||||
continue;
|
||||
}
|
||||
@@ -1317,12 +1574,23 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return false;
|
||||
}
|
||||
|
||||
CacheCommittedRange(pageAddress, rangeEnd, mappingGeneration);
|
||||
pageAddress = rangeEnd;
|
||||
}
|
||||
|
||||
CacheCommittedRange(startPage, endPage, mappingGeneration);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void CacheCommittedRange(ulong startPage, ulong endPage, long mappingGeneration)
|
||||
{
|
||||
(_committedRangeCache ??= new CommittedRangeCache()).Add(
|
||||
this,
|
||||
mappingGeneration,
|
||||
startPage,
|
||||
endPage);
|
||||
}
|
||||
|
||||
private bool TryTemporarilyProtectForRead(
|
||||
ulong address,
|
||||
ulong size,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Core.Loader;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Core.Memory;
|
||||
|
||||
@@ -93,8 +94,14 @@ public sealed class VirtualMemory : IVirtualMemory
|
||||
}
|
||||
|
||||
CopyToRegions(virtualAddress, source, regionIndex);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (GuestWriteWatch.Armed)
|
||||
{
|
||||
GuestWriteWatch.Check(virtualAddress, source);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryValidateRange(
|
||||
|
||||
@@ -248,7 +248,7 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
{
|
||||
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
|
||||
if (!CreateProcessW(
|
||||
exePath,
|
||||
null,
|
||||
commandLine,
|
||||
0,
|
||||
0,
|
||||
@@ -629,7 +629,7 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
|
||||
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CreateProcessW(string applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
|
||||
private static extern bool CreateProcessW(string? applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern uint WaitForSingleObject(nint handle, uint milliseconds);
|
||||
|
||||
@@ -351,6 +351,13 @@ public sealed class GameSurfaceHost : NativeControlHost
|
||||
var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale));
|
||||
var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale));
|
||||
var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height;
|
||||
if (Environment.GetEnvironmentVariable("SHARPEMU_TRACE_SURFACE_SIZE") == "1")
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[GUI][TRACE] GameSurfaceHost.UpdateSurfaceSize bounds={Bounds.Width}x{Bounds.Height} " +
|
||||
$"scale={renderScale} computed={width}x{height} changed={sizeChanged} " +
|
||||
$"prevSurface={_surface.PixelWidth}x{_surface.PixelHeight}");
|
||||
}
|
||||
_surface.UpdatePixelSize(width, height);
|
||||
|
||||
if (!sizeChanged)
|
||||
|
||||
@@ -53,6 +53,9 @@ public sealed class GuiSettings
|
||||
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
|
||||
public List<string> EnvironmentToggles { get; set; } = new();
|
||||
|
||||
/// <summary>Internal render resolution scale (1.0 = native, 0.5 = half).</summary>
|
||||
public double RenderResolutionScale { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Discord application ID used for Rich Presence; the default is the
|
||||
/// SharpEmu application. Override to rebrand what Discord shows as
|
||||
@@ -71,7 +74,7 @@ public sealed class GuiSettings
|
||||
if (File.Exists(SettingsPath))
|
||||
{
|
||||
var json = File.ReadAllText(SettingsPath);
|
||||
return JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
|
||||
return NormalizeFromJson(json);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -82,6 +85,39 @@ public sealed class GuiSettings
|
||||
return new GuiSettings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes settings and normalizes null references and null or empty list
|
||||
/// entries introduced by JSON. Empty scalar strings remain unchanged.
|
||||
/// </summary>
|
||||
internal static GuiSettings NormalizeFromJson(string json)
|
||||
{
|
||||
var settings = JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
|
||||
|
||||
settings.GameFolders = FilterNullOrEmpty(settings.GameFolders);
|
||||
settings.ExcludedGames = FilterNullOrEmpty(settings.ExcludedGames);
|
||||
settings.EnvironmentToggles = FilterNullOrEmpty(settings.EnvironmentToggles);
|
||||
settings.LogLevel ??= "Info";
|
||||
settings.Language ??= "en";
|
||||
settings.DiscordClientId ??= "1525606762248540221";
|
||||
if (settings.RenderResolutionScale <= 0 || settings.RenderResolutionScale > 2.0)
|
||||
{
|
||||
settings.RenderResolutionScale = 1.0;
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
// JSON can populate non-nullable lists with null references and entries.
|
||||
private static List<string> FilterNullOrEmpty(List<string>? source)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return source.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -192,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 += (_, _) =>
|
||||
@@ -869,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;
|
||||
@@ -1788,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;
|
||||
@@ -2030,8 +2055,6 @@ 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;
|
||||
@@ -2040,6 +2063,19 @@ public partial class MainWindow : Window
|
||||
LaunchBar.IsVisible = 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -32,6 +32,7 @@ public static unsafe class GuestImageWriteTracker
|
||||
public int Armed;
|
||||
public int FirstCpuWriteSeen;
|
||||
public int PendingFirstCpuWrite;
|
||||
public long WriteGeneration;
|
||||
public bool TraceLifetime;
|
||||
public long SourceSequence;
|
||||
public long FirstCpuWriteTraceSequence;
|
||||
@@ -155,10 +156,21 @@ public static unsafe class GuestImageWriteTracker
|
||||
{
|
||||
// Never resize an object that is still reachable from the
|
||||
// signal handler's lock-free snapshot. Retire it and publish
|
||||
// a fresh immutable range.
|
||||
// a fresh immutable range, carrying the write generation so
|
||||
// resizes do not hide guest CPU rewrites from cache owners.
|
||||
var writeGeneration = Volatile.Read(ref range.WriteGeneration);
|
||||
DisarmLocked(range, "replace-range");
|
||||
_rangesByAddress.Remove(address);
|
||||
range = null;
|
||||
range = new TrackedRange
|
||||
{
|
||||
Address = address,
|
||||
ByteCount = byteCount,
|
||||
Start = start,
|
||||
End = start + length,
|
||||
WriteGeneration = writeGeneration,
|
||||
};
|
||||
_rangesByAddress[address] = range;
|
||||
RebuildSnapshotLocked();
|
||||
}
|
||||
|
||||
if (range is null)
|
||||
@@ -272,6 +284,31 @@ public static unsafe class GuestImageWriteTracker
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the monotonic first-write generation for a tracked allocation.
|
||||
/// Unlike the consuming dirty flag, this remains changed after another
|
||||
/// cache owner consumes and re-arms the range.
|
||||
/// </summary>
|
||||
public static bool TryGetWriteGeneration(ulong address, out long generation)
|
||||
{
|
||||
generation = 0;
|
||||
if (!_enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_rangesByAddress.TryGetValue(address, out var range))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
generation = Volatile.Read(ref range.WriteGeneration);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prepares pages touched by a managed HLE memory write. Native guest
|
||||
/// stores fault and enter <see cref="TryHandleWriteFault"/> through the
|
||||
@@ -425,6 +462,10 @@ public static unsafe class GuestImageWriteTracker
|
||||
}
|
||||
|
||||
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) != 0;
|
||||
if (wasArmed)
|
||||
{
|
||||
Interlocked.Increment(ref range.WriteGeneration);
|
||||
}
|
||||
if (wasArmed &&
|
||||
range.TraceLifetime &&
|
||||
Interlocked.CompareExchange(ref range.FirstCpuWriteSeen, 1, 0) == 0)
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.HLE;
|
||||
|
||||
/// <summary>
|
||||
/// Support for HLE synchronization primitives that block the guest thread's
|
||||
/// host thread in place (inside the HLE call, on a host primitive) instead of
|
||||
/// capturing a continuation and re-scheduling through the cooperative wake-key
|
||||
/// machinery. In-place blocking makes block-and-wake atomic — the host
|
||||
/// primitive owns the race — which removes the lost-wakeup window the
|
||||
/// continuation path had between block registration and wake delivery.
|
||||
/// </summary>
|
||||
public static class GuestThreadBlocking
|
||||
{
|
||||
/// <summary>
|
||||
/// Upper bound on a single host wait while a guest thread is parked. Waits
|
||||
/// are sliced so parked threads observe <see cref="ShutdownRequested"/>
|
||||
/// promptly at teardown; a wake via Monitor.Pulse still lands immediately.
|
||||
/// </summary>
|
||||
public const int WaitSliceMilliseconds = 50;
|
||||
|
||||
private static volatile bool _shutdownRequested;
|
||||
|
||||
// Guest thread handle -> what it is parked on. Populated only while a
|
||||
// thread is blocked (the slow path), read by the stall watchdog so
|
||||
// in-place-blocked threads are not reported as opaque "Running" threads.
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, string> _blockDescriptions = new();
|
||||
|
||||
/// <summary>True once emulator teardown has begun; parked guest threads unwind.</summary>
|
||||
public static bool ShutdownRequested => _shutdownRequested;
|
||||
|
||||
/// <summary>Called by the execution backend when guest execution is being torn down.</summary>
|
||||
public static void RequestShutdown() => _shutdownRequested = true;
|
||||
|
||||
/// <summary>Records what the given guest thread is about to park on (diagnostics only).</summary>
|
||||
public static void NoteBlocked(ulong guestThreadHandle, string description)
|
||||
{
|
||||
if (guestThreadHandle != 0)
|
||||
{
|
||||
_blockDescriptions[guestThreadHandle] = description;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears the parked-state note recorded by <see cref="NoteBlocked"/>.</summary>
|
||||
public static void NoteUnblocked(ulong guestThreadHandle)
|
||||
{
|
||||
if (guestThreadHandle != 0)
|
||||
{
|
||||
_blockDescriptions.TryRemove(guestThreadHandle, out _);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What the thread is parked on, or null if it is not parked in place.</summary>
|
||||
public static string? DescribeBlock(ulong guestThreadHandle) =>
|
||||
_blockDescriptions.TryGetValue(guestThreadHandle, out var description) ? description : null;
|
||||
|
||||
/// <summary>All currently parked threads (diagnostics; covers the primary thread too).</summary>
|
||||
public static KeyValuePair<ulong, string>[] SnapshotBlockDescriptions() => _blockDescriptions.ToArray();
|
||||
|
||||
// Interrupt delivery for threads parked in place. A thread blocked inside
|
||||
// an HLE wait keeps its executor busy, so it never reaches the import-return
|
||||
// safe point where queued guest exceptions (IL2CPP stop-the-world suspend)
|
||||
// are delivered. When an exception is queued for such a thread, its handle
|
||||
// is flagged here; each sliced wait loop calls Checkpoint, which — on the
|
||||
// thread's OWN host thread, with the wait's gate released — runs the
|
||||
// registered deliverer (the same safe-point delivery used at import
|
||||
// boundaries), then the loop re-checks its predicate and re-parks. This is
|
||||
// the SA_RESTART-style "signal on top of a blocking wait" a real kernel
|
||||
// provides. Dormant unless an exception is actually pending (empty-check
|
||||
// fast path), so it adds no cost to normal blocking.
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, byte> _interrupted = new();
|
||||
|
||||
/// <summary>Set by the backend: delivers any exception queued for the current guest thread, in place.</summary>
|
||||
public static Action? DeliverInterruptForCurrentThread { get; set; }
|
||||
|
||||
/// <summary>Flags a parked guest thread to deliver a queued exception at its next wait checkpoint.</summary>
|
||||
public static void RequestInterrupt(ulong guestThreadHandle)
|
||||
{
|
||||
if (guestThreadHandle != 0)
|
||||
{
|
||||
_interrupted[guestThreadHandle] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called from every sliced wait loop while it holds <paramref name="gate"/>. If an
|
||||
/// exception is pending for the current guest thread, releases the gate, delivers it on
|
||||
/// this host thread, then re-acquires the gate so the loop re-checks its predicate.
|
||||
/// </summary>
|
||||
public static void Checkpoint(ulong guestThreadHandle, object gate)
|
||||
{
|
||||
if (_interrupted.IsEmpty || guestThreadHandle == 0 || !_interrupted.TryRemove(guestThreadHandle, out _))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var deliver = DeliverInterruptForCurrentThread;
|
||||
if (deliver is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Never run guest code (the handler) while holding an HLE gate — the
|
||||
// handler may re-enter this same primitive. Release across delivery.
|
||||
Monitor.Exit(gate);
|
||||
try
|
||||
{
|
||||
deliver();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Monitor.Enter(gate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,13 @@ public readonly record struct GuestThreadSnapshot(
|
||||
/// false leaves it parked. Resume runs later on the woken thread outside that gate, and
|
||||
/// its return value becomes the guest's RAX for the resumed call.
|
||||
/// </summary>
|
||||
public interface IGuestThreadBlockWaiter
|
||||
{
|
||||
int Resume();
|
||||
|
||||
bool TryWake();
|
||||
}
|
||||
|
||||
public interface IGuestThreadScheduler
|
||||
{
|
||||
bool SupportsGuestContextTransfer { get; }
|
||||
@@ -50,6 +57,10 @@ public interface IGuestThreadScheduler
|
||||
out ulong returnValue,
|
||||
out string? error);
|
||||
|
||||
void Pump(CpuContext callerContext, string reason);
|
||||
|
||||
int WakeBlockedThreads(string wakeKey, int maxCount = int.MaxValue);
|
||||
|
||||
/// <summary>
|
||||
/// Applies a new guest scheduling priority to a live thread, mapping it
|
||||
/// onto the host thread if one is running. Returns false when the thread
|
||||
@@ -141,12 +152,46 @@ public readonly record struct GuestCpuContinuation(
|
||||
|
||||
public static class GuestThreadExecution
|
||||
{
|
||||
private sealed class DelegateGuestThreadBlockWaiter : IGuestThreadBlockWaiter
|
||||
{
|
||||
private readonly Func<int> _resume;
|
||||
private readonly Func<bool> _tryWake;
|
||||
|
||||
public DelegateGuestThreadBlockWaiter(Func<int> resume, Func<bool> tryWake)
|
||||
{
|
||||
_resume = resume;
|
||||
_tryWake = tryWake;
|
||||
}
|
||||
|
||||
public int Resume() => _resume();
|
||||
|
||||
public bool TryWake() => _tryWake();
|
||||
}
|
||||
|
||||
[ThreadStatic]
|
||||
private static ulong _currentGuestThreadHandle;
|
||||
|
||||
[ThreadStatic]
|
||||
private static ulong _currentFiberAddress;
|
||||
|
||||
[ThreadStatic]
|
||||
private static string? _pendingBlockReason;
|
||||
|
||||
[ThreadStatic]
|
||||
private static bool _pendingBlockContinuationValid;
|
||||
|
||||
[ThreadStatic]
|
||||
private static GuestCpuContinuation _pendingBlockContinuation;
|
||||
|
||||
[ThreadStatic]
|
||||
private static string? _pendingBlockWakeKey;
|
||||
|
||||
[ThreadStatic]
|
||||
private static IGuestThreadBlockWaiter? _pendingBlockWaiter;
|
||||
|
||||
[ThreadStatic]
|
||||
private static long _pendingBlockDeadlineTimestamp;
|
||||
|
||||
[ThreadStatic]
|
||||
private static bool _pendingEntryExit;
|
||||
|
||||
@@ -186,6 +231,12 @@ public static class GuestThreadExecution
|
||||
{
|
||||
var previous = _currentGuestThreadHandle;
|
||||
_currentGuestThreadHandle = threadHandle;
|
||||
_pendingBlockReason = null;
|
||||
_pendingBlockContinuationValid = false;
|
||||
_pendingBlockContinuation = default;
|
||||
_pendingBlockWakeKey = null;
|
||||
_pendingBlockWaiter = null;
|
||||
_pendingBlockDeadlineTimestamp = 0;
|
||||
_pendingEntryExit = false;
|
||||
_pendingEntryExitValue = 0;
|
||||
_pendingEntryExitReason = null;
|
||||
@@ -201,6 +252,12 @@ public static class GuestThreadExecution
|
||||
public static void RestoreGuestThread(ulong previousThreadHandle)
|
||||
{
|
||||
_currentGuestThreadHandle = previousThreadHandle;
|
||||
_pendingBlockReason = null;
|
||||
_pendingBlockContinuationValid = false;
|
||||
_pendingBlockContinuation = default;
|
||||
_pendingBlockWakeKey = null;
|
||||
_pendingBlockWaiter = null;
|
||||
_pendingBlockDeadlineTimestamp = 0;
|
||||
_pendingEntryExit = false;
|
||||
_pendingEntryExitValue = 0;
|
||||
_pendingEntryExitReason = null;
|
||||
@@ -224,6 +281,123 @@ public static class GuestThreadExecution
|
||||
_currentFiberAddress = previousFiberAddress;
|
||||
}
|
||||
|
||||
public static bool RequestCurrentThreadBlock(string reason) => RequestCurrentThreadBlock(null, reason);
|
||||
|
||||
public static bool RequestCurrentThreadBlock(
|
||||
CpuContext? context,
|
||||
string reason,
|
||||
string? wakeKey = null,
|
||||
IGuestThreadBlockWaiter? waiter = null,
|
||||
long blockDeadlineTimestamp = 0)
|
||||
{
|
||||
if (!IsGuestThread)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_pendingBlockReason = string.IsNullOrWhiteSpace(reason) ? "guest_thread_blocked" : reason;
|
||||
_pendingBlockWakeKey = string.IsNullOrWhiteSpace(wakeKey) ? _pendingBlockReason : wakeKey;
|
||||
_pendingBlockWaiter = waiter;
|
||||
_pendingBlockDeadlineTimestamp = blockDeadlineTimestamp;
|
||||
if (context is not null && TryCaptureCurrentBlockContinuation(context, out var continuation))
|
||||
{
|
||||
_pendingBlockContinuation = continuation;
|
||||
_pendingBlockContinuationValid = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_pendingBlockContinuation = default;
|
||||
_pendingBlockContinuationValid = false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Compatibility bridge for exports that still describe blocked work as a
|
||||
// resume/wake delegate pair. New hot paths should provide an
|
||||
// IGuestThreadBlockWaiter directly to avoid allocating closures.
|
||||
public static bool RequestCurrentThreadBlock(
|
||||
CpuContext? context,
|
||||
string reason,
|
||||
string? wakeKey,
|
||||
Func<int> resumeHandler,
|
||||
Func<bool> wakeHandler,
|
||||
long blockDeadlineTimestamp = 0) =>
|
||||
RequestCurrentThreadBlock(
|
||||
context,
|
||||
reason,
|
||||
wakeKey,
|
||||
new DelegateGuestThreadBlockWaiter(resumeHandler, wakeHandler),
|
||||
blockDeadlineTimestamp);
|
||||
|
||||
public static bool TryConsumeCurrentThreadBlock(out string reason)
|
||||
{
|
||||
return TryConsumeCurrentThreadBlock(out reason, out _, out _);
|
||||
}
|
||||
|
||||
public static bool TryConsumeCurrentThreadBlock(
|
||||
out string reason,
|
||||
out GuestCpuContinuation continuation,
|
||||
out bool hasContinuation)
|
||||
{
|
||||
return TryConsumeCurrentThreadBlock(
|
||||
out reason,
|
||||
out continuation,
|
||||
out hasContinuation,
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
}
|
||||
|
||||
public static bool TryConsumeCurrentThreadBlock(
|
||||
out string reason,
|
||||
out GuestCpuContinuation continuation,
|
||||
out bool hasContinuation,
|
||||
out string wakeKey,
|
||||
out IGuestThreadBlockWaiter? waiter)
|
||||
{
|
||||
return TryConsumeCurrentThreadBlock(
|
||||
out reason,
|
||||
out continuation,
|
||||
out hasContinuation,
|
||||
out wakeKey,
|
||||
out waiter,
|
||||
out _);
|
||||
}
|
||||
|
||||
public static bool TryConsumeCurrentThreadBlock(
|
||||
out string reason,
|
||||
out GuestCpuContinuation continuation,
|
||||
out bool hasContinuation,
|
||||
out string wakeKey,
|
||||
out IGuestThreadBlockWaiter? waiter,
|
||||
out long blockDeadlineTimestamp)
|
||||
{
|
||||
reason = _pendingBlockReason ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(reason))
|
||||
{
|
||||
continuation = default;
|
||||
hasContinuation = false;
|
||||
wakeKey = string.Empty;
|
||||
waiter = null;
|
||||
blockDeadlineTimestamp = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
continuation = _pendingBlockContinuation;
|
||||
hasContinuation = _pendingBlockContinuationValid;
|
||||
wakeKey = _pendingBlockWakeKey ?? reason;
|
||||
waiter = _pendingBlockWaiter;
|
||||
blockDeadlineTimestamp = _pendingBlockDeadlineTimestamp;
|
||||
_pendingBlockReason = null;
|
||||
_pendingBlockContinuation = default;
|
||||
_pendingBlockContinuationValid = false;
|
||||
_pendingBlockWakeKey = null;
|
||||
_pendingBlockWaiter = null;
|
||||
_pendingBlockDeadlineTimestamp = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static long ComputeDeadlineTimestamp(TimeSpan timeout)
|
||||
{
|
||||
if (timeout <= TimeSpan.Zero)
|
||||
@@ -243,6 +417,45 @@ public static class GuestThreadExecution
|
||||
return now + Math.Max(1, ticks);
|
||||
}
|
||||
|
||||
private static bool TryCaptureCurrentBlockContinuation(CpuContext context, out GuestCpuContinuation continuation)
|
||||
{
|
||||
if (!TryGetCurrentImportCallFrame(out var frame) ||
|
||||
frame.ReturnRip < 65536 ||
|
||||
frame.ResumeRsp == 0 ||
|
||||
frame.ReturnSlotAddress == 0)
|
||||
{
|
||||
continuation = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
continuation = new GuestCpuContinuation(
|
||||
frame.ReturnRip,
|
||||
frame.ResumeRsp,
|
||||
frame.ReturnSlotAddress,
|
||||
context.Rflags,
|
||||
context.FsBase,
|
||||
context.GsBase,
|
||||
0,
|
||||
context[CpuRegister.Rcx],
|
||||
context[CpuRegister.Rdx],
|
||||
context[CpuRegister.Rbx],
|
||||
context[CpuRegister.Rbp],
|
||||
context[CpuRegister.Rsi],
|
||||
context[CpuRegister.Rdi],
|
||||
context[CpuRegister.R8],
|
||||
context[CpuRegister.R9],
|
||||
context[CpuRegister.R10],
|
||||
context[CpuRegister.R11],
|
||||
context[CpuRegister.R12],
|
||||
context[CpuRegister.R13],
|
||||
context[CpuRegister.R14],
|
||||
context[CpuRegister.R15],
|
||||
context.FpuControlWord,
|
||||
context.Mxcsr,
|
||||
RestoreFullFpuState: false);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void RequestCurrentEntryExit(string reason, int status)
|
||||
{
|
||||
RequestCurrentEntryExit(reason, unchecked((ulong)(long)status));
|
||||
|
||||
@@ -17,7 +17,7 @@ public static class GuestTlsTemplate
|
||||
// Must match CpuDispatcher/DirectExecutionBackend's mapped prefix. PS5
|
||||
// modules can require more than one host page of Variant II static TLS;
|
||||
// Dreaming Sarah's startup image, for example, reaches 0x1870 bytes.
|
||||
public const ulong StartupStaticTlsReservation = 0x10000UL;
|
||||
public const ulong StartupStaticTlsReservation = 0x20000UL; // Was 0x10000UL, but thats too small for GTA V
|
||||
private static readonly object _gate = new();
|
||||
private static readonly SortedDictionary<ulong, ModuleTemplate> _modules = new();
|
||||
private static readonly Dictionary<ulong, ThreadDtv> _threadDtvs = new();
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Globalization;
|
||||
using System.Threading;
|
||||
|
||||
namespace SharpEmu.HLE;
|
||||
|
||||
// This tool monitors guest-memory writes only when a watch mode is active.
|
||||
public static class GuestWriteWatch
|
||||
{
|
||||
private const ulong WatchBytes = 8;
|
||||
private const int MaxBulkReports = 64;
|
||||
|
||||
private static readonly ulong WatchBase = Parse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_WATCH_WRITE"));
|
||||
|
||||
private static readonly bool WatchPoolHeaders = IsEnabled("SHARPEMU_WATCH_POOL_HEADER");
|
||||
|
||||
private static readonly ulong[] PoolSlots = new ulong[64];
|
||||
private static int _poolSlotCount;
|
||||
|
||||
private static readonly bool WatchValuePattern = IsEnabled("SHARPEMU_WATCH_VALUE_PATTERN");
|
||||
|
||||
private static readonly bool WatchValue1 = IsEnabled("SHARPEMU_WATCH_VALUE1");
|
||||
|
||||
private const ulong DirectBandLow = 0x100_0000_0000;
|
||||
private const ulong DirectBandHigh = 0x1000_0000_0000;
|
||||
private static int _value1Reports;
|
||||
|
||||
private static readonly bool WatchBulkTorn = IsEnabled("SHARPEMU_WATCH_BULK_TORN");
|
||||
|
||||
private static readonly ulong BulkDestHigh = Parse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_WATCH_BULK_DEST_HI"));
|
||||
private static int _bulkTornReports;
|
||||
private static int _bulkShiftReports;
|
||||
|
||||
public static bool Armed =>
|
||||
WatchBase != 0 || WatchPoolHeaders || WatchValuePattern || WatchValue1 || WatchBulkTorn;
|
||||
|
||||
public static void OnDirectMapping(ulong mappedAddress, ulong length, int protection)
|
||||
{
|
||||
if (!WatchPoolHeaders || !IsPoolMapping(length, protection))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var index = Interlocked.Increment(ref _poolSlotCount) - 1;
|
||||
if (index < PoolSlots.Length)
|
||||
{
|
||||
Volatile.Write(ref PoolSlots[index], mappedAddress + 0x40);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] watch_write armed on pool header slot 0x{mappedAddress + 0x40:X16}");
|
||||
}
|
||||
}
|
||||
|
||||
public static void Check(ulong address, ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (WatchBulkTorn &&
|
||||
data.Length >= 8 &&
|
||||
(BulkDestHigh != 0
|
||||
? (address >> 32) == BulkDestHigh
|
||||
: address >= DirectBandLow && address < DirectBandHigh))
|
||||
{
|
||||
for (var offset = FirstAlignedOffset(address); offset + 8 <= data.Length; offset += 8)
|
||||
{
|
||||
var qword = BinaryPrimitives.ReadUInt64LittleEndian(data.Slice(offset, 8));
|
||||
var kind = ClassifyBulkValue(qword);
|
||||
if (kind is not null && ReserveBulkReport(kind))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] watch_bulk_torn HIT ({kind}) " +
|
||||
$"dest=0x{address + (ulong)offset:X16} (base=0x{address:X16}+0x{offset:X}) " +
|
||||
$"len={data.Length} qword=0x{qword:X16}{Environment.NewLine}{Environment.StackTrace}");
|
||||
Console.Error.Flush();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (WatchValue1 &&
|
||||
address >= DirectBandLow && address < DirectBandHigh &&
|
||||
data.Length is >= 1 and <= 8 &&
|
||||
LittleEndianValue(data) == 1 &&
|
||||
Interlocked.Increment(ref _value1Reports) <= 128)
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (WatchValuePattern && data.Length == 8)
|
||||
{
|
||||
var value = BinaryPrimitives.ReadUInt64LittleEndian(data);
|
||||
if ((value & 0xFFFFFFFF) == 1 && value >> 32 is > 0 and <= 0xFFFF)
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (WatchBase != 0 && Overlaps(address, data.Length, WatchBase))
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
|
||||
var slots = Math.Min(Volatile.Read(ref _poolSlotCount), PoolSlots.Length);
|
||||
for (var i = 0; i < slots; i++)
|
||||
{
|
||||
var slot = Volatile.Read(ref PoolSlots[i]);
|
||||
if (slot != 0 && Overlaps(address, data.Length, slot))
|
||||
{
|
||||
Report(address, data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static string? ClassifyBulkValue(ulong qword)
|
||||
{
|
||||
var low32 = qword & 0xFFFFFFFF;
|
||||
var high32 = qword >> 32;
|
||||
if (low32 == 1 && high32 is > 0 and <= 0xFFFF)
|
||||
{
|
||||
return "torn";
|
||||
}
|
||||
|
||||
var prefix = low32 & 0xFF00_0000;
|
||||
var hasShiftedPointerPrefix = prefix is 0x0800_0000 or 0x8000_0000;
|
||||
return high32 == 0 && hasShiftedPointerPrefix && (low32 & 0xFF) == 0
|
||||
? "shift"
|
||||
: null;
|
||||
}
|
||||
|
||||
internal static int FirstAlignedOffset(ulong address) =>
|
||||
(int)((8 - (address & 7)) & 7);
|
||||
|
||||
internal static bool IsPoolMapping(ulong length, int protection) =>
|
||||
length == 0x10000 && protection == 0xF2;
|
||||
|
||||
internal static bool Overlaps(ulong address, int length, ulong slot)
|
||||
{
|
||||
if (length <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var writeLength = (ulong)length - 1;
|
||||
var writeEnd = address > ulong.MaxValue - writeLength
|
||||
? ulong.MaxValue
|
||||
: address + writeLength;
|
||||
var slotEnd = slot > ulong.MaxValue - (WatchBytes - 1)
|
||||
? ulong.MaxValue
|
||||
: slot + WatchBytes - 1;
|
||||
return address <= slotEnd && slot <= writeEnd;
|
||||
}
|
||||
|
||||
private static ulong LittleEndianValue(ReadOnlySpan<byte> data)
|
||||
{
|
||||
ulong value = 0;
|
||||
for (var i = 0; i < data.Length; i++)
|
||||
{
|
||||
value |= (ulong)data[i] << (i * 8);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void Report(ulong address, ReadOnlySpan<byte> data)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] watch_write HIT addr=0x{address:X16} len={data.Length} " +
|
||||
$"first_qword=0x{LittleEndianValue(data):X16}{Environment.NewLine}{Environment.StackTrace}");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
|
||||
private static bool IsEnabled(string name) =>
|
||||
string.Equals(Environment.GetEnvironmentVariable(name), "1", StringComparison.Ordinal);
|
||||
|
||||
private static bool ReserveBulkReport(string kind) =>
|
||||
kind == "torn"
|
||||
? Interlocked.Increment(ref _bulkTornReports) <= MaxBulkReports
|
||||
: Interlocked.Increment(ref _bulkShiftReports) <= MaxBulkReports;
|
||||
|
||||
internal static ulong Parse(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
text = text.Trim();
|
||||
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
text = text[2..];
|
||||
}
|
||||
|
||||
return ulong.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value)
|
||||
? value
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
@@ -10,4 +10,6 @@ public interface ICpuMemory
|
||||
bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source);
|
||||
|
||||
bool TryCompare(ulong virtualAddress, ReadOnlySpan<byte> expected) => false;
|
||||
|
||||
bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length) => false;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,17 @@ public interface IGuestAddressSpace : IGuestMemoryAllocator
|
||||
{
|
||||
ulong AllocateAt(ulong desiredAddress, ulong size, bool executable = true, bool allowAlternative = true);
|
||||
|
||||
/// <summary>
|
||||
/// Backs an entire fixed-address range, matching the guest's
|
||||
/// <c>SCE_KERNEL_MAP_FIXED</c> contract. Unlike <see cref="AllocateAt"/>, which
|
||||
/// reserves the range in one all-or-nothing host call, this walks the range and
|
||||
/// fills only the sub-ranges that are not already backed. That keeps a fixed
|
||||
/// mapping whole when part of the requested window is already occupied — the
|
||||
/// partial-overlap case where the single-call reservation fails outright and
|
||||
/// leaves the remainder unmapped for the guest to fault into.
|
||||
/// </summary>
|
||||
bool TryBackFixedRange(ulong address, ulong size, bool executable);
|
||||
|
||||
bool TryAllocateAtOrAbove(ulong desiredAddress, ulong size, bool executable, ulong alignment, out ulong actualAddress);
|
||||
|
||||
bool TryProtect(ulong address, ulong size, GuestPageProtection protection);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Libs.Acm;
|
||||
|
||||
public static class AcmExports
|
||||
{
|
||||
private static int _nextContextHandle;
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "ZIXln2K3XMk",
|
||||
ExportName = "sceAcmContextCreate",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAcm")]
|
||||
public static int AcmContextCreate(CpuContext ctx)
|
||||
{
|
||||
var outContextAddress = ctx[CpuRegister.Rdi];
|
||||
if (outContextAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
var handle = (ulong)Interlocked.Increment(ref _nextContextHandle);
|
||||
Span<byte> handleBytes = stackalloc byte[sizeof(ulong)];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(handleBytes, handle);
|
||||
return ctx.Memory.TryWrite(outContextAddress, handleBytes)
|
||||
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "jBgBjAj02R8",
|
||||
ExportName = "sceAcmContextDestroy",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAcm")]
|
||||
public static int AcmContextDestroy(CpuContext ctx)
|
||||
{
|
||||
_ = ctx;
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
}
|
||||
+498
-155
@@ -3,6 +3,7 @@
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Bink;
|
||||
using SharpEmu.Libs.Gpu;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
@@ -48,6 +49,7 @@ public static partial class AgcExports
|
||||
private const uint ItWriteData = 0x37;
|
||||
private const uint ItDispatchDirect = 0x15;
|
||||
private const uint ItDispatchIndirect = 0x16;
|
||||
private const uint ItSetPredication = 0x20;
|
||||
private const uint ItWaitRegMem = 0x3C;
|
||||
private const uint ItIndirectBuffer = 0x3F;
|
||||
private const uint ItEventWrite = 0x46;
|
||||
@@ -147,6 +149,10 @@ public static partial class AgcExports
|
||||
private const uint Gen5TextureFormatR16G16B16A16Float = 12;
|
||||
private const uint Gen5TextureType1D = 8;
|
||||
private const uint Gen5TextureType2D = 9;
|
||||
private const uint Gen5TextureType3D = 10;
|
||||
private const uint Gen5TextureTypeCube = 11;
|
||||
private const uint Gen5TextureType1DArray = 12;
|
||||
private const uint Gen5TextureType2DArray = 13;
|
||||
private const ulong MaxPresentedTextureBytes = 128UL * 1024UL * 1024UL;
|
||||
private const ulong VideoOutPixelFormatA8R8G8B8Srgb = 0x80000000;
|
||||
private const ulong VideoOutPixelFormatA8B8G8R8Srgb = 0x80002200;
|
||||
@@ -217,6 +223,7 @@ public static partial class AgcExports
|
||||
(ulong Es, ulong State, ulong AliasAlignment),
|
||||
IGuestCompiledShader> _depthOnlyVertexShaderCache = new();
|
||||
private static readonly Dictionary<ulong, ulong> _shaderHeadersByCode = new();
|
||||
private static readonly ConcurrentDictionary<ulong, byte> _arrayUploadUnsupported = new();
|
||||
private static readonly bool _traceAgc = string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_LOG_AGC"),
|
||||
"1",
|
||||
@@ -272,7 +279,7 @@ public static partial class AgcExports
|
||||
private static long _labelProducerSequence;
|
||||
private static readonly object _labelProducerGate = new();
|
||||
private static readonly List<LabelProducerTrace> _labelProducers = [];
|
||||
private static readonly HashSet<(object Memory, ulong Address, ulong SubmissionId)>
|
||||
private static readonly HashSet<(object Memory, ulong Address)>
|
||||
_tracedProducerlessWaits = new();
|
||||
private static long _shaderTranslationMissTraceCount;
|
||||
private static long _translatedDrawTraceCount;
|
||||
@@ -438,7 +445,8 @@ public static partial class AgcExports
|
||||
TextureDescriptor Descriptor,
|
||||
bool IsStorage,
|
||||
uint MipLevel,
|
||||
IReadOnlyList<uint> SamplerDescriptor);
|
||||
IReadOnlyList<uint> SamplerDescriptor,
|
||||
bool IsArrayed = false);
|
||||
|
||||
private readonly record struct RenderTargetWriter(
|
||||
ulong Sequence,
|
||||
@@ -537,6 +545,7 @@ public static partial class AgcExports
|
||||
public uint IndexSize { get; set; }
|
||||
public uint InstanceCount { get; set; } = 1;
|
||||
public uint DrawIndexOffset { get; set; }
|
||||
public bool PredicateSkip { get; set; }
|
||||
public string QueueName { get; set; } = "graphics";
|
||||
public ulong ActiveSubmissionId { get; set; }
|
||||
public Queue<PendingSubmission> PendingSubmissions { get; } = new();
|
||||
@@ -640,6 +649,21 @@ public static partial class AgcExports
|
||||
public static int GetRegisterDefaults2Internal(CpuContext ctx) =>
|
||||
ReturnRegisterDefaults(ctx, internalDefaults: true);
|
||||
|
||||
/// <summary>
|
||||
/// Reports that the GPU is not running in Trinity mode, matching the base
|
||||
/// console this backend emulates.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "BfBDZGbti7A",
|
||||
ExportName = "sceAgcGetIsTrinityMode",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int GetIsTrinityMode(CpuContext ctx)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "f3dg2CSgRKY",
|
||||
ExportName = "sceAgcCreateShader",
|
||||
@@ -1162,12 +1186,12 @@ public static partial class AgcExports
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
var packetDwords = size == 0 ? 6u : 9u;
|
||||
var packetDwords = size == 0 ? 7u : 9u;
|
||||
var packetRegister = size == 0 ? RWaitMem32 : RWaitMem64;
|
||||
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress) ||
|
||||
!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItNop, packetRegister)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address & (size == 0 ? ~0x3u : ~0x7u)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32) & 0x3FFFFu) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)mask))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
@@ -1175,8 +1199,9 @@ public static partial class AgcExports
|
||||
|
||||
if (size == 0)
|
||||
{
|
||||
if (!TryWriteUInt32(ctx, commandAddress + 16, compareFunction) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference))
|
||||
if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)reference) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, EncodeWaitRegMem32Control(compareFunction, 0, cachePolicy)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 24, EncodeWaitRegMemPoll(pollCycles)))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
@@ -1184,8 +1209,8 @@ public static partial class AgcExports
|
||||
else if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)(mask >> 32)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 24, (uint)(reference >> 32)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 28, compareFunction) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 32, pollCycles / 40))
|
||||
!TryWriteUInt32(ctx, commandAddress + 28, EncodeWaitRegMem64Control(compareFunction, 0, cachePolicy)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 32, EncodeWaitRegMemPoll(pollCycles)))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
@@ -1775,58 +1800,6 @@ public static partial class AgcExports
|
||||
return ReturnPointer(ctx, commandAddress);
|
||||
}
|
||||
|
||||
// Single-register variant of the SET_SH_REG builders: the register rides
|
||||
// in rsi as a packed struct (low 16 bits = register offset, high dword =
|
||||
// byte offset of this dword within a multi-dword register write) and the
|
||||
// value in edx. Emits the same 3-dword SET_SH_REG packet the plural
|
||||
// sceAgcCbSetShRegistersDirect path produces per run. Hades calls this
|
||||
// ~1k times per boot; leaving it unresolved corrupted its DCB stream.
|
||||
[SysAbiExport(
|
||||
Nid = "pFLArOT53+w",
|
||||
ExportName = "sceAgcDcbSetShRegisterDirect",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int DcbSetShRegisterDirect(CpuContext ctx)
|
||||
{
|
||||
var commandBufferAddress = ctx[CpuRegister.Rdi];
|
||||
var packedRegister = ctx[CpuRegister.Rsi];
|
||||
var value = (uint)ctx[CpuRegister.Rdx];
|
||||
if (commandBufferAddress == 0)
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
var offset = (uint)(packedRegister & 0xFFFFu) + (uint)((packedRegister >> 32) >> 2);
|
||||
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 3, out var commandAddress) ||
|
||||
!TryWriteUInt32(ctx, commandAddress, Pm4(3, ItSetShReg, 0)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, offset & 0xFFFFu) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 8, value))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
TraceAgc($"agc.dcb_set_sh_register_direct buf=0x{commandBufferAddress:X16} reg=0x{offset:X4} value=0x{value:X8}");
|
||||
return ReturnPointer(ctx, commandAddress);
|
||||
}
|
||||
|
||||
// Size probe for the wait-on-address writer below: same argument prefix
|
||||
// minus the command buffer, returns the byte size the writer will emit so
|
||||
// the game can reserve DCB space (7 dwords for a standard WAIT_REG_MEM,
|
||||
// 6/9 for the 32/64-bit polled-NOP forms).
|
||||
[SysAbiExport(
|
||||
Nid = "43WJ08sSugE",
|
||||
ExportName = "sceAgcDcbWaitOnAddressGetSize",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int DcbWaitOnAddressGetSize(CpuContext ctx)
|
||||
{
|
||||
var size = (uint)(ctx[CpuRegister.Rdi] & 0xFF);
|
||||
var operation = (uint)(ctx[CpuRegister.Rdx] & 0xFF);
|
||||
var packetDwords = operation is 2 or 3 ? 7u : size == 0 ? 6u : 9u;
|
||||
ctx[CpuRegister.Rax] = packetDwords * sizeof(uint);
|
||||
return (int)ctx[CpuRegister.Rax];
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "VmW0Tdpy420",
|
||||
ExportName = "sceAgcDcbWaitRegMem",
|
||||
@@ -1857,38 +1830,21 @@ public static partial class AgcExports
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
var standardWait = operation is 2 or 3;
|
||||
var packetDwords = standardWait ? 7u : size == 0 ? 6u : 9u;
|
||||
var packetDwords = size == 0 ? 7u : 9u;
|
||||
var packetRegister = size == 0 ? RWaitMem32 : RWaitMem64;
|
||||
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
if (standardWait)
|
||||
{
|
||||
if (!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItWaitRegMem, 0)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, compareFunction | ((operation & 1) << 8)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)address) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)(address >> 32)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 16, (uint)reference) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)mask) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 24, pollCycles / 40))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
}
|
||||
else if (!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItNop, packetRegister)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32)) ||
|
||||
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress) ||
|
||||
!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItNop, packetRegister)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address & (size == 0 ? ~0x3u : ~0x7u)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32) & 0x3FFFFu) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)mask))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
else if (size == 0)
|
||||
{
|
||||
if (!TryWriteUInt32(ctx, commandAddress + 16, compareFunction | (operation << 8)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference))
|
||||
if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)reference) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, EncodeWaitRegMem32Control(compareFunction, operation, cachePolicy)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 24, EncodeWaitRegMemPoll(pollCycles)))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
@@ -1896,8 +1852,8 @@ public static partial class AgcExports
|
||||
else if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)(mask >> 32)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 24, (uint)(reference >> 32)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 28, compareFunction | (operation << 8)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 32, pollCycles / 40))
|
||||
!TryWriteUInt32(ctx, commandAddress + 28, EncodeWaitRegMem64Control(compareFunction, operation, cachePolicy)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 32, EncodeWaitRegMemPoll(pollCycles)))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
@@ -2336,7 +2292,14 @@ public static partial class AgcExports
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
return ctx.TryWriteUInt64(commandAddress + fieldOffset, address)
|
||||
var wrote = op == ItNop && register is RWaitMem32 or RWaitMem64
|
||||
? TryWriteUInt32(
|
||||
ctx,
|
||||
commandAddress + fieldOffset,
|
||||
(uint)address & (register == RWaitMem32 ? ~0x3u : ~0x7u)) &&
|
||||
TryWriteUInt32(ctx, commandAddress + fieldOffset + 4, (uint)(address >> 32) & 0x3FFFFu)
|
||||
: ctx.TryWriteUInt64(commandAddress + fieldOffset, address);
|
||||
return wrote
|
||||
? SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
: SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
@@ -2359,7 +2322,7 @@ public static partial class AgcExports
|
||||
var fieldOffset = op == ItWaitRegMem
|
||||
? 4UL
|
||||
: op == ItNop && register == RWaitMem32
|
||||
? 16UL
|
||||
? 20UL
|
||||
: op == ItNop && register == RWaitMem64
|
||||
? 28UL
|
||||
: 0;
|
||||
@@ -2388,7 +2351,7 @@ public static partial class AgcExports
|
||||
var wrote = op == ItWaitRegMem
|
||||
? TryWriteUInt32(ctx, commandAddress + 16, (uint)reference)
|
||||
: op == ItNop && register == RWaitMem32
|
||||
? TryWriteUInt32(ctx, commandAddress + 20, (uint)reference)
|
||||
? TryWriteUInt32(ctx, commandAddress + 16, (uint)reference)
|
||||
: op == ItNop && register == RWaitMem64 &&
|
||||
ctx.TryWriteUInt64(commandAddress + 20, reference);
|
||||
return wrote
|
||||
@@ -2676,32 +2639,6 @@ public static partial class AgcExports
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
// Hands the game the driver-side context id its GPU event-queue packets
|
||||
// reference. We key events purely on (equeue, ident, filter), so a single
|
||||
// stable id satisfies the contract; the game only checks the call
|
||||
// succeeded and threads the id back through later driver calls.
|
||||
[SysAbiExport(
|
||||
Nid = "Zw7uUVPulbw",
|
||||
ExportName = "sceAgcDriverGetEqContextId",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgcDriver")]
|
||||
public static int DriverGetEqContextId(CpuContext ctx)
|
||||
{
|
||||
var contextIdAddress = ctx[CpuRegister.Rdi];
|
||||
if (contextIdAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
if (!TryWriteUInt32(ctx, contextIdAddress, 1))
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
TraceAgc($"agc.driver_get_eq_context_id out=0x{contextIdAddress:X16} -> 1");
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "DL2RXaXOy88",
|
||||
ExportName = "sceAgcDriverDeleteEqEvent",
|
||||
@@ -3162,6 +3099,26 @@ public static partial class AgcExports
|
||||
CountSubmittedOpcode(op, register);
|
||||
}
|
||||
|
||||
if ((header & 1u) != 0 && state.PredicateSkip)
|
||||
{
|
||||
if (tracePackets)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.dcb.predicated_skip queue={state.QueueName} " +
|
||||
$"packet=0x{currentAddress:X16} op=0x{op:X2} len={length}");
|
||||
}
|
||||
|
||||
offset += length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op == ItSetPredication)
|
||||
{
|
||||
ApplySubmittedPredication(ctx, state, currentAddress, length, tracePackets);
|
||||
offset += length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op == ItNop &&
|
||||
register is RDrawReset or RAcbReset &&
|
||||
length >= 2)
|
||||
@@ -3880,7 +3837,7 @@ public static partial class AgcExports
|
||||
|
||||
if (!stale && producer is null &&
|
||||
!_tracedProducerlessWaits.Add(
|
||||
(memory, waiter.WaitAddress, waiter.SubmissionId)))
|
||||
(memory, waiter.WaitAddress)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -4076,6 +4033,111 @@ public static partial class AgcExports
|
||||
state.DrawIndexOffset = 0;
|
||||
}
|
||||
|
||||
private static void ApplySubmittedPredication(
|
||||
CpuContext ctx,
|
||||
SubmittedDcbState state,
|
||||
ulong packetAddress,
|
||||
uint packetLength,
|
||||
bool tracePacket)
|
||||
{
|
||||
if (packetLength < 3 ||
|
||||
!TryReadUInt32(ctx, packetAddress + 4, out var first) ||
|
||||
!TryReadUInt32(ctx, packetAddress + 8, out var second))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const uint flagsMask = 0x0007_1100u;
|
||||
uint flags;
|
||||
ulong predicateAddress;
|
||||
if (packetLength >= 4 &&
|
||||
(first & ~flagsMask) == 0 &&
|
||||
TryReadUInt32(ctx, packetAddress + 12, out var third) &&
|
||||
third <= 0xFFFFu)
|
||||
{
|
||||
flags = first;
|
||||
predicateAddress = ((ulong)third << 32) | (second & 0xFFFF_FFF0u);
|
||||
}
|
||||
else
|
||||
{
|
||||
flags = second;
|
||||
predicateAddress = (first & 0xFFFF_FFF0u) | ((ulong)(second & 0xFFu) << 32);
|
||||
}
|
||||
|
||||
var operation = (flags >> 16) & 0x7u;
|
||||
if (operation == 0)
|
||||
{
|
||||
state.PredicateSkip = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation != 3)
|
||||
{
|
||||
if (tracePacket)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.dcb.predication_unsupported packet=0x{packetAddress:X16} " +
|
||||
$"op={operation} addr=0x{predicateAddress:X16}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var waitOperation = (flags >> 12) & 1u;
|
||||
var value = 0UL;
|
||||
var readSucceeded = false;
|
||||
void ReadPredicate() =>
|
||||
readSucceeded = ctx.TryReadUInt64(predicateAddress, out value);
|
||||
|
||||
if (waitOperation != 0)
|
||||
{
|
||||
var sequence = GuestGpu.Current.SubmitOrderedGuestAction(
|
||||
ReadPredicate,
|
||||
$"set_predication read 0x{predicateAddress:X16}");
|
||||
if (sequence == 0)
|
||||
{
|
||||
ReadPredicate();
|
||||
}
|
||||
else if (!GuestGpu.Current.WaitForGuestWork(sequence))
|
||||
{
|
||||
if (tracePacket)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.dcb.predication_wait_failed packet=0x{packetAddress:X16} " +
|
||||
$"addr=0x{predicateAddress:X16} sequence={sequence}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ReadPredicate();
|
||||
}
|
||||
|
||||
if (!readSucceeded)
|
||||
{
|
||||
if (tracePacket)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.dcb.predication_read_failed packet=0x{packetAddress:X16} " +
|
||||
$"addr=0x{predicateAddress:X16}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var condition = (flags >> 8) & 1u;
|
||||
state.PredicateSkip = condition == 0 ? value != 0 : value == 0;
|
||||
if (tracePacket)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.dcb.predication packet=0x{packetAddress:X16} " +
|
||||
$"addr=0x{predicateAddress:X16} value=0x{value:X16} " +
|
||||
$"condition={condition} wait={waitOperation} skip={state.PredicateSkip}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RangesOverlap(
|
||||
ulong leftAddress,
|
||||
ulong leftLength,
|
||||
@@ -4618,6 +4680,7 @@ public static partial class AgcExports
|
||||
private static bool TryParseSubmittedWait(
|
||||
CpuContext ctx,
|
||||
ulong packetAddress,
|
||||
uint packetLength,
|
||||
bool is64Bit,
|
||||
bool isStandard,
|
||||
out ulong waitAddress,
|
||||
@@ -4648,8 +4711,10 @@ public static partial class AgcExports
|
||||
return true;
|
||||
}
|
||||
|
||||
var legacyWait32 = !is64Bit && packetLength == 6;
|
||||
var controlOffset = is64Bit ? 28u : legacyWait32 ? 16u : 20u;
|
||||
if (!TryReadUInt64(ctx, packetAddress + 4, out waitAddress) ||
|
||||
!TryReadUInt32(ctx, packetAddress + (is64Bit ? 28u : 16u), out var control))
|
||||
!TryReadUInt32(ctx, packetAddress + controlOffset, out var control))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -4662,8 +4727,9 @@ public static partial class AgcExports
|
||||
TryReadUInt64(ctx, packetAddress + 20, out reference);
|
||||
}
|
||||
|
||||
var referenceOffset = legacyWait32 ? 20u : 16u;
|
||||
if (!TryReadUInt32(ctx, packetAddress + 12, out var mask32) ||
|
||||
!TryReadUInt32(ctx, packetAddress + 20, out var reference32))
|
||||
!TryReadUInt32(ctx, packetAddress + referenceOffset, out var reference32))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -4768,7 +4834,7 @@ public static partial class AgcExports
|
||||
bool tracePacket)
|
||||
{
|
||||
if (!TryParseSubmittedWait(
|
||||
ctx, packetAddress, is64Bit, isStandard,
|
||||
ctx, packetAddress, length, is64Bit, isStandard,
|
||||
out var waitAddress, out var reference, out var mask, out var compareFunction,
|
||||
out var controlValue))
|
||||
{
|
||||
@@ -6022,7 +6088,8 @@ public static partial class AgcExports
|
||||
binding,
|
||||
exportEvaluation.ImageBindings),
|
||||
binding.MipLevel ?? 0,
|
||||
binding.SamplerDescriptor));
|
||||
binding.SamplerDescriptor,
|
||||
Gen5ShaderTranslator.IsArrayedImageBinding(binding)));
|
||||
}
|
||||
|
||||
IReadOnlyList<Gen5VertexInputBinding> vertexInputs =
|
||||
@@ -6479,7 +6546,8 @@ public static partial class AgcExports
|
||||
texture,
|
||||
isStorage,
|
||||
binding.MipLevel ?? 0,
|
||||
binding.SamplerDescriptor));
|
||||
binding.SamplerDescriptor,
|
||||
Gen5ShaderTranslator.IsArrayedImageBinding(binding)));
|
||||
}
|
||||
|
||||
error = string.Empty;
|
||||
@@ -7450,6 +7518,7 @@ public static partial class AgcExports
|
||||
binding.IsStorage,
|
||||
binding.MipLevel,
|
||||
binding.SamplerDescriptor,
|
||||
binding.IsArrayed,
|
||||
out var texture))
|
||||
{
|
||||
textures.Add(texture);
|
||||
@@ -7845,7 +7914,10 @@ public static partial class AgcExports
|
||||
TextureDescriptor descriptor,
|
||||
uint sourceWidth,
|
||||
int logicalByteCount,
|
||||
byte[] source)
|
||||
byte[] source,
|
||||
bool baseMipInTail = false,
|
||||
int tailElementX = 0,
|
||||
int tailElementY = 0)
|
||||
{
|
||||
if (!GnmTiling.NeedsDetile(descriptor.TileMode) ||
|
||||
!TryGetTextureElementLayout(
|
||||
@@ -7858,6 +7930,48 @@ public static partial class AgcExports
|
||||
return null;
|
||||
}
|
||||
|
||||
if (baseMipInTail)
|
||||
{
|
||||
if (!GnmTiling.TryGetBlockElementDimensions(
|
||||
descriptor.TileMode,
|
||||
bytesPerElement,
|
||||
out var blockWidth,
|
||||
out var blockHeight))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var blockByteCount = (long)blockWidth * blockHeight * bytesPerElement;
|
||||
if (source.Length < blockByteCount ||
|
||||
(long)elementsWide * elementsHigh * bytesPerElement > logicalByteCount)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var blockLinear = new byte[blockByteCount];
|
||||
if (!GnmTiling.TryDetile(
|
||||
source,
|
||||
blockLinear,
|
||||
descriptor.TileMode,
|
||||
blockWidth,
|
||||
blockHeight,
|
||||
bytesPerElement))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tailLinear = new byte[logicalByteCount];
|
||||
var rowBytes = elementsWide * bytesPerElement;
|
||||
for (var y = 0; y < elementsHigh; y++)
|
||||
{
|
||||
var sourceOffset = (((long)tailElementY + y) * blockWidth + tailElementX) * bytesPerElement;
|
||||
blockLinear.AsSpan((int)sourceOffset, rowBytes)
|
||||
.CopyTo(tailLinear.AsSpan(y * rowBytes, rowBytes));
|
||||
}
|
||||
|
||||
return tailLinear;
|
||||
}
|
||||
|
||||
var linear = new byte[logicalByteCount];
|
||||
return GnmTiling.TryDetile(
|
||||
source,
|
||||
@@ -7895,18 +8009,23 @@ public static partial class AgcExports
|
||||
bool isStorage,
|
||||
uint mipLevel,
|
||||
IReadOnlyList<uint> samplerDescriptor,
|
||||
bool isArrayed,
|
||||
out GuestDrawTexture texture)
|
||||
{
|
||||
texture = default!;
|
||||
if ((descriptor.Type != Gen5TextureType1D &&
|
||||
descriptor.Type != Gen5TextureType2D) ||
|
||||
descriptor.Type != Gen5TextureType2D &&
|
||||
descriptor.Type != Gen5TextureType3D &&
|
||||
descriptor.Type != Gen5TextureTypeCube &&
|
||||
descriptor.Type != Gen5TextureType1DArray &&
|
||||
descriptor.Type != Gen5TextureType2DArray) ||
|
||||
descriptor.Width == 0 ||
|
||||
descriptor.Height == 0 ||
|
||||
descriptor.Width > 8192 ||
|
||||
descriptor.Height > 8192)
|
||||
{
|
||||
TraceTextureFallback(descriptor, "invalid-descriptor");
|
||||
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType);
|
||||
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType, isArrayed);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -7927,18 +8046,22 @@ public static partial class AgcExports
|
||||
TraceTextureFallback(
|
||||
descriptor,
|
||||
$"invalid-byte-count:{sourceByteCount}");
|
||||
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType);
|
||||
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType, isArrayed);
|
||||
return true;
|
||||
}
|
||||
|
||||
var physicalSourceByteCount = sourceByteCount;
|
||||
if (GnmTiling.NeedsDetile(descriptor.TileMode) &&
|
||||
var elementsWide = 0;
|
||||
var elementsHigh = 0;
|
||||
var bytesPerElement = 0;
|
||||
var hasElementLayout = GnmTiling.NeedsDetile(descriptor.TileMode) &&
|
||||
TryGetTextureElementLayout(
|
||||
descriptor,
|
||||
sourceWidth,
|
||||
out var elementsWide,
|
||||
out var elementsHigh,
|
||||
out var bytesPerElement) &&
|
||||
out elementsWide,
|
||||
out elementsHigh,
|
||||
out bytesPerElement);
|
||||
if (hasElementLayout &&
|
||||
GnmTiling.TryGetTiledByteCount(
|
||||
descriptor.TileMode,
|
||||
elementsWide,
|
||||
@@ -7952,13 +8075,51 @@ public static partial class AgcExports
|
||||
if (physicalSourceByteCount > MaxPresentedTextureBytes ||
|
||||
physicalSourceByteCount > int.MaxValue)
|
||||
{
|
||||
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType);
|
||||
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType, isArrayed);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isStorage &&
|
||||
var resourceMipLevels = descriptor.HasExtendedDescriptor
|
||||
? descriptor.ResourceMipLevels
|
||||
: 1u;
|
||||
var baseMipByteOffset = 0UL;
|
||||
var baseMipInTail = false;
|
||||
var mipTailElementX = 0;
|
||||
var mipTailElementY = 0;
|
||||
var chainSliceBytes = physicalSourceByteCount;
|
||||
if (hasElementLayout && resourceMipLevels > 1 &&
|
||||
GnmTiling.TryGetBaseMipPlacement(
|
||||
descriptor.TileMode,
|
||||
elementsWide,
|
||||
elementsHigh,
|
||||
bytesPerElement,
|
||||
resourceMipLevels,
|
||||
out baseMipByteOffset,
|
||||
out baseMipInTail,
|
||||
out mipTailElementX,
|
||||
out mipTailElementY,
|
||||
out var placedChainSliceBytes))
|
||||
{
|
||||
chainSliceBytes = placedChainSliceBytes;
|
||||
}
|
||||
|
||||
var wantsArrayUpload = isArrayed &&
|
||||
!isStorage &&
|
||||
descriptor.Address != 0 &&
|
||||
GuestGpu.Current.IsGpuGuestImageAvailable(
|
||||
(descriptor.Type == Gen5TextureType2DArray ||
|
||||
descriptor.Type == Gen5TextureType1DArray) &&
|
||||
descriptor.Depth > 1 &&
|
||||
!_arrayUploadUnsupported.ContainsKey(descriptor.Address);
|
||||
var arrayUploadLayers = wantsArrayUpload ? descriptor.Depth : 1u;
|
||||
|
||||
// Upload-known (not plain availability): the presenter's answer goes
|
||||
// generation-stale when the guest CPU rewrites a CPU-backed image
|
||||
// (video planes, streamed font atlases), which routes this draw back
|
||||
// through the texel copy below so the refresh path re-uploads.
|
||||
if (!isStorage &&
|
||||
!wantsArrayUpload &&
|
||||
descriptor.Address != 0 &&
|
||||
GuestGpu.Current.IsGuestImageUploadKnown(
|
||||
descriptor.Address,
|
||||
descriptor.Format,
|
||||
descriptor.NumberType))
|
||||
@@ -7979,7 +8140,8 @@ public static partial class AgcExports
|
||||
Pitch: sourceWidth,
|
||||
TileMode: descriptor.TileMode,
|
||||
DstSelect: descriptor.DstSelect,
|
||||
Sampler: ToGuestSampler(samplerDescriptor));
|
||||
Sampler: ToGuestSampler(samplerDescriptor),
|
||||
ArrayedView: isArrayed);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -8002,14 +8164,17 @@ public static partial class AgcExports
|
||||
// and run the same AddrLib-derived detile path used below for
|
||||
// sampled textures before seeding the Vulkan image.
|
||||
var storageSource = new byte[(int)physicalSourceByteCount];
|
||||
if (ctx.Memory.TryRead(descriptor.Address, storageSource))
|
||||
if (ctx.Memory.TryRead(descriptor.Address + baseMipByteOffset, storageSource))
|
||||
{
|
||||
readSucceeded = true;
|
||||
var linearStorage = TryDetileTextureSource(
|
||||
descriptor,
|
||||
sourceWidth,
|
||||
checked((int)sourceByteCount),
|
||||
storageSource) ?? storageSource
|
||||
storageSource,
|
||||
baseMipInTail,
|
||||
mipTailElementX,
|
||||
mipTailElementY) ?? storageSource
|
||||
.AsSpan(0, checked((int)sourceByteCount))
|
||||
.ToArray();
|
||||
if (linearStorage.AsSpan().IndexOfAnyExcept((byte)0) >= 0)
|
||||
@@ -8065,6 +8230,19 @@ public static partial class AgcExports
|
||||
// (skipping would leave the draw with no pixels and a fallback
|
||||
// texture for the frame — visible flicker on animated textures).
|
||||
var sampler = ToGuestSampler(samplerDescriptor);
|
||||
// Track the guest allocation before reading its texels so a CPU
|
||||
// rewrite landing after the copy still bumps the write generation.
|
||||
// The generation rides on the texture and is recorded by the
|
||||
// presenter after upload, where the upload-known skip compares it
|
||||
// against the tracker to force fresh texels for rewritten memory.
|
||||
SharpEmu.HLE.GuestImageWriteTracker.Track(
|
||||
descriptor.Address,
|
||||
physicalSourceByteCount,
|
||||
source: "agc.decoded-texture");
|
||||
var hasWriteGeneration =
|
||||
SharpEmu.HLE.GuestImageWriteTracker.TryGetWriteGeneration(
|
||||
descriptor.Address,
|
||||
out var writeGeneration);
|
||||
if (!_textureCopySkipDisabled &&
|
||||
descriptor.Address != 0 &&
|
||||
!SharpEmu.HLE.GuestImageWriteTracker.PeekDirty(descriptor.Address) &&
|
||||
@@ -8078,7 +8256,9 @@ public static partial class AgcExports
|
||||
descriptor.DstSelect,
|
||||
descriptor.TileMode,
|
||||
sourceWidth,
|
||||
sampler)))
|
||||
sampler,
|
||||
isArrayed,
|
||||
arrayUploadLayers)))
|
||||
{
|
||||
texture = new GuestDrawTexture(
|
||||
descriptor.Address,
|
||||
@@ -8096,17 +8276,79 @@ public static partial class AgcExports
|
||||
Pitch: sourceWidth,
|
||||
TileMode: descriptor.TileMode,
|
||||
DstSelect: descriptor.DstSelect,
|
||||
Sampler: sampler);
|
||||
Sampler: sampler,
|
||||
ArrayedView: isArrayed,
|
||||
ArrayLayers: arrayUploadLayers);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (wantsArrayUpload)
|
||||
{
|
||||
var arrayLayers = arrayUploadLayers;
|
||||
var layerBytes = checked((int)sourceByteCount);
|
||||
var totalBytes = (long)layerBytes * arrayLayers;
|
||||
if (totalBytes <= int.MaxValue)
|
||||
{
|
||||
var layered = new byte[totalBytes];
|
||||
var uploadedLayers = 0u;
|
||||
for (var layer = 0u; layer < arrayLayers; layer++)
|
||||
{
|
||||
var sliceSource = new byte[(int)physicalSourceByteCount];
|
||||
if (!ctx.Memory.TryRead(
|
||||
descriptor.Address + layer * chainSliceBytes + baseMipByteOffset,
|
||||
sliceSource))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var sliceLinear = TryDetileTextureSource(
|
||||
descriptor,
|
||||
sourceWidth,
|
||||
layerBytes,
|
||||
sliceSource,
|
||||
baseMipInTail,
|
||||
mipTailElementX,
|
||||
mipTailElementY) ?? sliceSource.AsSpan(0, layerBytes).ToArray();
|
||||
sliceLinear.AsSpan(0, layerBytes)
|
||||
.CopyTo(layered.AsSpan(checked((int)(layer * layerBytes))));
|
||||
uploadedLayers++;
|
||||
}
|
||||
|
||||
if (uploadedLayers == arrayLayers)
|
||||
{
|
||||
texture = new GuestDrawTexture(
|
||||
descriptor.Address,
|
||||
descriptor.Width,
|
||||
descriptor.Height,
|
||||
descriptor.Format,
|
||||
descriptor.NumberType,
|
||||
layered,
|
||||
IsFallback: false,
|
||||
IsStorage: false,
|
||||
MipLevels: descriptor.MipLevels,
|
||||
MipLevel: mipLevel,
|
||||
BaseMipLevel: descriptor.ViewBaseLevel,
|
||||
ResourceMipLevels: descriptor.ResourceMipLevels,
|
||||
Pitch: sourceWidth,
|
||||
TileMode: descriptor.TileMode,
|
||||
DstSelect: descriptor.DstSelect,
|
||||
Sampler: sampler,
|
||||
ArrayedView: true,
|
||||
ArrayLayers: arrayLayers);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
_arrayUploadUnsupported.TryAdd(descriptor.Address, 0);
|
||||
}
|
||||
|
||||
var source = new byte[(int)physicalSourceByteCount];
|
||||
if (!ctx.Memory.TryRead(descriptor.Address, source))
|
||||
if (!ctx.Memory.TryRead(descriptor.Address + baseMipByteOffset, source))
|
||||
{
|
||||
TraceTextureFallback(
|
||||
descriptor,
|
||||
$"guest-read-failed:{sourceByteCount}");
|
||||
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType);
|
||||
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType, isArrayed);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -8138,7 +8380,10 @@ public static partial class AgcExports
|
||||
descriptor,
|
||||
sourceWidth,
|
||||
checked((int)sourceByteCount),
|
||||
source) ?? source.AsSpan(0, checked((int)sourceByteCount)).ToArray();
|
||||
source,
|
||||
baseMipInTail,
|
||||
mipTailElementX,
|
||||
mipTailElementY) ?? source.AsSpan(0, checked((int)sourceByteCount)).ToArray();
|
||||
DumpLinearTextureIfRequested(descriptor, sourceWidth, rgba);
|
||||
texture = new GuestDrawTexture(
|
||||
descriptor.Address,
|
||||
@@ -8156,7 +8401,9 @@ public static partial class AgcExports
|
||||
Pitch: sourceWidth,
|
||||
TileMode: descriptor.TileMode,
|
||||
DstSelect: descriptor.DstSelect,
|
||||
Sampler: ToGuestSampler(samplerDescriptor));
|
||||
Sampler: ToGuestSampler(samplerDescriptor),
|
||||
WriteGeneration: hasWriteGeneration ? writeGeneration : -1,
|
||||
ArrayedView: isArrayed);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -8435,7 +8682,8 @@ public static partial class AgcExports
|
||||
private static GuestDrawTexture CreateFallbackGuestDrawTexture(
|
||||
bool isStorage,
|
||||
uint format,
|
||||
uint numberType)
|
||||
uint numberType,
|
||||
bool isArrayed = false)
|
||||
{
|
||||
var fallbackFormat = format == 0 ? 10u : format;
|
||||
var fallbackNumberType = numberType;
|
||||
@@ -8449,7 +8697,8 @@ public static partial class AgcExports
|
||||
IsFallback: true,
|
||||
IsStorage: isStorage,
|
||||
MipLevels: 1,
|
||||
MipLevel: 0);
|
||||
MipLevel: 0,
|
||||
ArrayedView: isArrayed);
|
||||
}
|
||||
|
||||
private static GuestSampler ToGuestSampler(IReadOnlyList<uint> descriptor) =>
|
||||
@@ -8825,7 +9074,8 @@ public static partial class AgcExports
|
||||
texture,
|
||||
isStorage,
|
||||
binding.MipLevel ?? 0,
|
||||
binding.SamplerDescriptor));
|
||||
binding.SamplerDescriptor,
|
||||
Gen5ShaderTranslator.IsArrayedImageBinding(binding)));
|
||||
hasStorageBinding |= isStorage;
|
||||
|
||||
var descriptorState = descriptorValid ? string.Empty : "/invalid-desc";
|
||||
@@ -10783,6 +11033,23 @@ public static partial class AgcExports
|
||||
((op & 0xFFu) << 8) |
|
||||
((register & 0x3Fu) << 2);
|
||||
|
||||
private static uint EncodeWaitRegMemPoll(uint pollCycles) =>
|
||||
Math.Min(pollCycles >> 4, 0xFFFFu);
|
||||
|
||||
private static uint EncodeWaitRegMem32Control(uint compareFunction, uint operation, uint cachePolicy) =>
|
||||
0x10u |
|
||||
(compareFunction & 0x7u) |
|
||||
((operation & 0x3u) << 8) |
|
||||
((operation & 0xCu) << 4) |
|
||||
((cachePolicy & 0x3u) << 25);
|
||||
|
||||
private static uint EncodeWaitRegMem64Control(uint compareFunction, uint operation, uint cachePolicy) =>
|
||||
0x10u |
|
||||
(compareFunction & 0x7u) |
|
||||
((operation & 0x1u) << 8) |
|
||||
((operation & 0x6u) << 5) |
|
||||
((cachePolicy & 0x3u) << 25);
|
||||
|
||||
private static uint Pm4Length(uint header) =>
|
||||
((header >> 16) & 0x3FFFu) + 2u;
|
||||
|
||||
@@ -11237,16 +11504,21 @@ public static partial class AgcExports
|
||||
public static int DcbSetPredication(CpuContext ctx)
|
||||
{
|
||||
var dcb = ctx[CpuRegister.Rdi];
|
||||
var address = ctx[CpuRegister.Rsi];
|
||||
var condition = (uint)(ctx[CpuRegister.Rsi] & 1u);
|
||||
var operation = (uint)(ctx[CpuRegister.Rdx] & 0x7u);
|
||||
var waitOperation = (uint)(ctx[CpuRegister.Rcx] & 1u);
|
||||
var address = ctx[CpuRegister.R8];
|
||||
if (dcb == 0)
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
if (!TryAllocateCommandDwords(ctx, dcb, 3, out var cmd) ||
|
||||
!ctx.TryWriteUInt32(cmd, Pm4(3, ItNop, RZero)) ||
|
||||
!ctx.TryWriteUInt32(cmd + 4, (uint)(address & 0xFFFF_FFFFUL)) ||
|
||||
!ctx.TryWriteUInt32(cmd + 8, (uint)(address >> 32)))
|
||||
var flags = (condition << 8) | (waitOperation << 12) | (operation << 16);
|
||||
if (!TryAllocateCommandDwords(ctx, dcb, 4, out var cmd) ||
|
||||
!ctx.TryWriteUInt32(cmd, Pm4(4, ItSetPredication, RZero)) ||
|
||||
!ctx.TryWriteUInt32(cmd + 4, flags) ||
|
||||
!ctx.TryWriteUInt32(cmd + 8, (uint)address & 0xFFFF_FFF0u) ||
|
||||
!ctx.TryWriteUInt32(cmd + 12, (uint)(address >> 32)))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
@@ -11261,9 +11533,17 @@ public static partial class AgcExports
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int SetPacketPredication(CpuContext ctx)
|
||||
{
|
||||
// Global predication toggle on a packet; a no-op is safe for rendering.
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
var packetAddress = ctx[CpuRegister.Rdi];
|
||||
var predication = ctx[CpuRegister.Rsi];
|
||||
if (packetAddress == 0 || !TryReadUInt32(ctx, packetAddress, out var header))
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
header = (header & ~1u) | (predication == 1 ? 1u : 0u);
|
||||
return !ctx.TryWriteUInt32(packetAddress, header)
|
||||
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT)
|
||||
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
// ABI (reversed from Quake): rdi = array of DCB base addresses (u64 each),
|
||||
@@ -11438,7 +11718,7 @@ public static partial class AgcExports
|
||||
uint owner;
|
||||
lock (state.Gate)
|
||||
{
|
||||
if (!state.ResourceRegistrationInitialized ||
|
||||
if (state.ResourceRegistrationInitialized &&
|
||||
state.ResourceRegistrationMaxOwners != 0 &&
|
||||
state.ResourceOwners.Count >= state.ResourceRegistrationMaxOwners)
|
||||
{
|
||||
@@ -11475,6 +11755,69 @@ public static partial class AgcExports
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
private static int RemoveResourcesForOwner(SubmittedGpuState state, uint owner)
|
||||
{
|
||||
var stale = new List<uint>();
|
||||
foreach (var (handle, resource) in state.RegisteredResources)
|
||||
{
|
||||
if (resource.Owner == owner)
|
||||
{
|
||||
stale.Add(handle);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var handle in stale)
|
||||
{
|
||||
state.RegisteredResources.Remove(handle);
|
||||
}
|
||||
|
||||
return stale.Count;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "ZLJk9r2+2Aw",
|
||||
ExportName = "sceAgcDriverUnregisterOwnerAndResources",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int DriverUnregisterOwnerAndResources(CpuContext ctx)
|
||||
{
|
||||
var owner = (uint)ctx[CpuRegister.Rdi];
|
||||
var state = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
|
||||
int resources;
|
||||
lock (state.Gate)
|
||||
{
|
||||
if (!state.ResourceOwners.Remove(owner))
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
resources = RemoveResourcesForOwner(state, owner);
|
||||
state.ComputeQueues.Remove(owner);
|
||||
}
|
||||
|
||||
TraceAgc($"agc.driver_unregister_owner owner={owner} resources={resources}");
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "SCoAN5fYlUM",
|
||||
ExportName = "sceAgcDriverUnregisterAllResourcesForOwner",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int DriverUnregisterAllResourcesForOwner(CpuContext ctx)
|
||||
{
|
||||
var owner = (uint)ctx[CpuRegister.Rdi];
|
||||
var state = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
|
||||
int resources;
|
||||
lock (state.Gate)
|
||||
{
|
||||
resources = RemoveResourcesForOwner(state, owner);
|
||||
}
|
||||
|
||||
TraceAgc($"agc.driver_unregister_owner_resources owner={owner} resources={resources}");
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "pWLG7WOpVcw",
|
||||
ExportName = "sceAgcDriverUnregisterResource",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace SharpEmu.Libs.Agc;
|
||||
|
||||
/// <summary>
|
||||
@@ -16,8 +19,11 @@ namespace SharpEmu.Libs.Agc;
|
||||
/// other D/R and pipe/bank-XOR modes stay opt-in while their complete AddrLib
|
||||
/// equations are being ported.
|
||||
/// </summary>
|
||||
internal static class GnmTiling
|
||||
internal static unsafe class GnmTiling
|
||||
{
|
||||
private const int ParallelDetileElementThreshold = 512 * 512;
|
||||
private const int MaxDetileWorkers = 4;
|
||||
|
||||
// Oberon uses the 16-pipe / 8-pixel-packer RB+ topology. These are the
|
||||
// single-sample 64 KiB equations generated by AMD AddrLib for that exact
|
||||
// topology. Each entry describes one address bit as an XOR of X/Y bits.
|
||||
@@ -118,6 +124,14 @@ internal static class GnmTiling
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static readonly HashSet<uint> _reportedModes = new();
|
||||
private static readonly ConcurrentDictionary<(uint SwizzleMode, int BppLog2), PatternTerms>
|
||||
_patternTermCache = new();
|
||||
private static readonly ConcurrentDictionary<(SwizzleKind Kind, int Width, int Height), int[]>
|
||||
_blockTableCache = new();
|
||||
private static readonly ParallelOptions _parallelDetileOptions = new()
|
||||
{
|
||||
MaxDegreeOfParallelism = Math.Min(MaxDetileWorkers, Environment.ProcessorCount),
|
||||
};
|
||||
|
||||
public static bool Enabled => _enabled || !_disabled;
|
||||
|
||||
@@ -194,6 +208,164 @@ internal static class GnmTiling
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetBlockElementDimensions(
|
||||
uint swizzleMode,
|
||||
int bytesPerElement,
|
||||
out int blockWidth,
|
||||
out int blockHeight)
|
||||
{
|
||||
blockWidth = 0;
|
||||
blockHeight = 0;
|
||||
if (bytesPerElement <= 0 ||
|
||||
!TryGetSwizzleKind(swizzleMode, out _, out var blockBytes))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var bppLog2 = BitLog2((uint)bytesPerElement);
|
||||
if (bppLog2 < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
(blockWidth, blockHeight) = SquareBlockDimensions(blockBytes >> bppLog2);
|
||||
return blockWidth != 0 && blockHeight != 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locates mip 0 in a GFX10 mip chain, which AddrLib stores smallest-first
|
||||
/// (Gfx10Lib::ComputeSurfaceInfoMacroTiled/MicroTiled).
|
||||
/// </summary>
|
||||
public static bool TryGetBaseMipPlacement(
|
||||
uint swizzleMode,
|
||||
int elementsWide,
|
||||
int elementsHigh,
|
||||
int bytesPerElement,
|
||||
uint resourceMipLevels,
|
||||
out ulong byteOffset,
|
||||
out bool inMipTail,
|
||||
out int tailElementX,
|
||||
out int tailElementY,
|
||||
out ulong chainSliceBytes)
|
||||
{
|
||||
byteOffset = 0;
|
||||
inMipTail = false;
|
||||
tailElementX = 0;
|
||||
tailElementY = 0;
|
||||
chainSliceBytes = 0;
|
||||
if (resourceMipLevels <= 1 ||
|
||||
!ShouldDetile(swizzleMode) ||
|
||||
elementsWide <= 0 ||
|
||||
elementsHigh <= 0 ||
|
||||
bytesPerElement <= 0 ||
|
||||
!TryGetSwizzleKind(swizzleMode, out _, out var blockBytes))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var bppLog2 = BitLog2((uint)bytesPerElement);
|
||||
if (bppLog2 < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var (blockWidth, blockHeight) = SquareBlockDimensions(blockBytes >> bppLog2);
|
||||
var blockSizeLog2 = BitLog2((uint)blockBytes);
|
||||
if (blockWidth == 0 || blockHeight == 0 || blockSizeLog2 < 8)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var mipLevels = (int)Math.Min(resourceMipLevels, 16u);
|
||||
var maxMipsInTail = blockSizeLog2 <= 8 ? 0
|
||||
: blockSizeLog2 <= 11
|
||||
? 1 + (1 << (blockSizeLog2 - 9))
|
||||
: blockSizeLog2 - 4;
|
||||
var tailWidth = (blockSizeLog2 & 1) != 0 ? blockWidth >> 1 : blockWidth;
|
||||
var tailHeight = (blockSizeLog2 & 1) != 0 ? blockHeight : blockHeight >> 1;
|
||||
|
||||
var firstMipInTail = mipLevels;
|
||||
var mipSizes = new ulong[mipLevels];
|
||||
for (var i = 0; i < mipLevels; i++)
|
||||
{
|
||||
var mipWidth = Math.Max(elementsWide >> i, 1);
|
||||
var mipHeight = Math.Max(elementsHigh >> i, 1);
|
||||
if (maxMipsInTail > 0 &&
|
||||
mipWidth <= tailWidth &&
|
||||
mipHeight <= tailHeight &&
|
||||
mipLevels - i <= maxMipsInTail)
|
||||
{
|
||||
firstMipInTail = i;
|
||||
break;
|
||||
}
|
||||
|
||||
var alignedWidth = (ulong)(mipWidth + blockWidth - 1) / (ulong)blockWidth * (ulong)blockWidth;
|
||||
var alignedHeight = (ulong)(mipHeight + blockHeight - 1) / (ulong)blockHeight * (ulong)blockHeight;
|
||||
mipSizes[i] = alignedWidth * alignedHeight * (ulong)bytesPerElement;
|
||||
}
|
||||
|
||||
if (firstMipInTail == 0)
|
||||
{
|
||||
var m = maxMipsInTail - 1;
|
||||
var mipOffset = m > 6 ? 16 << m : m << 8;
|
||||
var mipX = ((mipOffset >> 9) & 1) |
|
||||
((mipOffset >> 10) & 2) |
|
||||
((mipOffset >> 11) & 4) |
|
||||
((mipOffset >> 12) & 8) |
|
||||
((mipOffset >> 13) & 16) |
|
||||
((mipOffset >> 14) & 32);
|
||||
var mipY = ((mipOffset >> 8) & 1) |
|
||||
((mipOffset >> 9) & 2) |
|
||||
((mipOffset >> 10) & 4) |
|
||||
((mipOffset >> 11) & 8) |
|
||||
((mipOffset >> 12) & 16) |
|
||||
((mipOffset >> 13) & 32);
|
||||
if ((blockSizeLog2 & 1) != 0)
|
||||
{
|
||||
(mipX, mipY) = (mipY, mipX);
|
||||
if ((bppLog2 & 1) != 0)
|
||||
{
|
||||
mipY = (mipY << 1) | (mipX & 1);
|
||||
mipX >>= 1;
|
||||
}
|
||||
}
|
||||
|
||||
var (microWidth, microHeight) = SquareBlockDimensions(256 >> bppLog2);
|
||||
if (microWidth == 0 || microHeight == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
tailElementX = mipX * microWidth;
|
||||
tailElementY = mipY * microHeight;
|
||||
if (tailElementX + elementsWide > blockWidth ||
|
||||
tailElementY + elementsHigh > blockHeight)
|
||||
{
|
||||
tailElementX = 0;
|
||||
tailElementY = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
inMipTail = true;
|
||||
chainSliceBytes = (ulong)blockBytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
byteOffset = firstMipInTail < mipLevels ? (ulong)blockBytes : 0;
|
||||
chainSliceBytes = byteOffset;
|
||||
for (var i = firstMipInTail - 1; i >= 1; i--)
|
||||
{
|
||||
byteOffset += mipSizes[i];
|
||||
}
|
||||
|
||||
for (var i = 0; i < firstMipInTail; i++)
|
||||
{
|
||||
chainSliceBytes += mipSizes[i];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deswizzles <paramref name="tiled"/> into linear row-major order.
|
||||
/// Elements are pixels for uncompressed formats and 4x4 blocks for
|
||||
@@ -246,50 +418,83 @@ internal static class GnmTiling
|
||||
return false;
|
||||
}
|
||||
|
||||
// Precompute the within-block element offset for each (x, y) inside a
|
||||
// single block. The swizzle equation only depends on the in-block
|
||||
// coordinates, so this table is reused for every block — turning the
|
||||
// per-pixel bit-interleave (a loop + calls) into a single array lookup.
|
||||
// Detiling a 2048x2048 texture is millions of elements; without this the
|
||||
// per-pixel math makes DETILE unusably slow during asset streaming.
|
||||
// Address tables depend only on the swizzle equation and element size,
|
||||
// so retain them across textures instead of rebuilding them per upload.
|
||||
var hasExactXorPattern = TryGetExactXorPattern(swizzleMode, bppLog2, out var xorPattern);
|
||||
var blockTable = hasExactXorPattern ? [] : new int[blockWidth * blockHeight];
|
||||
for (var by = 0; !hasExactXorPattern && by < blockHeight; by++)
|
||||
{
|
||||
for (var bx = 0; bx < blockWidth; bx++)
|
||||
{
|
||||
blockTable[by * blockWidth + bx] = (int)(kind == SwizzleKind.ZOrder
|
||||
? MortonInterleave((uint)bx, (uint)by, blockWidth, blockHeight)
|
||||
: StandardSwizzleOffset((uint)bx, (uint)by, blockWidth, blockHeight));
|
||||
}
|
||||
}
|
||||
var patternTerms = hasExactXorPattern
|
||||
? _patternTermCache.GetOrAdd(
|
||||
(swizzleMode, bppLog2),
|
||||
_ => CreatePatternTerms(xorPattern))
|
||||
: default;
|
||||
var blockTable = hasExactXorPattern
|
||||
? []
|
||||
: _blockTableCache.GetOrAdd(
|
||||
(kind, blockWidth, blockHeight),
|
||||
static key => CreateBlockTable(key.Kind, key.Width, key.Height));
|
||||
|
||||
for (var y = 0; y < elementsHigh; y++)
|
||||
// The XOR equation offset factors cleanly into independent X and Y
|
||||
// fields — each output bit is parity(x & XMask) XOR parity(y & YMask),
|
||||
// and parity distributes over XOR, so offset(x, y) == xTerm(x) ^ yTerm(y).
|
||||
// Exact equations repeat at a small power-of-two period. Cached axis
|
||||
// terms reduce the inner loop to two array loads and one XOR.
|
||||
fixed (byte* tiledPointer = tiled)
|
||||
fixed (byte* linearPointer = linear)
|
||||
{
|
||||
var blockY = y / blockHeight;
|
||||
var inBlockY = y % blockHeight;
|
||||
var rowBlockBase = (long)blockY * blocksPerRow;
|
||||
var tableRowBase = inBlockY * blockWidth;
|
||||
var destRowBase = (long)y * elementsWide * bytesPerElement;
|
||||
for (var x = 0; x < elementsWide; x++)
|
||||
var sourceAddress = (nint)tiledPointer;
|
||||
var destinationAddress = (nint)linearPointer;
|
||||
var sourceLength = tiled.Length;
|
||||
var destinationLength = linear.Length;
|
||||
var blockWidthShift = BitLog2((uint)blockWidth);
|
||||
var blockWidthMask = blockWidth - 1;
|
||||
var detileRow = (int y) =>
|
||||
{
|
||||
var blockX = x / blockWidth;
|
||||
var inBlockX = x % blockWidth;
|
||||
|
||||
var blockIndex = rowBlockBase + blockX;
|
||||
var sourceByte = hasExactXorPattern
|
||||
? blockIndex * blockBytes + ComputePatternOffset((uint)x, (uint)y, xorPattern)
|
||||
: (blockIndex * blockElements + blockTable[tableRowBase + inBlockX]) *
|
||||
(long)bytesPerElement;
|
||||
var destByte = destRowBase + (long)x * bytesPerElement;
|
||||
if (sourceByte + bytesPerElement > tiled.Length ||
|
||||
destByte + bytesPerElement > linear.Length)
|
||||
var blockY = y / blockHeight;
|
||||
var inBlockY = y & (blockHeight - 1);
|
||||
var rowBlockBase = (long)blockY * blocksPerRow;
|
||||
var tableRowBase = inBlockY * blockWidth;
|
||||
var destRowBase = (long)y * elementsWide * bytesPerElement;
|
||||
var yTerm = hasExactXorPattern
|
||||
? patternTerms.Y[y & patternTerms.YMask]
|
||||
: 0;
|
||||
for (var x = 0; x < elementsWide; x++)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var blockX = x >> blockWidthShift;
|
||||
var inBlockX = x & blockWidthMask;
|
||||
var blockIndex = rowBlockBase + blockX;
|
||||
var sourceByte = hasExactXorPattern
|
||||
? blockIndex * blockBytes + (patternTerms.X[x & patternTerms.XMask] ^ yTerm)
|
||||
: (blockIndex * blockElements + blockTable[tableRowBase + inBlockX]) *
|
||||
(long)bytesPerElement;
|
||||
var destByte = destRowBase + (long)x * bytesPerElement;
|
||||
if (sourceByte < 0 ||
|
||||
sourceByte + bytesPerElement > sourceLength ||
|
||||
destByte + bytesPerElement > destinationLength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
tiled.Slice((int)sourceByte, bytesPerElement)
|
||||
.CopyTo(linear.Slice((int)destByte, bytesPerElement));
|
||||
CopyElement(
|
||||
(byte*)sourceAddress + sourceByte,
|
||||
(byte*)destinationAddress + destByte,
|
||||
bytesPerElement);
|
||||
}
|
||||
};
|
||||
|
||||
var elementCount = (long)elementsWide * elementsHigh;
|
||||
if (elementCount >= ParallelDetileElementThreshold && Environment.ProcessorCount > 1)
|
||||
{
|
||||
Parallel.For(
|
||||
0,
|
||||
elementsHigh,
|
||||
_parallelDetileOptions,
|
||||
detileRow);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var y = 0; y < elementsHigh; y++)
|
||||
{
|
||||
detileRow(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +507,80 @@ internal static class GnmTiling
|
||||
ZOrder,
|
||||
}
|
||||
|
||||
private readonly record struct PatternTerms(int[] X, int XMask, int[] Y, int YMask);
|
||||
|
||||
private static PatternTerms CreatePatternTerms(AddressBit[] pattern)
|
||||
{
|
||||
uint xMask = 0;
|
||||
uint yMask = 0;
|
||||
foreach (var bit in pattern)
|
||||
{
|
||||
xMask |= bit.XMask;
|
||||
yMask |= bit.YMask;
|
||||
}
|
||||
|
||||
var xLength = AxisTermPeriod(xMask);
|
||||
var yLength = AxisTermPeriod(yMask);
|
||||
var xTerms = new int[xLength];
|
||||
var yTerms = new int[yLength];
|
||||
for (var x = 0; x < xTerms.Length; x++)
|
||||
{
|
||||
xTerms[x] = (int)PatternAxisTerm((uint)x, pattern, useX: true);
|
||||
}
|
||||
|
||||
for (var y = 0; y < yTerms.Length; y++)
|
||||
{
|
||||
yTerms[y] = (int)PatternAxisTerm((uint)y, pattern, useX: false);
|
||||
}
|
||||
|
||||
return new PatternTerms(xTerms, xLength - 1, yTerms, yLength - 1);
|
||||
}
|
||||
|
||||
private static int AxisTermPeriod(uint mask) =>
|
||||
mask == 0 ? 1 : 1 << (32 - System.Numerics.BitOperations.LeadingZeroCount(mask));
|
||||
|
||||
private static int[] CreateBlockTable(SwizzleKind kind, int blockWidth, int blockHeight)
|
||||
{
|
||||
var table = new int[blockWidth * blockHeight];
|
||||
for (var y = 0; y < blockHeight; y++)
|
||||
{
|
||||
for (var x = 0; x < blockWidth; x++)
|
||||
{
|
||||
table[y * blockWidth + x] = (int)(kind == SwizzleKind.ZOrder
|
||||
? MortonInterleave((uint)x, (uint)y, blockWidth, blockHeight)
|
||||
: StandardSwizzleOffset((uint)x, (uint)y, blockWidth, blockHeight));
|
||||
}
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void CopyElement(byte* source, byte* destination, int bytesPerElement)
|
||||
{
|
||||
switch (bytesPerElement)
|
||||
{
|
||||
case 1:
|
||||
*destination = *source;
|
||||
break;
|
||||
case 2:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<ushort>(source));
|
||||
break;
|
||||
case 4:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<uint>(source));
|
||||
break;
|
||||
case 8:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<ulong>(source));
|
||||
break;
|
||||
case 16:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<UInt128>(source));
|
||||
break;
|
||||
default:
|
||||
Unsafe.CopyBlockUnaligned(destination, source, (uint)bytesPerElement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly AddressBit Zero = new(0, 0);
|
||||
|
||||
private static AddressBit X(int bit) => new(1u << bit, 0);
|
||||
@@ -335,14 +614,20 @@ internal static class GnmTiling
|
||||
return pattern.Length != 0;
|
||||
}
|
||||
|
||||
private static long ComputePatternOffset(uint x, uint y, AddressBit[] pattern)
|
||||
// The AddrLib within-block byte offset is a per-bit XOR equation:
|
||||
// offset = OR over bits of ( parity(x & XMask) XOR parity(y & YMask) ) << bit
|
||||
// Because parity distributes over XOR, that whole offset factors into two
|
||||
// independent axis terms: PatternAxisTerm(x, useX: true) ^
|
||||
// PatternAxisTerm(y, useX: false). Splitting the axes lets TryDetile cache
|
||||
// the X term per column and hoist the Y term per row instead of recomputing
|
||||
// the full 16-bit interleave (32 PopCounts) for every element.
|
||||
private static uint PatternAxisTerm(uint coordinate, AddressBit[] pattern, bool useX)
|
||||
{
|
||||
uint offset = 0;
|
||||
for (var bit = 0; bit < pattern.Length; bit++)
|
||||
{
|
||||
var equation = pattern[bit];
|
||||
var parity = (System.Numerics.BitOperations.PopCount(x & equation.XMask) +
|
||||
System.Numerics.BitOperations.PopCount(y & equation.YMask)) & 1;
|
||||
var mask = useX ? pattern[bit].XMask : pattern[bit].YMask;
|
||||
var parity = System.Numerics.BitOperations.PopCount(coordinate & mask) & 1;
|
||||
offset |= (uint)parity << bit;
|
||||
}
|
||||
|
||||
|
||||
@@ -343,6 +343,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)
|
||||
|
||||
@@ -340,6 +340,18 @@ public static class AmprExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "4fgtGfXDrFc",
|
||||
ExportName = "sceAmprMeasureCommandSizeWriteAddress_04_00",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAmpr")]
|
||||
public static int MeasureCommandSizeWriteAddress0400(CpuContext ctx)
|
||||
{
|
||||
TraceAmpr(ctx, "measure_write_address", 0, WriteAddressRecordSize, 0);
|
||||
ctx[CpuRegister.Rax] = WriteAddressRecordSize;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "tZDDEo2tE5k",
|
||||
ExportName = "sceAmprCommandBufferGetSize",
|
||||
@@ -509,6 +521,32 @@ public static class AmprExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "j0+3uJMxYJY",
|
||||
ExportName = "sceAmprCommandBufferWriteAddress_04_00",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAmpr")]
|
||||
public static int CommandBufferWriteAddress0400(CpuContext ctx)
|
||||
{
|
||||
var commandBuffer = ctx[CpuRegister.Rdi];
|
||||
var address = ctx[CpuRegister.Rsi];
|
||||
var value = ctx[CpuRegister.Rdx];
|
||||
|
||||
if (commandBuffer == 0 || address == 0)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (!AppendWriteAddressRecord(ctx, commandBuffer, address, value))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
TraceAmpr(ctx, "write_address", commandBuffer, address, value);
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
public static int CompleteCommandBuffer(CpuContext ctx, ulong commandBuffer)
|
||||
{
|
||||
if (commandBuffer == 0)
|
||||
|
||||
@@ -17,7 +17,7 @@ public static class AjmExports
|
||||
private const int OrbisAjmErrorCodecAlreadyRegistered = unchecked((int)0x80930009);
|
||||
private const int OrbisAjmErrorCodecNotRegistered = unchecked((int)0x8093000A);
|
||||
private const int OrbisAjmErrorWrongRevisionFlag = unchecked((int)0x8093000B);
|
||||
private const uint MaxCodecType = 23;
|
||||
private const uint MaxCodecType = 25;
|
||||
private const int MaxInstanceIndex = 0x2FFF;
|
||||
private static readonly ConcurrentDictionary<uint, AjmContextState> Contexts = new();
|
||||
private static int _nextContextId;
|
||||
|
||||
@@ -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,15 +18,43 @@ 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;
|
||||
|
||||
internal static bool IsHostPlaybackActive
|
||||
{
|
||||
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
|
||||
@@ -37,109 +65,106 @@ internal static class Bink2MovieBridge
|
||||
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
|
||||
ResolveMode() == MovieMode.Skip;
|
||||
|
||||
internal static void ObserveGuestMovie(string hostPath)
|
||||
/// <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;
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
if (string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
return _playback is not null || _frameBuffer is not null;
|
||||
}
|
||||
|
||||
var mode = ResolveMode();
|
||||
if (mode == MovieMode.Dummy)
|
||||
if (mode is MovieMode.Guest or MovieMode.Skip)
|
||||
{
|
||||
AttachDummyMovieLocked(hostPath);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mode != MovieMode.Native)
|
||||
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);
|
||||
}
|
||||
|
||||
var adapter = GetAdapterLocked();
|
||||
if (adapter is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -152,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");
|
||||
@@ -170,15 +241,22 @@ internal static class Bink2MovieBridge
|
||||
return MovieMode.Skip;
|
||||
}
|
||||
|
||||
// Prefer the optional host adapter when one is supplied. Otherwise let
|
||||
// the game's statically linked Bink implementation consume the file.
|
||||
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.Guest;
|
||||
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)
|
||||
@@ -195,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;
|
||||
}
|
||||
@@ -219,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;
|
||||
}
|
||||
@@ -244,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;
|
||||
@@ -343,66 +404,222 @@ internal static class Bink2MovieBridge
|
||||
Skip,
|
||||
Dummy,
|
||||
Native,
|
||||
Ffmpeg,
|
||||
}
|
||||
|
||||
private sealed class NativeAdapter
|
||||
private static readonly Queue<string> PendingMoviePaths = new();
|
||||
private static readonly HashSet<string> PendingMoviePathSet =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private static void AttachNextQueuedMovieLocked()
|
||||
{
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int OpenUtf8Delegate(IntPtr pathUtf8, out IntPtr movie, out Bink2MovieInfo info);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int DecodeNextBgraDelegate(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void CloseDelegate(IntPtr movie);
|
||||
|
||||
private readonly OpenUtf8Delegate _openUtf8;
|
||||
private readonly DecodeNextBgraDelegate _decodeNextBgra;
|
||||
private readonly CloseDelegate _close;
|
||||
|
||||
private NativeAdapter(
|
||||
OpenUtf8Delegate openUtf8,
|
||||
DecodeNextBgraDelegate decodeNextBgra,
|
||||
CloseDelegate close)
|
||||
while (PendingMoviePaths.Count > 0)
|
||||
{
|
||||
_openUtf8 = openUtf8;
|
||||
_decodeNextBgra = decodeNextBgra;
|
||||
_close = close;
|
||||
var path = PendingMoviePaths.Dequeue();
|
||||
PendingMoviePathSet.Remove(path);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AttachMovieLocked(path, ResolveMode());
|
||||
if (_playback is not null || _frameBuffer is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryCreate(IntPtr library, out NativeAdapter? adapter)
|
||||
}
|
||||
// Longest a guest _read() will block waiting for real host playback to
|
||||
// finish. A safety net, not a target: real movies finish well under
|
||||
// this. Bounds the damage if a movie fails to attach/decode after being
|
||||
// queued, so the guest thread doesn't hang forever.
|
||||
private const long MaxCompletionWaitMilliseconds = 5 * 60 * 1000;
|
||||
/// <summary>
|
||||
/// Blocks the calling (guest I/O) thread until the host has actually
|
||||
/// finished presenting <paramref name="hostPath"/> — either because it
|
||||
/// played through, or because something else took over the timeline.
|
||||
///
|
||||
/// The completion shim tells the guest's own Bink header parse "this
|
||||
/// movie is one frame and already done" so its native decoder never
|
||||
/// blocks the guest on real per-frame work. Without this wait, that lie
|
||||
/// lands the instant the guest reads the header, so guest-side game
|
||||
/// logic races far ahead of whatever the host is still showing on
|
||||
/// screen: pressing a button lands on the (already-advanced) guest
|
||||
/// state, but the video visibly keeps playing, and any real-time-gated
|
||||
/// trigger later in the guest's own flow can fire against a clock that
|
||||
/// no longer matches wall time. Gating the "done" read on real host
|
||||
/// completion keeps guest pacing and on-screen playback in lockstep.
|
||||
/// </summary>
|
||||
internal static void WaitForHostPlaybackToFinish(string hostPath)
|
||||
{
|
||||
var deadline = Environment.TickCount64 + MaxCompletionWaitMilliseconds;
|
||||
lock (Gate)
|
||||
{
|
||||
adapter = null;
|
||||
if (!NativeLibrary.TryGetExport(library, "sharpemu_bink2_open_utf8", out var open) ||
|
||||
!NativeLibrary.TryGetExport(library, "sharpemu_bink2_decode_next_bgra", out var decode) ||
|
||||
!NativeLibrary.TryGetExport(library, "sharpemu_bink2_close", out var close))
|
||||
while (IsTrackedLocked(hostPath))
|
||||
{
|
||||
var remaining = deadline - Environment.TickCount64;
|
||||
if (remaining <= 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink2 bridge completion wait timed out for '" +
|
||||
Path.GetFileName(hostPath) + "'.");
|
||||
return;
|
||||
}
|
||||
|
||||
Monitor.Wait(Gate, (int)Math.Min(remaining, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTrackedLocked(string hostPath) =>
|
||||
string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
PendingMoviePathSet.Contains(hostPath);
|
||||
|
||||
internal static bool TryTakeOverGuestMovie(
|
||||
string hostPath,
|
||||
out BinkGuestCompletionShim completionShim,
|
||||
out bool observed)
|
||||
{
|
||||
completionShim = default;
|
||||
observed = ObserveGuestMovie(hostPath);
|
||||
|
||||
// Keep the real header visible so the guest creates its movie surface
|
||||
// and draw. Host-decoded pixels replace that sampled image later; a
|
||||
// one-frame completion shim would finish before the descriptor exists.
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static void NotifyGuestMovieClosed(string hostPath)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (PendingMoviePathSet.Remove(hostPath))
|
||||
{
|
||||
var retained = PendingMoviePaths
|
||||
.Where(path => !string.Equals(
|
||||
path,
|
||||
hostPath,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
PendingMoviePaths.Clear();
|
||||
foreach (var path in retained)
|
||||
{
|
||||
PendingMoviePaths.Enqueue(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Monitor.PulseAll(Gate);
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge stopped by guest close: " +
|
||||
Path.GetFileName(hostPath));
|
||||
CloseActiveLocked();
|
||||
AttachNextQueuedMovieLocked();
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryReadGuestCompletionShim(
|
||||
string hostPath,
|
||||
out BinkGuestCompletionShim completionShim)
|
||||
{
|
||||
completionShim = default;
|
||||
Span<byte> header = stackalloc byte[48];
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(hostPath);
|
||||
stream.ReadExactly(header);
|
||||
if (!header[..3].SequenceEqual("KB2"u8))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
adapter = new NativeAdapter(
|
||||
Marshal.GetDelegateForFunctionPointer<OpenUtf8Delegate>(open),
|
||||
Marshal.GetDelegateForFunctionPointer<DecodeNextBgraDelegate>(decode),
|
||||
Marshal.GetDelegateForFunctionPointer<CloseDelegate>(close));
|
||||
var frameCount = BinaryPrimitives.ReadUInt32LittleEndian(header[8..12]);
|
||||
var audioTrackCount = BinaryPrimitives.ReadUInt32LittleEndian(header[40..44]);
|
||||
if (frameCount < 2 || audioTrackCount > 256)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var revision = header[3];
|
||||
var frameIndexOffset = 44L + checked(12L * audioTrackCount);
|
||||
if (revision == (byte)'m')
|
||||
{
|
||||
frameIndexOffset += 16;
|
||||
}
|
||||
else if (revision is (byte)'i' or (byte)'j' or (byte)'k' or (byte)'n')
|
||||
{
|
||||
frameIndexOffset += 4;
|
||||
}
|
||||
|
||||
Span<byte> frameOffsets = stackalloc byte[8];
|
||||
stream.Position = frameIndexOffset;
|
||||
stream.ReadExactly(frameOffsets);
|
||||
var firstFrameOffset = BinaryPrimitives.ReadUInt32LittleEndian(frameOffsets[..4]) & ~1u;
|
||||
var secondFrameOffset = BinaryPrimitives.ReadUInt32LittleEndian(frameOffsets[4..]) & ~1u;
|
||||
if (firstFrameOffset < frameIndexOffset + 8 ||
|
||||
secondFrameOffset <= firstFrameOffset ||
|
||||
secondFrameOffset > stream.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
completionShim = new BinkGuestCompletionShim(
|
||||
secondFrameOffset - 8,
|
||||
secondFrameOffset - firstFrameOffset);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool TryOpen(string path, out IntPtr movie, out Bink2MovieInfo info)
|
||||
catch (Exception exception) when (
|
||||
exception is IOException or EndOfStreamException or OverflowException)
|
||||
{
|
||||
var utf8 = Marshal.StringToCoTaskMemUTF8(path);
|
||||
try
|
||||
{
|
||||
return _openUtf8(utf8, out movie, out info) != 0 && movie != IntPtr.Zero;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeCoTaskMem(utf8);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly struct BinkGuestCompletionShim
|
||||
{
|
||||
private readonly uint _fileSizeMinusHeader;
|
||||
private readonly uint _largestFrameSize;
|
||||
|
||||
internal BinkGuestCompletionShim(uint fileSizeMinusHeader, uint largestFrameSize)
|
||||
{
|
||||
_fileSizeMinusHeader = fileSizeMinusHeader;
|
||||
_largestFrameSize = largestFrameSize;
|
||||
}
|
||||
|
||||
internal bool DecodeNextBgra(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes) =>
|
||||
_decodeNextBgra(movie, destination, stride, destinationBytes) != 0;
|
||||
/// <summary>
|
||||
/// Rewrites the frame-count/size fields the guest's own Bink header
|
||||
/// parse reads, if this read covers them. Returns true when the
|
||||
/// NumFrames field (the field that tells the guest "this movie is
|
||||
/// done") was in range, so the caller can gate that specific read on
|
||||
/// the host's real playback actually finishing first.
|
||||
/// </summary>
|
||||
internal bool Patch(long fileOffset, Span<byte> bytes)
|
||||
{
|
||||
PatchUInt32(fileOffset, bytes, 4, _fileSizeMinusHeader);
|
||||
var touchedCompletionField = PatchUInt32(fileOffset, bytes, 8, 1);
|
||||
PatchUInt32(fileOffset, bytes, 12, _largestFrameSize);
|
||||
return touchedCompletionField;
|
||||
}
|
||||
|
||||
internal void Close(IntPtr movie) => _close(movie);
|
||||
private static bool PatchUInt32(
|
||||
long fileOffset,
|
||||
Span<byte> bytes,
|
||||
long fieldOffset,
|
||||
uint value)
|
||||
{
|
||||
var relativeOffset = fieldOffset - fileOffset;
|
||||
if (relativeOffset < 0 || relativeOffset + sizeof(uint) > bytes.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
bytes.Slice((int)relativeOffset, sizeof(uint)),
|
||||
value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace SharpEmu.Libs.Bink;
|
||||
|
||||
internal interface IBinkFrameDecoder : IDisposable
|
||||
{
|
||||
uint Width { get; }
|
||||
|
||||
uint Height { get; }
|
||||
|
||||
uint FramesPerSecondNumerator { get; }
|
||||
|
||||
uint FramesPerSecondDenominator { get; }
|
||||
|
||||
bool TryDecodeNextFrame(Span<byte> destination);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps blocking codec work away from the Vulkan presentation thread and
|
||||
/// releases decoded frames according to the movie time base.
|
||||
/// </summary>
|
||||
internal sealed class BinkFramePlayback : IDisposable
|
||||
{
|
||||
private const int BufferCount = 5;
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly IBinkFrameDecoder _decoder;
|
||||
private readonly Queue<byte[]> _freeBuffers = new();
|
||||
private readonly Queue<DecodedFrame> _decodedFrames = new();
|
||||
private readonly Thread _decoderThread;
|
||||
private byte[]? _currentFrame;
|
||||
private byte[]? _retiredFrame;
|
||||
private long _currentFrameIndex = -1;
|
||||
private long _nextDecodedFrameIndex;
|
||||
private long _playbackStartTimestamp;
|
||||
private bool _playbackClockStarted;
|
||||
private bool _decoderCompleted;
|
||||
private bool _stopRequested;
|
||||
private bool _finished;
|
||||
private int _disposed;
|
||||
|
||||
internal BinkFramePlayback(IBinkFrameDecoder decoder)
|
||||
{
|
||||
_decoder = decoder;
|
||||
Width = decoder.Width;
|
||||
Height = decoder.Height;
|
||||
FramesPerSecondNumerator = decoder.FramesPerSecondNumerator;
|
||||
FramesPerSecondDenominator = decoder.FramesPerSecondDenominator;
|
||||
|
||||
var frameBytes = checked((int)((ulong)Width * Height * 4));
|
||||
for (var index = 0; index < BufferCount; index++)
|
||||
{
|
||||
_freeBuffers.Enqueue(GC.AllocateUninitializedArray<byte>(frameBytes));
|
||||
}
|
||||
|
||||
_decoderThread = new Thread(DecodeLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "SharpEmu Bink video decoder",
|
||||
};
|
||||
_decoderThread.Start();
|
||||
}
|
||||
|
||||
internal uint Width { get; }
|
||||
|
||||
internal uint Height { get; }
|
||||
|
||||
internal uint FramesPerSecondNumerator { get; }
|
||||
|
||||
internal uint FramesPerSecondDenominator { get; }
|
||||
|
||||
internal bool IsFinished
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _finished;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal bool TryGetFrame(
|
||||
bool advanceClock,
|
||||
out byte[] pixels,
|
||||
out bool advanced)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
pixels = [];
|
||||
advanced = false;
|
||||
if (_finished)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_currentFrame is null)
|
||||
{
|
||||
if (_decodedFrames.Count == 0)
|
||||
{
|
||||
if (_decoderCompleted)
|
||||
{
|
||||
_finished = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var first = _decodedFrames.Dequeue();
|
||||
_currentFrame = first.Pixels;
|
||||
_currentFrameIndex = first.Index;
|
||||
advanced = true;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
|
||||
if (advanceClock && !_playbackClockStarted)
|
||||
{
|
||||
_playbackStartTimestamp = Stopwatch.GetTimestamp();
|
||||
_playbackClockStarted = true;
|
||||
}
|
||||
|
||||
var elapsedSeconds = _playbackClockStarted
|
||||
? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds
|
||||
: 0;
|
||||
var targetFrameIndex = (long)Math.Floor(
|
||||
elapsedSeconds * FramesPerSecondNumerator / FramesPerSecondDenominator);
|
||||
DecodedFrame? replacement = null;
|
||||
while (_decodedFrames.Count > 0 &&
|
||||
_decodedFrames.Peek().Index <= targetFrameIndex)
|
||||
{
|
||||
if (replacement is { } skipped)
|
||||
{
|
||||
_freeBuffers.Enqueue(skipped.Pixels);
|
||||
}
|
||||
replacement = _decodedFrames.Dequeue();
|
||||
}
|
||||
|
||||
if (replacement is { } next)
|
||||
{
|
||||
if (_retiredFrame is not null)
|
||||
{
|
||||
_freeBuffers.Enqueue(_retiredFrame);
|
||||
}
|
||||
_retiredFrame = _currentFrame;
|
||||
_currentFrame = next.Pixels;
|
||||
_currentFrameIndex = next.Index;
|
||||
advanced = true;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
|
||||
var frameDurationSeconds =
|
||||
(double)FramesPerSecondDenominator / FramesPerSecondNumerator;
|
||||
if (_playbackClockStarted &&
|
||||
_decoderCompleted &&
|
||||
_decodedFrames.Count == 0 &&
|
||||
elapsedSeconds >= (_currentFrameIndex + 1) * frameDurationSeconds)
|
||||
{
|
||||
_finished = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
pixels = _currentFrame;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void DecodeLoop()
|
||||
{
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
byte[] destination;
|
||||
lock (_gate)
|
||||
{
|
||||
while (!_stopRequested && _freeBuffers.Count == 0)
|
||||
{
|
||||
Monitor.Wait(_gate);
|
||||
}
|
||||
if (_stopRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
destination = _freeBuffers.Dequeue();
|
||||
}
|
||||
|
||||
if (!_decoder.TryDecodeNextFrame(destination))
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_freeBuffers.Enqueue(destination);
|
||||
_decoderCompleted = true;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_decodedFrames.Enqueue(new DecodedFrame(
|
||||
_nextDecodedFrameIndex++, destination));
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or
|
||||
InvalidOperationException)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Bink decoder stopped: {exception.Message}");
|
||||
lock (_gate)
|
||||
{
|
||||
_decoderCompleted = true;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_stopRequested = true;
|
||||
Monitor.PulseAll(_gate);
|
||||
}
|
||||
if (Thread.CurrentThread != _decoderThread &&
|
||||
!_decoderThread.Join(TimeSpan.FromMilliseconds(100)))
|
||||
{
|
||||
_decoder.Dispose();
|
||||
_decoderThread.Join(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
else
|
||||
{
|
||||
_decoder.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct DecodedFrame(long Index, byte[] Pixels);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Diagnostics;
|
||||
using SharpEmu.Libs.AvPlayer;
|
||||
|
||||
namespace SharpEmu.Libs.Bink;
|
||||
|
||||
internal sealed class FfmpegBinkFrameSource : IBinkFrameDecoder
|
||||
{
|
||||
private readonly Process _process;
|
||||
private readonly Stream _output;
|
||||
private int _errorLines;
|
||||
private int _disposed;
|
||||
|
||||
private FfmpegBinkFrameSource(
|
||||
Process process,
|
||||
uint width,
|
||||
uint height,
|
||||
uint framesPerSecondNumerator,
|
||||
uint framesPerSecondDenominator)
|
||||
{
|
||||
_process = process;
|
||||
_output = process.StandardOutput.BaseStream;
|
||||
Width = width;
|
||||
Height = height;
|
||||
FramesPerSecondNumerator = framesPerSecondNumerator;
|
||||
FramesPerSecondDenominator = framesPerSecondDenominator;
|
||||
}
|
||||
|
||||
public uint Width { get; }
|
||||
|
||||
public uint Height { get; }
|
||||
|
||||
public uint FramesPerSecondNumerator { get; }
|
||||
|
||||
public uint FramesPerSecondDenominator { get; }
|
||||
|
||||
internal static bool IsAvailable => AvPlayerExports.FindFfmpeg() is not null;
|
||||
|
||||
internal static bool TryOpen(
|
||||
string path,
|
||||
uint width,
|
||||
uint height,
|
||||
uint framesPerSecondNumerator,
|
||||
uint framesPerSecondDenominator,
|
||||
out FfmpegBinkFrameSource? source)
|
||||
{
|
||||
source = null;
|
||||
var ffmpeg = AvPlayerExports.FindFfmpeg();
|
||||
if (ffmpeg is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo(ffmpeg)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
startInfo.ArgumentList.Add("-nostdin");
|
||||
startInfo.ArgumentList.Add("-hide_banner");
|
||||
startInfo.ArgumentList.Add("-loglevel");
|
||||
startInfo.ArgumentList.Add("error");
|
||||
startInfo.ArgumentList.Add("-i");
|
||||
startInfo.ArgumentList.Add(path);
|
||||
startInfo.ArgumentList.Add("-map");
|
||||
startInfo.ArgumentList.Add("0:v:0");
|
||||
startInfo.ArgumentList.Add("-an");
|
||||
startInfo.ArgumentList.Add("-pix_fmt");
|
||||
startInfo.ArgumentList.Add("bgra");
|
||||
startInfo.ArgumentList.Add("-f");
|
||||
startInfo.ArgumentList.Add("rawvideo");
|
||||
startInfo.ArgumentList.Add("pipe:1");
|
||||
|
||||
try
|
||||
{
|
||||
var process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
source = new FfmpegBinkFrameSource(
|
||||
process,
|
||||
width,
|
||||
height,
|
||||
framesPerSecondNumerator,
|
||||
framesPerSecondDenominator);
|
||||
process.ErrorDataReceived += source.OnErrorData;
|
||||
process.BeginErrorReadLine();
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or
|
||||
InvalidOperationException or
|
||||
System.ComponentModel.Win32Exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Bink FFmpeg decoder could not start: {exception.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDecodeNextFrame(Span<byte> destination)
|
||||
{
|
||||
try
|
||||
{
|
||||
var offset = 0;
|
||||
while (offset < destination.Length)
|
||||
{
|
||||
var read = _output.Read(destination[offset..]);
|
||||
if (read == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
offset += read;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or ObjectDisposedException)
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Bink FFmpeg stream failed: {exception.Message}");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnErrorData(object sender, DataReceivedEventArgs eventArgs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(eventArgs.Data) ||
|
||||
Interlocked.Increment(ref _errorLines) > 20)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Console.Error.WriteLine($"[LOADER][FFMPEG-BINK] {eventArgs.Data}");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_output.Dispose();
|
||||
try
|
||||
{
|
||||
if (!_process.HasExited)
|
||||
{
|
||||
_process.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
_process.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using FFmpeg.AutoGen;
|
||||
|
||||
namespace SharpEmu.Libs.Bink;
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a .bk2 (or any FFmpeg-readable movie) directly via FFmpeg's C API
|
||||
/// through FFmpeg.AutoGen P/Invoke bindings against the dynamically linked
|
||||
/// libraries published by github.com/sharpemu/ffmpeg-core -- no native C
|
||||
/// bridge of our own to build. See docs/bink2-bridge.md.
|
||||
/// </summary>
|
||||
internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
||||
{
|
||||
private AVFormatContext* _formatContext;
|
||||
private AVCodecContext* _codecContext;
|
||||
private SwsContext* _swsContext;
|
||||
private AVFrame* _frame;
|
||||
private AVPacket* _packet;
|
||||
private readonly int _videoStreamIndex;
|
||||
private bool _draining;
|
||||
private int _disposed;
|
||||
|
||||
public uint Width { get; }
|
||||
|
||||
public uint Height { get; }
|
||||
|
||||
public uint FramesPerSecondNumerator { get; }
|
||||
|
||||
public uint FramesPerSecondDenominator { get; }
|
||||
|
||||
private FfmpegNativeBinkFrameSource(
|
||||
AVFormatContext* formatContext,
|
||||
AVCodecContext* codecContext,
|
||||
int videoStreamIndex,
|
||||
uint width,
|
||||
uint height,
|
||||
uint framesPerSecondNumerator,
|
||||
uint framesPerSecondDenominator)
|
||||
{
|
||||
_formatContext = formatContext;
|
||||
_codecContext = codecContext;
|
||||
_videoStreamIndex = videoStreamIndex;
|
||||
Width = width;
|
||||
Height = height;
|
||||
FramesPerSecondNumerator = framesPerSecondNumerator;
|
||||
FramesPerSecondDenominator = framesPerSecondDenominator;
|
||||
_frame = ffmpeg.av_frame_alloc();
|
||||
_packet = ffmpeg.av_packet_alloc();
|
||||
}
|
||||
|
||||
private static bool _rootPathInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// Points FFmpeg.AutoGen at the FFmpeg shared libraries SharpEmu.CLI
|
||||
/// downloads next to the executable (see SharpEmu.CLI.csproj's
|
||||
/// FetchFfmpegRuntime target); kept as loose files rather than embedded
|
||||
/// in the single-file bundle so the OS loader can resolve the normal
|
||||
/// inter-library dependencies (avcodec depends on avutil, etc.) itself.
|
||||
/// </summary>
|
||||
private static void EnsureRootPathInitialized()
|
||||
{
|
||||
if (_rootPathInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_rootPathInitialized = true;
|
||||
// SharpEmu.CLI.csproj publishes FFmpeg's shared libraries into a
|
||||
// "plugins" subfolder next to the executable rather than flat beside
|
||||
// it (see NativeLibraryFolderName in SharpEmu.CLI.csproj).
|
||||
ffmpeg.RootPath = Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||
|
||||
// ffmpeg's static constructor runs DynamicallyLoadedBindings.Initialize()
|
||||
// itself, but that constructor fires on first touch of the ffmpeg type --
|
||||
// which is the RootPath assignment above -- so it binds against the
|
||||
// default (empty) RootPath before the assignment's own setter body runs.
|
||||
// Every function resolved during that first pass permanently throws
|
||||
// NotSupportedException. Re-running Initialize() now, with RootPath
|
||||
// actually set, rebinds everything against the real search path.
|
||||
DynamicallyLoadedBindings.Initialize();
|
||||
}
|
||||
|
||||
internal static bool TryOpen(
|
||||
string path,
|
||||
uint maximumWidth,
|
||||
uint maximumHeight,
|
||||
out FfmpegNativeBinkFrameSource? source)
|
||||
{
|
||||
source = null;
|
||||
EnsureRootPathInitialized();
|
||||
|
||||
AVFormatContext* formatContext = null;
|
||||
AVCodecContext* codecContext = null;
|
||||
try
|
||||
{
|
||||
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ffmpeg.avformat_find_stream_info(formatContext, null) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
AVCodec* decoder = null;
|
||||
var videoStreamIndex = ffmpeg.av_find_best_stream(
|
||||
formatContext, AVMediaType.AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0);
|
||||
if (videoStreamIndex < 0 || decoder is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var stream = formatContext->streams[videoStreamIndex];
|
||||
codecContext = ffmpeg.avcodec_alloc_context3(decoder);
|
||||
if (codecContext is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ffmpeg.avcodec_parameters_to_context(codecContext, stream->codecpar) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
codecContext->thread_count = 0;
|
||||
codecContext->thread_type = ffmpeg.FF_THREAD_FRAME | ffmpeg.FF_THREAD_SLICE;
|
||||
if (ffmpeg.avcodec_open2(codecContext, decoder, null) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (codecContext->width <= 0 || codecContext->height <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var frameRate = ffmpeg.av_guess_frame_rate(formatContext, stream, null);
|
||||
if (frameRate.num <= 0 || frameRate.den <= 0)
|
||||
{
|
||||
frameRate = stream->avg_frame_rate;
|
||||
}
|
||||
if (frameRate.num <= 0 || frameRate.den <= 0)
|
||||
{
|
||||
frameRate = stream->r_frame_rate;
|
||||
}
|
||||
if (frameRate.num <= 0 || frameRate.den <= 0)
|
||||
{
|
||||
frameRate = new AVRational { num = 30, den = 1 };
|
||||
}
|
||||
|
||||
var outputWidth = (uint)codecContext->width;
|
||||
var outputHeight = (uint)codecContext->height;
|
||||
if (maximumWidth > 0 && maximumHeight > 0 &&
|
||||
(outputWidth > maximumWidth || outputHeight > maximumHeight))
|
||||
{
|
||||
if ((ulong)outputWidth * maximumHeight > (ulong)outputHeight * maximumWidth)
|
||||
{
|
||||
outputHeight = (uint)((ulong)outputHeight * maximumWidth / outputWidth);
|
||||
outputWidth = maximumWidth;
|
||||
}
|
||||
else
|
||||
{
|
||||
outputWidth = (uint)((ulong)outputWidth * maximumHeight / outputHeight);
|
||||
outputHeight = maximumHeight;
|
||||
}
|
||||
|
||||
outputWidth = Math.Max(1, outputWidth);
|
||||
outputHeight = Math.Max(1, outputHeight);
|
||||
}
|
||||
|
||||
source = new FfmpegNativeBinkFrameSource(
|
||||
formatContext,
|
||||
codecContext,
|
||||
videoStreamIndex,
|
||||
outputWidth,
|
||||
outputHeight,
|
||||
(uint)frameRate.num,
|
||||
(uint)frameRate.den);
|
||||
formatContext = null;
|
||||
codecContext = null;
|
||||
return true;
|
||||
}
|
||||
catch (DllNotFoundException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (codecContext is not null)
|
||||
{
|
||||
ffmpeg.avcodec_free_context(&codecContext);
|
||||
}
|
||||
|
||||
if (formatContext is not null)
|
||||
{
|
||||
ffmpeg.avformat_close_input(&formatContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDecodeNextFrame(Span<byte> destination)
|
||||
{
|
||||
var stride = checked((int)(Width * 4));
|
||||
var required = (long)stride * Height;
|
||||
if (destination.Length < required)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryReceiveFrame())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_swsContext = ffmpeg.sws_getCachedContext(
|
||||
_swsContext,
|
||||
_frame->width,
|
||||
_frame->height,
|
||||
(AVPixelFormat)_frame->format,
|
||||
(int)Width,
|
||||
(int)Height,
|
||||
AVPixelFormat.AV_PIX_FMT_BGRA,
|
||||
ffmpeg.SWS_FAST_BILINEAR,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
if (_swsContext is null)
|
||||
{
|
||||
ffmpeg.av_frame_unref(_frame);
|
||||
return false;
|
||||
}
|
||||
|
||||
fixed (byte* destinationPointer = destination)
|
||||
{
|
||||
var destinationPlanes = new byte*[4] { destinationPointer, null, null, null };
|
||||
var destinationStrides = new int[4] { stride, 0, 0, 0 };
|
||||
var convertedRows = ffmpeg.sws_scale(
|
||||
_swsContext,
|
||||
_frame->data,
|
||||
_frame->linesize,
|
||||
0,
|
||||
_frame->height,
|
||||
destinationPlanes,
|
||||
destinationStrides);
|
||||
ffmpeg.av_frame_unref(_frame);
|
||||
return convertedRows == (int)Height;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryReceiveFrame()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var receiveResult = ffmpeg.avcodec_receive_frame(_codecContext, _frame);
|
||||
if (receiveResult >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (receiveResult == ffmpeg.AVERROR_EOF)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (receiveResult != ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_draining)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryFeedPacket())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryFeedPacket()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var readResult = ffmpeg.av_read_frame(_formatContext, _packet);
|
||||
if (readResult < 0)
|
||||
{
|
||||
_draining = true;
|
||||
ffmpeg.avcodec_send_packet(_codecContext, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_packet->stream_index != _videoStreamIndex)
|
||||
{
|
||||
ffmpeg.av_packet_unref(_packet);
|
||||
continue;
|
||||
}
|
||||
|
||||
var sendResult = ffmpeg.avcodec_send_packet(_codecContext, _packet);
|
||||
ffmpeg.av_packet_unref(_packet);
|
||||
if (sendResult < 0 && sendResult != ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_swsContext is not null)
|
||||
{
|
||||
ffmpeg.sws_freeContext(_swsContext);
|
||||
_swsContext = null;
|
||||
}
|
||||
|
||||
if (_packet is not null)
|
||||
{
|
||||
var packet = _packet;
|
||||
ffmpeg.av_packet_free(&packet);
|
||||
_packet = null;
|
||||
}
|
||||
|
||||
if (_frame is not null)
|
||||
{
|
||||
var frame = _frame;
|
||||
ffmpeg.av_frame_free(&frame);
|
||||
_frame = null;
|
||||
}
|
||||
|
||||
if (_codecContext is not null)
|
||||
{
|
||||
var codecContext = _codecContext;
|
||||
ffmpeg.avcodec_free_context(&codecContext);
|
||||
_codecContext = null;
|
||||
}
|
||||
|
||||
if (_formatContext is not null)
|
||||
{
|
||||
var formatContext = _formatContext;
|
||||
ffmpeg.avformat_close_input(&formatContext);
|
||||
_formatContext = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,37 @@ public static class FontExports
|
||||
return SetSuccess(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "3BrWWFU+4ts",
|
||||
ExportName = "sceFontGetVerticalLayout",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int GetVerticalLayout(CpuContext ctx)
|
||||
{
|
||||
var layoutAddress = ctx[CpuRegister.Rsi];
|
||||
if (layoutAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
// Baseline (horizontal offset), line advance, decoration extent.
|
||||
// Mirrors the same three-float layout as GetHorizontalLayout, but
|
||||
// interpreted for vertical writing (e.g. CJK text rendered top-to-bottom).
|
||||
var values = new[] { 8.0f, 16.0f, 0.0f };
|
||||
for (var index = 0; index < values.Length; index++)
|
||||
{
|
||||
if (!TryWriteUInt32(
|
||||
ctx,
|
||||
layoutAddress + (ulong)(index * sizeof(float)),
|
||||
BitConverter.SingleToUInt32Bits(values[index])))
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
}
|
||||
|
||||
return SetSuccess(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "cKYtVmeSTcw",
|
||||
ExportName = "sceFontOpenFontSet",
|
||||
|
||||
@@ -27,7 +27,12 @@ internal sealed record GuestDrawTexture(
|
||||
uint Pitch = 0,
|
||||
uint TileMode = 0,
|
||||
uint DstSelect = 0xFAC,
|
||||
GuestSampler Sampler = default);
|
||||
GuestSampler Sampler = default,
|
||||
// Guest CPU write-tracker generation of the memory RgbaPixels was read
|
||||
// from; -1 when the range is untracked or the pixels were not read here.
|
||||
long WriteGeneration = -1,
|
||||
bool ArrayedView = false,
|
||||
uint ArrayLayers = 1);
|
||||
|
||||
/// <summary>Raw guest sampler descriptor dwords, copied verbatim from guest memory.</summary>
|
||||
internal readonly record struct GuestSampler(
|
||||
@@ -48,7 +53,9 @@ internal readonly record struct TextureContentIdentity(
|
||||
uint DstSelect,
|
||||
uint TileMode,
|
||||
uint Pitch,
|
||||
GuestSampler Sampler);
|
||||
GuestSampler Sampler,
|
||||
bool Arrayed = false,
|
||||
uint ArrayLayers = 1);
|
||||
|
||||
internal sealed record GuestMemoryBuffer(
|
||||
ulong BaseAddress,
|
||||
|
||||
@@ -228,17 +228,6 @@ internal static partial class MetalVideoPresenter
|
||||
|
||||
private static void RunWindowLoop()
|
||||
{
|
||||
// The Metal Performance HUD attaches to a process at Metal init;
|
||||
// toggling developerHUDProperties on a live layer is inert when the
|
||||
// HUD was never armed. SHARPEMU_METAL_HUD=1 arms it the documented way
|
||||
// (setenv lands in the native environ before the device initializes),
|
||||
// showing the HUD from launch; Cmd+F1 then toggles it per-layer.
|
||||
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_METAL_HUD"), "1", StringComparison.Ordinal))
|
||||
{
|
||||
Environment.SetEnvironmentVariable("MTL_HUD_ENABLED", "1");
|
||||
_metalHudVisible = true;
|
||||
}
|
||||
|
||||
MetalNative.EnsureFrameworksLoaded();
|
||||
|
||||
_device = MetalNative.MTLCreateSystemDefaultDevice();
|
||||
@@ -517,14 +506,6 @@ internal static partial class MetalVideoPresenter
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] Metal Performance HUD {(_metalHudVisible ? "shown" : "hidden")} (Cmd+F1).");
|
||||
if (_metalHudVisible &&
|
||||
!string.Equals(Environment.GetEnvironmentVariable("MTL_HUD_ENABLED"), "1", StringComparison.Ordinal))
|
||||
{
|
||||
// The per-layer property only takes effect when the HUD attached at
|
||||
// Metal init; without that the toggle is a no-op on some macOS builds.
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] If the HUD does not appear, relaunch with SHARPEMU_METAL_HUD=1 (arms Apple's HUD at startup).");
|
||||
}
|
||||
}
|
||||
|
||||
private static unsafe nint CreateRenderTimerTarget()
|
||||
|
||||
@@ -43,25 +43,4 @@ public static class ImeExports
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
// No hardware keyboard is ever connected; zero the caller's info struct so
|
||||
// it reads as "not connected" rather than uninitialized stack.
|
||||
[SysAbiExport(
|
||||
Nid = "VkqLPArfFdc",
|
||||
ExportName = "sceImeKeyboardGetInfo",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceIme")]
|
||||
public static int ImeKeyboardGetInfo(CpuContext ctx)
|
||||
{
|
||||
var infoAddress = ctx[CpuRegister.Rsi] != 0 ? ctx[CpuRegister.Rsi] : ctx[CpuRegister.Rdi];
|
||||
if (infoAddress != 0)
|
||||
{
|
||||
Span<byte> info = stackalloc byte[0x40];
|
||||
info.Clear();
|
||||
_ = ctx.Memory.TryWrite(infoAddress, info);
|
||||
}
|
||||
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,17 @@ public static class JsonExports
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
// Catalog alias NID for the same callback setter.
|
||||
#pragma warning disable SHEM004
|
||||
[SysAbiExport(
|
||||
Nid = "00oCq0RwSAY",
|
||||
ExportName = "_ZN3sce4Json11Initializer27setGlobalNullAccessCallbackEPFRKNS0_5ValueENS0_9ValueTypeEPS3_PvES7_",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceJson")]
|
||||
public static int InitializerSetGlobalNullAccessCallbackAlt(CpuContext ctx) =>
|
||||
InitializerSetGlobalNullAccessCallback(ctx);
|
||||
#pragma warning restore SHEM004
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "WSOuge5IsCg",
|
||||
ExportName = "_ZN3sce4Json14InitParameter2C1Ev",
|
||||
|
||||
@@ -5,12 +5,14 @@ using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Fiber;
|
||||
|
||||
namespace SharpEmu.Libs.Kernel;
|
||||
|
||||
public static class KernelEventFlagCompatExports
|
||||
{
|
||||
private const int MaxEventFlagNameLength = 31;
|
||||
private const int HostWaitPumpMilliseconds = 1;
|
||||
private const uint AttrThreadFifo = 0x01;
|
||||
private const uint AttrThreadPriority = 0x02;
|
||||
private const uint AttrSingle = 0x10;
|
||||
@@ -23,7 +25,8 @@ public static class KernelEventFlagCompatExports
|
||||
private static readonly ConcurrentDictionary<ulong, EventFlagState> _eventFlags = new();
|
||||
private static long _nextEventFlagHandle = 1;
|
||||
|
||||
// Cached once: gating every call site avoids building the interpolated trace string when disabled.
|
||||
// Cached once: gating every call site avoids building the interpolated
|
||||
// trace string (and FormatFrameChain/FormatGuestWaitObject) when disabled.
|
||||
private static readonly bool _traceEventFlag = string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_LOG_EVENT_FLAG"), "1", StringComparison.Ordinal);
|
||||
|
||||
@@ -125,11 +128,11 @@ public static class KernelEventFlagCompatExports
|
||||
lock (state.Gate)
|
||||
{
|
||||
state.Bits |= pattern;
|
||||
// Wake threads parked in-place on the gate; each re-checks its pattern.
|
||||
Monitor.PulseAll(state.Gate);
|
||||
if (_traceEventFlag) TraceEventFlag($"set handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} ret=0x{returnRip:X16}");
|
||||
}
|
||||
|
||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetEventFlagWakeKey(handle));
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
@@ -226,67 +229,134 @@ public static class KernelEventFlagCompatExports
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
// A zero-microsecond timeout degrades to an instant poll because the
|
||||
// deadline is already in the past.
|
||||
var hostDeadlineMs = timeoutAddress != 0
|
||||
? Environment.TickCount64 + (timeoutUsec == 0
|
||||
? 0L
|
||||
: Math.Max(1L, (timeoutUsec + 999L) / 1000L))
|
||||
: long.MaxValue;
|
||||
|
||||
lock (state.Gate)
|
||||
Monitor.Enter(state.Gate);
|
||||
try
|
||||
{
|
||||
if (TryCompleteSatisfiedWait(ctx, state, pattern, waitMode, resultAddress, out var immediateWaitResult))
|
||||
{
|
||||
if (_traceEventFlag) TraceEventFlag($"poll handle=0x{handle:X16} pattern=0x{pattern:X16} mode=0x{waitMode:X2} bits=0x{state.Bits:X16} ret=0x{returnRip:X16}");
|
||||
return SetReturn(ctx, immediateWaitResult);
|
||||
}
|
||||
|
||||
// In-place block on the flag gate. Monitor.Wait releases the gate
|
||||
// and parks atomically, so a concurrent SetEventFlag's PulseAll
|
||||
// cannot be lost between the satisfy check and the park. On wake
|
||||
// the predicate is re-evaluated (Set semantics on FreeBSD-style
|
||||
// event flags: OR/AND over the bit pattern, optional clear).
|
||||
state.WaitingThreads++;
|
||||
var guestThreadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
|
||||
if (_traceEventFlag) TraceEventFlag($"wait-block handle=0x{handle:X16} pattern=0x{pattern:X16} waiters={state.WaitingThreads} guest_thread=0x{guestThreadHandle:X16} ret=0x{returnRip:X16}");
|
||||
GuestThreadBlocking.NoteBlocked(guestThreadHandle, "sceKernelWaitEventFlag");
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
// Timed waits block on a deadline instead of returning TIMED_OUT
|
||||
// immediately; a zero-microsecond timeout still degrades to an
|
||||
// instant poll because the deadline is already in the past.
|
||||
var deadline = timeoutAddress != 0
|
||||
? GuestThreadExecution.ComputeDeadlineTimestamp(TimeSpan.FromMicroseconds(timeoutUsec))
|
||||
: 0;
|
||||
var hostDeadlineMs = timeoutAddress != 0
|
||||
? Environment.TickCount64 + (timeoutUsec == 0
|
||||
? 0L
|
||||
: Math.Max(1L, (timeoutUsec + 999L) / 1000L))
|
||||
: long.MaxValue;
|
||||
|
||||
var currentGuestThread = GuestThreadExecution.CurrentGuestThreadHandle;
|
||||
var currentFiber = FiberExports.GetCurrentFiberAddressForDiagnostics(ctx);
|
||||
var managedThread = Environment.CurrentManagedThreadId;
|
||||
var blockedWaitResult = OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
var satisfied = false;
|
||||
var requestedBlock = GuestThreadExecution.RequestCurrentThreadBlock(
|
||||
ctx,
|
||||
"sceKernelWaitEventFlag",
|
||||
GetEventFlagWakeKey(handle),
|
||||
() =>
|
||||
{
|
||||
if (GuestThreadBlocking.ShutdownRequested)
|
||||
if (satisfied)
|
||||
{
|
||||
if (timeoutAddress != 0) _ = TryWriteUInt32(ctx, timeoutAddress, 0);
|
||||
_ = TryWriteResultPattern(ctx, resultAddress, state.Bits);
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
|
||||
return (int)blockedWaitResult;
|
||||
}
|
||||
|
||||
if (TryCompleteSatisfiedWait(ctx, state, pattern, waitMode, resultAddress, out var wokeResult))
|
||||
{
|
||||
if (timeoutAddress != 0) _ = TryWriteUInt32(ctx, timeoutAddress, 0);
|
||||
if (_traceEventFlag) TraceEventFlag($"wait-wake handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} ret=0x{returnRip:X16}");
|
||||
return SetReturn(ctx, wokeResult);
|
||||
}
|
||||
|
||||
var remaining = hostDeadlineMs - Environment.TickCount64;
|
||||
if (timeoutAddress != 0 && remaining <= 0)
|
||||
// Deadline expiry: report timeout with the current bits.
|
||||
if (timeoutAddress != 0)
|
||||
{
|
||||
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
|
||||
_ = TryWriteResultPattern(ctx, resultAddress, state.Bits);
|
||||
if (_traceEventFlag) TraceEventFlag($"wait-timeout handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} ret=0x{returnRip:X16}");
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
|
||||
}
|
||||
|
||||
GuestThreadBlocking.Checkpoint(guestThreadHandle, state.Gate);
|
||||
_ = Monitor.Wait(state.Gate, (int)Math.Min(remaining, GuestThreadBlocking.WaitSliceMilliseconds));
|
||||
_ = TryWriteResultPattern(ctx, resultAddress, state.Bits);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
|
||||
},
|
||||
() =>
|
||||
{
|
||||
if (!TryPrepareBlockedWait(
|
||||
ctx,
|
||||
state,
|
||||
pattern,
|
||||
waitMode,
|
||||
resultAddress,
|
||||
out var preparedResult))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
blockedWaitResult = preparedResult;
|
||||
satisfied = true;
|
||||
return true;
|
||||
},
|
||||
deadline);
|
||||
if (_traceEventFlag) TraceEventFlag($"wait-unsatisfied handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} guest_thread=0x{currentGuestThread:X16} fiber=0x{currentFiber:X16} managed={managedThread} block={requestedBlock} ret=0x{returnRip:X16} frames={FormatFrameChain(ctx)}");
|
||||
if (_traceEventFlag) TraceEventFlag($"wait-object handle=0x{handle:X16} name='{state.Name}' {FormatGuestWaitObject(ctx)}");
|
||||
if (!requestedBlock)
|
||||
{
|
||||
var scheduler = GuestThreadExecution.Scheduler;
|
||||
if (scheduler is null)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN);
|
||||
}
|
||||
|
||||
state.WaitingThreads++;
|
||||
if (_traceEventFlag) TraceEventFlag($"wait-pump handle=0x{handle:X16} pattern=0x{pattern:X16} waiters={state.WaitingThreads} guest_thread=0x{currentGuestThread:X16} fiber=0x{currentFiber:X16} managed={managedThread} ret=0x{returnRip:X16}");
|
||||
var releaseWaiter = true;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
Monitor.Exit(state.Gate);
|
||||
try
|
||||
{
|
||||
scheduler.Pump(ctx, "sceKernelWaitEventFlag");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Monitor.Enter(state.Gate);
|
||||
}
|
||||
|
||||
if (TryCompleteSatisfiedWait(ctx, state, pattern, waitMode, resultAddress, out var pumpedWaitResult))
|
||||
{
|
||||
state.WaitingThreads = Math.Max(0, state.WaitingThreads - 1);
|
||||
releaseWaiter = false;
|
||||
if (_traceEventFlag) TraceEventFlag($"wait-wake handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} waiters={state.WaitingThreads} ret=0x{returnRip:X16}");
|
||||
return SetReturn(ctx, pumpedWaitResult);
|
||||
}
|
||||
|
||||
var remaining = hostDeadlineMs - Environment.TickCount64;
|
||||
if (timeoutAddress != 0 && remaining <= 0)
|
||||
{
|
||||
state.WaitingThreads = Math.Max(0, state.WaitingThreads - 1);
|
||||
releaseWaiter = false;
|
||||
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
|
||||
_ = TryWriteResultPattern(ctx, resultAddress, state.Bits);
|
||||
if (_traceEventFlag) TraceEventFlag($"wait-timeout handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} ret=0x{returnRip:X16}");
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
|
||||
}
|
||||
|
||||
Monitor.Wait(state.Gate, (int)Math.Min(remaining, HostWaitPumpMilliseconds));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (releaseWaiter)
|
||||
{
|
||||
state.WaitingThreads = Math.Max(0, state.WaitingThreads - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
state.WaitingThreads = Math.Max(0, state.WaitingThreads - 1);
|
||||
GuestThreadBlocking.NoteUnblocked(guestThreadHandle);
|
||||
}
|
||||
|
||||
state.WaitingThreads++;
|
||||
if (_traceEventFlag) TraceEventFlag($"wait-block handle=0x{handle:X16} pattern=0x{pattern:X16} waiters={state.WaitingThreads} guest_thread=0x{currentGuestThread:X16} fiber=0x{currentFiber:X16} managed={managedThread} ret=0x{returnRip:X16}");
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Monitor.Exit(state.Gate);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,6 +455,41 @@ public static class KernelEventFlagCompatExports
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryPrepareBlockedWait(
|
||||
CpuContext ctx,
|
||||
EventFlagState state,
|
||||
ulong pattern,
|
||||
uint waitMode,
|
||||
ulong resultAddress,
|
||||
out OrbisGen2Result result)
|
||||
{
|
||||
lock (state.Gate)
|
||||
{
|
||||
result = OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
if (!IsSatisfied(state.Bits, pattern, waitMode))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryWriteResultPattern(ctx, resultAddress, state.Bits))
|
||||
{
|
||||
result = OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyClearMode(state, pattern, waitMode);
|
||||
}
|
||||
|
||||
state.WaitingThreads = Math.Max(0, state.WaitingThreads - 1);
|
||||
if (_traceEventFlag) TraceEventFlag(
|
||||
$"wait-wake pattern=0x{pattern:X16} mode=0x{waitMode:X2} bits=0x{state.Bits:X16} waiters={state.WaitingThreads}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetEventFlagWakeKey(ulong handle) =>
|
||||
$"event_flag:0x{handle:X16}";
|
||||
|
||||
private static bool TryWriteResultPattern(CpuContext ctx, ulong address, ulong bits) =>
|
||||
address == 0 || ctx.TryWriteUInt64(address, bits);
|
||||
|
||||
@@ -414,6 +519,19 @@ public static class KernelEventFlagCompatExports
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryReadByte(CpuContext ctx, ulong address, out byte value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[1];
|
||||
if (!ctx.Memory.TryRead(address, buffer))
|
||||
{
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = buffer[0];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryWriteUInt32(CpuContext ctx, ulong address, uint value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[sizeof(uint)];
|
||||
@@ -466,4 +584,95 @@ public static class KernelEventFlagCompatExports
|
||||
? frame.ReturnRip
|
||||
: 0UL;
|
||||
|
||||
private static string FormatFrameChain(CpuContext ctx)
|
||||
{
|
||||
Span<ulong> returns = stackalloc ulong[4];
|
||||
var count = 0;
|
||||
var frame = ctx[CpuRegister.Rbp];
|
||||
for (var index = 0; index < returns.Length && frame != 0; index++)
|
||||
{
|
||||
if (!ctx.TryReadUInt64(frame, out var nextFrame) ||
|
||||
!ctx.TryReadUInt64(frame + sizeof(ulong), out var returnAddress))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
returns[count++] = returnAddress;
|
||||
if (nextFrame <= frame)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
frame = nextFrame;
|
||||
}
|
||||
|
||||
return count switch
|
||||
{
|
||||
0 => "none",
|
||||
1 => $"0x{returns[0]:X16}",
|
||||
2 => $"0x{returns[0]:X16},0x{returns[1]:X16}",
|
||||
3 => $"0x{returns[0]:X16},0x{returns[1]:X16},0x{returns[2]:X16}",
|
||||
_ => $"0x{returns[0]:X16},0x{returns[1]:X16},0x{returns[2]:X16},0x{returns[3]:X16}",
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatGuestWaitObject(CpuContext ctx)
|
||||
{
|
||||
var r12 = ctx[CpuRegister.R12];
|
||||
var r13 = ctx[CpuRegister.R13];
|
||||
var objectAddress = r12 != 0
|
||||
? r12
|
||||
: r13 >= 0xA8
|
||||
? r13 - 0xA8
|
||||
: 0;
|
||||
|
||||
var builder = new StringBuilder(256);
|
||||
builder.Append($"r12=0x{r12:X16} r13=0x{r13:X16}");
|
||||
if (objectAddress == 0)
|
||||
{
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
builder.Append($" obj=0x{objectAddress:X16}");
|
||||
AppendUInt32(builder, ctx, objectAddress + 0x58, "o58");
|
||||
AppendUInt32(builder, ctx, objectAddress + 0x5C, "o5C");
|
||||
AppendUInt64(builder, ctx, objectAddress + 0x60, "o60");
|
||||
AppendByte(builder, ctx, objectAddress + 0x6C, "state6C");
|
||||
AppendByte(builder, ctx, objectAddress + 0x6D, "o6D");
|
||||
AppendByte(builder, ctx, objectAddress + 0xA0, "waitA0");
|
||||
AppendByte(builder, ctx, objectAddress + 0xA1, "stateA1");
|
||||
AppendByte(builder, ctx, objectAddress + 0xA2, "oA2");
|
||||
AppendUInt64(builder, ctx, objectAddress + 0xA8, "eventA8");
|
||||
if (r13 != 0)
|
||||
{
|
||||
AppendUInt64(builder, ctx, r13, "r13_0");
|
||||
AppendUInt64(builder, ctx, r13 + 8, "r13_8");
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void AppendByte(StringBuilder builder, CpuContext ctx, ulong address, string name)
|
||||
{
|
||||
if (TryReadByte(ctx, address, out var value))
|
||||
{
|
||||
builder.Append($" {name}=0x{value:X2}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendUInt32(StringBuilder builder, CpuContext ctx, ulong address, string name)
|
||||
{
|
||||
if (TryReadUInt32(ctx, address, out var value))
|
||||
{
|
||||
builder.Append($" {name}=0x{value:X8}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendUInt64(StringBuilder builder, CpuContext ctx, ulong address, string name)
|
||||
{
|
||||
if (TryReadUInt64(ctx, address, out var value))
|
||||
{
|
||||
builder.Append($" {name}=0x{value:X16}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,19 @@ public static class KernelEventQueueCompatExports
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class EqueueWaiter : IGuestThreadBlockWaiter
|
||||
{
|
||||
public required CpuContext Ctx { get; init; }
|
||||
public required ulong Handle { get; init; }
|
||||
public required ulong EventsAddress { get; init; }
|
||||
public required int EventCapacity { get; init; }
|
||||
public required ulong OutCountAddress { get; init; }
|
||||
|
||||
public int Resume() => ResumeWaitEqueue(Ctx, Handle, EventsAddress, EventCapacity, OutCountAddress);
|
||||
|
||||
public bool TryWake() => HasPendingEvents(Handle);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "D0OdFMjp46I",
|
||||
ExportName = "sceKernelCreateEqueue",
|
||||
@@ -136,10 +149,10 @@ public static class KernelEventQueueCompatExports
|
||||
_eventQueues.Remove(handle);
|
||||
_pendingEvents.Remove(handle);
|
||||
_registeredEvents.Remove(handle);
|
||||
// Wake any thread parked on this queue so it observes the deletion.
|
||||
Monitor.PulseAll(_eventQueueGate);
|
||||
}
|
||||
|
||||
_wakeKeys.TryRemove(handle, out _);
|
||||
|
||||
TraceEventQueue(ctx, "delete", handle);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
@@ -399,81 +412,59 @@ public static class KernelEventQueueCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
// No events ready: block this host thread in place on the queue gate.
|
||||
// Monitor.Wait releases the gate and parks atomically, so an
|
||||
// EnqueueEvent/TriggerDisplayEvent PulseAll issued the instant after
|
||||
// the emptiness check cannot be lost. kqueue/kevent semantics: sleep
|
||||
// until an event matching a registration is delivered or the timeout
|
||||
// (usec, infinite when the arg pointer is null) lapses; a zero timeout
|
||||
// degrades to an instant poll.
|
||||
long deadline;
|
||||
if (timeoutAddress == 0)
|
||||
{
|
||||
deadline = long.MaxValue;
|
||||
}
|
||||
else if (timeoutUsec == 0)
|
||||
{
|
||||
deadline = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
deadline = Environment.TickCount64 + Math.Max(1L, timeoutUsec / 1000L);
|
||||
}
|
||||
|
||||
TraceEventQueue(ctx, "wait-block", handle);
|
||||
var guestThreadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
|
||||
GuestThreadBlocking.NoteBlocked(guestThreadHandle, "sceKernelWaitEqueue");
|
||||
try
|
||||
{
|
||||
lock (_eventQueueGate)
|
||||
{
|
||||
while (true)
|
||||
if (timeoutAddress == 0 &&
|
||||
GuestThreadExecution.RequestCurrentThreadBlock(
|
||||
ctx,
|
||||
"sceKernelWaitEqueue",
|
||||
GetEventQueueWakeKey(handle),
|
||||
new EqueueWaiter
|
||||
{
|
||||
if ((_pendingEvents.TryGetValue(handle, out var queue) && queue.Count != 0) ||
|
||||
!_eventQueues.Contains(handle) ||
|
||||
GuestThreadBlocking.ShutdownRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var remaining = deadline - Environment.TickCount64;
|
||||
if (timeoutAddress != 0 && remaining <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var slice = timeoutAddress == 0
|
||||
? GuestThreadBlocking.WaitSliceMilliseconds
|
||||
: (int)Math.Min(remaining, GuestThreadBlocking.WaitSliceMilliseconds);
|
||||
GuestThreadBlocking.Checkpoint(guestThreadHandle, _eventQueueGate);
|
||||
_ = Monitor.Wait(_eventQueueGate, slice);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
Ctx = ctx,
|
||||
Handle = handle,
|
||||
EventsAddress = eventsAddress,
|
||||
EventCapacity = eventCapacity,
|
||||
OutCountAddress = outCountAddress,
|
||||
}))
|
||||
{
|
||||
GuestThreadBlocking.NoteUnblocked(guestThreadHandle);
|
||||
}
|
||||
|
||||
deliveredCount = DequeueEvents(ctx, handle, eventsAddress, eventCapacity);
|
||||
if (outCountAddress != 0 && !TryWriteUInt32(ctx, outCountAddress, (uint)deliveredCount))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
if (deliveredCount > 0)
|
||||
{
|
||||
TraceEventQueue(ctx, "wait-deliver", handle);
|
||||
TraceEventQueue(ctx, "wait-block", handle);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
if (timeoutAddress != 0)
|
||||
if (timeoutAddress != 0 && ctx.TryReadUInt64(timeoutAddress, out var timeoutRaw))
|
||||
{
|
||||
var timeoutMicros = timeoutRaw & 0xFFFF_FFFFUL;
|
||||
var deadline = Environment.TickCount64 +
|
||||
Math.Max(1L, (long)Math.Min(timeoutMicros / 1000, int.MaxValue));
|
||||
lock (_eventQueueGate)
|
||||
{
|
||||
while (!HasPendingEvents(handle))
|
||||
{
|
||||
var remaining = deadline - Environment.TickCount64;
|
||||
if (remaining <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
Monitor.Wait(_eventQueueGate, (int)Math.Min(remaining, 100));
|
||||
}
|
||||
}
|
||||
|
||||
deliveredCount = DequeueEvents(ctx, handle, eventsAddress, eventCapacity);
|
||||
if (outCountAddress != 0 && !TryWriteUInt32(ctx, outCountAddress, (uint)deliveredCount))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
if (deliveredCount > 0)
|
||||
{
|
||||
TraceEventQueue(ctx, "wait-timed-deliver", handle);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
TraceEventQueue(ctx, "wait-timeout", handle);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
|
||||
}
|
||||
|
||||
// Reached only on queue deletion or teardown; the guest sees zero events.
|
||||
TraceEventQueue(ctx, "wait", handle);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
@@ -807,6 +798,32 @@ public static class KernelEventQueueCompatExports
|
||||
return triggered;
|
||||
}
|
||||
|
||||
private static int ResumeWaitEqueue(
|
||||
CpuContext ctx,
|
||||
ulong handle,
|
||||
ulong eventsAddress,
|
||||
int eventCapacity,
|
||||
ulong outCountAddress)
|
||||
{
|
||||
var deliveredCount = DequeueEvents(ctx, handle, eventsAddress, eventCapacity);
|
||||
if (outCountAddress != 0 && !TryWriteUInt32(ctx, outCountAddress, (uint)deliveredCount))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
return deliveredCount > 0
|
||||
? (int)OrbisGen2Result.ORBIS_GEN2_OK
|
||||
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
|
||||
}
|
||||
|
||||
private static bool HasPendingEvents(ulong handle)
|
||||
{
|
||||
lock (_eventQueueGate)
|
||||
{
|
||||
return _pendingEvents.TryGetValue(handle, out var events) && events.Count != 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static void QueueOrUpdateEvent(
|
||||
KernelEventDeque queue,
|
||||
KernelQueuedEvent queuedEvent)
|
||||
@@ -824,16 +841,16 @@ public static class KernelEventQueueCompatExports
|
||||
};
|
||||
}
|
||||
|
||||
// Wake threads parked in-place on the queue gate; each re-checks for a
|
||||
// matching pending event. The handle is unused (all queues share one gate)
|
||||
// but kept in the signature so call sites read intent-fully.
|
||||
// Wake keys are formatted once per handle: WakeEventQueue runs on every event
|
||||
// enqueue (vblank/flip edges included), so formatting there is steady string churn.
|
||||
private static readonly ConcurrentDictionary<ulong, string> _wakeKeys = new();
|
||||
|
||||
private static string GetEventQueueWakeKey(ulong handle) =>
|
||||
_wakeKeys.GetOrAdd(handle, static h => $"sceKernelWaitEqueue:{h:X16}");
|
||||
|
||||
private static void WakeEventQueue(ulong handle)
|
||||
{
|
||||
_ = handle;
|
||||
lock (_eventQueueGate)
|
||||
{
|
||||
Monitor.PulseAll(_eventQueueGate);
|
||||
}
|
||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetEventQueueWakeKey(handle));
|
||||
}
|
||||
|
||||
private static int DequeueEvents(CpuContext ctx, ulong handle, ulong eventsAddress, int eventCapacity)
|
||||
|
||||
@@ -362,7 +362,7 @@ public static class KernelExports
|
||||
ExportName = "open",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int Open(CpuContext ctx) => KernelMemoryCompatExports.KernelOpenUnderscore(ctx);
|
||||
public static int Open(CpuContext ctx) => KernelMemoryCompatExports.PosixOpen(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "1G3lF1Gg1k8",
|
||||
@@ -376,7 +376,7 @@ public static class KernelExports
|
||||
ExportName = "fstat",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libc")]
|
||||
public static int Fstat(CpuContext ctx) => KernelMemoryCompatExports.KernelFstat(ctx);
|
||||
public static int Fstat(CpuContext ctx) => KernelMemoryCompatExports.PosixFstat(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "hcuQgD53UxM",
|
||||
|
||||
@@ -267,6 +267,11 @@ public static partial class KernelMemoryCompatExports
|
||||
}
|
||||
|
||||
var hostPath = ResolveGuestPath(guestPath);
|
||||
if (string.IsNullOrEmpty(hostPath))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(hostPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite);
|
||||
@@ -310,6 +315,11 @@ public static partial class KernelMemoryCompatExports
|
||||
|
||||
var fromHost = ResolveGuestPath(fromGuest);
|
||||
var toHost = ResolveGuestPath(toGuest);
|
||||
if (string.IsNullOrEmpty(fromHost) || string.IsNullOrEmpty(toHost))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(fromHost))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -91,6 +91,10 @@ public static class KernelPthreadExtendedCompatExports
|
||||
public PthreadAttrState Attributes { get; set; } = PthreadAttrState.Default;
|
||||
}
|
||||
|
||||
// On the outer class deliberately: a static on the nested state class gives it a type
|
||||
// initializer that first runs on a guest thread and fail-fasts the CLR.
|
||||
private static long _nextRwlockWakeId;
|
||||
|
||||
private sealed class PthreadRwlockState
|
||||
{
|
||||
public object SyncRoot { get; } = new();
|
||||
@@ -101,6 +105,8 @@ public static class KernelPthreadExtendedCompatExports
|
||||
public ulong WriterThreadId { get; set; }
|
||||
public int WaitingWriters { get; set; }
|
||||
|
||||
// See PthreadMutexState.WakeKey.
|
||||
public string WakeKey { get; } = "pthread_rwlock#" + Interlocked.Increment(ref _nextRwlockWakeId).ToString("X");
|
||||
|
||||
public int GetReaderCount(ulong threadId)
|
||||
{
|
||||
@@ -162,6 +168,17 @@ public static class KernelPthreadExtendedCompatExports
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RwlockWaiter : IGuestThreadBlockWaiter
|
||||
{
|
||||
public required PthreadRwlockState Rwlock { get; init; }
|
||||
public required ulong ThreadId { get; init; }
|
||||
public required bool Write { get; init; }
|
||||
|
||||
public int Resume() => (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
|
||||
public bool TryWake() => TryAcquireBlockedRwlock(Rwlock, ThreadId, Write);
|
||||
}
|
||||
|
||||
private readonly record struct TlsKeyState(ulong Destructor);
|
||||
|
||||
private readonly record struct PthreadAttrState(
|
||||
@@ -843,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",
|
||||
@@ -1116,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",
|
||||
@@ -1167,6 +1280,7 @@ public static class KernelPthreadExtendedCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED;
|
||||
}
|
||||
|
||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(rwlock.WakeKey);
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
@@ -1461,30 +1575,35 @@ public static class KernelPthreadExtendedCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
// In-place block: Monitor.Wait releases SyncRoot and parks
|
||||
// atomically, so an unlock's PulseAll cannot be lost. Sliced
|
||||
// only so teardown can unwind parked threads.
|
||||
rwlock.WaitingWriters++;
|
||||
GuestThreadBlocking.NoteBlocked(currentThreadId, "pthread_rwlock_wrlock");
|
||||
var transferredToScheduler = false;
|
||||
try
|
||||
{
|
||||
if (GuestThreadExecution.IsGuestThread &&
|
||||
GuestThreadExecution.TryGetCurrentImportCallFrame(out _) &&
|
||||
GuestThreadExecution.RequestCurrentThreadBlock(
|
||||
ctx,
|
||||
"pthread_rwlock_wrlock",
|
||||
rwlock.WakeKey,
|
||||
new RwlockWaiter { Rwlock = rwlock, ThreadId = currentThreadId, Write = true }))
|
||||
{
|
||||
transferredToScheduler = true;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
while (rwlock.WriterThreadId != 0 || rwlock.ReaderTotalCount != 0 || rwlock.CompatWriterTotalCount != 0)
|
||||
{
|
||||
if (GuestThreadBlocking.ShutdownRequested)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN;
|
||||
}
|
||||
|
||||
GuestThreadBlocking.Checkpoint(currentThreadId, rwlock.SyncRoot);
|
||||
_ = Monitor.Wait(rwlock.SyncRoot, GuestThreadBlocking.WaitSliceMilliseconds);
|
||||
Monitor.Wait(rwlock.SyncRoot);
|
||||
}
|
||||
|
||||
rwlock.WriterThreadId = currentThreadId;
|
||||
}
|
||||
finally
|
||||
{
|
||||
rwlock.WaitingWriters = Math.Max(0, rwlock.WaitingWriters - 1);
|
||||
GuestThreadBlocking.NoteUnblocked(currentThreadId);
|
||||
if (!transferredToScheduler)
|
||||
{
|
||||
rwlock.WaitingWriters = Math.Max(0, rwlock.WaitingWriters - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -1494,26 +1613,20 @@ public static class KernelPthreadExtendedCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
|
||||
}
|
||||
|
||||
if (ReaderMustWaitForRwlock(rwlock, currentThreadId))
|
||||
while (ReaderMustWaitForRwlock(rwlock, currentThreadId))
|
||||
{
|
||||
GuestThreadBlocking.NoteBlocked(currentThreadId, "pthread_rwlock_rdlock");
|
||||
try
|
||||
if (GuestThreadExecution.IsGuestThread &&
|
||||
GuestThreadExecution.TryGetCurrentImportCallFrame(out _) &&
|
||||
GuestThreadExecution.RequestCurrentThreadBlock(
|
||||
ctx,
|
||||
"pthread_rwlock_rdlock",
|
||||
rwlock.WakeKey,
|
||||
new RwlockWaiter { Rwlock = rwlock, ThreadId = currentThreadId, Write = false }))
|
||||
{
|
||||
while (ReaderMustWaitForRwlock(rwlock, currentThreadId))
|
||||
{
|
||||
if (GuestThreadBlocking.ShutdownRequested)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN;
|
||||
}
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
GuestThreadBlocking.Checkpoint(currentThreadId, rwlock.SyncRoot);
|
||||
_ = Monitor.Wait(rwlock.SyncRoot, GuestThreadBlocking.WaitSliceMilliseconds);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
GuestThreadBlocking.NoteUnblocked(currentThreadId);
|
||||
}
|
||||
Monitor.Wait(rwlock.SyncRoot);
|
||||
}
|
||||
|
||||
if (rwlock.WriterThreadId != 0 ||
|
||||
@@ -1530,6 +1643,33 @@ public static class KernelPthreadExtendedCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
private static bool TryAcquireBlockedRwlock(PthreadRwlockState rwlock, ulong currentThreadId, bool write)
|
||||
{
|
||||
lock (rwlock.SyncRoot)
|
||||
{
|
||||
if (write)
|
||||
{
|
||||
if (rwlock.WriterThreadId != 0 || rwlock.ReaderTotalCount != 0 || rwlock.CompatWriterTotalCount != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DetectRwlockWriterConflict(0, rwlock, currentThreadId, "wrlock-resume");
|
||||
rwlock.WriterThreadId = currentThreadId;
|
||||
rwlock.WaitingWriters = Math.Max(0, rwlock.WaitingWriters - 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ReaderMustWaitForRwlock(rwlock, currentThreadId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
rwlock.AddReader(currentThreadId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Call while holding lock(rwlock.SyncRoot): an existing reader/writer here means a
|
||||
// writer would share the rwlock with another holder — a data race.
|
||||
private static void DetectRwlockWriterConflict(ulong resolvedAddress, PthreadRwlockState rwlock, ulong currentThreadId, string site)
|
||||
@@ -1560,6 +1700,8 @@ public static class KernelPthreadExtendedCompatExports
|
||||
rwlock.GetReaderCount(currentThreadId) == 0;
|
||||
}
|
||||
|
||||
private static string GetRwlockWakeKey(ulong rwlockAddress) => $"pthread_rwlock:0x{rwlockAddress:X16}";
|
||||
|
||||
public static string? DumpRwlockStateForStall(ulong rwlockAddress)
|
||||
{
|
||||
PthreadRwlockState? rwlock;
|
||||
@@ -1773,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);
|
||||
}
|
||||
|
||||
@@ -97,6 +97,8 @@ public static class KernelRuntimeCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
GuestThreadExecution.Scheduler?.Pump(ctx, "sceKernelUsleep");
|
||||
|
||||
if (micros < 1000)
|
||||
{
|
||||
// Guest worker pools use usleep(1) as a polling backoff. Do not turn
|
||||
@@ -1302,30 +1304,6 @@ public static class KernelRuntimeCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
// Same (pc, flags, out-info) contract as sceKernelGetModuleInfoForUnwind,
|
||||
// surfaced through libSceSysmodule on Gen5; the unwinder threads whichever
|
||||
// one the module's libc was linked against.
|
||||
[SysAbiExport(
|
||||
Nid = "4fU5yvOkVG4",
|
||||
ExportName = "sceSysmoduleGetModuleInfoForUnwind",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceSysmodule")]
|
||||
public static int SysmoduleGetModuleInfoForUnwind(CpuContext ctx) => KernelGetModuleInfoForUnwind(ctx);
|
||||
|
||||
// libc unwinder predicate: is this PC the kernel signal-return trampoline?
|
||||
// Guest signal returns do not run through a guest-visible trampoline here,
|
||||
// so no PC is ever one — report false and let the frame unwind normally.
|
||||
[SysAbiExport(
|
||||
Nid = "crb5j7mkk1c",
|
||||
ExportName = "_is_signal_return",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libc")]
|
||||
public static int IsSignalReturn(CpuContext ctx)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "nu4a0-arQis",
|
||||
ExportName = "sceKernelAioInitializeParam",
|
||||
@@ -2080,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",
|
||||
@@ -2119,6 +2104,7 @@ public static class KernelRuntimeCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
GuestThreadExecution.Scheduler?.Pump(ctx, posix ? "nanosleep" : "sceKernelNanosleep");
|
||||
var totalTicks = tvSec * TimeSpan.TicksPerSecond + Math.Max(tvNsec / 100L, 1L);
|
||||
try
|
||||
{
|
||||
|
||||
@@ -17,6 +17,8 @@ public static class KernelSemaphoreCompatExports
|
||||
private sealed class KernelSemaphoreState
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
// Formatted once at creation; signal/wait/cancel/delete all wake through this key.
|
||||
public required string WakeKey { get; init; }
|
||||
public required int InitialCount { get; init; }
|
||||
public required int MaxCount { get; init; }
|
||||
public int Count { get; set; }
|
||||
@@ -63,6 +65,7 @@ public static class KernelSemaphoreCompatExports
|
||||
_semaphores[handle] = new KernelSemaphoreState
|
||||
{
|
||||
Name = name,
|
||||
WakeKey = GetSemaphoreWakeKey(handle),
|
||||
InitialCount = initialCount,
|
||||
MaxCount = maxCount,
|
||||
Count = initialCount,
|
||||
@@ -108,79 +111,169 @@ public static class KernelSemaphoreCompatExports
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
// In-place block on the semaphore gate. Monitor.Wait releases the gate
|
||||
// and parks atomically, so a concurrent SignalSema's PulseAll cannot be
|
||||
// lost between the count check and the park. Semantics mirror FreeBSD
|
||||
// ksem / sem_wait: acquire when count>=need, else sleep until posted or
|
||||
// the deadline lapses. Waits are sliced only so teardown can unwind.
|
||||
var deadlineMs = timeoutAddress != 0
|
||||
? Environment.TickCount64 + Math.Max(1L, timeoutUsec / 1000L)
|
||||
: long.MaxValue;
|
||||
|
||||
lock (semaphore.Gate)
|
||||
{
|
||||
if (semaphore.Count < needCount)
|
||||
if (semaphore.Count >= needCount)
|
||||
{
|
||||
semaphore.WaitingThreads++;
|
||||
semaphore.Count -= needCount;
|
||||
if (timeoutAddress != 0)
|
||||
{
|
||||
_ = TryWriteUInt32(ctx, timeoutAddress, timeoutUsec);
|
||||
}
|
||||
|
||||
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)}");
|
||||
}
|
||||
|
||||
var guestThreadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
|
||||
GuestThreadBlocking.NoteBlocked(guestThreadHandle, "sceKernelWaitSema");
|
||||
try
|
||||
{
|
||||
while (semaphore.Count < needCount)
|
||||
{
|
||||
if (GuestThreadBlocking.ShutdownRequested)
|
||||
{
|
||||
if (timeoutAddress != 0)
|
||||
{
|
||||
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
|
||||
}
|
||||
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
|
||||
}
|
||||
|
||||
var remaining = deadlineMs - Environment.TickCount64;
|
||||
if (timeoutAddress != 0 && remaining <= 0)
|
||||
{
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"wait-timeout handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
||||
}
|
||||
|
||||
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
|
||||
}
|
||||
|
||||
GuestThreadBlocking.Checkpoint(guestThreadHandle, semaphore.Gate);
|
||||
_ = Monitor.Wait(semaphore.Gate, (int)Math.Min(remaining, GuestThreadBlocking.WaitSliceMilliseconds));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
|
||||
GuestThreadBlocking.NoteUnblocked(guestThreadHandle);
|
||||
TraceSemaphore($"wait handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)}");
|
||||
}
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
semaphore.Count -= needCount;
|
||||
semaphore.WaitingThreads++;
|
||||
}
|
||||
|
||||
// Block cooperatively: the wake predicate atomically acquires the
|
||||
// tokens (so a wake commits the acquisition), while the resume
|
||||
// handler distinguishes a real acquisition from a deadline expiry.
|
||||
var acquired = false;
|
||||
var deadline = timeoutAddress != 0
|
||||
? GuestThreadExecution.ComputeDeadlineTimestamp(TimeSpan.FromMicroseconds(timeoutUsec))
|
||||
: 0;
|
||||
|
||||
bool WakePredicate()
|
||||
{
|
||||
lock (semaphore.Gate)
|
||||
{
|
||||
if (semaphore.Count >= needCount)
|
||||
{
|
||||
semaphore.Count -= needCount;
|
||||
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
|
||||
acquired = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
int ResumeWait()
|
||||
{
|
||||
if (timeoutAddress != 0)
|
||||
{
|
||||
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
|
||||
}
|
||||
|
||||
if (acquired)
|
||||
{
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"wait-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
||||
}
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
lock (semaphore.Gate)
|
||||
{
|
||||
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
|
||||
}
|
||||
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"wait handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)}");
|
||||
TraceSemaphore($"wait-timeout handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
||||
}
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
|
||||
}
|
||||
|
||||
if (GuestThreadExecution.RequestCurrentThreadBlock(
|
||||
ctx,
|
||||
"sceKernelWaitSema",
|
||||
GetSemaphoreWakeKey(handle),
|
||||
ResumeWait,
|
||||
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)}");
|
||||
}
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
// Not a guest thread (or no scheduler): fall back to a host-thread
|
||||
// wait so the semantics still hold on non-cooperative callers.
|
||||
return WaitSemaphoreOnHostThread(ctx, semaphore, handle, needCount, timeoutAddress, timeoutUsec);
|
||||
}
|
||||
|
||||
private static int WaitSemaphoreOnHostThread(
|
||||
CpuContext ctx,
|
||||
KernelSemaphoreState semaphore,
|
||||
uint handle,
|
||||
int needCount,
|
||||
ulong timeoutAddress,
|
||||
uint timeoutUsec)
|
||||
{
|
||||
var deadlineMs = timeoutAddress != 0
|
||||
? Environment.TickCount64 + Math.Max(1L, timeoutUsec / 1000L)
|
||||
: long.MaxValue;
|
||||
lock (semaphore.Gate)
|
||||
{
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore(
|
||||
$"wait-host-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} " +
|
||||
$"count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} {FormatCallSite(ctx)}");
|
||||
}
|
||||
while (semaphore.Count < needCount)
|
||||
{
|
||||
var remaining = deadlineMs - Environment.TickCount64;
|
||||
if (timeoutAddress != 0 && remaining <= 0)
|
||||
{
|
||||
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
|
||||
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
|
||||
}
|
||||
|
||||
Monitor.Wait(semaphore.Gate, (int)Math.Min(remaining, 100));
|
||||
}
|
||||
|
||||
semaphore.Count -= needCount;
|
||||
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore(
|
||||
$"wait-host-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
|
||||
}
|
||||
if (timeoutAddress != 0)
|
||||
{
|
||||
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
|
||||
}
|
||||
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetSemaphoreWakeKey(uint handle) => $"sceKernelWaitSema:{handle:X8}";
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "12wOHk8ywb0",
|
||||
ExportName = "sceKernelPollSema",
|
||||
@@ -243,7 +336,7 @@ public static class KernelSemaphoreCompatExports
|
||||
}
|
||||
|
||||
semaphore.Count += signalCount;
|
||||
// Wake threads parked in-place on the gate; each re-checks the count.
|
||||
// Wake host-thread waiters parked in the fallback path.
|
||||
Monitor.PulseAll(semaphore.Gate);
|
||||
if (_traceSema)
|
||||
{
|
||||
@@ -251,6 +344,9 @@ public static class KernelSemaphoreCompatExports
|
||||
}
|
||||
}
|
||||
|
||||
// Wake cooperatively-blocked guest threads; their wake predicate
|
||||
// acquires the tokens atomically, so this respects the new count.
|
||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetSemaphoreWakeKey(handle));
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
@@ -279,6 +375,7 @@ public static class KernelSemaphoreCompatExports
|
||||
}
|
||||
|
||||
semaphore.Count = setCount < 0 ? semaphore.InitialCount : setCount;
|
||||
semaphore.WaitingThreads = 0;
|
||||
Monitor.PulseAll(semaphore.Gate);
|
||||
if (_traceSema)
|
||||
{
|
||||
@@ -286,6 +383,7 @@ public static class KernelSemaphoreCompatExports
|
||||
}
|
||||
}
|
||||
|
||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetSemaphoreWakeKey(handle));
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
@@ -333,6 +431,7 @@ public static class KernelSemaphoreCompatExports
|
||||
_semaphores[handle] = new KernelSemaphoreState
|
||||
{
|
||||
Name = $"posix@0x{semaphoreAddress:X16}",
|
||||
WakeKey = GetSemaphoreWakeKey(handle),
|
||||
InitialCount = initialCount,
|
||||
MaxCount = int.MaxValue,
|
||||
Count = initialCount,
|
||||
|
||||
@@ -14,35 +14,44 @@ namespace SharpEmu.Libs.Kernel;
|
||||
// loop; left unimplemented, every wait returns immediately and the runtime
|
||||
// busy-spins forever (millions of calls, no forward progress).
|
||||
//
|
||||
// Waits block in place on a per-address gate (see GuestThreadBlocking):
|
||||
// Monitor.Wait releases the gate and parks atomically, so a wake's generation
|
||||
// bump + PulseAll cannot be lost between the generation check and the park.
|
||||
// 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 bounded by a self-heal deadline and treated as a
|
||||
// spurious-wakeup-tolerant park: the guest re-checks its own condition after
|
||||
// resuming, which futex callers already tolerate. Wake-one degrades to
|
||||
// wake-all for the same reason (each resumed waiter re-evaluates).
|
||||
// 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 bound. Real releases come from the wake side; this only limits
|
||||
// how long a wait that genuinely raced/missed its wake stays parked before
|
||||
// the guest re-evaluates. Kept large: a short bound turns every parked
|
||||
// waiter into a hot re-poll that steals CPU from the threads that would
|
||||
// issue the wake, so it must be a rare last resort, not a spin substitute.
|
||||
// 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);
|
||||
|
||||
private static readonly ConcurrentDictionary<ulong, object> _addressGates = new();
|
||||
// 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
|
||||
// stays parked while it is unchanged; a wake bumps it first, then pulses
|
||||
// the gate, so a wait between its generation check and its park still
|
||||
// observes the bump (the check happens under the gate).
|
||||
// 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",
|
||||
@@ -57,32 +66,31 @@ public static class KernelSyncOnAddressCompatExports
|
||||
}
|
||||
|
||||
var observedGeneration = CurrentGeneration(address);
|
||||
var gate = _addressGates.GetOrAdd(address, static _ => new object());
|
||||
var deadlineMs = Environment.TickCount64 + (long)WaitSelfHealTimeout.TotalMilliseconds;
|
||||
var guestThreadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
|
||||
GuestThreadBlocking.NoteBlocked(guestThreadHandle, "sceKernelSyncOnAddressWait");
|
||||
try
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
while (CurrentGeneration(address) == observedGeneration &&
|
||||
!GuestThreadBlocking.ShutdownRequested)
|
||||
{
|
||||
var remaining = deadlineMs - Environment.TickCount64;
|
||||
if (remaining <= 0)
|
||||
{
|
||||
// Self-heal: resume and let the guest re-check its condition.
|
||||
break;
|
||||
}
|
||||
var deadline = GuestThreadExecution.ComputeDeadlineTimestamp(WaitSelfHealTimeout);
|
||||
|
||||
GuestThreadBlocking.Checkpoint(guestThreadHandle, gate);
|
||||
_ = Monitor.Wait(gate, (int)Math.Min(remaining, GuestThreadBlocking.WaitSliceMilliseconds));
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
// 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))
|
||||
{
|
||||
GuestThreadBlocking.NoteUnblocked(guestThreadHandle);
|
||||
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);
|
||||
@@ -101,13 +109,18 @@ public static class KernelSyncOnAddressCompatExports
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
// Bump the generation first so a wait that has checked but not yet
|
||||
// parked (it holds the gate for both) observes the change; then pulse
|
||||
// parked waiters. rsi's wake count degrades to wake-all — resumed
|
||||
// waiters re-evaluate their own condition, which futex callers tolerate.
|
||||
// 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);
|
||||
|
||||
if (_addressGates.TryGetValue(address, out var gate))
|
||||
GuestThreadExecution.Scheduler?.WakeBlockedThreads(WakeKey(address), wakeCount);
|
||||
|
||||
if (_hostAddressGates.TryGetValue(address, out var gate))
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -69,6 +69,23 @@ 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;
|
||||
}
|
||||
|
||||
[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)
|
||||
|
||||
@@ -10,6 +10,11 @@ public static class NpWebApi2Exports
|
||||
private const int NpWebApi2ErrorInvalidArgument = unchecked((int)0x80553402);
|
||||
|
||||
private static int _initialized;
|
||||
private static int _nextLibraryContextHandle;
|
||||
private static int _nextPushEventHandle;
|
||||
private static int _nextUserContextHandle = 1000;
|
||||
private static readonly object _contextGate = new();
|
||||
private static readonly HashSet<int> _libraryContexts = [];
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "+o9816YQhqQ",
|
||||
@@ -26,9 +31,28 @@ public static class NpWebApi2Exports
|
||||
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
|
||||
}
|
||||
|
||||
var libraryContextId = CreateLibraryContextId();
|
||||
Interlocked.Exchange(ref _initialized, 1);
|
||||
TraceNpWebApi2("init", httpContextId, poolSize);
|
||||
return ctx.SetReturn(0);
|
||||
return ctx.SetReturn(libraryContextId);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "MsaFhR+lPE4",
|
||||
ExportName = "sceNpWebApi2PushEventCreateFilter",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNpWebApi2")]
|
||||
public static int NpWebApi2PushEventCreateFilter(CpuContext ctx)
|
||||
{
|
||||
var libraryContextId = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
if (!IsValidLibraryContextId(libraryContextId))
|
||||
{
|
||||
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
|
||||
}
|
||||
|
||||
var filterHandle = Interlocked.Increment(ref _nextPushEventHandle);
|
||||
TraceNpWebApi2("push-event-create-filter", libraryContextId, (ulong)filterHandle);
|
||||
return ctx.SetReturn(filterHandle);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
@@ -38,9 +62,16 @@ public static class NpWebApi2Exports
|
||||
LibraryName = "libSceNpWebApi2")]
|
||||
public static int NpWebApi2InitializeAlt(CpuContext ctx)
|
||||
{
|
||||
var libraryContextId = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
if (!IsValidLibraryContextId(libraryContextId))
|
||||
{
|
||||
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
|
||||
}
|
||||
|
||||
var handle = CreatePushEventHandle();
|
||||
Interlocked.Exchange(ref _initialized, 1);
|
||||
TraceNpWebApi2("init-alt", unchecked((int)ctx[CpuRegister.Rdi]), ctx[CpuRegister.Rsi]);
|
||||
return ctx.SetReturn(0);
|
||||
TraceNpWebApi2("init-alt", libraryContextId, 0);
|
||||
return ctx.SetReturn(handle);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
@@ -50,10 +81,23 @@ public static class NpWebApi2Exports
|
||||
LibraryName = "libSceNpWebApi2")]
|
||||
public static int NpWebApi2CreateUserContext(CpuContext ctx)
|
||||
{
|
||||
// No PSN backend: refuse user-context creation so the title's online
|
||||
// layer backs off instead of driving a half-created context handle.
|
||||
TraceNpWebApi2("create-user-context", unchecked((int)ctx[CpuRegister.Rdi]), ctx[CpuRegister.Rsi]);
|
||||
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
|
||||
var libraryContextId = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var userId = unchecked((int)ctx[CpuRegister.Rsi]);
|
||||
|
||||
TraceNpWebApi2(
|
||||
"create-user-context",
|
||||
libraryContextId,
|
||||
unchecked((uint)userId));
|
||||
|
||||
if (Volatile.Read(ref _initialized) == 0 ||
|
||||
!IsValidLibraryContextId(libraryContextId) ||
|
||||
userId == -1)
|
||||
{
|
||||
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
|
||||
}
|
||||
|
||||
var userContextId = Interlocked.Increment(ref _nextUserContextHandle);
|
||||
return ctx.SetReturn(userContextId);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
@@ -64,11 +108,57 @@ public static class NpWebApi2Exports
|
||||
public static int NpWebApi2Terminate(CpuContext ctx)
|
||||
{
|
||||
var libraryContextId = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
Interlocked.Exchange(ref _initialized, 0);
|
||||
if (!IsValidLibraryContextId(libraryContextId))
|
||||
{
|
||||
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
|
||||
}
|
||||
|
||||
RemoveLibraryContextId(libraryContextId);
|
||||
TraceNpWebApi2("term", libraryContextId, 0);
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
private static int CreateLibraryContextId()
|
||||
{
|
||||
var handle = Interlocked.Increment(ref _nextLibraryContextHandle);
|
||||
lock (_contextGate)
|
||||
{
|
||||
_libraryContexts.Add(handle);
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
private static int CreatePushEventHandle()
|
||||
{
|
||||
return Interlocked.Increment(ref _nextPushEventHandle);
|
||||
}
|
||||
|
||||
private static bool IsValidLibraryContextId(int libraryContextId)
|
||||
{
|
||||
if (libraryContextId <= 0 || libraryContextId >= 0x8000)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_contextGate)
|
||||
{
|
||||
return _libraryContexts.Contains(libraryContextId);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RemoveLibraryContextId(int libraryContextId)
|
||||
{
|
||||
lock (_contextGate)
|
||||
{
|
||||
_libraryContexts.Remove(libraryContextId);
|
||||
if (_libraryContexts.Count == 0)
|
||||
{
|
||||
Interlocked.Exchange(ref _initialized, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void TraceNpWebApi2(string operation, int id, ulong arg0)
|
||||
{
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NP_WEB_API2"), "1", StringComparison.Ordinal))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
@@ -698,14 +698,22 @@ public static class PlayGoExports
|
||||
var hasMetadata = File.Exists(playGoDat) || File.Exists(scenarioJson) || File.Exists(chunkDefsXml);
|
||||
if (!hasMetadata)
|
||||
{
|
||||
// No PlayGo sidecar: report a fully-installed single chunk. Available must
|
||||
// stay true or scePlayGoOpen fails with NotSupportPlayGo (fatal PS5-component
|
||||
// init failure for UE titles); chunk 0 reports LocalFast and every other id
|
||||
// returns BAD_CHUNK_ID, terminating title-side chunk enumeration.
|
||||
TracePlayGo("metadata_missing; fully-installed single chunk");
|
||||
// No PlayGo sidecar: derive the installed chunk set from the pak files
|
||||
// actually present on disk. A locally dumped title has all of its data
|
||||
// installed, and a package that splits content across chunks names them
|
||||
// pakchunk<N>-<platform>.pak, so those N are exactly the chunks that
|
||||
// exist. Reporting only chunk 0 told such a title its remaining content
|
||||
// was missing: The Invincible (PPSA06426) ships pakchunk0..8 and spun
|
||||
// forever re-querying scePlayGoGetLocus for a chunk that never became
|
||||
// available. Available must stay true or scePlayGoOpen fails with
|
||||
// NotSupportPlayGo (fatal PS5-component init failure for UE titles).
|
||||
// Ids outside the discovered set still return BAD_CHUNK_ID, so
|
||||
// title-side chunk enumeration still terminates.
|
||||
var installedChunkIds = DiscoverInstalledChunkIds(app0Root);
|
||||
TracePlayGo($"metadata_missing; fully-installed chunks=[{string.Join(',', installedChunkIds)}]");
|
||||
return new PlayGoMetadata(
|
||||
true,
|
||||
[(ushort)0],
|
||||
installedChunkIds,
|
||||
PlayGoChunkIdKnowledge.Authoritative);
|
||||
}
|
||||
|
||||
@@ -718,6 +726,41 @@ public static class PlayGoExports
|
||||
: PlayGoChunkIdKnowledge.Authoritative);
|
||||
}
|
||||
|
||||
// Chunk ids for a title that ships no PlayGo sidecar, taken from the
|
||||
// pakchunk<N>-<platform>.pak files on disk. Chunk 0 is always included: it
|
||||
// is the base chunk and must resolve even for a title with no pak files at
|
||||
// all (which keeps the single-chunk behaviour for such titles).
|
||||
private static ushort[] DiscoverInstalledChunkIds(string app0Root)
|
||||
{
|
||||
var ids = new SortedSet<ushort> { 0 };
|
||||
try
|
||||
{
|
||||
foreach (var pakFile in Directory.EnumerateFiles(app0Root, "pakchunk*.pak", SearchOption.AllDirectories))
|
||||
{
|
||||
var name = Path.GetFileNameWithoutExtension(pakFile);
|
||||
var digits = name.AsSpan("pakchunk".Length);
|
||||
var length = 0;
|
||||
while (length < digits.Length && char.IsAsciiDigit(digits[length]))
|
||||
{
|
||||
length++;
|
||||
}
|
||||
|
||||
if (length > 0 && ushort.TryParse(digits[..length], out var chunkId))
|
||||
{
|
||||
ids.Add(chunkId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
|
||||
return ids.ToArray();
|
||||
}
|
||||
|
||||
private static ushort[] LoadChunkIds(string chunkDefsXml)
|
||||
{
|
||||
if (!File.Exists(chunkDefsXml))
|
||||
|
||||
@@ -28,6 +28,7 @@ public static class SaveDataExports
|
||||
private const ulong ResultInfosOffset = 0x20;
|
||||
private const uint SortKeyFreeBlocks = 5;
|
||||
private const uint SortOrderDescent = 1;
|
||||
private const uint MountModeReadOnly = 1u << 0;
|
||||
private const uint MountModeCreate = 1u << 2;
|
||||
private const uint MountModeCreate2 = 1u << 5;
|
||||
private const int MountResultSize = 0x40;
|
||||
@@ -713,16 +714,43 @@ public static class SaveDataExports
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
if (userId < 0 || string.IsNullOrWhiteSpace(dirName))
|
||||
return MountSaveData(
|
||||
ctx,
|
||||
"mount3",
|
||||
userId,
|
||||
ResolveConfiguredTitleId(),
|
||||
dirName,
|
||||
blocks,
|
||||
systemBlocks,
|
||||
mountMode,
|
||||
resource,
|
||||
mode,
|
||||
resultAddress);
|
||||
}
|
||||
|
||||
private static int MountSaveData(
|
||||
CpuContext ctx,
|
||||
string operation,
|
||||
int userId,
|
||||
string titleId,
|
||||
string dirName,
|
||||
ulong blocks,
|
||||
ulong systemBlocks,
|
||||
uint mountMode,
|
||||
uint resource,
|
||||
uint mode,
|
||||
ulong resultAddress)
|
||||
{
|
||||
if (userId < 0 || string.IsNullOrWhiteSpace(titleId) || string.IsNullOrWhiteSpace(dirName))
|
||||
{
|
||||
return SetReturn(ctx, OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var titleId = ResolveConfiguredTitleId();
|
||||
var sanitizedTitleId = SanitizePathSegment(titleId.Trim());
|
||||
var savePath = Path.Combine(
|
||||
ResolveTitleSaveRoot(userId, titleId),
|
||||
ResolveTitleSaveRoot(userId, sanitizedTitleId),
|
||||
SanitizePathSegment(dirName));
|
||||
var existed = Directory.Exists(savePath);
|
||||
var create = (mountMode & MountModeCreate) != 0;
|
||||
@@ -760,7 +788,7 @@ public static class SaveDataExports
|
||||
}
|
||||
|
||||
TraceSaveData(
|
||||
$"mount3 user={userId} title={titleId} dir={dirName} blocks={blocks} " +
|
||||
$"{operation} user={userId} title={sanitizedTitleId} dir={dirName} blocks={blocks} " +
|
||||
$"system_blocks={systemBlocks} mount_mode=0x{mountMode:X} resource={resource} mode={mode} " +
|
||||
$"mount_point={mountPoint} created={!existed} root='{savePath}'");
|
||||
return SetReturn(ctx, 0);
|
||||
@@ -779,6 +807,52 @@ public static class SaveDataExports
|
||||
}
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "WAzWTZm1H+I",
|
||||
ExportName = "sceSaveDataTransferringMount",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataTransferringMount(CpuContext ctx)
|
||||
{
|
||||
var mountAddress = ctx[CpuRegister.Rdi];
|
||||
var resultAddress = ctx[CpuRegister.Rsi];
|
||||
if (mountAddress == 0 || resultAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
if (!TryReadInt32(ctx, mountAddress, out var userId) ||
|
||||
!ctx.TryReadUInt64(mountAddress + 0x08, out var titleIdAddress) ||
|
||||
!ctx.TryReadUInt64(mountAddress + 0x10, out var dirNameAddress) ||
|
||||
titleIdAddress == 0 ||
|
||||
dirNameAddress == 0 ||
|
||||
!TryReadFixedAscii(ctx, titleIdAddress, SaveDataTitleIdSize, out var titleId) ||
|
||||
!TryReadFixedAscii(ctx, dirNameAddress, SaveDataDirNameSize, out var dirName))
|
||||
{
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
return MountSaveData(
|
||||
ctx,
|
||||
"transferring_mount",
|
||||
userId,
|
||||
titleId,
|
||||
dirName,
|
||||
0,
|
||||
0,
|
||||
MountModeReadOnly,
|
||||
0,
|
||||
0,
|
||||
resultAddress);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "RjMlsR8EXrw",
|
||||
ExportName = "sceSaveDataTransferringMountPs4",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataTransferringMountPs4(CpuContext ctx) => SaveDataTransferringMount(ctx);
|
||||
|
||||
private static int _nextTransactionResource;
|
||||
[SysAbiExport(
|
||||
Nid = "gjRZNnw0JPE",
|
||||
@@ -787,33 +861,48 @@ public static class SaveDataExports
|
||||
LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataCreateTransactionResource(CpuContext ctx)
|
||||
{
|
||||
// Demon's Souls first-run call:
|
||||
// RDI = 0xC0000, RSI = RDX + 8, RDX = resource output.
|
||||
// Writing integer handle 1 makes the title dereference [1 + 8],
|
||||
// causing the repeatable access violation at guest address 0x9.
|
||||
var desWorkSize = ctx[CpuRegister.Rdi];
|
||||
var desWorkAddress = ctx[CpuRegister.Rsi];
|
||||
var desResourceAddress = ctx[CpuRegister.Rdx];
|
||||
|
||||
if (desWorkSize == 0xC0000 &&
|
||||
desResourceAddress != 0 &&
|
||||
desResourceAddress <= ulong.MaxValue - sizeof(ulong) &&
|
||||
desWorkAddress == desResourceAddress + sizeof(ulong))
|
||||
{
|
||||
if (!ctx.TryWriteUInt64(desResourceAddress, 0))
|
||||
{
|
||||
return SetReturn(
|
||||
ctx,
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
TraceSaveData(
|
||||
$"create_transaction_resource_des_guard " +
|
||||
$"work_size=0x{desWorkSize:X} " +
|
||||
$"work=0x{desWorkAddress:X} " +
|
||||
$"resource_addr=0x{desResourceAddress:X} resource=0x0");
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
var userId = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var reserved = ctx[CpuRegister.Rsi];
|
||||
|
||||
var id = (uint)Interlocked.Increment(ref _nextTransactionResource);
|
||||
|
||||
// The resource-out pointer's argument slot varies by SDK revision: some
|
||||
// callers pass it in rdx, others in rcx (a 4-arg form where rdx holds a
|
||||
// count/flag). Void Terrarium passes rdx=0x1 (not a pointer) and the
|
||||
// real out-pointer in rcx. Probe the plausible candidates and write the
|
||||
// handle to the first writable one instead of faulting on a bad rdx.
|
||||
// This is a stub-level create (matches shadPS4's return-OK semantics);
|
||||
// never return MEMORY_FAULT for it, or the guest treats savedata init as
|
||||
// failed and never advances.
|
||||
// A small RDX value is a flag, and RCX contains the output address.
|
||||
// A larger RDX value is the output address for the older ABI.
|
||||
var resourceAddress = 0UL;
|
||||
foreach (var candidate in new[]
|
||||
{
|
||||
ctx[CpuRegister.Rdx],
|
||||
ctx[CpuRegister.Rcx],
|
||||
ctx[CpuRegister.R8],
|
||||
ctx[CpuRegister.R9],
|
||||
})
|
||||
var selectedAddress = SelectTransactionResourceAddress(
|
||||
ctx[CpuRegister.Rdx],
|
||||
ctx[CpuRegister.Rcx]);
|
||||
if (selectedAddress != 0 && TryWriteUInt32(ctx, selectedAddress, id))
|
||||
{
|
||||
if (candidate != 0 && TryWriteUInt32(ctx, candidate, id))
|
||||
{
|
||||
resourceAddress = candidate;
|
||||
break;
|
||||
}
|
||||
resourceAddress = selectedAddress;
|
||||
}
|
||||
|
||||
TraceSaveData(
|
||||
@@ -822,6 +911,16 @@ public static class SaveDataExports
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
internal static ulong SelectTransactionResourceAddress(ulong rdx, ulong rcx)
|
||||
{
|
||||
if (rdx == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return rdx <= ushort.MaxValue ? rcx : rdx;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "lJUQuaKqoKY",
|
||||
ExportName = "sceSaveDataDeleteTransactionResource",
|
||||
|
||||
@@ -12,6 +12,9 @@ public static class ShareExports
|
||||
|
||||
private static int _initialized;
|
||||
private static string _contentParam = string.Empty;
|
||||
private static readonly object _callbackGate = new();
|
||||
private static ulong _contentEventCallback;
|
||||
private static ulong _contentEventCallbackArgument;
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "nBDD66kiFW8",
|
||||
@@ -62,6 +65,56 @@ public static class ShareExports
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "Sygnk9dr5WQ",
|
||||
ExportName = "sceShareRegisterContentEventCallback",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceShareUtility")]
|
||||
public static int ShareRegisterContentEventCallback(CpuContext ctx)
|
||||
{
|
||||
var callback = ctx[CpuRegister.Rdi];
|
||||
var argument = ctx[CpuRegister.Rsi];
|
||||
if (callback == 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
lock (_callbackGate)
|
||||
{
|
||||
_contentEventCallback = callback;
|
||||
_contentEventCallbackArgument = argument;
|
||||
}
|
||||
|
||||
TraceShare($"register_content_event_callback fn=0x{callback:X16} arg=0x{argument:X16}");
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "KnsfHKmZqFA",
|
||||
ExportName = "sceShareUnregisterContentEventCallback",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceShareUtility")]
|
||||
public static int ShareUnregisterContentEventCallback(CpuContext ctx)
|
||||
{
|
||||
var callback = ctx[CpuRegister.Rdi];
|
||||
if (callback == 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
lock (_callbackGate)
|
||||
{
|
||||
if (_contentEventCallback == callback)
|
||||
{
|
||||
_contentEventCallback = 0;
|
||||
_contentEventCallbackArgument = 0;
|
||||
}
|
||||
}
|
||||
|
||||
TraceShare($"unregister_content_event_callback fn=0x{callback:X16}");
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
private static bool TryReadNullTerminatedUtf8(CpuContext ctx, ulong address, int maxLength, out string value)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[maxLength];
|
||||
|
||||
@@ -25,6 +25,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FFmpeg.AutoGen" />
|
||||
<PackageReference Include="Silk.NET.Input" />
|
||||
<PackageReference Include="Silk.NET.Vulkan" />
|
||||
<PackageReference Include="Silk.NET.Vulkan.Extensions.EXT" />
|
||||
|
||||
@@ -118,7 +118,7 @@ public static class UserServiceExports
|
||||
var userId = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var nameAddress = ctx[CpuRegister.Rsi];
|
||||
var capacity = ctx[CpuRegister.Rdx];
|
||||
if (userId != PrimaryUserId)
|
||||
if (userId != PrimaryUserId && userId != 1)
|
||||
{
|
||||
return SetReturn(ctx, OrbisUserServiceErrorInvalidParameter);
|
||||
}
|
||||
@@ -144,6 +144,16 @@ public static class UserServiceExports
|
||||
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
// Title-captured alias NID for the same username query.
|
||||
#pragma warning disable SHEM004
|
||||
[SysAbiExport(
|
||||
Nid = "znaWI0gpuo8",
|
||||
ExportName = "sceUserServiceGetUserName",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceUserService")]
|
||||
public static int UserServiceGetUserNameAlt(CpuContext ctx) => UserServiceGetUserName(ctx);
|
||||
#pragma warning restore SHEM004
|
||||
|
||||
// Name not yet in ps5_names.txt and the NID was captured from titles; revisit when the symbol is catalogued.
|
||||
#pragma warning disable SHEM006
|
||||
[SysAbiExport(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -959,6 +959,15 @@ public static partial class Gen5SpirvTranslator
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "VFmaMixF32":
|
||||
case "VFmaMixloF16":
|
||||
case "VFmaMixhiF16":
|
||||
if (!TryEmitFmaMix(instruction, destination, out result, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
error = $"unsupported vector opcode {instruction.Opcode}";
|
||||
@@ -1015,12 +1024,6 @@ public static partial class Gen5SpirvTranslator
|
||||
return false;
|
||||
}
|
||||
|
||||
if (control.Clamp)
|
||||
{
|
||||
error = $"unsupported vop3p modifiers (clamp) for {instruction.Opcode}";
|
||||
return false;
|
||||
}
|
||||
|
||||
var sourceCount = instruction.Opcode == "VPkFmaF16" ? 3 : 2;
|
||||
for (var index = 0; index < sourceCount; index++)
|
||||
{
|
||||
@@ -1039,7 +1042,112 @@ public static partial class Gen5SpirvTranslator
|
||||
return true;
|
||||
}
|
||||
|
||||
// V_FMA_MIX_F32 / _MIXLO_F16 / _MIXHI_F16 (VOP3P opcodes 0x20 / 0x21 /
|
||||
// 0x22). Unlike the packed v_pk_* ops these compute a single f32
|
||||
// fma(a, b, c): each of the three sources is *independently* read as
|
||||
// either a full f32 register/constant or one f16 half widened to f32,
|
||||
// selected per operand by op_sel_hi (read as f16 when set) and op_sel
|
||||
// (which half feeds the f32). For the mix ops the VOP3P neg_hi field is
|
||||
// the absolute-value modifier and neg negates, applied abs-then-neg to
|
||||
// match the hardware and shadPS4's GetSrcMix. _MIXLO / _MIXHI round the
|
||||
// f32 result back to f16 and write it into the low / high 16 bits of
|
||||
// vdst, leaving the other half intact.
|
||||
private bool TryEmitFmaMix(
|
||||
Gen5ShaderInstruction instruction,
|
||||
uint destination,
|
||||
out uint result,
|
||||
out string error)
|
||||
{
|
||||
result = 0;
|
||||
error = string.Empty;
|
||||
if (instruction.Control is not Gen5Vop3pControl control)
|
||||
{
|
||||
error = $"missing vop3p control for {instruction.Opcode}";
|
||||
return false;
|
||||
}
|
||||
|
||||
var product = Bitcast(
|
||||
_uintType,
|
||||
Ext(
|
||||
50,
|
||||
_floatType,
|
||||
EmitFmaMixOperand(instruction, control, 0),
|
||||
EmitFmaMixOperand(instruction, control, 1),
|
||||
EmitFmaMixOperand(instruction, control, 2)));
|
||||
if (control.Clamp)
|
||||
{
|
||||
product = EmitClampToUnitInterval(product);
|
||||
}
|
||||
|
||||
if (instruction.Opcode == "VFmaMixF32")
|
||||
{
|
||||
result = product;
|
||||
return true;
|
||||
}
|
||||
|
||||
// _MIXLO / _MIXHI: narrow to f16 and merge into one half of vdst.
|
||||
var half = EmitFloatToHalf(product);
|
||||
var existing = LoadV(destination);
|
||||
result = instruction.Opcode == "VFmaMixloF16"
|
||||
? BitwiseOr(BitwiseAnd(existing, UInt(0xFFFF_0000)), half)
|
||||
: BitwiseOr(
|
||||
BitwiseAnd(existing, UInt(0x0000_FFFF)),
|
||||
ShiftLeftLogical(half, UInt(16)));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Reads one V_FMA_MIX source as an f32. op_sel_hi selects whether a
|
||||
// register operand is taken as an f16 (the half picked by op_sel, widened
|
||||
// exactly to f32) or as a full f32; inline constants are always f32. The
|
||||
// per-operand neg_hi bit takes the absolute value and neg negates, in that
|
||||
// order (abs-then-neg), reusing the VOP3P modifier fields the way the mix
|
||||
// ops define them rather than the packed low/high-lane meaning.
|
||||
private uint EmitFmaMixOperand(
|
||||
Gen5ShaderInstruction instruction,
|
||||
Gen5Vop3pControl control,
|
||||
int index)
|
||||
{
|
||||
var source = instruction.Sources[index];
|
||||
var readAsHalf =
|
||||
((control.OpSelHiMask >> index) & 1) != 0 &&
|
||||
source.Kind is Gen5OperandKind.VectorRegister or Gen5OperandKind.ScalarRegister;
|
||||
|
||||
uint value;
|
||||
if (readAsHalf)
|
||||
{
|
||||
var raw = GetRawSource(instruction, index);
|
||||
var half = ((control.OpSelMask >> index) & 1) != 0
|
||||
? ShiftRightLogical(raw, UInt(16))
|
||||
: raw;
|
||||
value = Bitcast(_floatType, EmitHalfToFloat(half));
|
||||
}
|
||||
else
|
||||
{
|
||||
value = GetFloatSource(instruction, index);
|
||||
}
|
||||
|
||||
if (((control.NegHiMask >> index) & 1) != 0)
|
||||
{
|
||||
value = Ext(4, _floatType, value);
|
||||
}
|
||||
|
||||
if (((control.NegLoMask >> index) & 1) != 0)
|
||||
{
|
||||
value = _module.AddInstruction(SpirvOp.FNegate, _floatType, value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// Computes one result lane (low or high) as a packed 16-bit f16 value.
|
||||
// The op runs in f32 and its result is narrowed back to f16 exactly (see
|
||||
// EmitFloatToHalf). When the clamp modifier is set the pre-narrowing f32
|
||||
// value is saturated to [0, 1] first; because 0.0 and 1.0 are exact in both
|
||||
// f32 and f16 and the clamp is monotonic, clamping before the narrowing
|
||||
// gives the same f16 the hardware produces by clamping the f16 result. For
|
||||
// the fused multiply-add the pre-narrowing value is the round-to-odd f32
|
||||
// from EmitPackedF16FusedMultiplyAdd, and round-to-odd preserves that
|
||||
// equivalence through the final round-to-nearest-even.
|
||||
private uint EmitPackedF16Lane(
|
||||
Gen5ShaderInstruction instruction,
|
||||
Gen5Vop3pControl control,
|
||||
@@ -1047,21 +1155,44 @@ public static partial class Gen5SpirvTranslator
|
||||
{
|
||||
var left = EmitPackedF16Operand(instruction, control, 0, highLane);
|
||||
var right = EmitPackedF16Operand(instruction, control, 1, highLane);
|
||||
uint value;
|
||||
if (instruction.Opcode == "VPkFmaF16")
|
||||
{
|
||||
var addend = EmitPackedF16Operand(instruction, control, 2, highLane);
|
||||
return EmitFloatToHalf(EmitPackedF16FusedMultiplyAdd(left, right, addend));
|
||||
value = EmitPackedF16FusedMultiplyAdd(left, right, addend);
|
||||
}
|
||||
else
|
||||
{
|
||||
value = Bitcast(_uintType, instruction.Opcode switch
|
||||
{
|
||||
"VPkAddF16" => _module.AddInstruction(SpirvOp.FAdd, _floatType, left, right),
|
||||
"VPkMulF16" => _module.AddInstruction(SpirvOp.FMul, _floatType, left, right),
|
||||
"VPkMinF16" => EmitPackedF16MinMax(left, right, isMax: false),
|
||||
"VPkMaxF16" => EmitPackedF16MinMax(left, right, isMax: true),
|
||||
_ => left,
|
||||
});
|
||||
}
|
||||
|
||||
var value = instruction.Opcode switch
|
||||
if (control.Clamp)
|
||||
{
|
||||
"VPkAddF16" => _module.AddInstruction(SpirvOp.FAdd, _floatType, left, right),
|
||||
"VPkMulF16" => _module.AddInstruction(SpirvOp.FMul, _floatType, left, right),
|
||||
"VPkMinF16" => EmitPackedF16MinMax(left, right, isMax: false),
|
||||
"VPkMaxF16" => EmitPackedF16MinMax(left, right, isMax: true),
|
||||
_ => left,
|
||||
};
|
||||
return EmitFloatToHalf(Bitcast(_uintType, value));
|
||||
value = EmitClampToUnitInterval(value);
|
||||
}
|
||||
|
||||
return EmitFloatToHalf(value);
|
||||
}
|
||||
|
||||
// Saturates an f32 bit pattern to [0, 1] the way the VOP3P clamp modifier
|
||||
// does: below 0 (and NaN, since the ordered compare is false for it) becomes
|
||||
// 0, above 1 becomes 1. Ordered compares match the hardware's NaN-to-zero
|
||||
// behaviour without a separate IsNan test.
|
||||
private uint EmitClampToUnitInterval(uint valueBits)
|
||||
{
|
||||
var value = Bitcast(_floatType, valueBits);
|
||||
var aboveZero = _module.AddInstruction(SpirvOp.FOrdGreaterThan, _boolType, value, Float(0));
|
||||
var lowerBounded = _module.AddInstruction(SpirvOp.Select, _floatType, aboveZero, value, Float(0));
|
||||
var belowOne = _module.AddInstruction(SpirvOp.FOrdLessThan, _boolType, lowerBounded, Float(1));
|
||||
var clamped = _module.AddInstruction(SpirvOp.Select, _floatType, belowOne, lowerBounded, Float(1));
|
||||
return Bitcast(_uintType, clamped);
|
||||
}
|
||||
|
||||
// Fused f16 multiply-add with a single rounding, emulated in f32 without the
|
||||
|
||||
@@ -313,7 +313,8 @@ public static partial class Gen5SpirvTranslator
|
||||
uint ComponentType,
|
||||
uint VectorType,
|
||||
ImageComponentKind ComponentKind,
|
||||
bool IsStorage);
|
||||
bool IsStorage,
|
||||
bool Arrayed);
|
||||
|
||||
private readonly record struct SpirvVertexInput(
|
||||
uint Variable,
|
||||
@@ -1000,11 +1001,13 @@ public static partial class Gen5SpirvTranslator
|
||||
SpirvCapability.StorageImageExtendedFormats);
|
||||
}
|
||||
|
||||
var isArrayed = !isStorage &&
|
||||
Gen5ShaderTranslator.IsArrayedImageBinding(binding);
|
||||
var imageType = _module.TypeImage(
|
||||
componentType,
|
||||
SpirvImageDim.Dim2D,
|
||||
depth: false,
|
||||
arrayed: false,
|
||||
arrayed: isArrayed,
|
||||
multisampled: false,
|
||||
sampled: isStorage ? 2u : 1u,
|
||||
isStorage ? format : SpirvImageFormat.Unknown);
|
||||
@@ -1031,7 +1034,8 @@ public static partial class Gen5SpirvTranslator
|
||||
componentType,
|
||||
_module.TypeVector(componentType, 4),
|
||||
componentKind,
|
||||
isStorage));
|
||||
isStorage,
|
||||
isArrayed));
|
||||
_interfaces.Add(variable);
|
||||
}
|
||||
}
|
||||
@@ -3529,12 +3533,16 @@ public static partial class Gen5SpirvTranslator
|
||||
addressCursor += 4;
|
||||
}
|
||||
|
||||
var coordinates = BuildFloatCoordinates(image, addressCursor);
|
||||
var coordinates = resource.Arrayed
|
||||
? BuildFloatArrayCoordinates(image, addressCursor)
|
||||
: BuildFloatCoordinates(image, addressCursor);
|
||||
var explicitLod = hasGradients || hasZeroLod || hasLod;
|
||||
var lod = hasZeroLod
|
||||
? Float(0)
|
||||
: hasLod
|
||||
? LoadImageFloatAddress(image, addressCursor + 2)
|
||||
? LoadImageFloatAddress(
|
||||
image,
|
||||
addressCursor + (resource.Arrayed ? 3 : 2))
|
||||
: lodOrBias;
|
||||
if (hasOffset)
|
||||
{
|
||||
@@ -3619,7 +3627,9 @@ public static partial class Gen5SpirvTranslator
|
||||
addressCursor += ImageFullAddressSlots(image);
|
||||
}
|
||||
|
||||
var coordinates = BuildFloatCoordinates(image, addressCursor);
|
||||
var coordinates = resource.Arrayed
|
||||
? BuildFloatArrayCoordinates(image, addressCursor)
|
||||
: BuildFloatCoordinates(image, addressCursor);
|
||||
var operands = new List<uint>
|
||||
{
|
||||
imageObject,
|
||||
@@ -3823,6 +3833,19 @@ public static partial class Gen5SpirvTranslator
|
||||
y);
|
||||
}
|
||||
|
||||
private uint BuildFloatArrayCoordinates(Gen5ImageControl image, int start)
|
||||
{
|
||||
var x = LoadImageFloatAddress(image, start);
|
||||
var y = LoadImageFloatAddress(image, start + 1);
|
||||
var slice = LoadImageFloatAddress(image, start + 2);
|
||||
return _module.AddInstruction(
|
||||
SpirvOp.CompositeConstruct,
|
||||
_vec3Type,
|
||||
x,
|
||||
y,
|
||||
slice);
|
||||
}
|
||||
|
||||
private static int ImageAddressRegister(
|
||||
Gen5ImageControl image,
|
||||
int component) => image.A16 ? component / 2 : component;
|
||||
@@ -4140,9 +4163,20 @@ public static partial class Gen5SpirvTranslator
|
||||
signedLod);
|
||||
var size = _module.AddInstruction(
|
||||
SpirvOp.ImageQuerySizeLod,
|
||||
ivec2,
|
||||
resource.Arrayed ? _module.TypeVector(_intType, 3) : ivec2,
|
||||
image,
|
||||
clampedLod);
|
||||
if (resource.Arrayed)
|
||||
{
|
||||
size = _module.AddInstruction(
|
||||
SpirvOp.VectorShuffle,
|
||||
ivec2,
|
||||
size,
|
||||
size,
|
||||
0u,
|
||||
1u);
|
||||
}
|
||||
|
||||
var sizeFloat = _module.AddInstruction(
|
||||
SpirvOp.ConvertSToF,
|
||||
_vec2Type,
|
||||
@@ -4156,11 +4190,34 @@ public static partial class Gen5SpirvTranslator
|
||||
_vec2Type,
|
||||
offsetFloat,
|
||||
sizeFloat);
|
||||
if (!resource.Arrayed)
|
||||
{
|
||||
return _module.AddInstruction(
|
||||
SpirvOp.FAdd,
|
||||
_vec2Type,
|
||||
coordinates,
|
||||
normalizedOffset);
|
||||
}
|
||||
|
||||
var offsetVec3 = _module.AddInstruction(
|
||||
SpirvOp.CompositeConstruct,
|
||||
_vec3Type,
|
||||
_module.AddInstruction(
|
||||
SpirvOp.CompositeExtract,
|
||||
_floatType,
|
||||
normalizedOffset,
|
||||
0u),
|
||||
_module.AddInstruction(
|
||||
SpirvOp.CompositeExtract,
|
||||
_floatType,
|
||||
normalizedOffset,
|
||||
1u),
|
||||
Float(0));
|
||||
return _module.AddInstruction(
|
||||
SpirvOp.FAdd,
|
||||
_vec2Type,
|
||||
_vec3Type,
|
||||
coordinates,
|
||||
normalizedOffset);
|
||||
offsetVec3);
|
||||
}
|
||||
|
||||
private bool TryEmitExport(
|
||||
@@ -5290,10 +5347,20 @@ public static partial class Gen5SpirvTranslator
|
||||
UInt(0x108));
|
||||
}
|
||||
|
||||
// A wave-mask SGPR (VCC/EXEC) consumed as a per-lane predicate — the
|
||||
// condition of VCndmask, a VCC/EXEC branch, or the derived _vcc/_exec
|
||||
// bool — must be tested at the CURRENT lane's bit, exactly as the
|
||||
// hardware does, not as "the 64-bit value is non-zero". The two coincide
|
||||
// for comparison results (only the lane's own bit is ever set), so the
|
||||
// single-lane path historically used a cheaper whole-word non-zero test.
|
||||
// But bitwise-complement wave-mask idioms (S_NOT/S_ORN2/S_ANDN2/S_NAND/
|
||||
// S_NOR on a 64-bit mask) set the unused upper 63 bits; a whole-word test
|
||||
// then reports "lane active" even when this lane's bit is clear. Unity's
|
||||
// PostProcessing NaN killer does exactly this (`anyNaN | ~allFinite`),
|
||||
// which made every valid pixel read as NaN and get replaced with 0 —
|
||||
// zeroing the whole scene before tonemap. Extract the lane bit always.
|
||||
private uint IsWaveMaskActive(uint mask) =>
|
||||
_subgroupInvocationIdInput == 0
|
||||
? IsNotZero64(mask)
|
||||
: IsCurrentLaneSet(mask);
|
||||
IsCurrentLaneSet(mask);
|
||||
|
||||
private uint IsCurrentLaneSet(uint mask) =>
|
||||
IsNotZero64(
|
||||
|
||||
@@ -80,7 +80,7 @@ public static class Gen5ShaderTranslator
|
||||
public static bool IsScalarConsumed(ulong[] mask, uint register) =>
|
||||
register < 256 && (mask[register >> 6] & (1UL << (int)(register & 63))) != 0;
|
||||
|
||||
private const int MaxInstructions = 4096;
|
||||
private const int MaxInstructions = 16384;
|
||||
private const uint PsUserDataRegister = 0x0C;
|
||||
private const uint VsUserDataRegister = 0x4C;
|
||||
private const uint GsUserDataRegister = 0x8C;
|
||||
@@ -1192,8 +1192,10 @@ public static class Gen5ShaderTranslator
|
||||
|
||||
// Opcode numbers taken from LLVM's AMDGPU VOP3PInstructions.td and the
|
||||
// gfx9/gfx10 MC test encodings; they are unchanged across gfx9 and gfx10.
|
||||
// Unhandled packed opcodes (integer, fma_mix, ...) stay opaque here and
|
||||
// fail loudly at emission rather than being silently mis-emitted.
|
||||
// The mix ops (0x20/0x21/0x22) are V_MAD_MIX_* on gfx9 and V_FMA_MIX_*
|
||||
// (fused) on the gfx10 the PS5 targets; both share these opcodes. Any
|
||||
// remaining packed opcode (integer, ...) stays opaque here and fails
|
||||
// loudly at emission rather than being silently mis-emitted.
|
||||
name = opcode switch
|
||||
{
|
||||
0x0E => "VPkFmaF16",
|
||||
@@ -1201,6 +1203,9 @@ public static class Gen5ShaderTranslator
|
||||
0x10 => "VPkMulF16",
|
||||
0x11 => "VPkMinF16",
|
||||
0x12 => "VPkMaxF16",
|
||||
0x20 => "VFmaMixF32",
|
||||
0x21 => "VFmaMixloF16",
|
||||
0x22 => "VFmaMixhiF16",
|
||||
_ => $"Vop3pRaw{opcode:X2}",
|
||||
};
|
||||
|
||||
@@ -1607,6 +1612,11 @@ public static class Gen5ShaderTranslator
|
||||
binding.ResourceDescriptor.SequenceEqual(candidate.ResourceDescriptor));
|
||||
}
|
||||
|
||||
public static bool IsArrayedImageBinding(Gen5ImageBinding binding) =>
|
||||
binding.Control.IsArray &&
|
||||
(binding.Opcode.StartsWith("ImageSample", StringComparison.Ordinal) ||
|
||||
binding.Opcode.StartsWith("ImageGather4", StringComparison.Ordinal));
|
||||
|
||||
public static bool IsDataShareAtomic(string name) => name switch
|
||||
{
|
||||
"DsAddU32" or "DsSubU32" or "DsIncU32" or "DsDecU32" or
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Agc;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
public sealed class AgcPredicationTests
|
||||
{
|
||||
private const ulong BaseAddress = 0x1_0000_0000;
|
||||
private const ulong CommandBufferAddress = BaseAddress + 0x100;
|
||||
private const ulong PacketAddress = BaseAddress + 0x400;
|
||||
private const ulong PredicateAddress = BaseAddress + 0x800;
|
||||
|
||||
[Fact]
|
||||
public void DcbSetPredication_EmitsGen5Packet()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, 0x2000);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
WriteUInt64(memory, CommandBufferAddress + 0x10, PacketAddress);
|
||||
WriteUInt64(memory, CommandBufferAddress + 0x18, PacketAddress + 0x100);
|
||||
|
||||
ctx[CpuRegister.Rdi] = CommandBufferAddress;
|
||||
ctx[CpuRegister.Rsi] = 1;
|
||||
ctx[CpuRegister.Rdx] = 3;
|
||||
ctx[CpuRegister.Rcx] = 1;
|
||||
ctx[CpuRegister.R8] = PredicateAddress + 7;
|
||||
ctx[CpuRegister.R9] = 2;
|
||||
|
||||
var result = AgcExports.DcbSetPredication(ctx);
|
||||
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
|
||||
Assert.Equal(PacketAddress, ctx[CpuRegister.Rax]);
|
||||
Assert.Equal(0xC002_2000u, ReadUInt32(memory, PacketAddress));
|
||||
Assert.Equal(0x0003_1100u, ReadUInt32(memory, PacketAddress + 4));
|
||||
Assert.Equal(unchecked((uint)PredicateAddress), ReadUInt32(memory, PacketAddress + 8));
|
||||
Assert.Equal((uint)(PredicateAddress >> 32), ReadUInt32(memory, PacketAddress + 12));
|
||||
Assert.Equal(PacketAddress + 16, ReadUInt64(memory, CommandBufferAddress + 0x10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetPacketPredication_TogglesPacketHeaderBit()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, 0x1000);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
const uint header = 0xC003_1500;
|
||||
WriteUInt32(memory, PacketAddress, header);
|
||||
|
||||
ctx[CpuRegister.Rdi] = PacketAddress;
|
||||
ctx[CpuRegister.Rsi] = 1;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
AgcExports.SetPacketPredication(ctx));
|
||||
Assert.Equal(header | 1u, ReadUInt32(memory, PacketAddress));
|
||||
|
||||
ctx[CpuRegister.Rsi] = 0;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
AgcExports.SetPacketPredication(ctx));
|
||||
Assert.Equal(header, ReadUInt32(memory, PacketAddress));
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
|
||||
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Agc;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
public sealed class AgcResourceOwnerTests
|
||||
{
|
||||
private const ulong BaseAddress = 0x1_0000_0000;
|
||||
private const ulong OwnerAddress = BaseAddress + 0x100;
|
||||
private const ulong NameAddress = BaseAddress + 0x200;
|
||||
private const ulong RegistrationMemoryAddress = BaseAddress + 0x400;
|
||||
|
||||
[Fact]
|
||||
public void RegisterOwner_DoesNotRequireOptionalResourceRegistryMemory()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, 0x2000);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
memory.WriteCString(NameAddress, "GIRender");
|
||||
ctx[CpuRegister.Rdi] = OwnerAddress;
|
||||
ctx[CpuRegister.Rsi] = NameAddress;
|
||||
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DriverRegisterOwner(ctx));
|
||||
Assert.NotEqual(0u, ReadUInt32(memory, OwnerAddress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterOwner_RespectsExplicitRegistryCapacity()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, 0x2000);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
ctx[CpuRegister.Rdi] = RegistrationMemoryAddress;
|
||||
ctx[CpuRegister.Rsi] = 0x1000;
|
||||
ctx[CpuRegister.Rdx] = 1;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
AgcExports.DriverInitResourceRegistration(ctx));
|
||||
|
||||
memory.WriteCString(NameAddress, "First");
|
||||
ctx[CpuRegister.Rdi] = OwnerAddress;
|
||||
ctx[CpuRegister.Rsi] = NameAddress;
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DriverRegisterOwner(ctx));
|
||||
|
||||
memory.WriteCString(NameAddress, "Second");
|
||||
ctx[CpuRegister.Rdi] = OwnerAddress + 4;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
|
||||
AgcExports.DriverRegisterOwner(ctx));
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Agc;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
public sealed class AgcWaitRegMemTests
|
||||
{
|
||||
private const ulong BaseAddress = 0x1_0000_0000;
|
||||
private const ulong CommandBufferAddress = BaseAddress + 0x100;
|
||||
private const ulong PacketAddress = BaseAddress + 0x400;
|
||||
private const ulong StackAddress = BaseAddress + 0x800;
|
||||
|
||||
[Fact]
|
||||
public void DcbWaitRegMem32_EmitsGen5PacketLayout()
|
||||
{
|
||||
var memory = CreateMemory(out var ctx);
|
||||
var waitAddress = BaseAddress + 0xC03;
|
||||
|
||||
ctx[CpuRegister.Rdi] = CommandBufferAddress;
|
||||
ctx[CpuRegister.Rsi] = 0;
|
||||
ctx[CpuRegister.Rdx] = 3;
|
||||
ctx[CpuRegister.Rcx] = 4;
|
||||
ctx[CpuRegister.R8] = 2;
|
||||
ctx[CpuRegister.R9] = waitAddress;
|
||||
WriteUInt64(memory, StackAddress + 8, 0x1122_3344_5566_7788);
|
||||
WriteUInt64(memory, StackAddress + 16, 0xAABB_CCDD_EEFF_0011);
|
||||
WriteUInt32(memory, StackAddress + 24, 0x123456);
|
||||
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DcbWaitRegMem(ctx));
|
||||
Assert.Equal(PacketAddress, ctx[CpuRegister.Rax]);
|
||||
Assert.Equal(0xC005_1028u, ReadUInt32(memory, PacketAddress));
|
||||
Assert.Equal(0x0000_0C00u, ReadUInt32(memory, PacketAddress + 4));
|
||||
Assert.Equal(1u, ReadUInt32(memory, PacketAddress + 8));
|
||||
Assert.Equal(0xEEFF_0011u, ReadUInt32(memory, PacketAddress + 12));
|
||||
Assert.Equal(0x5566_7788u, ReadUInt32(memory, PacketAddress + 16));
|
||||
Assert.Equal(0x0400_0053u, ReadUInt32(memory, PacketAddress + 20));
|
||||
Assert.Equal(0xFFFFu, ReadUInt32(memory, PacketAddress + 24));
|
||||
Assert.Equal(PacketAddress + 28, ReadUInt64(memory, CommandBufferAddress + 0x10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DcbWaitRegMem64_EmitsGen5PacketLayout()
|
||||
{
|
||||
var memory = CreateMemory(out var ctx);
|
||||
var waitAddress = BaseAddress + 0xC07;
|
||||
|
||||
ctx[CpuRegister.Rdi] = CommandBufferAddress;
|
||||
ctx[CpuRegister.Rsi] = 1;
|
||||
ctx[CpuRegister.Rdx] = 6;
|
||||
ctx[CpuRegister.Rcx] = 3;
|
||||
ctx[CpuRegister.R8] = 1;
|
||||
ctx[CpuRegister.R9] = waitAddress;
|
||||
WriteUInt64(memory, StackAddress + 8, 0x1122_3344_5566_7788);
|
||||
WriteUInt64(memory, StackAddress + 16, 0xAABB_CCDD_EEFF_0011);
|
||||
WriteUInt32(memory, StackAddress + 24, 0x320);
|
||||
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DcbWaitRegMem(ctx));
|
||||
Assert.Equal(0xC007_1058u, ReadUInt32(memory, PacketAddress));
|
||||
Assert.Equal(0x0000_0C00u, ReadUInt32(memory, PacketAddress + 4));
|
||||
Assert.Equal(1u, ReadUInt32(memory, PacketAddress + 8));
|
||||
Assert.Equal(0xEEFF_0011u, ReadUInt32(memory, PacketAddress + 12));
|
||||
Assert.Equal(0xAABB_CCDDu, ReadUInt32(memory, PacketAddress + 16));
|
||||
Assert.Equal(0x5566_7788u, ReadUInt32(memory, PacketAddress + 20));
|
||||
Assert.Equal(0x1122_3344u, ReadUInt32(memory, PacketAddress + 24));
|
||||
Assert.Equal(0x0200_0156u, ReadUInt32(memory, PacketAddress + 28));
|
||||
Assert.Equal(0x32u, ReadUInt32(memory, PacketAddress + 32));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitRegMemPatchFunctions_UseGen5Fields()
|
||||
{
|
||||
var memory = CreateMemory(out var ctx);
|
||||
WriteUInt32(memory, PacketAddress, 0xC005_1028);
|
||||
WriteUInt32(memory, PacketAddress + 20, 0x0400_0153);
|
||||
|
||||
ctx[CpuRegister.Rdi] = PacketAddress;
|
||||
ctx[CpuRegister.Rsi] = BaseAddress + 0xD07;
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.WaitRegMemPatchAddress(ctx));
|
||||
Assert.Equal(0x0000_0D04u, ReadUInt32(memory, PacketAddress + 4));
|
||||
Assert.Equal(1u, ReadUInt32(memory, PacketAddress + 8));
|
||||
|
||||
ctx[CpuRegister.Rsi] = 5;
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.WaitRegMemPatchCompareFunction(ctx));
|
||||
Assert.Equal(0x0400_0155u, ReadUInt32(memory, PacketAddress + 20));
|
||||
|
||||
ctx[CpuRegister.Rsi] = 0xDEAD_BEEF;
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.WaitRegMemPatchReference(ctx));
|
||||
Assert.Equal(0xDEAD_BEEFu, ReadUInt32(memory, PacketAddress + 16));
|
||||
}
|
||||
|
||||
private static FakeCpuMemory CreateMemory(out CpuContext ctx)
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, 0x2000);
|
||||
ctx = new CpuContext(memory, Generation.Gen5);
|
||||
ctx[CpuRegister.Rsp] = StackAddress;
|
||||
WriteUInt64(memory, CommandBufferAddress + 0x10, PacketAddress);
|
||||
WriteUInt64(memory, CommandBufferAddress + 0x18, PacketAddress + 0x100);
|
||||
return memory;
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
|
||||
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
using SharpEmu.ShaderCompiler.Vulkan;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
// Regression tests for the VOP3P mix ops V_FMA_MIX_F32 / _MIXLO_F16 / _MIXHI_F16
|
||||
// (opcodes 0x20 / 0x21 / 0x22). The decoder leaves any unlowered VOP3P opcode
|
||||
// opaque (Vop3pRaw20/21/22); before these were lowered they hit the vector-ALU
|
||||
// switch default and failed emission ("unsupported vector opcode"), which drops
|
||||
// the whole shader. Unity HDR / tone-mapping / auto-exposure shaders use
|
||||
// V_FMA_MIX_F32 and so failed to translate entirely.
|
||||
//
|
||||
// Each mix op computes a single f32 fma(a, b, c) where every source is read
|
||||
// *independently* as either a full f32 register or one f16 half widened to f32,
|
||||
// selected per operand by op_sel_hi (f16 when set) and op_sel (which half). The
|
||||
// mix ops also repurpose the VOP3P neg_hi field as an absolute-value modifier.
|
||||
public sealed class Gen5FmaMixSpirvTests
|
||||
{
|
||||
private const ulong ShaderAddress = 0x1_0000_0000;
|
||||
|
||||
// GLSL.std.450 extended-instruction numbers used by the lowering.
|
||||
private const uint GlslFma = 50;
|
||||
private const uint GlslFAbs = 4;
|
||||
|
||||
[Fact]
|
||||
public void FmaMixF32_TranslatesToFmaAndDoesNotDropShader()
|
||||
{
|
||||
// V_FMA_MIX_F32 v3, v0, v1, v2
|
||||
// op_sel_hi = 0b011 -> src0/src1 read as f16, src2 as full f32
|
||||
// op_sel = 0b010 -> src1 takes its high f16 half (src0 low half)
|
||||
// neg_hi = 0b001 -> abs(src0)
|
||||
// neg = 0b100 -> -src2
|
||||
// Reaching TryCompileComputeShader == true already proves the shader is no
|
||||
// longer dropped at the VOP3P default error path.
|
||||
var spirv = Compile([0xCC201103u, 0x9C0A0300u]);
|
||||
|
||||
Assert.True(
|
||||
ContainsExtInst(spirv, GlslFma),
|
||||
"V_FMA_MIX_F32 must lower to a GLSL.std.450 Fma");
|
||||
Assert.True(
|
||||
ContainsExtInst(spirv, GlslFAbs),
|
||||
"the neg_hi modifier on a mix source must lower to an FAbs (abs-then-neg)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FmaMixLoF16_TranslatesWithoutDroppingShader()
|
||||
{
|
||||
// V_FMA_MIXLO_F16 v3, v0, v1, v2 with op_sel_hi = 0b111 (all sources read
|
||||
// as f16 low halves). The f32 fma result is narrowed to f16 and merged
|
||||
// into the low 16 bits of vdst; the fma itself is still emitted.
|
||||
var spirv = Compile([0xCC214003u, 0x1C0A0300u]);
|
||||
|
||||
Assert.True(
|
||||
ContainsExtInst(spirv, GlslFma),
|
||||
"V_FMA_MIXLO_F16 must still lower its multiply-add to a GLSL.std.450 Fma");
|
||||
}
|
||||
|
||||
// True when the module contains an OpExtInst selecting the given GLSL.std.450
|
||||
// instruction number.
|
||||
private static bool ContainsExtInst(byte[] spirv, uint instruction)
|
||||
{
|
||||
foreach (var (op, wordCount, offset) in EnumerateInstructions(spirv))
|
||||
{
|
||||
// OpExtInst = 12: (opcode, resultType, resultId, set, instruction, ...).
|
||||
if (op != 12 || wordCount < 5)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ReadWord(spirv, offset + 16) == instruction)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<(ushort Op, int WordCount, int Offset)> EnumerateInstructions(
|
||||
byte[] spirv)
|
||||
{
|
||||
// 5-word SPIR-V header, then (wordCount << 16 | opcode) packed instructions.
|
||||
for (var offset = 5 * sizeof(uint); offset + sizeof(uint) <= spirv.Length;)
|
||||
{
|
||||
var word = ReadWord(spirv, offset);
|
||||
var wordCount = (int)(word >> 16);
|
||||
if (wordCount <= 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return ((ushort)word, wordCount, offset);
|
||||
offset += wordCount * sizeof(uint);
|
||||
}
|
||||
}
|
||||
|
||||
private static uint ReadWord(byte[] spirv, int offset) =>
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(spirv.AsSpan(offset, sizeof(uint)));
|
||||
|
||||
private static byte[] Compile(uint[] programWords)
|
||||
{
|
||||
var memory = new FakeCpuMemory(ShaderAddress, 0x2000);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
Gen5ShaderAtomicDecodeTests.WriteProgram(memory, ShaderAddress, programWords);
|
||||
var shaderRegisters = new Dictionary<uint, uint>
|
||||
{
|
||||
[Gen5ShaderAtomicDecodeTests.ComputePgmRsrc2Register] = 16u << 1,
|
||||
};
|
||||
|
||||
Assert.True(
|
||||
Gen5ShaderTranslator.TryCreateState(
|
||||
ctx,
|
||||
ShaderAddress,
|
||||
0,
|
||||
shaderRegisters,
|
||||
Gen5ShaderAtomicDecodeTests.ComputeUserDataRegister,
|
||||
out var state,
|
||||
out var error),
|
||||
error);
|
||||
Assert.True(
|
||||
Gen5ShaderScalarEvaluator.TryEvaluate(ctx, state, out var evaluation, out error),
|
||||
error);
|
||||
Assert.True(
|
||||
Gen5SpirvTranslator.TryCompileComputeShader(
|
||||
state, evaluation, 1, 1, 1, out var shader, out error),
|
||||
error);
|
||||
return shader.Spirv;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,20 @@ using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
// Gen5ShaderScalarEvaluator.FallbackMemoryReader is a process-global static. This
|
||||
// test swaps it, but the SharpEmu.Libs [ModuleInitializer] (AgcShaderCompilerHooks)
|
||||
// reassigns the same static the first time any Libs type is touched. Under xUnit's
|
||||
// default cross-class parallelism a Libs test running concurrently can fire that
|
||||
// initializer mid-test and clobber the swapped-in reader (observed as all-zero
|
||||
// reads on CI). A DisableParallelization collection runs alone in the non-parallel
|
||||
// phase, so nothing else can mutate the static while this test holds it.
|
||||
[CollectionDefinition(Gen5ScalarEvaluatorStateCollection.Name, DisableParallelization = true)]
|
||||
public sealed class Gen5ScalarEvaluatorStateCollection
|
||||
{
|
||||
public const string Name = "Gen5ScalarEvaluatorState";
|
||||
}
|
||||
|
||||
[Collection(Gen5ScalarEvaluatorStateCollection.Name)]
|
||||
public sealed class Gen5ScalarMemoryFallbackTests
|
||||
{
|
||||
private const ulong ScalarTableAddress = 0x4_4665_4FD0;
|
||||
|
||||
@@ -13,7 +13,8 @@ public sealed class Gen5ShaderDecoderBoundaryTests
|
||||
private const ulong ShaderAddress = 0x1_0000_0000;
|
||||
private const uint Export = 0xF8000000;
|
||||
private const uint Nop = 0xBF800000;
|
||||
private const int MaximumInstructionCount = 4096;
|
||||
private const uint EndPgm = 0xBF810000;
|
||||
private const int MaximumInstructionCount = 16384;
|
||||
|
||||
[Fact]
|
||||
public void MissingAddress_IsRejectedWithoutReadingGuestMemory()
|
||||
@@ -99,6 +100,23 @@ public sealed class Gen5ShaderDecoderBoundaryTests
|
||||
memory.Reads[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgramMayEndAfterPreviousDecoderLimit()
|
||||
{
|
||||
const int previousDecoderLimit = 4096;
|
||||
var words = new uint[previousDecoderLimit + 1];
|
||||
Array.Fill(words, Nop);
|
||||
words[^1] = EndPgm;
|
||||
var memory = RecordingCpuMemory.FromWords(ShaderAddress, words);
|
||||
|
||||
var decoded = Decode(memory, ShaderAddress, out var program, out var error);
|
||||
|
||||
Assert.True(decoded, error);
|
||||
Assert.Equal(words.Length, program.Instructions.Count);
|
||||
Assert.Equal("SEndpgm", program.Instructions[^1].Opcode);
|
||||
Assert.Equal(words.Length, memory.Reads.Count);
|
||||
}
|
||||
|
||||
private static bool Decode(
|
||||
RecordingCpuMemory memory,
|
||||
ulong address,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
using SharpEmu.ShaderCompiler.Vulkan;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
// Regression tests for how a VCC/EXEC wave mask consumed as a per-lane predicate
|
||||
// is lowered to SPIR-V. A wave mask must be tested at the current lane's bit
|
||||
// (mask & lane_bit) — exactly as the hardware evaluates the VCndmask condition or
|
||||
// a VCC/EXEC branch — not with a whole-word "the 64-bit value is non-zero" test.
|
||||
//
|
||||
// The two agree for comparison results (only the lane's own bit is ever set), but
|
||||
// diverge for the bitwise-complement wave-mask idioms (S_NOT / S_ORN2 / S_ANDN2 /
|
||||
// S_NAND / S_NOR), which set the unused upper 63 bits. A whole-word test then
|
||||
// reports the lane active even when its bit is clear. Unity's PostProcessing NaN
|
||||
// killer combines its channels as `anyNaN | ~allFinite` (S_ORN2_B64); under the
|
||||
// whole-word test every valid pixel read as NaN and was replaced with 0, zeroing
|
||||
// the whole HDR scene before tone-mapping.
|
||||
public sealed class Gen5WaveMaskSpirvTests
|
||||
{
|
||||
private const ulong ShaderAddress = 0x1_0000_0000;
|
||||
|
||||
[Fact]
|
||||
public void WaveMaskPredicate_IsTestedAtCurrentLaneBit()
|
||||
{
|
||||
// V_CMP_EQ_F32 vcc, v0, v1 writes VCC at run time, which re-materialises
|
||||
// the per-lane _vcc predicate from the wave mask via IsWaveMaskActive.
|
||||
var spirv = Compile([0x7C04_0300u]);
|
||||
|
||||
// The lane's bit in single-lane emulation is the 64-bit constant 1, so the
|
||||
// predicate is `(mask & 1) != 0`. The whole-word bug emitted `mask != 0`
|
||||
// with no such mask. Require the lane-bit AND to be present.
|
||||
Assert.True(
|
||||
ContainsLaneBitMaskedWaveTest(spirv),
|
||||
"wave-mask predicate must be tested at the current lane bit "
|
||||
+ "(mask & lane_bit), not as a whole-word non-zero test");
|
||||
}
|
||||
|
||||
// True when the module contains an OpBitwiseAnd whose operand is a 64-bit
|
||||
// constant of value 1 — the current-lane bit that IsCurrentLaneSet masks the
|
||||
// wave mask with before the non-zero test.
|
||||
private static bool ContainsLaneBitMaskedWaveTest(byte[] spirv)
|
||||
{
|
||||
var laneBitConstIds = new HashSet<uint>();
|
||||
|
||||
// Pass 1: collect 64-bit OpConstant result-ids whose value is 1.
|
||||
foreach (var (op, wordCount, offset) in EnumerateInstructions(spirv))
|
||||
{
|
||||
// OpConstant = 43; a 64-bit constant occupies 5 words
|
||||
// (opcode, resultType, resultId, valueLow, valueHigh).
|
||||
if (op != 43 || wordCount != 5)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var resultId = ReadWord(spirv, offset + 8);
|
||||
var low = ReadWord(spirv, offset + 12);
|
||||
var high = ReadWord(spirv, offset + 16);
|
||||
if (low == 1 && high == 0)
|
||||
{
|
||||
laneBitConstIds.Add(resultId);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: look for an OpBitwiseAnd that consumes one of those constants.
|
||||
foreach (var (op, wordCount, offset) in EnumerateInstructions(spirv))
|
||||
{
|
||||
// OpBitwiseAnd = 199 (opcode, resultType, resultId, operand0, operand1).
|
||||
if (op != 199 || wordCount != 5)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var operand0 = ReadWord(spirv, offset + 12);
|
||||
var operand1 = ReadWord(spirv, offset + 16);
|
||||
if (laneBitConstIds.Contains(operand0) || laneBitConstIds.Contains(operand1))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static IEnumerable<(ushort Op, int WordCount, int Offset)> EnumerateInstructions(
|
||||
byte[] spirv)
|
||||
{
|
||||
// 5-word SPIR-V header, then (wordCount << 16 | opcode) packed instructions.
|
||||
for (var offset = 5 * sizeof(uint); offset + sizeof(uint) <= spirv.Length;)
|
||||
{
|
||||
var word = ReadWord(spirv, offset);
|
||||
var wordCount = (int)(word >> 16);
|
||||
if (wordCount <= 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return ((ushort)word, wordCount, offset);
|
||||
offset += wordCount * sizeof(uint);
|
||||
}
|
||||
}
|
||||
|
||||
private static uint ReadWord(byte[] spirv, int offset) =>
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(spirv.AsSpan(offset, sizeof(uint)));
|
||||
|
||||
private static byte[] Compile(uint[] programWords)
|
||||
{
|
||||
var memory = new FakeCpuMemory(ShaderAddress, 0x2000);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
Gen5ShaderAtomicDecodeTests.WriteProgram(memory, ShaderAddress, programWords);
|
||||
var shaderRegisters = new Dictionary<uint, uint>
|
||||
{
|
||||
[Gen5ShaderAtomicDecodeTests.ComputePgmRsrc2Register] = 16u << 1,
|
||||
};
|
||||
|
||||
Assert.True(
|
||||
Gen5ShaderTranslator.TryCreateState(
|
||||
ctx,
|
||||
ShaderAddress,
|
||||
0,
|
||||
shaderRegisters,
|
||||
Gen5ShaderAtomicDecodeTests.ComputeUserDataRegister,
|
||||
out var state,
|
||||
out var error),
|
||||
error);
|
||||
Assert.True(
|
||||
Gen5ShaderScalarEvaluator.TryEvaluate(ctx, state, out var evaluation, out error),
|
||||
error);
|
||||
Assert.True(
|
||||
Gen5SpirvTranslator.TryCompileComputeShader(
|
||||
state, evaluation, 1, 1, 1, out var shader, out error),
|
||||
error);
|
||||
return shader.Spirv;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.Agc;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
// TryDetile's exact-XOR fast path (PS5 swizzle modes 5/9/24/27) factors the
|
||||
// AddrLib bit-interleave into independent per-column X and per-row Y terms so
|
||||
// the inner loop is one array load and one XOR instead of a 16-bit interleave.
|
||||
// These tests pin that the factored output stays byte-identical to the direct
|
||||
// AddrLib address equation.
|
||||
public sealed class GnmTilingDetileTests
|
||||
{
|
||||
// Independent re-derivation of the 64 KiB RB+ R_X equation (swizzle mode 27,
|
||||
// 2 bytes/element) straight from the address-bit table, so the tiled source
|
||||
// layout does not depend on TryDetile's own internal factoring.
|
||||
private static readonly (uint XMask, uint YMask)[] RbPlus64KRenderX2Bpp =
|
||||
[
|
||||
(0, 0), (1u << 0, 0), (1u << 1, 0), (1u << 2, 0),
|
||||
(0, 1u << 0), (0, 1u << 1), (0, 1u << 2), (1u << 3, 0),
|
||||
(1u << 7, (1u << 4) | (1u << 7)), (1u << 4, 1u << 4), (1u << 6, 1u << 5), (1u << 5, 1u << 6),
|
||||
(0, 1u << 3), (1u << 6, 0), (1u << 7, 1u << 7), (1u << 8, 1u << 6),
|
||||
];
|
||||
|
||||
private static uint ReferenceOffset(uint x, uint y, (uint XMask, uint YMask)[] pattern)
|
||||
{
|
||||
uint offset = 0;
|
||||
for (var bit = 0; bit < pattern.Length; bit++)
|
||||
{
|
||||
var parity = (System.Numerics.BitOperations.PopCount(x & pattern[bit].XMask) +
|
||||
System.Numerics.BitOperations.PopCount(y & pattern[bit].YMask)) & 1;
|
||||
offset |= (uint)parity << bit;
|
||||
}
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(384, 200)]
|
||||
[InlineData(768, 512)]
|
||||
public void TryDetile_ExactXorMode27_MatchesReferenceAddressEquation(
|
||||
int elementsWide,
|
||||
int elementsHigh)
|
||||
{
|
||||
const uint swizzleMode = 27; // 64 KiB RB+ R_X
|
||||
const int bytesPerElement = 2;
|
||||
const int blockBytes = 65536;
|
||||
// SquareBlockDimensions(32768 elements): 15 bits split 8/7, x favored.
|
||||
const int blockWidth = 256;
|
||||
const int blockHeight = 128;
|
||||
var blocksPerRow = (elementsWide + blockWidth - 1) / blockWidth;
|
||||
var blocksPerColumn = (elementsHigh + blockHeight - 1) / blockHeight;
|
||||
|
||||
// Lay out a tiled source where each element stores its own linear index,
|
||||
// placed at the byte address the AddrLib equation dictates. The tiled
|
||||
// buffer is sized by padded whole blocks (block addressing overshoots the
|
||||
// linear extent). A correct detile must recover ascending linear indices.
|
||||
var tiled = new byte[blocksPerRow * blocksPerColumn * blockBytes];
|
||||
for (var y = 0; y < elementsHigh; y++)
|
||||
{
|
||||
for (var x = 0; x < elementsWide; x++)
|
||||
{
|
||||
var blockIndex = (long)(y / blockHeight) * blocksPerRow + (x / blockWidth);
|
||||
// The equation yields a byte offset within the block (bit 0 is
|
||||
// Zero at 2bpp, keeping element writes 2-byte aligned).
|
||||
var sourceByte = (int)(blockIndex * blockBytes +
|
||||
ReferenceOffset((uint)x, (uint)y, RbPlus64KRenderX2Bpp));
|
||||
var linearIndex = (ushort)(y * elementsWide + x);
|
||||
tiled[sourceByte] = (byte)linearIndex;
|
||||
tiled[sourceByte + 1] = (byte)(linearIndex >> 8);
|
||||
}
|
||||
}
|
||||
|
||||
var linear = new byte[elementsWide * elementsHigh * bytesPerElement];
|
||||
var ok = GnmTiling.TryDetile(tiled, linear, swizzleMode, elementsWide, elementsHigh, bytesPerElement);
|
||||
|
||||
Assert.True(ok);
|
||||
for (var i = 0; i < elementsWide * elementsHigh; i++)
|
||||
{
|
||||
var value = (ushort)(linear[i * 2] | (linear[i * 2 + 1] << 8));
|
||||
Assert.Equal((ushort)i, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Ampr;
|
||||
using System.Buffers.Binary;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Ampr;
|
||||
|
||||
public sealed class AmprWriteAddressTests
|
||||
{
|
||||
[Fact]
|
||||
public void MeasureCommandSizeWriteAddress0400_MatchesOnCompletionVariant()
|
||||
{
|
||||
const string nid = "4fgtGfXDrFc";
|
||||
const ulong memoryBase = 0x1_0000_0000;
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x1000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
var manager = CreateManagerWithExport(
|
||||
nid,
|
||||
"sceAmprMeasureCommandSizeWriteAddress_04_00");
|
||||
|
||||
Assert.Equal(OrbisGen2Result.ORBIS_GEN2_OK, manager.Dispatch(nid, context));
|
||||
var measured = context[CpuRegister.Rax];
|
||||
|
||||
Assert.Equal(0, AmprExports.MeasureCommandSizeWriteAddressOnCompletion(context));
|
||||
Assert.Equal(context[CpuRegister.Rax], measured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandBufferWriteAddress0400_WritesValueOnCompletion()
|
||||
{
|
||||
const string nid = "j0+3uJMxYJY";
|
||||
const ulong memoryBase = 0x1_0000_0000;
|
||||
const ulong commandBufferAddress = memoryBase + 0x100;
|
||||
const ulong recordBufferAddress = memoryBase + 0x200;
|
||||
const ulong watcherAddress = memoryBase + 0x800;
|
||||
const ulong watcherValue = 1;
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x1000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
var manager = CreateManagerWithExport(
|
||||
nid,
|
||||
"sceAmprCommandBufferWriteAddress_04_00");
|
||||
|
||||
context[CpuRegister.Rdi] = commandBufferAddress;
|
||||
context[CpuRegister.Rsi] = recordBufferAddress;
|
||||
context[CpuRegister.Rdx] = 0x100;
|
||||
|
||||
Assert.Equal(0, AmprExports.CommandBufferConstructor(context));
|
||||
|
||||
context[CpuRegister.Rdi] = commandBufferAddress;
|
||||
context[CpuRegister.Rsi] = watcherAddress;
|
||||
context[CpuRegister.Rdx] = watcherValue;
|
||||
|
||||
Assert.Equal(OrbisGen2Result.ORBIS_GEN2_OK, manager.Dispatch(nid, context));
|
||||
|
||||
Span<byte> watcher = stackalloc byte[sizeof(ulong)];
|
||||
Assert.True(memory.TryRead(watcherAddress, watcher));
|
||||
Assert.Equal(0UL, BinaryPrimitives.ReadUInt64LittleEndian(watcher));
|
||||
|
||||
Assert.Equal(0, AmprExports.CompleteCommandBuffer(context, commandBufferAddress));
|
||||
|
||||
Assert.True(memory.TryRead(watcherAddress, watcher));
|
||||
Assert.Equal(watcherValue, BinaryPrimitives.ReadUInt64LittleEndian(watcher));
|
||||
}
|
||||
|
||||
private static ModuleManager CreateManagerWithExport(string nid, string exportName)
|
||||
{
|
||||
var manager = new ModuleManager();
|
||||
manager.RegisterExports(
|
||||
SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen5));
|
||||
|
||||
Assert.True(manager.TryGetExport(nid, out var export), $"NID {nid} did not register.");
|
||||
Assert.Equal(exportName, export.Name);
|
||||
Assert.Equal("libSceAmpr", export.LibraryName);
|
||||
Assert.Equal(Generation.Gen5, export.Target);
|
||||
return manager;
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,25 @@ public sealed class AprStreamingContractTests
|
||||
const ulong destinationAddress = memoryBase + 0x2000;
|
||||
const ulong stackAddress = memoryBase + 0x3000;
|
||||
byte[] fileContents = [10, 11, 12, 13, 14, 15, 16, 17];
|
||||
var hostPath = Path.GetTempFileName();
|
||||
// The kernel FS resolver default-denies raw absolute host paths, so the
|
||||
// guest addresses the file through a registered mount instead of handing
|
||||
// in a bare host temp path.
|
||||
var mountRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"sharpemu-apr-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(mountRoot);
|
||||
var mountPoint = $"/sharpemu_apr_mnt_{Guid.NewGuid():N}";
|
||||
const string fileName = "asset.bin";
|
||||
var hostPath = Path.Combine(mountRoot, fileName);
|
||||
var guestPath = $"{mountPoint}/{fileName}";
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(hostPath, fileContents);
|
||||
KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, mountRoot);
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x4000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
memory.WriteCString(pathAddress, hostPath);
|
||||
memory.WriteCString(pathAddress, guestPath);
|
||||
WriteUInt64(memory, pathListAddress, pathAddress);
|
||||
|
||||
context[CpuRegister.Rdi] = pathListAddress;
|
||||
@@ -86,10 +97,155 @@ public sealed class AprStreamingContractTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(hostPath);
|
||||
KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint);
|
||||
if (Directory.Exists(mountRoot))
|
||||
{
|
||||
Directory.Delete(mountRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveFilepathsToIdsAndFileSizes_MissingFile_FailsFastWithErrorIndex()
|
||||
{
|
||||
const ulong memoryBase = 0x1_0000_0000;
|
||||
const ulong pathListAddress = memoryBase + 0x100;
|
||||
const ulong pathAddress = memoryBase + 0x200;
|
||||
const ulong idsAddress = memoryBase + 0x800;
|
||||
const ulong sizesAddress = memoryBase + 0x880;
|
||||
const ulong errorIndexAddress = memoryBase + 0x8F0;
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x4000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
var missingHostPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"sharpemu-apr-missing-{Guid.NewGuid():N}.bin");
|
||||
memory.WriteCString(pathAddress, missingHostPath);
|
||||
WriteUInt64(memory, pathListAddress, pathAddress);
|
||||
|
||||
context[CpuRegister.Rdi] = pathListAddress;
|
||||
context[CpuRegister.Rsi] = 1;
|
||||
context[CpuRegister.Rdx] = idsAddress;
|
||||
context[CpuRegister.Rcx] = sizesAddress;
|
||||
context[CpuRegister.R8] = errorIndexAddress;
|
||||
|
||||
Assert.Equal(-1, KernelMemoryCompatExports.KernelAprResolveFilepathsToIdsAndFileSizes(context));
|
||||
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
|
||||
Assert.Equal(uint.MaxValue, ReadUInt32(memory, idsAddress));
|
||||
Assert.Equal(0ul, ReadUInt64(memory, sizesAddress));
|
||||
Assert.Equal(0u, ReadUInt32(memory, errorIndexAddress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveFilepathsToIdsAndFileSizes_InvalidErrorIndex_ReturnsMemoryFault()
|
||||
{
|
||||
const ulong memoryBase = 0x1_0000_0000;
|
||||
const ulong pathListAddress = memoryBase + 0x100;
|
||||
const ulong pathAddress = memoryBase + 0x200;
|
||||
const ulong idsAddress = memoryBase + 0x800;
|
||||
const ulong sizesAddress = memoryBase + 0x880;
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x4000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
var missingHostPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"sharpemu-apr-missing-{Guid.NewGuid():N}.bin");
|
||||
memory.WriteCString(pathAddress, missingHostPath);
|
||||
WriteUInt64(memory, pathListAddress, pathAddress);
|
||||
|
||||
context[CpuRegister.Rdi] = pathListAddress;
|
||||
context[CpuRegister.Rsi] = 1;
|
||||
context[CpuRegister.Rdx] = idsAddress;
|
||||
context[CpuRegister.Rcx] = sizesAddress;
|
||||
context[CpuRegister.R8] = memoryBase + 0x5000;
|
||||
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT,
|
||||
KernelMemoryCompatExports.KernelAprResolveFilepathsToIdsAndFileSizes(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveFilepathsToIdsAndFileSizes_MissingMidBatch_StopsAtFailingEntry()
|
||||
{
|
||||
const ulong memoryBase = 0x1_0000_0000;
|
||||
const ulong pathListAddress = memoryBase + 0x100;
|
||||
const ulong idsAddress = memoryBase + 0x800;
|
||||
const ulong sizesAddress = memoryBase + 0x880;
|
||||
const ulong errorIndexAddress = memoryBase + 0x8F0;
|
||||
byte[] fileContents = [1, 2, 3, 4, 5];
|
||||
// Entries 0 and 2 must resolve to a real file; the kernel FS resolver
|
||||
// default-denies raw absolute host paths, so the present file is reached
|
||||
// through a registered mount. The missing entry stays an unresolvable
|
||||
// path so the batch fails mid-way at index 1.
|
||||
var mountRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"sharpemu-apr-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(mountRoot);
|
||||
var mountPoint = $"/sharpemu_apr_mnt_{Guid.NewGuid():N}";
|
||||
const string fileName = "asset.bin";
|
||||
var hostPath = Path.Combine(mountRoot, fileName);
|
||||
var guestPath = $"{mountPoint}/{fileName}";
|
||||
var missingGuestPath = $"{mountPoint}/missing-{Guid.NewGuid():N}.bin";
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(hostPath, fileContents);
|
||||
KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, mountRoot);
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x4000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
memory.WriteCString(memoryBase + 0x200, guestPath);
|
||||
memory.WriteCString(memoryBase + 0x400, missingGuestPath);
|
||||
memory.WriteCString(memoryBase + 0x600, guestPath);
|
||||
WriteUInt64(memory, pathListAddress, memoryBase + 0x200);
|
||||
WriteUInt64(memory, pathListAddress + 8, memoryBase + 0x400);
|
||||
WriteUInt64(memory, pathListAddress + 16, memoryBase + 0x600);
|
||||
WriteUInt32(memory, idsAddress + 8, 0x1234_5678); // sentinel: entry 2 untouched
|
||||
WriteUInt64(memory, sizesAddress + 16, 0xDEAD);
|
||||
|
||||
context[CpuRegister.Rdi] = pathListAddress;
|
||||
context[CpuRegister.Rsi] = 3;
|
||||
context[CpuRegister.Rdx] = idsAddress;
|
||||
context[CpuRegister.Rcx] = sizesAddress;
|
||||
context[CpuRegister.R8] = errorIndexAddress;
|
||||
|
||||
Assert.Equal(-1, KernelMemoryCompatExports.KernelAprResolveFilepathsToIdsAndFileSizes(context));
|
||||
Assert.NotEqual(uint.MaxValue, ReadUInt32(memory, idsAddress));
|
||||
Assert.Equal((ulong)fileContents.Length, ReadUInt64(memory, sizesAddress));
|
||||
Assert.Equal(uint.MaxValue, ReadUInt32(memory, idsAddress + 4));
|
||||
Assert.Equal(0ul, ReadUInt64(memory, sizesAddress + 8));
|
||||
Assert.Equal(1u, ReadUInt32(memory, errorIndexAddress));
|
||||
Assert.Equal(0x1234_5678u, ReadUInt32(memory, idsAddress + 8));
|
||||
Assert.Equal(0xDEADul, ReadUInt64(memory, sizesAddress + 16));
|
||||
}
|
||||
finally
|
||||
{
|
||||
KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint);
|
||||
if (Directory.Exists(mountRoot))
|
||||
{
|
||||
Directory.Delete(mountRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[sizeof(uint)];
|
||||
Assert.True(memory.TryRead(address, bytes));
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(bytes);
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
|
||||
Assert.True(memory.TryRead(address, bytes));
|
||||
return BinaryPrimitives.ReadUInt64LittleEndian(bytes);
|
||||
}
|
||||
|
||||
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[sizeof(uint)];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(bytes, value);
|
||||
Assert.True(memory.TryWrite(address, bytes));
|
||||
}
|
||||
|
||||
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
|
||||
|
||||
@@ -80,6 +80,19 @@ public sealed class AjmExportsTests : IDisposable
|
||||
Assert.Equal(InvalidContext, RegisterCodec(contextId + 1, 1));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(23u)]
|
||||
[InlineData(24u)]
|
||||
public void Gen5CodecTypesCanRegisterAndCreateInstances(uint codecType)
|
||||
{
|
||||
var contextId = Initialize();
|
||||
|
||||
Assert.Equal(0, RegisterCodec(contextId, codecType));
|
||||
Assert.Equal(
|
||||
0,
|
||||
CreateInstance(contextId, codecType, 0x401, InstanceAddress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceDestroy_RejectsUnknownContextAndSlot()
|
||||
{
|
||||
|
||||
@@ -40,6 +40,84 @@ public sealed class AvPlayerPathTests : IDisposable
|
||||
AssertPathIsInsideApp0(resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnrealRelativeRawPathAnchorsAtApp0AndResolvesMedia()
|
||||
{
|
||||
var mediaPath = CreateFile("SampleProject/Content/Movies/Startup.mp4");
|
||||
|
||||
var resolved = AvPlayerExports.ResolveGuestPath(
|
||||
"../../../SampleProject/Content/Movies/Startup.mp4");
|
||||
|
||||
Assert.NotNull(resolved);
|
||||
Assert.Equal(File.ReadAllBytes(mediaPath), File.ReadAllBytes(resolved));
|
||||
AssertPathIsInsideApp0(resolved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnrealRelativeRawPathCannotEscapeApp0()
|
||||
{
|
||||
var outsidePath = Path.Combine(_tempRoot, "outside.mp4");
|
||||
File.WriteAllBytes(outsidePath, [0x7F]);
|
||||
CreateFile("outside.mp4");
|
||||
|
||||
Assert.Null(AvPlayerExports.ResolveGuestPath("../../../outside.mp4"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentDirectoryRawPathResolvesInsideApp0()
|
||||
{
|
||||
var mediaPath = CreateFile("Movies/Intro.mp4");
|
||||
|
||||
var resolved = AvPlayerExports.ResolveGuestPath("./Movies/Intro.mp4");
|
||||
|
||||
Assert.NotNull(resolved);
|
||||
Assert.Equal(Path.GetFullPath(mediaPath), resolved);
|
||||
AssertPathIsInsideApp0(resolved);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, "ffmpeg", "ffprobe")]
|
||||
[InlineData(true, "ffmpeg.exe", "ffprobe.exe")]
|
||||
public void MediaToolLookupUsesPlatformNames(
|
||||
bool isWindows,
|
||||
string ffmpegName,
|
||||
string ffprobeName)
|
||||
{
|
||||
var toolDirectory = Path.Combine(_tempRoot, "Media Tools");
|
||||
Directory.CreateDirectory(toolDirectory);
|
||||
var ffmpeg = Path.Combine(toolDirectory, ffmpegName);
|
||||
File.WriteAllBytes(ffmpeg, []);
|
||||
|
||||
var resolved = AvPlayerExports.FindFfmpeg(
|
||||
configured: null,
|
||||
searchPath: $"\"{toolDirectory}\"",
|
||||
isWindows);
|
||||
|
||||
Assert.Equal(ffmpeg, resolved);
|
||||
Assert.Equal(
|
||||
Path.Combine(toolDirectory, ffprobeName),
|
||||
AvPlayerExports.GetFfprobePath(ffmpeg, isWindows));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, "ffmpeg")]
|
||||
[InlineData(true, "ffmpeg.exe")]
|
||||
public void MediaToolLookupFindsPackagedBinary(bool isWindows, string executable)
|
||||
{
|
||||
var publishDirectory = Path.Combine(_tempRoot, "publish");
|
||||
Directory.CreateDirectory(Path.Combine(publishDirectory, "ffmpeg"));
|
||||
var ffmpeg = Path.Combine(publishDirectory, "ffmpeg", executable);
|
||||
File.WriteAllBytes(ffmpeg, []);
|
||||
|
||||
Assert.Equal(
|
||||
ffmpeg,
|
||||
AvPlayerExports.FindFfmpeg(
|
||||
configured: null,
|
||||
searchPath: null,
|
||||
isWindows,
|
||||
publishDirectory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RelativeFileUriCannotEscapeApp0()
|
||||
{
|
||||
@@ -143,12 +221,14 @@ public sealed class AvPlayerPathTests : IDisposable
|
||||
|
||||
private void AssertPathIsInsideApp0(string resolved)
|
||||
{
|
||||
var rootWithSeparator =
|
||||
Path.TrimEndingDirectorySeparator(Path.GetFullPath(_app0Root)) +
|
||||
Path.DirectorySeparatorChar;
|
||||
Assert.StartsWith(
|
||||
rootWithSeparator,
|
||||
Path.GetFullPath(resolved),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
var relative = Path.GetRelativePath(
|
||||
Path.GetFullPath(_app0Root),
|
||||
Path.GetFullPath(resolved));
|
||||
Assert.False(Path.IsPathFullyQualified(relative));
|
||||
Assert.NotEqual("..", relative);
|
||||
Assert.False(
|
||||
relative.StartsWith(
|
||||
".." + Path.DirectorySeparatorChar,
|
||||
StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.AvPlayer;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.AvPlayer;
|
||||
|
||||
public sealed class AvPlayerStreamInfoTests
|
||||
{
|
||||
private const string StreamInfoExNid = "ctTAcF5DiKQ";
|
||||
private const ulong BaseAddress = 0x1_0000_0000;
|
||||
private const int MemorySize = 0x2000;
|
||||
private const ulong InfoAddress = BaseAddress + 0x100;
|
||||
private const ulong Handle = 0xA0_0000_0001;
|
||||
private const ulong DurationMilliseconds = 0x0102_0304_0506_0708;
|
||||
private const byte Sentinel = 0xAB;
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, 0u)]
|
||||
[InlineData(true, 0u)]
|
||||
[InlineData(false, 1u)]
|
||||
[InlineData(true, 1u)]
|
||||
public void GetStreamInfoFunctionsDoNotWritePastThe32ByteStructure(
|
||||
bool useExtendedFunction,
|
||||
uint streamIndex)
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
AvPlayerExports.RegisterPlayerForTest(Handle, 1280, 720, DurationMilliseconds);
|
||||
try
|
||||
{
|
||||
Span<byte> window = stackalloc byte[40];
|
||||
window.Fill(Sentinel);
|
||||
Assert.True(memory.TryWrite(InfoAddress, window));
|
||||
|
||||
context[CpuRegister.Rdi] = Handle;
|
||||
context[CpuRegister.Rsi] = streamIndex;
|
||||
context[CpuRegister.Rdx] = InfoAddress;
|
||||
|
||||
var resultCode = useExtendedFunction
|
||||
? AvPlayerExports.AvPlayerGetStreamInfoEx(context)
|
||||
: AvPlayerExports.AvPlayerGetStreamInfo(context);
|
||||
Assert.Equal(0, resultCode);
|
||||
|
||||
Span<byte> result = stackalloc byte[40];
|
||||
Assert.True(memory.TryRead(InfoAddress, result));
|
||||
Assert.Equal(streamIndex, BinaryPrimitives.ReadUInt32LittleEndian(result));
|
||||
if (streamIndex == 0)
|
||||
{
|
||||
Assert.Equal(1280u, BinaryPrimitives.ReadUInt32LittleEndian(result[8..]));
|
||||
Assert.Equal(720u, BinaryPrimitives.ReadUInt32LittleEndian(result[12..]));
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(2, BinaryPrimitives.ReadUInt16LittleEndian(result[8..]));
|
||||
Assert.Equal(48_000u, BinaryPrimitives.ReadUInt32LittleEndian(result[12..]));
|
||||
}
|
||||
Assert.Equal(DurationMilliseconds, BinaryPrimitives.ReadUInt64LittleEndian(result[24..]));
|
||||
|
||||
for (var index = 32; index < result.Length; index++)
|
||||
{
|
||||
Assert.Equal(Sentinel, result[index]);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
AvPlayerExports.RemovePlayerForTest(Handle);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void GetStreamInfoFunctionsRejectInvalidArguments(bool useExtendedFunction)
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
AvPlayerExports.RegisterPlayerForTest(Handle, 1280, 720, DurationMilliseconds);
|
||||
|
||||
try
|
||||
{
|
||||
context[CpuRegister.Rdi] = Handle;
|
||||
context[CpuRegister.Rsi] = 2;
|
||||
context[CpuRegister.Rdx] = InfoAddress;
|
||||
Assert.NotEqual(0, InvokeGetStreamInfo(context, useExtendedFunction));
|
||||
|
||||
context[CpuRegister.Rsi] = 0;
|
||||
context[CpuRegister.Rdx] = 0;
|
||||
Assert.NotEqual(0, InvokeGetStreamInfo(context, useExtendedFunction));
|
||||
|
||||
context[CpuRegister.Rdi] = Handle + 1;
|
||||
context[CpuRegister.Rdx] = InfoAddress;
|
||||
Assert.NotEqual(0, InvokeGetStreamInfo(context, useExtendedFunction));
|
||||
}
|
||||
finally
|
||||
{
|
||||
AvPlayerExports.RemovePlayerForTest(Handle);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StreamInfoExExportIsRegisteredForGen5Only()
|
||||
{
|
||||
var gen4Manager = new ModuleManager();
|
||||
gen4Manager.RegisterExports(
|
||||
SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen4));
|
||||
Assert.False(gen4Manager.TryGetExport(StreamInfoExNid, out _));
|
||||
|
||||
var gen5Manager = new ModuleManager();
|
||||
gen5Manager.RegisterExports(
|
||||
SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen5));
|
||||
Assert.True(gen5Manager.TryGetExport(StreamInfoExNid, out var export));
|
||||
Assert.Equal("sceAvPlayerGetStreamInfoEx", export.Name);
|
||||
Assert.Equal("libSceAvPlayer", export.LibraryName);
|
||||
Assert.Equal(Generation.Gen5, export.Target);
|
||||
}
|
||||
|
||||
private static int InvokeGetStreamInfo(CpuContext context, bool useExtendedFunction) =>
|
||||
useExtendedFunction
|
||||
? AvPlayerExports.AvPlayerGetStreamInfoEx(context)
|
||||
: AvPlayerExports.AvPlayerGetStreamInfo(context);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.Libs.Bink;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Bink;
|
||||
|
||||
public sealed class Bink2MovieBridgeTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDirectory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"sharpemu-bink-{Guid.NewGuid():N}");
|
||||
|
||||
public Bink2MovieBridgeTests()
|
||||
{
|
||||
Directory.CreateDirectory(_tempDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeaderPreservesFractionalFrameRate()
|
||||
{
|
||||
var path = WriteHeader("KB2j"u8, 3840, 2160, 30_000, 1_001);
|
||||
|
||||
Assert.True(Bink2MovieBridge.TryReadBinkInfo(path, out var info));
|
||||
Assert.Equal(3840u, info.Width);
|
||||
Assert.Equal(2160u, info.Height);
|
||||
Assert.Equal(30_000u, info.FramesPerSecondNumerator);
|
||||
Assert.Equal(1_001u, info.FramesPerSecondDenominator);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("KB2g")]
|
||||
[InlineData("KB2i")]
|
||||
[InlineData("KB2j")]
|
||||
public void HeaderAcceptsBink2Revisions(string signature)
|
||||
{
|
||||
var path = WriteHeader(
|
||||
System.Text.Encoding.ASCII.GetBytes(signature),
|
||||
1920,
|
||||
1080,
|
||||
60,
|
||||
1);
|
||||
|
||||
Assert.True(Bink2MovieBridge.TryReadBinkInfo(path, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeaderRejectsMissingFrameRateDenominator()
|
||||
{
|
||||
var path = WriteHeader("KB2j"u8, 1920, 1080, 60, 0);
|
||||
|
||||
Assert.False(Bink2MovieBridge.TryReadBinkInfo(path, out _));
|
||||
}
|
||||
|
||||
private string WriteHeader(
|
||||
ReadOnlySpan<byte> signature,
|
||||
uint width,
|
||||
uint height,
|
||||
uint fpsNumerator,
|
||||
uint fpsDenominator)
|
||||
{
|
||||
var header = new byte[36];
|
||||
signature.CopyTo(header);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x14), width);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x18), height);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x1C), fpsNumerator);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x20), fpsDenominator);
|
||||
var path = Path.Combine(_tempDirectory, $"{Guid.NewGuid():N}.bk2");
|
||||
File.WriteAllBytes(path, header);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Directory.Delete(_tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.Bink;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Bink;
|
||||
|
||||
public sealed class BinkFramePlaybackTests
|
||||
{
|
||||
[Fact]
|
||||
public void FramesAdvanceAccordingToMovieClock()
|
||||
{
|
||||
using var playback = new BinkFramePlayback(new SequenceDecoder(1, 2, 3));
|
||||
|
||||
Assert.Equal(1, WaitForAdvancedFrame(playback)[0]);
|
||||
Assert.True(playback.TryGetFrame(true, out var heldFrame, out var advanced));
|
||||
Assert.False(advanced);
|
||||
Assert.Equal(1, heldFrame[0]);
|
||||
|
||||
Assert.Equal(2, WaitForAdvancedFrame(playback)[0]);
|
||||
Assert.Equal(3, WaitForAdvancedFrame(playback)[0]);
|
||||
}
|
||||
|
||||
private static byte[] WaitForAdvancedFrame(BinkFramePlayback playback)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (playback.TryGetFrame(true, out var frame, out var advanced) && advanced)
|
||||
{
|
||||
return frame;
|
||||
}
|
||||
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
|
||||
throw new TimeoutException("The decoder did not produce a frame.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstFrameWaitsUntilPresentationStarts()
|
||||
{
|
||||
using var playback = new BinkFramePlayback(new SequenceDecoder(1, 2));
|
||||
|
||||
var first = WaitForFrame(playback, advanceClock: false);
|
||||
Assert.Equal(1, first[0]);
|
||||
Thread.Sleep(100);
|
||||
|
||||
Assert.True(playback.TryGetFrame(false, out var held, out var advanced));
|
||||
Assert.False(advanced);
|
||||
Assert.Equal(1, held[0]);
|
||||
|
||||
Assert.True(playback.TryGetFrame(true, out held, out advanced));
|
||||
Assert.False(advanced);
|
||||
Assert.Equal(1, held[0]);
|
||||
Assert.Equal(2, WaitForAdvancedFrame(playback)[0]);
|
||||
}
|
||||
|
||||
private static byte[] WaitForFrame(
|
||||
BinkFramePlayback playback,
|
||||
bool advanceClock)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (playback.TryGetFrame(advanceClock, out var frame, out _))
|
||||
{
|
||||
return frame;
|
||||
}
|
||||
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
|
||||
throw new TimeoutException("The decoder did not produce a frame.");
|
||||
}
|
||||
|
||||
private sealed class SequenceDecoder(params byte[] values) : IBinkFrameDecoder
|
||||
{
|
||||
private int _index;
|
||||
|
||||
public uint Width => 1;
|
||||
|
||||
public uint Height => 1;
|
||||
|
||||
public uint FramesPerSecondNumerator => 20;
|
||||
|
||||
public uint FramesPerSecondDenominator => 1;
|
||||
|
||||
public bool TryDecodeNextFrame(Span<byte> destination)
|
||||
{
|
||||
if (_index >= values.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
destination.Fill(values[_index++]);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Reflection;
|
||||
using SharpEmu.Core.Cpu.Native;
|
||||
using SharpEmu.HLE;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Cpu;
|
||||
|
||||
public sealed class GuestThreadBlockWaiterRepresentationTests
|
||||
{
|
||||
[Fact]
|
||||
public void SchedulerStoresOnlyTheWaiterObjectRepresentation()
|
||||
{
|
||||
var stateType = typeof(DirectExecutionBackend).GetNestedType(
|
||||
"GuestThreadState",
|
||||
BindingFlags.NonPublic);
|
||||
Assert.NotNull(stateType);
|
||||
|
||||
var waiterProperty = stateType.GetProperty(
|
||||
"BlockWaiter",
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
|
||||
Assert.NotNull(waiterProperty);
|
||||
Assert.Equal(typeof(IGuestThreadBlockWaiter), waiterProperty.PropertyType);
|
||||
Assert.Null(stateType.GetProperty(
|
||||
"BlockResumeHandler",
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic));
|
||||
Assert.Null(stateType.GetProperty(
|
||||
"BlockWakeHandler",
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic));
|
||||
|
||||
var registerMethods = typeof(DirectExecutionBackend)
|
||||
.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.Where(method => method.Name == "RegisterBlockedGuestThreadContinuation")
|
||||
.ToArray();
|
||||
var registerMethod = Assert.Single(registerMethods);
|
||||
Assert.Contains(
|
||||
registerMethod.GetParameters(),
|
||||
parameter => parameter.ParameterType == typeof(IGuestThreadBlockWaiter));
|
||||
Assert.DoesNotContain(
|
||||
registerMethod.GetParameters(),
|
||||
parameter => IsFuncParameter(parameter.ParameterType));
|
||||
|
||||
var consumeMethods = typeof(GuestThreadExecution)
|
||||
.GetMethods(BindingFlags.Static | BindingFlags.Public)
|
||||
.Where(method => method.Name == nameof(GuestThreadExecution.TryConsumeCurrentThreadBlock));
|
||||
Assert.DoesNotContain(
|
||||
consumeMethods.SelectMany(method => method.GetParameters()),
|
||||
parameter => IsFuncParameter(parameter.ParameterType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DelegateCompatibilityBridgeIsConsumedAsOneWaiterObject()
|
||||
{
|
||||
var previousThread = GuestThreadExecution.EnterGuestThread(0x1234);
|
||||
try
|
||||
{
|
||||
var canWake = false;
|
||||
var wakeCalls = 0;
|
||||
var resumeCalls = 0;
|
||||
Assert.True(GuestThreadExecution.RequestCurrentThreadBlock(
|
||||
context: null,
|
||||
reason: "test_wait",
|
||||
wakeKey: "test_waiter:1",
|
||||
resumeHandler: () =>
|
||||
{
|
||||
resumeCalls++;
|
||||
return 42;
|
||||
},
|
||||
wakeHandler: () =>
|
||||
{
|
||||
wakeCalls++;
|
||||
return canWake;
|
||||
}));
|
||||
|
||||
Assert.True(GuestThreadExecution.TryConsumeCurrentThreadBlock(
|
||||
out var reason,
|
||||
out _,
|
||||
out var hasContinuation,
|
||||
out var wakeKey,
|
||||
out IGuestThreadBlockWaiter? waiter,
|
||||
out var deadline));
|
||||
Assert.Equal("test_wait", reason);
|
||||
Assert.Equal("test_waiter:1", wakeKey);
|
||||
Assert.False(hasContinuation);
|
||||
Assert.Equal(0, deadline);
|
||||
Assert.NotNull(waiter);
|
||||
|
||||
Assert.False(waiter.TryWake());
|
||||
canWake = true;
|
||||
Assert.True(waiter.TryWake());
|
||||
Assert.Equal(42, waiter.Resume());
|
||||
Assert.Equal(2, wakeCalls);
|
||||
Assert.Equal(1, resumeCalls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
GuestThreadExecution.RestoreGuestThread(previousThread);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsFuncParameter(Type parameterType)
|
||||
{
|
||||
var type = parameterType.IsByRef
|
||||
? parameterType.GetElementType()
|
||||
: parameterType;
|
||||
return type is not null &&
|
||||
type.IsGenericType &&
|
||||
type.GetGenericTypeDefinition() == typeof(Func<>);
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Core.Cpu.Emulation;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Cpu;
|
||||
|
||||
public sealed class Sha1InstructionEmulatorTests
|
||||
{
|
||||
private static readonly Sha1Vector Destination = new(
|
||||
0x0123_4567u, 0x89AB_CDEFu, 0x0F1E_2D3Cu, 0x4B5A_6978u);
|
||||
private static readonly Sha1Vector Source = new(
|
||||
0xFEDC_BA98u, 0x7654_3210u, 0xF0E1_D2C3u, 0xB4A5_9687u);
|
||||
|
||||
[Fact]
|
||||
public void MessageSchedule1_MatchesIntelLaneSemantics()
|
||||
{
|
||||
Assert.Equal(
|
||||
new Sha1Vector(0xF1C2_97A4u, 0x3D0E_5B68u, 0x0E3D_685Bu, 0xC2F1_A497u),
|
||||
Sha1InstructionEmulator.MessageSchedule1(Destination, Source));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MessageSchedule2_MatchesIntelLaneSemantics()
|
||||
{
|
||||
Assert.Equal(
|
||||
new Sha1Vector(0xECA8_6420u, 0xEEEE_EEEEu, 0xF294_3E58u, 0x7777_7777u),
|
||||
Sha1InstructionEmulator.MessageSchedule2(Destination, Source));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NextE_MatchesIntelLaneSemantics()
|
||||
{
|
||||
Assert.Equal(
|
||||
new Sha1Vector(0xFEDC_BA98u, 0x7654_3210u, 0xF0E1_D2C3u, 0xC77C_30E5u),
|
||||
Sha1InstructionEmulator.NextE(Destination, Source));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0x20E8_2326u, 0x911F_2CA8u, 0xECE0_593Fu, 0x0C1C_11FBu)]
|
||||
[InlineData(1, 0x4598_D5B9u, 0x7BA0_0411u, 0x464E_3C51u, 0xF514_1B52u)]
|
||||
[InlineData(2, 0xEE0E_73F6u, 0x2509_A67Bu, 0x26C6_85CCu, 0x0097_5845u)]
|
||||
[InlineData(3, 0x9C7B_0B46u, 0xAEC8_EB49u, 0x8FD5_A2B7u, 0xFD49_9AECu)]
|
||||
public void FourRounds_MatchesAllFourSha1Functions(
|
||||
byte function,
|
||||
uint lane0,
|
||||
uint lane1,
|
||||
uint lane2,
|
||||
uint lane3)
|
||||
{
|
||||
Assert.Equal(
|
||||
new Sha1Vector(lane0, lane1, lane2, lane3),
|
||||
Sha1InstructionEmulator.FourRounds(Destination, Source, function));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Core.Cpu.Emulation;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Cpu;
|
||||
|
||||
// These exercise the pure EXTRQ/INSERTQ bit-field semantics used by the general SSE4a
|
||||
// illegal-instruction software fallback (DirectExecutionBackend.Amd64Compat.cs). Expected values
|
||||
// were computed from the AMD64 Architecture Programmer's Manual definitions and cross-checked
|
||||
// with an independent Python re-implementation before being written here, so a regression in the
|
||||
// ported bit math fails in this file without needing a live guest or a Windows host.
|
||||
public sealed class Sse4aBitFieldEmulatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExtractBitField_ExtractsLowByte()
|
||||
{
|
||||
var result = Sse4aBitFieldEmulator.ExtractBitField(0x1234_5678_9ABC_DEF0, length: 8, index: 0);
|
||||
|
||||
Assert.Equal(0xF0UL, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBitField_ExtractsMidFieldAtNonZeroIndex()
|
||||
{
|
||||
// bits [31:16] of 0x1234_5678_9ABC_DEF0 == 0x9ABC
|
||||
var result = Sse4aBitFieldEmulator.ExtractBitField(0x1234_5678_9ABC_DEF0, length: 16, index: 16);
|
||||
|
||||
Assert.Equal(0x9ABCUL, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBitField_LengthZeroMeansSixtyFour()
|
||||
{
|
||||
var result = Sse4aBitFieldEmulator.ExtractBitField(0xFFFF_FFFF_FFFF_FFFF, length: 0, index: 0);
|
||||
|
||||
Assert.Equal(0xFFFF_FFFF_FFFF_FFFFUL, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBitField_MasksImmediatesToLowSixBits()
|
||||
{
|
||||
// length=0x28 (40) and index=0 is exactly the idiom SharpEmu's load-time
|
||||
// Sse4aExtrqBlendPatch already recognizes; the general emulator must agree with it.
|
||||
var result = Sse4aBitFieldEmulator.ExtractBitField(0x0000_0000_0000_00FF, length: 0x28, index: 0);
|
||||
|
||||
Assert.Equal(0xFFUL, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0x1234_5678_9ABC_DEF0UL)]
|
||||
[InlineData(0x0000_0000_0000_0000UL)]
|
||||
[InlineData(0xFFFF_FFFF_FFFF_FFFFUL)]
|
||||
[InlineData(0x00FF_00FF_00FF_00FFUL)]
|
||||
public void ExtractBitField_AgreesWithSse4aExtrqBlendPatchsByteFourRule(ulong value)
|
||||
{
|
||||
// Sse4aExtrqBlendPatch's own comment states that after "EXTRQ xmmN, 0x28, 0x00", dword
|
||||
// lane 1 (bits 63:32) of the result equals byte 4 of the source zero-extended. The
|
||||
// general emulator (used for every other EXTRQ occurrence) must produce a result
|
||||
// consistent with that independently-reverse-engineered rule for the one idiom both
|
||||
// paths can be checked against.
|
||||
var extractedLow64 = Sse4aBitFieldEmulator.ExtractBitField(value, length: 0x28, index: 0);
|
||||
var dword1 = (uint)(extractedLow64 >> 32);
|
||||
var byteFourZeroExtended = (uint)((value >> 32) & 0xFF);
|
||||
|
||||
Assert.Equal(byteFourZeroExtended, dword1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBitField_RejectsUndefinedFieldPastRegisterEnd()
|
||||
{
|
||||
Assert.False(Sse4aBitFieldEmulator.IsValidBitField(length: 8, index: 60));
|
||||
Assert.Equal(0UL, Sse4aBitFieldEmulator.ExtractBitField(
|
||||
0xFFFF_FFFF_FFFF_FFFF,
|
||||
length: 8,
|
||||
index: 60));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractBitField_RejectsZeroLengthAtNonZeroIndex()
|
||||
{
|
||||
Assert.False(Sse4aBitFieldEmulator.IsValidBitField(length: 0, index: 1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertBitField_InsertsFieldAtNonZeroIndexWithoutDisturbingOtherBits()
|
||||
{
|
||||
var result = Sse4aBitFieldEmulator.InsertBitField(
|
||||
destination: 0x0000_0000_0000_0000,
|
||||
source: 0xFFFF_FFFF_FFFF_FFFF,
|
||||
length: 8,
|
||||
index: 8);
|
||||
|
||||
Assert.Equal(0x0000_0000_0000_FF00UL, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertBitField_ClearsExactlyTheDestinationWindowBeforeInserting()
|
||||
{
|
||||
var result = Sse4aBitFieldEmulator.InsertBitField(
|
||||
destination: 0xFFFF_FFFF_FFFF_FFFF,
|
||||
source: 0x0000_0000_0000_0000,
|
||||
length: 16,
|
||||
index: 16);
|
||||
|
||||
Assert.Equal(0xFFFF_FFFF_0000_FFFFUL, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertBitField_InsertsLowByteAtIndexZero()
|
||||
{
|
||||
var result = Sse4aBitFieldEmulator.InsertBitField(
|
||||
destination: 0x1122_3344_5566_7788,
|
||||
source: 0xAABB_CCDD_EEFF_0011,
|
||||
length: 8,
|
||||
index: 0);
|
||||
|
||||
Assert.Equal(0x1122_3344_5566_7711UL, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertBitField_LengthZeroMeansSixtyFourAndOverwritesEverything()
|
||||
{
|
||||
var result = Sse4aBitFieldEmulator.InsertBitField(
|
||||
destination: 0x1111_1111_1111_1111,
|
||||
source: 0xFFFF_FFFF_FFFF_FFFF,
|
||||
length: 0,
|
||||
index: 0);
|
||||
|
||||
Assert.Equal(0xFFFF_FFFF_FFFF_FFFFUL, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertBitField_ZeroSourceFieldClearsOnlyItsOwnWindow()
|
||||
{
|
||||
// A zero-valued 12-bit field inserted at index 20 clears exactly bits [31:20]
|
||||
// (0x234 -> 0x000) and leaves every bit outside that window untouched.
|
||||
var result = Sse4aBitFieldEmulator.InsertBitField(
|
||||
destination: 0xABCD_EF01_2345_6789,
|
||||
source: 0,
|
||||
length: 12,
|
||||
index: 20);
|
||||
|
||||
Assert.Equal(0xABCD_EF01_0005_6789UL, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using SharpEmu.Core.Cpu.Emulation;
|
||||
using SharpEmu.Core.Cpu.Native;
|
||||
using SharpEmu.HLE;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Cpu;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for the SSE4a EXTRQ/INSERTQ fault recovery through the POSIX signal bridge on
|
||||
/// Linux. Each test fabricates the exact frame the kernel hands the SIGILL handler - gregs
|
||||
/// whose RIP points at a real EXTRQ/INSERTQ encoding in probe-visible host memory, plus an
|
||||
/// FXSAVE image carrying the XMM registers - and drives the production entry point
|
||||
/// (TryHandlePosixFault) over it. The bridge must capture the XMM state into the CONTEXT
|
||||
/// scratch buffer, the recovery must decode and emulate the instruction, and the write-back
|
||||
/// must land the result in the FXSAVE image and advance RIP, because that is precisely what
|
||||
/// sigreturn restores on a live fault.
|
||||
/// </summary>
|
||||
public sealed unsafe class Sse4aPosixSignalRecoveryTests
|
||||
{
|
||||
private const int PosixSigIll = 4;
|
||||
private const int LinuxUcontextGregsOffset = 40;
|
||||
private const int LinuxGregsRipOffset = 16 * 8;
|
||||
private const int LinuxGregsFpstateOffset = 184;
|
||||
private const int FxsaveXmm0Offset = 160;
|
||||
private const int FxsaveXmm1Offset = 176;
|
||||
|
||||
private static readonly MethodInfo TryHandlePosixFault = typeof(DirectExecutionBackend).GetMethod(
|
||||
"TryHandlePosixFault",
|
||||
BindingFlags.Static | BindingFlags.NonPublic)!;
|
||||
|
||||
private static readonly FieldInfo PosixSignalBackend = typeof(DirectExecutionBackend).GetField(
|
||||
"_posixSignalBackend",
|
||||
BindingFlags.Static | BindingFlags.NonPublic)!;
|
||||
|
||||
private static readonly FieldInfo EmulatedCounter = typeof(DirectExecutionBackend).GetField(
|
||||
"_sse4aInstructionsEmulated",
|
||||
BindingFlags.Static | BindingFlags.NonPublic)!;
|
||||
|
||||
private static readonly FieldInfo XmmBridgedFlag = typeof(DirectExecutionBackend).GetField(
|
||||
"_posixXmmContextBridged",
|
||||
BindingFlags.Static | BindingFlags.NonPublic)!;
|
||||
|
||||
private static readonly MethodInfo TryRecoverAmdCompat = typeof(DirectExecutionBackend).GetMethod(
|
||||
"TryRecoverAmdCompatInstruction",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
|
||||
[Fact]
|
||||
public void ExtrqSigillRoundTripsXmmThroughTheBridge()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux() ||
|
||||
RuntimeInformation.ProcessArchitecture != Architecture.X64)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// extrq xmm0, 0x10, 0x08
|
||||
var code = AllocateProbeVisibleCode([0x66, 0x0F, 0x78, 0xC0, 0x10, 0x08]);
|
||||
try
|
||||
{
|
||||
const ulong value = 0x1234_5678_9ABC_DEF0UL;
|
||||
var frame = new FakeSignalFrame((ulong)code);
|
||||
frame.SetXmmLow(FxsaveXmm0Offset, value);
|
||||
var emulatedBefore = (long)EmulatedCounter.GetValue(null)!;
|
||||
|
||||
Assert.True(frame.Dispatch());
|
||||
|
||||
Assert.Equal(
|
||||
Sse4aBitFieldEmulator.ExtractBitField(value, length: 0x10, index: 0x08),
|
||||
frame.XmmLow(FxsaveXmm0Offset));
|
||||
Assert.Equal(0UL, frame.XmmHigh(FxsaveXmm0Offset));
|
||||
Assert.Equal((ulong)code + 6, frame.Rip);
|
||||
Assert.True((long)EmulatedCounter.GetValue(null)! > emulatedBefore);
|
||||
}
|
||||
finally
|
||||
{
|
||||
FreeProbeVisibleCode(code);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertqSigillReadsSourceXmmThroughTheBridge()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux() ||
|
||||
RuntimeInformation.ProcessArchitecture != Architecture.X64)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// insertq xmm0, xmm1, 0x10, 0x08
|
||||
var code = AllocateProbeVisibleCode([0xF2, 0x0F, 0x78, 0xC1, 0x10, 0x08]);
|
||||
try
|
||||
{
|
||||
const ulong destination = 0x1111_2222_3333_4444UL;
|
||||
const ulong source = 0xAAAA_BBBB_CCCC_DDDDUL;
|
||||
var frame = new FakeSignalFrame((ulong)code);
|
||||
frame.SetXmmLow(FxsaveXmm0Offset, destination);
|
||||
frame.SetXmmLow(FxsaveXmm1Offset, source);
|
||||
|
||||
Assert.True(frame.Dispatch());
|
||||
|
||||
Assert.Equal(
|
||||
Sse4aBitFieldEmulator.InsertBitField(destination, source, length: 0x10, index: 0x08),
|
||||
frame.XmmLow(FxsaveXmm0Offset));
|
||||
Assert.Equal((ulong)code + 6, frame.Rip);
|
||||
}
|
||||
finally
|
||||
{
|
||||
FreeProbeVisibleCode(code);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecoveryDeclinesWhenNoXmmStateWasBridged()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux() ||
|
||||
RuntimeInformation.ProcessArchitecture != Architecture.X64)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// extrq xmm0, 0x10, 0x08 - valid and recoverable, but without bridged XMM state
|
||||
// (fpstate missing from the frame) the recovery must decline rather than emulate
|
||||
// over the zeroed scratch bytes. Drive the recovery entry directly: earlier tests
|
||||
// on this thread leave the thread-static bridge flag set, so clear it the way a
|
||||
// fpstate-less capture would.
|
||||
var code = AllocateProbeVisibleCode([0x66, 0x0F, 0x78, 0xC0, 0x10, 0x08]);
|
||||
try
|
||||
{
|
||||
XmmBridgedFlag.SetValue(null, false);
|
||||
var backend = RuntimeHelpers.GetUninitializedObject(typeof(DirectExecutionBackend));
|
||||
var contextRecord = stackalloc byte[0x4D0];
|
||||
|
||||
var recovered = (bool)TryRecoverAmdCompat.Invoke(
|
||||
backend,
|
||||
[Pointer.Box(contextRecord, typeof(void*)), (ulong)code])!;
|
||||
|
||||
Assert.False(recovered);
|
||||
}
|
||||
finally
|
||||
{
|
||||
FreeProbeVisibleCode(code);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Linux x86-64 signal frame as TryHandlePosixFault consumes it: a ucontext whose
|
||||
/// mcontext gregs sit at +40 (kernel sigcontext layout) with the fpstate pointer at
|
||||
/// gregs+184 aiming at a 512-byte FXSAVE image.
|
||||
/// </summary>
|
||||
private sealed class FakeSignalFrame
|
||||
{
|
||||
private readonly byte[] _ucontext = new byte[512];
|
||||
private readonly byte[] _fpstate = new byte[512];
|
||||
private readonly bool _wireFpstate;
|
||||
|
||||
public FakeSignalFrame(ulong rip, bool wireFpstate = true)
|
||||
{
|
||||
_wireFpstate = wireFpstate;
|
||||
fixed (byte* ucontext = _ucontext)
|
||||
{
|
||||
*(ulong*)(ucontext + LinuxUcontextGregsOffset + LinuxGregsRipOffset) = rip;
|
||||
}
|
||||
}
|
||||
|
||||
public ulong Rip
|
||||
{
|
||||
get
|
||||
{
|
||||
fixed (byte* ucontext = _ucontext)
|
||||
{
|
||||
return *(ulong*)(ucontext + LinuxUcontextGregsOffset + LinuxGregsRipOffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetXmmLow(int fxsaveOffset, ulong value)
|
||||
{
|
||||
fixed (byte* fpstate = _fpstate)
|
||||
{
|
||||
*(ulong*)(fpstate + fxsaveOffset) = value;
|
||||
}
|
||||
}
|
||||
|
||||
public ulong XmmLow(int fxsaveOffset)
|
||||
{
|
||||
fixed (byte* fpstate = _fpstate)
|
||||
{
|
||||
return *(ulong*)(fpstate + fxsaveOffset);
|
||||
}
|
||||
}
|
||||
|
||||
public ulong XmmHigh(int fxsaveOffset)
|
||||
{
|
||||
fixed (byte* fpstate = _fpstate)
|
||||
{
|
||||
return *(ulong*)(fpstate + fxsaveOffset + 8);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Dispatch()
|
||||
{
|
||||
EnsureBridgeBackend();
|
||||
fixed (byte* ucontext = _ucontext)
|
||||
fixed (byte* fpstate = _fpstate)
|
||||
{
|
||||
if (_wireFpstate)
|
||||
{
|
||||
*(byte**)(ucontext + LinuxUcontextGregsOffset + LinuxGregsFpstateOffset) = fpstate;
|
||||
}
|
||||
|
||||
return (bool)TryHandlePosixFault.Invoke(
|
||||
null,
|
||||
[PosixSigIll, (nint)0, (nint)ucontext])!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TryHandlePosixFault only runs the recovery chain when a backend instance is
|
||||
/// registered. The tests do not need any of the constructor's state (and must not run
|
||||
/// it: it installs process-wide signal handlers), so register an uninitialized
|
||||
/// instance - the SIGILL recovery path only touches static state.
|
||||
/// </summary>
|
||||
private static void EnsureBridgeBackend()
|
||||
{
|
||||
if (PosixSignalBackend.GetValue(null) == null)
|
||||
{
|
||||
PosixSignalBackend.SetValue(
|
||||
null,
|
||||
RuntimeHelpers.GetUninitializedObject(typeof(DirectExecutionBackend)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The instruction bytes must live in memory the fault-time page probe
|
||||
/// (TryReadHostBytes -> VirtualQuery) can see; on POSIX that is HostMemory's shadow
|
||||
/// region table, the same allocator guest code pages come from. A raw libc mmap or a
|
||||
/// pinned managed array would be invisible and the recovery would decline before
|
||||
/// decoding.
|
||||
/// </summary>
|
||||
private static nint AllocateProbeVisibleCode(ReadOnlySpan<byte> instructions)
|
||||
{
|
||||
var size = checked((nuint)Environment.SystemPageSize);
|
||||
var mapping = (nint)HostMemory.Alloc(
|
||||
null,
|
||||
size,
|
||||
HostMemory.MEM_COMMIT | HostMemory.MEM_RESERVE,
|
||||
HostMemory.PAGE_READWRITE);
|
||||
Assert.NotEqual((nint)0, mapping);
|
||||
|
||||
instructions.CopyTo(new Span<byte>((void*)mapping, checked((int)size)));
|
||||
return mapping;
|
||||
}
|
||||
|
||||
private static void FreeProbeVisibleCode(nint mapping)
|
||||
{
|
||||
Assert.True(HostMemory.Free((void*)mapping, 0, HostMemory.MEM_RELEASE));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Fiber;
|
||||
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Fiber;
|
||||
|
||||
/// <summary>
|
||||
/// Contract tests for the libSceFiber HLE exports. These pin the current
|
||||
/// validation and layout behaviour of <see cref="FiberExports"/>; they do not
|
||||
/// exercise a live guest thread scheduler.
|
||||
/// </summary>
|
||||
public sealed class FiberExportsTests
|
||||
{
|
||||
private const ulong Base = 0x3_0000_0000UL;
|
||||
private const int RegionSize = 0x2000;
|
||||
|
||||
private const int ErrorNull = unchecked((int)0x80590001);
|
||||
private const int ErrorAlignment = unchecked((int)0x80590002);
|
||||
private const int ErrorRange = unchecked((int)0x80590003);
|
||||
private const int ErrorInvalid = unchecked((int)0x80590004);
|
||||
private const int ErrorPermission = unchecked((int)0x80590005);
|
||||
|
||||
private const uint SignatureStart = 0xDEF1649Cu;
|
||||
private const uint SignatureEnd = 0xB37592A0u;
|
||||
private const ulong StackSignature = 0x7149F2CA7149F2CAUL;
|
||||
private const uint StateIdle = 2;
|
||||
|
||||
private const ulong FiberAddress = Base;
|
||||
private const ulong NameAddress = Base + 0x200;
|
||||
private const ulong ContextAddress = Base + 0x400;
|
||||
private const ulong EntryAddress = 0x4_0000_1000UL;
|
||||
private const ulong InfoAddress = Base + 0x800;
|
||||
|
||||
public FiberExportsTests()
|
||||
{
|
||||
FiberExports.ResetRuntimeState();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptParamInitialize_NullParam_ReturnsNullError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
context[CpuRegister.Rdi] = 0;
|
||||
|
||||
var result = FiberExports.FiberOptParamInitialize(context);
|
||||
|
||||
Assert.Equal(ErrorNull, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSelf_NullOutAddress_ReturnsNullError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
context[CpuRegister.Rdi] = 0;
|
||||
|
||||
var result = FiberExports.FiberGetSelf(context);
|
||||
|
||||
Assert.Equal(ErrorNull, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSelf_OutsideFiberContext_ReturnsPermissionError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
context[CpuRegister.Rdi] = Base + 0x100;
|
||||
|
||||
var result = FiberExports.FiberGetSelf(context);
|
||||
|
||||
Assert.Equal(ErrorPermission, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInfo_NullInfo_ReturnsNullError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = 0;
|
||||
|
||||
var result = FiberExports.FiberGetInfo(context);
|
||||
|
||||
Assert.Equal(ErrorNull, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_NullFiber_ReturnsNullError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
WriteCString(memory, NameAddress, "F");
|
||||
|
||||
context[CpuRegister.Rdi] = 0;
|
||||
context[CpuRegister.Rsi] = NameAddress;
|
||||
context[CpuRegister.Rdx] = EntryAddress;
|
||||
|
||||
var result = FiberExports.FiberInitialize(context);
|
||||
|
||||
Assert.Equal(ErrorNull, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_NullName_ReturnsNullError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = 0;
|
||||
context[CpuRegister.Rdx] = EntryAddress;
|
||||
|
||||
var result = FiberExports.FiberInitialize(context);
|
||||
|
||||
Assert.Equal(ErrorNull, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_NullEntry_ReturnsNullError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
WriteCString(memory, NameAddress, "F");
|
||||
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = NameAddress;
|
||||
context[CpuRegister.Rdx] = 0;
|
||||
|
||||
var result = FiberExports.FiberInitialize(context);
|
||||
|
||||
Assert.Equal(ErrorNull, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_MisalignedFiber_ReturnsAlignmentError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
WriteCString(memory, NameAddress, "F");
|
||||
|
||||
context[CpuRegister.Rdi] = FiberAddress + 4;
|
||||
context[CpuRegister.Rsi] = NameAddress;
|
||||
context[CpuRegister.Rdx] = EntryAddress;
|
||||
|
||||
var result = FiberExports.FiberInitialize(context);
|
||||
|
||||
Assert.Equal(ErrorAlignment, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_TooSmallContextSize_ReturnsRangeError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
WriteCString(memory, NameAddress, "F");
|
||||
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = NameAddress;
|
||||
context[CpuRegister.Rdx] = EntryAddress;
|
||||
context[CpuRegister.R8] = ContextAddress;
|
||||
context[CpuRegister.R9] = 256;
|
||||
|
||||
var result = FiberExports.FiberInitialize(context);
|
||||
|
||||
Assert.Equal(ErrorRange, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_ContextAddressWithoutSize_ReturnsInvalidError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
WriteCString(memory, NameAddress, "F");
|
||||
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = NameAddress;
|
||||
context[CpuRegister.Rdx] = EntryAddress;
|
||||
context[CpuRegister.R8] = ContextAddress;
|
||||
context[CpuRegister.R9] = 0;
|
||||
|
||||
var result = FiberExports.FiberInitialize(context);
|
||||
|
||||
Assert.Equal(ErrorInvalid, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_Valid_WritesExpectedLayout()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
const string name = "TestFiber";
|
||||
WriteCString(memory, NameAddress, name);
|
||||
const ulong argOnInitialize = 0xDEADUL;
|
||||
const ulong contextSize = 512UL;
|
||||
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = NameAddress;
|
||||
context[CpuRegister.Rdx] = EntryAddress;
|
||||
context[CpuRegister.Rcx] = argOnInitialize;
|
||||
context[CpuRegister.R8] = ContextAddress;
|
||||
context[CpuRegister.R9] = contextSize;
|
||||
// RSP defaults to 0 (unmapped); ReadStackArg64 falls back to 0 ->
|
||||
// optParam = 0, buildVersion = 0. ApplyInitializationFlags(0, 0, false) == 0.
|
||||
|
||||
var result = FiberExports.FiberInitialize(context);
|
||||
|
||||
Assert.Equal(0, result);
|
||||
Assert.Equal(0UL, context[CpuRegister.Rax]);
|
||||
|
||||
Assert.Equal(SignatureStart, ReadUInt32(memory, FiberAddress + 0));
|
||||
Assert.Equal(StateIdle, ReadUInt32(memory, FiberAddress + 4));
|
||||
Assert.Equal(EntryAddress, ReadUInt64(memory, FiberAddress + 8));
|
||||
Assert.Equal(argOnInitialize, ReadUInt64(memory, FiberAddress + 16));
|
||||
Assert.Equal(ContextAddress, ReadUInt64(memory, FiberAddress + 24));
|
||||
Assert.Equal(contextSize, ReadUInt64(memory, FiberAddress + 32));
|
||||
AssertInlineName(memory, FiberAddress + 40, name);
|
||||
Assert.Equal(0UL, ReadUInt64(memory, FiberAddress + 72));
|
||||
Assert.Equal(0u, ReadUInt32(memory, FiberAddress + 80));
|
||||
Assert.Equal(ContextAddress, ReadUInt64(memory, FiberAddress + 88));
|
||||
Assert.Equal(ContextAddress + contextSize, ReadUInt64(memory, FiberAddress + 96));
|
||||
Assert.Equal(SignatureEnd, ReadUInt32(memory, FiberAddress + 104));
|
||||
Assert.Equal(StackSignature, ReadUInt64(memory, ContextAddress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInfo_AfterInitialize_RoundTripsFields()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
const string name = "RoundTrip";
|
||||
WriteCString(memory, NameAddress, name);
|
||||
const ulong argOnInitialize = 0xCAFEUL;
|
||||
const ulong contextSize = 512UL;
|
||||
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = NameAddress;
|
||||
context[CpuRegister.Rdx] = EntryAddress;
|
||||
context[CpuRegister.Rcx] = argOnInitialize;
|
||||
context[CpuRegister.R8] = ContextAddress;
|
||||
context[CpuRegister.R9] = contextSize;
|
||||
|
||||
Assert.Equal(0, FiberExports.FiberInitialize(context));
|
||||
|
||||
WriteUInt64(memory, InfoAddress, 128);
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = InfoAddress;
|
||||
|
||||
var result = FiberExports.FiberGetInfo(context);
|
||||
|
||||
Assert.Equal(0, result);
|
||||
Assert.Equal(EntryAddress, ReadUInt64(memory, InfoAddress + 8));
|
||||
Assert.Equal(argOnInitialize, ReadUInt64(memory, InfoAddress + 16));
|
||||
Assert.Equal(ContextAddress, ReadUInt64(memory, InfoAddress + 24));
|
||||
Assert.Equal(contextSize, ReadUInt64(memory, InfoAddress + 32));
|
||||
AssertInlineName(memory, InfoAddress + 40, name);
|
||||
Assert.Equal(ulong.MaxValue, ReadUInt64(memory, InfoAddress + 72));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetInfo_WrongSize_ReturnsInvalidError()
|
||||
{
|
||||
var memory = new FakeCpuMemory(Base, RegionSize);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
WriteCString(memory, NameAddress, "F");
|
||||
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = NameAddress;
|
||||
context[CpuRegister.Rdx] = EntryAddress;
|
||||
context[CpuRegister.R8] = ContextAddress;
|
||||
context[CpuRegister.R9] = 512;
|
||||
|
||||
Assert.Equal(0, FiberExports.FiberInitialize(context));
|
||||
|
||||
WriteUInt64(memory, InfoAddress, 64);
|
||||
context[CpuRegister.Rdi] = FiberAddress;
|
||||
context[CpuRegister.Rsi] = InfoAddress;
|
||||
|
||||
var result = FiberExports.FiberGetInfo(context);
|
||||
|
||||
Assert.Equal(ErrorInvalid, result);
|
||||
}
|
||||
|
||||
private static void WriteCString(FakeCpuMemory memory, ulong address, string text)
|
||||
{
|
||||
memory.WriteCString(address, text);
|
||||
}
|
||||
|
||||
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[sizeof(uint)];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static void AssertInlineName(FakeCpuMemory memory, ulong address, string expected)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[32];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
var length = buffer.IndexOf((byte)0);
|
||||
if (length < 0)
|
||||
{
|
||||
length = buffer.Length;
|
||||
}
|
||||
|
||||
Assert.Equal(expected, System.Text.Encoding.UTF8.GetString(buffer[..length]));
|
||||
}
|
||||
}
|
||||
@@ -41,4 +41,32 @@ public sealed class FontExportsTests
|
||||
Assert.Equal(0.0f, BinaryPrimitives.ReadSingleLittleEndian(layout[8..]));
|
||||
Assert.Equal(Sentinel, BinaryPrimitives.ReadUInt32LittleEndian(layout[12..]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVerticalLayout_WritesExactlyThreeFloats()
|
||||
{
|
||||
const uint Sentinel = 0xDEADBEEF;
|
||||
Span<byte> sentinelBytes = stackalloc byte[sizeof(uint)];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(sentinelBytes, Sentinel);
|
||||
Assert.True(_ctx.Memory.TryWrite(LayoutAddress + 12, sentinelBytes));
|
||||
|
||||
_ctx[CpuRegister.Rsi] = LayoutAddress;
|
||||
Assert.Equal(0, FontExports.GetVerticalLayout(_ctx));
|
||||
|
||||
Span<byte> layout = stackalloc byte[16];
|
||||
Assert.True(_ctx.Memory.TryRead(LayoutAddress, layout));
|
||||
Assert.Equal(8.0f, BinaryPrimitives.ReadSingleLittleEndian(layout));
|
||||
Assert.Equal(16.0f, BinaryPrimitives.ReadSingleLittleEndian(layout[4..]));
|
||||
Assert.Equal(0.0f, BinaryPrimitives.ReadSingleLittleEndian(layout[8..]));
|
||||
Assert.Equal(Sentinel, BinaryPrimitives.ReadUInt32LittleEndian(layout[12..]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVerticalLayout_NullBuffer_ReturnsInvalidArgument()
|
||||
{
|
||||
_ctx[CpuRegister.Rsi] = 0;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
|
||||
FontExports.GetVerticalLayout(_ctx));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.GUI;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.GUI;
|
||||
|
||||
public sealed class GuiSettingsTests
|
||||
{
|
||||
[Fact]
|
||||
public void NormalizeFromJson_AllPropertiesNull_FallsBackToDefaults()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"LogLevel": null,
|
||||
"GameFolders": null,
|
||||
"ExcludedGames": null,
|
||||
"EnvironmentToggles": null,
|
||||
"Language": null,
|
||||
"DiscordClientId": null
|
||||
}
|
||||
""";
|
||||
|
||||
var settings = GuiSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.Equal("Info", settings.LogLevel);
|
||||
Assert.Equal("en", settings.Language);
|
||||
Assert.Equal("1525606762248540221", settings.DiscordClientId);
|
||||
Assert.Empty(settings.GameFolders);
|
||||
Assert.Empty(settings.ExcludedGames);
|
||||
Assert.Empty(settings.EnvironmentToggles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeFromJson_ValidValues_ArePreserved()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"LogLevel": "Debug",
|
||||
"GameFolders": ["C:\\Games"],
|
||||
"ExcludedGames": ["C:\\Games\\skip.bin"],
|
||||
"EnvironmentToggles": ["SHARPEMU_TRACE"],
|
||||
"Language": "pt-BR",
|
||||
"DiscordClientId": "999"
|
||||
}
|
||||
""";
|
||||
|
||||
var settings = GuiSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.Equal("Debug", settings.LogLevel);
|
||||
Assert.Equal("pt-BR", settings.Language);
|
||||
Assert.Equal("999", settings.DiscordClientId);
|
||||
Assert.Equal(["C:\\Games"], settings.GameFolders);
|
||||
Assert.Equal(["C:\\Games\\skip.bin"], settings.ExcludedGames);
|
||||
Assert.Equal(["SHARPEMU_TRACE"], settings.EnvironmentToggles);
|
||||
}
|
||||
|
||||
// An empty Discord client ID intentionally disables Rich Presence.
|
||||
[Fact]
|
||||
public void NormalizeFromJson_EmptyDiscordClientId_IsPreservedNotNormalized()
|
||||
{
|
||||
const string json = """{ "DiscordClientId": "" }""";
|
||||
|
||||
var settings = GuiSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.Equal(string.Empty, settings.DiscordClientId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeFromJson_NullOrEmptyListEntries_AreFilteredOut()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"GameFolders": ["C:\\Games", null, ""],
|
||||
"ExcludedGames": [null],
|
||||
"EnvironmentToggles": [null, "SHARPEMU_TRACE", ""]
|
||||
}
|
||||
""";
|
||||
|
||||
var settings = GuiSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.Equal(["C:\\Games"], settings.GameFolders);
|
||||
Assert.Empty(settings.ExcludedGames);
|
||||
Assert.Equal(["SHARPEMU_TRACE"], settings.EnvironmentToggles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeFromJson_EmptyObject_UsesConstructorDefaults()
|
||||
{
|
||||
var settings = GuiSettings.NormalizeFromJson("{}");
|
||||
|
||||
Assert.Equal("Info", settings.LogLevel);
|
||||
Assert.Equal("en", settings.Language);
|
||||
Assert.Equal("1525606762248540221", settings.DiscordClientId);
|
||||
Assert.Empty(settings.GameFolders);
|
||||
Assert.Empty(settings.ExcludedGames);
|
||||
Assert.Empty(settings.EnvironmentToggles);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.GUI;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.GUI;
|
||||
|
||||
public sealed class PerGameSettingsTests
|
||||
{
|
||||
// Invalid entries must not reach Environment.SetEnvironmentVariable.
|
||||
[Fact]
|
||||
public void NormalizeFromJson_NullOrEmptyToggleEntries_AreFilteredOut()
|
||||
{
|
||||
const string json = """
|
||||
{ "EnvironmentToggles": [null, "SHARPEMU_TRACE", ""] }
|
||||
""";
|
||||
|
||||
var settings = PerGameSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.NotNull(settings);
|
||||
Assert.Equal(["SHARPEMU_TRACE"], settings.EnvironmentToggles);
|
||||
}
|
||||
|
||||
// A null list means that the global setting should be inherited.
|
||||
[Fact]
|
||||
public void NormalizeFromJson_NullToggleList_StaysNull()
|
||||
{
|
||||
const string json = """{ "EnvironmentToggles": null }""";
|
||||
|
||||
var settings = PerGameSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.NotNull(settings);
|
||||
Assert.Null(settings.EnvironmentToggles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeFromJson_EmptyToggleList_StaysEmpty()
|
||||
{
|
||||
const string json = """{ "EnvironmentToggles": [] }""";
|
||||
|
||||
var settings = PerGameSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.NotNull(settings);
|
||||
Assert.Empty(Assert.IsType<List<string>>(settings.EnvironmentToggles));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeFromJson_ValidToggles_ArePreserved()
|
||||
{
|
||||
const string json = """
|
||||
{ "EnvironmentToggles": ["SHARPEMU_TRACE", "SHARPEMU_NO_JIT"] }
|
||||
""";
|
||||
|
||||
var settings = PerGameSettings.NormalizeFromJson(json);
|
||||
|
||||
Assert.NotNull(settings);
|
||||
Assert.Equal(["SHARPEMU_TRACE", "SHARPEMU_NO_JIT"], settings.EnvironmentToggles);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user