Compare commits

..

1 Commits

184 changed files with 2046 additions and 29672 deletions
+26 -29
View File
@@ -89,6 +89,7 @@ jobs:
DOTNET_NOLOGO: true
NUGET_PACKAGES: ${{ github.workspace }}\.nuget\packages
PUBLISH_DIR: ${{ github.workspace }}\artifacts\publish\win-x64
RELEASE_DIR: ${{ github.workspace }}\artifacts\release
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -120,13 +121,24 @@ jobs:
- name: Publish win-x64 CLI
run: dotnet publish src/SharpEmu.CLI/SharpEmu.CLI.csproj -c Release -r win-x64 --self-contained true --no-restore -p:PublishDir="${env:PUBLISH_DIR}"
- name: Create release archive
run: |
New-Item -ItemType Directory -Path $env:RELEASE_DIR -Force | Out-Null
$archiveName = "sharpemu-${{ needs.init.outputs.version }}-win-x64.zip"
$archivePath = Join-Path $env:RELEASE_DIR $archiveName
if (Test-Path $archivePath) {
Remove-Item $archivePath -Force
}
Compress-Archive -Path (Join-Path $env:PUBLISH_DIR '*') -DestinationPath $archivePath -CompressionLevel Optimal
- name: Upload build artifact
uses: actions/upload-artifact@v7
with:
name: sharpemu-win-x64-${{ needs.init.outputs.short-sha }}
path: ${{ env.PUBLISH_DIR }}
path: ${{ env.RELEASE_DIR }}\sharpemu-${{ needs.init.outputs.version }}-win-x64.zip
if-no-files-found: error
include-hidden-files: true
build-posix:
name: Build ${{ matrix.rid }}
@@ -146,6 +158,7 @@ jobs:
DOTNET_NOLOGO: true
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
PUBLISH_DIR: ${{ github.workspace }}/artifacts/publish/${{ matrix.rid }}
RELEASE_DIR: ${{ github.workspace }}/artifacts/release
SPIRV_HEADERS_COMMIT: ad9184e76a66b1001c29db9b0a3e87f646c64de0
# SpirvModuleBuilder emits SPIR-V 1.5 and VulkanVideoPresenter requests Vulkan 1.2.
SPIRV_TARGET_ENV: vulkan1.2
@@ -210,13 +223,19 @@ jobs:
if: matrix.rid == 'osx-x64'
run: scripts/fetch-macos-moltenvk.sh "$PUBLISH_DIR"
- name: Create release archive
run: |
mkdir -p "$RELEASE_DIR"
# tar keeps the executable bit, which zip would drop.
tar -czf "$RELEASE_DIR/sharpemu-${{ needs.init.outputs.version }}-${{ matrix.rid }}.tar.gz" \
-C "$PUBLISH_DIR" .
- name: Upload build artifact
uses: actions/upload-artifact@v7
with:
name: sharpemu-${{ matrix.rid }}-${{ needs.init.outputs.short-sha }}
path: ${{ env.PUBLISH_DIR }}
path: ${{ env.RELEASE_DIR }}/sharpemu-${{ needs.init.outputs.version }}-${{ matrix.rid }}.tar.gz
if-no-files-found: error
include-hidden-files: true
release:
name: Publish GitHub Release
@@ -236,28 +255,6 @@ jobs:
with:
path: release
- name: Package release assets
shell: bash
env:
SHORT_SHA: ${{ needs.init.outputs.short-sha }}
VERSION: ${{ needs.init.outputs.version }}
run: |
set -euo pipefail
win_dir="release/sharpemu-win-x64-${SHORT_SHA}"
linux_dir="release/sharpemu-linux-x64-${SHORT_SHA}"
macos_dir="release/sharpemu-osx-x64-${SHORT_SHA}"
for package_dir in "${win_dir}" "${linux_dir}" "${macos_dir}"; do
test -d "${package_dir}"
done
mkdir -p release-assets
(cd "${win_dir}" && zip -q -r "../../release-assets/sharpemu-${VERSION}-win-x64.zip" .)
chmod +x "${linux_dir}/SharpEmu" "${macos_dir}/SharpEmu"
tar -czf "release-assets/sharpemu-${VERSION}-linux-x64.tar.gz" -C "${linux_dir}" .
tar -czf "release-assets/sharpemu-${VERSION}-osx-x64.tar.gz" -C "${macos_dir}" .
- name: Create release
shell: bash
env:
@@ -267,9 +264,9 @@ jobs:
RELEASE_TAG: ${{ needs.init.outputs.release-tag }}
VERSION: ${{ needs.init.outputs.version }}
run: |
mapfile -t assets < <(find release-assets -maxdepth 1 -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
if [ "${#assets[@]}" -ne 3 ]; then
echo "Expected 3 release assets, found ${#assets[@]}." >&2
mapfile -t assets < <(find release -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release assets found." >&2
exit 1
fi
-1
View File
@@ -42,4 +42,3 @@ ehthumbs.db
.vs/
.idea/
.vscode/
+1 -8
View File
@@ -9,18 +9,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<SharpEmuVersion>0.0.2-beta.4</SharpEmuVersion>
<SharpEmuVersion>0.0.2-beta.3</SharpEmuVersion>
<Version>$(SharpEmuVersion)</Version>
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And $([MSBuild]::IsOSPlatform('Windows'))">win</_HostRidOSPrefix>
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And '$(_HostRidOSPrefix)' == '' And $([MSBuild]::IsOSPlatform('Linux'))">linux</_HostRidOSPrefix>
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And '$(_HostRidOSPrefix)' == '' And $([MSBuild]::IsOSPlatform('OSX'))">osx</_HostRidOSPrefix>
<_HostRidArch Condition="'$(_HostRidOSPrefix)' != '' And '$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)' == 'Arm64'">arm64</_HostRidArch>
<_HostRidArch Condition="'$(_HostRidOSPrefix)' != '' And '$(_HostRidArch)' == ''">x64</_HostRidArch>
<RuntimeIdentifier Condition="'$(_HostRidOSPrefix)' != ''">$(_HostRidOSPrefix)-$(_HostRidArch)</RuntimeIdentifier>
<BaseIntermediateOutputPath>$(RepoRoot)artifacts/obj/$(MSBuildProjectName)/</BaseIntermediateOutputPath>
<BaseOutputPath>$(RepoRoot)artifacts/bin/</BaseOutputPath>
-1
View File
@@ -11,7 +11,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<PackageVersion Include="Avalonia.Desktop" Version="11.3.18" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.18" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.18" />
<PackageVersion Include="FFmpeg.AutoGen" Version="7.1.1" />
<PackageVersion Include="Iced" Version="1.21.0" />
<PackageVersion Include="Microsoft.Build.Framework" Version="17.14.8" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
-2
View File
@@ -8,8 +8,6 @@ path = [
"**/packages.lock.json",
"scripts/ps5_names.txt",
"src/SharpEmu.GUI/Languages/**",
"src/SharpEmu.ShaderCompiler.Metal/Templates/**",
"tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/**",
"_logs/**",
".github/images/**",
".github/pull_request_template.md",
-2
View File
@@ -14,13 +14,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Project Path="src/SharpEmu.Libs/SharpEmu.Libs.csproj" />
<Project Path="src/SharpEmu.Logging/SharpEmu.Logging.csproj" />
<Project Path="src/SharpEmu.ShaderCompiler/SharpEmu.ShaderCompiler.csproj" />
<Project Path="src/SharpEmu.ShaderCompiler.Metal/SharpEmu.ShaderCompiler.Metal.csproj" />
<Project Path="src/SharpEmu.ShaderCompiler.Vulkan/SharpEmu.ShaderCompiler.Vulkan.csproj" />
<Project Path="src/SharpEmu.SourceGenerators/SharpEmu.SourceGenerators.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj" />
<Project Path="tests/SharpEmu.ShaderCompiler.Metal.Tests/SharpEmu.ShaderCompiler.Metal.Tests.csproj" />
<Project Path="tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj" />
</Folder>
</Solution>
-20
View File
@@ -1,20 +0,0 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
# Aerolib Catalog
```bash
# NID to export name
python scripts/aerolib_catalog.py lookup Zxa0VhQVTsk
# Export name to NID
python scripts/aerolib_catalog.py lookup sceKernelWaitSema
# Search export names
python scripts/aerolib_catalog.py search VideoOut --limit 20
# Export all NID/name pairs to artifacts/aerolib.txt
python scripts/aerolib_catalog.py export
```
+22 -51
View File
@@ -9,67 +9,38 @@ Demon's Souls plays Bink 2 (.bk2) files through a Bink implementation linked
directly into eboot.bin. It does not use libSceVideodec, therefore an HLE video
decoder cannot observe or replace those frames.
SharpEmu observes successful guest .bk2 opens and, when a Bink decoder is
SharpEmu observes successful guest .bk2 opens and, when a Bink bridge is
available, presents its decoded BGRA frames at the normal guest-flip boundary.
This preserves the game's own timing and lets the host Vulkan presenter display
the movie without trying to execute the PS5-specific Bink GPU decode path.
The default path decodes by calling FFmpeg's own C API directly from managed
code (`src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs`, via the
[FFmpeg.AutoGen](https://github.com/Ruslan-B/FFmpeg.AutoGen) P/Invoke
bindings) against a custom FFmpeg build
(`github.com/sharpemu/ffmpeg-core`, LGPL-2.1) that adds a Bink 2 decoder to
FFmpeg 7.1.2; see "Supplying the FFmpeg libraries" below for where those
libraries come from. No proprietary RAD SDK is needed to build or run
SharpEmu, and there is no C/C++ code of SharpEmu's own involved in decoding
-- SharpEmu.CLI.csproj only downloads a prebuilt release archive.
Set `SHARPEMU_BINK_MODE=guest` to leave decoding to the Bink implementation
statically linked into the game instead. Set `skip` only when explicitly
testing a title whose cinematics are optional.
Without an adapter, Bink movies are skipped by default: their open call returns
not-found so games that mark cinematics as optional progress to their next
state instead of waiting on an empty Bink GPU texture.
Set SHARPEMU_BINK_MODE=dummy to retain the open and show a built-in,
non-decoded placeholder frame. This requires no SDK, but is a visual diagnostic
only; it does not decode the movie or alter its game logic.
SHARPEMU_BINK_MODE=native is equivalent to the default and mainly useful for
being explicit about it.
only; it does not decode the movie or alter its game logic. Set
SHARPEMU_BINK_MODE=native to force native bridge mode.
The experimental `SHARPEMU_BINK_MODE=ffmpeg` override is unrelated to the
default path above: instead of calling into FFmpeg in-process, it spawns a
standalone `ffmpeg` executable and reads raw frames from its stdout
(`src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs`). SharpEmu searches
`SHARPEMU_FFMPEG_PATH`, the executable directory, its `ffmpeg` subdirectory,
and then `PATH` (plus a couple of common Homebrew paths on macOS). That
`ffmpeg` build must contain a Bink 2 decoder itself; a stock FFmpeg build that
only recognizes the Bink container is not sufficient. Most users want the
default `native` mode instead, which always has Bink 2 support since it's
built against `ffmpeg-core` specifically.
## Supplying the adapter
## Supplying the FFmpeg libraries
Bink 2 is proprietary. Obtain a compatible Mac Bink 2 SDK from RAD Game Tools,
then compile sharpemu_bink2_bridge.c against the SDK's bink.h and Mac library.
The adapter deliberately contains only a three-function C ABI so the managed
emulator never depends on RAD's private binary ABI.
`dotnet publish` fetches a prebuilt release of `github.com/sharpemu/ffmpeg-core`
(the tag is pinned in `SharpEmu.CLI.csproj`'s `FfmpegRuntimeTag`, matched to
the `FFmpeg.AutoGen` package version in `Directory.Packages.props` -- both
need to agree on the same FFmpeg ABI) and copies its dynamically linked
libraries into a `plugins` folder next to the published executable. No C
toolchain is required to build SharpEmu; publishing just downloads a zip.
`plugins` is a loose, unpacked folder rather than something embedded in the
single-file bundle, so the OS loader can resolve the libraries' own
inter-dependencies (`avcodec` depends on `avutil`, etc.) itself.
Place the resulting libsharpemu_bink2_bridge.dylib next to the SharpEmu
executable, or point to it explicitly:
A plain `dotnet publish` with no `-r` still works: it defaults to the host
machine's own RID (see `Directory.Build.props`), so it fetches the matching
`ffmpeg-core` archive and populates `plugins` without any extra flags.
Passing an explicit `-r <rid>` (e.g. to cross-publish `linux-x64` from
Windows) still overrides that default normally.
SHARPEMU_BINK2_BRIDGE=/absolute/path/libsharpemu_bink2_bridge.dylib \
./SharpEmu /path/to/eboot.bin
To use a different set of FFmpeg libraries, drop them into the published
`plugins` folder yourself (matching FFmpeg's own file-naming and versioning
conventions, e.g. `avformat-61.dll` / `libavformat.so.61` / matching
`.dylib`) -- `FfmpegNativeBinkFrameSource` points `ffmpeg.RootPath` at that
folder and does not otherwise care where the files came from.
The expected exports are sharpemu_bink2_open_utf8,
sharpemu_bink2_decode_next_bgra, and sharpemu_bink2_close. The supplied
adapter opens one movie, exposes BGRA pixels, and advances after each decoded
frame. The managed side validates dimensions and retains ownership of the
destination buffer.
If the libraries are absent or fail to load, `FfmpegNativeBinkFrameSource.TryOpen`
degrades gracefully: SharpEmu logs one informational line ("Bink2 bridge
could not open movie ...") and leaves the guest's own rendering path
untouched, rather than crashing.
If the bridge is absent in native mode, SharpEmu logs one informational line
and retains the existing guest rendering path.
-57
View File
@@ -1,57 +0,0 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
# Guest write watch
`GuestWriteWatch` is an optional diagnostic tool. It helps you find managed
code and HLE code that damage guest memory. The tool starts only if you set one
or more `SHARPEMU_WATCH_*` environment variables.
The tool monitors writes through the SharpEmu managed virtual-memory APIs. It
does not monitor stores that native guest code makes directly. Use a platform
debugger or a hardware watchpoint to monitor these stores.
## Watch modes
- `SHARPEMU_WATCH_WRITE=0x<address>` logs a write that overlaps the eight-byte
block at the specified guest address.
- `SHARPEMU_WATCH_POOL_HEADER=1` monitors the pointer at offset `0x40`. It
monitors the first 64 direct mappings that have a size of 64 KiB and
protection value `0xF2`.
- `SHARPEMU_WATCH_VALUE_PATTERN=1` logs an eight-byte write if its lower 32 bits
are `1`. The upper 32 bits must look like a small guest-pointer prefix.
- `SHARPEMU_WATCH_VALUE1=1` logs short writes of value `1` in the high guest
memory range. The tool logs a maximum of 128 entries for each process.
- `SHARPEMU_WATCH_BULK_TORN=1` scans aligned 64-bit words in bulk writes. It
finds damaged pointer patterns and byte-shifted pointer patterns. The tool
logs a maximum of 64 entries for each process.
- `SHARPEMU_WATCH_BULK_DEST_HI=0x<high-dword>` scans only writes that have the
specified upper 32 bits in the destination address.
For each match, the tool logs the destination address, the data pattern, and the
managed call stack. The log uses the `watch_write` or `watch_bulk_torn` warning
tag.
Use these variables together to scan bulk writes in the
`0x00000080xxxxxxxx` region.
macOS and Linux:
```sh
SHARPEMU_WATCH_BULK_TORN=1 \
SHARPEMU_WATCH_BULK_DEST_HI=0x80 \
SharpEmu /path/to/eboot.bin
```
Windows PowerShell:
```powershell
$env:SHARPEMU_WATCH_BULK_TORN = "1"
$env:SHARPEMU_WATCH_BULK_DEST_HI = "0x80"
& .\SharpEmu.exe C:\path\to\game\eboot.bin
```
To reduce unnecessary log entries, use an exact `SHARPEMU_WATCH_WRITE`
address from a crash dump.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.103",
"rollForward": "latestFeature"
"rollForward": "disable"
}
}
@@ -0,0 +1,66 @@
/*
* Copyright (C) 2026 SharpEmu Emulator Project
* SPDX-License-Identifier: GPL-2.0-or-later
*
* Build this small adapter with a licensed RAD Bink 2 SDK. The SDK and its
* headers are not distributed by SharpEmu. See docs/bink2-bridge.md.
*/
#include <stdint.h>
#include "bink.h"
typedef struct sharpemu_bink2_info {
uint32_t width;
uint32_t height;
uint32_t frames_per_second_numerator;
uint32_t frames_per_second_denominator;
} sharpemu_bink2_info;
int sharpemu_bink2_open_utf8(const char *path, HBINK *movie, sharpemu_bink2_info *info) {
HBINK bink;
if (!path || !movie || !info) return 0;
*movie = NULL;
bink = BinkOpen(path, 0);
if (!bink) return 0;
if (bink->Width == 0 || bink->Height == 0) {
BinkClose(bink);
return 0;
}
*movie = bink;
info->width = bink->Width;
info->height = bink->Height;
info->frames_per_second_numerator = bink->FrameRate;
info->frames_per_second_denominator = bink->FrameRateDiv;
return 1;
}
int sharpemu_bink2_decode_next_bgra(HBINK movie, uint8_t *destination,
uint32_t stride, uint32_t destination_bytes) {
uint64_t needed;
uint64_t min_stride;
if (!movie || !destination) return 0;
min_stride = (uint64_t)movie->Width * 4;
if ((uint64_t)stride < min_stride) return 0;
needed = (uint64_t)stride * movie->Height;
if (needed > destination_bytes) return 0;
/* Async Bink I/O has not filled the next frame yet; retry on the next host present. */
if (BinkWait(movie)) return 0;
if (!BinkDoFrame(movie)) return 0;
if (!BinkCopyToBuffer(movie, destination, stride, movie->Height, 0, 0, BINKSURFACE32RA)) return 0;
BinkNextFrame(movie);
return 1;
}
void sharpemu_bink2_close(HBINK movie) {
if (movie) BinkClose(movie);
}
-181
View File
@@ -1,181 +0,0 @@
#!/usr/bin/env python3
# Copyright (C) 2026 SharpEmu Emulator Project
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
import argparse
import base64
import hashlib
import re
import sys
from pathlib import Path
NID_SUFFIX = bytes.fromhex("518d64a635ded8c1e6b039b1c3e55230")
NID_PATTERN = re.compile(r"^[A-Za-z0-9+-]{11}$")
DEFAULT_NAMES_FILE = Path(__file__).resolve().with_name("ps5_names.txt")
DEFAULT_EXPORT_FILE = Path(__file__).resolve().parents[1] / "artifacts" / "aerolib.txt"
def compute_nid(export_name: str) -> str:
digest = hashlib.sha1(export_name.encode("utf-8") + NID_SUFFIX).digest()
encoded = base64.b64encode(digest[:8][::-1]).decode("ascii")
return encoded.rstrip("=").replace("/", "-")
def read_names(path: Path) -> list[str]:
try:
return [
line.strip()
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
except OSError as error:
raise SystemExit(f"Unable to read catalog '{path}': {error}") from error
def write_pair(nid: str, export_name: str) -> None:
print(f"{nid}\t{export_name}")
def lookup(args: argparse.Namespace) -> int:
value = args.value.strip()
if NID_PATTERN.fullmatch(value):
for export_name in read_names(args.names):
if compute_nid(export_name) == value:
write_pair(value, export_name)
return 0
print(f"NID not found in catalog: {value}", file=sys.stderr)
return 1
names = set(read_names(args.names))
write_pair(compute_nid(value), value)
if value not in names:
print("Warning: export name is not present in the catalog.", file=sys.stderr)
return 0
def search(args: argparse.Namespace) -> int:
names = read_names(args.names)
if args.regex:
try:
pattern = re.compile(args.query, 0 if args.case_sensitive else re.IGNORECASE)
except re.error as error:
print(f"Invalid regular expression: {error}", file=sys.stderr)
return 2
matches = (name for name in names if pattern.search(name))
elif args.case_sensitive:
matches = (name for name in names if args.query in name)
else:
query = args.query.casefold()
matches = (name for name in names if query in name.casefold())
count = 0
for export_name in matches:
write_pair(compute_nid(export_name), export_name)
count += 1
if args.limit and count >= args.limit:
break
if count == 0:
print(f"No catalog names matched: {args.query}", file=sys.stderr)
return 1
return 0
def export_catalog(args: argparse.Namespace) -> int:
pairs = [(compute_nid(name), name) for name in read_names(args.names)]
if args.sort == "nid":
pairs.sort(key=lambda pair: (pair[0], pair[1]))
elif args.sort == "name":
pairs.sort(key=lambda pair: pair[1])
args.output.parent.mkdir(parents=True, exist_ok=True)
try:
with args.output.open("w", encoding="utf-8", newline="\n") as output:
output.write("# NID\tExportName\n")
for nid, export_name in pairs:
output.write(f"{nid}\t{export_name}\n")
except OSError as error:
print(f"Unable to write catalog '{args.output}': {error}", file=sys.stderr)
return 1
print(f"Wrote {len(pairs)} entries to {args.output}")
return 0
def create_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Inspect the SharpEmu PS5 export-name/NID catalog.",
epilog=(
"Examples:\n"
" python scripts/aerolib_catalog.py lookup Zxa0VhQVTsk\n"
" python scripts/aerolib_catalog.py lookup sceKernelWaitSema\n"
" python scripts/aerolib_catalog.py search VideoOut --limit 20\n"
" python scripts/aerolib_catalog.py export"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--names",
type=Path,
default=DEFAULT_NAMES_FILE,
help=f"source name list (default: {DEFAULT_NAMES_FILE})",
)
subparsers = parser.add_subparsers(dest="command", required=True)
lookup_parser = subparsers.add_parser(
"lookup", help="resolve a NID or calculate the NID for an export name"
)
lookup_parser.add_argument("value", help="11-character NID or exact export name")
lookup_parser.set_defaults(handler=lookup)
search_parser = subparsers.add_parser(
"search", help="find export names and print matching NID/name pairs"
)
search_parser.add_argument("query", help="name substring or regular expression")
search_parser.add_argument(
"--limit", type=int, default=50, help="maximum matches; 0 means unlimited"
)
search_parser.add_argument(
"--case-sensitive", action="store_true", help="match case exactly"
)
search_parser.add_argument(
"--regex", action="store_true", help="treat the query as a regular expression"
)
search_parser.set_defaults(handler=search)
export_parser = subparsers.add_parser(
"export", help="write every NID/name pair to a tab-separated text file"
)
export_parser.add_argument(
"output",
type=Path,
nargs="?",
default=DEFAULT_EXPORT_FILE,
help=f"output file (default: {DEFAULT_EXPORT_FILE})",
)
export_parser.add_argument(
"--sort",
choices=("source", "nid", "name"),
default="nid",
help="output ordering (default: nid)",
)
export_parser.set_defaults(handler=export_catalog)
return parser
def main() -> int:
parser = create_parser()
args = parser.parse_args()
return args.handler(args)
if __name__ == "__main__":
raise SystemExit(main())
+7 -24
View File
@@ -45,6 +45,11 @@ internal static partial class Program
[STAThread]
private static int Main(string[] args)
{
// Avoid blocking full collections while guest and render threads are
// running, and establish the GC mode before the runtime reserves the
// fixed guest address-space window.
System.Runtime.GCSettings.LatencyMode = System.Runtime.GCLatencyMode.SustainedLowLatency;
try
{
return Run(args);
@@ -64,7 +69,6 @@ internal static partial class Program
}
args = NormalizeInternalArguments(args, out var isMitigatedChild);
PreloadGlfw();
if (args.Length == 0)
{
@@ -214,27 +218,6 @@ internal static partial class Program
"as libvulkan.1.dylib.");
}
/// <summary>
/// SharpEmu.CLI.csproj publishes glfw into a "plugins" subfolder rather
/// than flat next to the executable, which falls outside the default OS
/// DLL/dlopen search path. Preloading it here by full path first means
/// any later bare-name lookup (however Silk.NET/GLFW itself resolves the
/// library) finds it already loaded in the process and reuses it -- the
/// same technique <see cref="PreloadMacVulkanLoader"/> already relies on
/// for the Vulkan loader.
/// </summary>
private static void PreloadGlfw()
{
var fileName = OperatingSystem.IsWindows() ? "glfw3.dll"
: OperatingSystem.IsMacOS() ? "libglfw.3.dylib"
: "libglfw.so.3";
var candidate = Path.Combine(AppContext.BaseDirectory, "plugins", fileName);
if (File.Exists(candidate))
{
NativeLibrary.TryLoad(candidate, out _);
}
}
private static int RunEmulator(string[] args, bool isMitigatedChild)
{
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
@@ -629,7 +612,7 @@ internal static partial class Program
nint jobHandle = 0;
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
var created = CreateProcessW(
null,
processPath,
cmdLineBuilder,
0,
0,
@@ -1455,7 +1438,7 @@ internal static partial class Program
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateProcessW(
string? applicationName,
string applicationName,
StringBuilder commandLine,
nint processAttributes,
nint threadAttributes,
+4 -66
View File
@@ -20,11 +20,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<!-- osx-x64 is the macOS target: the CPU backend executes guest x86-64
natively, so on Apple Silicon it runs under Rosetta 2. -->
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
<!-- A plain "dotnet publish" with no -r defaults $(RuntimeIdentifier) to
the host's own RID; see Directory.Build.props, which is where that
default actually has to live (PublishDir's RID suffix is decided
there, evaluated before this file, so a default set only here would
be too late for it). -->
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
@@ -54,6 +49,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == ''">
@@ -65,7 +61,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<PropertyGroup>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<Content Include="..\..\LICENSE.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
@@ -79,75 +75,17 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Content>
</ItemGroup>
<!-- Native libraries (glfw, FFmpeg) publish into a subfolder next to the
executable instead of sitting loose beside it, so the publish
directory stays uncluttered as more native deps get added. The folder
name is a fixed constant, not derived from the RID/architecture: each
publish output only ever holds one architecture's binaries anyway, so
varying the name added a class of bugs (RID resolution timing, host-OS
vs. target-RID mixups) for no benefit. Runtime code (Program.cs's
PreloadGlfw, FfmpegNativeBinkFrameSource's RootPath) uses the same
literal "plugins" folder name. -->
<PropertyGroup>
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
</PropertyGroup>
<!-- Keep glfw as a loose file in the native subfolder; every other native
<!-- Keep glfw as a loose file next to the executable; every other native
library is embedded into the single-file bundle. -->
<Target Name="KeepGlfwOutsideSingleFile" AfterTargets="ComputeResolvedFilesToPublishList">
<ItemGroup>
<_GlfwPublishFiles Include="@(ResolvedFileToPublish)"
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
<ResolvedFileToPublish Remove="@(_GlfwPublishFiles)" />
<ResolvedFileToPublish Include="@(_GlfwPublishFiles)">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<RelativePath>$(NativeLibraryFolderName)/%(Filename)%(Extension)</RelativePath>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
<PropertyGroup>
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
<FfmpegRuntimeDir>
$(BaseIntermediateOutputPath)ffmpeg-runtime/$(FfmpegRuntimeTag)/$(RuntimeIdentifier)</FfmpegRuntimeDir>
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'win-x64'">ffmpeg-windows-x64.zip</FfmpegRuntimePackage>
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'linux-x64'">ffmpeg-linux-x64.zip</FfmpegRuntimePackage>
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'osx-x64'">ffmpeg-macos-x64.zip</FfmpegRuntimePackage>
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'osx-arm64'">ffmpeg-macos-arm64.zip</FfmpegRuntimePackage>
<FfmpegRuntimeArchive>$(FfmpegRuntimeDir)/$(FfmpegRuntimePackage)</FfmpegRuntimeArchive>
<FfmpegRuntimeExtractDir>$(FfmpegRuntimeDir)/extracted</FfmpegRuntimeExtractDir>
</PropertyGroup>
<Target Name="FetchFfmpegRuntime"
BeforeTargets="Publish"
Condition="'$(RuntimeIdentifier)' != '' And '$(FfmpegRuntimePackage)' != ''">
<DownloadFile
SourceUrl="https://github.com/sharpemu/ffmpeg-core/releases/download/$(FfmpegRuntimeTag)/$(FfmpegRuntimePackage)"
DestinationFolder="$(FfmpegRuntimeDir)"
Condition="!Exists('$(FfmpegRuntimeArchive)')" />
<Unzip
SourceFiles="$(FfmpegRuntimeArchive)"
DestinationFolder="$(FfmpegRuntimeExtractDir)"
Condition="!Exists('$(FfmpegRuntimeExtractDir)')" />
</Target>
<Target Name="PublishFfmpegRuntime"
AfterTargets="Publish"
DependsOnTargets="FetchFfmpegRuntime"
Condition="'$(RuntimeIdentifier)' != '' And '$(FfmpegRuntimePackage)' != ''">
<!-- Keyed off the target $(RuntimeIdentifier), not the host OS: publishing
e.g. linux-x64 from a Windows machine is a supported cross-publish,
and the extracted archive's own layout (bin/*.dll vs lib/*.so*) only
depends on which platform's ffmpeg-core package was fetched. -->
<ItemGroup>
<_FfmpegRuntimeFiles Condition="$(RuntimeIdentifier.StartsWith('win'))"
Include="$(FfmpegRuntimeExtractDir)/bin/*.dll" />
<_FfmpegRuntimeFiles Condition="!$(RuntimeIdentifier.StartsWith('win'))"
Include="$(FfmpegRuntimeExtractDir)/lib/*.so;$(FfmpegRuntimeExtractDir)/lib/*.so.*;$(FfmpegRuntimeExtractDir)/lib/*.dylib" />
</ItemGroup>
<Copy SourceFiles="@(_FfmpegRuntimeFiles)"
DestinationFolder="$(PublishDir)$(NativeLibraryFolderName)"
SkipUnchangedFiles="true" />
</Target>
</Project>
-5
View File
@@ -13,9 +13,4 @@ SPDX-License-Identifier: GPL-2.0-or-later
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<asmv3:windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</asmv3:windowsSettings>
</asmv3:application>
</assembly>
+596
View File
@@ -0,0 +1,596 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.3, )",
"resolved": "10.0.3",
"contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA=="
},
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.BuildServices": {
"type": "Transitive",
"resolved": "11.3.2",
"contentHash": "qHDToxto1e3hci5YqbG9n0Ty8mlp3zBUN5wT66wKqaDVzXyQ0do3EnRILd4Ke9jpvsktaPpgE0YjEk7hornryQ=="
},
"Avalonia.FreeDesktop": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "aUwv8BNruRUOaUfMu4U3uibIUS60/rSHgGOhd8zBkLkpxY3JFJvgRbeq5ZzHIyKXCuKi18PO00YHAgCarp3wdw==",
"dependencies": {
"Avalonia": "11.3.18",
"Tmds.DBus.Protocol": "0.21.3"
}
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"Avalonia.Remote.Protocol": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "vw+6ZfgTuu72dA9aVWn6u56t2nrBd5MoMU0wo/qI9XJAl/c0oYYphIvwLvJP1JorubQY4UE3d0ac8ULBhrGBiA=="
},
"Avalonia.Skia": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "/B4aXmNRNjG8I5U/a1xJI+bIi0XO6DDzS3mBrIKlVnJRY2CyZiUeESRQXLnIU77Z9TvqkUROs+D47s085YjFtA==",
"dependencies": {
"Avalonia": "11.3.18",
"HarfBuzzSharp": "8.3.1.1",
"HarfBuzzSharp.NativeAssets.Linux": "8.3.1.1",
"HarfBuzzSharp.NativeAssets.WebAssembly": "8.3.1.1",
"SkiaSharp": "2.88.9",
"SkiaSharp.NativeAssets.Linux": "2.88.9",
"SkiaSharp.NativeAssets.WebAssembly": "2.88.9"
}
},
"Avalonia.Win32": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "eioUHkM2PeLPETd1aEks3rvb9plbba6buIrNdrqCpwE/qgHKUjvRNBd5mUQfAbGgTLiAes524gB8uUMDhrsJVQ==",
"dependencies": {
"Avalonia": "11.3.18",
"Avalonia.Angle.Windows.Natives": "2.1.25547.20250602"
}
},
"Avalonia.X11": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "m4Ki/G5Dovnq+6QzfS0iGbK8V77Q6oTjToMLOB0CxPCCrl3Oxywh6kIjuGJDPaN6kopMmjxlNShyQf+vPYL+JA==",
"dependencies": {
"Avalonia": "11.3.18",
"Avalonia.FreeDesktop": "11.3.18",
"Avalonia.Skia": "11.3.18"
}
},
"HarfBuzzSharp": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "tLZN66oe/uiRPTZfrCU4i8ScVGwqHNh5MHrXj0yVf4l7Mz0FhTGnQ71RGySROTmdognAs0JtluHkL41pIabWuQ==",
"dependencies": {
"HarfBuzzSharp.NativeAssets.Win32": "8.3.1.1",
"HarfBuzzSharp.NativeAssets.macOS": "8.3.1.1"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.WebAssembly": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "loJweK2u/mH/3C2zBa0ggJlITIszOkK64HLAZB7FUT670dTg965whLFYHDQo69NmC4+d9UN0icLC9VHidXaVCA=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"MicroCom.Runtime": {
"type": "Transitive",
"resolved": "0.11.0",
"contentHash": "MEnrZ3UIiH40hjzMDsxrTyi8dtqB5ziv3iBeeU4bXsL/7NLSal9F1lZKpK+tfBRnUoDSdtcW3KufE4yhATOMCA=="
},
"Microsoft.DotNet.PlatformAbstractions": {
"type": "Transitive",
"resolved": "3.1.6",
"contentHash": "jek4XYaQ/PGUwDKKhwR8K47Uh1189PFzMeLqO83mXrXQVIpARZCcfuDedH50YDTepBkfijCZN5U/vZi++erxtg=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "9.0.9",
"contentHash": "fNGvKct2De8ghm0Bpfq0iWthtzIWabgOTi+gJhNOPhNJIowXNEUE2eZNW/zNCzrHVA3PXg2yZ+3cWZndC2IqYA=="
},
"Silk.NET.Core": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "D7AT/nnwlB+4RZ84XY8QNGBZMJI5z9l4CSSETIJ1wCfRJzRt/341y3MRZ4HbnFz4r/IGaWOEZr86iE+0/65yyQ==",
"dependencies": {
"Microsoft.DotNet.PlatformAbstractions": "3.1.6",
"Microsoft.Extensions.DependencyModel": "9.0.9"
}
},
"Silk.NET.GLFW": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "UIs4sH57xlPUNHQ/1bt9rymPWlGy8IMDCNv86h0iM4TOA1CkIx0XM/n/tA4AReh1zQkNrvkxPEdZ3Blvy1dyXg==",
"dependencies": {
"Silk.NET.Core": "2.23.0",
"Ultz.Native.GLFW": "3.4.0"
}
},
"Silk.NET.Input.Common": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "QbJVV7kFBHEByayXCYdJtXXI9Sp4a+QAf0IdGV6uCWkFYcEmqBYW3aaNGvFOdSwTBDbHL5T/OtOCrGh4qYhk7A==",
"dependencies": {
"Silk.NET.Windowing.Common": "2.23.0"
}
},
"Silk.NET.Input.Glfw": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "KGHYqsv/IQRJtD6dloYh2tN4CkaM40vxM2kj0cGKBoCQiBDYHHhJiyDTyMPx0W7Fz5IgnhnG42ELmIAa0DH69A==",
"dependencies": {
"Silk.NET.Input.Common": "2.23.0",
"Silk.NET.Windowing.Glfw": "2.23.0"
}
},
"Silk.NET.Maths": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "r8PdIVzME8EH0qAgbmRPO87I4GfgR2j8TofT7EMuRJDf1QluoQwnVypDoFJjQ2ZBSRsGYk5unYxxogI05Ogsmw=="
},
"Silk.NET.Windowing.Common": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "ThStSinmY9KQI8DGiF5XEhkLJVnBcgRTBTzL9ijg1wMZAYuckz7ykrNw04fjRm2Gryh6tCNGbvz2XaY0efeFzg==",
"dependencies": {
"Silk.NET.Core": "2.23.0",
"Silk.NET.Maths": "2.23.0"
}
},
"Silk.NET.Windowing.Glfw": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "aYBudKmENmvLRn9p15HbdvlQTnnXskcDfTfbYwSb/4fr263rGLwYuDw/txUEc2jihHJiWCp5+75Y7z5wTJWl7g==",
"dependencies": {
"Silk.NET.GLFW": "2.23.0",
"Silk.NET.Windowing.Common": "2.23.0"
}
},
"SkiaSharp": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "3MD5VHjXXieSHCleRLuaTXmL2pD0mB7CcOB1x2kA1I4bhptf4e3R27iM93264ZYuAq6mkUyX5XbcxnZvMJYc1Q==",
"dependencies": {
"SkiaSharp.NativeAssets.Win32": "2.88.9",
"SkiaSharp.NativeAssets.macOS": "2.88.9"
}
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.WebAssembly": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "kt06RccBHSnAs2wDYdBSfsjIDbY3EpsOVqnlDgKdgvyuRA8ZFDaHRdWNx1VHjGgYzmnFCGiTJBnXFl5BqGwGnA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
},
"sharpemu.core": {
"type": "Project",
"dependencies": {
"Iced": "[1.21.0, )",
"SharpEmu.HLE": "[0.0.2-beta.3, )",
"SharpEmu.Libs": "[0.0.2-beta.3, )",
"SharpEmu.Logging": "[0.0.2-beta.3, )"
}
},
"sharpemu.debugger": {
"type": "Project",
"dependencies": {
"SharpEmu.Core": "[0.0.2-beta.3, )",
"SharpEmu.HLE": "[0.0.2-beta.3, )",
"SharpEmu.Logging": "[0.0.2-beta.3, )"
}
},
"sharpemu.gui": {
"type": "Project",
"dependencies": {
"Avalonia": "[11.3.18, )",
"Avalonia.Desktop": "[11.3.18, )",
"Avalonia.Fonts.Inter": "[11.3.18, )",
"Avalonia.Themes.Fluent": "[11.3.18, )",
"SharpEmu.Core": "[0.0.2-beta.3, )",
"SharpEmu.Libs": "[0.0.2-beta.3, )",
"SharpEmu.Logging": "[0.0.2-beta.3, )",
"Tmds.DBus.Protocol": "[0.21.3, )"
}
},
"sharpemu.hle": {
"type": "Project",
"dependencies": {
"SharpEmu.Logging": "[0.0.2-beta.3, )"
}
},
"sharpemu.libs": {
"type": "Project",
"dependencies": {
"SharpEmu.HLE": "[0.0.2-beta.3, )",
"SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )",
"SharpEmu.ShaderCompiler.Vulkan": "[0.0.2-beta.3, )",
"Silk.NET.Input": "[2.23.0, )",
"Silk.NET.Vulkan": "[2.23.0, )",
"Silk.NET.Vulkan.Extensions.EXT": "[2.23.0, )",
"Silk.NET.Vulkan.Extensions.KHR": "[2.23.0, )",
"Silk.NET.Windowing": "[2.23.0, )"
}
},
"sharpemu.logging": {
"type": "Project"
},
"sharpemu.shadercompiler": {
"type": "Project",
"dependencies": {
"SharpEmu.HLE": "[0.0.2-beta.3, )"
}
},
"sharpemu.shadercompiler.vulkan": {
"type": "Project",
"dependencies": {
"SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )"
}
},
"Avalonia": {
"type": "CentralTransitive",
"requested": "[11.3.18, )",
"resolved": "11.3.18",
"contentHash": "2C4UxhWUObWGgYKWic1x5BMMWGJP6SElb91WeOxs+X/iR26rtkqpxFFwwo50FXS9AyYnHfk8QKXDEfe7oT/kZA==",
"dependencies": {
"Avalonia.BuildServices": "11.3.2",
"Avalonia.Remote.Protocol": "11.3.18",
"MicroCom.Runtime": "0.11.0"
}
},
"Avalonia.Desktop": {
"type": "CentralTransitive",
"requested": "[11.3.18, )",
"resolved": "11.3.18",
"contentHash": "bilMPa5vYiis6fbNovb6esKytBnOCEGojBa1XFegLCRHCP6g6PvZwS0XF/YOAGkENRlHG8dI7lohOpQ9bIkq1g==",
"dependencies": {
"Avalonia": "11.3.18",
"Avalonia.Native": "11.3.18",
"Avalonia.Skia": "11.3.18",
"Avalonia.Win32": "11.3.18",
"Avalonia.X11": "11.3.18"
}
},
"Avalonia.Fonts.Inter": {
"type": "CentralTransitive",
"requested": "[11.3.18, )",
"resolved": "11.3.18",
"contentHash": "27u6hB3Y2Ue586yjfeVakberY73VNQXtuKwe/P927XG1QPlhsfmOyifLHDDpSHG85Zl1x/Xv9IZ3+tk9FnjcZQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"Avalonia.Themes.Fluent": {
"type": "CentralTransitive",
"requested": "[11.3.18, )",
"resolved": "11.3.18",
"contentHash": "+Q/TJoynD0zNuu5w2gD+xcTl7GNKJFxlPYAndRLs/mTDrNbbsvv/271WyIysbMPsXSjCyBDp7RCZzQkpD6x5Bg==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"Iced": {
"type": "CentralTransitive",
"requested": "[1.21.0, )",
"resolved": "1.21.0",
"contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg=="
},
"Silk.NET.Input": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "Xzl+tVwAp2eEd8blmGQjmJrsZPnp3PWG0KJjiAQHaY2Zr/ELVWeAROKXmZdCAvexzmte2JVGEy/dxnMycbxlpg==",
"dependencies": {
"Silk.NET.Input.Common": "2.23.0",
"Silk.NET.Input.Glfw": "2.23.0"
}
},
"Silk.NET.Vulkan": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "3/irtlSWXZ3eTi8N6nelI6L34NTB8ZJHpqVMNzZx2aX7Ek9YEQ34NoQW8/Tljrtmkg8KRhHW8hKTEzZaKV8PgA==",
"dependencies": {
"Silk.NET.Core": "2.23.0"
}
},
"Silk.NET.Vulkan.Extensions.EXT": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "+Oth189ksRiL6HvGCwIdnsYHawqrbO8y49u1H61z3wsfcHhQZeVDYe/wF5LD7fk3NcdgDvwFD3mLm1QWhdZySw==",
"dependencies": {
"Silk.NET.Core": "2.23.0",
"Silk.NET.Vulkan": "2.23.0"
}
},
"Silk.NET.Vulkan.Extensions.KHR": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "uRaf4j+SmH3DumjSSSUbFg33BnsGZUyXGj93O9NgGKZSJN3OTmNmQDxRew+/KiVLcgH6qzbto8aNGZ++j9GFWg==",
"dependencies": {
"Silk.NET.Core": "2.23.0",
"Silk.NET.Vulkan": "2.23.0"
}
},
"Silk.NET.Windowing": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "OPNPmt/lRyUKVYrFLQXVxyATqD3MKLc1iY1oKx1/2GppgmZxVZPwN12tekrQ4C7408kgB1L5JD1Wnirqqeb2kg==",
"dependencies": {
"Silk.NET.Windowing.Common": "2.23.0",
"Silk.NET.Windowing.Glfw": "2.23.0"
}
},
"Tmds.DBus.Protocol": {
"type": "CentralTransitive",
"requested": "[0.21.3, )",
"resolved": "0.21.3",
"contentHash": "hDwB8WsQoyALQKqIbwzS68UKdlnafDm4T/DkO/JrA/YIneP/rKv96SxYPVXeh3FP4i/SXfShrYftKLtciJAIlw=="
}
},
"net10.0/linux-x64": {
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
}
},
"net10.0/osx-arm64": {
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
}
},
"net10.0/osx-x64": {
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
}
},
"net10.0/win-x64": {
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
}
}
}
}
@@ -1,71 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Core.Cpu.Emulation;
/// <summary>
/// Pure software implementation of the bit-field math behind AMD's SSE4a EXTRQ/INSERTQ
/// (immediate-form) instructions.
///
/// The direct-execution backend runs guest PS5 code natively on the host CPU. The PS5's Zen 2
/// cores implement AMD-only SSE4a (EXTRQ/INSERTQ), but Intel hosts - and Rosetta 2 on Apple
/// Silicon - do not, so they raise #UD (STATUS_ILLEGAL_INSTRUCTION) instead of executing the
/// opcode. SharpEmu already rewrites one specific compiled EXTRQ+VPBLENDD idiom at load time
/// (see <see cref="Native.Sse4aExtrqBlendPatch"/>), but any other occurrence of EXTRQ/INSERTQ -
/// a different register allocation, a title built with a different compiler version, and so on
/// - still aborts the title. This class ported from Kyty's
/// <c>Loader::X64InstructionEmulator::TryEmulateSse4a</c> provides the general bit-field
/// extract/insert so the illegal-instruction handler can finish *any* immediate-form
/// EXTRQ/INSERTQ in software and resume, instead of relying on a single hard-coded byte pattern.
///
/// The methods operate on plain 64-bit integers rather than the OS CONTEXT record so the bit
/// math can be unit-tested in isolation; the unsafe CONTEXT/XMM plumbing lives in the backend
/// adapter (<see cref="Native.DirectExecutionBackend"/>).
/// </summary>
public static class Sse4aBitFieldEmulator
{
public static bool IsValidBitField(int length, int index)
{
var len = length & 0x3F;
var idx = index & 0x3F;
return (len != 0 || idx == 0) && (len == 0 ? idx == 0 : idx + len <= 64);
}
public static ulong ExtractBitField(ulong value, int length, int index)
{
var len = length & 0x3F;
var idx = index & 0x3F;
if (!IsValidBitField(length, index))
{
return 0;
}
if (len == 0)
{
return value;
}
var mask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
return (value >> idx) & mask;
}
public static ulong InsertBitField(ulong destination, ulong source, int length, int index)
{
var len = length & 0x3F;
var idx = index & 0x3F;
if (!IsValidBitField(length, index))
{
return destination;
}
if (len == 0)
{
return source;
}
var fieldMask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
var destinationClearMask = fieldMask << idx;
var sourceField = (source & fieldMask) << idx;
return (destination & ~destinationClearMask) | sourceField;
}
}
@@ -1,199 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Threading;
using Iced.Intel;
using SharpEmu.Core.Cpu.Emulation;
namespace SharpEmu.Core.Cpu.Native;
// General software fallback for the AMD-only instructions PS5 titles occasionally emit that a
// Zen 2-only host implements but Intel hosts (and Rosetta 2 on Apple Silicon) do not:
// - SSE4a EXTRQ/INSERTQ, immediate form
// - MONITORX/MWAITX
//
// This is a direct port of Kyty's Loader::X64InstructionEmulator (TryEmulateSse4a /
// TryEmulateMonitorxMwaitx). SharpEmu already special-cases exactly one compiled EXTRQ+VPBLENDD
// byte sequence at load time (Sse4aExtrqBlendPatch), which only helps the one idiom it was
// reverse-engineered from. This file is a general, fault-time fallback that engages for any
// immediate-form EXTRQ/INSERTQ or MONITORX/MWAITX the narrower patch (or a title using a
// different compiler/register allocation) does not cover, complementing rather than replacing
// it: the load-time patch still avoids paying the fault-and-recover cost on the hot path it was
// built for, while this method is the safety net for everything else.
//
// This is deliberately additive: DirectExecutionBackend.IllegalInstruction.cs (the BMI1/BMI2/ABM
// fallback) is untouched, and this method is only reached from VectoredHandler after that one
// has already declined to handle the fault.
public sealed partial class DirectExecutionBackend
{
// Byte offset of Xmm0 within the Win64 CONTEXT record: FltSave (the XMM_SAVE_AREA32/FXSAVE
// image) starts right after Rip at offset 256, and XmmRegisters[0] sits 160 bytes into that
// area (32-byte header + 8 legacy x87/MMX slots x 16 bytes). 256 + 160 = 416 (0x1A0). Cross-
// checked against this file's own Win64ContextSize (0x4D0): rebuilding the whole CONTEXT
// layout field-by-field from offset 0 lands on the same 0x4D0 total, which would not happen
// if this offset (or anything before it) were wrong.
private const int Win64ContextXmm0Offset = 0x1A0;
private static int _sse4aSoftwareFallbackAnnounced;
private static long _sse4aInstructionsEmulated;
private static int _monitorxSoftwareFallbackAnnounced;
private static long _monitorxInstructionsEmulated;
private unsafe bool TryRecoverAmdCompatInstruction(void* contextRecord, ulong rip)
{
if (TryRecoverMonitorxMwaitx(contextRecord, rip))
{
return true;
}
// MONITORX/MWAITX above only ever reads guest code memory and rewrites RIP, both of
// which the POSIX signal bridge (DirectExecutionBackend.PosixSignals.cs) faithfully
// round-trips through the real ucontext, so it works on every supported OS. EXTRQ/
// INSERTQ additionally read and write an XMM register: on Windows contextRecord is the
// live CONTEXT the OS resumes the thread from, so touching the Xmm0.. slots is visible
// to the guest, and on Linux the bridge copies the mcontext's FXSAVE image into the
// Xmm0.. slots and writes them back through sigreturn (_posixXmmContextBridged). On
// Darwin the XMM area is still a zeroed scratch buffer - running this there would
// silently compute a result from stale bytes and then discard whatever it "wrote", so
// the recovery declines until that bridge exists.
return (OperatingSystem.IsWindows() || _posixXmmContextBridged) &&
TryRecoverSse4aExtractInsert(contextRecord, rip);
}
private unsafe bool TryRecoverMonitorxMwaitx(void* contextRecord, ulong rip)
{
// MONITORX (0F 01 FA) and MWAITX (0F 01 FB) are fixed 3-byte encodings with no
// ModRM/SIB/displacement/immediate, so a raw byte compare is sufficient and unambiguous.
var opcode = new byte[3];
if (!TryReadHostBytes(rip, opcode) ||
opcode[0] != 0x0F || opcode[1] != 0x01 || (opcode[2] != 0xFA && opcode[2] != 0xFB))
{
return false;
}
// PS5 titles use this pair in idle/wait loops: MONITORX arms a monitor on a cache line
// and MWAITX blocks until that line is written (or a timeout elapses). Hosts without
// the extension raise #UD on either one. We do not model the monitor itself, only its
// observable effect on guest forward progress: MONITORX becomes a no-op (arming a
// watch we never honour has no side effect of its own) and MWAITX becomes a plain
// thread yield, i.e. treat the awaited condition as already satisfied so the guest
// loop keeps making progress instead of executing an illegal opcode forever.
if (opcode[2] == 0xFB)
{
Thread.Yield();
}
WriteCtxU64(contextRecord, CTX_RIP, rip + 3);
Interlocked.Increment(ref _monitorxInstructionsEmulated);
if (Interlocked.Exchange(ref _monitorxSoftwareFallbackAnnounced, 1) == 0)
{
Console.Error.WriteLine(
"[LOADER][INFO] Host lacks AMD MONITORX/MWAITX used by the guest; " +
"emulating those instructions in software.");
}
return true;
}
private unsafe bool TryRecoverSse4aExtractInsert(void* contextRecord, ulong rip)
{
if (!OperatingSystem.IsWindows() && !_posixXmmContextBridged ||
!TryReadFaultingInstruction(rip, out var instruction))
{
return false;
}
var isExtrq = instruction.Mnemonic == Mnemonic.Extrq;
var isInsertq = instruction.Mnemonic == Mnemonic.Insertq;
if (!isExtrq && !isInsertq)
{
return false;
}
if (isExtrq && instruction.OpCount != 3 || isInsertq && instruction.OpCount != 4)
{
return false;
}
if (instruction.GetOpKind(0) != OpKind.Register ||
!TryGetXmmOffset(instruction.GetOpRegister(0), out var destOffset))
{
return false;
}
var destLow = ReadCtxU64(contextRecord, destOffset);
if (isExtrq)
{
var length = (int)instruction.GetImmediate(1);
var index = (int)instruction.GetImmediate(2);
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
{
return false;
}
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.ExtractBitField(destLow, length, index));
WriteCtxU64(contextRecord, destOffset + 8, 0);
}
else
{
if (instruction.GetOpKind(1) != OpKind.Register ||
!TryGetXmmOffset(instruction.GetOpRegister(1), out var srcOffset))
{
return false;
}
var length = (int)instruction.GetImmediate(2);
var index = (int)instruction.GetImmediate(3);
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
{
return false;
}
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.InsertBitField(
destLow, ReadCtxU64(contextRecord, srcOffset), length, index));
WriteCtxU64(contextRecord, destOffset + 8, 0);
}
WriteCtxU64(contextRecord, CTX_RIP, rip + (ulong)instruction.Length);
Interlocked.Increment(ref _sse4aInstructionsEmulated);
if (Interlocked.Exchange(ref _sse4aSoftwareFallbackAnnounced, 1) == 0)
{
Console.Error.WriteLine(
"[LOADER][INFO] Host lacks SSE4a EXTRQ/INSERTQ used by the guest; " +
"emulating those instructions in software.");
}
return true;
}
// Maps an Iced XMM register to its byte offset in the Win64 CONTEXT record. Written as an
// explicit switch (rather than arithmetic on the Register enum) to match the style already
// used by TryGetGprSlot/TryGetGpr64Offset in DirectExecutionBackend.IllegalInstruction.cs.
private static bool TryGetXmmOffset(Register register, out int offset)
{
switch (register)
{
case Register.XMM0: offset = Win64ContextXmm0Offset + 16 * 0; return true;
case Register.XMM1: offset = Win64ContextXmm0Offset + 16 * 1; return true;
case Register.XMM2: offset = Win64ContextXmm0Offset + 16 * 2; return true;
case Register.XMM3: offset = Win64ContextXmm0Offset + 16 * 3; return true;
case Register.XMM4: offset = Win64ContextXmm0Offset + 16 * 4; return true;
case Register.XMM5: offset = Win64ContextXmm0Offset + 16 * 5; return true;
case Register.XMM6: offset = Win64ContextXmm0Offset + 16 * 6; return true;
case Register.XMM7: offset = Win64ContextXmm0Offset + 16 * 7; return true;
case Register.XMM8: offset = Win64ContextXmm0Offset + 16 * 8; return true;
case Register.XMM9: offset = Win64ContextXmm0Offset + 16 * 9; return true;
case Register.XMM10: offset = Win64ContextXmm0Offset + 16 * 10; return true;
case Register.XMM11: offset = Win64ContextXmm0Offset + 16 * 11; return true;
case Register.XMM12: offset = Win64ContextXmm0Offset + 16 * 12; return true;
case Register.XMM13: offset = Win64ContextXmm0Offset + 16 * 13; return true;
case Register.XMM14: offset = Win64ContextXmm0Offset + 16 * 14; return true;
case Register.XMM15: offset = Win64ContextXmm0Offset + 16 * 15; return true;
default:
offset = 0;
return false;
}
}
}
@@ -133,11 +133,6 @@ public sealed partial class DirectExecutionBackend
{
return -1;
}
if (exceptionCode == StatusIllegalInstruction &&
TryRecoverAmdCompatInstruction(contextRecord, rip))
{
return -1;
}
if (IsBenignHostDebugException(exceptionCode))
{
return -1;
@@ -483,7 +478,7 @@ public sealed partial class DirectExecutionBackend
if (count <= 16 || count % 65536 == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (default-on; set SHARPEMU_IGNORE_INT41=0 to disable)");
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (SHARPEMU_IGNORE_INT41=1)");
Console.Error.Flush();
}
return true;
@@ -530,12 +530,9 @@ public sealed partial class DirectExecutionBackend
{
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
}
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
{
DeliverPendingGuestExceptionAtSafePoint(
cpuContext,
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
}
DeliverPendingGuestExceptionAtSafePoint(
cpuContext,
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
StoreImportVectorReturn(cpuContext, argPackPtr);
if (dispatchResolved &&
orbisGen2Result == OrbisGen2Result.ORBIS_GEN2_OK &&
@@ -1329,12 +1326,9 @@ public sealed partial class DirectExecutionBackend
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
}
}
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
{
DeliverPendingGuestExceptionAtSafePoint(
cpuContext,
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
}
DeliverPendingGuestExceptionAtSafePoint(
cpuContext,
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
StoreImportVectorReturn(cpuContext, argPackPtr);
if (returnValue != (int)OrbisGen2Result.ORBIS_GEN2_OK)
@@ -1404,13 +1398,11 @@ public sealed partial class DirectExecutionBackend
"vWU-odnS+fU" or // sceAmprMeasureCommandSizeReadFile
"sSAUCCU1dv4" or // sceAmprMeasureCommandSizeWriteKernelEventQueue_04_00
"C+IEj+BsAFM" or // sceAmprMeasureCommandSizeWriteAddressOnCompletion
"4fgtGfXDrFc" or // sceAmprMeasureCommandSizeWriteAddress_04_00
"tZDDEo2tE5k" or // sceAmprCommandBufferGetSize
"GnxKOHEawhk" or // sceAmprCommandBufferGetCurrentOffset
"gzndltBEzWc" or // sceAmprCommandBufferGetNumCommands
"H896Pt-yB4I" or // sceAmprCommandBufferWriteKernelEventQueue_04_00
"sJXyWHjP-F8" or // sceAmprCommandBufferWriteAddressOnCompletion
"j0+3uJMxYJY" or // sceAmprCommandBufferWriteAddress_04_00
"mPpPxv5CZt4" or // sceSystemServiceGetHdrToneMapLuminance
"1FZBKy8HeNU" or // sceVideoOutGetVblankStatus
"ASoW5WE-UPo" or // sceKernelAprSubmitCommandBufferAndGetResult
@@ -1418,8 +1410,6 @@ public sealed partial class DirectExecutionBackend
"eE4Szl8sil8" or // sceKernelAprSubmitCommandBuffer
"qvMUCyyaCSI" or // sceKernelAprSubmitCommandBufferAndGetId
"Q2V+iqvjgC0" or // vsnprintf
"AV6ipCNa4Rw" or // strcasecmp
"viiwFMaNamA" or // strstr
"q1cHNfGycLI" or // scePadRead
"xk0AcarP3V4" or // scePadOpen
"yH17Q6NWtVg" or // sceUserServiceGetEvent
@@ -1446,9 +1436,6 @@ public sealed partial class DirectExecutionBackend
var expectedMutexTrylockBusy =
string.Equals(nid, "K-jXhbt2gn4", StringComparison.Ordinal) &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
var expectedSemaphoreTrywaitAgain =
string.Equals(nid, "H2a+IN9TP0E", StringComparison.Ordinal) &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN;
var expectedNetAcceptWouldBlock =
string.Equals(nid, "PIWqhn9oSxc", StringComparison.Ordinal) &&
resultValue == unchecked((int)0x80410123);
@@ -1462,7 +1449,6 @@ public sealed partial class DirectExecutionBackend
!expectedTimedWaitTimeout &&
!expectedEqueueTimeout &&
!expectedMutexTrylockBusy &&
!expectedSemaphoreTrywaitAgain &&
!expectedNetAcceptWouldBlock &&
!expectedUserServiceNoEvent &&
!expectedPrivacyInvalidParameter)
@@ -1556,13 +1542,11 @@ public sealed partial class DirectExecutionBackend
"vWU-odnS+fU" or
"sSAUCCU1dv4" or
"C+IEj+BsAFM" or
"4fgtGfXDrFc" or
"tZDDEo2tE5k" or
"GnxKOHEawhk" or
"gzndltBEzWc" or
"H896Pt-yB4I" or
"sJXyWHjP-F8" or
"j0+3uJMxYJY" or
"mPpPxv5CZt4" or
"1FZBKy8HeNU" or
"ASoW5WE-UPo" or
@@ -1587,8 +1571,6 @@ public sealed partial class DirectExecutionBackend
"WkkeywLJcgU" or // wcslen
"Ovb2dSJOAuE" or // strcmp
"aesyjrHVWy4" or // strncmp
"AV6ipCNa4Rw" or // strcasecmp
"viiwFMaNamA" or // strstr
"pNtJdE3x49E" or // wcscmp
"fV2xHER+bKE" or // wcscoll
"E8wCoUEbfzk" or // wcsncmp
@@ -50,19 +50,6 @@ public sealed unsafe partial class DirectExecutionBackend
private const int LinuxUcontextGregsOffset = 40;
private const int LinuxGregsErrOffset = 19 * 8;
// The kernel's x86-64 sigcontext places the FXSAVE-image pointer right
// after the general registers it hands to the handler: err(152)
// trapno(160) oldmask(168) cr2(176) fpstate(184), all relative to
// GetPosixRegisterBase. glibc and musl both overlay this kernel layout
// verbatim (glibc's mcontext_t.fpregs is the same slot), so the offset
// is libc-independent. Inside the FXSAVE image the XMM registers start
// at +160 (32-byte header + 8 legacy x87/MMX slots x 16 bytes) - the
// same relative position they occupy in the Win64 CONTEXT's FltSave
// area (Win64ContextXmm0Offset = 256 + 160).
private const int LinuxGregsFpstateOffset = 184;
private const int FxsaveXmmOffset = 160;
private const int XmmBlockSize = 16 * 16;
// Byte offsets of the general registers relative to GetPosixRegisterBase,
// ordered to match the contiguous Win64 CONTEXT block CTX_RAX..CTX_RIP
// (rax, rcx, rdx, rbx, rsp, rbp, rsi, rdi, r8..r15, rip). Verified
@@ -84,15 +71,6 @@ public sealed unsafe partial class DirectExecutionBackend
[ThreadStatic]
private static int _posixSignalHandlerDepth;
// True while the current thread's in-flight POSIX fault carries the real
// XMM registers in the CONTEXT scratch buffer and writes to them will
// reach the mcontext on resume. Gates recovery paths (SSE4a EXTRQ/
// INSERTQ) that would otherwise compute results from a zeroed XMM area
// and silently discard what they "wrote". Darwin is not bridged yet, so
// the flag stays false there.
[ThreadStatic]
private static bool _posixXmmContextBridged;
private void SetupPosixExceptionHandler()
{
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_POSIX_SIGNALS"), "1", StringComparison.Ordinal))
@@ -274,26 +252,6 @@ public sealed unsafe partial class DirectExecutionBackend
WriteCtxU64(contextRecord, CTX_RAX + i * 8, *(ulong*)(registers + offsets[i]));
}
// Bridge the XMM registers alongside the GPRs where the layout is
// known: on Linux the fpstate pointer and FXSAVE image are kernel
// ABI, so recovery paths that read or write XMM state (SSE4a
// EXTRQ/INSERTQ) see the live registers and their writes reach the
// guest through sigreturn.
byte* fpstate = null;
if (OperatingSystem.IsLinux())
{
fpstate = *(byte**)(registers + LinuxGregsFpstateOffset);
if (fpstate != null)
{
Buffer.MemoryCopy(
fpstate + FxsaveXmmOffset,
contextRecord + Win64ContextXmm0Offset,
XmmBlockSize,
XmmBlockSize);
}
}
_posixXmmContextBridged = fpstate != null;
EXCEPTION_RECORD record = default;
record.ExceptionAddress = (void*)ReadCtxU64(contextRecord, CTX_RIP);
if (signal == PosixSigIll)
@@ -359,14 +317,6 @@ public sealed unsafe partial class DirectExecutionBackend
{
*(ulong*)(registers + offsets[i]) = ReadCtxU64(contextRecord, CTX_RAX + i * 8);
}
if (fpstate != null)
{
Buffer.MemoryCopy(
contextRecord + Win64ContextXmm0Offset,
fpstate + FxsaveXmmOffset,
XmmBlockSize,
XmmBlockSize);
}
return true;
}
@@ -712,11 +712,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private readonly Dictionary<ulong, PendingGuestException> _pendingGuestExceptions = new Dictionary<ulong, PendingGuestException>();
// Import dispatch is the hottest managed path in UE titles. Most imports do
// not have an exception queued, so publish the dictionary population and let
// safe points skip _guestThreadGate entirely in the common case.
private int _pendingGuestExceptionCount;
private readonly HashSet<ulong> _activeGuestExceptionDeliveries = new HashSet<ulong>();
private int _guestThreadPumpDepth;
@@ -1123,9 +1118,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
_logStrlenBursts = _logStrlenImports ||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_STRLEN_BURSTS"), "1", StringComparison.Ordinal);
_logGuestContext = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_CONTEXT"), "1", StringComparison.Ordinal);
var ignoreGuestInt41Env = Environment.GetEnvironmentVariable("SHARPEMU_IGNORE_INT41");
_ignoreGuestInt41 = !string.Equals(ignoreGuestInt41Env, "0", StringComparison.Ordinal) &&
!string.Equals(ignoreGuestInt41Env, "false", StringComparison.OrdinalIgnoreCase);
_ignoreGuestInt41 = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_IGNORE_INT41"), "1", StringComparison.Ordinal);
_ignoredGuestInt41Count = 0;
_logGuestThreads = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_GUEST_THREADS"), "1", StringComparison.Ordinal);
_logUsleep = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_USLEEP"), "1", StringComparison.Ordinal);
@@ -1298,12 +1291,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private unsafe bool TryCreateNativeImportIntrinsic(string nid, out nint address)
{
if (IsHlePreferredNid(nid))
{
address = 0;
return false;
}
if (nid == "1jfXLRVzisc" &&
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_USLEEP"), "1", StringComparison.Ordinal))
{
@@ -1415,54 +1402,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
0x75, 0xE7,
0xC3,
],
"AV6ipCNa4Rw" =>
[
0x0F, 0xB6, 0x07,
0x0F, 0xB6, 0x16,
0x8D, 0x48, 0xBF,
0x83, 0xF9, 0x19,
0x77, 0x03,
0x83, 0xC0, 0x20,
0x8D, 0x4A, 0xBF,
0x83, 0xF9, 0x19,
0x77, 0x03,
0x83, 0xC2, 0x20,
0x29, 0xD0,
0x75, 0x0C,
0x85, 0xD2,
0x74, 0x08,
0x48, 0xFF, 0xC7,
0x48, 0xFF, 0xC6,
0xEB, 0xD4,
0xC3,
],
"viiwFMaNamA" =>
[
0x0F, 0xB6, 0x16,
0x84, 0xD2,
0x74, 0x2D,
0x0F, 0xB6, 0x07,
0x84, 0xC0,
0x74, 0x2A,
0x38, 0xD0,
0x75, 0x1D,
0x4C, 0x8D, 0x47, 0x01,
0x4C, 0x8D, 0x4E, 0x01,
0x41, 0x0F, 0xB6, 0x09,
0x84, 0xC9,
0x74, 0x12,
0x41, 0x38, 0x08,
0x75, 0x08,
0x49, 0xFF, 0xC0,
0x49, 0xFF, 0xC1,
0xEB, 0xEB,
0x48, 0xFF, 0xC7,
0xEB, 0xD3,
0x48, 0x89, 0xF8,
0xC3,
0x31, 0xC0,
0xC3,
],
"pNtJdE3x49E" or "fV2xHER+bKE" =>
[
0x0F, 0xB7, 0x07,
@@ -1527,14 +1466,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
"Q3VBxCXhUHs" =>
[
0x48, 0x89, 0xF8,
0x48, 0x85, 0xD2,
0x74, 0x11,
0x44, 0x8A, 0x06,
0x44, 0x88, 0x07,
0x48, 0xFF, 0xC6,
0x48, 0xFF, 0xC7,
0x48, 0xFF, 0xCA,
0x75, 0xEF,
0x48, 0x89, 0xD1,
0xF3, 0xA4,
0xC3,
],
"8zTFvBIAIN8" =>
@@ -1668,8 +1601,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private static bool IsHlePreferredNid(string nid)
{
return string.Equals(nid, "QrZZdJ8XsX0", StringComparison.Ordinal) ||
string.Equals(nid, "Q3VBxCXhUHs", StringComparison.Ordinal);
return string.Equals(nid, "QrZZdJ8XsX0", StringComparison.Ordinal);
}
private static bool IsLibcLibrary(string libraryName)
@@ -2609,22 +2541,28 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private unsafe bool TryPatchSse4aExtrqBlend(nint address, byte* source)
{
// Rosetta does not implement AMD SSE4a EXTRQ. Recognize the compiler's
// EXTRQ+blend idiom (against whichever xmm0-xmm7 it allocated) and rewrite
// it into an equivalent SSE4.1 sequence. Match/encode is isolated in
// Sse4aExtrqBlendPatch so it can be unit-tested; here we only patch bytes.
var window = new ReadOnlySpan<byte>(source, Sse4aExtrqBlendPatch.SequenceLength);
if (!Sse4aExtrqBlendPatch.TryMatch(window, out var destRegister, out var srcRegister))
// Rosetta does not implement AMD SSE4a EXTRQ. This exact sequence masks
// xmm2 to its low 40 bits, then copies the resulting second dword into
// xmm0. PEXTRB/PINSRD provides the same observable result in 12 bytes:
// extract source byte 4 and insert the zero-extended value into lane 1.
ReadOnlySpan<byte> pattern =
[
0x66, 0x0F, 0x78, 0xC2, 0x28, 0x00,
0xC4, 0xE3, 0x79, 0x02, 0xC2, 0x02,
];
for (var i = 0; i < pattern.Length; i++)
{
return false;
}
Span<byte> replacement = stackalloc byte[Sse4aExtrqBlendPatch.SequenceLength];
if (!Sse4aExtrqBlendPatch.TryEncode(destRegister, srcRegister, replacement))
{
return false;
if (source[i] != pattern[i])
{
return false;
}
}
ReadOnlySpan<byte> replacement =
[
0x66, 0x0F, 0x3A, 0x14, 0xD0, 0x04,
0x66, 0x0F, 0x3A, 0x22, 0xC0, 0x01,
];
uint oldProtect = 0;
if (!VirtualProtect((void*)address, (nuint)replacement.Length, 64u, &oldProtect))
{
@@ -3955,10 +3893,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
// unwinding. Unity can begin its next stop-the-world cycle in
// that window; treating the new raise as part of the old delivery
// strands the collector waiting for an acknowledgement.
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
handler,
exceptionType,
external.ExceptionStackBase));
external.ExceptionStackBase);
return true;
}
@@ -3967,10 +3905,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
// managed thread corrupts the worker's control state. Queue the
// request and let that exact executor consume it at its next HLE
// boundary, where the original guest thread is safely paused.
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
handler,
exceptionType,
external.ExceptionStackBase));
external.ExceptionStackBase);
if (logGuestExceptions)
{
Console.Error.WriteLine(
@@ -4015,17 +3953,17 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
}
if (target.ExceptionDeliveryActive)
{
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
handler,
exceptionType,
exceptionStackBase));
exceptionStackBase);
return true;
}
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
handler,
exceptionType,
exceptionStackBase));
exceptionStackBase);
if (logGuestExceptions)
{
Console.Error.WriteLine(
@@ -4186,7 +4124,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
RestoreInterruptedGuestThread();
if (target.State == GuestThreadRunState.Blocked &&
!target.ExecutorActive &&
TryRemovePendingGuestExceptionLocked(threadHandle, out var queued))
_pendingGuestExceptions.Remove(threadHandle, out var queued))
{
followUp = queued;
}
@@ -4272,11 +4210,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
CpuContext currentContext,
GuestCpuContinuation interruptedContinuation)
{
if (Volatile.Read(ref _pendingGuestExceptionCount) == 0)
{
return;
}
var threadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
if (threadHandle == 0)
{
@@ -4290,7 +4223,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return;
}
if (!TryRemovePendingGuestExceptionLocked(threadHandle, out pending))
if (!_pendingGuestExceptions.Remove(threadHandle, out pending))
{
return;
}
@@ -4352,27 +4285,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
}
}
private void QueuePendingGuestExceptionLocked(
ulong threadHandle,
PendingGuestException pending)
{
_pendingGuestExceptions[threadHandle] = pending;
Volatile.Write(ref _pendingGuestExceptionCount, _pendingGuestExceptions.Count);
}
private bool TryRemovePendingGuestExceptionLocked(
ulong threadHandle,
out PendingGuestException pending)
{
if (!_pendingGuestExceptions.Remove(threadHandle, out pending))
{
return false;
}
Volatile.Write(ref _pendingGuestExceptionCount, _pendingGuestExceptions.Count);
return true;
}
private static bool TryWriteGuestExceptionContext(
CpuContext context,
ulong address,
@@ -4467,7 +4379,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
_guestThreads.Clear();
_externalGuestThreads.Clear();
_pendingGuestExceptions.Clear();
Volatile.Write(ref _pendingGuestExceptionCount, 0);
_activeGuestExceptionDeliveries.Clear();
}
+1 -1
View File
@@ -252,7 +252,7 @@ public static unsafe class JitStubs
var pattern = TlsAccessPattern;
var end = start + length - pattern.Length;
for (var ptr = start; ptr <= end; ptr++)
for (var ptr = start; ptr < end; ptr++)
{
if (MatchesPattern(ptr, pattern))
{
@@ -1,115 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System;
namespace SharpEmu.Core.Cpu.Native;
/// <summary>
/// Recognizes Sony's AMD-only SSE4a EXTRQ+blend idiom and rewrites it into an
/// equivalent SSE4.1 sequence. SharpEmu executes guest x86-64 natively, but
/// Rosetta 2 and Intel hosts do not implement SSE4a, so the original opcode
/// raises #UD -> SIGILL. The compiler emits the idiom against whichever XMM
/// register it happens to allocate (Dead Cells uses xmm1, others xmm2), so the
/// source register is read from the ModRM r/m field rather than hard-coded.
///
/// The match/encode logic is deliberately free of native page-patching so it
/// can be unit-tested against handcrafted byte sequences.
/// </summary>
public static class Sse4aExtrqBlendPatch
{
/// <summary>Length in bytes of both the matched idiom and its replacement.</summary>
public const int SequenceLength = 12;
/// <summary>
/// Matches the 12-byte idiom, extracting the destination register D and the
/// source (scratch) register N:
/// <code>
/// EXTRQ xmmN, 0x28, 0x00 ; 66 0F 78 /0 28 00 mask xmmN to low 40 bits
/// VPBLENDD xmmD, xmmD, xmmN, 2 ; C4 E3 vvvv 02 /r 02 copy dword 1 into xmmD
/// </code>
/// N lives in the ModRM r/m field of both instructions; D (the blend
/// destination and src1) lives in the VPBLENDD ModRM reg field and VEX.vvvv.
/// Both are xmm0-xmm7 (the VEX byte1 0xE3 pins R/X/B, so no xmm8-15 extension).
/// The compiler allocates whichever registers it likes — Dead Cells builds use
/// D=xmm0 and D=xmm3, others differ — so both are read from the encoding.
/// </summary>
public static bool TryMatch(ReadOnlySpan<byte> source, out int destRegister, out int srcRegister)
{
destRegister = -1;
srcRegister = -1;
if (source.Length < SequenceLength)
{
return false;
}
// EXTRQ xmmN, 0x28, 0x00 : 66 0F 78, ModRM (mod=11 reg=000 rm=N), 28, 00.
if (source[0] != 0x66 || source[1] != 0x0F || source[2] != 0x78 ||
(source[3] & 0xF8) != 0xC0 || source[4] != 0x28 || source[5] != 0x00)
{
return false;
}
var n = source[3] & 0x07;
// VPBLENDD xmmD, xmmD, xmmN, 2 : C4 E3 <W=0 vvvv=~D L=0 pp=01> 02 ModRM 02.
// VEX.byte2 fixed bits (W, L, pp) must read 0b*0000*01; vvvv encodes ~D.
if (source[6] != 0xC4 || source[7] != 0xE3 || (source[8] & 0x87) != 0x01 ||
source[9] != 0x02 || source[11] != 0x02)
{
return false;
}
var d = (~(source[8] >> 3)) & 0x0F;
if (d > 7)
{
return false;
}
// ModRM: mod=11, reg=D (dest = src1), rm=N (src2 = the masked register).
if (source[10] != (0xC0 | (d << 3) | n))
{
return false;
}
destRegister = d;
srcRegister = n;
return true;
}
/// <summary>
/// Writes the SSE4.1 equivalent into <paramref name="destination"/>:
/// <code>
/// PEXTRB eax, xmmN, 4 ; 66 0F 3A 14 /r 04 extract byte 4 (zero-extended)
/// PINSRD xmmD, eax, 1 ; 66 0F 3A 22 /r 01 insert into xmmD dword lane 1
/// </code>
/// After EXTRQ masks xmmN to its low 40 bits, dword 1 is just byte 4
/// zero-extended, so the two-instruction extract/insert reproduces the exact
/// observable result the AMD idiom left in xmmD. eax is a caller-dead scratch
/// at every site the compiler emits this idiom.
/// </summary>
public static bool TryEncode(int destRegister, int srcRegister, Span<byte> destination)
{
if ((uint)destRegister > 7 || (uint)srcRegister > 7 || destination.Length < SequenceLength)
{
return false;
}
// PEXTRB eax, xmmN, 4 : ModRM (mod=11 reg=N rm=000 -> eax), imm8 = byte index 4.
destination[0] = 0x66;
destination[1] = 0x0F;
destination[2] = 0x3A;
destination[3] = 0x14;
destination[4] = (byte)(0xC0 | (srcRegister << 3));
destination[5] = 0x04;
// PINSRD xmmD, eax, 1 : ModRM (mod=11 reg=D -> xmmD, rm=000 -> eax), lane 1.
destination[6] = 0x66;
destination[7] = 0x0F;
destination[8] = 0x3A;
destination[9] = 0x22;
destination[10] = (byte)(0xC0 | (destRegister << 3));
destination[11] = 0x01;
return true;
}
}
@@ -40,9 +40,6 @@ public sealed class TrackedCpuMemory : ICpuMemory, ITrackedCpuMemory, IGuestMemo
return result;
}
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length) =>
_inner.TryCopy(destinationAddress, sourceAddress, length);
public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address)
{
if (_inner is IGuestMemoryAllocator allocator)
+4 -70
View File
@@ -199,32 +199,9 @@ public sealed class SelfLoader : ISelfLoader
{
if (!physicalVm.TryAllocateAtExact(imageBase, totalImageSize, executable: true, out var allocatedBase))
{
// Exact allocation failed — the host may have already claimed
// part of this range (ASLR, Rosetta 2, or another process).
// Try backing the fixed range page by page to claim whatever
// free gaps exist. If the whole range is occupied the backfill
// returns false and we surface the original failure reason.
Console.Error.WriteLine(
$"[LOADER] Exact allocation at main image base 0x{imageBase:X16} " +
$"(size=0x{totalImageSize:X}) failed; attempting fixed-range backfill.");
if (!physicalVm.TryBackFixedRange(imageBase, totalImageSize, executable: true))
{
// TryBackFixedRange may have partially backed pages before
// failing. The earlier Clear() already reset all regions, so
// this second Clear() is idempotent for everything except the
// partial backfill — it frees only those orphaned pages.
physicalVm.Clear();
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
throw new InvalidOperationException(
$"Could not allocate main image at required base 0x{imageBase:X16} " +
$"(size=0x{totalImageSize:X}): {reason}. " +
"Try closing other applications, rebooting, or " +
(OperatingSystem.IsWindows()
? "setting SHARPEMU_DISABLE_MITIGATION_RELAUNCH=1."
: "ensuring no other process maps into this address range."));
}
allocatedBase = imageBase;
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
throw new InvalidOperationException(
$"Could not allocate main image at required base 0x{imageBase:X16} (size=0x{totalImageSize:X}): {reason}.");
}
imageBase = allocatedBase;
@@ -737,9 +714,8 @@ public sealed class SelfLoader : ISelfLoader
importedRelocations = BuildImportedRelocations(descriptors);
var stubEligibleNids = CollectStubEligibleNids(descriptors, moduleManager);
var stubImportNids = orderedImportNids
.Where(stubEligibleNids.Contains)
.Where(nid => ShouldCreateImportStub(nid, descriptors, moduleManager))
.ToArray();
var stubsByAddress = CreateImportStubMapping(virtualMemory, stubImportNids);
Console.WriteLine($"[LOADER] Created {stubsByAddress.Count} import stubs");
@@ -1184,35 +1160,6 @@ public sealed class SelfLoader : ISelfLoader
isWeak);
}
// Collects every NID that needs a trap import stub in a single pass over the
// descriptors. This mirrors ShouldCreateImportStub applied per NID, but avoids
// the O(nids * descriptors) rescan that filtering each unique NID against the
// full descriptor list would incur on large modules. A NID qualifies as soon as
// one of its descriptors is non-weak, or is weak but resolvable via the module
// manager.
private static HashSet<string> CollectStubEligibleNids(
IReadOnlyList<RelocationDescriptor> descriptors,
IModuleManager? moduleManager)
{
var eligible = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < descriptors.Count; i++)
{
var descriptor = descriptors[i];
var nid = descriptor.ImportNid;
if (nid is null || eligible.Contains(nid))
{
continue;
}
if (!descriptor.IsWeak || moduleManager?.TryGetExport(nid, out _) == true)
{
eligible.Add(nid);
}
}
return eligible;
}
private static bool ShouldCreateImportStub(
string nid,
IReadOnlyList<RelocationDescriptor> descriptors,
@@ -2484,19 +2431,6 @@ public sealed class SelfLoader : ISelfLoader
Debug.Assert(
!ShouldCreateImportStub("weak", [weak], moduleManager: null),
"An unresolved weak symbol incorrectly received a trap import stub.");
var strong = new RelocationDescriptor(
TargetAddress: 0x3000,
Addend: 0,
ImportNid: "strong",
SymbolValue: 0,
RelocationValueKind.Pointer,
IsDataImport: false);
var mixed = new List<RelocationDescriptor> { weak, strong };
var eligible = CollectStubEligibleNids(mixed, moduleManager: null);
Debug.Assert(
eligible.Contains("strong") && !eligible.Contains("weak"),
"CollectStubEligibleNids disagreed with the per-NID stub eligibility rule.");
}
private static ulong AlignUp(ulong value, ulong alignment)
@@ -20,11 +20,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
private readonly Dictionary<(ulong DesiredAddress, ulong Alignment, bool Executable), ulong> _allocationSearchHints = new();
private readonly Dictionary<ulong, ProgramHeaderFlags> _pageProtections = new();
private bool _disposed;
[ThreadStatic]
private static CommittedRangeCache? _committedRangeCache;
private long _mappingGeneration;
private const ulong PageSize = 0x1000;
private const ulong GuestAllocationArenaAddress = 0x00006000_0000_0000;
private const ulong GuestAllocationArenaSize = 0x0100_0000;
@@ -33,77 +28,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
private const ulong FullCommitRegionLimit = 4UL << 30;
private const ulong DefaultLazyReservePrimeBytes = 0x0400_0000UL; // 64 MiB
private const ulong LazyReservePrimeChunkBytes = 0x0200_0000UL; // 32 MiB
private const int CommittedRangeCacheCapacity = 4;
private sealed class CommittedRangeCache
{
private readonly CommittedRange[] _ranges = new CommittedRange[CommittedRangeCacheCapacity];
private PhysicalVirtualMemory? _owner;
private long _generation;
private int _count;
private int _nextReplacement;
public bool Contains(
PhysicalVirtualMemory owner,
long generation,
ulong start,
ulong end)
{
if (!ReferenceEquals(_owner, owner) || _generation != generation)
{
return false;
}
for (var index = 0; index < _count; index++)
{
var range = _ranges[index];
if (start >= range.Start && end <= range.End)
{
return true;
}
}
return false;
}
public void Add(
PhysicalVirtualMemory owner,
long generation,
ulong start,
ulong end)
{
if (!ReferenceEquals(_owner, owner) || _generation != generation)
{
_owner = owner;
_generation = generation;
_count = 0;
_nextReplacement = 0;
}
for (var index = 0; index < _count; index++)
{
var range = _ranges[index];
if (start <= range.End && end >= range.Start)
{
_ranges[index] = new CommittedRange(
Math.Min(start, range.Start),
Math.Max(end, range.End));
return;
}
}
if (_count < _ranges.Length)
{
_ranges[_count++] = new CommittedRange(start, end);
return;
}
_ranges[_nextReplacement] = new CommittedRange(start, end);
_nextReplacement = (_nextReplacement + 1) % _ranges.Length;
}
}
private readonly record struct CommittedRange(ulong Start, ulong End);
// Raw Windows PAGE_* values retained for the internal region/protection
// bookkeeping: regions and saved old-protection values always carry the raw
@@ -425,111 +349,6 @@ 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,
@@ -621,7 +440,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
_gate.ExitWriteLock();
}
Interlocked.Increment(ref _mappingGeneration);
_hostMemory.Free(address);
}
@@ -793,7 +611,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
{
_allocationSearchHints.Clear();
}
Interlocked.Increment(ref _mappingGeneration);
}
finally
{
@@ -1056,15 +873,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
public bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source)
{
// A managed write into a page the guest-image write tracker has
// protected surfaces as a fatal AccessViolation — the runtime turns
// SIGSEGV in managed code into an exception before the resumable
// signal bridge can restore access (native guest stores recover
// there). Pre-visit the span so tracked pages are unprotected and
// their owners dirtied before the copy; guest addresses are
// host-identical, matching the tracker's fault addresses.
GuestImageWriteTracker.NotifyManagedWrite(virtualAddress, (ulong)source.Length);
var requiresExclusiveAccess = false;
_gate.EnterReadLock();
try
@@ -1102,7 +910,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
}
NotifyGuestWriteWatch(virtualAddress, source);
return true;
}
}
@@ -1128,68 +935,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
}
}
private static void NotifyGuestWriteWatch(ulong virtualAddress, ReadOnlySpan<byte> source)
{
if (GuestWriteWatch.Armed)
{
GuestWriteWatch.Check(virtualAddress, source);
}
}
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length)
{
if (length == 0)
{
return true;
}
if (length > int.MaxValue)
{
return false;
}
// Match TryWrite's managed-write notification before touching an
// identity-mapped guest page protected by the image tracker.
GuestImageWriteTracker.NotifyManagedWrite(destinationAddress, length);
_gate.EnterReadLock();
try
{
var sourceRegion = FindRegion(sourceAddress, length);
var destinationRegion = FindRegion(destinationAddress, length);
if (sourceRegion is null || destinationRegion is null ||
!TryResolveRegionOffset(sourceAddress, length, sourceRegion, out var sourceOffset) ||
!TryResolveRegionOffset(destinationAddress, length, destinationRegion, out var destinationOffset))
{
return false;
}
var sourcePointer = sourceRegion.VirtualAddress + sourceOffset;
var destinationPointer = destinationRegion.VirtualAddress + destinationOffset;
if ((sourceRegion.IsReservedOnly &&
!EnsureRangeCommitted(sourcePointer, length, sourceRegion)) ||
(destinationRegion.IsReservedOnly &&
!EnsureRangeCommitted(destinationPointer, length, destinationRegion)) ||
!CanReadWithoutProtectionChange(sourcePointer, length, sourceRegion) ||
!CanWriteWithoutProtectionChange(destinationPointer, length, destinationRegion))
{
return false;
}
// Span.CopyTo has memmove overlap semantics, so this allocation-free
// path safely serves both libc memcpy and libc memmove.
new ReadOnlySpan<byte>((void*)sourcePointer, checked((int)length)).CopyTo(
new Span<byte>((void*)destinationPointer, checked((int)length)));
NotifyGuestWriteWatch(
destinationAddress,
new ReadOnlySpan<byte>((void*)destinationPointer, checked((int)length)));
return true;
}
finally
{
_gate.ExitReadLock();
}
}
private bool TryReadExclusive(ulong virtualAddress, Span<byte> destination)
{
var region = FindRegion(virtualAddress, (ulong)destination.Length);
@@ -1262,7 +1007,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
}
NotifyGuestWriteWatch(virtualAddress, source);
return true;
}
@@ -1287,7 +1031,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
}
}
NotifyGuestWriteWatch(virtualAddress, source);
return true;
}
@@ -1529,12 +1272,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
var startPage = AlignDown(address, PageSize);
var endPage = AlignUp(address + size, PageSize);
var mappingGeneration = Volatile.Read(ref _mappingGeneration);
var committedRangeCache = _committedRangeCache ??= new CommittedRangeCache();
if (committedRangeCache.Contains(this, mappingGeneration, startPage, endPage))
{
return true;
}
var commitProtection = GetCommitProtection(region);
var pageAddress = startPage;
@@ -1556,9 +1293,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
if (info.State == HostRegionState.Committed)
{
// The host query proved this whole range is committed. Retain
// that result instead of caching only the caller's small span.
CacheCommittedRange(info.BaseAddress, queriedEnd, mappingGeneration);
pageAddress = rangeEnd;
continue;
}
@@ -1574,23 +1308,12 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return false;
}
CacheCommittedRange(pageAddress, rangeEnd, mappingGeneration);
pageAddress = rangeEnd;
}
CacheCommittedRange(startPage, endPage, mappingGeneration);
return true;
}
private void CacheCommittedRange(ulong startPage, ulong endPage, long mappingGeneration)
{
(_committedRangeCache ??= new CommittedRangeCache()).Add(
this,
mappingGeneration,
startPage,
endPage);
}
private bool TryTemporarilyProtectForRead(
ulong address,
ulong size,
+1 -8
View File
@@ -2,7 +2,6 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Loader;
using SharpEmu.HLE;
namespace SharpEmu.Core.Memory;
@@ -94,14 +93,8 @@ public sealed class VirtualMemory : IVirtualMemory
}
CopyToRegions(virtualAddress, source, regionIndex);
return true;
}
if (GuestWriteWatch.Armed)
{
GuestWriteWatch.Check(virtualAddress, source);
}
return true;
}
private bool TryValidateRange(
+2 -2
View File
@@ -248,7 +248,7 @@ internal sealed class EmulatorProcess : IDisposable
{
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
if (!CreateProcessW(
null,
exePath,
commandLine,
0,
0,
@@ -629,7 +629,7 @@ internal sealed class EmulatorProcess : IDisposable
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateProcessW(string? applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
private static extern bool CreateProcessW(string applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(nint handle, uint milliseconds);
-7
View File
@@ -351,13 +351,6 @@ public sealed class GameSurfaceHost : NativeControlHost
var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale));
var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale));
var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height;
if (Environment.GetEnvironmentVariable("SHARPEMU_TRACE_SURFACE_SIZE") == "1")
{
Console.Error.WriteLine(
$"[GUI][TRACE] GameSurfaceHost.UpdateSurfaceSize bounds={Bounds.Width}x{Bounds.Height} " +
$"scale={renderScale} computed={width}x{height} changed={sizeChanged} " +
$"prevSurface={_surface.PixelWidth}x{_surface.PixelHeight}");
}
_surface.UpdatePixelSize(width, height);
if (!sizeChanged)
+1 -37
View File
@@ -53,9 +53,6 @@ public sealed class GuiSettings
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
public List<string> EnvironmentToggles { get; set; } = new();
/// <summary>Internal render resolution scale (1.0 = native, 0.5 = half).</summary>
public double RenderResolutionScale { get; set; } = 1.0;
/// <summary>
/// Discord application ID used for Rich Presence; the default is the
/// SharpEmu application. Override to rebrand what Discord shows as
@@ -74,7 +71,7 @@ public sealed class GuiSettings
if (File.Exists(SettingsPath))
{
var json = File.ReadAllText(SettingsPath);
return NormalizeFromJson(json);
return JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
}
}
catch (Exception)
@@ -85,39 +82,6 @@ public sealed class GuiSettings
return new GuiSettings();
}
/// <summary>
/// Deserializes settings and normalizes null references and null or empty list
/// entries introduced by JSON. Empty scalar strings remain unchanged.
/// </summary>
internal static GuiSettings NormalizeFromJson(string json)
{
var settings = JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
settings.GameFolders = FilterNullOrEmpty(settings.GameFolders);
settings.ExcludedGames = FilterNullOrEmpty(settings.ExcludedGames);
settings.EnvironmentToggles = FilterNullOrEmpty(settings.EnvironmentToggles);
settings.LogLevel ??= "Info";
settings.Language ??= "en";
settings.DiscordClientId ??= "1525606762248540221";
if (settings.RenderResolutionScale <= 0 || settings.RenderResolutionScale > 2.0)
{
settings.RenderResolutionScale = 1.0;
}
return settings;
}
// JSON can populate non-nullable lists with null references and entries.
private static List<string> FilterNullOrEmpty(List<string>? source)
{
if (source is null)
{
return [];
}
return source.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
}
public void Save()
{
try
-23
View File
@@ -400,29 +400,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem x:Name="GraphicsTabItem" Header="Graphics" FontSize="15">
<ScrollViewer>
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="RenderingSectionTitle" Classes="sectionTitle" Text="RENDERING" />
<local:SettingRow x:Name="RenderResolutionRow" Label="Internal resolution"
Description="Render offscreen targets below native resolution and upscale on present. Lower values trade image quality for GPU headroom; takes effect on next launch.">
<ComboBox x:Name="RenderResolutionBox" Width="160" SelectedIndex="0"
VerticalAlignment="Center" CornerRadius="8">
<ComboBoxItem x:Name="RenderResolution100Item" Content="100% (native)" Tag="1.0" />
<ComboBoxItem x:Name="RenderResolution75Item" Content="75%" Tag="0.75" />
<ComboBoxItem x:Name="RenderResolution50Item" Content="50%" Tag="0.5" />
<ComboBoxItem x:Name="RenderResolution25Item" Content="25%" Tag="0.25" />
</ComboBox>
</local:SettingRow>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem x:Name="EnvTabItem" Header="Environment" FontSize="15">
<ScrollViewer>
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
+12 -84
View File
@@ -92,11 +92,6 @@ public partial class MainWindow : Window
// plain window color remains the fallback when the asset fails to load.
private Bitmap? _defaultBackdrop;
// Whether the native loading/closing popup should be showing; it is a
// desktop-topmost popup, so it closes while the launcher is in the
// background or minimized and reopens from this flag on activation.
private bool _sessionLoadingActive;
// Controller navigation state.
private readonly DispatcherTimer _gamepadTimer;
private HostGamepadButtons _previousPadButtons;
@@ -155,18 +150,8 @@ public partial class MainWindow : Window
};
_libraryBlurTimer.Tick += (_, _) => AdvanceLibraryBlur();
// Native popups float above every window on the desktop; they must
// follow the launcher into the background or a minimized state.
Activated += (_, _) =>
{
UpdateSessionBarVisibility();
SessionLoadingPopup.IsOpen = _sessionLoadingActive;
};
Deactivated += (_, _) =>
{
SessionBarPopup.IsOpen = false;
SessionLoadingPopup.IsOpen = false;
};
Activated += (_, _) => UpdateSessionBarVisibility();
Deactivated += (_, _) => SessionBarPopup.IsOpen = false;
TitleBar.PointerPressed += OnTitleBarPointerPressed;
GameList.SelectionChanged += (_, _) => UpdateSelectedGame();
@@ -192,18 +177,6 @@ public partial class MainWindow : Window
// it is open already uses the new values.
LogLevelBox.SelectionChanged += (_, _) => _settings.LogLevel = SelectedLogLevel();
TraceImportsBox.ValueChanged += (_, _) => _settings.ImportTraceLimit = (int)(TraceImportsBox.Value ?? 0);
RenderResolutionBox.SelectionChanged += (_, _) =>
{
if (RenderResolutionBox.SelectedItem is ComboBoxItem { Tag: string tag } &&
double.TryParse(
tag,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var scale))
{
_settings.RenderResolutionScale = scale;
}
};
StrictToggle.IsCheckedChanged += (_, _) => _settings.StrictDynlibResolution = StrictToggle.IsChecked == true;
LogToFileToggle.IsCheckedChanged += (_, _) => _settings.LogToFile = LogToFileToggle.IsChecked == true;
OverrideLogFileToggle.IsCheckedChanged += (_, _) =>
@@ -441,15 +414,6 @@ public partial class MainWindow : Window
return;
}
if (_isRunning || _isStopping)
{
// The game renders inside the launcher window, so the launcher
// stays active while playing. The controller belongs to the game
// then: no navigation, and Circle/B must never stop the session.
_previousPadButtons = pad.Buttons;
return;
}
var shoulderPressed = pad.Buttons & ~_previousPadButtons;
if ((shoulderPressed & HostGamepadButtons.L1) != 0)
{
@@ -499,6 +463,11 @@ public partial class MainWindow : Window
LaunchSelected();
}
if ((pressed & HostGamepadButtons.Circle) != 0)
{
StopEmulator();
}
_previousPadButtons = pad.Buttons;
}
@@ -881,13 +850,6 @@ public partial class MainWindow : Window
_ => 2,
};
TraceImportsBox.Value = Math.Clamp(_settings.ImportTraceLimit, 0, 4096);
RenderResolutionBox.SelectedIndex = _settings.RenderResolutionScale switch
{
>= 0.875 => 0,
>= 0.625 => 1,
>= 0.375 => 2,
_ => 3,
};
StrictToggle.IsChecked = _settings.StrictDynlibResolution;
LogToFileToggle.IsChecked = _settings.LogToFile;
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
@@ -1664,23 +1626,13 @@ public partial class MainWindow : Window
base.OnPropertyChanged(change);
if (change.Property == WindowStateProperty)
{
// The XAML WindowState="Maximized" assignment raises this change
// during InitializeComponent, before named controls are wired up.
if (WindowState == WindowState.Minimized)
{
_sndPreview.Pause();
if (SessionLoadingPopup is { } popup)
{
popup.IsOpen = false;
}
}
else
{
_sndPreview.Resume();
if (SessionLoadingPopup is { } popup)
{
popup.IsOpen = _sessionLoadingActive;
}
}
}
}
@@ -1807,12 +1759,6 @@ public partial class MainWindow : Window
_appliedEnvironmentVariables.Add(name);
}
Environment.SetEnvironmentVariable(
"SHARPEMU_RENDER_SCALE",
_settings.RenderResolutionScale.ToString(
"0.###",
System.Globalization.CultureInfo.InvariantCulture));
if (SharpEmuLog.TryParseLevel(effective.LogLevel, out var logLevel))
{
SharpEmuLog.MinimumLevel = logLevel;
@@ -2055,27 +2001,16 @@ public partial class MainWindow : Window
RestoreGameViewToFull();
GameView.Background = Brushes.Black;
GameView.IsHitTestVisible = true;
_gameSurfaceHost?.SetPresentationVisible(true);
_gameSurfaceHost?.SetCursorAutoHide(true);
LibraryPage.IsVisible = false;
OptionsPage.IsVisible = false;
LibraryToolbar.IsVisible = false;
ContentToolbar.IsVisible = false;
ConsolePanel.IsVisible = false;
LaunchBar.IsVisible = false;
HideSessionLoading();
SessionLoadingPopup.IsOpen = false;
UpdateSessionBarVisibility();
// Defer so the layout pass from the margin change above settles first.
Dispatcher.UIThread.Post(() =>
{
if (!_isRunning || _isStopping)
{
return;
}
_gameSurfaceHost?.RefreshSurfaceSize();
_gameSurfaceHost?.SetPresentationVisible(true);
_gameSurfaceHost?.SetCursorAutoHide(true);
});
}
});
}
@@ -2174,7 +2109,7 @@ public partial class MainWindow : Window
GameView.IsVisible = false;
GameView.IsHitTestVisible = true;
SessionBarPopup.IsOpen = false;
HideSessionLoading();
SessionLoadingPopup.IsOpen = false;
AnimateLibraryBlur(0, clearWhenComplete: true);
MainContent.Margin = new Thickness(32, 24, 32, 20);
ContentToolbar.IsVisible = true;
@@ -2258,14 +2193,7 @@ public partial class MainWindow : Window
{
SessionLoadingTitle.Text = title;
SessionLoadingDetail.Text = detail;
_sessionLoadingActive = true;
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
}
private void HideSessionLoading()
{
_sessionLoadingActive = false;
SessionLoadingPopup.IsOpen = false;
SessionLoadingPopup.IsOpen = true;
}
private void ReturnToLibraryWhileStopping()
+1 -13
View File
@@ -49,7 +49,7 @@ public sealed class PerGameSettings
var path = PathFor(titleId);
if (File.Exists(path))
{
return NormalizeFromJson(File.ReadAllText(path));
return JsonSerializer.Deserialize<PerGameSettings>(File.ReadAllText(path), SerializerOptions);
}
}
catch (Exception)
@@ -59,18 +59,6 @@ public sealed class PerGameSettings
return null;
}
// A null list inherits global settings; only entries in a present list are sanitized.
internal static PerGameSettings? NormalizeFromJson(string json)
{
var settings = JsonSerializer.Deserialize<PerGameSettings>(json, SerializerOptions);
if (settings?.EnvironmentToggles is { } toggles)
{
settings.EnvironmentToggles = toggles.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
}
return settings;
}
public void Save(string titleId)
{
if (string.IsNullOrWhiteSpace(titleId))
-4
View File
@@ -24,10 +24,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="SharpEmu.Libs.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
+7 -83
View File
@@ -32,7 +32,6 @@ public static unsafe class GuestImageWriteTracker
public int Armed;
public int FirstCpuWriteSeen;
public int PendingFirstCpuWrite;
public long WriteGeneration;
public bool TraceLifetime;
public long SourceSequence;
public long FirstCpuWriteTraceSequence;
@@ -52,33 +51,9 @@ public static unsafe class GuestImageWriteTracker
private static readonly object _gate = new();
private static readonly Dictionary<ulong, TrackedRange> _rangesByAddress = new();
/// <summary>Immutable snapshot read lock-free from the signal handler and
/// the managed-write pre-visit; rebuilt on every mutation under the gate
/// (signal handlers must not take managed locks). Carrying the overall
/// bounds inside the same object keeps the hot-path intersection test
/// consistent with the array it guards.</summary>
private sealed class RangeSnapshot
{
public static readonly RangeSnapshot Empty = new([]);
public readonly TrackedRange[] Ranges;
public readonly ulong Start;
public readonly ulong End;
public RangeSnapshot(TrackedRange[] ranges)
{
Ranges = ranges;
Start = ulong.MaxValue;
End = 0;
foreach (var range in ranges)
{
Start = Math.Min(Start, range.Start);
End = Math.Max(End, range.End);
}
}
}
private static RangeSnapshot _rangeSnapshot = RangeSnapshot.Empty;
// Snapshot array read lock-free from the signal handler; rebuilt on every
// mutation under the gate. Signal handlers must not take managed locks.
private static TrackedRange[] _rangeSnapshot = [];
private static readonly bool _enabled = !OperatingSystem.IsWindows() &&
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
@@ -156,21 +131,10 @@ public static unsafe class GuestImageWriteTracker
{
// Never resize an object that is still reachable from the
// signal handler's lock-free snapshot. Retire it and publish
// a fresh immutable range, carrying the write generation so
// resizes do not hide guest CPU rewrites from cache owners.
var writeGeneration = Volatile.Read(ref range.WriteGeneration);
// a fresh immutable range.
DisarmLocked(range, "replace-range");
_rangesByAddress.Remove(address);
range = new TrackedRange
{
Address = address,
ByteCount = byteCount,
Start = start,
End = start + length,
WriteGeneration = writeGeneration,
};
_rangesByAddress[address] = range;
RebuildSnapshotLocked();
range = null;
}
if (range is null)
@@ -284,31 +248,6 @@ public static unsafe class GuestImageWriteTracker
}
}
/// <summary>
/// Returns the monotonic first-write generation for a tracked allocation.
/// Unlike the consuming dirty flag, this remains changed after another
/// cache owner consumes and re-arms the range.
/// </summary>
public static bool TryGetWriteGeneration(ulong address, out long generation)
{
generation = 0;
if (!_enabled)
{
return false;
}
lock (_gate)
{
if (!_rangesByAddress.TryGetValue(address, out var range))
{
return false;
}
generation = Volatile.Read(ref range.WriteGeneration);
return true;
}
}
/// <summary>
/// Prepares pages touched by a managed HLE memory write. Native guest
/// stores fault and enter <see cref="TryHandleWriteFault"/> through the
@@ -327,17 +266,6 @@ public static unsafe class GuestImageWriteTracker
var end = address > ulong.MaxValue - byteCount
? ulong.MaxValue
: address + byteCount;
// Fast rejection for the hot path: this runs on every managed guest
// write, and almost none of them touch tracked texture pages. The
// bounds live inside the snapshot so they are always consistent with
// the ranges the per-page visit below would consult.
var snapshot = Volatile.Read(ref _rangeSnapshot);
if (snapshot.Ranges.Length == 0 || end <= snapshot.Start || address >= snapshot.End)
{
return;
}
var candidate = address;
while (candidate < end)
{
@@ -383,7 +311,7 @@ public static unsafe class GuestImageWriteTracker
return false;
}
var ranges = Volatile.Read(ref _rangeSnapshot).Ranges;
var ranges = Volatile.Read(ref _rangeSnapshot);
var writableStart = ulong.MaxValue;
var writableEnd = 0UL;
for (var index = 0; index < ranges.Length; index++)
@@ -462,10 +390,6 @@ public static unsafe class GuestImageWriteTracker
}
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) != 0;
if (wasArmed)
{
Interlocked.Increment(ref range.WriteGeneration);
}
if (wasArmed &&
range.TraceLifetime &&
Interlocked.CompareExchange(ref range.FirstCpuWriteSeen, 1, 0) == 0)
@@ -534,7 +458,7 @@ public static unsafe class GuestImageWriteTracker
private static void RebuildSnapshotLocked()
{
Volatile.Write(ref _rangeSnapshot, new RangeSnapshot(_rangesByAddress.Values.ToArray()));
_rangeSnapshot = _rangesByAddress.Values.ToArray();
}
private static (ulong Start, ulong Length) PageAlign(ulong address, ulong byteCount)
+1 -1
View File
@@ -17,7 +17,7 @@ public static class GuestTlsTemplate
// Must match CpuDispatcher/DirectExecutionBackend's mapped prefix. PS5
// modules can require more than one host page of Variant II static TLS;
// Dreaming Sarah's startup image, for example, reaches 0x1870 bytes.
public const ulong StartupStaticTlsReservation = 0x20000UL; // Was 0x10000UL, but thats too small for GTA V
public const ulong StartupStaticTlsReservation = 0x10000UL;
private static readonly object _gate = new();
private static readonly SortedDictionary<ulong, ModuleTemplate> _modules = new();
private static readonly Dictionary<ulong, ThreadDtv> _threadDtvs = new();
-203
View File
@@ -1,203 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using System.Globalization;
using System.Threading;
namespace SharpEmu.HLE;
// This tool monitors guest-memory writes only when a watch mode is active.
public static class GuestWriteWatch
{
private const ulong WatchBytes = 8;
private const int MaxBulkReports = 64;
private static readonly ulong WatchBase = Parse(
Environment.GetEnvironmentVariable("SHARPEMU_WATCH_WRITE"));
private static readonly bool WatchPoolHeaders = IsEnabled("SHARPEMU_WATCH_POOL_HEADER");
private static readonly ulong[] PoolSlots = new ulong[64];
private static int _poolSlotCount;
private static readonly bool WatchValuePattern = IsEnabled("SHARPEMU_WATCH_VALUE_PATTERN");
private static readonly bool WatchValue1 = IsEnabled("SHARPEMU_WATCH_VALUE1");
private const ulong DirectBandLow = 0x100_0000_0000;
private const ulong DirectBandHigh = 0x1000_0000_0000;
private static int _value1Reports;
private static readonly bool WatchBulkTorn = IsEnabled("SHARPEMU_WATCH_BULK_TORN");
private static readonly ulong BulkDestHigh = Parse(
Environment.GetEnvironmentVariable("SHARPEMU_WATCH_BULK_DEST_HI"));
private static int _bulkTornReports;
private static int _bulkShiftReports;
public static bool Armed =>
WatchBase != 0 || WatchPoolHeaders || WatchValuePattern || WatchValue1 || WatchBulkTorn;
public static void OnDirectMapping(ulong mappedAddress, ulong length, int protection)
{
if (!WatchPoolHeaders || !IsPoolMapping(length, protection))
{
return;
}
var index = Interlocked.Increment(ref _poolSlotCount) - 1;
if (index < PoolSlots.Length)
{
Volatile.Write(ref PoolSlots[index], mappedAddress + 0x40);
Console.Error.WriteLine(
$"[LOADER][WARN] watch_write armed on pool header slot 0x{mappedAddress + 0x40:X16}");
}
}
public static void Check(ulong address, ReadOnlySpan<byte> data)
{
if (WatchBulkTorn &&
data.Length >= 8 &&
(BulkDestHigh != 0
? (address >> 32) == BulkDestHigh
: address >= DirectBandLow && address < DirectBandHigh))
{
for (var offset = FirstAlignedOffset(address); offset + 8 <= data.Length; offset += 8)
{
var qword = BinaryPrimitives.ReadUInt64LittleEndian(data.Slice(offset, 8));
var kind = ClassifyBulkValue(qword);
if (kind is not null && ReserveBulkReport(kind))
{
Console.Error.WriteLine(
$"[LOADER][WARN] watch_bulk_torn HIT ({kind}) " +
$"dest=0x{address + (ulong)offset:X16} (base=0x{address:X16}+0x{offset:X}) " +
$"len={data.Length} qword=0x{qword:X16}{Environment.NewLine}{Environment.StackTrace}");
Console.Error.Flush();
return;
}
}
}
if (WatchValue1 &&
address >= DirectBandLow && address < DirectBandHigh &&
data.Length is >= 1 and <= 8 &&
LittleEndianValue(data) == 1 &&
Interlocked.Increment(ref _value1Reports) <= 128)
{
Report(address, data);
return;
}
if (WatchValuePattern && data.Length == 8)
{
var value = BinaryPrimitives.ReadUInt64LittleEndian(data);
if ((value & 0xFFFFFFFF) == 1 && value >> 32 is > 0 and <= 0xFFFF)
{
Report(address, data);
return;
}
}
if (WatchBase != 0 && Overlaps(address, data.Length, WatchBase))
{
Report(address, data);
return;
}
var slots = Math.Min(Volatile.Read(ref _poolSlotCount), PoolSlots.Length);
for (var i = 0; i < slots; i++)
{
var slot = Volatile.Read(ref PoolSlots[i]);
if (slot != 0 && Overlaps(address, data.Length, slot))
{
Report(address, data);
return;
}
}
}
internal static string? ClassifyBulkValue(ulong qword)
{
var low32 = qword & 0xFFFFFFFF;
var high32 = qword >> 32;
if (low32 == 1 && high32 is > 0 and <= 0xFFFF)
{
return "torn";
}
var prefix = low32 & 0xFF00_0000;
var hasShiftedPointerPrefix = prefix is 0x0800_0000 or 0x8000_0000;
return high32 == 0 && hasShiftedPointerPrefix && (low32 & 0xFF) == 0
? "shift"
: null;
}
internal static int FirstAlignedOffset(ulong address) =>
(int)((8 - (address & 7)) & 7);
internal static bool IsPoolMapping(ulong length, int protection) =>
length == 0x10000 && protection == 0xF2;
internal static bool Overlaps(ulong address, int length, ulong slot)
{
if (length <= 0)
{
return false;
}
var writeLength = (ulong)length - 1;
var writeEnd = address > ulong.MaxValue - writeLength
? ulong.MaxValue
: address + writeLength;
var slotEnd = slot > ulong.MaxValue - (WatchBytes - 1)
? ulong.MaxValue
: slot + WatchBytes - 1;
return address <= slotEnd && slot <= writeEnd;
}
private static ulong LittleEndianValue(ReadOnlySpan<byte> data)
{
ulong value = 0;
for (var i = 0; i < data.Length; i++)
{
value |= (ulong)data[i] << (i * 8);
}
return value;
}
private static void Report(ulong address, ReadOnlySpan<byte> data)
{
Console.Error.WriteLine(
$"[LOADER][WARN] watch_write HIT addr=0x{address:X16} len={data.Length} " +
$"first_qword=0x{LittleEndianValue(data):X16}{Environment.NewLine}{Environment.StackTrace}");
Console.Error.Flush();
}
private static bool IsEnabled(string name) =>
string.Equals(Environment.GetEnvironmentVariable(name), "1", StringComparison.Ordinal);
private static bool ReserveBulkReport(string kind) =>
kind == "torn"
? Interlocked.Increment(ref _bulkTornReports) <= MaxBulkReports
: Interlocked.Increment(ref _bulkShiftReports) <= MaxBulkReports;
internal static ulong Parse(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return 0;
}
text = text.Trim();
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
text = text[2..];
}
return ulong.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value)
? value
: 0;
}
}
-2
View File
@@ -10,6 +10,4 @@ public interface ICpuMemory
bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source);
bool TryCompare(ulong virtualAddress, ReadOnlySpan<byte> expected) => false;
bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length) => false;
}
-11
View File
@@ -15,17 +15,6 @@ public interface IGuestAddressSpace : IGuestMemoryAllocator
{
ulong AllocateAt(ulong desiredAddress, ulong size, bool executable = true, bool allowAlternative = true);
/// <summary>
/// Backs an entire fixed-address range, matching the guest's
/// <c>SCE_KERNEL_MAP_FIXED</c> contract. Unlike <see cref="AllocateAt"/>, which
/// reserves the range in one all-or-nothing host call, this walks the range and
/// fills only the sub-ranges that are not already backed. That keeps a fixed
/// mapping whole when part of the requested window is already occupied — the
/// partial-overlap case where the single-call reservation fails outright and
/// leaves the remainder unmapped for the guest to fault into.
/// </summary>
bool TryBackFixedRange(ulong address, ulong size, bool executable);
bool TryAllocateAtOrAbove(ulong desiredAddress, ulong size, bool executable, ulong alignment, out ulong actualAddress);
bool TryProtect(ulong address, ulong size, GuestPageProtection protection);
-44
View File
@@ -1,44 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Acm;
public static class AcmExports
{
private static int _nextContextHandle;
[SysAbiExport(
Nid = "ZIXln2K3XMk",
ExportName = "sceAcmContextCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmContextCreate(CpuContext ctx)
{
var outContextAddress = ctx[CpuRegister.Rdi];
if (outContextAddress == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var handle = (ulong)Interlocked.Increment(ref _nextContextHandle);
Span<byte> handleBytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(handleBytes, handle);
return ctx.Memory.TryWrite(outContextAddress, handleBytes)
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK)
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
[SysAbiExport(
Nid = "jBgBjAj02R8",
ExportName = "sceAcmContextDestroy",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmContextDestroy(CpuContext ctx)
{
_ = ctx;
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
}
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,6 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using SharpEmu.Libs.Gpu;
using SharpEmu.Libs.Kernel;
using SharpEmu.Libs.VideoOut;
using SharpEmu.ShaderCompiler;
@@ -27,8 +26,8 @@ internal static class AgcShaderCompilerHooks
internal static void Install()
{
Gen5ShaderScalarEvaluator.FallbackMemoryReader =
KernelMemoryCompatExports.TryReadShaderGuestMemory;
KernelMemoryCompatExports.TryReadTrackedLibcHeap;
Gen5ShaderScalarEvaluator.GlobalMemoryPool =
GuestDataPool.Shared;
VulkanVideoPresenter.GuestDataPool;
}
}
+42 -327
View File
@@ -1,9 +1,6 @@
// 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>
@@ -19,11 +16,8 @@ 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 unsafe class GnmTiling
internal static class GnmTiling
{
private const int ParallelDetileElementThreshold = 512 * 512;
private const int MaxDetileWorkers = 4;
// Oberon uses the 16-pipe / 8-pixel-packer RB+ topology. These are the
// single-sample 64 KiB equations generated by AMD AddrLib for that exact
// topology. Each entry describes one address bit as an XOR of X/Y bits.
@@ -124,14 +118,6 @@ internal static unsafe class GnmTiling
StringComparison.Ordinal);
private static readonly HashSet<uint> _reportedModes = new();
private static readonly ConcurrentDictionary<(uint SwizzleMode, int BppLog2), PatternTerms>
_patternTermCache = new();
private static readonly ConcurrentDictionary<(SwizzleKind Kind, int Width, int Height), int[]>
_blockTableCache = new();
private static readonly ParallelOptions _parallelDetileOptions = new()
{
MaxDegreeOfParallelism = Math.Min(MaxDetileWorkers, Environment.ProcessorCount),
};
public static bool Enabled => _enabled || !_disabled;
@@ -208,164 +194,6 @@ internal static unsafe class GnmTiling
}
}
public static bool TryGetBlockElementDimensions(
uint swizzleMode,
int bytesPerElement,
out int blockWidth,
out int blockHeight)
{
blockWidth = 0;
blockHeight = 0;
if (bytesPerElement <= 0 ||
!TryGetSwizzleKind(swizzleMode, out _, out var blockBytes))
{
return false;
}
var bppLog2 = BitLog2((uint)bytesPerElement);
if (bppLog2 < 0)
{
return false;
}
(blockWidth, blockHeight) = SquareBlockDimensions(blockBytes >> bppLog2);
return blockWidth != 0 && blockHeight != 0;
}
/// <summary>
/// Locates mip 0 in a GFX10 mip chain, which AddrLib stores smallest-first
/// (Gfx10Lib::ComputeSurfaceInfoMacroTiled/MicroTiled).
/// </summary>
public static bool TryGetBaseMipPlacement(
uint swizzleMode,
int elementsWide,
int elementsHigh,
int bytesPerElement,
uint resourceMipLevels,
out ulong byteOffset,
out bool inMipTail,
out int tailElementX,
out int tailElementY,
out ulong chainSliceBytes)
{
byteOffset = 0;
inMipTail = false;
tailElementX = 0;
tailElementY = 0;
chainSliceBytes = 0;
if (resourceMipLevels <= 1 ||
!ShouldDetile(swizzleMode) ||
elementsWide <= 0 ||
elementsHigh <= 0 ||
bytesPerElement <= 0 ||
!TryGetSwizzleKind(swizzleMode, out _, out var blockBytes))
{
return false;
}
var bppLog2 = BitLog2((uint)bytesPerElement);
if (bppLog2 < 0)
{
return false;
}
var (blockWidth, blockHeight) = SquareBlockDimensions(blockBytes >> bppLog2);
var blockSizeLog2 = BitLog2((uint)blockBytes);
if (blockWidth == 0 || blockHeight == 0 || blockSizeLog2 < 8)
{
return false;
}
var mipLevels = (int)Math.Min(resourceMipLevels, 16u);
var maxMipsInTail = blockSizeLog2 <= 8 ? 0
: blockSizeLog2 <= 11
? 1 + (1 << (blockSizeLog2 - 9))
: blockSizeLog2 - 4;
var tailWidth = (blockSizeLog2 & 1) != 0 ? blockWidth >> 1 : blockWidth;
var tailHeight = (blockSizeLog2 & 1) != 0 ? blockHeight : blockHeight >> 1;
var firstMipInTail = mipLevels;
var mipSizes = new ulong[mipLevels];
for (var i = 0; i < mipLevels; i++)
{
var mipWidth = Math.Max(elementsWide >> i, 1);
var mipHeight = Math.Max(elementsHigh >> i, 1);
if (maxMipsInTail > 0 &&
mipWidth <= tailWidth &&
mipHeight <= tailHeight &&
mipLevels - i <= maxMipsInTail)
{
firstMipInTail = i;
break;
}
var alignedWidth = (ulong)(mipWidth + blockWidth - 1) / (ulong)blockWidth * (ulong)blockWidth;
var alignedHeight = (ulong)(mipHeight + blockHeight - 1) / (ulong)blockHeight * (ulong)blockHeight;
mipSizes[i] = alignedWidth * alignedHeight * (ulong)bytesPerElement;
}
if (firstMipInTail == 0)
{
var m = maxMipsInTail - 1;
var mipOffset = m > 6 ? 16 << m : m << 8;
var mipX = ((mipOffset >> 9) & 1) |
((mipOffset >> 10) & 2) |
((mipOffset >> 11) & 4) |
((mipOffset >> 12) & 8) |
((mipOffset >> 13) & 16) |
((mipOffset >> 14) & 32);
var mipY = ((mipOffset >> 8) & 1) |
((mipOffset >> 9) & 2) |
((mipOffset >> 10) & 4) |
((mipOffset >> 11) & 8) |
((mipOffset >> 12) & 16) |
((mipOffset >> 13) & 32);
if ((blockSizeLog2 & 1) != 0)
{
(mipX, mipY) = (mipY, mipX);
if ((bppLog2 & 1) != 0)
{
mipY = (mipY << 1) | (mipX & 1);
mipX >>= 1;
}
}
var (microWidth, microHeight) = SquareBlockDimensions(256 >> bppLog2);
if (microWidth == 0 || microHeight == 0)
{
return false;
}
tailElementX = mipX * microWidth;
tailElementY = mipY * microHeight;
if (tailElementX + elementsWide > blockWidth ||
tailElementY + elementsHigh > blockHeight)
{
tailElementX = 0;
tailElementY = 0;
return false;
}
inMipTail = true;
chainSliceBytes = (ulong)blockBytes;
return true;
}
byteOffset = firstMipInTail < mipLevels ? (ulong)blockBytes : 0;
chainSliceBytes = byteOffset;
for (var i = firstMipInTail - 1; i >= 1; i--)
{
byteOffset += mipSizes[i];
}
for (var i = 0; i < firstMipInTail; i++)
{
chainSliceBytes += mipSizes[i];
}
return true;
}
/// <summary>
/// Deswizzles <paramref name="tiled"/> into linear row-major order.
/// Elements are pixels for uncompressed formats and 4x4 blocks for
@@ -418,83 +246,50 @@ internal static unsafe class GnmTiling
return false;
}
// Address tables depend only on the swizzle equation and element size,
// so retain them across textures instead of rebuilding them per upload.
// Precompute the within-block element offset for each (x, y) inside a
// single block. The swizzle equation only depends on the in-block
// coordinates, so this table is reused for every block — turning the
// per-pixel bit-interleave (a loop + calls) into a single array lookup.
// Detiling a 2048x2048 texture is millions of elements; without this the
// per-pixel math makes DETILE unusably slow during asset streaming.
var hasExactXorPattern = TryGetExactXorPattern(swizzleMode, bppLog2, out var xorPattern);
var patternTerms = hasExactXorPattern
? _patternTermCache.GetOrAdd(
(swizzleMode, bppLog2),
_ => CreatePatternTerms(xorPattern))
: default;
var blockTable = hasExactXorPattern
? []
: _blockTableCache.GetOrAdd(
(kind, blockWidth, blockHeight),
static key => CreateBlockTable(key.Kind, key.Width, key.Height));
// The XOR equation offset factors cleanly into independent X and Y
// fields — each output bit is parity(x & XMask) XOR parity(y & YMask),
// and parity distributes over XOR, so offset(x, y) == xTerm(x) ^ yTerm(y).
// Exact equations repeat at a small power-of-two period. Cached axis
// terms reduce the inner loop to two array loads and one XOR.
fixed (byte* tiledPointer = tiled)
fixed (byte* linearPointer = linear)
var blockTable = hasExactXorPattern ? [] : new int[blockWidth * blockHeight];
for (var by = 0; !hasExactXorPattern && by < blockHeight; by++)
{
var sourceAddress = (nint)tiledPointer;
var destinationAddress = (nint)linearPointer;
var sourceLength = tiled.Length;
var destinationLength = linear.Length;
var blockWidthShift = BitLog2((uint)blockWidth);
var blockWidthMask = blockWidth - 1;
var detileRow = (int y) =>
for (var bx = 0; bx < blockWidth; bx++)
{
var blockY = y / blockHeight;
var inBlockY = y & (blockHeight - 1);
var rowBlockBase = (long)blockY * blocksPerRow;
var tableRowBase = inBlockY * blockWidth;
var destRowBase = (long)y * elementsWide * bytesPerElement;
var yTerm = hasExactXorPattern
? patternTerms.Y[y & patternTerms.YMask]
: 0;
for (var x = 0; x < elementsWide; x++)
{
var blockX = x >> blockWidthShift;
var inBlockX = x & blockWidthMask;
var blockIndex = rowBlockBase + blockX;
var sourceByte = hasExactXorPattern
? blockIndex * blockBytes + (patternTerms.X[x & patternTerms.XMask] ^ yTerm)
: (blockIndex * blockElements + blockTable[tableRowBase + inBlockX]) *
(long)bytesPerElement;
var destByte = destRowBase + (long)x * bytesPerElement;
if (sourceByte < 0 ||
sourceByte + bytesPerElement > sourceLength ||
destByte + bytesPerElement > destinationLength)
{
continue;
}
CopyElement(
(byte*)sourceAddress + sourceByte,
(byte*)destinationAddress + destByte,
bytesPerElement);
}
};
var elementCount = (long)elementsWide * elementsHigh;
if (elementCount >= ParallelDetileElementThreshold && Environment.ProcessorCount > 1)
{
Parallel.For(
0,
elementsHigh,
_parallelDetileOptions,
detileRow);
blockTable[by * blockWidth + bx] = (int)(kind == SwizzleKind.ZOrder
? MortonInterleave((uint)bx, (uint)by, blockWidth, blockHeight)
: StandardSwizzleOffset((uint)bx, (uint)by, blockWidth, blockHeight));
}
else
}
for (var y = 0; y < elementsHigh; y++)
{
var blockY = y / blockHeight;
var inBlockY = y % blockHeight;
var rowBlockBase = (long)blockY * blocksPerRow;
var tableRowBase = inBlockY * blockWidth;
var destRowBase = (long)y * elementsWide * bytesPerElement;
for (var x = 0; x < elementsWide; x++)
{
for (var y = 0; y < elementsHigh; 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)
{
detileRow(y);
continue;
}
tiled.Slice((int)sourceByte, bytesPerElement)
.CopyTo(linear.Slice((int)destByte, bytesPerElement));
}
}
@@ -507,80 +302,6 @@ internal static unsafe class GnmTiling
ZOrder,
}
private readonly record struct PatternTerms(int[] X, int XMask, int[] Y, int YMask);
private static PatternTerms CreatePatternTerms(AddressBit[] pattern)
{
uint xMask = 0;
uint yMask = 0;
foreach (var bit in pattern)
{
xMask |= bit.XMask;
yMask |= bit.YMask;
}
var xLength = AxisTermPeriod(xMask);
var yLength = AxisTermPeriod(yMask);
var xTerms = new int[xLength];
var yTerms = new int[yLength];
for (var x = 0; x < xTerms.Length; x++)
{
xTerms[x] = (int)PatternAxisTerm((uint)x, pattern, useX: true);
}
for (var y = 0; y < yTerms.Length; y++)
{
yTerms[y] = (int)PatternAxisTerm((uint)y, pattern, useX: false);
}
return new PatternTerms(xTerms, xLength - 1, yTerms, yLength - 1);
}
private static int AxisTermPeriod(uint mask) =>
mask == 0 ? 1 : 1 << (32 - System.Numerics.BitOperations.LeadingZeroCount(mask));
private static int[] CreateBlockTable(SwizzleKind kind, int blockWidth, int blockHeight)
{
var table = new int[blockWidth * blockHeight];
for (var y = 0; y < blockHeight; y++)
{
for (var x = 0; x < blockWidth; x++)
{
table[y * blockWidth + x] = (int)(kind == SwizzleKind.ZOrder
? MortonInterleave((uint)x, (uint)y, blockWidth, blockHeight)
: StandardSwizzleOffset((uint)x, (uint)y, blockWidth, blockHeight));
}
}
return table;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void CopyElement(byte* source, byte* destination, int bytesPerElement)
{
switch (bytesPerElement)
{
case 1:
*destination = *source;
break;
case 2:
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<ushort>(source));
break;
case 4:
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<uint>(source));
break;
case 8:
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<ulong>(source));
break;
case 16:
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<UInt128>(source));
break;
default:
Unsafe.CopyBlockUnaligned(destination, source, (uint)bytesPerElement);
break;
}
}
private static readonly AddressBit Zero = new(0, 0);
private static AddressBit X(int bit) => new(1u << bit, 0);
@@ -614,20 +335,14 @@ internal static unsafe class GnmTiling
return pattern.Length != 0;
}
// The AddrLib within-block byte offset is a per-bit XOR equation:
// offset = OR over bits of ( parity(x & XMask) XOR parity(y & YMask) ) << bit
// Because parity distributes over XOR, that whole offset factors into two
// independent axis terms: PatternAxisTerm(x, useX: true) ^
// PatternAxisTerm(y, useX: false). Splitting the axes lets TryDetile cache
// the X term per column and hoist the Y term per row instead of recomputing
// the full 16-bit interleave (32 PopCounts) for every element.
private static uint PatternAxisTerm(uint coordinate, AddressBit[] pattern, bool useX)
private static long ComputePatternOffset(uint x, uint y, AddressBit[] pattern)
{
uint offset = 0;
for (var bit = 0; bit < pattern.Length; bit++)
{
var mask = useX ? pattern[bit].XMask : pattern[bit].YMask;
var parity = System.Numerics.BitOperations.PopCount(coordinate & mask) & 1;
var equation = pattern[bit];
var parity = (System.Numerics.BitOperations.PopCount(x & equation.XMask) +
System.Numerics.BitOperations.PopCount(y & equation.YMask)) & 1;
offset |= (uint)parity << bit;
}
+2 -220
View File
@@ -37,26 +37,10 @@ internal static class GpuWaitRegistry
public long RegisteredTicks;
public bool StaleReported;
public object? State;
// Latched by LatchSatisfiedByValue when a producer wrote a value that
// satisfies this waiter. The label is frequently reused (reset to 0 for
// the next frame) immediately after the producing write, so re-reading
// guest memory at wake time can miss the transient satisfied window.
// Latching records satisfaction at the moment of the write instead.
public bool Latched;
// Non-zero for indirect-dispatch dimension retries: a bounded deadline
// (Stopwatch ticks) after which the waiter is resumed even if unsatisfied,
// so a legitimately empty indirect dispatch can never stall forever.
public long RetryDeadlineTicks;
}
private static readonly object _gate = new();
private static readonly Dictionary<ulong, List<WaitingDcb>> _waiters = new();
// The last value each label producer wrote. Used only by the deadlock
// breaker: our serial submission parser cannot model two GPU queues running
// concurrently, so a label written -> reset -> re-waited across queues can
// cycle forever even though a real producer did signal it. Keyed by (memory,
// address) so distinct guest processes never alias.
private static readonly Dictionary<(object, ulong), ulong> _lastProduced = new();
public static int Count
{
@@ -130,14 +114,8 @@ internal static class GpuWaitRegistry
continue;
}
var satisfied = list[i].Latched;
if (!satisfied)
{
var value = readValue(address, list[i].Is64Bit);
satisfied = value is not null && Compare(list[i], value.Value);
}
if (!satisfied)
var value = readValue(address, list[i].Is64Bit);
if (value is null || !Compare(list[i], value.Value))
{
continue;
}
@@ -258,201 +236,6 @@ internal static class GpuWaitRegistry
return matches;
}
/// <summary>
/// Records satisfaction for every waiter at <paramref name="address"/> whose
/// condition is met by <paramref name="value"/> — the value a producer just
/// wrote to that label. Called from the ordered producer side effect so a
/// same-frame label reset cannot lose the wakeup. The waiters stay registered
/// (latched) and are drained by the next CollectSatisfied. Returns true when
/// at least one waiter latched, so the caller can trigger a wake pass.
/// </summary>
public static bool LatchSatisfiedByValue(object memory, ulong address, ulong value)
{
var latchedAny = false;
lock (_gate)
{
if (!_waiters.TryGetValue(address, out var list))
{
return false;
}
for (var i = 0; i < list.Count; i++)
{
var waiter = list[i];
if (waiter.Latched ||
!ReferenceEquals(waiter.Memory, memory) ||
!Compare(waiter, value))
{
continue;
}
waiter.Latched = true;
list[i] = waiter;
latchedAny = true;
}
}
return latchedAny;
}
/// <summary>
/// Removes and returns waiters carrying a <see cref="WaitingDcb.RetryDeadlineTicks"/>
/// that has elapsed. Used for indirect-dispatch dimension retries: the caller
/// resumes them so a genuinely empty dispatch (dims that never become non-zero)
/// is dropped after a bounded wait instead of stalling the queue forever.
/// </summary>
public static List<WaitingDcb>? CollectExpiredRetries(object memory, long nowTicks)
{
List<WaitingDcb>? expired = null;
lock (_gate)
{
List<ulong>? emptied = null;
foreach (var (address, list) in _waiters)
{
for (var i = list.Count - 1; i >= 0; i--)
{
var waiter = list[i];
if (waiter.RetryDeadlineTicks == 0 ||
!ReferenceEquals(waiter.Memory, memory) ||
nowTicks < waiter.RetryDeadlineTicks)
{
continue;
}
expired ??= new List<WaitingDcb>();
expired.Add(waiter);
list.RemoveAt(i);
}
if (list.Count == 0)
{
emptied ??= new List<ulong>();
emptied.Add(address);
}
}
if (emptied is not null)
{
foreach (var address in emptied)
{
_waiters.Remove(address);
}
}
}
return expired;
}
public static List<WaitingDcb>? CollectAllForMemory(object memory)
{
List<WaitingDcb>? collected = null;
lock (_gate)
{
List<ulong>? emptied = null;
foreach (var (address, list) in _waiters)
{
for (var index = list.Count - 1; index >= 0; index--)
{
if (!ReferenceEquals(list[index].Memory, memory))
{
continue;
}
collected ??= new List<WaitingDcb>();
collected.Add(list[index]);
list.RemoveAt(index);
}
if (list.Count == 0)
{
emptied ??= new List<ulong>();
emptied.Add(address);
}
}
if (emptied is not null)
{
foreach (var address in emptied)
{
_waiters.Remove(address);
}
}
}
return collected;
}
/// <summary>Records the value a label producer wrote, for the deadlock
/// breaker. Also latches any already-waiting waiter it satisfies.</summary>
public static bool RecordProduced(object memory, ulong address, ulong value)
{
lock (_gate)
{
if (_lastProduced.Count >= 8192)
{
_lastProduced.Clear();
}
_lastProduced[(memory, address)] = value;
}
return LatchSatisfiedByValue(memory, address, value);
}
/// <summary>
/// Breaks cross-queue GPU deadlocks the serial parser cannot avoid: returns
/// (and removes) waiters that have been stuck longer than
/// <paramref name="minAgeTicks"/> and whose condition is satisfied by the
/// last value a real producer wrote to their label — even though guest
/// memory has since been reset. Never fabricates a value: a waiter is only
/// released when an actual producer signalled it at least once.
/// </summary>
public static List<WaitingDcb>? CollectDeadlockBroken(
object memory,
long nowTicks,
long minAgeTicks)
{
List<WaitingDcb>? broken = null;
lock (_gate)
{
List<ulong>? emptied = null;
foreach (var (address, list) in _waiters)
{
for (var i = list.Count - 1; i >= 0; i--)
{
var waiter = list[i];
if (!ReferenceEquals(waiter.Memory, memory) ||
nowTicks - waiter.RegisteredTicks < minAgeTicks ||
!_lastProduced.TryGetValue((memory, address), out var produced) ||
!Compare(waiter, produced))
{
continue;
}
broken ??= new List<WaitingDcb>();
broken.Add(waiter);
list.RemoveAt(i);
}
if (list.Count == 0)
{
emptied ??= new List<ulong>();
emptied.Add(address);
}
}
if (emptied is not null)
{
foreach (var address in emptied)
{
_waiters.Remove(address);
}
}
}
return broken;
}
public static bool Compare(in WaitingDcb waiter, ulong value)
{
var masked = value & waiter.Mask;
@@ -477,7 +260,6 @@ internal static class GpuWaitRegistry
lock (_gate)
{
_waiters.Clear();
_lastProduced.Clear();
}
}
}
+17 -48
View File
@@ -6,7 +6,6 @@ using SharpEmu.Libs.Kernel;
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Concurrent;
using Microsoft.Win32.SafeHandles;
namespace SharpEmu.Libs.Ampr;
@@ -44,17 +43,17 @@ public static class AmprExports
{
public CachedHostFile(string path)
{
Handle = File.OpenHandle(
Stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete,
bufferSize: 1024 * 1024,
FileOptions.RandomAccess);
Length = RandomAccess.GetLength(Handle);
}
public SafeFileHandle Handle { get; }
public long Length { get; }
public object Gate { get; } = new();
public FileStream Stream { get; }
}
[SysAbiExport(
@@ -340,18 +339,6 @@ public static class AmprExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "4fgtGfXDrFc",
ExportName = "sceAmprMeasureCommandSizeWriteAddress_04_00",
Target = Generation.Gen5,
LibraryName = "libSceAmpr")]
public static int MeasureCommandSizeWriteAddress0400(CpuContext ctx)
{
TraceAmpr(ctx, "measure_write_address", 0, WriteAddressRecordSize, 0);
ctx[CpuRegister.Rax] = WriteAddressRecordSize;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "tZDDEo2tE5k",
ExportName = "sceAmprCommandBufferGetSize",
@@ -521,32 +508,6 @@ public static class AmprExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "j0+3uJMxYJY",
ExportName = "sceAmprCommandBufferWriteAddress_04_00",
Target = Generation.Gen5,
LibraryName = "libSceAmpr")]
public static int CommandBufferWriteAddress0400(CpuContext ctx)
{
var commandBuffer = ctx[CpuRegister.Rdi];
var address = ctx[CpuRegister.Rsi];
var value = ctx[CpuRegister.Rdx];
if (commandBuffer == 0 || address == 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
if (!AppendWriteAddressRecord(ctx, commandBuffer, address, value))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
TraceAmpr(ctx, "write_address", commandBuffer, address, value);
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
public static int CompleteCommandBuffer(CpuContext ctx, ulong commandBuffer)
{
if (commandBuffer == 0)
@@ -774,7 +735,13 @@ public static class AmprExports
return openResult;
}
if (fileOffset >= (ulong)cachedFile.Length)
long fileLength;
lock (cachedFile.Gate)
{
fileLength = cachedFile.Stream.Length;
}
if (fileOffset >= (ulong)fileLength)
{
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -793,10 +760,12 @@ public static class AmprExports
}
var request = (int)Math.Min((ulong)buffer.Length, size - bytesRead);
var read = RandomAccess.Read(
cachedFile.Handle,
buffer.AsSpan(0, request),
unchecked((long)absoluteOffset));
int read;
lock (cachedFile.Gate)
{
cachedFile.Stream.Position = unchecked((long)absoluteOffset);
read = cachedFile.Stream.Read(buffer, 0, request);
}
if (read <= 0)
{
@@ -121,33 +121,6 @@ public static class AppContentExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
// Download data is not emulated as a real quota; report a comfortable
// fixed amount of free space so titles never take the "storage full" path.
[SysAbiExport(
Nid = "Gl6w5i0JokY",
ExportName = "sceAppContentDownloadDataGetAvailableSpaceKb",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAppContent")]
public static int AppContentDownloadDataGetAvailableSpaceKb(CpuContext ctx)
{
const ulong availableSpaceKb = 1024UL * 1024UL; // 1 GiB
var availableSpaceAddress = ctx[CpuRegister.Rsi];
if (availableSpaceAddress == 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
Span<byte> spaceBytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(spaceBytes, availableSpaceKb);
if (!ctx.Memory.TryWrite(availableSpaceAddress, spaceBytes))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
private static bool TryReadUserDefinedParam(uint paramId, out int value)
{
value = 0;
+1 -1
View File
@@ -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 = 25;
private const uint MaxCodecType = 23;
private const int MaxInstanceIndex = 0x2FFF;
private static readonly ConcurrentDictionary<uint, AjmContextState> Contexts = new();
private static int _nextContextId;
+1 -88
View File
@@ -14,12 +14,6 @@ public static class AudioOutExports
private static readonly ConcurrentDictionary<int, PortState> Ports = new();
private static int _nextPortHandle;
// Diagnostic: confirm sceAudioOutOutput is actually called and whether the
// guest submits real samples or silence. Gated so it costs nothing when off.
private static readonly bool _traceOutput = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_AUDIO_OUT"), "1", StringComparison.Ordinal);
private static long _outputCount;
private sealed class PortState : IDisposable
{
private readonly object _paceGate = new();
@@ -161,37 +155,6 @@ public static class AudioOutExports
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "GrQ9s4IrNaQ",
ExportName = "sceAudioOutGetPortState",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAudioOut")]
public static int AudioOutGetPortState(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var stateAddress = ctx[CpuRegister.Rsi];
if (stateAddress == 0 || !Ports.TryGetValue(handle, out var port))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
// SceAudioOutPortState: report a connected primary output at full volume
// so pacing/mixing code sees a live port. We do no host rerouting, so
// rerouteCounter and flag stay zero.
Span<byte> state = stackalloc byte[16];
state.Clear();
System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(state, 1);
System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(
state[2..], (ushort)port.Channels);
state[7] = 127;
if (!ctx.Memory.TryWrite(stateAddress, state))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "QOQtbeDqsT4",
ExportName = "sceAudioOutOutput",
@@ -203,12 +166,7 @@ public static class AudioOutExports
var sourceAddress = ctx[CpuRegister.Rsi];
if (!Ports.TryGetValue(handle, out var port))
{
// Host shutdown disposes the ports while guest audio threads are
// still draining their last buffers; report success so the guest
// winds down without a per-buffer error (and its WARN log flood).
return ctx.SetReturn(_shutdown
? 0
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
if (sourceAddress == 0)
@@ -225,17 +183,6 @@ public static class AudioOutExports
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
if (_traceOutput)
{
var n = Interlocked.Increment(ref _outputCount);
if (n <= 8 || n % 200 == 0)
{
var peak = PeakAmplitude(source, port.IsFloat, port.BytesPerSample);
Console.Error.WriteLine(
$"[LOADER][TRACE] audioout.output#{n} handle={handle} bytes={source.Length} ch={port.Channels} float={port.IsFloat} vol={port.Volume:F2} peak={peak:F4} backend={(port.Backend is null ? "none" : "coreaudio")}");
}
}
if (port.Backend is null)
{
port.PaceSilence();
@@ -319,40 +266,8 @@ public static class AudioOutExports
return ctx.SetReturn(0);
}
// Peak normalized amplitude [0,1] of an interleaved PCM buffer, used only by
// the SHARPEMU_LOG_AUDIO_OUT diagnostic to distinguish real audio from silence.
private static float PeakAmplitude(ReadOnlySpan<byte> source, bool isFloat, int bytesPerSample)
{
var peak = 0f;
if (isFloat && bytesPerSample == 4)
{
for (var i = 0; i + 4 <= source.Length; i += 4)
{
var v = Math.Abs(System.Buffers.Binary.BinaryPrimitives.ReadSingleLittleEndian(source.Slice(i, 4)));
if (v > peak)
{
peak = v;
}
}
}
else if (bytesPerSample == 2)
{
for (var i = 0; i + 2 <= source.Length; i += 2)
{
var v = Math.Abs(System.Buffers.Binary.BinaryPrimitives.ReadInt16LittleEndian(source.Slice(i, 2)) / 32768f);
if (v > peak)
{
peak = v;
}
}
}
return peak;
}
public static void ShutdownAllPorts()
{
Volatile.Write(ref _shutdown, true);
foreach (var handle in Ports.Keys)
{
if (Ports.TryRemove(handle, out var port))
@@ -362,8 +277,6 @@ public static class AudioOutExports
}
}
private static bool _shutdown;
private static bool TryGetFormat(
int rawFormat,
out int channels,
@@ -25,10 +25,6 @@ internal static class AudioPcmConversion
float volume)
{
var sourceFrameSize = checked(channels * bytesPerSample);
// Volume is constant for the whole submission, so clamp it once here
// rather than per sample inside the loop (this runs on every real-time
// audio buffer, hundreds of frames at a time).
var clampedVolume = Math.Clamp(volume, 0.0f, 1.0f);
for (var frame = 0; frame < frames; frame++)
{
var sourceFrame = source.Slice(frame * sourceFrameSize, sourceFrameSize);
@@ -36,8 +32,8 @@ internal static class AudioPcmConversion
var right = channels == 1
? left
: ReadSample(sourceFrame, 1, bytesPerSample, isFloat);
left = ApplyVolume(left, clampedVolume);
right = ApplyVolume(right, clampedVolume);
left = ApplyVolume(left, volume);
right = ApplyVolume(right, volume);
BinaryPrimitives.WriteInt16LittleEndian(destination[(frame * OutputFrameSize)..], left);
BinaryPrimitives.WriteInt16LittleEndian(destination[((frame * OutputFrameSize) + 2)..], right);
}
@@ -71,10 +67,9 @@ internal static class AudioPcmConversion
return checked((short)MathF.Round(value * scale));
}
// <paramref name="volume"/> is expected pre-clamped to [0, 1] by the caller.
private static short ApplyVolume(short sample, float volume)
{
var scaled = MathF.Round(sample * volume);
var scaled = MathF.Round(sample * Math.Clamp(volume, 0.0f, 1.0f));
return (short)Math.Clamp(scaled, short.MinValue, short.MaxValue);
}
}
@@ -1,154 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
namespace SharpEmu.Libs.Audio;
// PS5 acoustic-propagation (3D-audio ray/portal/room) module. We do not model
// acoustic propagation; the geometry-driven reverb/occlusion it produces is a
// quality feature, not a correctness gate. Games (e.g. Astro Bot) call it
// during audio init and hard-assert if any entry point is missing:
// ASSERT ... sceAudioPropagationSystemQueryMemory failed : 0x80020002
// The API is placement-style: QueryMemory reports a buffer size, the game
// allocates it, and the "system"/objects live inside that caller-owned buffer,
// so success-returning stubs let init proceed without us owning any state.
public static class AudioPropagationExports
{
private const int Ok = 0;
// QueryMemory reports the working-set size the caller must allocate before
// SystemCreate. rsi points at the out size/alignment; write a modest,
// aligned block so the caller's allocation succeeds.
[SysAbiExport(
Nid = "7xyAxrusLko",
ExportName = "sceAudioPropagationSystemQueryMemory",
Target = Generation.Gen5,
LibraryName = "libSceAudioPropagation")]
public static int SystemQueryMemory(CpuContext ctx)
{
var outAddress = ctx[CpuRegister.Rsi];
if (outAddress != 0)
{
// {size, alignment} — 1 MiB / 256 B covers the caller's allocation.
ctx.TryWriteUInt64(outAddress, 0x10_0000);
ctx.TryWriteUInt64(outAddress + sizeof(ulong), 0x100);
}
return ctx.SetReturn(Ok);
}
[SysAbiExport(Nid = "GrA9ke1QT+E", ExportName = "sceAudioPropagationSystemQueryInfo", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemQueryInfo(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "aNEqtSHdUSo", ExportName = "sceAudioPropagationSystemCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemCreate(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "x5VPqg5iyAk", ExportName = "sceAudioPropagationSystemDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemDestroy(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "ile38Gl-p5M", ExportName = "sceAudioPropagationSystem", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int System(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "cMl3u+7QBBM", ExportName = "sceAudioPropagationSystemMemoryInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemMemoryInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "3B9IabLByyM", ExportName = "sceAudioPropagationSystemOptionInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemOptionInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "B2KI2AachWE", ExportName = "sceAudioPropagationSystemLock", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemLock(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "kIdb+iQUzCs", ExportName = "sceAudioPropagationSystemSetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemSetAttributes(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "VlBT16890mA", ExportName = "sceAudioPropagationSystemSetRays", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemSetRays(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "ht-QXT3zGxo", ExportName = "sceAudioPropagationSystemGetRays", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemGetRays(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "CPLV6G-eXmk", ExportName = "sceAudioPropagationSystemRegisterMaterial", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemRegisterMaterial(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "XKCN4gpeYsM", ExportName = "sceAudioPropagationSystemUnregisterMaterial", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemUnregisterMaterial(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "8bI5h8req30", ExportName = "sceAudioPropagationRoomCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int RoomCreate(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "S0JwP2AFTTE", ExportName = "sceAudioPropagationRoomDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int RoomDestroy(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "b-dYXrjSNZU", ExportName = "sceAudioPropagationPortalCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int PortalCreate(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "ZQXE-xS6MTE", ExportName = "sceAudioPropagationPortalDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int PortalDestroy(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "WXMhENV2NcA", ExportName = "sceAudioPropagationPortalSetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int PortalSetAttributes(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "i687TNRF+hw", ExportName = "sceAudioPropagationPortalSettingsInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int PortalSettingsInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "d84otraxt2s", ExportName = "sceAudioPropagationSourceCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceCreate(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "wkseM3LWPuc", ExportName = "sceAudioPropagationSourceDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceDestroy(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "-wsUTr31yeg", ExportName = "sceAudioPropagationSourceSetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceSetAttributes(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "PBcrVpEqUVY", ExportName = "sceAudioPropagationSourceCalculateAudioPaths", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceCalculateAudioPaths(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "eEeKqFeNI3o", ExportName = "sceAudioPropagationSourceGetAudioPath", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceGetAudioPath(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "G+QLTfyLMYk", ExportName = "sceAudioPropagationSourceGetAudioPathCount", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceGetAudioPathCount(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "aKJZx7wCma8", ExportName = "sceAudioPropagationSourceGetRays", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceGetRays(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "3aEY9tPXGKc", ExportName = "sceAudioPropagationSourceQueryInfo", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceQueryInfo(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "hhz9pITnC8k", ExportName = "sceAudioPropagationSourceRender", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceRender(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "SoKPzY1-3SU", ExportName = "sceAudioPropagationSourceRenderInfoInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceRenderInfoInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "tKSmk2JsMAA", ExportName = "sceAudioPropagationSourceSetAudioPath", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceSetAudioPath(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "5vzOS2pHMFc", ExportName = "sceAudioPropagationSourceSetAudioPaths", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceSetAudioPaths(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "MNmGapXrYRs", ExportName = "sceAudioPropagationSourceSetAudioPathsParamInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceSetAudioPathsParamInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "i-0aUex3zCE", ExportName = "sceAudioPropagationAudioPathInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int AudioPathInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "JZIkSbmt2BE", ExportName = "sceAudioPropagationAudioPathPointInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int AudioPathPointInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "tL2AEPejVQE", ExportName = "sceAudioPropagationPathGetNumPoints", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int PathGetNumPoints(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "2BSFmuKtRss", ExportName = "sceAudioPropagationMaterialInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int MaterialInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "0r2+9UTg1BA", ExportName = "sceAudioPropagationRayInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int RayInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "BbOT4vBwAjs", ExportName = "sceAudioPropagationResetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int ResetAttributes(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "gCmQm6dvMxw", ExportName = "sceAudioPropagationReportApi", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int ReportApi(CpuContext ctx) => ctx.SetReturn(Ok);
}
+11 -107
View File
@@ -17,9 +17,7 @@ public static class AvPlayerExports
private const int FrameBufferCount = 3;
private const int FrameInfoSize = 40;
private const int FrameInfoExSize = 104;
// This structure is 32 bytes. A larger write can damage the guest stack.
private const int StreamInfoSize = 32;
private const int StreamInfoExSize = 32;
private const int StreamInfoSize = 40;
private const int MaxGuestPathLength = 4096;
private static readonly object StateGate = new();
private static readonly Dictionary<ulong, PlayerState> Players = new();
@@ -406,8 +404,7 @@ public static class AvPlayerExports
ExportName = "sceAvPlayerGetStreamInfoEx",
Target = Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerGetStreamInfoEx(CpuContext ctx) =>
GetStreamInfoCore(ctx, StreamInfoExSize);
public static int AvPlayerSetDecoderMode(CpuContext ctx) => ValidatePlayer(ctx);
[SysAbiExport(
Nid = "XC9wM+xULz8",
@@ -564,48 +561,12 @@ public static class AvPlayerExports
}
}
internal static void RegisterPlayerForTest(
ulong handle,
int width,
int height,
ulong durationMilliseconds)
{
PlayerState? previous;
lock (StateGate)
{
Players.Remove(handle, out previous);
Players[handle] = new PlayerState
{
Handle = handle,
Width = width,
Height = height,
DurationMilliseconds = durationMilliseconds,
};
}
previous?.Dispose();
}
internal static void RemovePlayerForTest(ulong handle)
{
PlayerState? player;
lock (StateGate)
{
Players.Remove(handle, out player);
}
player?.Dispose();
}
[SysAbiExport(
Nid = "d8FcbzfAdQw",
ExportName = "sceAvPlayerGetStreamInfo",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerGetStreamInfo(CpuContext ctx) =>
GetStreamInfoCore(ctx, StreamInfoSize);
private static int GetStreamInfoCore(CpuContext ctx, int infoSize)
public static int AvPlayerGetStreamInfo(CpuContext ctx)
{
var streamIndex = unchecked((uint)ctx[CpuRegister.Rsi]);
var infoAddress = ctx[CpuRegister.Rdx];
@@ -617,7 +578,7 @@ public static class AvPlayerExports
return SetReturn(ctx, InvalidParameters);
}
Span<byte> info = stackalloc byte[infoSize];
Span<byte> info = stackalloc byte[StreamInfoSize];
info.Clear();
BinaryPrimitives.WriteUInt32LittleEndian(info[0..], streamIndex); // 0=video, 1=audio
if (streamIndex == 0)
@@ -1048,7 +1009,7 @@ public static class AvPlayerExports
{
return false;
}
var ffprobe = GetFfprobePath(ffmpeg, OperatingSystem.IsWindows());
var ffprobe = Path.Combine(Path.GetDirectoryName(ffmpeg) ?? string.Empty, "ffprobe");
if (!File.Exists(ffprobe))
{
return false;
@@ -1131,50 +1092,13 @@ public static class AvPlayerExports
}
}
internal static string? FindFfmpeg() =>
FindFfmpeg(
Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH"),
Environment.GetEnvironmentVariable("PATH"),
OperatingSystem.IsWindows(),
AppContext.BaseDirectory);
internal static string? FindFfmpeg(
string? configured,
string? searchPath,
bool isWindows,
string? baseDirectory = null)
private static string? FindFfmpeg()
{
var configured = Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH");
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
{
return configured;
}
var executable = isWindows ? "ffmpeg.exe" : "ffmpeg";
if (!string.IsNullOrWhiteSpace(baseDirectory))
{
foreach (var candidate in new[]
{
Path.Combine(baseDirectory, executable),
Path.Combine(baseDirectory, "ffmpeg", executable),
})
{
if (File.Exists(candidate))
{
return candidate;
}
}
}
foreach (var directory in (searchPath ?? string.Empty)
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
var candidate = Path.Combine(RemovePathQuotes(directory), executable);
if (File.Exists(candidate))
{
return candidate;
}
}
foreach (var candidate in new[] { "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg" })
{
if (File.Exists(candidate))
@@ -1185,16 +1109,6 @@ public static class AvPlayerExports
return null;
}
internal static string GetFfprobePath(string ffmpeg, bool isWindows) =>
Path.Combine(
Path.GetDirectoryName(ffmpeg) ?? string.Empty,
isWindows ? "ffprobe.exe" : "ffprobe");
private static string RemovePathQuotes(string directory) =>
directory.Length >= 2 && directory[0] == '"' && directory[^1] == '"'
? directory[1..^1]
: directory;
internal static string? ResolveGuestPath(string guestPath)
{
if (string.IsNullOrWhiteSpace(guestPath))
@@ -1204,9 +1118,7 @@ public static class AvPlayerExports
var normalized = guestPath.Replace('\\', '/');
var fileReference = normalized.StartsWith("file:", StringComparison.OrdinalIgnoreCase);
var unrealProjectRelative =
normalized.StartsWith("../", StringComparison.Ordinal) ||
normalized.StartsWith("./", StringComparison.Ordinal);
var unrealProjectRelative = false;
if (normalized.StartsWith("file://", StringComparison.OrdinalIgnoreCase) &&
Uri.TryCreate(normalized, UriKind.Absolute, out var uri) &&
uri.IsFile)
@@ -1237,10 +1149,7 @@ public static class AvPlayerExports
if (unrealProjectRelative)
{
if (!TryRemoveUnrealLeadingDotSegments(normalized, out normalized))
{
return null;
}
normalized = RemoveUnrealLeadingDotSegments(normalized);
}
var app0 = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
@@ -1324,20 +1233,15 @@ public static class AvPlayerExports
}
}
private static bool TryRemoveUnrealLeadingDotSegments(
string guestPath,
out string normalized)
private static string RemoveUnrealLeadingDotSegments(string guestPath)
{
var removedParent = false;
while (guestPath.StartsWith("../", StringComparison.Ordinal) ||
guestPath.StartsWith("./", StringComparison.Ordinal))
{
removedParent |= guestPath.StartsWith("../", StringComparison.Ordinal);
guestPath = guestPath[(guestPath.IndexOf('/') + 1)..];
}
normalized = guestPath;
return !removedParent || guestPath.Contains('/');
return guestPath;
}
private static bool TryDecodeFileReference(string encoded, out string decoded)
+194 -416
View File
@@ -18,153 +18,123 @@ namespace SharpEmu.Libs.Bink;
internal static class Bink2MovieBridge
{
private const uint MaxDimension = 16384;
private const uint MaxHostVideoWidth = 1920;
private const uint MaxHostVideoHeight = 1080;
private static readonly object Gate = new();
private static NativeAdapter? _adapter;
private static string? _activePath;
private static IntPtr _activeMovie;
private static Bink2MovieInfo _activeInfo;
private static byte[]? _frameBuffer;
private static bool _frameBufferPresented;
private static BinkFramePlayback? _playback;
private static long _frameSerial;
private static uint _presentationWidth = MaxHostVideoWidth;
private static uint _presentationHeight = MaxHostVideoHeight;
private static bool _usingDummyMovie;
private static bool _loadAttempted;
private static bool _availabilityReported;
internal static bool IsHostPlaybackActive
{
get
{
lock (Gate)
{
return _playback is not null || _frameBuffer is not null;
}
}
}
/// <summary>
/// Returns true when the guest should receive a normal "file not found"
/// result for a Bink movie. This is the safe default without a decoder:
/// games that treat movies as optional fall through to their next state
/// rather than submitting an empty Bink GPU texture forever.
/// </summary>
internal static bool ShouldSkipGuestMovie(string hostPath) =>
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
ResolveMode() == MovieMode.Skip;
internal static void SetPresentationSize(uint width, uint height)
internal static void ObserveGuestMovie(string hostPath)
{
if (width == 0 || height == 0)
if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
!File.Exists(hostPath))
{
return;
}
lock (Gate)
{
_presentationWidth = Math.Min(width, MaxHostVideoWidth);
_presentationHeight = Math.Min(height, MaxHostVideoHeight);
}
}
/// <summary>
/// Returns true only when movie skipping was explicitly requested. Without
/// a host adapter the guest must be allowed to run the Bink implementation
/// statically linked into its executable.
/// </summary>
internal static bool ShouldSkipGuestMovie(string hostPath) =>
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
ResolveMode() == MovieMode.Skip;
/// <summary>
/// Starts or queues host decoding. Decoded frames are only exposed as a
/// sampled guest texture; presentation and UI composition remain guest-owned.
/// </summary>
internal static bool ObserveGuestMovie(string hostPath)
{
if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
!File.Exists(hostPath))
{
return false;
}
lock (Gate)
{
if (string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
{
return _playback is not null || _frameBuffer is not null;
return;
}
var mode = ResolveMode();
if (mode is MovieMode.Guest or MovieMode.Skip)
if (ResolveMode() == MovieMode.Dummy)
{
return false;
AttachDummyMovieLocked(hostPath);
return;
}
if (_playback is not null || _frameBuffer is not null)
var adapter = GetAdapterLocked();
if (adapter is null)
{
if (PendingMoviePathSet.Add(hostPath))
{
PendingMoviePaths.Enqueue(hostPath);
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge queued: " +
Path.GetFileName(hostPath));
}
return PendingMoviePathSet.Contains(hostPath);
return;
}
AttachMovieLocked(hostPath, mode);
return string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase) &&
(_playback is not null || _frameBuffer is not null);
CloseActiveLocked();
if (!adapter.TryOpen(hostPath, out var movie, out var info))
{
Console.Error.WriteLine(
"[LOADER][WARN] Bink2 bridge could not open movie '" +
Path.GetFileName(hostPath) + "'.");
return;
}
if (!IsValid(info))
{
adapter.Close(movie);
Console.Error.WriteLine(
"[LOADER][WARN] Bink2 bridge rejected invalid movie dimensions for '" +
Path.GetFileName(hostPath) + "'.");
return;
}
_activePath = hostPath;
_activeMovie = movie;
_activeInfo = info;
_frameBuffer = GC.AllocateUninitializedArray<byte>(GetFrameBufferLength(info));
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge attached: " + Path.GetFileName(hostPath) + " " +
info.Width + "x" + info.Height + " @ " +
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
}
}
internal static bool TryDecodeNextFrame(
bool advanceClock,
out byte[] pixels,
out uint width,
out uint height,
out bool advanced,
out long frameSerial,
out string hostPath)
out uint height)
{
lock (Gate)
{
pixels = [];
width = 0;
height = 0;
advanced = false;
frameSerial = _frameSerial;
hostPath = _activePath ?? string.Empty;
if (_playback is not null)
if (_adapter is null || _activeMovie == IntPtr.Zero || _frameBuffer is null)
{
if (!_playback.TryGetFrame(advanceClock, out pixels, out advanced))
if (_usingDummyMovie && _frameBuffer is not null)
{
if (_playback.IsFinished)
{
var completedPath = _activePath;
CloseActiveLocked();
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge completed: " +
Path.GetFileName(completedPath));
AttachNextQueuedMovieLocked();
}
return false;
pixels = _frameBuffer;
width = _activeInfo.Width;
height = _activeInfo.Height;
return true;
}
width = _activeInfo.Width;
height = _activeInfo.Height;
if (advanced)
{
frameSerial = ++_frameSerial;
}
return true;
return false;
}
if (_frameBuffer is null)
unsafe
{
return false;
fixed (byte* destination = _frameBuffer)
{
if (!_adapter.DecodeNextBgra(
_activeMovie,
(IntPtr)destination,
_activeInfo.Width * 4,
(uint)_frameBuffer.Length))
{
return false;
}
}
}
pixels = _frameBuffer;
width = _activeInfo.Width;
height = _activeInfo.Height;
advanced = !_frameBufferPresented;
_frameBufferPresented = true;
if (advanced)
{
frameSerial = ++_frameSerial;
}
return true;
}
}
@@ -177,52 +147,6 @@ internal static class Bink2MovieBridge
private static int GetFrameBufferLength(Bink2MovieInfo info) =>
checked((int)((ulong)info.Width * info.Height * 4));
private static void AttachMovieLocked(string hostPath, MovieMode mode)
{
switch (mode)
{
case MovieMode.Dummy:
AttachDummyMovieLocked(hostPath);
return;
case MovieMode.Ffmpeg:
AttachFfmpegMovieLocked(hostPath);
return;
case MovieMode.Native:
AttachNativeMovieLocked(hostPath);
return;
}
}
private static void AttachNativeMovieLocked(string hostPath)
{
if (!FfmpegNativeBinkFrameSource.TryOpen(
hostPath, _presentationWidth, _presentationHeight, out var source) ||
source is null)
{
Console.Error.WriteLine(
"[LOADER][WARN] Bink2 bridge could not open movie '" +
Path.GetFileName(hostPath) + "'.");
return;
}
var info = new Bink2MovieInfo(
source.Width, source.Height, source.FramesPerSecondNumerator, source.FramesPerSecondDenominator);
if (!IsValid(info))
{
source.Dispose();
Console.Error.WriteLine(
"[LOADER][WARN] Bink2 bridge rejected invalid movie dimensions for '" +
Path.GetFileName(hostPath) + "'.");
return;
}
AttachPlaybackLocked(hostPath, info, source);
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge attached: " + Path.GetFileName(hostPath) + " " +
info.Width + "x" + info.Height + " @ " +
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
}
private static MovieMode ResolveMode()
{
var configured = Environment.GetEnvironmentVariable("SHARPEMU_BINK_MODE");
@@ -241,22 +165,16 @@ internal static class Bink2MovieBridge
return MovieMode.Skip;
}
if (string.Equals(configured, "guest", StringComparison.OrdinalIgnoreCase))
// With no SDK adapter present, returning "not found" makes optional
// cinematics advance. Supplying either an explicit path or the normal
// side-by-side adapter enables native playback automatically.
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE")) ||
EnumerateAdapterCandidates().Any(File.Exists))
{
return MovieMode.Guest;
return MovieMode.Native;
}
if (string.Equals(configured, "ffmpeg", StringComparison.OrdinalIgnoreCase))
{
return MovieMode.Ffmpeg;
}
// Native is the default: FfmpegNativeBinkFrameSource.TryOpen degrades
// gracefully (falls back to the guest's own decode, logging one
// informational line) if the FFmpeg libraries SharpEmu.CLI.csproj
// downloads next to the executable are genuinely unavailable, so
// defaulting to Native unconditionally is safe.
return MovieMode.Native;
return MovieMode.Skip;
}
private static void AttachDummyMovieLocked(string hostPath)
@@ -273,61 +191,22 @@ internal static class Bink2MovieBridge
_activePath = hostPath;
_activeInfo = info;
_frameBuffer = GC.AllocateUninitializedArray<byte>(GetFrameBufferLength(info));
_frameBufferPresented = false;
FillDummyFrame(_frameBuffer, info.Width, info.Height);
_usingDummyMovie = true;
Console.Error.WriteLine(
"[LOADER][INFO] Bink dummy attached: " + Path.GetFileName(hostPath) + " " +
info.Width + "x" + info.Height + ".");
}
private static void AttachFfmpegMovieLocked(string hostPath)
{
if (!TryReadBinkInfo(hostPath, out var info) || !IsValid(info))
{
Console.Error.WriteLine(
"[LOADER][WARN] Bink FFmpeg source has an invalid header: " +
Path.GetFileName(hostPath));
return;
}
if (!FfmpegBinkFrameSource.TryOpen(
hostPath,
info.Width,
info.Height,
info.FramesPerSecondNumerator,
info.FramesPerSecondDenominator,
out var source) || source is null)
{
return;
}
AttachPlaybackLocked(hostPath, info, source);
Console.Error.WriteLine(
"[LOADER][INFO] Bink FFmpeg source attached: " +
Path.GetFileName(hostPath) + " " + info.Width + "x" + info.Height + " @ " +
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
}
private static void AttachPlaybackLocked(
string hostPath,
Bink2MovieInfo info,
IBinkFrameDecoder decoder)
{
CloseActiveLocked();
_activePath = hostPath;
_activeInfo = info;
_playback = new BinkFramePlayback(decoder);
}
internal static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
private static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
{
info = default;
Span<byte> header = stackalloc byte[36];
Span<byte> header = stackalloc byte[32];
try
{
using var stream = File.OpenRead(path);
stream.ReadExactly(header);
if (!header[..3].SequenceEqual("KB2"u8))
if (stream.Read(header) != header.Length ||
!header[..4].SequenceEqual("KB2j"u8))
{
return false;
}
@@ -336,11 +215,10 @@ internal static class Bink2MovieBridge
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x14, 4)),
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x18, 4)),
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x1C, 4)),
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x20, 4)));
return info.FramesPerSecondNumerator != 0 &&
info.FramesPerSecondDenominator != 0;
1);
return true;
}
catch (Exception exception) when (exception is IOException or EndOfStreamException)
catch (IOException)
{
return false;
}
@@ -362,23 +240,80 @@ internal static class Bink2MovieBridge
}
}
private static NativeAdapter? GetAdapterLocked()
{
if (_loadAttempted)
{
return _adapter;
}
_loadAttempted = true;
foreach (var candidate in EnumerateAdapterCandidates())
{
if (!NativeLibrary.TryLoad(candidate, out var library))
{
continue;
}
if (NativeAdapter.TryCreate(library, out var adapter))
{
_adapter = adapter;
Console.Error.WriteLine("[LOADER][INFO] Bink2 bridge loaded: " + candidate);
return adapter;
}
NativeLibrary.Free(library);
}
if (!_availabilityReported)
{
_availabilityReported = true;
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge unavailable; install the licensed adapter and set SHARPEMU_BINK2_BRIDGE.");
}
return null;
}
private static IEnumerable<string> EnumerateAdapterCandidates()
{
var configured = Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE");
if (!string.IsNullOrWhiteSpace(configured))
{
yield return configured;
}
var baseDirectory = AppContext.BaseDirectory;
if (OperatingSystem.IsMacOS())
{
yield return Path.Combine(baseDirectory, "libsharpemu_bink2_bridge.dylib");
}
else if (OperatingSystem.IsWindows())
{
yield return Path.Combine(baseDirectory, "sharpemu_bink2_bridge.dll");
}
else
{
yield return Path.Combine(baseDirectory, "libsharpemu_bink2_bridge.so");
}
}
private static void CloseActiveLocked()
{
_playback?.Dispose();
_playback = null;
if (_activeMovie != IntPtr.Zero)
{
_adapter?.Close(_activeMovie);
}
_activePath = null;
_activeMovie = IntPtr.Zero;
_activeInfo = default;
_frameBuffer = null;
_frameBufferPresented = false;
// Wake any guest _read() blocked in WaitForHostPlaybackToFinish: its
// movie either just finished or is being pre-empted by a new attach.
Monitor.PulseAll(Gate);
_usingDummyMovie = false;
}
[StructLayout(LayoutKind.Sequential)]
internal readonly struct Bink2MovieInfo
private readonly struct Bink2MovieInfo
{
public readonly uint Width;
public readonly uint Height;
@@ -400,226 +335,69 @@ internal static class Bink2MovieBridge
private enum MovieMode
{
Guest,
Skip,
Dummy,
Native,
Ffmpeg,
}
private static readonly Queue<string> PendingMoviePaths = new();
private static readonly HashSet<string> PendingMoviePathSet =
new(StringComparer.OrdinalIgnoreCase);
private static void AttachNextQueuedMovieLocked()
private sealed class NativeAdapter
{
while (PendingMoviePaths.Count > 0)
{
var path = PendingMoviePaths.Dequeue();
PendingMoviePathSet.Remove(path);
if (!File.Exists(path))
{
continue;
}
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int OpenUtf8Delegate(IntPtr pathUtf8, out IntPtr movie, out Bink2MovieInfo info);
AttachMovieLocked(path, ResolveMode());
if (_playback is not null || _frameBuffer is not null)
{
return;
}
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int DecodeNextBgraDelegate(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void CloseDelegate(IntPtr movie);
private readonly OpenUtf8Delegate _openUtf8;
private readonly DecodeNextBgraDelegate _decodeNextBgra;
private readonly CloseDelegate _close;
private NativeAdapter(
OpenUtf8Delegate openUtf8,
DecodeNextBgraDelegate decodeNextBgra,
CloseDelegate close)
{
_openUtf8 = openUtf8;
_decodeNextBgra = decodeNextBgra;
_close = close;
}
}
// Longest a guest _read() will block waiting for real host playback to
// finish. A safety net, not a target: real movies finish well under
// this. Bounds the damage if a movie fails to attach/decode after being
// queued, so the guest thread doesn't hang forever.
private const long MaxCompletionWaitMilliseconds = 5 * 60 * 1000;
/// <summary>
/// Blocks the calling (guest I/O) thread until the host has actually
/// finished presenting <paramref name="hostPath"/> — either because it
/// played through, or because something else took over the timeline.
///
/// The completion shim tells the guest's own Bink header parse "this
/// movie is one frame and already done" so its native decoder never
/// blocks the guest on real per-frame work. Without this wait, that lie
/// lands the instant the guest reads the header, so guest-side game
/// logic races far ahead of whatever the host is still showing on
/// screen: pressing a button lands on the (already-advanced) guest
/// state, but the video visibly keeps playing, and any real-time-gated
/// trigger later in the guest's own flow can fire against a clock that
/// no longer matches wall time. Gating the "done" read on real host
/// completion keeps guest pacing and on-screen playback in lockstep.
/// </summary>
internal static void WaitForHostPlaybackToFinish(string hostPath)
{
var deadline = Environment.TickCount64 + MaxCompletionWaitMilliseconds;
lock (Gate)
internal static bool TryCreate(IntPtr library, out NativeAdapter? adapter)
{
while (IsTrackedLocked(hostPath))
{
var remaining = deadline - Environment.TickCount64;
if (remaining <= 0)
{
Console.Error.WriteLine(
"[LOADER][WARN] Bink2 bridge completion wait timed out for '" +
Path.GetFileName(hostPath) + "'.");
return;
}
Monitor.Wait(Gate, (int)Math.Min(remaining, 200));
}
}
}
private static bool IsTrackedLocked(string hostPath) =>
string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase) ||
PendingMoviePathSet.Contains(hostPath);
internal static bool TryTakeOverGuestMovie(
string hostPath,
out BinkGuestCompletionShim completionShim,
out bool observed)
{
completionShim = default;
observed = ObserveGuestMovie(hostPath);
// Keep the real header visible so the guest creates its movie surface
// and draw. Host-decoded pixels replace that sampled image later; a
// one-frame completion shim would finish before the descriptor exists.
return false;
}
internal static void NotifyGuestMovieClosed(string hostPath)
{
lock (Gate)
{
if (PendingMoviePathSet.Remove(hostPath))
{
var retained = PendingMoviePaths
.Where(path => !string.Equals(
path,
hostPath,
StringComparison.OrdinalIgnoreCase))
.ToArray();
PendingMoviePaths.Clear();
foreach (var path in retained)
{
PendingMoviePaths.Enqueue(path);
}
}
if (!string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
{
Monitor.PulseAll(Gate);
return;
}
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge stopped by guest close: " +
Path.GetFileName(hostPath));
CloseActiveLocked();
AttachNextQueuedMovieLocked();
}
}
internal static bool TryReadGuestCompletionShim(
string hostPath,
out BinkGuestCompletionShim completionShim)
{
completionShim = default;
Span<byte> header = stackalloc byte[48];
try
{
using var stream = File.OpenRead(hostPath);
stream.ReadExactly(header);
if (!header[..3].SequenceEqual("KB2"u8))
adapter = null;
if (!NativeLibrary.TryGetExport(library, "sharpemu_bink2_open_utf8", out var open) ||
!NativeLibrary.TryGetExport(library, "sharpemu_bink2_decode_next_bgra", out var decode) ||
!NativeLibrary.TryGetExport(library, "sharpemu_bink2_close", out var close))
{
return false;
}
var frameCount = BinaryPrimitives.ReadUInt32LittleEndian(header[8..12]);
var audioTrackCount = BinaryPrimitives.ReadUInt32LittleEndian(header[40..44]);
if (frameCount < 2 || audioTrackCount > 256)
{
return false;
}
var revision = header[3];
var frameIndexOffset = 44L + checked(12L * audioTrackCount);
if (revision == (byte)'m')
{
frameIndexOffset += 16;
}
else if (revision is (byte)'i' or (byte)'j' or (byte)'k' or (byte)'n')
{
frameIndexOffset += 4;
}
Span<byte> frameOffsets = stackalloc byte[8];
stream.Position = frameIndexOffset;
stream.ReadExactly(frameOffsets);
var firstFrameOffset = BinaryPrimitives.ReadUInt32LittleEndian(frameOffsets[..4]) & ~1u;
var secondFrameOffset = BinaryPrimitives.ReadUInt32LittleEndian(frameOffsets[4..]) & ~1u;
if (firstFrameOffset < frameIndexOffset + 8 ||
secondFrameOffset <= firstFrameOffset ||
secondFrameOffset > stream.Length)
{
return false;
}
completionShim = new BinkGuestCompletionShim(
secondFrameOffset - 8,
secondFrameOffset - firstFrameOffset);
adapter = new NativeAdapter(
Marshal.GetDelegateForFunctionPointer<OpenUtf8Delegate>(open),
Marshal.GetDelegateForFunctionPointer<DecodeNextBgraDelegate>(decode),
Marshal.GetDelegateForFunctionPointer<CloseDelegate>(close));
return true;
}
catch (Exception exception) when (
exception is IOException or EndOfStreamException or OverflowException)
{
return false;
}
}
internal readonly struct BinkGuestCompletionShim
{
private readonly uint _fileSizeMinusHeader;
private readonly uint _largestFrameSize;
internal BinkGuestCompletionShim(uint fileSizeMinusHeader, uint largestFrameSize)
internal bool TryOpen(string path, out IntPtr movie, out Bink2MovieInfo info)
{
_fileSizeMinusHeader = fileSizeMinusHeader;
_largestFrameSize = largestFrameSize;
}
/// <summary>
/// Rewrites the frame-count/size fields the guest's own Bink header
/// parse reads, if this read covers them. Returns true when the
/// NumFrames field (the field that tells the guest "this movie is
/// done") was in range, so the caller can gate that specific read on
/// the host's real playback actually finishing first.
/// </summary>
internal bool Patch(long fileOffset, Span<byte> bytes)
{
PatchUInt32(fileOffset, bytes, 4, _fileSizeMinusHeader);
var touchedCompletionField = PatchUInt32(fileOffset, bytes, 8, 1);
PatchUInt32(fileOffset, bytes, 12, _largestFrameSize);
return touchedCompletionField;
}
private static bool PatchUInt32(
long fileOffset,
Span<byte> bytes,
long fieldOffset,
uint value)
{
var relativeOffset = fieldOffset - fileOffset;
if (relativeOffset < 0 || relativeOffset + sizeof(uint) > bytes.Length)
var utf8 = Marshal.StringToCoTaskMemUTF8(path);
try
{
return false;
return _openUtf8(utf8, out movie, out info) != 0 && movie != IntPtr.Zero;
}
finally
{
Marshal.FreeCoTaskMem(utf8);
}
BinaryPrimitives.WriteUInt32LittleEndian(
bytes.Slice((int)relativeOffset, sizeof(uint)),
value);
return true;
}
internal bool DecodeNextBgra(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes) =>
_decodeNextBgra(movie, destination, stride, destinationBytes) != 0;
internal void Close(IntPtr movie) => _close(movie);
}
}
-246
View File
@@ -1,246 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
namespace SharpEmu.Libs.Bink;
internal interface IBinkFrameDecoder : IDisposable
{
uint Width { get; }
uint Height { get; }
uint FramesPerSecondNumerator { get; }
uint FramesPerSecondDenominator { get; }
bool TryDecodeNextFrame(Span<byte> destination);
}
/// <summary>
/// Keeps blocking codec work away from the Vulkan presentation thread and
/// releases decoded frames according to the movie time base.
/// </summary>
internal sealed class BinkFramePlayback : IDisposable
{
private const int BufferCount = 5;
private readonly object _gate = new();
private readonly IBinkFrameDecoder _decoder;
private readonly Queue<byte[]> _freeBuffers = new();
private readonly Queue<DecodedFrame> _decodedFrames = new();
private readonly Thread _decoderThread;
private byte[]? _currentFrame;
private byte[]? _retiredFrame;
private long _currentFrameIndex = -1;
private long _nextDecodedFrameIndex;
private long _playbackStartTimestamp;
private bool _playbackClockStarted;
private bool _decoderCompleted;
private bool _stopRequested;
private bool _finished;
private int _disposed;
internal BinkFramePlayback(IBinkFrameDecoder decoder)
{
_decoder = decoder;
Width = decoder.Width;
Height = decoder.Height;
FramesPerSecondNumerator = decoder.FramesPerSecondNumerator;
FramesPerSecondDenominator = decoder.FramesPerSecondDenominator;
var frameBytes = checked((int)((ulong)Width * Height * 4));
for (var index = 0; index < BufferCount; index++)
{
_freeBuffers.Enqueue(GC.AllocateUninitializedArray<byte>(frameBytes));
}
_decoderThread = new Thread(DecodeLoop)
{
IsBackground = true,
Name = "SharpEmu Bink video decoder",
};
_decoderThread.Start();
}
internal uint Width { get; }
internal uint Height { get; }
internal uint FramesPerSecondNumerator { get; }
internal uint FramesPerSecondDenominator { get; }
internal bool IsFinished
{
get
{
lock (_gate)
{
return _finished;
}
}
}
internal bool TryGetFrame(
bool advanceClock,
out byte[] pixels,
out bool advanced)
{
lock (_gate)
{
pixels = [];
advanced = false;
if (_finished)
{
return false;
}
if (_currentFrame is null)
{
if (_decodedFrames.Count == 0)
{
if (_decoderCompleted)
{
_finished = true;
}
return false;
}
var first = _decodedFrames.Dequeue();
_currentFrame = first.Pixels;
_currentFrameIndex = first.Index;
advanced = true;
Monitor.PulseAll(_gate);
}
if (advanceClock && !_playbackClockStarted)
{
_playbackStartTimestamp = Stopwatch.GetTimestamp();
_playbackClockStarted = true;
}
var elapsedSeconds = _playbackClockStarted
? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds
: 0;
var targetFrameIndex = (long)Math.Floor(
elapsedSeconds * FramesPerSecondNumerator / FramesPerSecondDenominator);
DecodedFrame? replacement = null;
while (_decodedFrames.Count > 0 &&
_decodedFrames.Peek().Index <= targetFrameIndex)
{
if (replacement is { } skipped)
{
_freeBuffers.Enqueue(skipped.Pixels);
}
replacement = _decodedFrames.Dequeue();
}
if (replacement is { } next)
{
if (_retiredFrame is not null)
{
_freeBuffers.Enqueue(_retiredFrame);
}
_retiredFrame = _currentFrame;
_currentFrame = next.Pixels;
_currentFrameIndex = next.Index;
advanced = true;
Monitor.PulseAll(_gate);
}
var frameDurationSeconds =
(double)FramesPerSecondDenominator / FramesPerSecondNumerator;
if (_playbackClockStarted &&
_decoderCompleted &&
_decodedFrames.Count == 0 &&
elapsedSeconds >= (_currentFrameIndex + 1) * frameDurationSeconds)
{
_finished = true;
return false;
}
pixels = _currentFrame;
return true;
}
}
private void DecodeLoop()
{
try
{
while (true)
{
byte[] destination;
lock (_gate)
{
while (!_stopRequested && _freeBuffers.Count == 0)
{
Monitor.Wait(_gate);
}
if (_stopRequested)
{
return;
}
destination = _freeBuffers.Dequeue();
}
if (!_decoder.TryDecodeNextFrame(destination))
{
lock (_gate)
{
_freeBuffers.Enqueue(destination);
_decoderCompleted = true;
Monitor.PulseAll(_gate);
}
return;
}
lock (_gate)
{
_decodedFrames.Enqueue(new DecodedFrame(
_nextDecodedFrameIndex++, destination));
Monitor.PulseAll(_gate);
}
}
}
catch (Exception exception) when (exception is IOException or
InvalidOperationException)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Bink decoder stopped: {exception.Message}");
lock (_gate)
{
_decoderCompleted = true;
Monitor.PulseAll(_gate);
}
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
lock (_gate)
{
_stopRequested = true;
Monitor.PulseAll(_gate);
}
if (Thread.CurrentThread != _decoderThread &&
!_decoderThread.Join(TimeSpan.FromMilliseconds(100)))
{
_decoder.Dispose();
_decoderThread.Join(TimeSpan.FromSeconds(2));
}
else
{
_decoder.Dispose();
}
}
private readonly record struct DecodedFrame(long Index, byte[] Pixels);
}
@@ -1,166 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
using SharpEmu.Libs.AvPlayer;
namespace SharpEmu.Libs.Bink;
internal sealed class FfmpegBinkFrameSource : IBinkFrameDecoder
{
private readonly Process _process;
private readonly Stream _output;
private int _errorLines;
private int _disposed;
private FfmpegBinkFrameSource(
Process process,
uint width,
uint height,
uint framesPerSecondNumerator,
uint framesPerSecondDenominator)
{
_process = process;
_output = process.StandardOutput.BaseStream;
Width = width;
Height = height;
FramesPerSecondNumerator = framesPerSecondNumerator;
FramesPerSecondDenominator = framesPerSecondDenominator;
}
public uint Width { get; }
public uint Height { get; }
public uint FramesPerSecondNumerator { get; }
public uint FramesPerSecondDenominator { get; }
internal static bool IsAvailable => AvPlayerExports.FindFfmpeg() is not null;
internal static bool TryOpen(
string path,
uint width,
uint height,
uint framesPerSecondNumerator,
uint framesPerSecondDenominator,
out FfmpegBinkFrameSource? source)
{
source = null;
var ffmpeg = AvPlayerExports.FindFfmpeg();
if (ffmpeg is null)
{
return false;
}
var startInfo = new ProcessStartInfo(ffmpeg)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-nostdin");
startInfo.ArgumentList.Add("-hide_banner");
startInfo.ArgumentList.Add("-loglevel");
startInfo.ArgumentList.Add("error");
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(path);
startInfo.ArgumentList.Add("-map");
startInfo.ArgumentList.Add("0:v:0");
startInfo.ArgumentList.Add("-an");
startInfo.ArgumentList.Add("-pix_fmt");
startInfo.ArgumentList.Add("bgra");
startInfo.ArgumentList.Add("-f");
startInfo.ArgumentList.Add("rawvideo");
startInfo.ArgumentList.Add("pipe:1");
try
{
var process = Process.Start(startInfo);
if (process is null)
{
return false;
}
source = new FfmpegBinkFrameSource(
process,
width,
height,
framesPerSecondNumerator,
framesPerSecondDenominator);
process.ErrorDataReceived += source.OnErrorData;
process.BeginErrorReadLine();
return true;
}
catch (Exception exception) when (exception is IOException or
InvalidOperationException or
System.ComponentModel.Win32Exception)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Bink FFmpeg decoder could not start: {exception.Message}");
return false;
}
}
public bool TryDecodeNextFrame(Span<byte> destination)
{
try
{
var offset = 0;
while (offset < destination.Length)
{
var read = _output.Read(destination[offset..]);
if (read == 0)
{
return false;
}
offset += read;
}
return true;
}
catch (Exception exception) when (exception is IOException or ObjectDisposedException)
{
if (Volatile.Read(ref _disposed) == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Bink FFmpeg stream failed: {exception.Message}");
}
return false;
}
}
private void OnErrorData(object sender, DataReceivedEventArgs eventArgs)
{
if (string.IsNullOrWhiteSpace(eventArgs.Data) ||
Interlocked.Increment(ref _errorLines) > 20)
{
return;
}
Console.Error.WriteLine($"[LOADER][FFMPEG-BINK] {eventArgs.Data}");
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_output.Dispose();
try
{
if (!_process.HasExited)
{
_process.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
}
finally
{
_process.Dispose();
}
}
}
@@ -1,355 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using FFmpeg.AutoGen;
namespace SharpEmu.Libs.Bink;
/// <summary>
/// Decodes a .bk2 (or any FFmpeg-readable movie) directly via FFmpeg's C API
/// through FFmpeg.AutoGen P/Invoke bindings against the dynamically linked
/// libraries published by github.com/sharpemu/ffmpeg-core -- no native C
/// bridge of our own to build. See docs/bink2-bridge.md.
/// </summary>
internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
{
private AVFormatContext* _formatContext;
private AVCodecContext* _codecContext;
private SwsContext* _swsContext;
private AVFrame* _frame;
private AVPacket* _packet;
private readonly int _videoStreamIndex;
private bool _draining;
private int _disposed;
public uint Width { get; }
public uint Height { get; }
public uint FramesPerSecondNumerator { get; }
public uint FramesPerSecondDenominator { get; }
private FfmpegNativeBinkFrameSource(
AVFormatContext* formatContext,
AVCodecContext* codecContext,
int videoStreamIndex,
uint width,
uint height,
uint framesPerSecondNumerator,
uint framesPerSecondDenominator)
{
_formatContext = formatContext;
_codecContext = codecContext;
_videoStreamIndex = videoStreamIndex;
Width = width;
Height = height;
FramesPerSecondNumerator = framesPerSecondNumerator;
FramesPerSecondDenominator = framesPerSecondDenominator;
_frame = ffmpeg.av_frame_alloc();
_packet = ffmpeg.av_packet_alloc();
}
private static bool _rootPathInitialized;
/// <summary>
/// Points FFmpeg.AutoGen at the FFmpeg shared libraries SharpEmu.CLI
/// downloads next to the executable (see SharpEmu.CLI.csproj's
/// FetchFfmpegRuntime target); kept as loose files rather than embedded
/// in the single-file bundle so the OS loader can resolve the normal
/// inter-library dependencies (avcodec depends on avutil, etc.) itself.
/// </summary>
private static void EnsureRootPathInitialized()
{
if (_rootPathInitialized)
{
return;
}
_rootPathInitialized = true;
// SharpEmu.CLI.csproj publishes FFmpeg's shared libraries into a
// "plugins" subfolder next to the executable rather than flat beside
// it (see NativeLibraryFolderName in SharpEmu.CLI.csproj).
ffmpeg.RootPath = Path.Combine(AppContext.BaseDirectory, "plugins");
// ffmpeg's static constructor runs DynamicallyLoadedBindings.Initialize()
// itself, but that constructor fires on first touch of the ffmpeg type --
// which is the RootPath assignment above -- so it binds against the
// default (empty) RootPath before the assignment's own setter body runs.
// Every function resolved during that first pass permanently throws
// NotSupportedException. Re-running Initialize() now, with RootPath
// actually set, rebinds everything against the real search path.
DynamicallyLoadedBindings.Initialize();
}
internal static bool TryOpen(
string path,
uint maximumWidth,
uint maximumHeight,
out FfmpegNativeBinkFrameSource? source)
{
source = null;
EnsureRootPathInitialized();
AVFormatContext* formatContext = null;
AVCodecContext* codecContext = null;
try
{
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
{
return false;
}
if (ffmpeg.avformat_find_stream_info(formatContext, null) < 0)
{
return false;
}
AVCodec* decoder = null;
var videoStreamIndex = ffmpeg.av_find_best_stream(
formatContext, AVMediaType.AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0);
if (videoStreamIndex < 0 || decoder is null)
{
return false;
}
var stream = formatContext->streams[videoStreamIndex];
codecContext = ffmpeg.avcodec_alloc_context3(decoder);
if (codecContext is null)
{
return false;
}
if (ffmpeg.avcodec_parameters_to_context(codecContext, stream->codecpar) < 0)
{
return false;
}
codecContext->thread_count = 0;
codecContext->thread_type = ffmpeg.FF_THREAD_FRAME | ffmpeg.FF_THREAD_SLICE;
if (ffmpeg.avcodec_open2(codecContext, decoder, null) < 0)
{
return false;
}
if (codecContext->width <= 0 || codecContext->height <= 0)
{
return false;
}
var frameRate = ffmpeg.av_guess_frame_rate(formatContext, stream, null);
if (frameRate.num <= 0 || frameRate.den <= 0)
{
frameRate = stream->avg_frame_rate;
}
if (frameRate.num <= 0 || frameRate.den <= 0)
{
frameRate = stream->r_frame_rate;
}
if (frameRate.num <= 0 || frameRate.den <= 0)
{
frameRate = new AVRational { num = 30, den = 1 };
}
var outputWidth = (uint)codecContext->width;
var outputHeight = (uint)codecContext->height;
if (maximumWidth > 0 && maximumHeight > 0 &&
(outputWidth > maximumWidth || outputHeight > maximumHeight))
{
if ((ulong)outputWidth * maximumHeight > (ulong)outputHeight * maximumWidth)
{
outputHeight = (uint)((ulong)outputHeight * maximumWidth / outputWidth);
outputWidth = maximumWidth;
}
else
{
outputWidth = (uint)((ulong)outputWidth * maximumHeight / outputHeight);
outputHeight = maximumHeight;
}
outputWidth = Math.Max(1, outputWidth);
outputHeight = Math.Max(1, outputHeight);
}
source = new FfmpegNativeBinkFrameSource(
formatContext,
codecContext,
videoStreamIndex,
outputWidth,
outputHeight,
(uint)frameRate.num,
(uint)frameRate.den);
formatContext = null;
codecContext = null;
return true;
}
catch (DllNotFoundException)
{
return false;
}
finally
{
if (codecContext is not null)
{
ffmpeg.avcodec_free_context(&codecContext);
}
if (formatContext is not null)
{
ffmpeg.avformat_close_input(&formatContext);
}
}
}
public bool TryDecodeNextFrame(Span<byte> destination)
{
var stride = checked((int)(Width * 4));
var required = (long)stride * Height;
if (destination.Length < required)
{
return false;
}
if (!TryReceiveFrame())
{
return false;
}
_swsContext = ffmpeg.sws_getCachedContext(
_swsContext,
_frame->width,
_frame->height,
(AVPixelFormat)_frame->format,
(int)Width,
(int)Height,
AVPixelFormat.AV_PIX_FMT_BGRA,
ffmpeg.SWS_FAST_BILINEAR,
null,
null,
null);
if (_swsContext is null)
{
ffmpeg.av_frame_unref(_frame);
return false;
}
fixed (byte* destinationPointer = destination)
{
var destinationPlanes = new byte*[4] { destinationPointer, null, null, null };
var destinationStrides = new int[4] { stride, 0, 0, 0 };
var convertedRows = ffmpeg.sws_scale(
_swsContext,
_frame->data,
_frame->linesize,
0,
_frame->height,
destinationPlanes,
destinationStrides);
ffmpeg.av_frame_unref(_frame);
return convertedRows == (int)Height;
}
}
private bool TryReceiveFrame()
{
while (true)
{
var receiveResult = ffmpeg.avcodec_receive_frame(_codecContext, _frame);
if (receiveResult >= 0)
{
return true;
}
if (receiveResult == ffmpeg.AVERROR_EOF)
{
return false;
}
if (receiveResult != ffmpeg.AVERROR(ffmpeg.EAGAIN))
{
return false;
}
if (_draining)
{
return false;
}
if (!TryFeedPacket())
{
return false;
}
}
}
private bool TryFeedPacket()
{
while (true)
{
var readResult = ffmpeg.av_read_frame(_formatContext, _packet);
if (readResult < 0)
{
_draining = true;
ffmpeg.avcodec_send_packet(_codecContext, null);
return true;
}
if (_packet->stream_index != _videoStreamIndex)
{
ffmpeg.av_packet_unref(_packet);
continue;
}
var sendResult = ffmpeg.avcodec_send_packet(_codecContext, _packet);
ffmpeg.av_packet_unref(_packet);
if (sendResult < 0 && sendResult != ffmpeg.AVERROR(ffmpeg.EAGAIN))
{
return false;
}
return true;
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
if (_swsContext is not null)
{
ffmpeg.sws_freeContext(_swsContext);
_swsContext = null;
}
if (_packet is not null)
{
var packet = _packet;
ffmpeg.av_packet_free(&packet);
_packet = null;
}
if (_frame is not null)
{
var frame = _frame;
ffmpeg.av_frame_free(&frame);
_frame = null;
}
if (_codecContext is not null)
{
var codecContext = _codecContext;
ffmpeg.avcodec_free_context(&codecContext);
_codecContext = null;
}
if (_formatContext is not null)
{
var formatContext = _formatContext;
ffmpeg.avformat_close_input(&formatContext);
_formatContext = null;
}
}
}
-31
View File
@@ -153,37 +153,6 @@ public static class FontExports
return SetSuccess(ctx);
}
[SysAbiExport(
Nid = "3BrWWFU+4ts",
ExportName = "sceFontGetVerticalLayout",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int GetVerticalLayout(CpuContext ctx)
{
var layoutAddress = ctx[CpuRegister.Rsi];
if (layoutAddress == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
// Baseline (horizontal offset), line advance, decoration extent.
// Mirrors the same three-float layout as GetHorizontalLayout, but
// interpreted for vertical writing (e.g. CJK text rendered top-to-bottom).
var values = new[] { 8.0f, 16.0f, 0.0f };
for (var index = 0; index < values.Length; index++)
{
if (!TryWriteUInt32(
ctx,
layoutAddress + (ulong)(index * sizeof(float)),
BitConverter.SingleToUInt32Bits(values[index])))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
return SetSuccess(ctx);
}
[SysAbiExport(
Nid = "cKYtVmeSTcw",
ExportName = "sceFontOpenFontSet",
-140
View File
@@ -1,140 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers;
using System.Numerics;
namespace SharpEmu.Libs.Gpu;
/// <summary>
/// The pool backing AGC-to-presenter ownership transfers, shared by every backend
/// (the AGC layer rents, the presenter returns, so both sides must use one pool).
/// Guest draw snapshots churn through a small set of 128 KiB-16 MiB size classes
/// thousands of times per second; the process-wide shared pool trims and
/// repartitions those large arrays aggressively under GC load, causing hundreds of
/// MiB/s of replacement byte[] allocations, so this pool is bounded and non-shared.
/// </summary>
internal static class GuestDataPool
{
public static ArrayPool<byte> Shared { get; } = new BoundedByteArrayPool(
maxArrayLength: 16 * 1024 * 1024,
maxCachedBytes: 256UL * 1024 * 1024,
maxArraysPerBucket: 8);
public static void Trim() => ((BoundedByteArrayPool)Shared).Trim();
private sealed class BoundedByteArrayPool : ArrayPool<byte>
{
private readonly object _gate = new();
private readonly int _maxArrayLength;
private readonly ulong _maxCachedBytes;
private readonly int _maxArraysPerBucket;
private readonly Dictionary<int, Stack<byte[]>> _cachedByBucket = [];
private readonly HashSet<byte[]> _leases =
new(System.Collections.Generic.ReferenceEqualityComparer.Instance);
private ulong _cachedBytes;
public BoundedByteArrayPool(
int maxArrayLength,
ulong maxCachedBytes,
int maxArraysPerBucket)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArrayLength);
ArgumentOutOfRangeException.ThrowIfZero(maxCachedBytes);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArraysPerBucket);
_maxArrayLength = maxArrayLength;
_maxCachedBytes = maxCachedBytes;
_maxArraysPerBucket = maxArraysPerBucket;
}
public override byte[] Rent(int minimumLength)
{
ArgumentOutOfRangeException.ThrowIfNegative(minimumLength);
var length = GetAllocationLength(minimumLength);
byte[]? array = null;
lock (_gate)
{
if (length <= _maxArrayLength &&
_cachedByBucket.TryGetValue(length, out var bucket) &&
bucket.TryPop(out array))
{
_cachedBytes -= (ulong)array.LongLength;
}
array ??= new byte[length];
_leases.Add(array);
}
return array;
}
public override void Return(byte[] array, bool clearArray = false)
{
ArgumentNullException.ThrowIfNull(array);
lock (_gate)
{
if (!_leases.Remove(array))
{
return;
}
}
if (clearArray)
{
Array.Clear(array);
}
lock (_gate)
{
if (array.Length > _maxArrayLength ||
!IsBucketLength(array.Length) ||
(ulong)array.LongLength > _maxCachedBytes -
Math.Min(_cachedBytes, _maxCachedBytes))
{
return;
}
if (!_cachedByBucket.TryGetValue(array.Length, out var bucket))
{
bucket = new Stack<byte[]>();
_cachedByBucket.Add(array.Length, bucket);
}
if (bucket.Count >= _maxArraysPerBucket)
{
return;
}
bucket.Push(array);
_cachedBytes += (ulong)array.LongLength;
}
}
public void Trim()
{
lock (_gate)
{
_cachedByBucket.Clear();
_cachedBytes = 0;
}
}
private int GetAllocationLength(int minimumLength)
{
if (minimumLength <= 16)
{
return 16;
}
if (minimumLength > _maxArrayLength)
{
return minimumLength;
}
return checked((int)BitOperations.RoundUpToPowerOf2((uint)minimumLength));
}
private static bool IsBucketLength(int length) =>
length >= 16 && (length & (length - 1)) == 0;
}
}
+2 -31
View File
@@ -1,7 +1,6 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Gpu.Metal;
using SharpEmu.Libs.Gpu.Vulkan;
namespace SharpEmu.Libs.Gpu;
@@ -9,39 +8,11 @@ namespace SharpEmu.Libs.Gpu;
/// <summary>
/// Process-wide access point for the guest-GPU backend, mirroring HostPlatform for the
/// host seam: static HLE export classes resolve the renderer through <see cref="Current"/>.
/// Vulkan is the default everywhere; SHARPEMU_GPU_BACKEND=metal opts into the Metal
/// backend (macOS only) while it is being brought up. macOS flips to Metal by default
/// once the presenter reaches parity.
/// Vulkan is the only backend today; Metal/DX12 slot in here.
/// </summary>
internal static class GuestGpu
{
private static readonly Lazy<IGuestGpuBackend> Instance = new(Create);
private static readonly Lazy<IGuestGpuBackend> Instance = new(static () => new VulkanGuestGpuBackend());
public static IGuestGpuBackend Current => Instance.Value;
private static IGuestGpuBackend Create()
{
var requested = Environment.GetEnvironmentVariable("SHARPEMU_GPU_BACKEND");
if (string.IsNullOrEmpty(requested) || requested.Equals("vulkan", StringComparison.OrdinalIgnoreCase))
{
return new VulkanGuestGpuBackend();
}
if (requested.Equals("metal", StringComparison.OrdinalIgnoreCase))
{
if (!OperatingSystem.IsMacOS())
{
Console.Error.WriteLine(
"[LOADER][WARN] SHARPEMU_GPU_BACKEND=metal is only available on macOS; using Vulkan.");
return new VulkanGuestGpuBackend();
}
Console.Error.WriteLine("[LOADER][INFO] GPU backend: Metal (SHARPEMU_GPU_BACKEND).");
return new MetalGuestGpuBackend();
}
Console.Error.WriteLine(
$"[LOADER][WARN] Unknown SHARPEMU_GPU_BACKEND value '{requested}'; using Vulkan.");
return new VulkanGuestGpuBackend();
}
}
+2 -33
View File
@@ -27,12 +27,7 @@ internal sealed record GuestDrawTexture(
uint Pitch = 0,
uint TileMode = 0,
uint DstSelect = 0xFAC,
GuestSampler Sampler = default,
// Guest CPU write-tracker generation of the memory RgbaPixels was read
// from; -1 when the range is untracked or the pixels were not read here.
long WriteGeneration = -1,
bool ArrayedView = false,
uint ArrayLayers = 1);
GuestSampler Sampler = default);
/// <summary>Raw guest sampler descriptor dwords, copied verbatim from guest memory.</summary>
internal readonly record struct GuestSampler(
@@ -41,22 +36,6 @@ internal readonly record struct GuestSampler(
uint Word2,
uint Word3);
/// <summary>Identity of a texture's content in a backend texture cache, keyed
/// entirely on raw guest descriptor values; the AGC layer uses it to skip texel
/// copies for content the backend already holds.</summary>
internal readonly record struct TextureContentIdentity(
ulong Address,
uint Width,
uint Height,
uint Format,
uint NumberType,
uint DstSelect,
uint TileMode,
uint Pitch,
GuestSampler Sampler,
bool Arrayed = false,
uint ArrayLayers = 1);
internal sealed record GuestMemoryBuffer(
ulong BaseAddress,
byte[] Data,
@@ -143,22 +122,12 @@ internal readonly record struct GuestBlendState(
WriteMask: 0xFu);
}
/// <summary>CB_BLEND_RED..ALPHA: the constant color referenced by the
/// CONSTANT_COLOR / CONSTANT_ALPHA blend factors. One constant serves every
/// render target of a draw; the hardware reset value is transparent black.</summary>
internal readonly record struct GuestBlendConstant(
float Red,
float Green,
float Blue,
float Alpha);
internal sealed record GuestRenderState(
IReadOnlyList<GuestBlendState> Blends,
GuestRect? Scissor,
GuestViewport? Viewport,
GuestRasterState Raster,
GuestDepthState Depth,
GuestBlendConstant BlendConstant = default)
GuestDepthState Depth)
{
public static GuestRenderState Default { get; } = new(
[GuestBlendState.Default],
-71
View File
@@ -1,7 +1,6 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.ShaderCompiler;
namespace SharpEmu.Libs.Gpu;
@@ -18,10 +17,6 @@ namespace SharpEmu.Libs.Gpu;
/// </summary>
internal interface IGuestGpuBackend
{
/// <summary>Human-readable name of this backend ("Metal", "Vulkan"), shown in
/// the window title on macOS where either backend can run.</summary>
string BackendName { get; }
/// <summary>Starts the presenter (window + device) once; safe to call repeatedly.</summary>
void EnsureStarted(uint width, uint height);
@@ -193,70 +188,4 @@ internal interface IGuestGpuBackend
/// the guest codes cross the seam and each backend maps them internally.
/// </summary>
bool TryGetRenderTargetOutputKind(uint dataFormat, uint numberType, out Gen5PixelOutputKind outputKind);
// Guest work ordering. AGC submissions execute on a single backend consumer in
// logical guest-queue order; sequences returned here are backend work tickets.
// A backend without a running presenter returns 0 from the Submit* methods and
// callers fall back to executing inline.
/// <summary>Scopes subsequent submissions on this thread to a named guest queue.</summary>
IDisposable EnterGuestQueue(string queueName, ulong submissionId);
/// <summary>Enqueues an action at its exact position in the current guest queue;
/// returns its work sequence, or 0 when nothing could be enqueued.</summary>
long SubmitOrderedGuestAction(Action action, string debugName);
/// <summary>Preserves sceAgcDcbWaitUntilSafeForRendering in queue order.</summary>
long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex);
/// <summary>Blocks until the given work sequence completes; false on timeout,
/// close, or a non-positive sequence.</summary>
bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite);
/// <summary>Sequence currently executing on the guest-work consumer; diagnostics only.</summary>
long CurrentGuestWorkSequenceForDiagnostics { get; }
// Guest image lifecycle beyond presentation: CPU-visible seeding, writes, and
// extent queries the AGC layer uses to keep guest memory and backend images
// coherent. Addresses and formats are always raw guest values.
/// <summary>Whether the image exists on the backend or an already-queued upload
/// owns its initialization (a pending image may skip a duplicate upload but is
/// not yet a valid flip source).</summary>
bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType);
/// <summary>True when the first draw into this address must seed the backend
/// image from guest memory (PS5 render targets alias guest memory, so
/// CPU-prefilled pixels are visible before the first draw).</summary>
bool GuestImageWantsInitialData(ulong address);
void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels);
void SubmitGuestImageFill(ulong address, uint fillValue);
void SubmitGuestImageWrite(ulong address, byte[] pixels);
bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount);
IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents();
/// <summary>Whether the backend's texture cache already holds this content; lets
/// the AGC layer skip copying texels out of guest memory on every draw.</summary>
bool IsTextureContentCached(in TextureContentIdentity identity);
/// <summary>Guest memory handle for backend self-healing (cache misses re-read
/// texels directly instead of showing a fallback pattern).</summary>
void AttachGuestMemory(ICpuMemory memory);
/// <summary>Alignment the AGC layer must apply to storage-buffer offsets before
/// they cross the seam.</summary>
ulong GuestStorageBufferOffsetAlignment { get; }
/// <summary>Counts a guest shader translation for the perf overlay.</summary>
void CountShaderCompilation();
(long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters();
/// <summary>Asks a running presenter to close its window.</summary>
void RequestClose();
}
@@ -1,28 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Text;
using SharpEmu.ShaderCompiler.Metal;
namespace SharpEmu.Libs.Gpu.Metal;
/// <summary>
/// The Metal backend's compiled shader: MSL source plus the reflection data
/// (<see cref="Gen5MslShader"/>) the presenter needs to create and bind pipeline
/// states. The diagnostics payload is the source text — Metal has no portable
/// binary form until an MTLBinaryArchive is introduced.
/// </summary>
internal sealed class MetalCompiledGuestShader(Gen5MslShader shader) : IGuestCompiledShader
{
private byte[]? _payload;
public Gen5MslShader Shader { get; } = shader;
/// <summary>MTLLibrary handle cached by the presenter after the first
/// runtime compile; the render loop is its only reader and writer.</summary>
internal nint CachedLibrary;
public byte[] Payload => _payload ??= Encoding.UTF8.GetBytes(Shader.Source);
public string PayloadFileExtension => "msl";
}
@@ -1,270 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.ShaderCompiler;
namespace SharpEmu.Libs.Gpu.Metal;
/// <summary>
/// MTLPixelFormat raw values — only the formats the backend maps. Declared here
/// rather than pulled from a binding package: the Metal backend talks to the OS
/// exclusively through objc_msgSend, so ABI constants are owned locally.
/// </summary>
internal enum MtlPixelFormat : uint
{
Invalid = 0,
R8Unorm = 10,
R8Snorm = 12,
R8Uint = 13,
R8Sint = 14,
R16Unorm = 20,
R16Snorm = 22,
R16Uint = 23,
R16Sint = 24,
R16Float = 25,
Rg8Unorm = 30,
Rg8Snorm = 32,
Rg8Uint = 33,
Rg8Sint = 34,
B5G6R5Unorm = 40,
R32Uint = 53,
R32Sint = 54,
R32Float = 55,
Rg16Unorm = 60,
Rg16Uint = 63,
Rg16Sint = 64,
Rg16Float = 65,
Rgba8Unorm = 70,
Rgba8UnormSrgb = 71,
Rgba8Uint = 73,
Rgba8Sint = 74,
Bgra8Unorm = 80,
Bgra8UnormSrgb = 81,
Rgb10A2Unorm = 90,
Rg11B10Float = 92,
Rgb9E5Float = 93,
Bgr10A2Unorm = 94,
Rg32Uint = 103,
Rg32Sint = 104,
Rg32Float = 105,
Rgba16Unorm = 110,
Rgba16Uint = 113,
Rgba16Sint = 114,
Rgba16Float = 115,
Rgba32Uint = 123,
Rgba32Sint = 124,
Rgba32Float = 125,
Bc1Rgba = 130,
Bc1RgbaSrgb = 131,
Bc2Rgba = 132,
Bc2RgbaSrgb = 133,
Bc3Rgba = 134,
Bc3RgbaSrgb = 135,
Bc4RUnorm = 140,
Bc4RSnorm = 141,
Bc5RgUnorm = 142,
Bc5RgSnorm = 143,
Bc6HRgbFloat = 150,
Bc6HRgbUfloat = 151,
Bc7RgbaUnorm = 152,
Bc7RgbaUnormSrgb = 153,
Depth32Float = 252,
}
/// <summary>A sampled-texture format: the Metal pixel format plus the byte
/// layout the upload path needs. <see cref="BlockBytes"/> is nonzero for
/// block-compressed formats (bytes per 4x4 block); otherwise
/// <see cref="BytesPerPixel"/> applies.</summary>
internal readonly record struct MetalTextureFormat(
MtlPixelFormat Format,
uint BytesPerPixel,
uint BlockBytes)
{
public bool IsBlockCompressed => BlockBytes != 0;
}
internal readonly record struct MetalRenderTargetFormat(
MtlPixelFormat Format,
Gen5PixelOutputKind OutputKind)
{
public static uint GetBytesPerPixel(MtlPixelFormat format) =>
format switch
{
MtlPixelFormat.R8Unorm or MtlPixelFormat.R8Uint => 1,
MtlPixelFormat.Rg8Unorm => 2,
MtlPixelFormat.Rg32Float => 8,
MtlPixelFormat.Rgba16Unorm or MtlPixelFormat.Rgba16Uint or
MtlPixelFormat.Rgba16Sint or MtlPixelFormat.Rgba16Float => 8,
MtlPixelFormat.Rgba32Float => 16,
_ => 4,
};
}
/// <summary>
/// Guest texture-descriptor codes to Metal formats, mirroring the Vulkan
/// backend's table case for case so both backends accept the same guest
/// formats. Guest format 9 (2:10:10:10) maps to BGR10A2 — the bit layout that
/// matches Vulkan's A2R10G10B10 pack.
/// </summary>
internal static class MetalGuestFormats
{
/// <summary>Guest sampled-texture format to Metal, mirroring the Vulkan
/// backend's GetTextureFormat case for case (including its RGBA8 fallback
/// for unmapped codes, so unknown formats render something rather than
/// nothing). BC formats upload raw blocks — Mac-family GPUs decode them
/// natively.</summary>
public static MetalTextureFormat DecodeTextureFormat(uint dataFormat, uint numberType)
{
var format = (dataFormat, numberType) switch
{
(1, 0) => MtlPixelFormat.R8Unorm,
(1, 1) => MtlPixelFormat.R8Snorm,
(1, 4) => MtlPixelFormat.R8Uint,
(1, 5) => MtlPixelFormat.R8Sint,
(2, 0) => MtlPixelFormat.R16Unorm,
(2, 1) => MtlPixelFormat.R16Snorm,
(2, 4) => MtlPixelFormat.R16Uint,
(2, 5) => MtlPixelFormat.R16Sint,
(2, 7) => MtlPixelFormat.R16Float,
(3, 0) => MtlPixelFormat.Rg8Unorm,
(3, 1) => MtlPixelFormat.Rg8Snorm,
(3, 4) => MtlPixelFormat.Rg8Uint,
(3, 5) => MtlPixelFormat.Rg8Sint,
(4, 4) => MtlPixelFormat.R32Uint,
(4, 5) => MtlPixelFormat.R32Sint,
(4, 7) => MtlPixelFormat.R32Float,
(5, 0) => MtlPixelFormat.Rg16Unorm,
(5, 4) => MtlPixelFormat.Rg16Uint,
(5, 5) => MtlPixelFormat.Rg16Sint,
(5, 7) => MtlPixelFormat.Rg16Float,
(6, 7) or (7, 7) => MtlPixelFormat.Rg11B10Float,
(8, _) or (9, _) => MtlPixelFormat.Bgr10A2Unorm,
(10, 4) => MtlPixelFormat.Rgba8Uint,
(10, 5) => MtlPixelFormat.Rgba8Sint,
(10, 9) => MtlPixelFormat.Rgba8UnormSrgb,
(11, 4) => MtlPixelFormat.Rg32Uint,
(11, 5) => MtlPixelFormat.Rg32Sint,
(11, 7) => MtlPixelFormat.Rg32Float,
(12, 0) => MtlPixelFormat.Rgba16Unorm,
(12, 4) => MtlPixelFormat.Rgba16Uint,
(12, 5) => MtlPixelFormat.Rgba16Sint,
(12, 7) => MtlPixelFormat.Rgba16Float,
(13, 4) or (14, 4) => MtlPixelFormat.Rgba32Uint,
(13, 5) or (14, 5) => MtlPixelFormat.Rgba32Sint,
(13, _) or (14, _) => MtlPixelFormat.Rgba32Float,
(16, 0) => MtlPixelFormat.B5G6R5Unorm,
(34, 7) => MtlPixelFormat.Rgb9E5Float,
(169, _) => MtlPixelFormat.Bc1Rgba,
(170, _) => MtlPixelFormat.Bc1RgbaSrgb,
(171, _) => MtlPixelFormat.Bc2Rgba,
(172, _) => MtlPixelFormat.Bc2RgbaSrgb,
(173, _) => MtlPixelFormat.Bc3Rgba,
(174, _) => MtlPixelFormat.Bc3RgbaSrgb,
(175, 1) or (176, _) => MtlPixelFormat.Bc4RSnorm,
(175, _) => MtlPixelFormat.Bc4RUnorm,
(177, 1) or (178, _) => MtlPixelFormat.Bc5RgSnorm,
(177, _) => MtlPixelFormat.Bc5RgUnorm,
(179, _) => MtlPixelFormat.Bc6HRgbUfloat,
(180, _) => MtlPixelFormat.Bc6HRgbFloat,
(181, _) => MtlPixelFormat.Bc7RgbaUnorm,
(182, _) => MtlPixelFormat.Bc7RgbaUnormSrgb,
_ => MtlPixelFormat.Rgba8Unorm,
};
var blockBytes = format switch
{
MtlPixelFormat.Bc1Rgba or MtlPixelFormat.Bc1RgbaSrgb or
MtlPixelFormat.Bc4RUnorm or MtlPixelFormat.Bc4RSnorm => 8u,
MtlPixelFormat.Bc2Rgba or MtlPixelFormat.Bc2RgbaSrgb or
MtlPixelFormat.Bc3Rgba or MtlPixelFormat.Bc3RgbaSrgb or
MtlPixelFormat.Bc5RgUnorm or MtlPixelFormat.Bc5RgSnorm or
MtlPixelFormat.Bc6HRgbFloat or MtlPixelFormat.Bc6HRgbUfloat or
MtlPixelFormat.Bc7RgbaUnorm or MtlPixelFormat.Bc7RgbaUnormSrgb => 16u,
_ => 0u,
};
var bytesPerPixel = format switch
{
MtlPixelFormat.R8Unorm or MtlPixelFormat.R8Snorm or
MtlPixelFormat.R8Uint or MtlPixelFormat.R8Sint => 1u,
MtlPixelFormat.R16Unorm or MtlPixelFormat.R16Snorm or
MtlPixelFormat.R16Uint or MtlPixelFormat.R16Sint or
MtlPixelFormat.R16Float or MtlPixelFormat.Rg8Unorm or
MtlPixelFormat.Rg8Snorm or MtlPixelFormat.Rg8Uint or
MtlPixelFormat.Rg8Sint or MtlPixelFormat.B5G6R5Unorm => 2u,
MtlPixelFormat.Rg32Uint or MtlPixelFormat.Rg32Sint or
MtlPixelFormat.Rg32Float or MtlPixelFormat.Rgba16Unorm or
MtlPixelFormat.Rgba16Uint or MtlPixelFormat.Rgba16Sint or
MtlPixelFormat.Rgba16Float => 8u,
MtlPixelFormat.Rgba32Uint or MtlPixelFormat.Rgba32Sint or
MtlPixelFormat.Rgba32Float => 16u,
_ => 4u,
};
return new MetalTextureFormat(format, bytesPerPixel, blockBytes);
}
/// <summary>Source byte footprint of a sampled texture, block-aware —
/// the same math the AGC layer uses to size the texel copy it ships.</summary>
public static ulong GetTextureByteCount(in MetalTextureFormat format, uint width, uint height) =>
format.IsBlockCompressed
? checked(((ulong)width + 3) / 4 * (((ulong)height + 3) / 4) * format.BlockBytes)
: checked((ulong)width * height * format.BytesPerPixel);
public static bool TryDecodeRenderTargetFormat(
uint dataFormat,
uint numberType,
out MetalRenderTargetFormat result)
{
var format = (dataFormat, numberType) switch
{
(4, 4) => MtlPixelFormat.R32Uint,
(4, 5) => MtlPixelFormat.R32Sint,
(4, 7) => MtlPixelFormat.R32Float,
(5, 4) => MtlPixelFormat.Rg16Uint,
(5, 5) => MtlPixelFormat.Rg16Sint,
(5, 7) => MtlPixelFormat.Rg16Float,
(6, 7) or (7, 7) => MtlPixelFormat.Rg11B10Float,
(9, _) => MtlPixelFormat.Bgr10A2Unorm,
(10, 4) => MtlPixelFormat.Rgba8Uint,
(10, 5) => MtlPixelFormat.Rgba8Sint,
(10, 9) => MtlPixelFormat.Rgba8UnormSrgb,
(10, _) => MtlPixelFormat.Rgba8Unorm,
(11, 7) => MtlPixelFormat.Rg32Float,
(12, 4) => MtlPixelFormat.Rgba16Uint,
(12, 5) => MtlPixelFormat.Rgba16Sint,
(12, 7) => MtlPixelFormat.Rgba16Float,
(13, 7) or (14, 7) => MtlPixelFormat.Rgba32Float,
(20, 0) => MtlPixelFormat.R32Uint,
(29, 0) or (4, 0) => MtlPixelFormat.R32Float,
(1, 0) or (36, 0) => MtlPixelFormat.R8Unorm,
(49, 0) => MtlPixelFormat.R8Uint,
(3, 0) => MtlPixelFormat.Rg8Unorm,
(5, 0) => MtlPixelFormat.Rg16Unorm,
(7, 0) => MtlPixelFormat.Rg11B10Float,
(12, 0) => MtlPixelFormat.Rgba16Unorm,
(13, 0) or (14, 0) => MtlPixelFormat.Rgba32Float,
(22, 0) or (71, 0) => MtlPixelFormat.Rgba16Float,
(56, 0) or (62, 0) or (64, 0) => MtlPixelFormat.Rgba8Unorm,
(75, 0) => MtlPixelFormat.Rg32Float,
_ => MtlPixelFormat.Invalid,
};
if (format == MtlPixelFormat.Invalid)
{
result = default;
return false;
}
var outputKind = format switch
{
MtlPixelFormat.R8Uint or MtlPixelFormat.R32Uint or MtlPixelFormat.Rg16Uint or
MtlPixelFormat.Rgba8Uint or MtlPixelFormat.Rgba16Uint => Gen5PixelOutputKind.Uint,
MtlPixelFormat.R32Sint or MtlPixelFormat.Rg16Sint or MtlPixelFormat.Rgba8Sint or
MtlPixelFormat.Rgba16Sint => Gen5PixelOutputKind.Sint,
_ => Gen5PixelOutputKind.Float,
};
result = new MetalRenderTargetFormat(format, outputKind);
return true;
}
}
@@ -1,426 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Metal;
namespace SharpEmu.Libs.Gpu.Metal;
/// <summary>
/// Metal backend for the guest-GPU seam: MSL codegen via
/// SharpEmu.ShaderCompiler.Metal, rendering via the Metal presenter — the full
/// surface (presentation, guest images, ordered flips, translated draws, and
/// compute) with no Vulkan, MoltenVK, or windowing-library dependency.
/// </summary>
internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
{
public string BackendName => "Metal";
private static readonly IGuestCompiledShader DepthOnlyFragmentShader =
new MetalCompiledGuestShader(new Gen5MslShader(
MslFixedShaders.CreateDepthOnlyFragment(),
"depth_only_fs",
Gen5MslStage.Pixel,
[],
[],
AttributeCount: 0,
[]));
public bool TryCompileVertexShader(
Gen5ShaderState state,
Gen5ShaderEvaluation evaluation,
out IGuestCompiledShader? shader,
out string error,
int globalBufferBase = 0,
int totalGlobalBufferCount = -1,
int imageBindingBase = 0,
int scalarRegisterBufferIndex = -1,
int requiredVertexOutputCount = 0,
ulong storageBufferOffsetAlignment = 1)
{
shader = null;
if (!Gen5MslTranslator.TryCompileVertexShader(
state,
evaluation,
out var compiled,
out error,
globalBufferBase,
totalGlobalBufferCount,
imageBindingBase,
scalarRegisterBufferIndex,
requiredVertexOutputCount,
storageBufferOffsetAlignment))
{
return false;
}
shader = new MetalCompiledGuestShader(compiled);
return true;
}
public bool TryCompilePixelShader(
Gen5ShaderState state,
Gen5ShaderEvaluation evaluation,
IReadOnlyList<Gen5PixelOutputBinding> outputs,
out IGuestCompiledShader? shader,
out string error,
int globalBufferBase = 0,
int totalGlobalBufferCount = -1,
int imageBindingBase = 0,
int scalarRegisterBufferIndex = -1,
uint pixelInputEnable = 0,
uint pixelInputAddress = 0,
ulong storageBufferOffsetAlignment = 1)
{
shader = null;
if (!Gen5MslTranslator.TryCompilePixelShader(
state,
evaluation,
outputs,
out var compiled,
out error,
globalBufferBase,
totalGlobalBufferCount,
imageBindingBase,
scalarRegisterBufferIndex,
pixelInputEnable,
pixelInputAddress,
storageBufferOffsetAlignment))
{
return false;
}
shader = new MetalCompiledGuestShader(compiled);
return true;
}
public bool TryCompileComputeShader(
Gen5ShaderState state,
Gen5ShaderEvaluation evaluation,
uint localSizeX,
uint localSizeY,
uint localSizeZ,
out IGuestCompiledShader? shader,
out string error,
int totalGlobalBufferCount = -1,
int initialScalarBufferIndex = -1,
uint waveLaneCount = 32,
ulong storageBufferOffsetAlignment = 1)
{
shader = null;
// Wave64 compute is emulated by the translator: cross-lane ops bridge
// the two 32-wide Apple simdgroups of a guest wave through threadgroup
// scratch, and wave-agnostic kernels run per-thread unchanged.
if (!Gen5MslTranslator.TryCompileComputeShader(
state,
evaluation,
localSizeX,
localSizeY,
localSizeZ,
out var compiled,
out error,
totalGlobalBufferCount,
initialScalarBufferIndex,
waveLaneCount,
storageBufferOffsetAlignment))
{
return false;
}
shader = new MetalCompiledGuestShader(compiled);
return true;
}
public IGuestCompiledShader GetDepthOnlyFragmentShader() =>
DepthOnlyFragmentShader;
public bool TryGetRenderTargetOutputKind(uint dataFormat, uint numberType, out Gen5PixelOutputKind outputKind)
{
if (MetalGuestFormats.TryDecodeRenderTargetFormat(dataFormat, numberType, out var format))
{
outputKind = format.OutputKind;
return true;
}
outputKind = default;
return false;
}
public void EnsureStarted(uint width, uint height) =>
MetalVideoPresenter.EnsureStarted(width, height);
public void HideSplashScreen() =>
MetalVideoPresenter.HideSplashScreen();
public void Submit(byte[] bgraFrame, uint width, uint height) =>
MetalVideoPresenter.Submit(bgraFrame, width, height);
public bool TrySubmitGuestImage(
ulong address,
uint width,
uint height,
uint pitchInPixel) =>
MetalVideoPresenter.TrySubmitGuestImage(address, width, height, pitchInPixel);
public bool TrySubmitOrderedGuestImageFlip(
int videoOutHandle,
int displayBufferIndex,
ulong address,
uint width,
uint height,
uint pitchInPixel) =>
MetalVideoPresenter.TrySubmitOrderedGuestImageFlip(
videoOutHandle,
displayBufferIndex,
address,
width,
height,
pitchInPixel);
public void RegisterKnownDisplayBuffer(ulong address, uint guestFormat) =>
MetalVideoPresenter.RegisterKnownDisplayBuffer(address, guestFormat);
public bool IsGpuGuestImageAvailable(ulong address, uint format, uint numberType) =>
MetalVideoPresenter.IsGuestImageAvailable(address, format, numberType);
public bool TrySubmitGuestImageBlit(
ulong sourceAddress,
uint sourceWidth,
uint sourceHeight,
uint sourceFormat,
uint sourceNumberType,
ulong destinationAddress,
uint destinationWidth,
uint destinationHeight,
uint destinationFormat,
uint destinationNumberType) =>
MetalVideoPresenter.TrySubmitGuestImageBlit(
sourceAddress,
sourceWidth,
sourceHeight,
sourceFormat,
sourceNumberType,
destinationAddress,
destinationWidth,
destinationHeight,
destinationFormat,
destinationNumberType);
public void SubmitGuestDraw(GuestDrawKind drawKind, uint width, uint height) =>
MetalVideoPresenter.SubmitGuestDraw(drawKind, width, height);
public void SubmitTranslatedDraw(
IGuestCompiledShader pixelShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint width,
uint height,
uint attributeCount,
IGuestCompiledShader? vertexShader = null,
uint vertexCount = 3,
uint instanceCount = 1,
uint primitiveType = 4,
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
GuestRenderState? renderState = null) =>
MetalVideoPresenter.SubmitTranslatedDraw(
Msl(pixelShader),
textures,
globalMemoryBuffers,
width,
height,
attributeCount,
vertexShader is null ? null : Msl(vertexShader),
vertexCount,
instanceCount,
primitiveType,
indexBuffer,
vertexBuffers,
renderState);
public void SubmitDepthOnlyTranslatedDraw(
IGuestCompiledShader pixelShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint attributeCount,
GuestDepthTarget depthTarget,
IGuestCompiledShader? vertexShader = null,
uint vertexCount = 3,
uint instanceCount = 1,
uint primitiveType = 4,
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
GuestRenderState? renderState = null,
ulong shaderAddress = 0) =>
MetalVideoPresenter.SubmitDepthOnlyTranslatedDraw(
Msl(pixelShader),
textures,
globalMemoryBuffers,
attributeCount,
depthTarget,
vertexShader is null ? null : Msl(vertexShader),
vertexCount,
instanceCount,
primitiveType,
indexBuffer,
vertexBuffers,
renderState,
shaderAddress);
public void SubmitOffscreenTranslatedDraw(
IGuestCompiledShader pixelShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint attributeCount,
IReadOnlyList<GuestRenderTarget> targets,
IGuestCompiledShader? vertexShader = null,
uint vertexCount = 3,
uint instanceCount = 1,
uint primitiveType = 4,
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
GuestRenderState? renderState = null,
GuestDepthTarget? depthTarget = null,
ulong shaderAddress = 0) =>
MetalVideoPresenter.SubmitOffscreenTranslatedDraw(
Msl(pixelShader),
textures,
globalMemoryBuffers,
attributeCount,
targets,
vertexShader is null ? null : Msl(vertexShader),
vertexCount,
instanceCount,
primitiveType,
indexBuffer,
vertexBuffers,
renderState,
depthTarget,
shaderAddress);
public void SubmitStorageTranslatedDraw(
IGuestCompiledShader pixelShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint attributeCount,
uint width,
uint height,
ulong shaderAddress = 0) =>
MetalVideoPresenter.SubmitStorageTranslatedDraw(
Msl(pixelShader),
textures,
globalMemoryBuffers,
attributeCount,
width,
height,
shaderAddress);
private static MetalCompiledGuestShader Msl(IGuestCompiledShader shader) =>
shader as MetalCompiledGuestShader ??
throw new InvalidOperationException(
$"shader handle of type {shader.GetType().Name} was not compiled by the Metal backend");
public long SubmitComputeDispatch(
ulong shaderAddress,
IGuestCompiledShader computeShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint groupCountX,
uint groupCountY,
uint groupCountZ,
uint baseGroupX,
uint baseGroupY,
uint baseGroupZ,
uint localSizeX,
uint localSizeY,
uint localSizeZ,
bool isIndirect,
bool writesGlobalMemory,
uint threadCountX = uint.MaxValue,
uint threadCountY = uint.MaxValue,
uint threadCountZ = uint.MaxValue)
{
// The translated kernel bakes its threadgroup size; localSize and
// isIndirect are already folded in by the AGC layer before submission.
_ = localSizeX;
_ = localSizeY;
_ = localSizeZ;
_ = isIndirect;
return MetalVideoPresenter.SubmitComputeDispatch(
shaderAddress,
Msl(computeShader),
textures,
globalMemoryBuffers,
groupCountX,
groupCountY,
groupCountZ,
baseGroupX,
baseGroupY,
baseGroupZ,
writesGlobalMemory,
threadCountX,
threadCountY,
threadCountZ);
}
private long _perfShaderCompilations;
public IDisposable EnterGuestQueue(string queueName, ulong submissionId) =>
MetalVideoPresenter.EnterGuestQueue(queueName, submissionId);
public long SubmitOrderedGuestAction(Action action, string debugName) =>
MetalVideoPresenter.SubmitOrderedGuestAction(action, debugName);
public long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex) =>
MetalVideoPresenter.SubmitOrderedGuestFlipWait(videoOutHandle, displayBufferIndex);
public bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite) =>
MetalVideoPresenter.WaitForGuestWork(workSequence, timeoutMilliseconds);
public long CurrentGuestWorkSequenceForDiagnostics =>
MetalVideoPresenter.CurrentGuestWorkSequenceForDiagnostics;
public bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType) =>
MetalVideoPresenter.IsGuestImageUploadKnown(address, format, numberType);
public bool GuestImageWantsInitialData(ulong address) =>
MetalVideoPresenter.GuestImageWantsInitialData(address);
public void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels) =>
MetalVideoPresenter.ProvideGuestImageInitialData(address, rgbaPixels);
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
MetalVideoPresenter.SubmitGuestImageFill(address, fillValue);
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
MetalVideoPresenter.SubmitGuestImageWrite(address, pixels);
public bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) =>
MetalVideoPresenter.TryGetGuestImageExtent(address, out width, out height, out byteCount);
public IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents() =>
MetalVideoPresenter.GetGuestImageExtents();
public bool IsTextureContentCached(in TextureContentIdentity identity) =>
MetalVideoPresenter.IsTextureContentCached(identity);
public void AttachGuestMemory(SharpEmu.HLE.ICpuMemory memory) =>
MetalVideoPresenter.AttachGuestMemory(memory);
// Over-alignment is always valid, and 256 covers every Metal buffer-offset
// requirement (Intel Macs need 256 for constant buffers; Apple GPUs less).
public ulong GuestStorageBufferOffsetAlignment => 256;
public void CountShaderCompilation() =>
Interlocked.Increment(ref _perfShaderCompilations);
public (long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters()
{
var (draws, drawMs, pipelines) = MetalVideoPresenter.ReadAndResetDrawPerfCounters();
return (draws, drawMs, pipelines, Interlocked.Exchange(ref _perfShaderCompilations, 0));
}
public void RequestClose() =>
MetalVideoPresenter.RequestClose();
}
@@ -1,182 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.Host;
using SharpEmu.HLE.Host.Posix;
namespace SharpEmu.Libs.Gpu.Metal;
/// <summary>
/// Keyboard state sampled from the Metal presenter's window, feeding the POSIX
/// host input seam so pad emulation works like the Vulkan presenter's
/// HostWindowInput. Key events arrive on the AppKit main thread as macOS
/// virtual key codes; pad reads happen on guest threads, so state is guarded.
/// Window gamepads are not surfaced by AppKit — controller support would go
/// through GameController.framework and is out of scope here.
/// </summary>
internal static class MetalHostInput
{
private static readonly object Gate = new();
private static readonly HashSet<ushort> Pressed = new();
private static volatile bool _connected;
/// <summary>Registers this window's keyboard as the host input source.</summary>
public static void Attach()
{
_connected = true;
PosixHostInput.SetSource(new MetalWindowInputSource());
Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
}
// Debug automation: SHARPEMU_METAL_AUTOKEY="12:0x24,15:0x24" presses the
// macOS key code at each elapsed-seconds mark for a few frames, letting
// headless test runs navigate menus without a human at the keyboard.
private static readonly List<(double At, ushort Key, bool[] State)> _autoKeys = ParseAutoKeys();
private static readonly System.Diagnostics.Stopwatch _autoKeyClock =
System.Diagnostics.Stopwatch.StartNew();
private static List<(double, ushort, bool[])> ParseAutoKeys()
{
var keys = new List<(double, ushort, bool[])>();
var spec = Environment.GetEnvironmentVariable("SHARPEMU_METAL_AUTOKEY");
if (string.IsNullOrWhiteSpace(spec))
{
return keys;
}
foreach (var entry in spec.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
var parts = entry.Split(':');
if (parts.Length == 2 &&
double.TryParse(parts[0], out var at) &&
TryParseKeyCode(parts[1], out var key))
{
keys.Add((at, key, new bool[2]));
}
}
return keys;
}
private static bool TryParseKeyCode(string text, out ushort key)
{
return text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? ushort.TryParse(text[2..], System.Globalization.NumberStyles.HexNumber, null, out key)
: ushort.TryParse(text, out key);
}
/// <summary>Called once per render frame; fires and releases scripted keys.</summary>
public static void PumpAutoKeys()
{
if (_autoKeys.Count == 0)
{
return;
}
var elapsed = _autoKeyClock.Elapsed.TotalSeconds;
foreach (var (at, key, state) in _autoKeys)
{
if (!state[0] && elapsed >= at)
{
state[0] = true;
KeyDown(key, isRepeat: false);
Console.Error.WriteLine($"[LOADER][INFO] Metal autokey press 0x{key:X} at {elapsed:F1}s");
}
else if (state[0] && !state[1] && elapsed >= at + 0.2)
{
state[1] = true;
KeyUp(key);
}
}
}
public static void KeyDown(ushort keyCode, bool isRepeat)
{
// kVK_F1: parity with the Vulkan window's perf-overlay toggle.
if (keyCode == 0x7A && !isRepeat)
{
VideoOut.PerfOverlay.Toggle();
}
lock (Gate)
{
Pressed.Add(keyCode);
}
}
public static void KeyUp(ushort keyCode)
{
lock (Gate)
{
Pressed.Remove(keyCode);
}
}
private static bool IsKeyCodeDown(ushort keyCode)
{
lock (Gate)
{
return Pressed.Contains(keyCode);
}
}
private sealed class MetalWindowInputSource : IPosixWindowInputSource
{
public bool HasKeyboardFocus => _connected;
public bool IsKeyDown(int virtualKey) =>
TryMapVirtualKey(virtualKey, out var keyCode) && IsKeyCodeDown(keyCode);
public int GetGamepadStates(Span<HostGamepadState> destination) => 0;
public string? DescribeConnectedGamepad() => null;
}
/// <summary>Windows virtual-key semantics (the seam's contract) to macOS
/// kVK virtual key codes, covering the keys pad emulation polls.</summary>
private static bool TryMapVirtualKey(int vk, out ushort keyCode)
{
keyCode = vk switch
{
0x08 => 0x33, // Backspace -> kVK_Delete
0x09 => 0x30, // Tab
0x0D => 0x24, // Enter -> kVK_Return
0x1B => 0x35, // Escape
0x20 => 0x31, // Space
0x25 => 0x7B, // Left
0x26 => 0x7E, // Up
0x27 => 0x7C, // Right
0x28 => 0x7D, // Down
// Letters: macOS ANSI key codes are layout-position based and
// non-contiguous, so map each polled letter explicitly.
0x41 => 0x00, // A
0x42 => 0x0B, // B
0x43 => 0x08, // C
0x44 => 0x02, // D
0x45 => 0x0E, // E
0x46 => 0x03, // F
0x47 => 0x05, // G
0x48 => 0x04, // H
0x49 => 0x22, // I
0x4A => 0x26, // J
0x4B => 0x28, // K
0x4C => 0x25, // L
0x4D => 0x2E, // M
0x4E => 0x2D, // N
0x4F => 0x1F, // O
0x50 => 0x23, // P
0x51 => 0x0C, // Q
0x52 => 0x0F, // R
0x53 => 0x01, // S
0x54 => 0x11, // T
0x55 => 0x20, // U
0x56 => 0x09, // V
0x57 => 0x0D, // W
0x58 => 0x07, // X
0x59 => 0x10, // Y
0x5A => 0x06, // Z
_ => ushort.MaxValue,
};
return keyCode != ushort.MaxValue;
}
}
-430
View File
@@ -1,430 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
namespace SharpEmu.Libs.Gpu.Metal;
// Core Graphics / Metal ABI structs passed by value through objc_msgSend. Struct
// *returns* are deliberately never used: on x86-64 (this process runs under Rosetta
// on Apple silicon) large struct returns switch to objc_msgSend_stret, and avoiding
// them entirely keeps one calling convention everywhere.
[StructLayout(LayoutKind.Sequential)]
internal struct CGRect
{
public double X;
public double Y;
public double Width;
public double Height;
}
[StructLayout(LayoutKind.Sequential)]
internal struct CGSize
{
public double Width;
public double Height;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MtlClearColor
{
public double Red;
public double Green;
public double Blue;
public double Alpha;
}
/// <summary>MTLTextureSwizzleChannels: one MTLTextureSwizzle byte per output
/// channel (Zero=0, One=1, Red=2, Green=3, Blue=4, Alpha=5).</summary>
[StructLayout(LayoutKind.Sequential)]
internal struct MtlTextureSwizzleChannels
{
public byte Red;
public byte Green;
public byte Blue;
public byte Alpha;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MtlRegion
{
public nuint X;
public nuint Y;
public nuint Z;
public nuint Width;
public nuint Height;
public nuint Depth;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MtlSize
{
public nuint Width;
public nuint Height;
public nuint Depth;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MtlOrigin
{
public nuint X;
public nuint Y;
public nuint Z;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MtlScissorRect
{
public nuint X;
public nuint Y;
public nuint Width;
public nuint Height;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MtlViewport
{
public double OriginX;
public double OriginY;
public double Width;
public double Height;
public double ZNear;
public double ZFar;
}
/// <summary>
/// Objective-C runtime access for the Metal presenter: AppKit, QuartzCore, and Metal
/// through objc_msgSend, with one LibraryImport overload per distinct native
/// signature. Dependency-free by design — this plus the OS frameworks is the entire
/// Metal path, which is what keeps it NativeAOT-clean.
/// </summary>
internal static partial class MetalNative
{
private const string CoreFoundation =
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
[LibraryImport(CoreFoundation)]
public static partial nint CFRunLoopGetMain();
[LibraryImport(CoreFoundation)]
public static partial void CFRunLoopStop(nint runLoop);
private const string ObjCLibrary = "/usr/lib/libobjc.A.dylib";
private const string MetalFramework = "/System/Library/Frameworks/Metal.framework/Metal";
private const string AppKitFramework = "/System/Library/Frameworks/AppKit.framework/AppKit";
private const string QuartzCoreFramework = "/System/Library/Frameworks/QuartzCore.framework/QuartzCore";
private static bool _frameworksLoaded;
/// <summary>
/// Makes the AppKit and QuartzCore classes visible to objc_getClass; Metal is
/// pulled in by its own LibraryImport. Call once before any Class() lookup.
/// </summary>
public static void EnsureFrameworksLoaded()
{
if (_frameworksLoaded)
{
return;
}
NativeLibrary.Load(AppKitFramework);
NativeLibrary.Load(QuartzCoreFramework);
_frameworksLoaded = true;
}
[LibraryImport(MetalFramework)]
public static partial nint MTLCreateSystemDefaultDevice();
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
private static partial nint objc_getClass(string name);
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
private static partial nint sel_registerName(string name);
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
public static partial nint objc_allocateClassPair(nint superclass, string name, nuint extraBytes);
[LibraryImport(ObjCLibrary)]
public static partial void objc_registerClassPair(nint cls);
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
[return: MarshalAs(UnmanagedType.I1)]
public static partial bool class_addMethod(nint cls, nint name, nint imp, string types);
[LibraryImport(ObjCLibrary)]
public static partial nint objc_autoreleasePoolPush();
[LibraryImport(ObjCLibrary)]
public static partial void objc_autoreleasePoolPop(nint pool);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector);
/// <summary>objc_msgSend for -gpuResourceID. MTLResourceID is a one-field
/// 8-byte struct, returned in a register on the x86-64 ABI, so it maps to a
/// ulong return — the value written into a Tier 2 argument buffer slot.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial ulong SendGpuResourceId(nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument);
/// <summary>objc_msgSend for a CGRect-returning selector (e.g. -bounds).
/// A 32-byte struct is returned via the x86-64 stret ABI — a hidden
/// pointer to caller storage passed ahead of self/_cmd — so this must not
/// be folded into the plain objc_msgSend overloads.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend_stret")]
public static partial void SendStretRect(out CGRect result, nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument, ref nint error);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument0, nint argument1, ref nint error);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendAtIndex(nint receiver, nint selector, nuint index);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
[return: MarshalAs(UnmanagedType.I1)]
public static partial bool SendBool(nint receiver, nint selector);
/// <summary>One-argument BOOL sends, e.g. respondsToSelector:.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
[return: MarshalAs(UnmanagedType.I1)]
public static partial bool SendBool(nint receiver, nint selector, nint argument);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial double SendDouble(nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoid(nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoid(nint receiver, nint selector, nint argument);
/// <summary>Two-object-argument void sends, e.g. setObject:forKey:.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoid(nint receiver, nint selector, nint argument0, nint argument1);
/// <summary>performSelectorOnMainThread:withObject:waitUntilDone: — the SEL
/// to perform is itself an argument, followed by the object and the wait
/// flag.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidPerformSelector(
nint receiver,
nint selector,
nint performedSelector,
nint argument,
[MarshalAs(UnmanagedType.I1)] bool waitUntilDone);
/// <summary>setSwizzle: on MTLTextureDescriptor. Four one-byte
/// MTLTextureSwizzle values, passed packed like the framework expects.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidSwizzle(
nint receiver,
nint selector,
MtlTextureSwizzleChannels channels);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidBool(nint receiver, nint selector, [MarshalAs(UnmanagedType.I1)] bool argument);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidDouble(nint receiver, nint selector, double argument);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidSize(nint receiver, nint selector, CGSize size);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidRect(nint receiver, nint selector, CGRect rect);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidClearColor(nint receiver, nint selector, MtlClearColor color);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidBlendColor(
nint receiver,
nint selector,
float red,
float green,
float blue,
float alpha);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidViewport(nint receiver, nint selector, MtlViewport viewport);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendSetAtIndex(nint receiver, nint selector, nint value, nuint index);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidCopyTexture(nint receiver, nint selector, nint source, nint destination);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendBuffer(nint receiver, nint selector, nint bytes, nuint length, nuint options);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendNewBuffer(nint receiver, nint selector, nuint length, nuint options);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendCopyTextureToBuffer(
nint receiver,
nint selector,
nint sourceTexture,
nuint sourceSlice,
nuint sourceLevel,
MtlOrigin sourceOrigin,
MtlSize sourceSize,
nint destinationBuffer,
nuint destinationOffset,
nuint destinationBytesPerRow,
nuint destinationBytesPerImage);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendCopyBufferToTexture(
nint receiver,
nint selector,
nint sourceBuffer,
nuint sourceOffset,
nuint sourceBytesPerRow,
nuint sourceBytesPerImage,
MtlSize sourceSize,
nint destinationTexture,
nuint destinationSlice,
nuint destinationLevel,
MtlOrigin destinationOrigin);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendDispatch(
nint receiver,
nint selector,
MtlSize threadgroups,
MtlSize threadsPerThreadgroup);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendSetBuffer(nint receiver, nint selector, nint buffer, nuint offset, nuint index);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidScissor(nint receiver, nint selector, MtlScissorRect rect);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendDrawPrimitivesInstanced(
nint receiver,
nint selector,
nuint primitiveType,
nuint vertexStart,
nuint vertexCount,
nuint instanceCount);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendDrawIndexedPrimitives(
nint receiver,
nint selector,
nuint primitiveType,
nuint indexCount,
nuint indexType,
nint indexBuffer,
nuint indexBufferOffset,
nuint instanceCount);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendTimer(
nint receiver,
nint selector,
double interval,
nint target,
nint timerSelector,
nint userInfo,
[MarshalAs(UnmanagedType.I1)] bool repeats);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendInitFrame(nint receiver, nint selector, CGRect frame);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendInitWindow(
nint receiver,
nint selector,
CGRect contentRect,
nuint styleMask,
nuint backing,
[MarshalAs(UnmanagedType.I1)] bool defer);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendNextEvent(
nint receiver,
nint selector,
ulong eventMask,
nint untilDate,
nint inMode,
[MarshalAs(UnmanagedType.I1)] bool dequeue);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendTextureDescriptor(
nint receiver,
nint selector,
nuint pixelFormat,
nuint width,
nuint height,
[MarshalAs(UnmanagedType.I1)] bool mipmapped);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendReplaceRegion(
nint receiver,
nint selector,
MtlRegion region,
nuint mipmapLevel,
nint bytes,
nuint bytesPerRow);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendDrawPrimitives(
nint receiver,
nint selector,
nuint primitiveType,
nuint vertexStart,
nuint vertexCount);
public static nint Class(string name) => objc_getClass(name);
public static nint Selector(string name) => sel_registerName(name);
/// <summary>Autoreleased NSString — only valid inside an autorelease pool
/// unless the caller retains it.</summary>
public static nint NsString(string value)
{
var utf8 = Marshal.StringToCoTaskMemUTF8(value);
try
{
return Send(Class("NSString"), Selector("stringWithUTF8String:"), utf8);
}
finally
{
Marshal.FreeCoTaskMem(utf8);
}
}
/// <summary>Reads an NSString's UTF-8 contents, or null if the handle is nil.</summary>
public static string? ReadNsString(nint nsString)
{
if (nsString == 0)
{
return null;
}
var utf8 = Send(nsString, Selector("UTF8String"));
return utf8 == 0 ? null : Marshal.PtrToStringUTF8(utf8);
}
public static string DescribeError(nint error)
{
if (error == 0)
{
return "unknown error";
}
var description = Send(error, Selector("localizedDescription"));
var utf8 = Send(description, Selector("UTF8String"));
return Marshal.PtrToStringUTF8(utf8) ?? "unknown error";
}
}
@@ -1,50 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Metal;
// Guest draws and compute dispatches batch into one command buffer per drain
// instead of one per work item, mirroring the Vulkan presenter's batched guest
// commands: commit overhead dominated CPU time for scenes with dozens of draws
// per frame. Ordering inside the batch is by encoder sequence (snapshot blits
// for a draw's feedback reads are encoded before its render pass opens), and
// everything that must observe batched work on the serial queue — flips, image
// writes/blits, CPU-visible write-backs, the present pass — flushes first.
internal static partial class MetalVideoPresenter
{
private static nint _batchCommandBuffer;
private static bool _batchOpen;
/// <summary>Returns the open batch command buffer, opening one on first
/// use. Render thread only, like the drain it serves.</summary>
private static nint BeginBatchedGuestCommands(nint queue)
{
if (_batchOpen)
{
return _batchCommandBuffer;
}
_batchCommandBuffer = MetalNative.Send(queue, MetalNative.Selector("commandBuffer"));
_batchOpen = _batchCommandBuffer != 0;
return _batchCommandBuffer;
}
/// <summary>Commits the open batch (if any), tagging the upload pages and
/// snapshot resources it consumed. Returns the committed command buffer so
/// write-back sites can wait on it, or 0 when nothing was open.</summary>
private static nint FlushBatchedGuestCommands()
{
if (!_batchOpen)
{
return 0;
}
_batchOpen = false;
var commandBuffer = _batchCommandBuffer;
_batchCommandBuffer = 0;
MetalNative.SendVoid(commandBuffer, MetalNative.Selector("commit"));
TagUploadPages(commandBuffer);
TagSnapshotResources(commandBuffer);
return commandBuffer;
}
}
@@ -1,424 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Metal;
// Guest compute dispatches: ordered guest work like draws, with two contracts to
// honor. Storage images are shared live through the guest-image registry so a
// dispatch's writes are visible to later draws, blits, and flips of the same
// address; and CPU-visible buffer writes land back in guest memory before the
// work item completes, which is the ordering point WaitForGuestWork promises.
internal static partial class MetalVideoPresenter
{
private static readonly bool _skipAllCompute =
Environment.GetEnvironmentVariable("SHARPEMU_SKIP_ALL_COMPUTE") == "1";
private static bool _tracedDispatchBase;
private sealed record ComputeGuestDispatch(
ulong ShaderAddress,
MetalCompiledGuestShader Shader,
GuestDrawTexture[] Textures,
GuestMemoryBuffer[] GlobalMemoryBuffers,
uint GroupCountX,
uint GroupCountY,
uint GroupCountZ,
uint BaseGroupX,
uint BaseGroupY,
uint BaseGroupZ,
uint ThreadCountX,
uint ThreadCountY,
uint ThreadCountZ);
private static readonly Dictionary<MetalCompiledGuestShader, nint> _computePipelineCache = new();
public static long SubmitComputeDispatch(
ulong shaderAddress,
MetalCompiledGuestShader computeShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint groupCountX,
uint groupCountY,
uint groupCountZ,
uint baseGroupX,
uint baseGroupY,
uint baseGroupZ,
bool writesGlobalMemory,
uint threadCountX,
uint threadCountY,
uint threadCountZ)
{
var hasStorage = false;
foreach (var texture in textures)
{
hasStorage |= texture.IsStorage;
}
if (groupCountX == 0 ||
groupCountY == 0 ||
groupCountZ == 0 ||
(!hasStorage && !writesGlobalMemory))
{
return 0;
}
lock (_gate)
{
if (_closed || _thread is null)
{
return 0;
}
// Storage images a dispatch writes become flip sources and sampled
// inputs for later work, exactly like published render targets.
foreach (var texture in textures)
{
if (!texture.IsStorage || texture.Address == 0)
{
continue;
}
var guestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType);
if (guestFormat != 0)
{
_availableGuestImages[texture.Address] = guestFormat;
}
}
var sequence = EnqueueGuestWorkLocked(
new ComputeGuestDispatch(
shaderAddress,
computeShader,
ToArray(textures),
ToArray(globalMemoryBuffers),
groupCountX,
groupCountY,
groupCountZ,
baseGroupX,
baseGroupY,
baseGroupZ,
threadCountX,
threadCountY,
threadCountZ));
foreach (var texture in textures)
{
if (texture.IsStorage && texture.Address != 0)
{
_guestImageWorkSequences[texture.Address] = sequence;
}
}
return sequence;
}
}
private static void ExecuteComputeDispatch(nint device, nint queue, ComputeGuestDispatch dispatch)
{
if (_skipAllCompute)
{
ReturnPooledComputeData(dispatch);
return;
}
VideoOut.PerfOverlay.RecordDraw();
if ((dispatch.BaseGroupX | dispatch.BaseGroupY | dispatch.BaseGroupZ) != 0 &&
!_tracedDispatchBase)
{
// Metal has no dispatch-base; the translated kernel derives its ids
// from the raw grid position, so a nonzero base computes offset-zero
// work until base support lands in the emitted kernel.
_tracedDispatchBase = true;
Console.Error.WriteLine(
"[LOADER][WARN] Metal compute dispatch with nonzero base group " +
$"({dispatch.BaseGroupX},{dispatch.BaseGroupY},{dispatch.BaseGroupZ}); " +
"executing without the base offset.");
}
if (!TryGetComputePipeline(device, dispatch.Shader, out var pipeline))
{
ReturnPooledComputeData(dispatch);
return;
}
var commandBuffer = BeginBatchedGuestCommands(queue);
// Pre-resolve textures before the compute encoder opens: snapshot
// blits for feedback reads encode into the batch and encoder order
// must place them ahead of this dispatch.
Span<nint> textureHandles = stackalloc nint[dispatch.Textures.Length];
Span<bool> textureOwned = stackalloc bool[dispatch.Textures.Length];
for (var index = 0; index < dispatch.Textures.Length; index++)
{
var descriptor = dispatch.Textures[index];
if (descriptor.IsStorage && descriptor.Address != 0)
{
textureHandles[index] = EnsureStorageImage(device, descriptor)?.Texture ?? 0;
textureOwned[index] = false;
}
else
{
textureHandles[index] = CreateDrawTexture(
device, commandBuffer, descriptor, out var ownedTexture);
textureOwned[index] = ownedTexture;
}
}
var encoder = MetalNative.Send(commandBuffer, MetalNative.Selector("computeCommandEncoder"));
MetalNative.SendVoid(encoder, MetalNative.Selector("setComputePipelineState:"), pipeline);
var writeBackBuffers = new List<(nint Pointer, GuestMemoryBuffer Guest)>();
var selSetBuffer = MetalNative.Selector("setBuffer:offset:atIndex:");
var bufferCount = dispatch.GlobalMemoryBuffers.Length;
Span<uint> boundBytes = stackalloc uint[Math.Max(bufferCount, 1)];
for (var index = 0; index < bufferCount; index++)
{
var guest = dispatch.GlobalMemoryBuffers[index];
var pointer = UploadGlobalBuffer(
device, guest, out var buffer, out var offset, out boundBytes[index]);
MetalNative.SendSetBuffer(encoder, selSetBuffer, buffer, (nuint)offset, (nuint)index);
if (guest.Writable && guest.WriteBackToGuest)
{
writeBackBuffers.Add((pointer, guest));
}
}
// SharpEmuUniforms: the dispatch limit clamps the overshoot threads of the
// last threadgroup row, then each bound buffer's byte length follows
// (including the alignment-bias prefix the shader indexes past).
var shader = dispatch.Shader.Shader;
var uniforms = AllocateUpload(
device,
16 + (Math.Max(bufferCount, 1) * sizeof(uint)),
out var uniformsBuffer,
out var uniformsOffset);
WriteDispatchLimit(uniforms, 0, dispatch.ThreadCountX, dispatch.GroupCountX, shader.ThreadgroupSizeX);
WriteDispatchLimit(uniforms, 4, dispatch.ThreadCountY, dispatch.GroupCountY, shader.ThreadgroupSizeY);
WriteDispatchLimit(uniforms, 8, dispatch.ThreadCountZ, dispatch.GroupCountZ, shader.ThreadgroupSizeZ);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(uniforms[12..], 0);
for (var index = 0; index < bufferCount; index++)
{
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(
uniforms[(16 + (index * sizeof(uint)))..],
boundBytes[index]);
}
// Bind at the stage's declared SharpEmuUniforms slot (see the draw path:
// stages compute their own index from globalBufferBase + total count).
var uniformsIndex = shader.UniformsBufferIndex;
MetalNative.SendSetBuffer(
encoder,
selSetBuffer,
uniformsBuffer,
(nuint)uniformsOffset,
(nuint)(uniformsIndex >= 0 ? uniformsIndex : bufferCount));
var selSetTexture = MetalNative.Selector("setTexture:atIndex:");
for (var index = 0; index < dispatch.Textures.Length; index++)
{
var texture = textureHandles[index];
if (texture != 0)
{
MetalNative.SendSetAtIndex(encoder, selSetTexture, texture, (nuint)index);
if (textureOwned[index])
{
MetalNative.SendVoid(texture, MetalNative.Selector("release"));
}
}
}
// Samplers travel in an argument buffer bound at setBuffer (see the draw
// path), sidestepping Metal's 16-sampler-per-stage cap.
BindSamplerArgumentBuffer(device, encoder, selSetBuffer, dispatch.Shader, dispatch.Textures);
MetalNative.SendDispatch(
encoder,
MetalNative.Selector("dispatchThreadgroups:threadsPerThreadgroup:"),
new MtlSize
{
Width = dispatch.GroupCountX,
Height = dispatch.GroupCountY,
Depth = dispatch.GroupCountZ,
},
new MtlSize
{
Width = Math.Max(shader.ThreadgroupSizeX, 1),
Height = Math.Max(shader.ThreadgroupSizeY, 1),
Depth = Math.Max(shader.ThreadgroupSizeZ, 1),
});
MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding"));
// CPU-visible writes are ordering points (see the draw path): flush
// the batch and wait so the write-back lands before this work item
// completes. Pure-GPU dispatches stay in the open batch.
if (writeBackBuffers.Count > 0)
{
var committed = FlushBatchedGuestCommands();
MetalNative.SendVoid(committed, MetalNative.Selector("waitUntilCompleted"));
WriteBuffersBackToGuest(writeBackBuffers);
}
foreach (var descriptor in dispatch.Textures)
{
if (!descriptor.IsStorage || descriptor.Address == 0)
{
continue;
}
GuestImage? image;
lock (_gate)
{
_guestImages.TryGetValue(descriptor.Address, out image);
}
if (image is not null)
{
image.MarkContentChanged();
}
}
ReturnPooledComputeData(dispatch);
}
/// <summary>The live, shared storage image for a guest address: dispatches,
/// draws, blits, and flips of the same address all see one texture.</summary>
private static GuestImage? EnsureStorageImage(nint device, GuestDrawTexture descriptor)
{
lock (_gate)
{
if (_guestImages.TryGetValue(descriptor.Address, out var existing))
{
return existing;
}
}
if (descriptor.Width == 0 || descriptor.Height == 0 ||
descriptor.Width > 16384 || descriptor.Height > 16384)
{
return null;
}
var format = MetalGuestFormats.TryDecodeRenderTargetFormat(
descriptor.Format, descriptor.NumberType, out var decoded)
? decoded.Format
: MtlPixelFormat.Rgba8Unorm;
var textureDescriptor = MetalNative.SendTextureDescriptor(
MetalNative.Class("MTLTextureDescriptor"),
MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"),
(nuint)format,
descriptor.Width,
descriptor.Height,
mipmapped: false);
MetalNative.Send(
textureDescriptor,
MetalNative.Selector("setUsage:"),
(nint)(UsageShaderRead | UsageShaderWrite | UsageRenderTarget));
var image = new GuestImage
{
Texture = MetalNative.Send(
device, MetalNative.Selector("newTextureWithDescriptor:"), textureDescriptor),
Width = descriptor.Width,
Height = descriptor.Height,
Format = format,
};
if (image.Texture == 0)
{
return null;
}
var bytesPerPixel = MetalRenderTargetFormat.GetBytesPerPixel(format);
// Snapshot copies arrive in the image's native texel layout; only
// 4-byte texels can be RGBA8 verbatim, wider ones carry native bytes.
if ((ulong)descriptor.RgbaPixels.Length >= (ulong)descriptor.Width * bytesPerPixel)
{
var pitch = descriptor.Pitch != 0
? Math.Max(descriptor.Pitch, descriptor.Width)
: descriptor.Width;
ReplaceTextureContents(
image.Texture, descriptor.Width, descriptor.Height, descriptor.RgbaPixels, pitch, bytesPerPixel);
image.MarkContentChanged();
}
lock (_gate)
{
if (_guestImages.TryGetValue(descriptor.Address, out var raced))
{
MetalNative.SendVoid(image.Texture, MetalNative.Selector("release"));
return raced;
}
_guestImages[descriptor.Address] = image;
_guestImageExtents[descriptor.Address] =
(descriptor.Width, descriptor.Height, (ulong)descriptor.Width * descriptor.Height * bytesPerPixel);
}
return image;
}
private static bool TryGetComputePipeline(nint device, MetalCompiledGuestShader shader, out nint pipeline)
{
lock (_computePipelineCache)
{
if (_computePipelineCache.TryGetValue(shader, out pipeline))
{
return pipeline != 0;
}
}
var function = GetShaderFunction(device, shader);
if (function != 0)
{
nint error = 0;
pipeline = MetalNative.Send(
device,
MetalNative.Selector("newComputePipelineStateWithFunction:error:"),
function,
ref error);
if (pipeline == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Metal compute pipeline creation failed: {MetalNative.DescribeError(error)}");
}
else
{
Interlocked.Increment(ref _perfPipelineCreations);
}
}
else
{
pipeline = 0;
}
lock (_computePipelineCache)
{
_computePipelineCache[shader] = pipeline;
}
return pipeline != 0;
}
private static void WriteDispatchLimit(
Span<byte> uniforms,
int offset,
uint threadCount,
uint groupCount,
uint threadgroupSize)
{
var limit = threadCount != uint.MaxValue
? threadCount
: groupCount * Math.Max(threadgroupSize, 1);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(
uniforms[offset..],
limit);
}
private static void ReturnPooledComputeData(ComputeGuestDispatch dispatch)
{
foreach (var buffer in dispatch.GlobalMemoryBuffers)
{
if (buffer.Pooled)
{
GuestDataPool.Shared.Return(buffer.Data);
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,180 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Metal;
// Feedback reads (draws sampling a live guest render target or depth image)
// need a fresh ordered snapshot per draw. Creating and destroying an MTLTexture
// — and for depth reads a private staging MTLBuffer — per draw is measurable
// CPU and allocator churn at hundreds of feedback draws per second, so both
// recycle through a pool with the same lifecycle as the upload arena pages:
// acquired snapshots are tagged with the command buffer that samples them at
// commit, and return to the free list once that command buffer completes (the
// command queue is serial, so the earlier snapshot-blit command buffer is
// necessarily complete by then too). Everything here runs on the render thread.
internal static partial class MetalVideoPresenter
{
private const int MaxFreeSnapshotResources = 16;
private sealed class PooledSnapshotResource
{
public nint Handle;
public bool IsBuffer;
/// <summary>Texture identity (unused for buffers).</summary>
public uint Format;
public uint Width;
public uint Height;
public nint Usage;
/// <summary>Buffer capacity in bytes (unused for textures).</summary>
public nuint Capacity;
/// <summary>Retained handle of the command buffer that samples this
/// snapshot; the resource is reusable once it completes.</summary>
public nint LastCommandBuffer;
}
private static readonly List<PooledSnapshotResource> _retiredSnapshotResources = [];
private static readonly List<PooledSnapshotResource> _pendingSnapshotResources = [];
private static readonly List<PooledSnapshotResource> _freeSnapshotResources = [];
/// <summary>Returns completed snapshot resources to the free list; called
/// once per render-loop drain, next to the upload-page recycler.</summary>
private static void RecycleCompletedSnapshotResources()
{
for (var index = _retiredSnapshotResources.Count - 1; index >= 0; index--)
{
var resource = _retiredSnapshotResources[index];
if (resource.LastCommandBuffer != 0)
{
// MTLCommandBufferStatus: Completed = 4, Error = 5.
var status = MetalNative.Send(
resource.LastCommandBuffer, MetalNative.Selector("status"));
if (status < 4)
{
continue;
}
MetalNative.SendVoid(resource.LastCommandBuffer, MetalNative.Selector("release"));
resource.LastCommandBuffer = 0;
}
_retiredSnapshotResources.RemoveAt(index);
if (_freeSnapshotResources.Count < MaxFreeSnapshotResources)
{
_freeSnapshotResources.Add(resource);
}
else
{
MetalNative.SendVoid(resource.Handle, MetalNative.Selector("release"));
}
}
}
/// <summary>Pops a pooled snapshot texture matching the exact identity, or
/// creates one. The returned handle is owned by the pool — callers must not
/// release it, and it must be tagged at the next commit.</summary>
private static nint AcquireSnapshotTexture(
nint device,
MtlPixelFormat format,
uint width,
uint height,
nint usage)
{
for (var index = 0; index < _freeSnapshotResources.Count; index++)
{
var candidate = _freeSnapshotResources[index];
if (!candidate.IsBuffer &&
candidate.Format == (uint)format &&
candidate.Width == width &&
candidate.Height == height &&
candidate.Usage == usage)
{
_freeSnapshotResources.RemoveAt(index);
_pendingSnapshotResources.Add(candidate);
return candidate.Handle;
}
}
var descriptor = MetalNative.SendTextureDescriptor(
MetalNative.Class("MTLTextureDescriptor"),
MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"),
(nuint)format,
width,
height,
mipmapped: false);
MetalNative.Send(descriptor, MetalNative.Selector("setUsage:"), usage);
var handle = MetalNative.Send(
device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor);
if (handle == 0)
{
return 0;
}
_pendingSnapshotResources.Add(new PooledSnapshotResource
{
Handle = handle,
Format = (uint)format,
Width = width,
Height = height,
Usage = usage,
});
return handle;
}
/// <summary>Pops a pooled private-storage staging buffer of at least
/// <paramref name="minimumBytes"/>, or creates one. Pool-owned like
/// <see cref="AcquireSnapshotTexture"/>.</summary>
private static nint AcquireSnapshotBuffer(nint device, nuint minimumBytes)
{
for (var index = 0; index < _freeSnapshotResources.Count; index++)
{
var candidate = _freeSnapshotResources[index];
if (candidate.IsBuffer && candidate.Capacity >= minimumBytes)
{
_freeSnapshotResources.RemoveAt(index);
_pendingSnapshotResources.Add(candidate);
return candidate.Handle;
}
}
// MTLResourceStorageModePrivate = 32: staging never touches the CPU.
var handle = MetalNative.SendNewBuffer(
device, MetalNative.Selector("newBufferWithLength:options:"), minimumBytes, 32);
if (handle == 0)
{
return 0;
}
_pendingSnapshotResources.Add(new PooledSnapshotResource
{
Handle = handle,
IsBuffer = true,
Capacity = minimumBytes,
});
return handle;
}
/// <summary>Marks every snapshot resource acquired since the previous tag
/// as owing its lifetime to <paramref name="commandBuffer"/>. Called at the
/// same commit sites as <see cref="TagUploadPages"/>; a resource acquired
/// for a draw that never committed is tagged by the next commit, which is
/// conservative but safe.</summary>
private static void TagSnapshotResources(nint commandBuffer)
{
if (_pendingSnapshotResources.Count == 0)
{
return;
}
foreach (var resource in _pendingSnapshotResources)
{
resource.LastCommandBuffer = MetalNative.Send(
commandBuffer, MetalNative.Selector("retain"));
_retiredSnapshotResources.Add(resource);
}
_pendingSnapshotResources.Clear();
}
}
@@ -1,179 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Concurrent;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Gpu.Metal;
// Draw textures decoded from guest memory are cached across draws keyed by
// their full descriptor identity, mirroring the Vulkan presenter's texture
// cache: once an identity is marked cached, the AGC submit thread skips the
// guest-memory read/detile/copy entirely (shipping empty texels) and the
// render thread serves the cached MTLTexture — for scenes that sample large
// textures every draw, that per-draw copy dominated both allocation churn
// and CPU time. GuestImageWriteTracker write-protects the source pages, so
// a guest CPU write dirties the address and the entry is evicted at the next
// drain; the following draw ships fresh texels and re-populates the cache.
internal static partial class MetalVideoPresenter
{
private const int MaxCachedDrawTextures = 2048;
/// <summary>Render-thread-only cache of decoded draw textures; each value
/// holds one retain. Committed command buffers retain the textures they
/// reference, so eviction releases immediately without a GPU drain.</summary>
private static readonly Dictionary<TextureContentIdentity, nint> _drawTextureCache = new();
/// <summary>Identities the AGC submit thread may skip texel copies for.
/// Read from the submit thread, written by the render thread.</summary>
private static readonly ConcurrentDictionary<TextureContentIdentity, byte> _cachedDrawTextureIdentities = new();
internal static bool IsTextureContentCached(in TextureContentIdentity identity) =>
_cachedDrawTextureIdentities.ContainsKey(identity);
/// <summary>Builds the same identity the AGC layer checks before skipping
/// a texel copy; the two must agree field-for-field or skips and cache
/// entries would never line up.</summary>
private static TextureContentIdentity GetDrawTextureIdentity(GuestDrawTexture texture) => new(
texture.Address,
texture.Width,
texture.Height,
texture.Format,
texture.NumberType,
texture.DstSelect,
texture.TileMode,
texture.Pitch,
texture.Sampler);
/// <summary>Caching requires the write tracker: without page protection a
/// guest CPU write would never evict the entry and draws would sample
/// stale texels forever. Storage textures are shader-writable on the GPU,
/// so their content identity is not stable either.</summary>
private static bool IsCacheableDrawTexture(GuestDrawTexture texture) =>
GuestImageWriteTracker.Enabled &&
texture.Address != 0 &&
!texture.IsStorage &&
!texture.IsFallback;
private static bool TryGetCachedDrawTexture(GuestDrawTexture texture, out nint handle) =>
_drawTextureCache.TryGetValue(GetDrawTextureIdentity(texture), out handle);
private static void CacheDrawTexture(GuestDrawTexture texture, nint handle)
{
var key = GetDrawTextureIdentity(texture);
if (_drawTextureCache.Remove(key, out var previous))
{
MetalNative.SendVoid(previous, MetalNative.Selector("release"));
}
_ = MetalNative.Send(handle, MetalNative.Selector("retain"));
_drawTextureCache[key] = handle;
_cachedDrawTextureIdentities[key] = 0;
GuestImageWriteTracker.Track(
texture.Address,
(ulong)texture.RgbaPixels.Length,
Volatile.Read(ref _executingGuestWorkSequence),
"metal.texture-cache");
}
/// <summary>Runs once per drain, before any queued draw executes: a draw
/// whose texels the submit thread skipped must never resolve to an entry
/// the guest has since rewritten.</summary>
private static void EvictDirtyCachedDrawTextures()
{
if (_drawTextureCache.Count == 0)
{
return;
}
// Evict by address rather than by identity: several identities can
// share one source address (same texels, different samplers), and
// ConsumeDirty clears the flag on first read — evicting only the
// first identity would leave the others sampling stale texels.
HashSet<ulong>? dirtyAddresses = null;
foreach (var entry in _drawTextureCache)
{
if (dirtyAddresses is not null && dirtyAddresses.Contains(entry.Key.Address))
{
continue;
}
if (GuestImageWriteTracker.ConsumeDirty(entry.Key.Address))
{
(dirtyAddresses ??= []).Add(entry.Key.Address);
}
}
if (dirtyAddresses is null && _drawTextureCache.Count <= MaxCachedDrawTextures)
{
return;
}
if (_drawTextureCache.Count > MaxCachedDrawTextures)
{
foreach (var entry in _drawTextureCache)
{
MetalNative.SendVoid(entry.Value, MetalNative.Selector("release"));
}
_drawTextureCache.Clear();
_cachedDrawTextureIdentities.Clear();
return;
}
List<TextureContentIdentity>? evicted = null;
foreach (var entry in _drawTextureCache)
{
if (dirtyAddresses!.Contains(entry.Key.Address))
{
(evicted ??= []).Add(entry.Key);
}
}
if (evicted is not null)
{
foreach (var key in evicted)
{
if (_drawTextureCache.Remove(key, out var handle))
{
_cachedDrawTextureIdentities.TryRemove(key, out _);
MetalNative.SendVoid(handle, MetalNative.Selector("release"));
}
}
}
foreach (var address in dirtyAddresses!)
{
GuestImageWriteTracker.Rearm(address);
}
}
/// <summary>Self-heal for the skip/eviction race: the submit thread saw a
/// cached identity and skipped the copy, but the entry was evicted before
/// this draw executed. Read the texels directly rather than rendering a
/// fallback texture for the frame, sized with the same block-aware math
/// the draw path expects.</summary>
private static byte[]? TryReadGuestDrawTexturePixels(GuestDrawTexture texture)
{
var memory = _guestMemory;
if (memory is null || texture.Address == 0)
{
return null;
}
var width = Math.Max(texture.Width, 1u);
var height = Math.Max(texture.Height, 1u);
var rowLength = texture.TileMode == 0
? Math.Max(texture.Pitch, width)
: width;
var format = MetalGuestFormats.DecodeTextureFormat(texture.Format, texture.NumberType);
var byteCount = MetalGuestFormats.GetTextureByteCount(format, rowLength, height);
if (byteCount == 0 || byteCount > int.MaxValue)
{
return null;
}
var pixels = new byte[(int)byteCount];
return memory.TryRead(texture.Address, pixels) ? pixels : null;
}
}
@@ -1,162 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Metal;
// Per-draw upload data (guest global buffers, uniforms, vertex and index
// bytes) bump-allocates from shared-storage arena pages bound by offset,
// instead of creating one MTLBuffer and one managed copy per binding per
// draw — which dominated allocation churn (hundreds of MB/s) and held the
// guest flip rate well under the display rate. Pages recycle once the last
// command buffer that referenced them reports completion; everything here
// runs on the render thread, so no state is locked.
internal static partial class MetalVideoPresenter
{
private const int UploadPageBytes = 8 * 1024 * 1024;
// Superset of every Metal bind-offset alignment rule (constant address
// space on Intel Macs is the strictest at 256), and conveniently the
// guest storage-buffer alignment the shader bias contract assumes.
private const int UploadAlignment = 256;
private sealed class UploadPage
{
public nint Buffer;
public nint Contents;
public int Capacity;
public int Offset;
/// <summary>Retained handle of the last command buffer that consumed
/// data from this page; the page is reusable once it completes.</summary>
public nint LastCommandBuffer;
/// <summary>Stamp of the last TagUploadPages call that saw this page,
/// so a commit only re-tags pages it actually touched.</summary>
public int TouchStamp;
}
private static readonly List<UploadPage> _retiredUploadPages = [];
private static readonly Stack<UploadPage> _freeUploadPages = new();
private static readonly List<UploadPage> _touchedUploadPages = [];
private static UploadPage? _currentUploadPage;
private static int _uploadTouchStamp;
/// <summary>Returns completed pages to the free stack. Called once per
/// render-loop drain; completion is polled (command buffer status) rather
/// than block-based so the ObjC interop stays block-free.</summary>
private static void RecycleCompletedUploadPages()
{
for (var index = _retiredUploadPages.Count - 1; index >= 0; index--)
{
var page = _retiredUploadPages[index];
if (page.LastCommandBuffer != 0)
{
// MTLCommandBufferStatus: Completed = 4, Error = 5.
var status = MetalNative.Send(
page.LastCommandBuffer, MetalNative.Selector("status"));
if (status < 4)
{
continue;
}
MetalNative.SendVoid(page.LastCommandBuffer, MetalNative.Selector("release"));
page.LastCommandBuffer = 0;
}
_retiredUploadPages.RemoveAt(index);
if (page.Capacity == UploadPageBytes)
{
page.Offset = 0;
_freeUploadPages.Push(page);
}
else
{
// Oversized one-off allocation; not worth pooling.
MetalNative.SendVoid(page.Buffer, MetalNative.Selector("release"));
}
}
}
/// <summary>Bump-allocates an aligned slice for CPU-written upload data.
/// The returned span is the slice's shared-storage memory; bind the
/// buffer at the returned offset.</summary>
private static unsafe Span<byte> AllocateUpload(
nint device,
int length,
out nint buffer,
out int offset)
{
var page = _currentUploadPage;
var aligned = page is null
? 0
: (page.Offset + UploadAlignment - 1) & ~(UploadAlignment - 1);
if (page is null || aligned + length > page.Capacity)
{
if (page is not null)
{
_retiredUploadPages.Add(page);
}
page = AcquireUploadPage(device, length);
_currentUploadPage = page;
aligned = 0;
}
if (page.TouchStamp != _uploadTouchStamp)
{
page.TouchStamp = _uploadTouchStamp;
_touchedUploadPages.Add(page);
}
buffer = page.Buffer;
offset = aligned;
page.Offset = aligned + length;
return new Span<byte>((void*)(page.Contents + aligned), length);
}
private static UploadPage AcquireUploadPage(nint device, int minimumBytes)
{
if (minimumBytes <= UploadPageBytes && _freeUploadPages.Count > 0)
{
return _freeUploadPages.Pop();
}
var capacity = Math.Max(minimumBytes, UploadPageBytes);
// Options 0 = MTLResourceStorageModeShared: CPU writes are coherent
// and write-backs read the GPU's stores after waitUntilCompleted.
var handle = MetalNative.SendNewBuffer(
device, MetalNative.Selector("newBufferWithLength:options:"), (nuint)capacity, 0);
return new UploadPage
{
Buffer = handle,
Contents = MetalNative.Send(handle, MetalNative.Selector("contents")),
Capacity = capacity,
};
}
/// <summary>Marks every page touched since the previous tag as owing its
/// lifetime to <paramref name="commandBuffer"/>. Called after each commit
/// that consumed arena data.</summary>
private static void TagUploadPages(nint commandBuffer)
{
if (_touchedUploadPages.Count == 0)
{
_uploadTouchStamp++;
return;
}
foreach (var page in _touchedUploadPages)
{
if (page.LastCommandBuffer != 0)
{
MetalNative.SendVoid(page.LastCommandBuffer, MetalNative.Selector("release"));
}
page.LastCommandBuffer = MetalNative.Send(
commandBuffer, MetalNative.Selector("retain"));
}
_touchedUploadPages.Clear();
_uploadTouchStamp++;
}
}
File diff suppressed because it is too large Load Diff
@@ -15,8 +15,6 @@ namespace SharpEmu.Libs.Gpu.Vulkan;
/// </summary>
internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
{
public string BackendName => "Vulkan";
private static readonly IGuestCompiledShader DepthOnlyFragmentShader =
new VulkanCompiledGuestShader(SpirvFixedShaders.CreateDepthOnlyFragment());
@@ -345,60 +343,6 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
return false;
}
public IDisposable EnterGuestQueue(string queueName, ulong submissionId) =>
VulkanVideoPresenter.EnterGuestQueue(queueName, submissionId);
public long SubmitOrderedGuestAction(Action action, string debugName) =>
VulkanVideoPresenter.SubmitOrderedGuestAction(action, debugName);
public long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex) =>
VulkanVideoPresenter.SubmitOrderedGuestFlipWait(videoOutHandle, displayBufferIndex);
public bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite) =>
VulkanVideoPresenter.WaitForGuestWork(workSequence, timeoutMilliseconds);
public long CurrentGuestWorkSequenceForDiagnostics =>
VulkanVideoPresenter.CurrentGuestWorkSequenceForDiagnostics;
public bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType) =>
VulkanVideoPresenter.IsGuestImageUploadKnown(address, format, numberType);
public bool GuestImageWantsInitialData(ulong address) =>
VulkanVideoPresenter.GuestImageWantsInitialData(address);
public void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels) =>
VulkanVideoPresenter.ProvideGuestImageInitialData(address, rgbaPixels);
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
VulkanVideoPresenter.SubmitGuestImageFill(address, fillValue);
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels);
public bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) =>
VulkanVideoPresenter.TryGetGuestImageExtent(address, out width, out height, out byteCount);
public IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents() =>
VulkanVideoPresenter.GetGuestImageExtents();
public bool IsTextureContentCached(in TextureContentIdentity identity) =>
VulkanVideoPresenter.IsTextureContentCached(identity);
public void AttachGuestMemory(SharpEmu.HLE.ICpuMemory memory) =>
VulkanVideoPresenter.AttachGuestMemory(memory);
public ulong GuestStorageBufferOffsetAlignment =>
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment;
public void CountShaderCompilation() =>
VulkanVideoPresenter.CountSpirvCompilation();
public (long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters() =>
VulkanVideoPresenter.ReadAndResetPerfCounters();
public void RequestClose() =>
VulkanVideoPresenter.RequestClose();
private static byte[] Spirv(IGuestCompiledShader shader) =>
shader is VulkanCompiledGuestShader vulkanShader
? vulkanShader.Spirv
-11
View File
@@ -119,17 +119,6 @@ public static class JsonExports
return SetReturn(ctx, 0);
}
// Catalog alias NID for the same callback setter.
#pragma warning disable SHEM004
[SysAbiExport(
Nid = "00oCq0RwSAY",
ExportName = "_ZN3sce4Json11Initializer27setGlobalNullAccessCallbackEPFRKNS0_5ValueENS0_9ValueTypeEPS3_PvES7_",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int InitializerSetGlobalNullAccessCallbackAlt(CpuContext ctx) =>
InitializerSetGlobalNullAccessCallback(ctx);
#pragma warning restore SHEM004
[SysAbiExport(
Nid = "WSOuge5IsCg",
ExportName = "_ZN3sce4Json14InitParameter2C1Ev",
+2 -2
View File
@@ -362,7 +362,7 @@ public static class KernelExports
ExportName = "open",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int Open(CpuContext ctx) => KernelMemoryCompatExports.PosixOpen(ctx);
public static int Open(CpuContext ctx) => KernelMemoryCompatExports.KernelOpenUnderscore(ctx);
[SysAbiExport(
Nid = "1G3lF1Gg1k8",
@@ -376,7 +376,7 @@ public static class KernelExports
ExportName = "fstat",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libc")]
public static int Fstat(CpuContext ctx) => KernelMemoryCompatExports.PosixFstat(ctx);
public static int Fstat(CpuContext ctx) => KernelMemoryCompatExports.KernelFstat(ctx);
[SysAbiExport(
Nid = "hcuQgD53UxM",
@@ -267,11 +267,6 @@ public static partial class KernelMemoryCompatExports
}
var hostPath = ResolveGuestPath(guestPath);
if (string.IsNullOrEmpty(hostPath))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
try
{
using var stream = new FileStream(hostPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite);
@@ -315,11 +310,6 @@ public static partial class KernelMemoryCompatExports
var fromHost = ResolveGuestPath(fromGuest);
var toHost = ResolveGuestPath(toGuest);
if (string.IsNullOrEmpty(fromHost) || string.IsNullOrEmpty(toHost))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
try
{
if (Directory.Exists(fromHost))
File diff suppressed because it is too large Load Diff
@@ -41,87 +41,18 @@ public static class KernelPthreadCompatExports
private sealed class PthreadMutexState
{
private long _ownerThreadId;
private int _recursionCount;
private int _queuedWaiterCount;
public Lock SyncRoot { get; } = new();
public ulong OwnerThreadId
{
get => unchecked((ulong)Volatile.Read(ref _ownerThreadId));
set => Volatile.Write(ref _ownerThreadId, unchecked((long)value));
}
public int RecursionCount
{
get => Volatile.Read(ref _recursionCount);
set => Volatile.Write(ref _recursionCount, value);
}
public int QueuedWaiterCount => Volatile.Read(ref _queuedWaiterCount);
public ulong OwnerThreadId { get; set; }
public int RecursionCount { get; set; }
public int Type { get; set; } = MutexTypeErrorCheck;
public int Protocol { get; set; }
public LinkedList<PthreadMutexWaiter> Waiters { get; } = new();
public bool TryAcquireUncontended(ulong threadId, bool allowWaiterBarge)
{
if (!allowWaiterBarge && QueuedWaiterCount != 0)
{
return false;
}
return TryAcquireOwner(threadId);
}
public bool TryAcquireOwner(ulong threadId)
{
if (Interlocked.CompareExchange(
ref _ownerThreadId,
unchecked((long)threadId),
0) != 0)
{
return false;
}
Volatile.Write(ref _recursionCount, 1);
return true;
}
public bool TryReleaseUncontended(ulong threadId)
{
if (QueuedWaiterCount != 0 || RecursionCount != 1)
{
return false;
}
Volatile.Write(ref _recursionCount, 0);
if (Interlocked.CompareExchange(
ref _ownerThreadId,
0,
unchecked((long)threadId)) == unchecked((long)threadId))
{
return true;
}
Volatile.Write(ref _recursionCount, 1);
return false;
}
public int IncrementRecursion() => Interlocked.Increment(ref _recursionCount);
public int DecrementRecursion() => Interlocked.Decrement(ref _recursionCount);
public void WaiterAddedLocked() => Interlocked.Increment(ref _queuedWaiterCount);
public void WaiterRemovedLocked() => Interlocked.Decrement(ref _queuedWaiterCount);
}
private sealed class PthreadMutexWaiter
{
public required ulong ThreadId { get; init; }
public required string WakeKey { get; init; }
public required bool Cooperative { get; set; }
public ManualResetEventSlim? HostSignal { get; set; }
public required bool Cooperative { get; init; }
public LinkedListNode<PthreadMutexWaiter>? Node { get; set; }
public int Granted;
}
@@ -163,10 +94,7 @@ public static class KernelPthreadCompatExports
public static int PthreadSelf(CpuContext ctx)
{
var currentThreadHandle = KernelPthreadState.GetCurrentThreadHandle();
if (GuestThreadExecution.CurrentGuestThreadHandle != currentThreadHandle)
{
GuestThreadExecution.Scheduler?.RegisterGuestThreadContext(currentThreadHandle, ctx);
}
GuestThreadExecution.Scheduler?.RegisterGuestThreadContext(currentThreadHandle, ctx);
ctx[CpuRegister.Rax] = currentThreadHandle;
TracePthreadSelf(ctx, currentThreadHandle);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
@@ -211,13 +139,6 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "B5GmVDKwpn0",
ExportName = "pthread_yield",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixPthreadYield(CpuContext ctx) => PthreadYield(ctx);
[SysAbiExport(
Nid = "GBUY7ywdULE",
ExportName = "scePthreadRename",
@@ -650,30 +571,6 @@ public static class KernelPthreadCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
/// <summary>
/// The POSIX-named alias of <see cref="PthreadOnce"/>. libKernel exports the
/// same routine under two NIDs, and shipped middleware links the plain name:
/// DOOM's libcohtml, PlayFab and party modules all import this one rather
/// than scePthreadOnce.
/// </summary>
[SysAbiExport(
Nid = "Z4QosVuAsA0",
ExportName = "pthread_once",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadOncePOSIX(CpuContext ctx) => PthreadOnce(ctx);
/// <summary>
/// The POSIX-named alias of <see cref="PthreadRename"/>, following the same
/// two-NID pattern as <see cref="PthreadOncePOSIX"/>.
/// </summary>
[SysAbiExport(
Nid = "9vyP6Z7bqzc",
ExportName = "pthread_rename_np",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadRenameNpPOSIX(CpuContext ctx) => PthreadRename(ctx);
private static int PthreadMutexInitCore(CpuContext ctx, ulong mutexAddress, ulong attrAddress)
{
if (mutexAddress == 0)
@@ -724,7 +621,7 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
lock (state.SyncRoot)
lock (state)
{
if (state.OwnerThreadId != 0 || state.RecursionCount != 0 || state.Waiters.Count != 0)
{
@@ -756,63 +653,11 @@ public static class KernelPthreadCompatExports
}
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
if (state.TryAcquireUncontended(currentThreadId, allowWaiterBarge: tryOnly))
{
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (state.OwnerThreadId == currentThreadId)
{
if (state.Type == MutexTypeRecursive)
{
state.IncrementRecursion();
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (!tryOnly && state.Type == MutexTypeAdaptiveNp &&
IsGuestTrackedSelfLock(ctx, mutexAddress, currentThreadId))
{
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
}
if (state.Type == MutexTypeAdaptiveNp)
{
var adaptiveResult = tryOnly
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
: (int)OrbisGen2Result.ORBIS_GEN2_OK;
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock-idempotent", mutexAddress, resolvedAddress, state, currentThreadId, adaptiveResult);
return adaptiveResult;
}
if (state.Type == MutexTypeNormal)
{
if (tryOnly)
{
TracePthreadMutex(ctx, "trylock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
state.IncrementRecursion();
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
var ownedResult = tryOnly
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, ownedResult);
return ownedResult;
}
var canCooperativelyBlock = !tryOnly &&
GuestThreadExecution.IsGuestThread &&
GuestThreadExecution.TryGetCurrentImportCallFrame(out _);
PthreadMutexWaiter? waiter = null;
var acquiredWhileQueueing = false;
lock (state.SyncRoot)
lock (state)
{
if (state.OwnerThreadId == currentThreadId)
{
@@ -823,30 +668,7 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (!tryOnly && state.Type == MutexTypeAdaptiveNp &&
IsGuestTrackedSelfLock(ctx, mutexAddress, currentThreadId))
{
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
}
if (state.Type == MutexTypeAdaptiveNp)
{
if (tryOnly)
{
TracePthreadMutex(ctx, "trylock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
// Gen5 runtime wrappers can layer an adaptive lock call over
// scePthreadMutexLock for one logical acquisition, followed by
// only one unlock. Keep the duplicate acquisition idempotent so
// the matching unlock fully releases the HLE mutex.
TracePthreadMutex(ctx, "lock-idempotent", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (state.Type == MutexTypeNormal)
if (state.Type is MutexTypeNormal or MutexTypeAdaptiveNp)
{
if (tryOnly)
{
@@ -855,7 +677,7 @@ public static class KernelPthreadCompatExports
}
// Several Gen5 runtimes layer their own owner/count bookkeeping
// over a NORMAL kernel mutex. Returning EDEADLK here
// over a NORMAL or ADAPTIVE kernel mutex. Returning EDEADLK here
// leaves that guest bookkeeping out of sync with the HLE owner and
// turns the wrapper into a permanent lock/unlock retry loop. Keep
// the compatibility recursion used by the original implementation;
@@ -874,17 +696,10 @@ public static class KernelPthreadCompatExports
}
}
// pthread_mutex_trylock succeeds whenever the mutex is not currently
// held; unlike the blocking lock it does not queue behind waiters
// (POSIX gives it no fairness obligation). Gating trylock on an empty
// wait queue is wrong and, worse, lets a single stale/undrainable
// waiter wedge a spin-on-trylock loop forever even though the mutex
// is free (owner==0). The blocking lock still honours FIFO so real
// blocked waiters are not starved by a barging locker.
if (state.OwnerThreadId == 0 &&
(tryOnly || state.Waiters.Count == 0) &&
state.TryAcquireOwner(currentThreadId))
if (state.OwnerThreadId == 0 && state.Waiters.Count == 0)
{
state.OwnerThreadId = currentThreadId;
state.RecursionCount = 1;
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -896,14 +711,6 @@ public static class KernelPthreadCompatExports
}
waiter = EnqueueMutexWaiterLocked(state, currentThreadId, canCooperativelyBlock);
acquiredWhileQueueing = TryGrantMutexWaiterLocked(state, waiter);
}
if (acquiredWhileQueueing)
{
waiter!.HostSignal?.Dispose();
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (canCooperativelyBlock && waiter is not null &&
@@ -937,29 +744,8 @@ public static class KernelPthreadCompatExports
}
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
if (state.OwnerThreadId == currentThreadId)
{
if (state.RecursionCount > 1)
{
state.DecrementRecursion();
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (state.TryReleaseUncontended(currentThreadId))
{
if (state.QueuedWaiterCount != 0)
{
WakeFirstMutexWaiter(state);
}
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
}
PthreadMutexWaiter? nextWaiter = null;
lock (state.SyncRoot)
string? nextWakeKey = null;
lock (state)
{
if (state.RecursionCount <= 0)
{
@@ -977,29 +763,16 @@ public static class KernelPthreadCompatExports
if (state.RecursionCount == 0)
{
state.OwnerThreadId = 0;
// Hand the mutex directly to the head waiter instead of only
// waking it and relying on it to re-acquire. A woken waiter that
// fails to self-grant (its wake races or is lost) would leave the
// mutex "free with a queued waiter"; the fast-acquire path refuses
// such a mutex (OwnerThreadId == 0 && Waiters.Count == 0), so every
// later locker — including the game's main thread — then queues
// behind a head that never advances and the process wedges.
if (state.Waiters.First is { } headNode &&
TryGrantMutexWaiterLocked(state, headNode.Value))
{
nextWaiter = headNode.Value;
if (!nextWaiter.Cooperative)
{
nextWaiter.HostSignal!.Set();
}
}
nextWakeKey = state.Waiters.First?.Value.Cooperative == true
? state.Waiters.First.Value.WakeKey
: null;
Monitor.PulseAll(state);
}
}
if (nextWaiter is { Cooperative: true })
if (nextWakeKey is not null)
{
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWaiter.WakeKey, 1);
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWakeKey, 1);
}
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
@@ -1459,22 +1232,8 @@ public static class KernelPthreadCompatExports
}
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
lock (mutexState.SyncRoot)
lock (mutexState)
{
if (mutexState.OwnerThreadId == 0 && mutexState.RecursionCount == 0)
{
// The guest holds the mutex through a path our host-side tracking
// never observed — most commonly libkernel's uncontended userspace
// fast-path, which locks the mutex word directly without an HLE
// call. Real pthread_cond_wait requires the caller to own the
// mutex and does not verify it for normal mutexes, so returning
// EPERM here is wrong: it spins the guest and, worse, leaves the
// mutex held (the unlock below is skipped), wedging every thread
// that later blocks on pthread_mutex_lock. Adopt ownership so the
// unlock/wait/re-lock cycle is balanced and releases the mutex.
_ = mutexState.TryAcquireOwner(currentThreadId);
}
if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
{
return mutexState.OwnerThreadId == currentThreadId
@@ -1626,32 +1385,6 @@ public static class KernelPthreadCompatExports
bool cooperative,
string? wakeKey = null)
{
// A guest thread can have at most one pending acquisition on a mutex —
// it is either running or blocked on exactly one wait. If a waiter for
// this thread is still queued when it comes back for a fresh
// acquisition, that entry is a stale leftover the thread abandoned
// (most often a cond_timedwait timeout whose re-acquire hand-off was
// lost). Stale entries clog the FIFO head with waiters no thread is
// blocked on, so the unlock hand-off wakes a dead wake-key and the
// mutex wedges permanently (observed deadlocking Hades: several
// re-acquire waiters from one thread piled ahead of a live locker).
// Prune any prior entry for this thread before enqueueing the new one.
if (threadId != 0)
{
for (var node = state.Waiters.First; node is not null;)
{
var next = node.Next;
if (node.Value.ThreadId == threadId)
{
state.Waiters.Remove(node);
state.WaiterRemovedLocked();
node.Value.Node = null;
}
node = next;
}
}
var waiter = new PthreadMutexWaiter
{
ThreadId = threadId,
@@ -1659,10 +1392,8 @@ public static class KernelPthreadCompatExports
WakeKey = cooperative
? wakeKey ?? $"pthread_mutex_waiter:{Interlocked.Increment(ref _nextSynchronizationWaiterId)}"
: string.Empty,
HostSignal = cooperative ? null : new ManualResetEventSlim(initialState: false),
};
waiter.Node = state.Waiters.AddLast(waiter);
state.WaiterAddedLocked();
return waiter;
}
@@ -1672,7 +1403,7 @@ public static class KernelPthreadCompatExports
var mutex = new PthreadMutexState();
PthreadMutexWaiter first;
PthreadMutexWaiter second;
lock (mutex.SyncRoot)
lock (mutex)
{
first = EnqueueMutexWaiterLocked(mutex, 0x101, cooperative: false);
second = EnqueueMutexWaiterLocked(mutex, 0x202, cooperative: false);
@@ -1716,72 +1447,26 @@ public static class KernelPthreadCompatExports
return false;
}
if (!state.TryAcquireOwner(waiter.ThreadId))
{
return false;
}
state.Waiters.Remove(waiter.Node);
state.WaiterRemovedLocked();
waiter.Node = null;
state.OwnerThreadId = waiter.ThreadId;
state.RecursionCount = 1;
Volatile.Write(ref waiter.Granted, 1);
Monitor.PulseAll(state);
return true;
}
private static void WakeFirstMutexWaiter(PthreadMutexState state)
{
PthreadMutexWaiter? nextWaiter;
lock (state.SyncRoot)
{
if (state.OwnerThreadId != 0)
{
return;
}
nextWaiter = state.Waiters.First?.Value;
if (nextWaiter is { Cooperative: false })
{
nextWaiter.HostSignal!.Set();
}
}
if (nextWaiter is { Cooperative: true })
{
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWaiter.WakeKey, 1);
}
}
private static int WaitForHostMutexLock(PthreadMutexState state, PthreadMutexWaiter waiter)
{
ManualResetEventSlim? hostSignal = null;
try
lock (state)
{
while (true)
while (!TryGrantMutexWaiterLocked(state, waiter))
{
lock (state.SyncRoot)
{
if (waiter.HostSignal is null)
{
waiter.Cooperative = false;
waiter.HostSignal = new ManualResetEventSlim(initialState: false);
}
hostSignal = waiter.HostSignal;
if (TryGrantMutexWaiterLocked(state, waiter))
{
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
hostSignal.Reset();
}
hostSignal.Wait();
Monitor.Wait(state);
}
}
finally
{
hostSignal?.Dispose();
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
private static bool TryGrantBlockedMutexLock(
@@ -1792,7 +1477,7 @@ public static class KernelPthreadCompatExports
PthreadMutexWaiter waiter)
{
var granted = false;
lock (state.SyncRoot)
lock (state)
{
granted = TryGrantMutexWaiterLocked(state, waiter);
}
@@ -1826,10 +1511,6 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
private static bool IsGuestTrackedSelfLock(CpuContext ctx, ulong mutexAddress, ulong currentThreadId) =>
KernelMemoryCompatExports.TryReadUInt64Compat(ctx, mutexAddress + 8, out var guestOwner) &&
guestOwner == currentThreadId;
private static bool CompleteCondWaiterLocked(
PthreadCondState state,
PthreadCondWaiter waiter,
@@ -1845,7 +1526,7 @@ public static class KernelPthreadCompatExports
waiter.TimeoutTimer?.Dispose();
waiter.TimeoutTimer = null;
lock (waiter.MutexState.SyncRoot)
lock (waiter.MutexState)
{
waiter.MutexWaiter = EnqueueMutexWaiterLocked(
waiter.MutexState,
@@ -1893,7 +1574,7 @@ public static class KernelPthreadCompatExports
return false;
}
lock (waiter.MutexState.SyncRoot)
lock (waiter.MutexState)
{
return TryGrantMutexWaiterLocked(waiter.MutexState, mutexWaiter);
}
@@ -860,18 +860,6 @@ public static class KernelPthreadExtendedCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
/// <summary>
/// The POSIX-named alias of <see cref="PthreadAttrGetschedparam"/>. libKernel
/// exports the same routine under two NIDs; middleware compiled against the
/// plain POSIX headers links this one rather than scePthreadAttrGetschedparam.
/// </summary>
[SysAbiExport(
Nid = "qlk9pSLsUmM",
ExportName = "pthread_attr_getschedparam",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrGetschedparamPOSIX(CpuContext ctx) => PthreadAttrGetschedparam(ctx);
[SysAbiExport(
Nid = "FXPWHNk8Of0",
ExportName = "scePthreadAttrGetschedparam",
@@ -1145,90 +1133,6 @@ public static class KernelPthreadExtendedCompatExports
LibraryName = "libKernel")]
public static int PosixPthreadRwlockWrlock(CpuContext ctx) => PthreadRwlockWrlock(ctx);
[SysAbiExport(
Nid = "SFxTMOfuCkE",
ExportName = "pthread_rwlock_tryrdlock",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixPthreadRwlockTryrdlock(CpuContext ctx) =>
PthreadRwlockTryLockCore(ctx, ctx[CpuRegister.Rdi], write: false);
[SysAbiExport(
Nid = "XhWHn6P5R7U",
ExportName = "pthread_rwlock_trywrlock",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixPthreadRwlockTrywrlock(CpuContext ctx) =>
PthreadRwlockTryLockCore(ctx, ctx[CpuRegister.Rdi], write: true);
/// <summary>
/// Non-blocking counterpart of <see cref="PthreadRwlockLockCore"/>: acquires
/// only if the lock is free right now, otherwise reports BUSY.
/// </summary>
/// <remarks>
/// Deliberately not routed through TryAcquireBlockedRwlock. That helper exists
/// for the scheduler resume path and decrements WaitingWriters on success,
/// which is correct only for a thread that previously incremented it. A fresh
/// try never did, so reusing it would silently consume another thread's
/// waiter count and let a queued writer be skipped.
/// </remarks>
private static int PthreadRwlockTryLockCore(CpuContext ctx, ulong rwlockAddress, bool write)
{
if (rwlockAddress == 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
if (!TryResolveRwlockState(ctx, rwlockAddress, createIfZero: true, out var resolvedAddress, out var rwlock))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
lock (rwlock.SyncRoot)
{
if (write)
{
if (rwlock.WriterThreadId == currentThreadId || rwlock.GetReaderCount(currentThreadId) > 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
}
// Mirrors the blocking path's re-entrant compat-writer grant so the
// two agree on what counts as already owning the lock.
if (rwlock.CompatWriterCounts.GetValueOrDefault(currentThreadId) > 0)
{
rwlock.AddCompatWriter(currentThreadId);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (rwlock.WriterThreadId != 0 ||
rwlock.ReaderTotalCount != 0 ||
rwlock.CompatWriterTotalCount != 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
DetectRwlockWriterConflict(resolvedAddress, rwlock, currentThreadId, "trywrlock");
rwlock.WriterThreadId = currentThreadId;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (rwlock.WriterThreadId == currentThreadId)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
}
if (ReaderMustWaitForRwlock(rwlock, currentThreadId))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
rwlock.AddReader(currentThreadId);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
}
[SysAbiExport(
Nid = "+L98PIbGttk",
ExportName = "scePthreadRwlockUnlock",
@@ -1915,94 +1819,4 @@ public static class KernelPthreadExtendedCompatExports
BinaryPrimitives.WriteInt32LittleEndian(bytes, value);
return ctx.Memory.TryWrite(address, bytes);
}
// POSIX-named aliases. libKernel exports each of these routines under two
// NIDs -- a scePthread* name and the plain POSIX name -- and middleware
// compiled against POSIX headers links the latter. Both take identical
// arguments and, per the convention already used by scePthreadOnce's alias,
// return the same OrbisGen2Result rather than translating to errno.
[SysAbiExport(
Nid = "a2P9wYGeZvc",
ExportName = "pthread_setprio",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSetprioPOSIX(CpuContext ctx) => PthreadSetprio(ctx);
[SysAbiExport(
Nid = "FIs3-UQT9sg",
ExportName = "pthread_getschedparam",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadGetschedparamPOSIX(CpuContext ctx) => PthreadGetschedparam(ctx);
[SysAbiExport(
Nid = "vQm4fDEsWi8",
ExportName = "pthread_attr_getstack",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrGetstackPOSIX(CpuContext ctx) => PthreadAttrGetstack(ctx);
[SysAbiExport(
Nid = "Ucsu-OK+els",
ExportName = "pthread_attr_get_np",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrGetNpPOSIX(CpuContext ctx) => PthreadAttrGet(ctx);
[SysAbiExport(
Nid = "JarMIy8kKEY",
ExportName = "pthread_attr_setschedpolicy",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrSetschedpolicyPOSIX(CpuContext ctx) => PthreadAttrSetschedpolicy(ctx);
[SysAbiExport(
Nid = "E+tyo3lp5Lw",
ExportName = "pthread_attr_setdetachstate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrSetdetachstatePOSIX(CpuContext ctx) => PthreadAttrSetdetachstate(ctx);
[SysAbiExport(
Nid = "euKRgm0Vn2M",
ExportName = "pthread_attr_setschedparam",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrSetschedparamPOSIX(CpuContext ctx) => PthreadAttrSetschedparam(ctx);
[SysAbiExport(
Nid = "7ZlAakEf0Qg",
ExportName = "pthread_attr_setinheritsched",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrSetinheritschedPOSIX(CpuContext ctx) => PthreadAttrSetinheritsched(ctx);
[SysAbiExport(
Nid = "0qOtCR-ZHck",
ExportName = "pthread_attr_getstacksize",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrGetstacksizePOSIX(CpuContext ctx) => PthreadAttrGetstacksize(ctx);
[SysAbiExport(
Nid = "VUT1ZSrHT0I",
ExportName = "pthread_attr_getdetachstate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrGetdetachstatePOSIX(CpuContext ctx) => PthreadAttrGetdetachstate(ctx);
[SysAbiExport(
Nid = "JKyG3SWyA10",
ExportName = "pthread_attr_setguardsize",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrSetguardsizePOSIX(CpuContext ctx) => PthreadAttrSetguardsize(ctx);
[SysAbiExport(
Nid = "JNkVVsVDmOk",
ExportName = "pthread_attr_getguardsize",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrGetguardsizePOSIX(CpuContext ctx) => PthreadAttrGetguardsize(ctx);
}
@@ -2058,13 +2058,6 @@ public static class KernelRuntimeCompatExports
LibraryName = "libKernel")]
public static int KernelNanosleep(CpuContext ctx) => NanosleepCore(ctx, posix: false);
[SysAbiExport(
Nid = "NhpspxdjEKU",
ExportName = "_nanosleep",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixNanosleepUnderscore(CpuContext ctx) => NanosleepCore(ctx, posix: true);
[SysAbiExport(
Nid = "yS8U2TGCe1A",
ExportName = "nanosleep",
@@ -191,27 +191,6 @@ public static class KernelSemaphoreCompatExports
WakePredicate,
deadline))
{
// A signal may have arrived between releasing the semaphore gate
// (after incrementing WaitingThreads) and the scheduler registering
// this block. When that happens WakeBlockedThreads cannot find the
// waiter yet and the exit-handler re-check runs later; a re-check
// here keeps the thread from yielding to the scheduler at all when
// the count is already sufficient.
lock (semaphore.Gate)
{
if (semaphore.Count >= needCount)
{
semaphore.Count -= needCount;
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
GuestThreadExecution.TryConsumeCurrentThreadBlock(out _);
if (_traceSema)
{
TraceSemaphore($"wait-recheck handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
}
if (_traceSema)
{
TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
@@ -449,22 +428,6 @@ public static class KernelSemaphoreCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "GEnUkDZoUwY",
ExportName = "scePthreadSemInit",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemInit(CpuContext ctx)
{
// scePthreadSemInit(sem, flag, value, name) seems to only support private semaphores
if (ctx[CpuRegister.Rsi] != 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
return PosixSemInit(ctx);
}
[SysAbiExport(
Nid = "YCV5dGGBcCo",
ExportName = "sem_wait",
@@ -483,13 +446,6 @@ public static class KernelSemaphoreCompatExports
return KernelWaitSema(ctx);
}
[SysAbiExport(
Nid = "C36iRE0F5sE",
ExportName = "scePthreadSemWait",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemWait(CpuContext ctx) => PosixSemWait(ctx);
[SysAbiExport(
Nid = "WBWzsRifCEA",
ExportName = "sem_trywait",
@@ -507,19 +463,6 @@ public static class KernelSemaphoreCompatExports
return KernelPollSema(ctx, handle, 1);
}
[SysAbiExport(
Nid = "H2a+IN9TP0E",
ExportName = "scePthreadSemTrywait",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemTryWait(CpuContext ctx)
{
var result = PosixSemTryWait(ctx);
return result == (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
? SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN)
: result;
}
[SysAbiExport(
Nid = "w5IHyvahg-o",
ExportName = "sem_timedwait",
@@ -556,13 +499,6 @@ public static class KernelSemaphoreCompatExports
return KernelSignalSema(ctx, handle, 1);
}
[SysAbiExport(
Nid = "aishVAiFaYM",
ExportName = "scePthreadSemPost",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemPost(CpuContext ctx) => PosixSemPost(ctx);
[SysAbiExport(
Nid = "Bq+LRV-N6Hk",
ExportName = "sem_getvalue",
@@ -613,13 +549,6 @@ public static class KernelSemaphoreCompatExports
return result;
}
[SysAbiExport(
Nid = "Vwc+L05e6oE",
ExportName = "scePthreadSemDestroy",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemDestroy(CpuContext ctx) => PosixSemDestroy(ctx);
private static bool TryGetPosixSemaphoreHandle(CpuContext ctx, ulong semaphoreAddress, out uint handle)
{
handle = 0;
@@ -1,140 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Concurrent;
using System.Threading;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Kernel;
// libKernel's address-wait primitives (sceKernelSyncOnAddress*) are the PS5's
// futex-style wait/wake: a thread parks on a guest address until another thread
// wakes that address. Guest runtimes (seen driving Juicy Realm, PPSA19268)
// build their own spinlocks/queues on top of it and call the wait in a hot
// loop; left unimplemented, every wait returns immediately and the runtime
// busy-spins forever (millions of calls, no forward progress).
//
// This implements wait/wake over the existing cooperative-block scheduler,
// keyed on the address. The real primitive takes a compare value so the wait
// only sleeps while the address still holds the expected value; that exact
// value is not recovered here, so each wait is given a bounded deadline and
// treated as a spurious-wakeup-tolerant park: a genuinely missed wake
// self-heals when the deadline expires and the guest re-checks its own
// condition, which futex callers already tolerate. A matching wake releases
// waiters immediately through the same key.
public static class KernelSyncOnAddressCompatExports
{
// Safety-net poll interval. Real releases come from the wake side (generation
// bump + WakeBlockedThreads); this only bounds how long a wait that genuinely
// raced/missed its wake stays parked before the guest re-evaluates. Kept
// large: a short interval turns every parked waiter into a hot re-poll that
// steals scheduler bandwidth from the threads that actually make progress
// (including the ones that would issue the wake), so it must be a rare last
// resort, not a spin substitute.
private static readonly TimeSpan WaitSelfHealTimeout = TimeSpan.FromMilliseconds(100);
// Per-address host gate for the non-cooperative (host main thread) fallback,
// which cannot use the guest-thread scheduler's block mechanism.
private static readonly ConcurrentDictionary<ulong, object> _hostAddressGates = new();
// Per-address wake generation. A wait captures the current generation and
// its wake predicate stays unsatisfied (keeps the thread parked) until a
// wake bumps it. This is what actually holds the thread blocked: a bare
// "always satisfied" predicate is treated as an immediate late-arrival by
// the dispatcher's race guard and never yields, leaving the guest to
// busy-spin. The generation also closes the register-vs-park race for free:
// a wake landing in that window bumps the generation, so the predicate is
// already satisfied and the guest correctly resumes at once.
private static readonly ConcurrentDictionary<ulong, long> _wakeGenerations = new();
private static long CurrentGeneration(ulong address) =>
_wakeGenerations.TryGetValue(address, out var generation) ? generation : 0;
private static string WakeKey(ulong address) => $"sceKernelSyncOnAddress:{address:X16}";
[SysAbiExport(
Nid = "Hc4CaR6JBL0",
ExportName = "sceKernelSyncOnAddressWait",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int SyncOnAddressWait(CpuContext ctx)
{
var address = ctx[CpuRegister.Rdi];
if (address == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var observedGeneration = CurrentGeneration(address);
var deadline = GuestThreadExecution.ComputeDeadlineTimestamp(WaitSelfHealTimeout);
// Cooperative path: stay parked until a wake bumps this address's
// generation (or the deadline expires as a self-heal). The guest
// re-evaluates its own condition after resuming.
if (GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
"sceKernelSyncOnAddressWait",
WakeKey(address),
resumeHandler: () => (int)OrbisGen2Result.ORBIS_GEN2_OK,
wakeHandler: () => CurrentGeneration(address) != observedGeneration,
deadline))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
// Non-cooperative caller (host main thread): bounded host wait so a
// missed wake self-heals instead of hanging.
var gate = _hostAddressGates.GetOrAdd(address, static _ => new object());
lock (gate)
{
if (CurrentGeneration(address) == observedGeneration)
{
Monitor.Wait(gate, WaitSelfHealTimeout);
}
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "q2y-wDIVWZA",
ExportName = "sceKernelSyncOnAddressWake",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int SyncOnAddressWake(CpuContext ctx)
{
var address = ctx[CpuRegister.Rdi];
if (address == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
// rsi carries the number of waiters to release (1 = wake-one, a large
// value = wake-all); default to all if it looks unset.
var requested = unchecked((long)ctx[CpuRegister.Rsi]);
var wakeCount = requested is > 0 and < int.MaxValue ? (int)requested : int.MaxValue;
// Bump the generation first so a wait that has registered but not yet
// parked sees the change and resumes instead of missing this wake.
_wakeGenerations.AddOrUpdate(address, 1, static (_, current) => current + 1);
GuestThreadExecution.Scheduler?.WakeBlockedThreads(WakeKey(address), wakeCount);
if (_hostAddressGates.TryGetValue(address, out var gate))
{
lock (gate)
{
Monitor.PulseAll(gate);
}
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
private static int SetReturn(CpuContext ctx, OrbisGen2Result result)
{
var value = (int)result;
ctx[CpuRegister.Rax] = unchecked((ulong)value);
return value;
}
}
@@ -18,8 +18,7 @@ internal static class KernelVirtualRangeAllocator
bool allowSearch,
bool allowAllocateAtAlternative,
string traceName,
out ulong mappedAddress,
bool backPartialOverlap = false)
out ulong mappedAddress)
{
mappedAddress = 0;
if (length == 0)
@@ -43,18 +42,6 @@ internal static class KernelVirtualRangeAllocator
return true;
}
// Fixed mappings must cover the whole requested window even when part of
// it is already backed by another allocation. The single-call AllocateAt
// below is all-or-nothing and fails outright on partial overlap, leaving
// the untouched pages unmapped for the guest to fault into. Fill the free
// pages directly instead.
if (backPartialOverlap &&
addressSpace.TryBackFixedRange(desiredAddress, length, executable))
{
mappedAddress = desiredAddress;
return true;
}
var allocated = addressSpace.AllocateAt(desiredAddress, length, executable, allowAllocateAtAlternative);
if (allocated == 0)
{
-206
View File
@@ -184,212 +184,6 @@ public static class NetExports
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
/// <summary>
/// POSIX alias of <see cref="NetSetsockopt"/>; identical
/// (fd, level, option, value, length) argument order.
/// </summary>
[SysAbiExport(
Nid = "fFxGkxF2bVo",
ExportName = "setsockopt",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixSetsockopt(CpuContext ctx) => NetSetsockopt(ctx);
/// <summary>
/// Reads back the socket options this backend actually tracks: SO_NBIO,
/// SO_REUSEADDR and SO_ERROR.
/// </summary>
/// <remarks>
/// Anything else returns EINVAL rather than a zero-filled buffer. A caller
/// that receives success for an option nobody stored would treat whatever
/// happens to be in its output buffer as the real setting, which is a harder
/// failure to trace than an explicit rejection.
/// </remarks>
[SysAbiExport(
Nid = "6O8EwYOgH9Y",
ExportName = "getsockopt",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixGetsockopt(CpuContext ctx)
{
var id = unchecked((int)ctx[CpuRegister.Rdi]);
var level = unchecked((int)ctx[CpuRegister.Rsi]);
var option = unchecked((int)ctx[CpuRegister.Rdx]);
var valueAddress = ctx[CpuRegister.Rcx];
var lengthAddress = ctx[CpuRegister.R8];
if (!_sockets.TryGetValue(id, out var socket))
{
return SetNetError(ctx, NetErrorBadFileDescriptor, NetErrnoBadFileDescriptor);
}
if (valueAddress == 0 || lengthAddress == 0 || level != 0xFFFF)
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
Span<byte> lengthBytes = stackalloc byte[sizeof(int)];
if (!ctx.Memory.TryRead(lengthAddress, lengthBytes))
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
if (BinaryPrimitives.ReadInt32LittleEndian(lengthBytes) < sizeof(int))
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
int value;
switch (option)
{
// ORBIS_NET_SO_NBIO: mirrors what sceNetSetsockopt stored.
case 0x1200:
value = socket.Blocking ? 0 : 1;
break;
case 0x0004:
value = (int)socket.GetSocketOption(
SocketOptionLevel.Socket,
SocketOptionName.ReuseAddress)! != 0 ? 1 : 0;
break;
// ORBIS_NET_SO_ERROR: nothing here records per-socket async errors,
// so report "no pending error" rather than inventing one.
case 0x1007:
value = 0;
break;
default:
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
Span<byte> valueBytes = stackalloc byte[sizeof(int)];
BinaryPrimitives.WriteInt32LittleEndian(valueBytes, value);
BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, sizeof(int));
if (!ctx.Memory.TryWrite(valueAddress, valueBytes) ||
!ctx.Memory.TryWrite(lengthAddress, lengthBytes))
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
TraceNet("socket.getsockopt", id, unchecked((uint)option), unchecked((uint)value), 0);
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "fZOeZIOEmLw",
ExportName = "send",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixSend(CpuContext ctx)
{
var id = unchecked((int)ctx[CpuRegister.Rdi]);
var bufferAddress = ctx[CpuRegister.Rsi];
var length = unchecked((int)ctx[CpuRegister.Rdx]);
if (!_sockets.TryGetValue(id, out var socket))
{
return SetNetError(ctx, NetErrorBadFileDescriptor, NetErrnoBadFileDescriptor);
}
if (length < 0 || (length != 0 && bufferAddress == 0))
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
if (length == 0)
{
return ctx.SetReturn(0);
}
var payload = new byte[length];
if (!ctx.Memory.TryRead(bufferAddress, payload))
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
try
{
var sent = socket.Send(payload, SocketFlags.None);
TraceNet("socket.send", id, unchecked((uint)length), unchecked((uint)sent), 0);
return ctx.SetReturn(sent);
}
catch (SocketException exception)
when (exception.SocketErrorCode == SocketError.WouldBlock)
{
return SetNetError(ctx, NetErrorWouldBlock, NetErrnoWouldBlock);
}
catch (SocketException)
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
catch (ObjectDisposedException)
{
return SetNetError(ctx, NetErrorBadFileDescriptor, NetErrnoBadFileDescriptor);
}
}
/// <summary>
/// Formats a binary address as text. Pure conversion with no socket state,
/// so it behaves identically to the console version for AF_INET/AF_INET6.
/// </summary>
[SysAbiExport(
Nid = "5jRCs2axtr4",
ExportName = "inet_ntop",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixInetNtop(CpuContext ctx)
{
var family = unchecked((int)ctx[CpuRegister.Rdi]);
var sourceAddress = ctx[CpuRegister.Rsi];
var destinationAddress = ctx[CpuRegister.Rdx];
var destinationSize = unchecked((int)ctx[CpuRegister.Rcx]);
if (sourceAddress == 0 || destinationAddress == 0 || destinationSize <= 0)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
// ORBIS_NET_AF_INET / ORBIS_NET_AF_INET6, matching TryMapAddressFamily.
var addressLength = family switch
{
2 => 4,
28 => 16,
_ => 0,
};
if (addressLength == 0)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
var rawAddress = new byte[addressLength];
if (!ctx.Memory.TryRead(sourceAddress, rawAddress))
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
var text = new IPAddress(rawAddress).ToString();
var encoded = Encoding.ASCII.GetBytes(text);
// POSIX requires the terminator to fit as well; a truncated address string
// is worse than a reported failure because the caller cannot detect it.
if (encoded.Length + 1 > destinationSize)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
var buffer = new byte[encoded.Length + 1];
encoded.CopyTo(buffer, 0);
if (!ctx.Memory.TryWrite(destinationAddress, buffer))
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
// inet_ntop returns the destination pointer on success.
ctx[CpuRegister.Rax] = destinationAddress;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "bErx49PgxyY",
ExportName = "sceNetBind",
+103 -448
View File
@@ -3,7 +3,6 @@
using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
using System.Buffers;
using System.Buffers.Binary;
using System.Threading;
@@ -26,44 +25,24 @@ public static class Ngs2Exports
private static long _nextUid;
private static long _renderCount;
// NGS2 renders one grain of interleaved float32 per sceNgs2SystemRender.
// The grain length defaults to 256 frames (matching the 8192-byte AudioOut
// buffers games copy it into) until the title overrides it.
private const int DefaultGrainSamples = 256;
private const double OutputSampleRate = 48000.0;
private sealed class SystemState
{
public SystemState(uint uid) => Uid = uid;
public uint Uid { get; }
public int GrainSamples { get; set; } = DefaultGrainSamples;
}
private sealed record SystemState(uint Uid);
private sealed record RackState(ulong SystemHandle, uint RackId);
private sealed record VoiceState(ulong RackHandle, uint VoiceIndex);
private sealed class VoiceState
[SysAbiExport(
Nid = "koBbCMvOKWw",
ExportName = "sceNgs2SystemCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2SystemCreate(CpuContext ctx)
{
public VoiceState(ulong rackHandle, uint voiceIndex)
var bufferInfoAddress = ctx[CpuRegister.Rsi];
if (!TryReadContextBuffer(ctx, bufferInfoAddress, out var hostBuffer))
{
RackHandle = rackHandle;
VoiceIndex = voiceIndex;
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
public ulong RackHandle { get; }
public uint VoiceIndex { get; }
// Software-mixer playback state. Pcm is the fully decoded mono waveform;
// Position is a fractional read cursor advanced at the source/output rate
// ratio each output frame.
public short[]? Pcm { get; set; }
public ulong SourceAddr { get; set; }
public int SourceRate { get; set; }
public double Position { get; set; }
public bool Playing { get; set; }
public int LoopStart { get; set; } = -1;
public int LoopEnd { get; set; }
public float Gain { get; set; } = 1f;
return CreateSystem(ctx, ctx[CpuRegister.Rdx], hostBuffer);
}
[SysAbiExport(
@@ -79,34 +58,14 @@ public static class Ngs2Exports
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
}
if (!TryCreateHandle(ctx, type: 1, ownerHandle: 0, out var handle) ||
!ctx.TryWriteUInt64(outHandleAddress, handle))
if (!TryCreateHandle(ctx, type: 1, ownerHandle: 0, out var handle))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
lock (StateGate)
{
Systems[handle] = new SystemState(unchecked((uint)Interlocked.Increment(ref _nextUid)));
}
return SetReturn(ctx, 0);
return CreateSystem(ctx, outHandleAddress, handle);
}
// Non-allocator create: identical to the WithAllocator form for our purposes.
// The only signature difference is the caller-supplied buffer info in rsi
// (vs an allocator callback); the system option (rdi) and out-handle (rdx)
// sit at the same argument positions, so we reuse the same implementation.
// Dead Cells uses these variants — leaving sceNgs2SystemCreate unresolved
// gave the game a garbage system handle, so every later rack/voice call
// failed and it polled sceNgs2VoiceGetState forever, freezing at FLIP 0.
[SysAbiExport(
Nid = "koBbCMvOKWw",
ExportName = "sceNgs2SystemCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2SystemCreate(CpuContext ctx) => Ngs2SystemCreateWithAllocator(ctx);
[SysAbiExport(
Nid = "u-WrYDaJA3k",
ExportName = "sceNgs2SystemDestroy",
@@ -135,6 +94,27 @@ public static class Ngs2Exports
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "cLV4aiT9JpA",
ExportName = "sceNgs2RackCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2RackCreate(CpuContext ctx)
{
var bufferInfoAddress = ctx[CpuRegister.Rcx];
if (!TryReadContextBuffer(ctx, bufferInfoAddress, out var hostBuffer))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
return CreateRack(
ctx,
ctx[CpuRegister.Rdi],
unchecked((uint)ctx[CpuRegister.Rsi]),
ctx[CpuRegister.R8],
hostBuffer);
}
[SysAbiExport(
Nid = "U546k6orxQo",
ExportName = "sceNgs2RackCreateWithAllocator",
@@ -158,29 +138,14 @@ public static class Ngs2Exports
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
}
if (!TryCreateHandle(ctx, type: 2, systemHandle, out var handle) ||
!ctx.TryWriteUInt64(outHandleAddress, handle))
if (!TryCreateHandle(ctx, type: 2, systemHandle, out var handle))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
lock (StateGate)
{
Racks[handle] = new RackState(systemHandle, rackId);
}
return SetReturn(ctx, 0);
return CreateRack(ctx, systemHandle, rackId, outHandleAddress, handle);
}
// Non-allocator rack create: system handle (rdi), rack id (rsi) and the
// out-handle (r8) share the WithAllocator argument layout, so reuse it.
[SysAbiExport(
Nid = "cLV4aiT9JpA",
ExportName = "sceNgs2RackCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2RackCreate(CpuContext ctx) => Ngs2RackCreateWithAllocator(ctx);
[SysAbiExport(
Nid = "lCqD7oycmIM",
ExportName = "sceNgs2RackDestroy",
@@ -255,217 +220,14 @@ public static class Ngs2Exports
LibraryName = "libSceNgs2")]
public static int Ngs2VoiceControl(CpuContext ctx)
{
var voiceHandle = ctx[CpuRegister.Rdi];
var paramList = ctx[CpuRegister.Rsi];
lock (StateGate)
{
if (!Voices.ContainsKey(voiceHandle))
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidVoiceHandle);
}
}
if (ShouldTrace())
{
TraceVoiceParamList(ctx, voiceHandle, paramList);
}
HandleVoiceParams(ctx, voiceHandle, paramList);
return SetReturn(ctx, 0);
}
// Parse the SceNgs2VoiceParamHead command list (header = u32 size, u32 id;
// params are laid out contiguously) and apply the ones the mixer needs:
// the waveform-blocks param arms a voice with decoded PCM, and the port
// matrix param carries its output gain.
private static void HandleVoiceParams(CpuContext ctx, ulong voiceHandle, ulong paramList)
{
if (paramList == 0)
{
return;
}
var offset = paramList;
for (var guard = 0; guard < 32; guard++)
{
if (!ctx.TryReadUInt32(offset, out var size) ||
!ctx.TryReadUInt32(offset + 4, out var id))
{
return;
}
switch (id)
{
case 0x10000001:
ApplyWaveformParam(ctx, voiceHandle, offset);
break;
case 0x20010001:
ApplyPortMatrixParam(ctx, voiceHandle, offset);
break;
}
// Advance to the next contiguous block; the game normally sends one
// param per call (size==whole block), so stop when size is degenerate.
if (size < 8 || size > 0x1000)
{
return;
}
offset += (size + 7) & ~7u;
return SetReturn(
ctx,
Voices.ContainsKey(ctx[CpuRegister.Rdi]) ? 0 : OrbisNgs2ErrorInvalidVoiceHandle);
}
}
// Waveform-blocks param: the guest pointer at +8 references a "VAGp"
// (PS-ADPCM) container. Decode it once and arm the voice for playback.
private static void ApplyWaveformParam(CpuContext ctx, ulong voiceHandle, ulong paramOffset)
{
if (!ctx.TryReadUInt64(paramOffset + 8, out var dataAddr) || dataAddr <= 0x10000)
{
return;
}
lock (StateGate)
{
if (Voices.TryGetValue(voiceHandle, out var existing) &&
existing.SourceAddr == dataAddr && existing.Pcm is not null)
{
// Same waveform already armed — don't restart it every frame.
return;
}
}
Span<byte> header = stackalloc byte[Ngs2VagDecoder.VagHeaderSize];
if (!ctx.Memory.TryRead(dataAddr, header) || !Ngs2VagDecoder.IsVag(header))
{
return;
}
var declaredSize = (int)BinaryPrimitives.ReadUInt32BigEndian(header[0x0C..]);
var totalBytes = Ngs2VagDecoder.VagHeaderSize + Math.Clamp(declaredSize, 0, 8 * 1024 * 1024);
var raw = System.Buffers.ArrayPool<byte>.Shared.Rent(totalBytes);
try
{
if (!ctx.Memory.TryRead(dataAddr, raw.AsSpan(0, totalBytes)) ||
!Ngs2VagDecoder.TryDecode(raw.AsSpan(0, totalBytes), out var waveform))
{
return;
}
lock (StateGate)
{
if (!Voices.TryGetValue(voiceHandle, out var voice))
{
return;
}
voice.Pcm = waveform.Samples;
voice.SourceAddr = dataAddr;
voice.SourceRate = waveform.SampleRate;
voice.LoopStart = waveform.LoopStart;
voice.LoopEnd = waveform.LoopEnd > 0 ? waveform.LoopEnd : waveform.Samples.Length;
voice.Position = 0;
voice.Playing = true;
}
if (ShouldTrace())
{
var peak = 0;
for (var i = 0; i < waveform.Samples.Length; i++)
{
peak = Math.Max(peak, Math.Abs((int)waveform.Samples[i]));
}
Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.arm voice=0x{voiceHandle:X16} addr=0x{dataAddr:X} rate={waveform.SampleRate} samples={waveform.Samples.Length} loop={waveform.LoopStart} peak={peak}");
}
}
finally
{
System.Buffers.ArrayPool<byte>.Shared.Return(raw);
}
}
// Port matrix param: the first float level is a reasonable proxy for the
// voice's output gain until per-channel panning is implemented.
private static void ApplyPortMatrixParam(CpuContext ctx, ulong voiceHandle, ulong paramOffset)
{
if (!ctx.TryReadUInt32(paramOffset + 12, out var levelBits))
{
return;
}
var level = BitConverter.UInt32BitsToSingle(levelBits);
if (!float.IsFinite(level) || level < 0f || level > 8f)
{
return;
}
lock (StateGate)
{
if (Voices.TryGetValue(voiceHandle, out var voice))
{
voice.Gain = level;
}
}
}
// Empirically dump the SceNgs2VoiceParamHead-chained command list so we can
// confirm the real struct layout (size/next/id) against public NGS2 sources
// before building the software mixer. Assumed header: u16 size, s16 next
// (byte offset to the next block, 0 = end), u32 id.
private static void TraceVoiceParamList(CpuContext ctx, ulong voiceHandle, ulong paramList)
{
if (paramList == 0)
{
return;
}
Span<byte> peek = stackalloc byte[32];
var offset = paramList;
for (int guard = 0; guard < 32; guard++)
{
if (!ctx.TryReadUInt16(offset, out var size) ||
!ctx.TryReadUInt16(offset + 2, out var next) ||
!ctx.TryReadUInt32(offset + 4, out var id))
{
Console.Error.WriteLine($"[LOADER][TRACE] ngs2.voiceparam voice=0x{voiceHandle:X16} @0x{offset:X}: unreadable header");
return;
}
peek.Clear();
var readable = Math.Min((int)Math.Max((ushort)8, size), peek.Length);
ctx.Memory.TryRead(offset, peek[..readable]);
Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.voiceparam voice=0x{voiceHandle:X16} id=0x{id:X} size={size} next={unchecked((short)next)} bytes={Convert.ToHexString(peek[..readable])}");
// For the waveform-blocks param, follow the embedded pointers and
// dump the pointed-to bytes so we can tell PCM16 from ATRAC9.
if (id == 0x10000001 && Interlocked.Increment(ref _waveformDumps) <= 8)
{
for (int po = 8; po + 8 <= readable; po += 8)
{
if (ctx.TryReadUInt64(offset + (ulong)po, out var ptr) && ptr > 0x10000 &&
ctx.Memory.TryRead(ptr, peek))
{
Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.waveform @+{po} ptr=0x{ptr:X} head={Convert.ToHexString(peek)}");
}
}
}
var advance = unchecked((short)next);
if (advance <= 0)
{
return;
}
offset += (ulong)advance;
}
}
private static long _waveformDumps;
private static long _renderInfoDumps;
[SysAbiExport(
Nid = "AbYvTOZ8Pts",
ExportName = "sceNgs2VoiceRunCommands",
@@ -511,32 +273,11 @@ public static class Ngs2Exports
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
// SceNgs2RenderBufferInfo: {ptr@0, size@8, waveformType@16,
// channelsCount@20}. Mix the armed voices into the leading grain
// as interleaved float32 — this is what the game copies to
// sceAudioOutOutput, so it is where NGS2 audio must appear.
var channels = 2;
if (ctx.TryReadUInt32(entryAddress + 20, out var declaredChannels) &&
declaredChannels is > 0 and <= 8)
{
channels = (int)declaredChannels;
}
MixVoicesIntoGrain(ctx, systemHandle, bufferAddress, bufferSize, channels);
if (ShouldTrace() && Interlocked.Increment(ref _renderInfoDumps) <= 4)
{
Span<byte> rbi = stackalloc byte[RenderBufferInfoSize];
ctx.Memory.TryRead(entryAddress, rbi);
Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.renderbufinfo addr=0x{bufferAddress:X} size={bufferSize} ch={channels} raw={Convert.ToHexString(rbi)}");
}
}
}
var count = Interlocked.Increment(ref _renderCount);
if (ShouldTrace() && (count <= 4 || count % 200 == 0))
if (ShouldTrace() && (count <= 4 || count % 10_000 == 0))
{
Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.render#{count} system=0x{systemHandle:X16} buffers={bufferInfoCount}");
@@ -545,135 +286,6 @@ public static class Ngs2Exports
return SetReturn(ctx, 0);
}
// Sum every armed voice belonging to this system into the leading grain of
// the render buffer as interleaved float32. The buffer was just zeroed, so
// this is a plain additive mix; silence stays silence when nothing plays.
private static void MixVoicesIntoGrain(
CpuContext ctx, ulong systemHandle, ulong bufferAddress, ulong bufferSize, int channels)
{
int grain;
lock (StateGate)
{
if (!Systems.TryGetValue(systemHandle, out var system))
{
return;
}
grain = system.GrainSamples;
}
var capacityFrames = (int)Math.Min((ulong)grain, bufferSize / (ulong)(channels * sizeof(float)));
if (capacityFrames <= 0)
{
return;
}
var floatCount = capacityFrames * channels;
var accum = ArrayPool<float>.Shared.Rent(floatCount);
var mixedAnything = false;
try
{
Array.Clear(accum, 0, floatCount);
lock (StateGate)
{
foreach (var pair in Voices)
{
var voice = pair.Value;
if (!voice.Playing || voice.Pcm is null || voice.Pcm.Length == 0)
{
continue;
}
if (!Racks.TryGetValue(voice.RackHandle, out var rack) ||
rack.SystemHandle != systemHandle)
{
continue;
}
MixOneVoice(accum, capacityFrames, channels, voice);
mixedAnything = true;
}
}
if (mixedAnything)
{
WriteGrain(ctx, bufferAddress, accum, floatCount);
}
}
finally
{
ArrayPool<float>.Shared.Return(accum);
}
}
// Resample one voice from its source rate to 48 kHz (nearest-sample) and add
// it to the front stereo pair. Advances the voice cursor and handles loop /
// one-shot end. Must be called under StateGate.
private static void MixOneVoice(float[] accum, int frames, int channels, VoiceState voice)
{
var pcm = voice.Pcm!;
var loopEnd = voice.LoopEnd > 0 && voice.LoopEnd <= pcm.Length ? voice.LoopEnd : pcm.Length;
var loopStart = voice.LoopStart;
var step = voice.SourceRate / OutputSampleRate;
var gain = voice.Gain / 32768f;
var pos = voice.Position;
for (var f = 0; f < frames; f++)
{
var idx = (int)pos;
if (idx >= loopEnd)
{
if (loopStart >= 0 && loopStart < loopEnd)
{
pos = loopStart;
idx = loopStart;
}
else
{
voice.Playing = false;
break;
}
}
if (idx < 0 || idx >= pcm.Length)
{
voice.Playing = false;
break;
}
var sample = pcm[idx] * gain;
var baseIndex = f * channels;
accum[baseIndex] += sample;
if (channels > 1)
{
accum[baseIndex + 1] += sample;
}
pos += step;
}
voice.Position = pos;
}
private static void WriteGrain(CpuContext ctx, ulong address, float[] accum, int count)
{
var bytes = ArrayPool<byte>.Shared.Rent(count * sizeof(float));
try
{
var span = bytes.AsSpan(0, count * sizeof(float));
for (var i = 0; i < count; i++)
{
var value = Math.Clamp(accum[i], -1f, 1f);
BinaryPrimitives.WriteSingleLittleEndian(span.Slice(i * sizeof(float), sizeof(float)), value);
}
ctx.Memory.TryWrite(address, span);
}
finally
{
ArrayPool<byte>.Shared.Return(bytes);
}
}
[SysAbiExport(
Nid = "pgFAiLR5qT4",
ExportName = "sceNgs2SystemQueryBufferSize",
@@ -711,25 +323,7 @@ public static class Ngs2Exports
ExportName = "sceNgs2SystemSetGrainSamples",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2SystemSetGrainSamples(CpuContext ctx)
{
var systemHandle = ctx[CpuRegister.Rdi];
var grain = unchecked((int)ctx[CpuRegister.Rsi]);
lock (StateGate)
{
if (!Systems.TryGetValue(systemHandle, out var system))
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidSystemHandle);
}
if (grain > 0 && grain <= 8192)
{
system.GrainSamples = grain;
}
}
return SetReturn(ctx, 0);
}
public static int Ngs2SystemSetGrainSamples(CpuContext ctx) => ValidateSystem(ctx);
[SysAbiExport(
Nid = "-tbc2SxQD60",
@@ -818,6 +412,67 @@ public static class Ngs2Exports
}
}
private static int CreateSystem(CpuContext ctx, ulong outHandleAddress, ulong handle)
{
if (outHandleAddress == 0)
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
}
if (handle == 0 || !ctx.TryWriteUInt64(outHandleAddress, handle))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
lock (StateGate)
{
Systems[handle] = new SystemState(unchecked((uint)Interlocked.Increment(ref _nextUid)));
}
return SetReturn(ctx, 0);
}
private static int CreateRack(
CpuContext ctx,
ulong systemHandle,
uint rackId,
ulong outHandleAddress,
ulong handle)
{
lock (StateGate)
{
if (!Systems.ContainsKey(systemHandle))
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidSystemHandle);
}
}
if (outHandleAddress == 0)
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
}
if (handle == 0 || !ctx.TryWriteUInt64(outHandleAddress, handle))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
lock (StateGate)
{
Racks[handle] = new RackState(systemHandle, rackId);
}
return SetReturn(ctx, 0);
}
private static bool TryReadContextBuffer(CpuContext ctx, ulong address, out ulong hostBuffer)
{
hostBuffer = 0;
return address != 0 &&
ctx.TryReadUInt64(address, out hostBuffer) &&
hostBuffer != 0;
}
private static bool TryCreateHandle(CpuContext ctx, uint type, ulong ownerHandle, out ulong handle)
{
handle = 0;
-150
View File
@@ -1,150 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
namespace SharpEmu.Libs.Ngs2;
// Clean-room PS-ADPCM ("VAG") decoder. NGS2 sampler voices point at waveforms
// wrapped in the classic Sony "VAGp" container: a 48-byte big-endian header
// followed by 16-byte ADPCM frames (2-byte predictor/shift + flags, then 14
// bytes = 28 nibbles = 28 samples). The predictor coefficient table and the
// nibble decode are the publicly documented PSX SPU ADPCM algorithm.
public static class Ngs2VagDecoder
{
// Standard PS-ADPCM predictor filters (scaled by 1/64).
private static readonly int[] Coeff0 = { 0, 60, 115, 98, 122 };
private static readonly int[] Coeff1 = { 0, 0, -52, -55, -60 };
public const int VagHeaderSize = 0x30;
private const uint VagMagic = 0x56414770; // "VAGp"
public readonly struct Waveform
{
public Waveform(short[] samples, int sampleRate, int loopStart, int loopEnd)
{
Samples = samples;
SampleRate = sampleRate;
LoopStart = loopStart;
LoopEnd = loopEnd;
}
public short[] Samples { get; }
public int SampleRate { get; }
public int LoopStart { get; } // -1 when the waveform does not loop
public int LoopEnd { get; }
}
// True when the buffer begins with a recognizable "VAGp" container header.
public static bool IsVag(ReadOnlySpan<byte> data) =>
data.Length >= VagHeaderSize &&
BinaryPrimitives.ReadUInt32BigEndian(data) == VagMagic;
// Decode a full "VAGp" container into mono PCM16. Returns false when the
// header is missing/short so callers can skip unsupported formats safely.
public static bool TryDecode(ReadOnlySpan<byte> data, out Waveform waveform)
{
waveform = default;
if (!IsVag(data))
{
return false;
}
// Header (big-endian): +0x0C dataSize, +0x10 sampleRate.
var declaredSize = (int)BinaryPrimitives.ReadUInt32BigEndian(data[0x0C..]);
var sampleRate = (int)BinaryPrimitives.ReadUInt32BigEndian(data[0x10..]);
if (sampleRate <= 0)
{
sampleRate = 48000;
}
var body = data[VagHeaderSize..];
// Trust the declared payload size when it fits; otherwise decode what we
// actually have (some tools pad or under-report).
var available = body.Length - (body.Length % 16);
var frameBytes = declaredSize > 0 && declaredSize <= available ? declaredSize - (declaredSize % 16) : available;
if (frameBytes <= 0)
{
return false;
}
waveform = Decode(body[..frameBytes], sampleRate);
return waveform.Samples.Length > 0;
}
// Decode raw 16-byte-framed PS-ADPCM (no container header) into PCM16 and
// resolve loop points from the per-frame flag bytes.
public static Waveform Decode(ReadOnlySpan<byte> frames, int sampleRate)
{
var frameCount = frames.Length / 16;
var samples = new short[frameCount * 28];
var loopStart = -1;
var loopEnd = -1;
var hist1 = 0;
var hist2 = 0;
var outIndex = 0;
var ended = false;
for (var frame = 0; frame < frameCount && !ended; frame++)
{
var offset = frame * 16;
var header = frames[offset];
var shift = header & 0x0F;
var filter = (header >> 4) & 0x0F;
if (filter > 4)
{
filter = 0;
}
// Per-frame loop marker (exact PS-ADPCM values, not bit masks):
// 3 = loop start, 6 = loop end + jump back, 1/7 = one-shot end.
var flags = frames[offset + 1];
var blockStart = outIndex;
if (flags == 0x03)
{
loopStart = blockStart;
}
var f0 = Coeff0[filter];
var f1 = Coeff1[filter];
for (var i = 0; i < 14; i++)
{
var d = frames[offset + 2 + i];
for (var nibble = 0; nibble < 2; nibble++)
{
var raw = nibble == 0 ? d & 0x0F : d >> 4;
// Sign-extend the 4-bit sample into the top nibble, then scale.
var s = (short)(raw << 12) >> shift;
var predicted = (hist1 * f0 + hist2 * f1) >> 6;
var sample = Math.Clamp(s + predicted, short.MinValue, short.MaxValue);
samples[outIndex++] = (short)sample;
hist2 = hist1;
hist1 = sample;
}
}
if (flags == 0x06)
{
loopEnd = outIndex;
}
else if (flags == 0x01 || flags == 0x07)
{
ended = true;
}
}
// Trim to the samples we actually decoded (a one-shot end marker can stop
// us before the declared frame count).
if (outIndex != samples.Length)
{
Array.Resize(ref samples, outIndex);
}
if (loopStart >= 0 && loopEnd <= loopStart)
{
loopEnd = outIndex;
}
return new Waveform(samples, sampleRate, loopStart, loopEnd);
}
}
@@ -58,36 +58,6 @@ public static class NpEntitlementAccessExports
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
private const int EmptyAddcontInfoSize = 0x30;
// Singular lookup of one add-on-content entitlement (rdx = info out). We own
// no DLC, so report an empty/zeroed info and success — matching the list
// variant's "no entitlements" answer. Dead Cells calls this while loading a
// level; leaving it unresolved left the info struct uninitialized.
[SysAbiExport(
Nid = "xddD23+8TfQ",
ExportName = "sceNpEntitlementAccessGetAddcontEntitlementInfo",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNpEntitlementAccess")]
public static int NpEntitlementAccessGetAddcontEntitlementInfo(CpuContext ctx)
{
var infoAddress = ctx[CpuRegister.Rdx];
if (infoAddress != 0)
{
Span<byte> info = stackalloc byte[EmptyAddcontInfoSize];
info.Clear();
if (!ctx.Memory.TryWrite(infoAddress, info))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
TraceNpEntitlementAccess(
$"get_addcont_info service=0x{ctx[CpuRegister.Rdi]:X16} label=0x{ctx[CpuRegister.Rsi]:X16} " +
$"info=0x{infoAddress:X16} -> empty");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
private static void TraceNpEntitlementAccess(string message)
{
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NP"), "1", StringComparison.Ordinal))
-17
View File
@@ -69,23 +69,6 @@ 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",
-19
View File
@@ -80,25 +80,6 @@ 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)
@@ -197,16 +197,4 @@ public static class NpUniversalDataSystemExports
{
return ctx.SetReturn(0, typeof(long));
}
// Telemetry property setter (event property array, string value). We do not
// upload analytics, so accept and drop it — matching the other Set* stubs.
[SysAbiExport(
Nid = "4llLk7YJRTE",
ExportName = "sceNpUniversalDataSystemEventPropertyArraySetString",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNpUniversalDataSystem")]
public static int NpUniversalDataSystemEventPropertyArraySetString(CpuContext ctx)
{
return ctx.SetReturn(0, typeof(long));
}
}
+8 -98
View File
@@ -10,11 +10,6 @@ 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",
@@ -31,28 +26,9 @@ public static class NpWebApi2Exports
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
}
var libraryContextId = CreateLibraryContextId();
Interlocked.Exchange(ref _initialized, 1);
TraceNpWebApi2("init", httpContextId, poolSize);
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);
return ctx.SetReturn(0);
}
[SysAbiExport(
@@ -62,16 +38,9 @@ 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", libraryContextId, 0);
return ctx.SetReturn(handle);
TraceNpWebApi2("init-alt", unchecked((int)ctx[CpuRegister.Rdi]), ctx[CpuRegister.Rsi]);
return ctx.SetReturn(0);
}
[SysAbiExport(
@@ -81,23 +50,10 @@ public static class NpWebApi2Exports
LibraryName = "libSceNpWebApi2")]
public static int NpWebApi2CreateUserContext(CpuContext ctx)
{
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);
// 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);
}
[SysAbiExport(
@@ -108,57 +64,11 @@ public static class NpWebApi2Exports
public static int NpWebApi2Terminate(CpuContext ctx)
{
var libraryContextId = unchecked((int)ctx[CpuRegister.Rdi]);
if (!IsValidLibraryContextId(libraryContextId))
{
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
}
RemoveLibraryContextId(libraryContextId);
Interlocked.Exchange(ref _initialized, 0);
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))
-61
View File
@@ -65,35 +65,6 @@ public static class PadExports
LibraryName = "libScePad")]
public static int PadOpenExt(CpuContext ctx) => PadOpenCore(ctx, extended: true);
// scePadGetHandle(userId, type, index): returns the handle of an already-open
// pad without opening a new one. Dead Cells calls it every frame to poll
// input; leaving it unresolved returned a garbage handle so the input path
// (and the game loop that drives it) misbehaved. Same validation as
// scePadOpen — the one primary pad — returning its handle or a not-connected
// error, never opening or logging.
[SysAbiExport(
Nid = "u1GRHp+oWoY",
ExportName = "scePadGetHandle",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePad")]
public static int PadGetHandle(CpuContext ctx)
{
var userId = unchecked((int)ctx[CpuRegister.Rdi]);
var type = unchecked((int)ctx[CpuRegister.Rsi]);
var index = unchecked((int)ctx[CpuRegister.Rdx]);
if (!_initialized)
{
return ctx.SetReturn(OrbisPadErrorNotInitialized);
}
if (userId != PrimaryUserId || type is not (0 or 1 or 2) || index != 0)
{
return ctx.SetReturn(OrbisPadErrorDeviceNotConnected);
}
return ctx.SetReturn(PrimaryPadHandle);
}
// scePadOpen rejects a non-null 4th arg and non-standard ports; scePadOpenExt accepts a
// ScePadOpenExtParam* plus ports 1/2 (racing titles retry scePadOpenExt(type=2) forever if rejected).
private static int PadOpenCore(CpuContext ctx, bool extended)
@@ -245,38 +216,6 @@ public static class PadExports
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
[SysAbiExport(
Nid = "AcslpN1jHR8",
ExportName = "scePadDeviceClassGetExtendedInformation",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePad")]
public static int PadDeviceClassGetExtendedInformation(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var informationAddress = ctx[CpuRegister.Rsi];
if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
if (informationAddress == 0)
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
// ScePadDeviceClassExtendedInformation: deviceClass 0 = standard pad
// (DualSense). We emulate no special peripheral (guitar/drums/wheel), so
// the class-data union stays zeroed — the guest treats it as a plain
// controller with no extended capabilities.
Span<byte> information = stackalloc byte[0x20];
information.Clear();
BinaryPrimitives.WriteInt32LittleEndian(information[0x00..], 0);
return ctx.Memory.TryWrite(informationAddress, information)
? ctx.SetReturn(0)
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
[SysAbiExport(
Nid = "YndgXqQVV7c",
ExportName = "scePadReadState",
+7 -50
View File
@@ -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,22 +698,14 @@ public static class PlayGoExports
var hasMetadata = File.Exists(playGoDat) || File.Exists(scenarioJson) || File.Exists(chunkDefsXml);
if (!hasMetadata)
{
// 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)}]");
// 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");
return new PlayGoMetadata(
true,
installedChunkIds,
[(ushort)0],
PlayGoChunkIdKnowledge.Authoritative);
}
@@ -726,41 +718,6 @@ 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))
-39
View File
@@ -1,39 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Security.Cryptography;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Random;
public static class RandomExports
{
private const int RandomErrorInvalid = unchecked((int)0x817C0016);
private const int MaxRandomBytes = 64;
[SysAbiExport(
Nid = "PI7jIZj4pcE",
ExportName = "sceRandomGetRandomNumber",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRandom")]
public static int RandomGetRandomNumber(CpuContext ctx)
{
var destination = ctx[CpuRegister.Rdi];
var size = ctx[CpuRegister.Rsi];
if ((destination == 0 && size != 0) || size > MaxRandomBytes)
{
return ctx.SetReturn(RandomErrorInvalid);
}
if (size == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
Span<byte> bytes = stackalloc byte[(int)size];
RandomNumberGenerator.Fill(bytes);
return ctx.Memory.TryWrite(destination, bytes)
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK)
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
+48 -688
View File
@@ -28,7 +28,6 @@ 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;
@@ -46,530 +45,6 @@ public static class SaveDataExports
_titleId = string.IsNullOrWhiteSpace(titleId) ? null : SanitizePathSegment(titleId.Trim());
_preparedTransactionResources.Clear();
}
lock (_eventGate)
{
_events.Clear();
}
lock (_mountGate)
{
_mounts.Clear();
}
}
// Additional error codes and the async-event model (see sceSaveDataGetEventResult).
private const int OrbisSaveDataErrorBusy = unchecked((int)0x809F0006);
private const int OrbisSaveDataErrorNoEvent = unchecked((int)0x809F0008); // NOT_FOUND: no pending event
private const int OrbisSaveDataErrorBadMounted = unchecked((int)0x809F0013);
// SceSaveDataEventType
private const uint EventTypeUmountBackupEnd = 1;
private const uint EventTypeBackupEnd = 2;
private const uint EventTypeSaveDataMemorySyncEnd = 3;
private const int SaveDataEventSize = 0x60;
private const int MountInfoSize = 0x40;
private const uint DefaultBlockSize = 32768;
private const ulong DefaultTotalBlocks = 0x8000; // 1 GiB of 32 KiB blocks
private static readonly object _eventGate = new();
private static readonly Queue<SaveDataEvent> _events = new();
private static readonly object _mountGate = new();
// mountPoint -> live mount, for umount/IsMounted/GetMountInfo.
private static readonly Dictionary<string, MountEntry> _mounts = new(StringComparer.Ordinal);
private readonly record struct SaveDataEvent(uint Type, int ErrorCode, int UserId, string DirName);
private sealed record MountEntry(string SlotDir, string DirName, int UserId);
private static void EnqueueEvent(uint type, int userId, string dirName, int errorCode = 0)
{
lock (_eventGate)
{
_events.Enqueue(new SaveDataEvent(type, errorCode, userId, dirName));
}
TraceSaveData($"event.enqueue type={type} user={userId} dir='{dirName}' err=0x{errorCode:X}");
}
[SysAbiExport(
Nid = "j8xKtiFj0SY",
ExportName = "sceSaveDataGetEventResult",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceSaveData")]
public static int SaveDataGetEventResult(CpuContext ctx)
{
// rdi: SceSaveDataEventParam* (filter, ignored). rsi: SceSaveDataEvent* out.
var eventAddress = ctx[CpuRegister.Rsi];
if (eventAddress == 0)
{
return SetReturn(ctx, OrbisSaveDataErrorParameter);
}
SaveDataEvent pending;
lock (_eventGate)
{
if (_events.Count == 0)
{
// No queued completion. Games poll this from a worker; report the
// defined "no event" status so the loop keeps polling instead of
// acting on an uninitialized event struct.
return SetReturn(ctx, OrbisSaveDataErrorNoEvent);
}
pending = _events.Dequeue();
}
Span<byte> ev = stackalloc byte[SaveDataEventSize];
ev.Clear();
BinaryPrimitives.WriteUInt32LittleEndian(ev[0x00..], pending.Type);
BinaryPrimitives.WriteInt32LittleEndian(ev[0x04..], pending.ErrorCode);
BinaryPrimitives.WriteInt32LittleEndian(ev[0x08..], pending.UserId);
WriteAscii(ev.Slice(0x10, SaveDataDirNameSize), pending.DirName);
if (!ctx.Memory.TryWrite(eventAddress, ev))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "hsKd5c21sQc",
ExportName = "sceSaveDataRegisterEventCallback",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceSaveData")]
public static int SaveDataRegisterEventCallback(CpuContext ctx) => SetReturn(ctx, 0);
[SysAbiExport(
Nid = "v-AK1AxQhS0",
ExportName = "sceSaveDataUnregisterEventCallback",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceSaveData")]
public static int SaveDataUnregisterEventCallback(CpuContext ctx) => SetReturn(ctx, 0);
// ---- lifecycle ----
[SysAbiExport(Nid = "ZkZhskCPXFw", ExportName = "sceSaveDataInitialize", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataInitialize(CpuContext ctx) => SaveDataInitializeCommon(ctx);
[SysAbiExport(Nid = "l1NmDeDpNGU", ExportName = "sceSaveDataInitialize2", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataInitialize2(CpuContext ctx) => SaveDataInitializeCommon(ctx);
private static int SaveDataInitializeCommon(CpuContext ctx)
{
try
{
Directory.CreateDirectory(ResolveSaveDataRoot());
return SetReturn(ctx, 0);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return SetReturn(ctx, OrbisSaveDataErrorInternal);
}
}
[SysAbiExport(Nid = "yKDy8S5yLA0", ExportName = "sceSaveDataTerminate", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataTerminate(CpuContext ctx) => SetReturn(ctx, 0);
// ---- mount variants (all share the SceSaveDataMount layout) ----
[SysAbiExport(Nid = "32HQAQdwM2o", ExportName = "sceSaveDataMount", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataMount(CpuContext ctx) => SaveDataMount3(ctx);
[SysAbiExport(Nid = "0z45PIH+SNI", ExportName = "sceSaveDataMount2", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataMount2(CpuContext ctx) => SaveDataMount3(ctx);
[SysAbiExport(Nid = "xz0YMi6BfNk", ExportName = "sceSaveDataMount5", Target = Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataMount5(CpuContext ctx) => SaveDataMount3(ctx);
[SysAbiExport(Nid = "BMR4F-Uek3E", ExportName = "sceSaveDataUmount", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataUmount(CpuContext ctx) => SaveDataUmount2(ctx);
[SysAbiExport(Nid = "ieP6jP138Qo", ExportName = "sceSaveDataIsMounted", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataIsMounted(CpuContext ctx)
{
var outAddress = ctx[CpuRegister.Rsi];
int mountCount;
lock (_mountGate)
{
mountCount = _mounts.Count;
}
if (outAddress != 0)
{
TryWriteUInt32(ctx, outAddress, mountCount > 0 ? 1u : 0u);
}
return SetReturn(ctx, 0);
}
[SysAbiExport(Nid = "65VH0Qaaz6s", ExportName = "sceSaveDataGetMountInfo", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataGetMountInfo(CpuContext ctx)
{
var mountPointAddress = ctx[CpuRegister.Rdi];
var infoAddress = ctx[CpuRegister.Rsi];
if (mountPointAddress == 0 || infoAddress == 0 ||
!TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint))
{
return SetReturn(ctx, OrbisSaveDataErrorParameter);
}
MountEntry? entry;
lock (_mountGate)
{
_mounts.TryGetValue(mountPoint, out entry);
}
if (entry is null)
{
return SetReturn(ctx, OrbisSaveDataErrorBadMounted);
}
var used = SafeDirectorySize(entry.SlotDir);
var usedBlocks = (ulong)((used + DefaultBlockSize - 1) / DefaultBlockSize);
Span<byte> info = stackalloc byte[MountInfoSize];
info.Clear();
BinaryPrimitives.WriteUInt64LittleEndian(info[0x00..], DefaultTotalBlocks); // blocks
BinaryPrimitives.WriteUInt64LittleEndian(info[0x08..], usedBlocks); // freeBlocks slot reused as used
return ctx.Memory.TryWrite(infoAddress, info)
? SetReturn(ctx, 0)
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
// ---- delete ----
[SysAbiExport(Nid = "S1GkePI17zQ", ExportName = "sceSaveDataDelete", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataDelete(CpuContext ctx) => SaveDataDeleteCommon(ctx);
[SysAbiExport(Nid = "SQWusLoK8Pw", ExportName = "sceSaveDataDelete5", Target = Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataDelete5(CpuContext ctx) => SaveDataDeleteCommon(ctx);
private static int SaveDataDeleteCommon(CpuContext ctx)
{
// SceSaveDataDelete: +0x00 userId, +0x08 dirName*, ... (dirName drives the slot).
var deleteAddress = ctx[CpuRegister.Rdi];
if (deleteAddress == 0 ||
!TryReadInt32(ctx, deleteAddress, out var userId) ||
!ctx.TryReadUInt64(deleteAddress + 0x08, out var dirNameAddress) ||
dirNameAddress == 0 ||
!TryReadFixedAscii(ctx, dirNameAddress, SaveDataDirNameSize, out var dirName) ||
string.IsNullOrWhiteSpace(dirName))
{
return SetReturn(ctx, OrbisSaveDataErrorParameter);
}
try
{
var slotDir = SaveDataStorage.SlotDir(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId()), dirName);
if (!Directory.Exists(slotDir))
{
return SetReturn(ctx, OrbisSaveDataErrorNotFound);
}
Directory.Delete(slotDir, recursive: true);
TraceSaveData($"delete user={userId} dir='{dirName}' path='{slotDir}'");
return SetReturn(ctx, 0);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return SetReturn(ctx, OrbisSaveDataErrorInternal);
}
}
// ---- params (metadata shown in the save UI) ----
[SysAbiExport(Nid = "XgvSuIdnMlw", ExportName = "sceSaveDataGetParam", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataGetParam(CpuContext ctx) => TransferParam(ctx, write: false);
[SysAbiExport(Nid = "85zul--eGXs", ExportName = "sceSaveDataSetParam", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataSetParam(CpuContext ctx) => TransferParam(ctx, write: true);
private static int TransferParam(CpuContext ctx, bool write)
{
// rdi: mount-point string (16 bytes). rsi: paramType. rdx: SceSaveDataParam*. rcx: size.
var mountPointAddress = ctx[CpuRegister.Rdi];
var paramAddress = ctx[CpuRegister.Rdx];
if (mountPointAddress == 0 || paramAddress == 0 ||
!TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint))
{
return SetReturn(ctx, OrbisSaveDataErrorParameter);
}
MountEntry? entry;
lock (_mountGate)
{
_mounts.TryGetValue(mountPoint, out entry);
}
if (entry is null)
{
return SetReturn(ctx, OrbisSaveDataErrorBadMounted);
}
try
{
if (write)
{
Span<byte> raw = stackalloc byte[SaveDataParamSize];
if (!ctx.Memory.TryRead(paramAddress, raw))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
var metadata = new SaveDataMetadata
{
Title = ReadAsciiField(raw.Slice(0x00, 128)),
SubTitle = ReadAsciiField(raw.Slice(0x80, 128)),
Detail = ReadAsciiField(raw.Slice(0x100, 1024)),
UserParam = BinaryPrimitives.ReadUInt32LittleEndian(raw[0x500..]),
};
SaveDataStorage.WriteMetadata(entry.SlotDir, metadata);
TraceSaveData($"set_param mount='{mountPoint}' title='{metadata.Title}'");
return SetReturn(ctx, 0);
}
var loaded = SaveDataStorage.ReadMetadata(entry.SlotDir);
var param = new byte[SaveDataParamSize];
WriteAscii(param.AsSpan(0x00, 128), loaded.Title);
WriteAscii(param.AsSpan(0x80, 128), loaded.SubTitle);
WriteAscii(param.AsSpan(0x100, 1024), loaded.Detail);
BinaryPrimitives.WriteUInt32LittleEndian(param.AsSpan(0x500), loaded.UserParam);
BinaryPrimitives.WriteInt64LittleEndian(
param.AsSpan(0x508),
new DateTimeOffset(SafeLastWriteUtc(entry.SlotDir)).ToUnixTimeSeconds());
return ctx.Memory.TryWrite(paramAddress, param)
? SetReturn(ctx, 0)
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return SetReturn(ctx, OrbisSaveDataErrorInternal);
}
}
// ---- icons ----
[SysAbiExport(Nid = "c88Yy54Mx0w", ExportName = "sceSaveDataSaveIcon", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataSaveIcon(CpuContext ctx) => TransferIconForMount(ctx, write: true);
[SysAbiExport(Nid = "cGjO3wM3V28", ExportName = "sceSaveDataLoadIcon", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataLoadIcon(CpuContext ctx) => TransferIconForMount(ctx, write: false);
private static int TransferIconForMount(CpuContext ctx, bool write)
{
// rdi: mount-point string. rsi: SceSaveDataIcon* {buf@+0x00, bufSize@+0x08, dataSize@+0x10}.
var mountPointAddress = ctx[CpuRegister.Rdi];
var iconAddress = ctx[CpuRegister.Rsi];
if (mountPointAddress == 0 || iconAddress == 0 ||
!TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint) ||
!ctx.TryReadUInt64(iconAddress + 0x00, out var bufferAddress) ||
!ctx.TryReadUInt64(iconAddress + 0x08, out var bufferSize))
{
return SetReturn(ctx, OrbisSaveDataErrorParameter);
}
MountEntry? entry;
lock (_mountGate)
{
_mounts.TryGetValue(mountPoint, out entry);
}
if (entry is null)
{
return SetReturn(ctx, OrbisSaveDataErrorBadMounted);
}
var iconPath = SaveDataStorage.IconPath(entry.SlotDir);
try
{
if (write)
{
var length = checked((int)Math.Min(bufferSize, (ulong)16 * 1024 * 1024));
var bytes = ArrayPool<byte>.Shared.Rent(length);
try
{
if (!ctx.Memory.TryRead(bufferAddress, bytes.AsSpan(0, length)))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
Directory.CreateDirectory(Path.GetDirectoryName(iconPath)!);
File.WriteAllBytes(iconPath, bytes.AsSpan(0, length).ToArray());
}
finally
{
ArrayPool<byte>.Shared.Return(bytes);
}
return SetReturn(ctx, 0);
}
if (!File.Exists(iconPath))
{
return SetReturn(ctx, OrbisSaveDataErrorNotFound);
}
var data = File.ReadAllBytes(iconPath);
var copy = (int)Math.Min((ulong)data.Length, bufferSize);
if (bufferAddress != 0 && copy > 0 && !ctx.Memory.TryWrite(bufferAddress, data.AsSpan(0, copy)))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TryWriteUInt32(ctx, iconAddress + 0x10, (uint)data.Length); // dataSize
return SetReturn(ctx, 0);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return SetReturn(ctx, OrbisSaveDataErrorInternal);
}
}
// ---- size / progress / abort ----
[SysAbiExport(Nid = "A1ThglSGUwA", ExportName = "sceSaveDataGetAllSize", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataGetAllSize(CpuContext ctx)
{
var outAddress = ctx[CpuRegister.Rsi];
long total = 0;
try
{
var titleRoot = ResolveTitleSaveRoot(0, ResolveConfiguredTitleId());
if (Directory.Exists(titleRoot))
{
total = SafeDirectorySize(titleRoot);
}
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// Report zero on an unreadable tree rather than fail the query.
}
if (outAddress != 0)
{
var kib = (ulong)((total + 1023) / 1024);
ctx.TryWriteUInt64(outAddress, kib);
}
return SetReturn(ctx, 0);
}
[SysAbiExport(Nid = "ANmSWUiyyGQ", ExportName = "sceSaveDataGetProgress", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataGetProgress(CpuContext ctx)
{
// Our operations complete synchronously, so any in-flight progress is 100%.
var outAddress = ctx[CpuRegister.Rdi];
if (outAddress != 0)
{
Span<byte> progress = stackalloc byte[8];
progress.Clear();
BinaryPrimitives.WriteSingleLittleEndian(progress, 1.0f);
ctx.Memory.TryWrite(outAddress, progress);
}
return SetReturn(ctx, 0);
}
[SysAbiExport(Nid = "Wz-4JZfeO9g", ExportName = "sceSaveDataClearProgress", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataClearProgress(CpuContext ctx) => SetReturn(ctx, 0);
[SysAbiExport(Nid = "dQ2GohUHXzk", ExportName = "sceSaveDataAbort", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataAbort(CpuContext ctx) => SetReturn(ctx, 0);
[SysAbiExport(Nid = "eBSSNIG6hMk", ExportName = "sceSaveDataGetEventInfo", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataGetEventInfo(CpuContext ctx) => SetReturn(ctx, 0);
[SysAbiExport(Nid = "52pL2GKkdjA", ExportName = "sceSaveDataSetEventInfo", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataSetEventInfo(CpuContext ctx) => SetReturn(ctx, 0);
[SysAbiExport(Nid = "Z7z6HXWORJY", ExportName = "sceSaveDataSaveIconByPath", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataSaveIconByPath(CpuContext ctx) => SetReturn(ctx, 0);
[SysAbiExport(Nid = "SN7rTPHS+Cg", ExportName = "sceSaveDataGetSaveDataCount", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataGetSaveDataCount(CpuContext ctx)
{
var outAddress = ctx[CpuRegister.Rsi];
var count = 0;
try
{
var titleRoot = ResolveTitleSaveRoot(0, ResolveConfiguredTitleId());
if (Directory.Exists(titleRoot))
{
foreach (var dir in Directory.EnumerateDirectories(titleRoot))
{
if (!string.Equals(Path.GetFileName(dir), "sce_sdmemory", StringComparison.Ordinal))
{
count++;
}
}
}
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// Report zero on an unreadable tree.
}
if (outAddress != 0)
{
TryWriteUInt32(ctx, outAddress, (uint)count);
}
return SetReturn(ctx, 0);
}
[SysAbiExport(Nid = "pc4guaUPVqA", ExportName = "sceSaveDataGetMountedSaveDataCount", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataGetMountedSaveDataCount(CpuContext ctx)
{
var outAddress = ctx[CpuRegister.Rsi];
int mounted;
lock (_mountGate)
{
mounted = _mounts.Count;
}
if (outAddress != 0)
{
TryWriteUInt32(ctx, outAddress, (uint)mounted);
}
return SetReturn(ctx, 0);
}
// ---- SaveDataMemory v1 aliases (identical arg layout to the v2 forms) ----
[SysAbiExport(Nid = "v7AAAMo0Lz4", ExportName = "sceSaveDataSetupSaveDataMemory", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataSetupSaveDataMemory(CpuContext ctx) => SaveDataSetupSaveDataMemory2(ctx);
[SysAbiExport(Nid = "7Bt5pBC-Aco", ExportName = "sceSaveDataGetSaveDataMemory", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataGetSaveDataMemory(CpuContext ctx) => SaveDataGetSaveDataMemory2(ctx);
[SysAbiExport(Nid = "h3YURzXGSVQ", ExportName = "sceSaveDataSetSaveDataMemory", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
public static int SaveDataSetSaveDataMemory(CpuContext ctx) => SaveDataSetSaveDataMemory2(ctx);
private static string ReadAsciiField(ReadOnlySpan<byte> field)
{
var length = field.IndexOf((byte)0);
if (length < 0)
{
length = field.Length;
}
return Encoding.ASCII.GetString(field[..length]);
}
private static long SafeDirectorySize(string root)
{
try
{
return Directory.Exists(root) ? GetDirectorySize(root) : 0;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return 0;
}
}
private static DateTime SafeLastWriteUtc(string path)
{
try
{
return Directory.Exists(path) ? Directory.GetLastWriteTimeUtc(path) : DateTime.UtcNow;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return DateTime.UtcNow;
}
}
[SysAbiExport(
@@ -714,43 +189,16 @@ public static class SaveDataExports
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
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))
if (userId < 0 || string.IsNullOrWhiteSpace(dirName))
{
return SetReturn(ctx, OrbisSaveDataErrorParameter);
}
try
{
var sanitizedTitleId = SanitizePathSegment(titleId.Trim());
var titleId = ResolveConfiguredTitleId();
var savePath = Path.Combine(
ResolveTitleSaveRoot(userId, sanitizedTitleId),
ResolveTitleSaveRoot(userId, titleId),
SanitizePathSegment(dirName));
var existed = Directory.Exists(savePath);
var create = (mountMode & MountModeCreate) != 0;
@@ -773,10 +221,6 @@ public static class SaveDataExports
const string mountPoint = "/savedata0";
KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, savePath);
lock (_mountGate)
{
_mounts[mountPoint] = new MountEntry(savePath, dirName, userId);
}
Span<byte> result = stackalloc byte[MountResultSize];
result.Clear();
@@ -788,7 +232,7 @@ public static class SaveDataExports
}
TraceSaveData(
$"{operation} user={userId} title={sanitizedTitleId} dir={dirName} blocks={blocks} " +
$"mount3 user={userId} title={titleId} 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);
@@ -807,52 +251,6 @@ 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",
@@ -861,48 +259,33 @@ 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);
// 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.
// 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.
var resourceAddress = 0UL;
var selectedAddress = SelectTransactionResourceAddress(
ctx[CpuRegister.Rdx],
ctx[CpuRegister.Rcx]);
if (selectedAddress != 0 && TryWriteUInt32(ctx, selectedAddress, id))
foreach (var candidate in new[]
{
ctx[CpuRegister.Rdx],
ctx[CpuRegister.Rcx],
ctx[CpuRegister.R8],
ctx[CpuRegister.R9],
})
{
resourceAddress = selectedAddress;
if (candidate != 0 && TryWriteUInt32(ctx, candidate, id))
{
resourceAddress = candidate;
break;
}
}
TraceSaveData(
@@ -911,16 +294,6 @@ 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",
@@ -945,20 +318,10 @@ public static class SaveDataExports
LibraryName = "libSceSaveData")]
public static int SaveDataUmount2(CpuContext ctx)
{
// rdi: SceSaveDataMountPoint* (16-byte mount point string) for umount2.
var mountPointAddress = ctx[CpuRegister.Rdi];
if (mountPointAddress != 0 && TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint) &&
!string.IsNullOrEmpty(mountPoint))
{
lock (_mountGate)
{
_mounts.Remove(mountPoint);
}
KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint);
TraceSaveData($"umount2 mount='{mountPoint}'");
}
// Unmounting a save directory always succeeds in the stub filesystem;
// returning an error here makes the game's save flow stall before it
// hands control to the title/gameplay state.
TraceSaveData($"umount2 user={unchecked((int)ctx[CpuRegister.Rdi])}");
return SetReturn(ctx, 0);
}
@@ -1042,12 +405,9 @@ public static class SaveDataExports
private static bool TryWriteParam(CpuContext ctx, ulong address, SaveEntry entry)
{
var metadata = SaveDataStorage.ReadMetadata(entry.Path);
var param = new byte[SaveDataParamSize];
WriteAscii(param.AsSpan(0x00, 128), metadata.Title);
WriteAscii(param.AsSpan(0x80, 128), metadata.SubTitle);
WriteAscii(param.AsSpan(0x100, 1024), string.IsNullOrEmpty(metadata.Detail) ? entry.Name : metadata.Detail);
BinaryPrimitives.WriteUInt32LittleEndian(param.AsSpan(0x500), metadata.UserParam);
WriteAscii(param.AsSpan(0x00, 128), "Saved Data");
WriteAscii(param.AsSpan(0x100, 1024), entry.Name);
BinaryPrimitives.WriteInt64LittleEndian(
param.AsSpan(0x508, sizeof(long)),
new DateTimeOffset(entry.LastWriteUtc).ToUnixTimeSeconds());
@@ -1114,14 +474,11 @@ public static class SaveDataExports
return false;
}
// Saves are keyed by title id only (single-user emulation) under
// ~/SharpEmu/Saves/<titleId>/; userId is accepted for API fidelity but not
// part of the host path.
private static string ResolveTitleSaveRoot(int userId, string titleId) =>
SaveDataStorage.TitleRoot(ResolveSaveDataRoot(), titleId);
Path.Combine(ResolveSaveDataRoot(), userId.ToString(), SanitizePathSegment(titleId));
private static string ResolveSaveDataMemoryPath(int userId) =>
SaveDataStorage.MemoryPath(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId()));
Path.Combine(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId()), "sce_sdmemory", "memory.dat");
private static bool TryReadMemoryData(
CpuContext ctx, ulong address, out ulong buffer, out ulong size, out ulong offset)
@@ -1133,7 +490,14 @@ public static class SaveDataExports
ctx.TryReadUInt64(address + 0x10, out offset);
}
private static string ResolveSaveDataRoot() => SaveDataStorage.Root();
private static string ResolveSaveDataRoot()
{
var configured = Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
var root = string.IsNullOrWhiteSpace(configured)
? Path.Combine(AppContext.BaseDirectory, "user", "savedata")
: configured;
return Path.GetFullPath(root);
}
private static string ResolveConfiguredTitleId()
{
@@ -1161,7 +525,12 @@ public static class SaveDataExports
return "default";
}
private static string SanitizePathSegment(string value) => SaveDataStorage.Sanitize(value);
private static string SanitizePathSegment(string value)
{
var invalid = Path.GetInvalidFileNameChars();
var sanitized = new string(value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray());
return string.IsNullOrWhiteSpace(sanitized) ? "default" : sanitized;
}
private static bool TryReadFixedAscii(CpuContext ctx, ulong address, int length, out string value)
{
@@ -1421,17 +790,8 @@ public static class SaveDataExports
return ctx.SetReturn(OrbisSaveDataErrorParameter);
}
if (!File.Exists(ResolveSaveDataMemoryPath(userId)))
{
return ctx.SetReturn(OrbisSaveDataErrorMemoryNotReady);
}
// The write already reached disk synchronously, but the guest treats
// sync as asynchronous and blocks a worker on sceSaveDataGetEventResult
// until the SAVE_DATA_MEMORY_SYNC_END event arrives. Post it so that
// poll completes (this is what wedged Dead Cells at FLIP 0 in-level).
EnqueueEvent(EventTypeSaveDataMemorySyncEnd, userId, string.Empty);
return ctx.SetReturn(0);
return ctx.SetReturn(
File.Exists(ResolveSaveDataMemoryPath(userId)) ? 0 : OrbisSaveDataErrorMemoryNotReady);
}
private static int TransferSaveDataMemory(CpuContext ctx, bool write)
@@ -1,130 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SharpEmu.Libs.SaveData;
/// <summary>
/// Host-side layout and metadata for PS5 save data. Saves live under
/// <c>~/SharpEmu/Saves/&lt;titleId&gt;/&lt;dirName&gt;/</c> (overridable via
/// <c>SHARPEMU_SAVEDATA_DIR</c>); the game's files are written directly inside a
/// slot through the mounted <c>/savedata0</c> filesystem, and the PS5 UI
/// metadata (title/subtitle/detail/userParam) plus icon live under
/// <c>&lt;slot&gt;/sce_sys/</c>. This type is pure filesystem logic with no guest
/// interop so the path and metadata handling can be unit-tested.
/// </summary>
public static class SaveDataStorage
{
/// <summary>Root of all saves: the env override, else <c>~/SharpEmu/Saves</c>.</summary>
public static string Root(string? overrideDir = null)
{
var configured = overrideDir ?? Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
var root = string.IsNullOrWhiteSpace(configured)
? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"SharpEmu",
"Saves")
: configured;
return Path.GetFullPath(root);
}
/// <summary>Per-title directory: <c>&lt;root&gt;/&lt;titleId&gt;</c>.</summary>
public static string TitleRoot(string root, string titleId) =>
Path.Combine(root, Sanitize(titleId));
/// <summary>A single save slot: <c>&lt;titleRoot&gt;/&lt;dirName&gt;</c>.</summary>
public static string SlotDir(string titleRoot, string dirName) =>
Path.Combine(titleRoot, Sanitize(dirName));
/// <summary>The SaveDataMemory blob shared by a title.</summary>
public static string MemoryPath(string titleRoot) =>
Path.Combine(titleRoot, "sce_sdmemory", "memory.dat");
public static string ParamPath(string slotDir) =>
Path.Combine(slotDir, "sce_sys", "param.json");
public static string IconPath(string slotDir) =>
Path.Combine(slotDir, "sce_sys", "icon0.png");
/// <summary>
/// Replaces characters that are invalid in a host path segment. Empty or
/// all-invalid input collapses to "default" so a bad guest name can never
/// escape the save root or produce an empty segment.
/// </summary>
public static string Sanitize(string value)
{
if (string.IsNullOrEmpty(value))
{
return "default";
}
var invalid = Path.GetInvalidFileNameChars();
Span<char> buffer = value.Length <= 128 ? stackalloc char[value.Length] : new char[value.Length];
for (var i = 0; i < value.Length; i++)
{
var ch = value[i];
buffer[i] = Array.IndexOf(invalid, ch) >= 0 ? '_' : ch;
}
var sanitized = new string(buffer).Trim();
return string.IsNullOrWhiteSpace(sanitized) ? "default" : sanitized;
}
/// <summary>Reads a slot's metadata, or defaults if none has been written.</summary>
public static SaveDataMetadata ReadMetadata(string slotDir)
{
var path = ParamPath(slotDir);
if (File.Exists(path))
{
try
{
var parsed = JsonSerializer.Deserialize(File.ReadAllText(path), SaveDataMetadataContext.Default.SaveDataMetadata);
if (parsed is not null)
{
return parsed;
}
}
catch (Exception exception) when (exception is JsonException or IOException or UnauthorizedAccessException)
{
// Fall through to defaults on a corrupt or unreadable metadata file.
}
}
return SaveDataMetadata.CreateDefault(Path.GetFileName(slotDir.TrimEnd(Path.DirectorySeparatorChar)));
}
/// <summary>Writes a slot's metadata, creating <c>sce_sys/</c> as needed.</summary>
public static void WriteMetadata(string slotDir, SaveDataMetadata metadata)
{
var path = ParamPath(slotDir);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, JsonSerializer.Serialize(metadata, SaveDataMetadataContext.Default.SaveDataMetadata));
}
}
/// <summary>PS5 save-slot metadata surfaced by sceSaveDataGetParam / the save UI.</summary>
public sealed record SaveDataMetadata
{
[JsonPropertyName("title")]
public string Title { get; init; } = "Saved Data";
[JsonPropertyName("subTitle")]
public string SubTitle { get; init; } = string.Empty;
[JsonPropertyName("detail")]
public string Detail { get; init; } = string.Empty;
[JsonPropertyName("userParam")]
public uint UserParam { get; init; }
public static SaveDataMetadata CreateDefault(string dirName) =>
new() { Title = string.IsNullOrWhiteSpace(dirName) ? "Saved Data" : dirName };
}
[JsonSerializable(typeof(SaveDataMetadata))]
[JsonSourceGenerationOptions(WriteIndented = true)]
internal sealed partial class SaveDataMetadataContext : JsonSerializerContext
{
}
-53
View File
@@ -12,9 +12,6 @@ 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",
@@ -65,56 +62,6 @@ 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];

Some files were not shown because too many files have changed in this diff Show More