Compare commits

..

1 Commits

Author SHA1 Message Date
ParantezTech f63204df75 [shader_recompiler] Fix guest image byte count calculation for Vulkan video presenter 2026-07-18 15:56:21 +03:00
124 changed files with 1304 additions and 19929 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<SharpEmuVersion>0.0.2-beta.4</SharpEmuVersion> <SharpEmuVersion>0.0.2-beta.3</SharpEmuVersion>
<Version>$(SharpEmuVersion)</Version> <Version>$(SharpEmuVersion)</Version>
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot> <RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
-2
View File
@@ -8,8 +8,6 @@ path = [
"**/packages.lock.json", "**/packages.lock.json",
"scripts/ps5_names.txt", "scripts/ps5_names.txt",
"src/SharpEmu.GUI/Languages/**", "src/SharpEmu.GUI/Languages/**",
"src/SharpEmu.ShaderCompiler.Metal/Templates/**",
"tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/**",
"_logs/**", "_logs/**",
".github/images/**", ".github/images/**",
".github/pull_request_template.md", ".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.Libs/SharpEmu.Libs.csproj" />
<Project Path="src/SharpEmu.Logging/SharpEmu.Logging.csproj" /> <Project Path="src/SharpEmu.Logging/SharpEmu.Logging.csproj" />
<Project Path="src/SharpEmu.ShaderCompiler/SharpEmu.ShaderCompiler.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.ShaderCompiler.Vulkan/SharpEmu.ShaderCompiler.Vulkan.csproj" />
<Project Path="src/SharpEmu.SourceGenerators/SharpEmu.SourceGenerators.csproj" /> <Project Path="src/SharpEmu.SourceGenerators/SharpEmu.SourceGenerators.csproj" />
</Folder> </Folder>
<Folder Name="/tests/"> <Folder Name="/tests/">
<Project Path="tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj" /> <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" /> <Project Path="tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj" />
</Folder> </Folder>
</Solution> </Solution>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 MiB

-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
```
+3 -3
View File
@@ -14,9 +14,9 @@ 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 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 movie without trying to execute the PS5-specific Bink GPU decode path.
Without an adapter, Bink files remain visible to the guest and the game's Without an adapter, Bink movies are skipped by default: their open call returns
statically linked decoder runs normally. Set SHARPEMU_BINK_MODE=skip only when not-found so games that mark cinematics as optional progress to their next
explicitly testing a title whose cinematics are optional. state instead of waiting on an empty Bink GPU texture.
Set SHARPEMU_BINK_MODE=dummy to retain the open and show a built-in, 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 non-decoded placeholder frame. This requires no SDK, but is a visual diagnostic
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"sdk": { "sdk": {
"version": "10.0.103", "version": "10.0.103",
"rollForward": "latestFeature" "rollForward": "disable"
} }
} }
-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())
+5
View File
@@ -45,6 +45,11 @@ internal static partial class Program
[STAThread] [STAThread]
private static int Main(string[] args) 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 try
{ {
return Run(args); return Run(args);
+1
View File
@@ -49,6 +49,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<GenerateDocumentationFile>false</GenerateDocumentationFile> <GenerateDocumentationFile>false</GenerateDocumentationFile>
<DebugType>none</DebugType> <DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols> <DebugSymbols>false</DebugSymbols>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == ''"> <PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == ''">
+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=="
}
}
}
}
@@ -1410,8 +1410,6 @@ public sealed partial class DirectExecutionBackend
"eE4Szl8sil8" or // sceKernelAprSubmitCommandBuffer "eE4Szl8sil8" or // sceKernelAprSubmitCommandBuffer
"qvMUCyyaCSI" or // sceKernelAprSubmitCommandBufferAndGetId "qvMUCyyaCSI" or // sceKernelAprSubmitCommandBufferAndGetId
"Q2V+iqvjgC0" or // vsnprintf "Q2V+iqvjgC0" or // vsnprintf
"AV6ipCNa4Rw" or // strcasecmp
"viiwFMaNamA" or // strstr
"q1cHNfGycLI" or // scePadRead "q1cHNfGycLI" or // scePadRead
"xk0AcarP3V4" or // scePadOpen "xk0AcarP3V4" or // scePadOpen
"yH17Q6NWtVg" or // sceUserServiceGetEvent "yH17Q6NWtVg" or // sceUserServiceGetEvent
@@ -1573,8 +1571,6 @@ public sealed partial class DirectExecutionBackend
"WkkeywLJcgU" or // wcslen "WkkeywLJcgU" or // wcslen
"Ovb2dSJOAuE" or // strcmp "Ovb2dSJOAuE" or // strcmp
"aesyjrHVWy4" or // strncmp "aesyjrHVWy4" or // strncmp
"AV6ipCNa4Rw" or // strcasecmp
"viiwFMaNamA" or // strstr
"pNtJdE3x49E" or // wcscmp "pNtJdE3x49E" or // wcscmp
"fV2xHER+bKE" or // wcscoll "fV2xHER+bKE" or // wcscoll
"E8wCoUEbfzk" or // wcsncmp "E8wCoUEbfzk" or // wcsncmp
@@ -1291,12 +1291,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private unsafe bool TryCreateNativeImportIntrinsic(string nid, out nint address) private unsafe bool TryCreateNativeImportIntrinsic(string nid, out nint address)
{ {
if (IsHlePreferredNid(nid))
{
address = 0;
return false;
}
if (nid == "1jfXLRVzisc" && if (nid == "1jfXLRVzisc" &&
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_USLEEP"), "1", StringComparison.Ordinal)) string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_USLEEP"), "1", StringComparison.Ordinal))
{ {
@@ -1408,54 +1402,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
0x75, 0xE7, 0x75, 0xE7,
0xC3, 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" => "pNtJdE3x49E" or "fV2xHER+bKE" =>
[ [
0x0F, 0xB7, 0x07, 0x0F, 0xB7, 0x07,
@@ -1520,14 +1466,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
"Q3VBxCXhUHs" => "Q3VBxCXhUHs" =>
[ [
0x48, 0x89, 0xF8, 0x48, 0x89, 0xF8,
0x48, 0x85, 0xD2, 0x48, 0x89, 0xD1,
0x74, 0x11, 0xF3, 0xA4,
0x44, 0x8A, 0x06,
0x44, 0x88, 0x07,
0x48, 0xFF, 0xC6,
0x48, 0xFF, 0xC7,
0x48, 0xFF, 0xCA,
0x75, 0xEF,
0xC3, 0xC3,
], ],
"8zTFvBIAIN8" => "8zTFvBIAIN8" =>
@@ -1661,8 +1601,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private static bool IsHlePreferredNid(string nid) private static bool IsHlePreferredNid(string nid)
{ {
return string.Equals(nid, "QrZZdJ8XsX0", StringComparison.Ordinal) || return string.Equals(nid, "QrZZdJ8XsX0", StringComparison.Ordinal);
string.Equals(nid, "Q3VBxCXhUHs", StringComparison.Ordinal);
} }
private static bool IsLibcLibrary(string libraryName) private static bool IsLibcLibrary(string libraryName)
@@ -2602,22 +2541,28 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private unsafe bool TryPatchSse4aExtrqBlend(nint address, byte* source) private unsafe bool TryPatchSse4aExtrqBlend(nint address, byte* source)
{ {
// Rosetta does not implement AMD SSE4a EXTRQ. Recognize the compiler's // Rosetta does not implement AMD SSE4a EXTRQ. This exact sequence masks
// EXTRQ+blend idiom (against whichever xmm0-xmm7 it allocated) and rewrite // xmm2 to its low 40 bits, then copies the resulting second dword into
// it into an equivalent SSE4.1 sequence. Match/encode is isolated in // xmm0. PEXTRB/PINSRD provides the same observable result in 12 bytes:
// Sse4aExtrqBlendPatch so it can be unit-tested; here we only patch bytes. // extract source byte 4 and insert the zero-extended value into lane 1.
var window = new ReadOnlySpan<byte>(source, Sse4aExtrqBlendPatch.SequenceLength); ReadOnlySpan<byte> pattern =
if (!Sse4aExtrqBlendPatch.TryMatch(window, out var destRegister, out var srcRegister)) [
0x66, 0x0F, 0x78, 0xC2, 0x28, 0x00,
0xC4, 0xE3, 0x79, 0x02, 0xC2, 0x02,
];
for (var i = 0; i < pattern.Length; i++)
{ {
return false; if (source[i] != pattern[i])
} {
return false;
Span<byte> replacement = stackalloc byte[Sse4aExtrqBlendPatch.SequenceLength]; }
if (!Sse4aExtrqBlendPatch.TryEncode(destRegister, srcRegister, replacement))
{
return false;
} }
ReadOnlySpan<byte> replacement =
[
0x66, 0x0F, 0x3A, 0x14, 0xD0, 0x04,
0x66, 0x0F, 0x3A, 0x22, 0xC0, 0x01,
];
uint oldProtect = 0; uint oldProtect = 0;
if (!VirtualProtect((void*)address, (nuint)replacement.Length, 64u, &oldProtect)) if (!VirtualProtect((void*)address, (nuint)replacement.Length, 64u, &oldProtect))
{ {
+1 -1
View File
@@ -252,7 +252,7 @@ public static unsafe class JitStubs
var pattern = TlsAccessPattern; var pattern = TlsAccessPattern;
var end = start + length - pattern.Length; var end = start + length - pattern.Length;
for (var ptr = start; ptr <= end; ptr++) for (var ptr = start; ptr < end; ptr++)
{ {
if (MatchesPattern(ptr, pattern)) 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;
}
}
@@ -873,15 +873,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
public bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source) 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; var requiresExclusiveAccess = false;
_gate.EnterReadLock(); _gate.EnterReadLock();
try try
+1 -44
View File
@@ -125,48 +125,5 @@
"Dialog.PsExecutables": "ملفات PS التنفيذية", "Dialog.PsExecutables": "ملفات PS التنفيذية",
"Dialog.SaveLogFile": "حدد مكان حفظ ملف السجل", "Dialog.SaveLogFile": "حدد مكان حفظ ملف السجل",
"Dialog.PlainTextFiles": "ملفات نصية عادية", "Dialog.PlainTextFiles": "ملفات نصية عادية",
"Dialog.LogFiles": "ملفات السجل", "Dialog.LogFiles": "ملفات السجل"
"Library.Context.GameSettings": "إعدادات اللعبة…",
"Options.Env.Tab": "البيئة",
"Options.Section.Environment": "متغيرات البيئة",
"Options.Env.Desc": "خيارات تُمرر إلى المحاكي كمتغيرات بيئة عند التشغيل.",
"Options.Env.Bthid.Desc": "الإبلاغ عن أن Bluetooth HID غير متاح للألعاب التي تنتظر برمجيات عجلة القيادة/FFB فيها إلى ما لا نهاية.\nاتركه معطلاً عادةً. بعض الألعاب تتجمد عند فشل التهيئة.",
"Options.Env.LoopGuard.Desc": "عدم إجبار الألعاب التي تكرر النداء نفسه لفترة طويلة على الإغلاق.\nجرّب هذا عندما تُغلق لعبة نفسها أثناء التحميل.",
"Options.Env.WritableApp0.Desc": "السماح للألعاب بإنشاء الملفات والكتابة داخل مجلد التثبيت الخاص بها.\nمطلوب للنسخ غير المحزومة التي تكتب بيانات الحفظ أو الإعدادات تحت ‎/app0.",
"Options.Env.VkValidation.Desc": "تفعيل طبقات التحقق في Vulkan لتصحيح أخطاء وحدة معالجة الرسوميات.\nبطيء. يتطلب تثبيت Vulkan SDK.",
"Options.Env.DumpSpirv.Desc": "تفريغ شيدرات AGC وترجماتها إلى SPIR-V في مجلد shader-dumps.\nاستخدمه عند الإبلاغ عن أخطاء الشيدرات أو العرض.",
"Options.Env.LogDirectMemory.Desc": "تسجيل تخصيصات الذاكرة المباشرة وإخفاقاتها في وحدة التحكم.\nاستخدمه عندما تنهار لعبة أو تُغلق أثناء الإقلاع.",
"Options.Env.LogIo.Desc": "تسجيل فتح الملفات وقراءتها وحلّ المسارات في وحدة التحكم.\nاستخدمه عندما لا تجد لعبة ملفات بياناتها أثناء الإقلاع.",
"Options.Env.LogNp.Desc": "تسجيل نداءات مكتبة NP (شبكة PlayStation) في وحدة التحكم.",
"Common.Save": "حفظ",
"Common.Cancel": "إلغاء",
"PerGame.Title": "إعدادات خاصة باللعبة — {0} ({1})",
"PerGame.InheritNote": "الصفوف غير المحددة ترث الإعدادات الافتراضية العامة.",
"PerGame.EnvToggles.Label": "مفاتيح البيئة",
"PerGame.EnvToggles.Desc": "تجاوز المجموعة العامة من مفاتيح ‎SHARPEMU_*‎ لهذه اللعبة.",
"Options.About": "حول",
"About.Github.Label": "GitHub",
"About.Github.Desc": "الكود المصدري والمشكلات وتطوير المشروع.",
"About.Github.LatestCommitLabel": "أحدث Commit",
"About.Github.LatestCommitDescription": "أحدث commit على الفرع main",
"About.Discord.Label": "دسكورد",
"About.Discord.Desc": "انضم إلى المجتمع واحصل على الدعم وتابع التطوير.",
"About.GithubButton": "ساهم على GitHub!",
"About.DiscordButton": "انضم إلى دسكوردنا!",
"Updater.Auto.Label": "التحقق من التحديثات عند بدء التشغيل",
"Updater.Auto.Desc": "يتحقق من GitHub دون تأخير بدء التشغيل.",
"Updater.Label": "التحديثات",
"Updater.Check": "التحقق من التحديثات",
"Updater.DownloadRestart": "تنزيل وإعادة التشغيل",
"Updater.Status.Ready": "الإصدار الحالي: {0}",
"Updater.Status.Checking": "جارٍ التحقق من التحديثات…",
"Updater.Status.Current": "أنت على أحدث إصدار ({0}).",
"Updater.Status.Available": "يتوفر إصدار جديد: {0}",
"Updater.Status.Downloading": "جارٍ تنزيل التحديث… {0}%",
"Updater.Status.Installing": "جارٍ تثبيت التحديث…",
"Updater.Status.Timeout": "انتهت مهلة التحقق من التحديثات بعد 10 ثوانٍ.",
"Updater.Status.Failed": "تعذر التحقق من التحديثات.",
"Updater.Status.ChecksumFailed": "فشل التحديث المنزَّل في اجتياز تحقق SHA-256.",
"Updater.Status.Unsupported": "يتطلب التحديث التلقائي إصدار x64 لنظام Windows أو Linux أو macOS."
} }
+1 -28
View File
@@ -142,32 +142,5 @@
"About.Discord.Label": "Discord", "About.Discord.Label": "Discord",
"About.Discord.Desc": "Participe da comunidade, obtenha suporte e acompanhe o desenvolvimento.", "About.Discord.Desc": "Participe da comunidade, obtenha suporte e acompanhe o desenvolvimento.",
"About.GithubButton": "Contribua no GitHub!", "About.GithubButton": "Contribua no GitHub!",
"About.DiscordButton": "Entre no nosso Discord!", "About.DiscordButton": "Entre no nosso Discord!"
"Library.Context.GameSettings": "Configurações do jogo…",
"Options.Env.WritableApp0.Desc": "Permite que os jogos criem e gravem arquivos dentro da própria pasta de instalação.\nNecessário para dumps não empacotados que gravam seus saves ou configurações em /app0.",
"Options.Env.LogIo.Desc": "Registra no console a abertura e leitura de arquivos e a resolução de caminhos.\nUse quando um jogo não encontrar seus arquivos de dados durante a inicialização.",
"Common.Save": "Salvar",
"Common.Cancel": "Cancelar",
"PerGame.Title": "Configurações por jogo — {0} ({1})",
"PerGame.InheritNote": "As linhas desmarcadas herdam os padrões globais.",
"PerGame.EnvToggles.Label": "Variáveis de ambiente",
"PerGame.EnvToggles.Desc": "Substitui o conjunto global de opções SHARPEMU_* para este jogo.",
"About.Github.LatestCommitLabel": "Último commit",
"About.Github.LatestCommitDescription": "Último commit na branch main",
"Updater.Auto.Label": "Verificar atualizações ao iniciar",
"Updater.Auto.Desc": "Consulta o GitHub sem atrasar a inicialização.",
"Updater.Label": "Atualizações",
"Updater.Check": "Verificar atualizações",
"Updater.DownloadRestart": "Baixar e reiniciar",
"Updater.Status.Ready": "Build atual: {0}",
"Updater.Status.Checking": "Verificando atualizações…",
"Updater.Status.Current": "Você está atualizado ({0}).",
"Updater.Status.Available": "Um novo build está disponível: {0}",
"Updater.Status.Downloading": "Baixando atualização… {0}%",
"Updater.Status.Installing": "Instalando atualização…",
"Updater.Status.Timeout": "A verificação de atualizações expirou após 10 segundos.",
"Updater.Status.Failed": "Não foi possível verificar as atualizações.",
"Updater.Status.ChecksumFailed": "A atualização baixada falhou na verificação SHA-256.",
"Updater.Status.Unsupported": "A atualização automática requer um build x64 para Windows, Linux ou macOS."
} }
+1 -44
View File
@@ -125,48 +125,5 @@
"Dialog.PsExecutables": "PS-Ausführbare Dateien", "Dialog.PsExecutables": "PS-Ausführbare Dateien",
"Dialog.SaveLogFile": "Protokolldatei speichern unter", "Dialog.SaveLogFile": "Protokolldatei speichern unter",
"Dialog.PlainTextFiles": "Textdateien", "Dialog.PlainTextFiles": "Textdateien",
"Dialog.LogFiles": "Protokolldateien", "Dialog.LogFiles": "Protokolldateien"
"Library.Context.GameSettings": "Spieleinstellungen…",
"Options.Env.Tab": "Umgebung",
"Options.Section.Environment": "UMGEBUNGSVARIABLEN",
"Options.Env.Desc": "Schalter, die dem Emulator beim Start als Umgebungsvariablen übergeben werden.",
"Options.Env.Bthid.Desc": "Bluetooth-HID als nicht verfügbar melden, wenn die Lenkrad-/FFB-Middleware eines Titels endlos wartet.\nNormalerweise ausgeschaltet lassen. Manche Titel frieren ein, wenn die Initialisierung fehlschlägt.",
"Options.Env.LoopGuard.Desc": "Titel nicht zwangsweise beenden, wenn sie denselben Aufruf zu lange wiederholen.\nAusprobieren, wenn ein Spiel sich beim Laden von selbst beendet.",
"Options.Env.WritableApp0.Desc": "Titeln erlauben, Dateien in ihrem Installationsordner anzulegen und zu schreiben.\nNötig für entpackte Dumps, die ihre Spielstände oder Konfiguration unter /app0 speichern.",
"Options.Env.VkValidation.Desc": "Vulkan-Validierungsschichten für GPU-Debugging aktivieren.\nLangsam. Erfordert ein installiertes Vulkan SDK.",
"Options.Env.DumpSpirv.Desc": "AGC-Shader und ihre SPIR-V-Übersetzungen im Ordner shader-dumps ablegen.\nBeim Melden von Shader- oder Grafikfehlern verwenden.",
"Options.Env.LogDirectMemory.Desc": "Direkte Speicherzuweisungen und Fehler in der Konsole protokollieren.\nVerwenden, wenn ein Spiel beim Start abbricht oder sich beendet.",
"Options.Env.LogIo.Desc": "Datei-Öffnen, -Lesen und Pfadauflösung in der Konsole protokollieren.\nVerwenden, wenn ein Spiel beim Start seine Datendateien nicht findet.",
"Options.Env.LogNp.Desc": "NP-Bibliotheksaufrufe (PlayStation Network) in der Konsole protokollieren.",
"Common.Save": "Speichern",
"Common.Cancel": "Abbrechen",
"PerGame.Title": "Spielspezifische Einstellungen — {0} ({1})",
"PerGame.InheritNote": "Nicht angehakte Zeilen übernehmen die globalen Standardwerte.",
"PerGame.EnvToggles.Label": "Umgebungsschalter",
"PerGame.EnvToggles.Desc": "Die globalen SHARPEMU_*-Schalter für dieses Spiel überschreiben.",
"Options.About": "Über",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Quellcode, Issues und Projektentwicklung.",
"About.Github.LatestCommitLabel": "Neuester Commit",
"About.Github.LatestCommitDescription": "Neuester Commit auf dem main-Branch",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Tritt der Community bei, erhalte Support und verfolge die Entwicklung.",
"About.GithubButton": "Auf GitHub mitwirken!",
"About.DiscordButton": "Tritt unserem Discord bei!",
"Updater.Auto.Label": "Beim Start nach Updates suchen",
"Updater.Auto.Desc": "Fragt GitHub ab, ohne den Start zu verzögern.",
"Updater.Label": "Updates",
"Updater.Check": "Nach Updates suchen",
"Updater.DownloadRestart": "Herunterladen und neu starten",
"Updater.Status.Ready": "Aktueller Build: {0}",
"Updater.Status.Checking": "Suche nach Updates…",
"Updater.Status.Current": "Du bist auf dem neuesten Stand ({0}).",
"Updater.Status.Available": "Ein neuer Build ist verfügbar: {0}",
"Updater.Status.Downloading": "Update wird heruntergeladen… {0}%",
"Updater.Status.Installing": "Update wird installiert…",
"Updater.Status.Timeout": "Die Updateprüfung ist nach 10 Sekunden abgelaufen.",
"Updater.Status.Failed": "Updates konnten nicht geprüft werden.",
"Updater.Status.ChecksumFailed": "Das heruntergeladene Update hat die SHA-256-Prüfung nicht bestanden.",
"Updater.Status.Unsupported": "Automatische Updates erfordern einen x64-Build für Windows, Linux oder macOS."
} }
+1 -44
View File
@@ -125,48 +125,5 @@
"Dialog.PsExecutables": "PS-programmer", "Dialog.PsExecutables": "PS-programmer",
"Dialog.SaveLogFile": "Vælg hvor logfilen skal gemmes", "Dialog.SaveLogFile": "Vælg hvor logfilen skal gemmes",
"Dialog.PlainTextFiles": "Almindelige tekstfiler", "Dialog.PlainTextFiles": "Almindelige tekstfiler",
"Dialog.LogFiles": "Logfiler", "Dialog.LogFiles": "Logfiler"
"Library.Context.GameSettings": "Spilindstillinger…",
"Options.Env.Tab": "Miljø",
"Options.Section.Environment": "MILJØVARIABLER",
"Options.Env.Desc": "Kontakter, der gives videre til emulatoren som miljøvariabler ved start.",
"Options.Env.Bthid.Desc": "Rapportér Bluetooth HID som utilgængelig for titler, hvis rat-/FFB-middleware venter i det uendelige.\nLad den normalt være slået fra. Nogle titler fryser, når initialiseringen fejler.",
"Options.Env.LoopGuard.Desc": "Tving ikke titler til at lukke, når de gentager det samme kald for længe.\nPrøv dette, når et spil lukker af sig selv under indlæsning.",
"Options.Env.WritableApp0.Desc": "Tillad titler at oprette og skrive filer i deres installationsmappe.\nKræves af upakkede dumps, der skriver deres gemte data eller konfiguration under /app0.",
"Options.Env.VkValidation.Desc": "Aktivér Vulkan-valideringslag til GPU-fejlfinding.\nLangsomt. Kræver at Vulkan SDK er installeret.",
"Options.Env.DumpSpirv.Desc": "Gem AGC-shadere og deres SPIR-V-oversættelser i mappen shader-dumps.\nBrug dette, når du rapporterer shader- eller grafikfejl.",
"Options.Env.LogDirectMemory.Desc": "Log direkte hukommelsestildelinger og fejl til konsollen.\nBrug dette, når et spil afbryder eller lukker under opstart.",
"Options.Env.LogIo.Desc": "Log åbning og læsning af filer samt stiopslag til konsollen.\nBrug dette, når et spil ikke kan finde sine datafiler under opstart.",
"Options.Env.LogNp.Desc": "Log NP-bibliotekskald (PlayStation Network) til konsollen.",
"Common.Save": "Gem",
"Common.Cancel": "Annuller",
"PerGame.Title": "Indstillinger pr. spil — {0} ({1})",
"PerGame.InheritNote": "Umarkerede rækker arver de globale standardværdier.",
"PerGame.EnvToggles.Label": "Miljøkontakter",
"PerGame.EnvToggles.Desc": "Tilsidesæt det globale sæt SHARPEMU_*-kontakter for dette spil.",
"Options.About": "Om",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Kildekode, issues og projektudvikling.",
"About.Github.LatestCommitLabel": "Seneste commit",
"About.Github.LatestCommitDescription": "Seneste commit på main-branchen",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Bliv en del af fællesskabet, få hjælp og følg udviklingen.",
"About.GithubButton": "Bidrag på GitHub!",
"About.DiscordButton": "Bliv medlem af vores Discord!",
"Updater.Auto.Label": "Søg efter opdateringer ved start",
"Updater.Auto.Desc": "Tjekker GitHub uden at forsinke opstarten.",
"Updater.Label": "Opdateringer",
"Updater.Check": "Søg efter opdateringer",
"Updater.DownloadRestart": "Download og genstart",
"Updater.Status.Ready": "Nuværende build: {0}",
"Updater.Status.Checking": "Søger efter opdateringer…",
"Updater.Status.Current": "Du er opdateret ({0}).",
"Updater.Status.Available": "Et nyt build er tilgængeligt: {0}",
"Updater.Status.Downloading": "Downloader opdatering… {0}%",
"Updater.Status.Installing": "Installerer opdatering…",
"Updater.Status.Timeout": "Opdateringstjekket fik timeout efter 10 sekunder.",
"Updater.Status.Failed": "Kunne ikke søge efter opdateringer.",
"Updater.Status.ChecksumFailed": "Den downloadede opdatering bestod ikke SHA-256-verifikationen.",
"Updater.Status.Unsupported": "Automatisk opdatering kræver et x64-build til Windows, Linux eller macOS."
} }
+1 -35
View File
@@ -135,39 +135,5 @@
"About.Github.LatestCommitDescription": "Último commit en la rama main", "About.Github.LatestCommitDescription": "Último commit en la rama main",
"About.Discord.Desc": "Únete a la comunidad, recibe soporte y sigue el desarrollo.", "About.Discord.Desc": "Únete a la comunidad, recibe soporte y sigue el desarrollo.",
"About.GithubButton": "Contribuye en GitHub!", "About.GithubButton": "Contribuye en GitHub!",
"About.DiscordButton": "Únete a nuestro Discord!", "About.DiscordButton": "Únete a nuestro Discord!"
"Library.Context.GameSettings": "Ajustes del juego…",
"Options.Env.Tab": "Entorno",
"Options.Section.Environment": "VARIABLES DE ENTORNO",
"Options.Env.Desc": "Opciones que se pasan al emulador como variables de entorno al iniciar.",
"Options.Env.Bthid.Desc": "Indicar que Bluetooth HID no está disponible para títulos cuyo middleware de volante/FFB espera indefinidamente.\nDéjalo desactivado normalmente. Algunos títulos se congelan cuando la inicialización falla.",
"Options.Env.LoopGuard.Desc": "No forzar el cierre de títulos que repiten la misma llamada durante demasiado tiempo.\nPruébalo cuando un juego se cierre solo durante la carga.",
"Options.Env.WritableApp0.Desc": "Permitir que los títulos creen y escriban archivos dentro de su carpeta de instalación.\nNecesario para dumps sin empaquetar que guardan sus datos o configuración en /app0.",
"Options.Env.VkValidation.Desc": "Activar las capas de validación de Vulkan para depurar la GPU.\nLento. Requiere tener instalado el SDK de Vulkan.",
"Options.Env.DumpSpirv.Desc": "Volcar los shaders AGC y sus traducciones SPIR-V a la carpeta shader-dumps.\nÚsalo al informar de errores de shaders o de renderizado.",
"Options.Env.LogDirectMemory.Desc": "Registrar en la consola las asignaciones de memoria directa y sus fallos.\nÚsalo cuando un juego se aborte o se cierre durante el arranque.",
"Options.Env.LogIo.Desc": "Registrar en la consola la apertura y lectura de archivos y la resolución de rutas.\nÚsalo cuando un juego no encuentre sus archivos de datos durante el arranque.",
"Options.Env.LogNp.Desc": "Registrar en la consola las llamadas a la biblioteca NP (PlayStation Network).",
"Common.Save": "Guardar",
"Common.Cancel": "Cancelar",
"PerGame.Title": "Ajustes por juego — {0} ({1})",
"PerGame.InheritNote": "Las filas sin marcar heredan los valores globales.",
"PerGame.EnvToggles.Label": "Variables de entorno",
"PerGame.EnvToggles.Desc": "Sustituir el conjunto global de opciones SHARPEMU_* para este juego.",
"Updater.Auto.Label": "Buscar actualizaciones al iniciar",
"Updater.Auto.Desc": "Consulta GitHub sin retrasar el arranque.",
"Updater.Label": "Actualizaciones",
"Updater.Check": "Buscar actualizaciones",
"Updater.DownloadRestart": "Descargar y reiniciar",
"Updater.Status.Ready": "Build actual: {0}",
"Updater.Status.Checking": "Buscando actualizaciones…",
"Updater.Status.Current": "Estás al día ({0}).",
"Updater.Status.Available": "Hay un nuevo build disponible: {0}",
"Updater.Status.Downloading": "Descargando actualización… {0}%",
"Updater.Status.Installing": "Instalando actualización…",
"Updater.Status.Timeout": "La comprobación de actualizaciones caducó tras 10 segundos.",
"Updater.Status.Failed": "No se pudieron comprobar las actualizaciones.",
"Updater.Status.ChecksumFailed": "La actualización descargada no superó la verificación SHA-256.",
"Updater.Status.Unsupported": "La actualización automática requiere un build x64 de Windows, Linux o macOS."
} }
+1 -28
View File
@@ -142,32 +142,5 @@
"About.Discord.Label": "Discord", "About.Discord.Label": "Discord",
"About.Discord.Desc": "Rejoignez la communauté, obtenez de laide et suivez le développement.", "About.Discord.Desc": "Rejoignez la communauté, obtenez de laide et suivez le développement.",
"About.GithubButton": "Contribuer sur GitHub !", "About.GithubButton": "Contribuer sur GitHub !",
"About.DiscordButton": "Rejoindre notre Discord !", "About.DiscordButton": "Rejoindre notre Discord !"
"Library.Context.GameSettings": "Paramètres du jeu…",
"Options.Env.WritableApp0.Desc": "Autoriser les jeux à créer et écrire des fichiers dans leur dossier dinstallation.\nNécessaire pour les dumps non empaquetés qui écrivent leurs sauvegardes ou leur configuration sous /app0.",
"Options.Env.LogIo.Desc": "Journaliser louverture et la lecture des fichiers ainsi que la résolution des chemins dans la console.\nÀ utiliser quand un jeu ne trouve pas ses fichiers de données au démarrage.",
"Common.Save": "Enregistrer",
"Common.Cancel": "Annuler",
"PerGame.Title": "Paramètres par jeu — {0} ({1})",
"PerGame.InheritNote": "Les lignes non cochées héritent des valeurs globales par défaut.",
"PerGame.EnvToggles.Label": "Variables denvironnement",
"PerGame.EnvToggles.Desc": "Remplacer lensemble global des options SHARPEMU_* pour ce jeu.",
"About.Github.LatestCommitLabel": "Dernier commit",
"About.Github.LatestCommitDescription": "Dernier commit sur la branche main",
"Updater.Auto.Label": "Vérifier les mises à jour au démarrage",
"Updater.Auto.Desc": "Interroge GitHub sans retarder le démarrage.",
"Updater.Label": "Mises à jour",
"Updater.Check": "Vérifier les mises à jour",
"Updater.DownloadRestart": "Télécharger et redémarrer",
"Updater.Status.Ready": "Build actuel : {0}",
"Updater.Status.Checking": "Recherche de mises à jour…",
"Updater.Status.Current": "Vous êtes à jour ({0}).",
"Updater.Status.Available": "Un nouveau build est disponible : {0}",
"Updater.Status.Downloading": "Téléchargement de la mise à jour… {0}%",
"Updater.Status.Installing": "Installation de la mise à jour…",
"Updater.Status.Timeout": "La vérification des mises à jour a expiré après 10 secondes.",
"Updater.Status.Failed": "Impossible de vérifier les mises à jour.",
"Updater.Status.ChecksumFailed": "La mise à jour téléchargée a échoué à la vérification SHA-256.",
"Updater.Status.Unsupported": "La mise à jour automatique nécessite un build x64 pour Windows, Linux ou macOS."
} }
+1 -28
View File
@@ -142,32 +142,5 @@
"About.Discord.Label": "Discord", "About.Discord.Label": "Discord",
"About.Discord.Desc": "Csatlakozz a közösséghe, kérj segítéget és kövesd nyomon a fejlesztést.", "About.Discord.Desc": "Csatlakozz a közösséghe, kérj segítéget és kövesd nyomon a fejlesztést.",
"About.GithubButton": "Járulj hozzá GitHubon!", "About.GithubButton": "Járulj hozzá GitHubon!",
"About.DiscordButton": "Csatlakozz a Discordunhoz!", "About.DiscordButton": "Csatlakozz a Discordunhoz!"
"Library.Context.GameSettings": "Játékbeállítások…",
"Options.Env.WritableApp0.Desc": "Engedélyezi, hogy a játékok fájlokat hozzanak létre és írjanak a telepítési mappájukban.\nA kicsomagolt dumpokhoz szükséges, amelyek a mentéseiket vagy beállításaikat az /app0 alá írják.",
"Options.Env.LogIo.Desc": "A fájlmegnyitások, olvasások és útvonal-feloldások naplózása a konzolra.\nAkkor használd, ha egy játék indításkor nem találja az adatfájljait.",
"Common.Save": "Mentés",
"Common.Cancel": "Mégse",
"PerGame.Title": "Játékonkénti beállítások — {0} ({1})",
"PerGame.InheritNote": "A be nem jelölt sorok a globális alapértelmezéseket öröklik.",
"PerGame.EnvToggles.Label": "Környezeti kapcsolók",
"PerGame.EnvToggles.Desc": "A globális SHARPEMU_* kapcsolókészlet felülírása ennél a játéknál.",
"About.Github.LatestCommitLabel": "Legutóbbi commit",
"About.Github.LatestCommitDescription": "A main ág legutóbbi commitja",
"Updater.Auto.Label": "Frissítések keresése indításkor",
"Updater.Auto.Desc": "A GitHubot az indítás késleltetése nélkül ellenőrzi.",
"Updater.Label": "Frissítések",
"Updater.Check": "Frissítések keresése",
"Updater.DownloadRestart": "Letöltés és újraindítás",
"Updater.Status.Ready": "Jelenlegi build: {0}",
"Updater.Status.Checking": "Frissítések keresése…",
"Updater.Status.Current": "Naprakész vagy ({0}).",
"Updater.Status.Available": "Új build érhető el: {0}",
"Updater.Status.Downloading": "Frissítés letöltése… {0}%",
"Updater.Status.Installing": "Frissítés telepítése…",
"Updater.Status.Timeout": "A frissítés-ellenőrzés 10 másodperc után túllépte az időkorlátot.",
"Updater.Status.Failed": "Nem sikerült frissítéseket keresni.",
"Updater.Status.ChecksumFailed": "A letöltött frissítés nem ment át az SHA-256-ellenőrzésen.",
"Updater.Status.Unsupported": "Az automatikus frissítéshez Windows, Linux vagy macOS x64 build szükséges."
} }
+1 -44
View File
@@ -130,48 +130,5 @@
"Dialog.PsExecutables": "Eseguibili PS", "Dialog.PsExecutables": "Eseguibili PS",
"Dialog.SaveLogFile": "Scegli dove salvare il file di log", "Dialog.SaveLogFile": "Scegli dove salvare il file di log",
"Dialog.PlainTextFiles": "File di testo semplice", "Dialog.PlainTextFiles": "File di testo semplice",
"Dialog.LogFiles": "File di log", "Dialog.LogFiles": "File di log"
"Library.Context.GameSettings": "Impostazioni del gioco…",
"Options.Env.Tab": "Ambiente",
"Options.Section.Environment": "VARIABILI D'AMBIENTE",
"Options.Env.Desc": "Opzioni passate all'emulatore come variabili d'ambiente all'avvio.",
"Options.Env.Bthid.Desc": "Segnala il Bluetooth HID come non disponibile per i titoli il cui middleware volante/FFB attende all'infinito.\nNormalmente lascialo disattivato. Alcuni titoli si bloccano quando l'inizializzazione fallisce.",
"Options.Env.LoopGuard.Desc": "Non forzare la chiusura dei titoli che ripetono la stessa chiamata troppo a lungo.\nProvalo quando un gioco si chiude da solo durante il caricamento.",
"Options.Env.WritableApp0.Desc": "Consenti ai titoli di creare e scrivere file nella propria cartella di installazione.\nNecessario per i dump non pacchettizzati che scrivono salvataggi o configurazioni in /app0.",
"Options.Env.VkValidation.Desc": "Abilita i validation layer di Vulkan per il debug della GPU.\nLento. Richiede l'SDK di Vulkan installato.",
"Options.Env.DumpSpirv.Desc": "Esporta gli shader AGC e le loro traduzioni SPIR-V nella cartella shader-dumps.\nUsalo quando segnali bug di shader o di rendering.",
"Options.Env.LogDirectMemory.Desc": "Registra in console le allocazioni di memoria diretta e i relativi errori.\nUsalo quando un gioco si interrompe o si chiude durante l'avvio.",
"Options.Env.LogIo.Desc": "Registra in console l'apertura e la lettura dei file e la risoluzione dei percorsi.\nUsalo quando un gioco non trova i propri file di dati durante l'avvio.",
"Options.Env.LogNp.Desc": "Registra in console le chiamate alla libreria NP (PlayStation Network).",
"Common.Save": "Salva",
"Common.Cancel": "Annulla",
"PerGame.Title": "Impostazioni per gioco — {0} ({1})",
"PerGame.InheritNote": "Le righe non selezionate ereditano i valori globali.",
"PerGame.EnvToggles.Label": "Variabili d'ambiente",
"PerGame.EnvToggles.Desc": "Sovrascrivi l'insieme globale delle opzioni SHARPEMU_* per questo gioco.",
"Options.About": "Informazioni",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Codice sorgente, issue e sviluppo del progetto.",
"About.Github.LatestCommitLabel": "Ultimo commit",
"About.Github.LatestCommitDescription": "Ultimo commit sul branch main",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Unisciti alla community, ricevi supporto e segui lo sviluppo.",
"About.GithubButton": "Contribuisci su GitHub!",
"About.DiscordButton": "Unisciti al nostro Discord!",
"Updater.Auto.Label": "Controlla aggiornamenti all'avvio",
"Updater.Auto.Desc": "Interroga GitHub senza rallentare l'avvio.",
"Updater.Label": "Aggiornamenti",
"Updater.Check": "Controlla aggiornamenti",
"Updater.DownloadRestart": "Scarica e riavvia",
"Updater.Status.Ready": "Build attuale: {0}",
"Updater.Status.Checking": "Ricerca aggiornamenti…",
"Updater.Status.Current": "Sei aggiornato ({0}).",
"Updater.Status.Available": "È disponibile un nuovo build: {0}",
"Updater.Status.Downloading": "Download dell'aggiornamento… {0}%",
"Updater.Status.Installing": "Installazione dell'aggiornamento…",
"Updater.Status.Timeout": "Il controllo degli aggiornamenti è scaduto dopo 10 secondi.",
"Updater.Status.Failed": "Impossibile controllare gli aggiornamenti.",
"Updater.Status.ChecksumFailed": "L'aggiornamento scaricato non ha superato la verifica SHA-256.",
"Updater.Status.Unsupported": "L'aggiornamento automatico richiede un build x64 per Windows, Linux o macOS."
} }
+1 -44
View File
@@ -125,48 +125,5 @@
"Dialog.PsExecutables": "PlayStation 実行ファイル", "Dialog.PsExecutables": "PlayStation 実行ファイル",
"Dialog.SaveLogFile": "ログファイルの保存先を選択", "Dialog.SaveLogFile": "ログファイルの保存先を選択",
"Dialog.PlainTextFiles": "プレーンテキストファイル", "Dialog.PlainTextFiles": "プレーンテキストファイル",
"Dialog.LogFiles": "ログファイル", "Dialog.LogFiles": "ログファイル"
"Library.Context.GameSettings": "ゲーム設定…",
"Options.Env.Tab": "環境",
"Options.Section.Environment": "環境変数",
"Options.Env.Desc": "起動時に環境変数としてエミュレータへ渡されるスイッチです。",
"Options.Env.Bthid.Desc": "ハンドル/FFBミドルウェアが永久に待機するタイトル向けに、Bluetooth HIDを利用不可として報告します。\n通常はオフのままにしてください。初期化に失敗するとフリーズするタイトルもあります。",
"Options.Env.LoopGuard.Desc": "同じ呼び出しを長時間繰り返すタイトルを強制終了しません。\nロード中にゲームが勝手に終了する場合に試してください。",
"Options.Env.WritableApp0.Desc": "タイトルがインストールフォルダー内にファイルを作成・書き込みできるようにします。\nセーブや設定データを/app0以下に書き込む未パッケージのダンプに必要です。",
"Options.Env.VkValidation.Desc": "GPUデバッグ用のVulkan検証レイヤーを有効にします。\n低速です。Vulkan SDKのインストールが必要です。",
"Options.Env.DumpSpirv.Desc": "AGCシェーダーとそのSPIR-V変換をshader-dumpsフォルダーに出力します。\nシェーダーや描画のバグを報告する際に使用してください。",
"Options.Env.LogDirectMemory.Desc": "ダイレクトメモリの割り当てと失敗をコンソールに記録します。\nゲームが起動中に中断・終了する場合に使用してください。",
"Options.Env.LogIo.Desc": "ファイルのオープン・読み込み・パス解決の動作をコンソールに記録します。\nゲームが起動中にデータファイルを見つけられない場合に使用してください。",
"Options.Env.LogNp.Desc": "NPPlayStation Network)ライブラリの呼び出しをコンソールに記録します。",
"Common.Save": "保存",
"Common.Cancel": "キャンセル",
"PerGame.Title": "ゲームごとの設定 — {0} ({1})",
"PerGame.InheritNote": "チェックされていない行はグローバルの既定値を継承します。",
"PerGame.EnvToggles.Label": "環境スイッチ",
"PerGame.EnvToggles.Desc": "このゲームに対してグローバルのSHARPEMU_*スイッチを上書きします。",
"Options.About": "情報",
"About.Github.Label": "GitHub",
"About.Github.Desc": "ソースコード、Issue、プロジェクトの開発。",
"About.Github.LatestCommitLabel": "最新コミット",
"About.Github.LatestCommitDescription": "mainブランチの最新コミット",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "コミュニティに参加して、サポートを受けたり開発を追いかけたりしましょう。",
"About.GithubButton": "GitHubで貢献しよう!",
"About.DiscordButton": "Discordに参加しよう!",
"Updater.Auto.Label": "起動時にアップデートを確認",
"Updater.Auto.Desc": "起動を遅らせずにGitHubへ確認します。",
"Updater.Label": "アップデート",
"Updater.Check": "アップデートを確認",
"Updater.DownloadRestart": "ダウンロードして再起動",
"Updater.Status.Ready": "現在のビルド: {0}",
"Updater.Status.Checking": "アップデートを確認しています…",
"Updater.Status.Current": "最新の状態です({0})。",
"Updater.Status.Available": "新しいビルドがあります: {0}",
"Updater.Status.Downloading": "アップデートをダウンロード中… {0}%",
"Updater.Status.Installing": "アップデートをインストール中…",
"Updater.Status.Timeout": "アップデートの確認が10秒でタイムアウトしました。",
"Updater.Status.Failed": "アップデートを確認できませんでした。",
"Updater.Status.ChecksumFailed": "ダウンロードしたアップデートはSHA-256検証に失敗しました。",
"Updater.Status.Unsupported": "自動アップデートにはWindows、Linux、またはmacOSのx64ビルドが必要です。"
} }
+1 -44
View File
@@ -125,48 +125,5 @@
"Dialog.PsExecutables": "PlayStation 실행 파일", "Dialog.PsExecutables": "PlayStation 실행 파일",
"Dialog.SaveLogFile": "로그 파일 저장 위치 선택", "Dialog.SaveLogFile": "로그 파일 저장 위치 선택",
"Dialog.PlainTextFiles": "일반 텍스트 파일", "Dialog.PlainTextFiles": "일반 텍스트 파일",
"Dialog.LogFiles": "로그 파일", "Dialog.LogFiles": "로그 파일"
"Library.Context.GameSettings": "게임 설정…",
"Options.Env.Tab": "환경",
"Options.Section.Environment": "환경 변수",
"Options.Env.Desc": "실행 시 환경 변수로 에뮬레이터에 전달되는 스위치입니다.",
"Options.Env.Bthid.Desc": "휠/FFB 미들웨어가 무한 대기하는 타이틀을 위해 블루투스 HID를 사용 불가로 보고합니다.\n평소에는 꺼 두세요. 초기화에 실패하면 멈추는 타이틀도 있습니다.",
"Options.Env.LoopGuard.Desc": "같은 호출을 너무 오래 반복하는 타이틀을 강제 종료하지 않습니다.\n게임이 로딩 중 저절로 종료될 때 시도해 보세요.",
"Options.Env.WritableApp0.Desc": "타이틀이 설치 폴더 안에 파일을 만들고 쓸 수 있도록 허용합니다.\n세이브나 설정 데이터를 /app0 아래에 쓰는 비패키지 덤프에 필요합니다.",
"Options.Env.VkValidation.Desc": "GPU 디버깅을 위한 Vulkan 검증 레이어를 활성화합니다.\n느립니다. Vulkan SDK가 설치되어 있어야 합니다.",
"Options.Env.DumpSpirv.Desc": "AGC 셰이더와 SPIR-V 변환 결과를 shader-dumps 폴더에 저장합니다.\n셰이더나 렌더링 버그를 보고할 때 사용하세요.",
"Options.Env.LogDirectMemory.Desc": "다이렉트 메모리 할당과 실패를 콘솔에 기록합니다.\n게임이 부팅 중 중단되거나 종료될 때 사용하세요.",
"Options.Env.LogIo.Desc": "파일 열기, 읽기, 경로 확인 동작을 콘솔에 기록합니다.\n게임이 부팅 중 데이터 파일을 찾지 못할 때 사용하세요.",
"Options.Env.LogNp.Desc": "NP(PlayStation Network) 라이브러리 호출을 콘솔에 기록합니다.",
"Common.Save": "저장",
"Common.Cancel": "취소",
"PerGame.Title": "게임별 설정 — {0} ({1})",
"PerGame.InheritNote": "선택하지 않은 항목은 전역 기본값을 따릅니다.",
"PerGame.EnvToggles.Label": "환경 스위치",
"PerGame.EnvToggles.Desc": "이 게임에 대해 전역 SHARPEMU_* 스위치 설정을 재정의합니다.",
"Options.About": "정보",
"About.Github.Label": "GitHub",
"About.Github.Desc": "소스 코드, 이슈, 프로젝트 개발.",
"About.Github.LatestCommitLabel": "최신 커밋",
"About.Github.LatestCommitDescription": "main 브랜치의 최신 커밋",
"About.Discord.Label": "디스코드",
"About.Discord.Desc": "커뮤니티에 참여해 지원을 받고 개발 소식을 확인하세요.",
"About.GithubButton": "GitHub에서 기여하기!",
"About.DiscordButton": "디스코드 참여하기!",
"Updater.Auto.Label": "시작 시 업데이트 확인",
"Updater.Auto.Desc": "시작을 지연시키지 않고 GitHub를 확인합니다.",
"Updater.Label": "업데이트",
"Updater.Check": "업데이트 확인",
"Updater.DownloadRestart": "다운로드 후 재시작",
"Updater.Status.Ready": "현재 빌드: {0}",
"Updater.Status.Checking": "업데이트 확인 중…",
"Updater.Status.Current": "최신 상태입니다 ({0}).",
"Updater.Status.Available": "새 빌드가 있습니다: {0}",
"Updater.Status.Downloading": "업데이트 다운로드 중… {0}%",
"Updater.Status.Installing": "업데이트 설치 중…",
"Updater.Status.Timeout": "업데이트 확인이 10초 후 시간 초과되었습니다.",
"Updater.Status.Failed": "업데이트를 확인할 수 없습니다.",
"Updater.Status.ChecksumFailed": "다운로드한 업데이트가 SHA-256 검증에 실패했습니다.",
"Updater.Status.Unsupported": "자동 업데이트에는 Windows, Linux 또는 macOS x64 빌드가 필요합니다."
} }
+1 -44
View File
@@ -125,48 +125,5 @@
"Dialog.PsExecutables": "PS-uitvoerbare bestanden", "Dialog.PsExecutables": "PS-uitvoerbare bestanden",
"Dialog.SaveLogFile": "Selecteer waar het logbestand moet worden opgeslagen", "Dialog.SaveLogFile": "Selecteer waar het logbestand moet worden opgeslagen",
"Dialog.PlainTextFiles": "Platte tekstbestanden", "Dialog.PlainTextFiles": "Platte tekstbestanden",
"Dialog.LogFiles": "Logbestanden", "Dialog.LogFiles": "Logbestanden"
"Library.Context.GameSettings": "Game-instellingen…",
"Options.Env.Tab": "Omgeving",
"Options.Section.Environment": "OMGEVINGSVARIABELEN",
"Options.Env.Desc": "Schakelaars die bij het starten als omgevingsvariabelen aan de emulator worden doorgegeven.",
"Options.Env.Bthid.Desc": "Meld Bluetooth HID als niet beschikbaar voor titels waarvan de stuur-/FFB-middleware eindeloos blijft wachten.\nLaat dit normaal uit. Sommige titels bevriezen wanneer de initialisatie mislukt.",
"Options.Env.LoopGuard.Desc": "Titels die dezelfde aanroep te lang herhalen niet geforceerd afsluiten.\nProbeer dit wanneer een game zichzelf tijdens het laden afsluit.",
"Options.Env.WritableApp0.Desc": "Sta titels toe bestanden aan te maken en te schrijven in hun installatiemap.\nNodig voor uitgepakte dumps die hun save- of configuratiegegevens onder /app0 wegschrijven.",
"Options.Env.VkValidation.Desc": "Schakel Vulkan-validatielagen in voor GPU-debugging.\nTraag. Vereist een geïnstalleerde Vulkan SDK.",
"Options.Env.DumpSpirv.Desc": "Sla AGC-shaders en hun SPIR-V-vertalingen op in de map shader-dumps.\nGebruik dit bij het melden van shader- of renderfouten.",
"Options.Env.LogDirectMemory.Desc": "Log directe geheugentoewijzingen en fouten naar de console.\nGebruik dit wanneer een game tijdens het opstarten afbreekt of afsluit.",
"Options.Env.LogIo.Desc": "Log het openen en lezen van bestanden en het oplossen van paden naar de console.\nGebruik dit wanneer een game zijn databestanden niet kan vinden tijdens het opstarten.",
"Options.Env.LogNp.Desc": "Log NP-bibliotheekaanroepen (PlayStation Network) naar de console.",
"Common.Save": "Opslaan",
"Common.Cancel": "Annuleren",
"PerGame.Title": "Instellingen per game — {0} ({1})",
"PerGame.InheritNote": "Niet-aangevinkte rijen erven de globale standaardwaarden.",
"PerGame.EnvToggles.Label": "Omgevingsschakelaars",
"PerGame.EnvToggles.Desc": "Overschrijf de globale set SHARPEMU_*-schakelaars voor deze game.",
"Options.About": "Over",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Broncode, issues en projectontwikkeling.",
"About.Github.LatestCommitLabel": "Nieuwste commit",
"About.Github.LatestCommitDescription": "Nieuwste commit op de main-branch",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Word lid van de community, krijg ondersteuning en volg de ontwikkeling.",
"About.GithubButton": "Draag bij op GitHub!",
"About.DiscordButton": "Word lid van onze Discord!",
"Updater.Auto.Label": "Bij het opstarten controleren op updates",
"Updater.Auto.Desc": "Controleert GitHub zonder het opstarten te vertragen.",
"Updater.Label": "Updates",
"Updater.Check": "Controleren op updates",
"Updater.DownloadRestart": "Downloaden en opnieuw starten",
"Updater.Status.Ready": "Huidige build: {0}",
"Updater.Status.Checking": "Controleren op updates…",
"Updater.Status.Current": "Je bent up-to-date ({0}).",
"Updater.Status.Available": "Er is een nieuwe build beschikbaar: {0}",
"Updater.Status.Downloading": "Update downloaden… {0}%",
"Updater.Status.Installing": "Update installeren…",
"Updater.Status.Timeout": "De updatecontrole is na 10 seconden verlopen.",
"Updater.Status.Failed": "Kon niet controleren op updates.",
"Updater.Status.ChecksumFailed": "De gedownloade update is niet door de SHA-256-verificatie gekomen.",
"Updater.Status.Unsupported": "Automatisch updaten vereist een x64-build voor Windows, Linux of macOS."
} }
+1 -28
View File
@@ -142,32 +142,5 @@
"About.Discord.Label": "Discord", "About.Discord.Label": "Discord",
"About.Discord.Desc": "Junte-se à comunidade, obtenha suporte e acompanhe o desenvolvimento.", "About.Discord.Desc": "Junte-se à comunidade, obtenha suporte e acompanhe o desenvolvimento.",
"About.GithubButton": "Contribua no GitHub!", "About.GithubButton": "Contribua no GitHub!",
"About.DiscordButton": "Junte-se ao nosso Discord!", "About.DiscordButton": "Junte-se ao nosso Discord!"
"Library.Context.GameSettings": "Definições do jogo…",
"Options.Env.WritableApp0.Desc": "Permitir que os jogos criem e escrevam ficheiros dentro da sua pasta de instalação.\nNecessário para dumps não empacotados que escrevem os seus dados guardados ou configurações em /app0.",
"Options.Env.LogIo.Desc": "Registar na consola a abertura e leitura de ficheiros e a resolução de caminhos.\nUtilize quando um jogo não encontrar os seus ficheiros de dados durante o arranque.",
"Common.Save": "Guardar",
"Common.Cancel": "Cancelar",
"PerGame.Title": "Definições por jogo — {0} ({1})",
"PerGame.InheritNote": "As linhas não assinaladas herdam as predefinições globais.",
"PerGame.EnvToggles.Label": "Variáveis de ambiente",
"PerGame.EnvToggles.Desc": "Substituir o conjunto global de opções SHARPEMU_* para este jogo.",
"About.Github.LatestCommitLabel": "Último commit",
"About.Github.LatestCommitDescription": "Último commit no ramo main",
"Updater.Auto.Label": "Procurar atualizações no arranque",
"Updater.Auto.Desc": "Consulta o GitHub sem atrasar o arranque.",
"Updater.Label": "Atualizações",
"Updater.Check": "Procurar atualizações",
"Updater.DownloadRestart": "Transferir e reiniciar",
"Updater.Status.Ready": "Build atual: {0}",
"Updater.Status.Checking": "A procurar atualizações…",
"Updater.Status.Current": "Está atualizado ({0}).",
"Updater.Status.Available": "Está disponível um novo build: {0}",
"Updater.Status.Downloading": "A transferir a atualização… {0}%",
"Updater.Status.Installing": "A instalar a atualização…",
"Updater.Status.Timeout": "A verificação de atualizações expirou após 10 segundos.",
"Updater.Status.Failed": "Não foi possível procurar atualizações.",
"Updater.Status.ChecksumFailed": "A atualização transferida falhou a verificação SHA-256.",
"Updater.Status.Unsupported": "A atualização automática requer um build x64 para Windows, Linux ou macOS."
} }
+1 -3
View File
@@ -169,7 +169,5 @@
"Updater.Status.Installing": "Установка обновления…", "Updater.Status.Installing": "Установка обновления…",
"Updater.Status.Timeout": "Проверка обновлений превысила лимит времени в 10 секунд.", "Updater.Status.Timeout": "Проверка обновлений превысила лимит времени в 10 секунд.",
"Updater.Status.Failed": "Не удалось проверить наличие обновлений.", "Updater.Status.Failed": "Не удалось проверить наличие обновлений.",
"Updater.Status.Unsupported": "Автоматическое обновление требует сборку Windows, Linux или macOS x64.", "Updater.Status.Unsupported": "Автоматическое обновление требует сборку Windows, Linux или macOS x64."
"Updater.Status.ChecksumFailed": "Скачанное обновление не прошло проверку SHA-256."
} }
+1 -29
View File
@@ -141,33 +141,5 @@
"Updater.Status.Timeout": "Güncelleme denetimi 10 saniye sonra zaman aşımına uğradı.", "Updater.Status.Timeout": "Güncelleme denetimi 10 saniye sonra zaman aşımına uğradı.",
"Updater.Status.Failed": "Güncellemeler denetlenemedi.", "Updater.Status.Failed": "Güncellemeler denetlenemedi.",
"Updater.Status.ChecksumFailed": "İndirilen güncelleme SHA-256 doğrulamasını geçemedi.", "Updater.Status.ChecksumFailed": "İndirilen güncelleme SHA-256 doğrulamasını geçemedi.",
"Updater.Status.Unsupported": "Otomatik güncelleme Windows, Linux veya macOS x64 build'i gerektirir.", "Updater.Status.Unsupported": "Otomatik güncelleme Windows, Linux veya macOS x64 build'i gerektirir."
"Library.Context.GameSettings": "Oyun ayarları…",
"Options.Env.Tab": "Ortam",
"Options.Section.Environment": "ORTAM DEĞİŞKENLERİ",
"Options.Env.Desc": "Başlatma sırasında emülatöre ortam değişkeni olarak geçirilen anahtarlar.",
"Options.Env.Bthid.Desc": "Direksiyon/FFB katmanı sonsuza kadar bekleyen oyunlar için Bluetooth HID'i kullanılamıyor olarak bildir.\nNormalde kapalı bırakın. Bazı oyunlar başlatma başarısız olduğunda donar.",
"Options.Env.LoopGuard.Desc": "Aynı çağrıyı uzun süre tekrarlayan oyunları zorla kapatma.\nBir oyun yükleme sırasında kendiliğinden kapanıyorsa bunu deneyin.",
"Options.Env.WritableApp0.Desc": "Oyunların kurulum klasörlerinde dosya oluşturup yazmasına izin ver.\nKayıt veya yapılandırma verisini /app0 altına yazan paketlenmemiş dump'lar için gereklidir.",
"Options.Env.VkValidation.Desc": "GPU hata ayıklaması için Vulkan doğrulama katmanlarını etkinleştir.\nYavaştır. Vulkan SDK'nın kurulu olması gerekir.",
"Options.Env.DumpSpirv.Desc": "AGC shader'larını ve SPIR-V çevirilerini shader-dumps klasörüne kaydet.\nShader veya görüntü hatalarını bildirirken kullanın.",
"Options.Env.LogDirectMemory.Desc": "Doğrudan bellek tahsislerini ve hatalarını konsola günlükle.\nBir oyun açılış sırasında çöküyor veya kapanıyorsa kullanın.",
"Options.Env.LogIo.Desc": "Dosya açma, okuma ve yol çözümleme etkinliğini konsola günlükle.\nBir oyun açılışta veri dosyalarını bulamıyorsa kullanın.",
"Options.Env.LogNp.Desc": "NP (PlayStation Network) kütüphane çağrılarını konsola günlükle.",
"Common.Save": "Kaydet",
"Common.Cancel": "İptal",
"PerGame.Title": "Oyuna özel ayarlar — {0} ({1})",
"PerGame.InheritNote": "İşaretlenmemiş satırlar genel varsayılanları kullanır.",
"PerGame.EnvToggles.Label": "Ortam anahtarları",
"PerGame.EnvToggles.Desc": "Bu oyun için genel SHARPEMU_* anahtar kümesini geçersiz kıl.",
"Options.About": "Hakkında",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Kaynak kodu, hata kayıtları ve proje geliştirme.",
"About.Github.LatestCommitLabel": "Son Commit",
"About.Github.LatestCommitDescription": "main dalındaki son commit",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Topluluğa katılın, destek alın ve geliştirmeyi takip edin.",
"About.GithubButton": "GitHub'da katkıda bulun!",
"About.DiscordButton": "Discord'umuza katıl!"
} }
+2 -5
View File
@@ -91,7 +91,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</StackPanel> </StackPanel>
</Grid> </Grid>
<Panel Grid.Row="1" x:Name="PagesHost"> <Panel Grid.Row="1">
<!-- Library page. The tile row gets extra top margin so it sits <!-- Library page. The tile row gets extra top margin so it sits
closer to eye level (PS5 home-screen style) instead of hugging closer to eye level (PS5 home-screen style) instead of hugging
@@ -604,12 +604,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
<!-- This is a native popup rather than an Avalonia overlay because the <!-- This is a native popup rather than an Avalonia overlay because the
emulated Vulkan surface is a native child window. --> emulated Vulkan surface is a native child window. -->
<!-- Anchored to MainContent, not GameView: the surface host is parked in
a 1x1 corner while loading/closing, which would pull a GameView-
anchored popup into the corner with it. -->
<primitives:Popup x:Name="SessionLoadingPopup" <primitives:Popup x:Name="SessionLoadingPopup"
IsOpen="False" IsOpen="False"
PlacementTarget="{Binding #MainContent}" PlacementTarget="{Binding #GameView}"
Placement="Center" Placement="Center"
Topmost="True" Topmost="True"
ShouldUseOverlayLayer="False" ShouldUseOverlayLayer="False"
+21 -108
View File
@@ -88,15 +88,6 @@ public partial class MainWindow : Window
private int _detailLoadGeneration; private int _detailLoadGeneration;
private int _backdropGeneration; private int _backdropGeneration;
// Bundled key art shown whenever no game-specific backdrop applies; the
// plain window color remains the fallback when the asset fails to load.
private Bitmap? _defaultBackdrop;
// Whether the native loading/closing popup should be showing; it is a
// desktop-topmost popup, so it closes while the launcher is in the
// background or minimized and reopens from this flag on activation.
private bool _sessionLoadingActive;
// Controller navigation state. // Controller navigation state.
private readonly DispatcherTimer _gamepadTimer; private readonly DispatcherTimer _gamepadTimer;
private HostGamepadButtons _previousPadButtons; private HostGamepadButtons _previousPadButtons;
@@ -120,18 +111,6 @@ public partial class MainWindow : Window
{ {
InitializeComponent(); InitializeComponent();
try
{
_defaultBackdrop = new Bitmap(
AssetLoader.Open(new Uri("avares://SharpEmu.GUI/Assets/pic0.png")));
BackdropImage.Source = _defaultBackdrop;
BackdropImage.Opacity = 1.0;
}
catch (Exception)
{
_defaultBackdrop = null; // color background remains the fallback
}
GameList.ItemsSource = _visibleGames; GameList.ItemsSource = _visibleGames;
ConsoleList.ItemsSource = _consoleLines; ConsoleList.ItemsSource = _consoleLines;
_consoleMirror = GuiConsoleMirror.Install((line, isError) => _consoleMirror = GuiConsoleMirror.Install((line, isError) =>
@@ -155,18 +134,8 @@ public partial class MainWindow : Window
}; };
_libraryBlurTimer.Tick += (_, _) => AdvanceLibraryBlur(); _libraryBlurTimer.Tick += (_, _) => AdvanceLibraryBlur();
// Native popups float above every window on the desktop; they must Activated += (_, _) => UpdateSessionBarVisibility();
// follow the launcher into the background or a minimized state. Deactivated += (_, _) => SessionBarPopup.IsOpen = false;
Activated += (_, _) =>
{
UpdateSessionBarVisibility();
SessionLoadingPopup.IsOpen = _sessionLoadingActive;
};
Deactivated += (_, _) =>
{
SessionBarPopup.IsOpen = false;
SessionLoadingPopup.IsOpen = false;
};
TitleBar.PointerPressed += OnTitleBarPointerPressed; TitleBar.PointerPressed += OnTitleBarPointerPressed;
GameList.SelectionChanged += (_, _) => UpdateSelectedGame(); GameList.SelectionChanged += (_, _) => UpdateSelectedGame();
@@ -429,15 +398,6 @@ public partial class MainWindow : Window
return; 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; var shoulderPressed = pad.Buttons & ~_previousPadButtons;
if ((shoulderPressed & HostGamepadButtons.L1) != 0) if ((shoulderPressed & HostGamepadButtons.L1) != 0)
{ {
@@ -487,6 +447,11 @@ public partial class MainWindow : Window
LaunchSelected(); LaunchSelected();
} }
if ((pressed & HostGamepadButtons.Circle) != 0)
{
StopEmulator();
}
_previousPadButtons = pad.Buttons; _previousPadButtons = pad.Buttons;
} }
@@ -1645,23 +1610,13 @@ public partial class MainWindow : Window
base.OnPropertyChanged(change); base.OnPropertyChanged(change);
if (change.Property == WindowStateProperty) if (change.Property == WindowStateProperty)
{ {
// The XAML WindowState="Maximized" assignment raises this change
// during InitializeComponent, before named controls are wired up.
if (WindowState == WindowState.Minimized) if (WindowState == WindowState.Minimized)
{ {
_sndPreview.Pause(); _sndPreview.Pause();
if (SessionLoadingPopup is { } popup)
{
popup.IsOpen = false;
}
} }
else else
{ {
_sndPreview.Resume(); _sndPreview.Resume();
if (SessionLoadingPopup is { } popup)
{
popup.IsOpen = _sessionLoadingActive;
}
} }
} }
} }
@@ -1676,20 +1631,8 @@ public partial class MainWindow : Window
var generation = ++_backdropGeneration; var generation = ++_backdropGeneration;
BackdropImage.Opacity = 0; BackdropImage.Opacity = 0;
// The bundled key art is the primary backdrop whenever the selection
// has no art of its own; the window color stays as the last fallback.
void ShowDefaultBackdrop()
{
if (generation == _backdropGeneration && _defaultBackdrop is not null)
{
BackdropImage.Source = _defaultBackdrop;
BackdropImage.Opacity = 1.0;
}
}
if (game?.BackgroundPath is null) if (game?.BackgroundPath is null)
{ {
ShowDefaultBackdrop();
return; return;
} }
@@ -1706,8 +1649,7 @@ public partial class MainWindow : Window
} }
catch (Exception) catch (Exception)
{ {
ShowDefaultBackdrop(); // undecodable key art return; // undecodable key art: keep the plain background
return;
} }
} }
@@ -2027,7 +1969,6 @@ public partial class MainWindow : Window
_awaitingFirstFrame = false; _awaitingFirstFrame = false;
ClearLibraryBlur(); ClearLibraryBlur();
MainContent.Margin = new Thickness(0); MainContent.Margin = new Thickness(0);
RestoreGameViewToFull();
GameView.Background = Brushes.Black; GameView.Background = Brushes.Black;
GameView.IsHitTestVisible = true; GameView.IsHitTestVisible = true;
_gameSurfaceHost?.SetPresentationVisible(true); _gameSurfaceHost?.SetPresentationVisible(true);
@@ -2038,7 +1979,7 @@ public partial class MainWindow : Window
ContentToolbar.IsVisible = false; ContentToolbar.IsVisible = false;
ConsolePanel.IsVisible = false; ConsolePanel.IsVisible = false;
LaunchBar.IsVisible = false; LaunchBar.IsVisible = false;
HideSessionLoading(); SessionLoadingPopup.IsOpen = false;
UpdateSessionBarVisibility(); UpdateSessionBarVisibility();
} }
}); });
@@ -2089,31 +2030,11 @@ public partial class MainWindow : Window
} }
} }
/// <summary>
/// The native host attachment is a real child window: it sits above every
/// Avalonia control it covers and swallows their mouse input regardless of
/// hit-test settings. While the library must stay interactive (loading,
/// closing), the surface is parked offscreen AT FULL SIZE via a negative
/// margin. It must not be shrunk instead: the emulator child polls the
/// HWND client size and its presenter defers swapchain creation while the
/// surface is 1px, which would deadlock the loading handshake.
/// </summary>
private void ParkGameViewOffscreen()
{
GameView.Margin = new Thickness(-20000, 0, 20000, 0);
}
private void RestoreGameViewToFull()
{
GameView.Margin = new Thickness(0);
}
private void ShowGameView() private void ShowGameView()
{ {
_isStopping = false; _isStopping = false;
_awaitingFirstFrame = true; _awaitingFirstFrame = true;
var host = EnsureGameSurfaceHost(); var host = EnsureGameSurfaceHost();
ParkGameViewOffscreen();
GameView.IsVisible = true; GameView.IsVisible = true;
GameView.Background = Brushes.Transparent; GameView.Background = Brushes.Transparent;
GameView.IsHitTestVisible = false; GameView.IsHitTestVisible = false;
@@ -2138,7 +2059,7 @@ public partial class MainWindow : Window
GameView.IsVisible = false; GameView.IsVisible = false;
GameView.IsHitTestVisible = true; GameView.IsHitTestVisible = true;
SessionBarPopup.IsOpen = false; SessionBarPopup.IsOpen = false;
HideSessionLoading(); SessionLoadingPopup.IsOpen = false;
AnimateLibraryBlur(0, clearWhenComplete: true); AnimateLibraryBlur(0, clearWhenComplete: true);
MainContent.Margin = new Thickness(32, 24, 32, 20); MainContent.Margin = new Thickness(32, 24, 32, 20);
ContentToolbar.IsVisible = true; ContentToolbar.IsVisible = true;
@@ -2147,15 +2068,16 @@ public partial class MainWindow : Window
LibraryPage.IsVisible = _activePageIndex == 0; LibraryPage.IsVisible = _activePageIndex == 0;
LibraryToolbar.IsVisible = _activePageIndex == 0; LibraryToolbar.IsVisible = _activePageIndex == 0;
OptionsPage.IsVisible = _activePageIndex == 1; OptionsPage.IsVisible = _activePageIndex == 1;
// Game art when the source still holds it, otherwise the bundled if (GameList.SelectedItem is GameEntry game && game.Background is not null)
// default; a bare color only when neither is available. {
BackdropImage.Opacity = BackdropImage.Source is not null ? 1 : 0; BackdropImage.Opacity = 1;
}
} }
private void AnimateLibraryBlur(double targetRadius, bool clearWhenComplete = false) private void AnimateLibraryBlur(double targetRadius, bool clearWhenComplete = false)
{ {
_libraryBlur ??= new BlurEffect(); _libraryBlur ??= new BlurEffect();
PagesHost.Effect = _libraryBlur; MainContent.Effect = _libraryBlur;
_libraryBlurStartRadius = _libraryBlur.Radius; _libraryBlurStartRadius = _libraryBlur.Radius;
_libraryBlurTargetRadius = Math.Max(0, targetRadius); _libraryBlurTargetRadius = Math.Max(0, targetRadius);
@@ -2204,7 +2126,7 @@ public partial class MainWindow : Window
if (_clearLibraryBlurWhenComplete) if (_clearLibraryBlurWhenComplete)
{ {
PagesHost.Effect = null; MainContent.Effect = null;
_libraryBlur = null; _libraryBlur = null;
_clearLibraryBlurWhenComplete = false; _clearLibraryBlurWhenComplete = false;
} }
@@ -2215,21 +2137,14 @@ public partial class MainWindow : Window
_libraryBlurTimer.Stop(); _libraryBlurTimer.Stop();
_libraryBlur = null; _libraryBlur = null;
_clearLibraryBlurWhenComplete = false; _clearLibraryBlurWhenComplete = false;
PagesHost.Effect = null; MainContent.Effect = null;
} }
private void ShowSessionLoading(string title, string detail) private void ShowSessionLoading(string title, string detail)
{ {
SessionLoadingTitle.Text = title; SessionLoadingTitle.Text = title;
SessionLoadingDetail.Text = detail; SessionLoadingDetail.Text = detail;
_sessionLoadingActive = true; SessionLoadingPopup.IsOpen = true;
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
}
private void HideSessionLoading()
{
_sessionLoadingActive = false;
SessionLoadingPopup.IsOpen = false;
} }
private void ReturnToLibraryWhileStopping() private void ReturnToLibraryWhileStopping()
@@ -2241,12 +2156,10 @@ public partial class MainWindow : Window
// Keep the native child alive until the session exits, but hide it // Keep the native child alive until the session exits, but hide it
// immediately. Destroying it while Vulkan still owns the surface can // immediately. Destroying it while Vulkan still owns the surface can
// crash the GUI; parking it in the 1x1 corner lets the library // crash the GUI; leaving it transparent lets the library recover
// recover — and stay clickable — while the native closing popup // while the native closing popup reports teardown progress.
// reports teardown progress.
_gameSurfaceHost?.SetPresentationVisible(false); _gameSurfaceHost?.SetPresentationVisible(false);
_awaitingFirstFrame = false; _awaitingFirstFrame = false;
ParkGameViewOffscreen();
GameView.Background = Brushes.Transparent; GameView.Background = Brushes.Transparent;
GameView.IsHitTestVisible = false; GameView.IsHitTestVisible = false;
SessionBarPopup.IsOpen = false; SessionBarPopup.IsOpen = false;
@@ -2258,7 +2171,7 @@ public partial class MainWindow : Window
LibraryPage.IsVisible = _activePageIndex == 0; LibraryPage.IsVisible = _activePageIndex == 0;
LibraryToolbar.IsVisible = _activePageIndex == 0; LibraryToolbar.IsVisible = _activePageIndex == 0;
OptionsPage.IsVisible = _activePageIndex == 1; OptionsPage.IsVisible = _activePageIndex == 1;
BackdropImage.Opacity = BackdropImage.Source is not null ? 1 : 0; BackdropImage.Opacity = GameList.SelectedItem is GameEntry { Background: not null } ? 1 : 0;
UpdateRunButtons(); UpdateRunButtons();
Console.Error.WriteLine("[GUI][INFO] Library restored while embedded session is closing."); Console.Error.WriteLine("[GUI][INFO] Library restored while embedded session is closing.");
} }
-1
View File
@@ -38,7 +38,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<AvaloniaResource Include="..\..\assets\images\discord.png" Link="Assets/discord.png" /> <AvaloniaResource Include="..\..\assets\images\discord.png" Link="Assets/discord.png" />
<AvaloniaResource Include="..\..\assets\images\update-icon.png" Link="Assets/update-icon.png" /> <AvaloniaResource Include="..\..\assets\images\update-icon.png" Link="Assets/update-icon.png" />
<AvaloniaResource Include="..\..\assets\images\commit-icon.png" Link="Assets/commit-icon.png" /> <AvaloniaResource Include="..\..\assets\images\commit-icon.png" Link="Assets/commit-icon.png" />
<AvaloniaResource Include="..\..\assets\images\pic0.png" Link="Assets/pic0.png" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+5 -40
View File
@@ -51,33 +51,9 @@ public static unsafe class GuestImageWriteTracker
private static readonly object _gate = new(); private static readonly object _gate = new();
private static readonly Dictionary<ulong, TrackedRange> _rangesByAddress = new(); private static readonly Dictionary<ulong, TrackedRange> _rangesByAddress = new();
/// <summary>Immutable snapshot read lock-free from the signal handler and // Snapshot array read lock-free from the signal handler; rebuilt on every
/// the managed-write pre-visit; rebuilt on every mutation under the gate // mutation under the gate. Signal handlers must not take managed locks.
/// (signal handlers must not take managed locks). Carrying the overall private static TrackedRange[] _rangeSnapshot = [];
/// bounds inside the same object keeps the hot-path intersection test
/// consistent with the array it guards.</summary>
private sealed class RangeSnapshot
{
public static readonly RangeSnapshot Empty = new([]);
public readonly TrackedRange[] Ranges;
public readonly ulong Start;
public readonly ulong End;
public RangeSnapshot(TrackedRange[] ranges)
{
Ranges = ranges;
Start = ulong.MaxValue;
End = 0;
foreach (var range in ranges)
{
Start = Math.Min(Start, range.Start);
End = Math.Max(End, range.End);
}
}
}
private static RangeSnapshot _rangeSnapshot = RangeSnapshot.Empty;
private static readonly bool _enabled = !OperatingSystem.IsWindows() && private static readonly bool _enabled = !OperatingSystem.IsWindows() &&
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0"; Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
@@ -290,17 +266,6 @@ public static unsafe class GuestImageWriteTracker
var end = address > ulong.MaxValue - byteCount var end = address > ulong.MaxValue - byteCount
? ulong.MaxValue ? ulong.MaxValue
: address + byteCount; : 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; var candidate = address;
while (candidate < end) while (candidate < end)
{ {
@@ -346,7 +311,7 @@ public static unsafe class GuestImageWriteTracker
return false; return false;
} }
var ranges = Volatile.Read(ref _rangeSnapshot).Ranges; var ranges = Volatile.Read(ref _rangeSnapshot);
var writableStart = ulong.MaxValue; var writableStart = ulong.MaxValue;
var writableEnd = 0UL; var writableEnd = 0UL;
for (var index = 0; index < ranges.Length; index++) for (var index = 0; index < ranges.Length; index++)
@@ -493,7 +458,7 @@ public static unsafe class GuestImageWriteTracker
private static void RebuildSnapshotLocked() 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) private static (ulong Start, ulong Length) PageAlign(ulong address, ulong byteCount)
+109 -332
View File
@@ -14,12 +14,6 @@ namespace SharpEmu.Libs.Agc;
public static partial class AgcExports public static partial class AgcExports
{ {
// The backend is a process-fixed singleton, so its offset-alignment
// requirement is snapshot once: several per-draw paths (shader-key
// hashing, buffer-offset alignment) read it in loops.
private static readonly ulong _storageBufferOffsetAlignment =
GuestGpu.Current.GuestStorageBufferOffsetAlignment;
#if DEBUG #if DEBUG
static AgcExports() static AgcExports()
{ {
@@ -568,8 +562,6 @@ public static partial class AgcExports
public ulong WorkSequence { get; set; } public ulong WorkSequence { get; set; }
public ulong SubmissionSequence { get; set; } public ulong SubmissionSequence { get; set; }
public bool WaitMonitorRunning { get; set; } public bool WaitMonitorRunning { get; set; }
public object WaitMonitorSignalGate { get; } = new();
public long WaitMonitorSignalVersion { get; set; }
} }
private readonly record struct RegisteredAgcResource( private readonly record struct RegisteredAgcResource(
@@ -2674,7 +2666,7 @@ public static partial class AgcExports
TraceAgc($"agc.driver_submit_dcb packet=0x{packetAddress:X16} addr=0x{commandAddress:X16} dwords={dwordCount}"); TraceAgc($"agc.driver_submit_dcb packet=0x{packetAddress:X16} addr=0x{commandAddress:X16} dwords={dwordCount}");
} }
GuestGpu.Current.AttachGuestMemory(ctx.Memory); VulkanVideoPresenter.AttachGuestMemory(ctx.Memory);
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState()); var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
lock (gpuState.Gate) lock (gpuState.Gate)
{ {
@@ -2726,7 +2718,7 @@ public static partial class AgcExports
$"addr=0x{commandAddress:X16} dwords={dwordCount}"); $"addr=0x{commandAddress:X16} dwords={dwordCount}");
} }
GuestGpu.Current.AttachGuestMemory(ctx.Memory); VulkanVideoPresenter.AttachGuestMemory(ctx.Memory);
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState()); var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
lock (gpuState.Gate) lock (gpuState.Gate)
{ {
@@ -2954,7 +2946,7 @@ public static partial class AgcExports
// guest-memory writes have finished. Put the notification on that same // guest-memory writes have finished. Put the notification on that same
// logical graphics queue instead of approximating completion with a // logical graphics queue instead of approximating completion with a
// timer, which can wake Unity while its upload data is still stale. // timer, which can wake Unity while its upload data is still stale.
if (GuestGpu.Current.SubmitOrderedGuestAction( if (VulkanVideoPresenter.SubmitOrderedGuestAction(
TriggerCompletionEvents, TriggerCompletionEvents,
$"agc submit completion {submissionId}") == 0) $"agc submit completion {submissionId}") == 0)
{ {
@@ -2978,11 +2970,11 @@ public static partial class AgcExports
return false; return false;
} }
using var guestQueueScope = GuestGpu.Current.EnterGuestQueue( using var guestQueueScope = VulkanVideoPresenter.EnterGuestQueue(
state.QueueName, state.QueueName,
state.ActiveSubmissionId); state.ActiveSubmissionId);
var windowByteCount = checked((int)(dwordCount * sizeof(uint))); var windowByteCount = checked((int)(dwordCount * sizeof(uint)));
var rented = GuestDataPool.Shared.Rent(windowByteCount); var rented = VulkanVideoPresenter.GuestDataPool.Rent(windowByteCount);
try try
{ {
if (ctx.Memory.TryRead(commandAddress, rented.AsSpan(0, windowByteCount))) if (ctx.Memory.TryRead(commandAddress, rented.AsSpan(0, windowByteCount)))
@@ -3004,7 +2996,7 @@ public static partial class AgcExports
{ {
_dcbWindowBuffer = null; _dcbWindowBuffer = null;
_dcbWindowByteLength = 0; _dcbWindowByteLength = 0;
GuestDataPool.Shared.Return(rented); VulkanVideoPresenter.GuestDataPool.Return(rented);
} }
} }
@@ -3306,20 +3298,17 @@ public static partial class AgcExports
indexed: false); indexed: false);
} }
if (op is ItDispatchDirect or ItDispatchIndirect) if ((op is ItDispatchDirect or ItDispatchIndirect) &&
TryReadComputeDispatch(
ctx,
state,
currentAddress,
length,
op,
out var dispatch))
{ {
if (TryReadComputeDispatch( state.FrameDispatchCount++;
ctx, ObserveComputeDispatch(ctx, gpuState, state, dispatch);
state,
currentAddress,
length,
op,
out var dispatch,
out _))
{
state.FrameDispatchCount++;
ObserveComputeDispatch(ctx, gpuState, state, dispatch);
}
} }
if (op == ItNop && if (op == ItNop &&
@@ -3328,7 +3317,7 @@ public static partial class AgcExports
TryReadUInt32(ctx, currentAddress + 4, out var waitVideoOutHandle) && TryReadUInt32(ctx, currentAddress + 4, out var waitVideoOutHandle) &&
TryReadUInt32(ctx, currentAddress + 8, out var waitDisplayBufferIndex)) TryReadUInt32(ctx, currentAddress + 8, out var waitDisplayBufferIndex))
{ {
var waitSequence = GuestGpu.Current.SubmitOrderedGuestFlipWait( var waitSequence = VulkanVideoPresenter.SubmitOrderedGuestFlipWait(
unchecked((int)waitVideoOutHandle), unchecked((int)waitVideoOutHandle),
unchecked((int)waitDisplayBufferIndex)); unchecked((int)waitDisplayBufferIndex));
TraceAgcShader( TraceAgcShader(
@@ -3657,11 +3646,27 @@ public static partial class AgcExports
void CompleteAndWake() void CompleteAndWake()
{ {
CompleteLabelProducer(producer); CompleteLabelProducer(producer);
lock (gpuState.WaitMonitorSignalGate) if (GpuWaitRegistry.Count == 0)
{ {
gpuState.WaitMonitorSignalVersion++; return;
Monitor.Pulse(gpuState.WaitMonitorSignalGate);
} }
// Resuming a DCB can enqueue another compute dispatch and wait for
// it. Never do that reentrantly on the Vulkan render thread.
ThreadPool.UnsafeQueueUserWorkItem(
static state =>
{
var (resumeContext, resumeGpuState) = state;
lock (resumeGpuState.Gate)
{
DrainResumableDcbs(
resumeContext,
resumeGpuState,
tracePackets: _traceAgc);
}
},
(ctx, gpuState),
preferLocal: false);
} }
void ApplyAndQueueCompletion() void ApplyAndQueueCompletion()
@@ -3672,7 +3677,7 @@ public static partial class AgcExports
// wake another queue before that mirror is visible. Queue a // wake another queue before that mirror is visible. Queue a
// second same-queue ordered action after all immediate follow-up // second same-queue ordered action after all immediate follow-up
// writes; it fences those writes before publishing the producer. // writes; it fences those writes before publishing the producer.
if (GuestGpu.Current.SubmitOrderedGuestAction( if (VulkanVideoPresenter.SubmitOrderedGuestAction(
CompleteAndWake, CompleteAndWake,
$"{debugName} completion") == 0) $"{debugName} completion") == 0)
{ {
@@ -3680,7 +3685,7 @@ public static partial class AgcExports
} }
} }
if (GuestGpu.Current.SubmitOrderedGuestAction( if (VulkanVideoPresenter.SubmitOrderedGuestAction(
ApplyAndQueueCompletion, ApplyAndQueueCompletion,
debugName) == 0) debugName) == 0)
{ {
@@ -3890,11 +3895,11 @@ public static partial class AgcExports
TraceAgc( TraceAgc(
$"agc.acquire_mem_applied queue={queueName} " + $"agc.acquire_mem_applied queue={queueName} " +
$"submission={submissionId} packet=0x{packetAddress:X16} " + $"submission={submissionId} packet=0x{packetAddress:X16} " +
$"work_sequence={GuestGpu.Current.CurrentGuestWorkSequenceForDiagnostics}"); $"work_sequence={VulkanVideoPresenter.CurrentGuestWorkSequenceForDiagnostics}");
} }
} }
var sequence = GuestGpu.Current.SubmitOrderedGuestAction( var sequence = VulkanVideoPresenter.SubmitOrderedGuestAction(
ApplyAcquire, ApplyAcquire,
debugName); debugName);
if (sequence == 0) if (sequence == 0)
@@ -4040,7 +4045,7 @@ public static partial class AgcExports
return; return;
} }
foreach (var (address, width, height, byteCount) in GuestGpu.Current.GetGuestImageExtents()) foreach (var (address, width, height, byteCount) in VulkanVideoPresenter.GetGuestImageExtents())
{ {
if (scopeByteCount != ulong.MaxValue && if (scopeByteCount != ulong.MaxValue &&
!RangesOverlap(address, byteCount, scopeAddress, scopeByteCount)) !RangesOverlap(address, byteCount, scopeAddress, scopeByteCount))
@@ -4061,7 +4066,7 @@ public static partial class AgcExports
var pixels = new byte[byteCount]; var pixels = new byte[byteCount];
if (ctx.Memory.TryRead(address, pixels)) if (ctx.Memory.TryRead(address, pixels))
{ {
GuestGpu.Current.SubmitGuestImageWrite(address, pixels); VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels);
if (Interlocked.Increment(ref _guestImageSyncTraceCount) <= 64) if (Interlocked.Increment(ref _guestImageSyncTraceCount) <= 64)
{ {
Console.Error.WriteLine( Console.Error.WriteLine(
@@ -4104,7 +4109,7 @@ public static partial class AgcExports
ulong byteCount, ulong byteCount,
uint? fillValue) uint? fillValue)
{ {
var hasImage = GuestGpu.Current.TryGetGuestImageExtent( var hasImage = VulkanVideoPresenter.TryGetGuestImageExtent(
destinationAddress, destinationAddress,
out var width, out var width,
out var height, out var height,
@@ -4128,14 +4133,14 @@ public static partial class AgcExports
if (fillValue is { } fill) if (fillValue is { } fill)
{ {
GuestGpu.Current.SubmitGuestImageFill(destinationAddress, fill); VulkanVideoPresenter.SubmitGuestImageFill(destinationAddress, fill);
return; return;
} }
var pixels = new byte[imageBytes]; var pixels = new byte[imageBytes];
if (ctx.Memory.TryRead(destinationAddress, pixels)) if (ctx.Memory.TryRead(destinationAddress, pixels))
{ {
GuestGpu.Current.SubmitGuestImageWrite(destinationAddress, pixels); VulkanVideoPresenter.SubmitGuestImageWrite(destinationAddress, pixels);
} }
} }
@@ -4523,17 +4528,6 @@ public static partial class AgcExports
? fallbackMs ? fallbackMs
: 0L) * System.Diagnostics.Stopwatch.Frequency / 1000L; : 0L) * System.Diagnostics.Stopwatch.Frequency / 1000L;
// How long a suspended GPU wait may sit before the deadlock breaker may
// release it using the last value a real producer wrote to its label. Long
// enough that legitimate GPU work (which completes within a frame) never
// trips it; short enough that a wedged cross-queue cycle unblocks quickly.
private static readonly long _gpuDeadlockBreakTicks =
(long.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_GPU_DEADLOCK_BREAK_MS"),
out var deadlockMs) && deadlockMs > 0
? deadlockMs
: 500L) * System.Diagnostics.Stopwatch.Frequency / 1000L;
// Reads the WAIT_REG_MEM watched address, reference, mask, and 3-bit compare // Reads the WAIT_REG_MEM watched address, reference, mask, and 3-bit compare
// function for both the AGC NOP-encapsulated (RWaitMem32/64) and the standard // function for both the AGC NOP-encapsulated (RWaitMem32/64) and the standard
// ItWaitRegMem packet layouts. // ItWaitRegMem packet layouts.
@@ -4598,85 +4592,6 @@ public static partial class AgcExports
// Returns true when the DCB should suspend parsing at this wait (its // Returns true when the DCB should suspend parsing at this wait (its
// continuation was registered into GpuWaitRegistry); false to keep parsing // continuation was registered into GpuWaitRegistry); false to keep parsing
// (already satisfied, unreadable, or legacy force-satisfy mode). // (already satisfied, unreadable, or legacy force-satisfy mode).
// How long an indirect dispatch may wait for its producing dispatch to write
// non-zero dimensions before we give up and drop it (matching the pre-existing
// reject behavior). The producer runs on the render thread within a frame or
// two; this only bounds the pathological/legitimately-empty case.
private const long IndirectDimsRetryBudgetMs = 150;
private static readonly object _indirectDimsGate = new();
// Keys (memory, packetAddress) whose retry deadline elapsed. Added by
// DrainResumableDcbs when it resumes an expired retry, consumed by the very
// next re-parse of that packet so it drops instead of re-suspending. Never
// persists across frames — a fresh submit of the same packet retries anew.
private static readonly HashSet<(object, ulong)> _indirectDimsExpired = new();
// Suspends an indirect-dispatch DCB until the guest buffer holding its
// thread-group dimensions becomes non-zero (written by a prior GPU dispatch),
// then re-parses the dispatch. Returns false — so the caller drops the work —
// when the dims already expired once (genuinely empty dispatch).
private static bool HandleSubmittedIndirectDimsWait(
CpuContext ctx,
SubmittedDcbState state,
ulong commandAddress,
ulong packetAddress,
uint offset,
uint dwordCount,
ulong dimsAddress,
bool tracePacket)
{
if (!_gpuWaitSuspendEnabled ||
dimsAddress == 0 ||
dimsAddress % sizeof(uint) != 0)
{
return false;
}
var key = (ctx.Memory, packetAddress);
lock (_indirectDimsGate)
{
// This is the re-parse right after the deadline elapsed: drop the
// dispatch instead of suspending again.
if (_indirectDimsExpired.Remove(key))
{
return false;
}
}
var waiter = new GpuWaitRegistry.WaitingDcb
{
CommandBufferAddress = commandAddress,
ResumeAddress = packetAddress, // re-parse this dispatch packet
ResumeOffset = offset,
TotalDwords = dwordCount,
WaitAddress = dimsAddress,
ReferenceValue = 0,
Mask = 0xFFFFFFFF,
CompareFunction = 4, // NOT_EQUAL: dims became available
Is64Bit = false,
IsStandard = false,
Memory = ctx.Memory,
QueueName = state.QueueName,
SubmissionId = state.ActiveSubmissionId,
RegisteredTicks = System.Diagnostics.Stopwatch.GetTimestamp(),
RetryDeadlineTicks = System.Diagnostics.Stopwatch.GetTimestamp() +
(IndirectDimsRetryBudgetMs * System.Diagnostics.Stopwatch.Frequency / 1000L),
State = state,
};
GpuWaitRegistry.Register(dimsAddress, waiter);
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
EnsureGpuWaitMonitor(ctx, gpuState);
if (tracePacket)
{
TraceAgc(
$"agc.dispatch_indirect_wait dims=0x{dimsAddress:X16} " +
$"packet=0x{packetAddress:X16} queue={state.QueueName}");
}
return true;
}
private static bool HandleSubmittedWaitRegMem( private static bool HandleSubmittedWaitRegMem(
CpuContext ctx, CpuContext ctx,
SubmittedDcbState state, SubmittedDcbState state,
@@ -4840,45 +4755,38 @@ public static partial class AgcExports
SubmittedGpuState gpuState) SubmittedGpuState gpuState)
{ {
var delayMilliseconds = 1; var delayMilliseconds = 1;
long observedSignal;
lock (gpuState.WaitMonitorSignalGate)
{
observedSignal = gpuState.WaitMonitorSignalVersion;
}
while (true) while (true)
{ {
int resumed; var madeProgress = false;
int remaining;
lock (gpuState.Gate) lock (gpuState.Gate)
{ {
resumed = DrainResumableDcbs(ctx, gpuState, tracePackets: _traceAgc); var before = GpuWaitRegistry.CountForMemory(ctx.Memory);
remaining = GpuWaitRegistry.CountForMemory(ctx.Memory); if (before == 0)
if (_traceAgc && resumed != 0) {
gpuState.WaitMonitorRunning = false;
return;
}
DrainResumableDcbs(ctx, gpuState, tracePackets: _traceAgc);
var after = GpuWaitRegistry.CountForMemory(ctx.Memory);
madeProgress = after < before;
if (madeProgress)
{ {
Console.Error.WriteLine( Console.Error.WriteLine(
$"[LOADER][TRACE] agc.wait_monitor_resumed count={resumed} " + $"[LOADER][TRACE] agc.wait_monitor_resumed count={before - after} " +
$"remaining={remaining}"); $"remaining={after}");
} }
if (remaining == 0) if (after == 0)
{ {
gpuState.WaitMonitorRunning = false; gpuState.WaitMonitorRunning = false;
return; return;
} }
} }
delayMilliseconds = resumed != 0 delayMilliseconds = madeProgress
? 1 ? 1
: Math.Min(delayMilliseconds * 2, 16); : Math.Min(delayMilliseconds * 2, 16);
lock (gpuState.WaitMonitorSignalGate) Thread.Sleep(delayMilliseconds);
{
if (gpuState.WaitMonitorSignalVersion == observedSignal)
{
Monitor.Wait(gpuState.WaitMonitorSignalGate, delayMilliseconds);
}
observedSignal = gpuState.WaitMonitorSignalVersion;
}
} }
} }
@@ -4932,17 +4840,16 @@ public static partial class AgcExports
// guest memory (labels are advanced by ReleaseMem/WriteData/DmaData packets // guest memory (labels are advanced by ReleaseMem/WriteData/DmaData packets
// or direct CPU writes) and resumes the ones now satisfied. A resumed DCB // or direct CPU writes) and resumes the ones now satisfied. A resumed DCB
// can itself write labels that unblock others, so loop to a fixed point. // can itself write labels that unblock others, so loop to a fixed point.
private static int DrainResumableDcbs( private static void DrainResumableDcbs(
CpuContext ctx, CpuContext ctx,
SubmittedGpuState gpuState, SubmittedGpuState gpuState,
bool tracePackets) bool tracePackets)
{ {
if (!_gpuWaitSuspendEnabled) if (!_gpuWaitSuspendEnabled)
{ {
return 0; return;
} }
var resumedCount = 0;
for (var pass = 0; pass < 256; pass++) for (var pass = 0; pass < 256; pass++)
{ {
var woken = GpuWaitRegistry.CollectSatisfied(ctx.Memory, (address, is64Bit) => var woken = GpuWaitRegistry.CollectSatisfied(ctx.Memory, (address, is64Bit) =>
@@ -4950,50 +4857,7 @@ public static partial class AgcExports
? TryReadUInt64(ctx, address, out var value64) ? value64 : (ulong?)null ? TryReadUInt64(ctx, address, out var value64) ? value64 : (ulong?)null
: TryReadUInt32(ctx, address, out var value32) ? value32 : (ulong?)null); : TryReadUInt32(ctx, address, out var value32) ? value32 : (ulong?)null);
// Indirect-dispatch dimension retries whose deadline elapsed are if (woken is null)
// resumed so they drop instead of stalling. Flag each so its immediate
// re-parse drops the dispatch rather than suspending again.
var expiredRetries = GpuWaitRegistry.CollectExpiredRetries(
ctx.Memory, System.Diagnostics.Stopwatch.GetTimestamp());
if (expiredRetries is not null)
{
lock (_indirectDimsGate)
{
foreach (var retry in expiredRetries)
{
_indirectDimsExpired.Add((ctx.Memory, retry.ResumeAddress));
}
}
foreach (var retry in expiredRetries)
{
ResumeSuspendedDcb(ctx, gpuState, retry, tracePackets);
}
}
// Break cross-queue deadlocks: a waiter stuck past the deadline whose
// label a real producer already signalled (but guest memory has since
// been reset for reuse) is released using that produced value. Only
// fires for genuinely wedged waits, so fast-resolving ones on working
// titles are untouched.
var deadlockBroken = GpuWaitRegistry.CollectDeadlockBroken(
ctx.Memory, System.Diagnostics.Stopwatch.GetTimestamp(), _gpuDeadlockBreakTicks);
if (deadlockBroken is not null)
{
foreach (var waiter in deadlockBroken)
{
if (tracePackets)
{
TraceAgc(
$"agc.deadlock_break label=0x{waiter.WaitAddress:X16} " +
$"queue={waiter.QueueName} submission={waiter.SubmissionId}");
}
ResumeSuspendedDcb(ctx, gpuState, waiter, tracePackets);
}
}
if (woken is null && expiredRetries is null && deadlockBroken is null)
{ {
if (_gpuWaitStaleTicks > 0 && if (_gpuWaitStaleTicks > 0 &&
GpuWaitRegistry.CollectUnreportedStale( GpuWaitRegistry.CollectUnreportedStale(
@@ -5020,20 +4884,14 @@ public static partial class AgcExports
} }
} }
return resumedCount; return;
} }
if (woken is not null) foreach (var waiter in woken)
{ {
foreach (var waiter in woken) ResumeSuspendedDcb(ctx, gpuState, waiter, tracePackets);
{
ResumeSuspendedDcb(ctx, gpuState, waiter, tracePackets);
resumedCount++;
}
} }
} }
return resumedCount;
} }
private static void ResumeSuspendedDcb( private static void ResumeSuspendedDcb(
@@ -5175,15 +5033,6 @@ public static partial class AgcExports
_ => false, _ => false,
}); });
// Record + latch the written value so a same-frame label reset
// cannot lose the wakeup, and so the deadlock breaker can release
// a cross-queue waiter later (see ApplySubmittedReleaseMem).
if (wroteData && dataSelection is 1 or 2)
{
GpuWaitRegistry.RecordProduced(
ctx.Memory, destinationAddress, dataSelection == 1 ? dataLo : data);
}
if (tracePacket) if (tracePacket)
{ {
TraceAgc( TraceAgc(
@@ -5249,16 +5098,6 @@ public static partial class AgcExports
_ => false, _ => false,
}; };
// Latch waiters against the value we just wrote: the guest reuses
// these labels and can reset them to 0 before the wake pass reads
// memory, which otherwise loses the wakeup and stalls at a black
// screen (Astro Bot: graphics queue waiting on a compute EOP label).
if (wroteData && dataSelection is 1 or 2)
{
GpuWaitRegistry.RecordProduced(
ctx.Memory, destinationAddress, dataSelection == 1 ? dataLo : data);
}
if (tracePacket) if (tracePacket)
{ {
TraceAgc( TraceAgc(
@@ -5559,7 +5398,7 @@ public static partial class AgcExports
state.KnownRenderTargets[resolveSource.Address] = resolveSource; state.KnownRenderTargets[resolveSource.Address] = resolveSource;
state.KnownRenderTargets[resolveDestination.Address] = resolveDestination; state.KnownRenderTargets[resolveDestination.Address] = resolveDestination;
ProvideRenderTargetInitialData(ctx, resolveSource); ProvideRenderTargetInitialData(ctx, resolveSource);
if (GuestGpu.Current.TrySubmitGuestImageBlit( if (VulkanVideoPresenter.TrySubmitGuestImageBlit(
resolveSource.Address, resolveSource.Address,
resolveSource.Width, resolveSource.Width,
resolveSource.Height, resolveSource.Height,
@@ -5870,7 +5709,7 @@ public static partial class AgcExports
var cacheKey = ( var cacheKey = (
exportShaderAddress, exportShaderAddress,
exportFingerprint, exportFingerprint,
_storageBufferOffsetAlignment); VulkanVideoPresenter.GuestStorageBufferOffsetAlignment);
_depthOnlyVertexShaderCache.TryGetValue(cacheKey, out var vertexShader); _depthOnlyVertexShaderCache.TryGetValue(cacheKey, out var vertexShader);
if (vertexShader is null) if (vertexShader is null)
@@ -5895,7 +5734,7 @@ public static partial class AgcExports
: guestGlobalBufferCount + 1, : guestGlobalBufferCount + 1,
requiredVertexOutputCount: 0, requiredVertexOutputCount: 0,
storageBufferOffsetAlignment: storageBufferOffsetAlignment:
_storageBufferOffsetAlignment)) VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
{ {
ReturnPooledEvaluationArrays(exportEvaluation); ReturnPooledEvaluationArrays(exportEvaluation);
return false; return false;
@@ -5907,7 +5746,7 @@ public static partial class AgcExports
exportFingerprint, exportFingerprint,
vertexShader!, vertexShader!,
exportState.Program); exportState.Program);
GuestGpu.Current.CountShaderCompilation(); VulkanVideoPresenter.CountSpirvCompilation();
_depthOnlyVertexShaderCache.TryAdd(cacheKey, vertexShader!); _depthOnlyVertexShaderCache.TryAdd(cacheKey, vertexShader!);
} }
@@ -6193,7 +6032,7 @@ public static partial class AgcExports
attributeCount, attributeCount,
psInputEna, psInputEna,
psInputAddr, psInputAddr,
_storageBufferOffsetAlignment); VulkanVideoPresenter.GuestStorageBufferOffsetAlignment);
var guestGlobalBuffers = var guestGlobalBuffers =
pixelEvaluation.GlobalMemoryBindings.Count + pixelEvaluation.GlobalMemoryBindings.Count +
@@ -6229,7 +6068,7 @@ public static partial class AgcExports
pixelInputEnable: psInputEna, pixelInputEnable: psInputEna,
pixelInputAddress: psInputAddr, pixelInputAddress: psInputAddr,
storageBufferOffsetAlignment: storageBufferOffsetAlignment:
_storageBufferOffsetAlignment) || VulkanVideoPresenter.GuestStorageBufferOffsetAlignment) ||
!GuestGpu.Current.TryCompileVertexShader( !GuestGpu.Current.TryCompileVertexShader(
exportState, exportState,
exportEvaluation, exportEvaluation,
@@ -6241,7 +6080,7 @@ public static partial class AgcExports
scalarRegisterBufferIndex: _bakeScalars ? -1 : guestGlobalBuffers + 1, scalarRegisterBufferIndex: _bakeScalars ? -1 : guestGlobalBuffers + 1,
requiredVertexOutputCount: (int)GetInterpolatedAttributeCount(pixelState), requiredVertexOutputCount: (int)GetInterpolatedAttributeCount(pixelState),
storageBufferOffsetAlignment: storageBufferOffsetAlignment:
_storageBufferOffsetAlignment)) VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
{ {
ReturnPooledEvaluationArrays(exportEvaluation); ReturnPooledEvaluationArrays(exportEvaluation);
ReturnPooledEvaluationArrays(pixelEvaluation); ReturnPooledEvaluationArrays(pixelEvaluation);
@@ -6261,7 +6100,7 @@ public static partial class AgcExports
pixelStateFingerprint, pixelStateFingerprint,
compiled.Pixel, compiled.Pixel,
pixelState.Program); pixelState.Program);
GuestGpu.Current.CountShaderCompilation(); VulkanVideoPresenter.CountSpirvCompilation();
_graphicsShaderCache.TryAdd(shaderKey, compiled); _graphicsShaderCache.TryAdd(shaderKey, compiled);
} }
@@ -6539,7 +6378,7 @@ public static partial class AgcExports
var bytesPerIndex = is32Bit ? sizeof(uint) : sizeof(ushort); var bytesPerIndex = is32Bit ? sizeof(uint) : sizeof(ushort);
var byteOffset = checked((ulong)state.DrawIndexOffset * (uint)bytesPerIndex); var byteOffset = checked((ulong)state.DrawIndexOffset * (uint)bytesPerIndex);
var byteCount = checked((int)(indexCount * (uint)bytesPerIndex)); var byteCount = checked((int)(indexCount * (uint)bytesPerIndex));
var data = GuestDataPool.Shared.Rent(byteCount); var data = VulkanVideoPresenter.GuestDataPool.Rent(byteCount);
var span = data.AsSpan(0, byteCount); var span = data.AsSpan(0, byteCount);
var address = state.IndexBufferAddress + byteOffset; var address = state.IndexBufferAddress + byteOffset;
if (ctx.Memory.TryRead(address, span) || if (ctx.Memory.TryRead(address, span) ||
@@ -6548,7 +6387,7 @@ public static partial class AgcExports
return new GuestIndexBuffer(data, byteCount, is32Bit, Pooled: true); return new GuestIndexBuffer(data, byteCount, is32Bit, Pooled: true);
} }
GuestDataPool.Shared.Return(data); VulkanVideoPresenter.GuestDataPool.Return(data);
return null; return null;
} }
@@ -6575,7 +6414,7 @@ public static partial class AgcExports
var byteOffset = checked((ulong)state.DrawIndexOffset * (uint)bytesPerIndex); var byteOffset = checked((ulong)state.DrawIndexOffset * (uint)bytesPerIndex);
var address = state.IndexBufferAddress + byteOffset; var address = state.IndexBufferAddress + byteOffset;
const int chunkBytes = 64 * 1024; const int chunkBytes = 64 * 1024;
var scratch = GuestDataPool.Shared.Rent(chunkBytes); var scratch = VulkanVideoPresenter.GuestDataPool.Rent(chunkBytes);
var remaining = drawCount; var remaining = drawCount;
var maxIndex = 0u; var maxIndex = 0u;
var sawIndex = false; var sawIndex = false;
@@ -6617,7 +6456,7 @@ public static partial class AgcExports
} }
finally finally
{ {
GuestDataPool.Shared.Return(scratch); VulkanVideoPresenter.GuestDataPool.Return(scratch);
} }
var indexedRecords = sawIndex && maxIndex != uint.MaxValue var indexedRecords = sawIndex && maxIndex != uint.MaxValue
@@ -6741,7 +6580,7 @@ public static partial class AgcExports
{ {
hash = (hash ^ ( hash = (hash ^ (
binding.BaseAddress & binding.BaseAddress &
(_storageBufferOffsetAlignment - 1))) * prime; (VulkanVideoPresenter.GuestStorageBufferOffsetAlignment - 1))) * prime;
} }
if (evaluation.ComputeSystemRegisters is { } computeSystemRegisters) if (evaluation.ComputeSystemRegisters is { } computeSystemRegisters)
@@ -6831,8 +6670,7 @@ public static partial class AgcExports
scissor, scissor,
DecodeViewport(registers, target.Width, target.Height, scissor), DecodeViewport(registers, target.Width, target.Height, scissor),
DecodeRasterState(registers), DecodeRasterState(registers),
DecodeDepthState(registers), DecodeDepthState(registers));
DecodeBlendConstant(registers));
} }
private static GuestRenderState CreateRenderState( private static GuestRenderState CreateRenderState(
@@ -6865,8 +6703,7 @@ public static partial class AgcExports
scissor, scissor,
DecodeViewport(registers, target.Width, target.Height, scissor), DecodeViewport(registers, target.Width, target.Height, scissor),
DecodeRasterState(registers), DecodeRasterState(registers),
DecodeDepthState(registers), DecodeDepthState(registers));
DecodeBlendConstant(registers));
} }
// DB_DEPTH_CONTROL (context register 0x200): Z_ENABLE bit1, Z_WRITE_ENABLE // DB_DEPTH_CONTROL (context register 0x200): Z_ENABLE bit1, Z_WRITE_ENABLE
@@ -6971,22 +6808,6 @@ public static partial class AgcExports
return new GuestRasterState(cullFront, cullBack, frontFaceClockwise, wireframe); return new GuestRasterState(cullFront, cullBack, frontFaceClockwise, wireframe);
} }
/// <summary>CB_BLEND_RED..ALPHA carry the constant blend color as raw
/// float bits; unwritten registers read as the reset value (0.0).</summary>
private static GuestBlendConstant DecodeBlendConstant(
IReadOnlyDictionary<uint, uint> registers)
{
registers.TryGetValue(CbBlendRed, out var red);
registers.TryGetValue(CbBlendGreen, out var green);
registers.TryGetValue(CbBlendBlue, out var blue);
registers.TryGetValue(CbBlendAlpha, out var alpha);
return new GuestBlendConstant(
BitConverter.Int32BitsToSingle(unchecked((int)red)),
BitConverter.Int32BitsToSingle(unchecked((int)green)),
BitConverter.Int32BitsToSingle(unchecked((int)blue)),
BitConverter.Int32BitsToSingle(unchecked((int)alpha)));
}
private static GuestBlendState DecodeBlendState( private static GuestBlendState DecodeBlendState(
IReadOnlyDictionary<uint, uint> registers, IReadOnlyDictionary<uint, uint> registers,
uint slot) uint slot)
@@ -7499,7 +7320,7 @@ public static partial class AgcExports
IReadOnlyList<uint> registers, IReadOnlyList<uint> registers,
IReadOnlyList<Gen5GlobalMemoryBinding> bindings) IReadOnlyList<Gen5GlobalMemoryBinding> bindings)
{ {
var bytes = GuestDataPool.Shared.Rent( var bytes = VulkanVideoPresenter.GuestDataPool.Rent(
GetRuntimeScalarBufferLength(bindings.Count)); GetRuntimeScalarBufferLength(bindings.Count));
PackRuntimeScalarStateInto(bytes, registers, bindings); PackRuntimeScalarStateInto(bytes, registers, bindings);
return bytes; return bytes;
@@ -7525,7 +7346,7 @@ public static partial class AgcExports
{ {
var byteBias = checked((uint)( var byteBias = checked((uint)(
bindings[index].BaseAddress & bindings[index].BaseAddress &
(_storageBufferOffsetAlignment - 1))); (VulkanVideoPresenter.GuestStorageBufferOffsetAlignment - 1)));
BinaryPrimitives.WriteUInt32LittleEndian( BinaryPrimitives.WriteUInt32LittleEndian(
bytes.AsSpan(biasOffset + index * sizeof(uint), sizeof(uint)), bytes.AsSpan(biasOffset + index * sizeof(uint), sizeof(uint)),
byteBias); byteBias);
@@ -7568,7 +7389,7 @@ public static partial class AgcExports
{ {
if (binding.DataPooled && returned.Add(binding.Data)) if (binding.DataPooled && returned.Add(binding.Data))
{ {
GuestDataPool.Shared.Return(binding.Data); VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
} }
} }
@@ -7578,7 +7399,7 @@ public static partial class AgcExports
{ {
if (binding.DataPooled && returned.Add(binding.Data)) if (binding.DataPooled && returned.Add(binding.Data))
{ {
GuestDataPool.Shared.Return(binding.Data); VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
} }
} }
} }
@@ -7604,7 +7425,7 @@ public static partial class AgcExports
{ {
if (binding.DataPooled && returned.Add(binding.Data)) if (binding.DataPooled && returned.Add(binding.Data))
{ {
GuestDataPool.Shared.Return(binding.Data); VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
} }
} }
} }
@@ -7615,7 +7436,7 @@ public static partial class AgcExports
{ {
if (binding.DataPooled && returned.Add(binding.Data)) if (binding.DataPooled && returned.Add(binding.Data))
{ {
GuestDataPool.Shared.Return(binding.Data); VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
} }
} }
} }
@@ -7623,7 +7444,7 @@ public static partial class AgcExports
if (index && draw.IndexBuffer is { Pooled: true } indexBuffer && if (index && draw.IndexBuffer is { Pooled: true } indexBuffer &&
returned.Add(indexBuffer.Data)) returned.Add(indexBuffer.Data))
{ {
GuestDataPool.Shared.Return(indexBuffer.Data); VulkanVideoPresenter.GuestDataPool.Return(indexBuffer.Data);
} }
} }
@@ -7880,7 +7701,7 @@ public static partial class AgcExports
if (!isStorage && if (!isStorage &&
descriptor.Address != 0 && descriptor.Address != 0 &&
GuestGpu.Current.IsGpuGuestImageAvailable( VulkanVideoPresenter.IsGuestImageAvailable(
descriptor.Address, descriptor.Address,
descriptor.Format, descriptor.Format,
descriptor.NumberType)) descriptor.NumberType))
@@ -7909,7 +7730,7 @@ public static partial class AgcExports
{ {
var initialPixels = Array.Empty<byte>(); var initialPixels = Array.Empty<byte>();
var uploadKnown = descriptor.Address != 0 && var uploadKnown = descriptor.Address != 0 &&
GuestGpu.Current.IsGuestImageUploadKnown( VulkanVideoPresenter.IsGuestImageUploadKnown(
descriptor.Address, descriptor.Address,
descriptor.Format, descriptor.Format,
descriptor.NumberType); descriptor.NumberType);
@@ -7990,8 +7811,8 @@ public static partial class AgcExports
if (!_textureCopySkipDisabled && if (!_textureCopySkipDisabled &&
descriptor.Address != 0 && descriptor.Address != 0 &&
!SharpEmu.HLE.GuestImageWriteTracker.PeekDirty(descriptor.Address) && !SharpEmu.HLE.GuestImageWriteTracker.PeekDirty(descriptor.Address) &&
GuestGpu.Current.IsTextureContentCached( VulkanVideoPresenter.IsTextureContentCached(
new TextureContentIdentity( new VulkanVideoPresenter.TextureContentIdentity(
descriptor.Address, descriptor.Address,
descriptor.Width, descriptor.Width,
descriptor.Height, descriptor.Height,
@@ -8094,7 +7915,7 @@ public static partial class AgcExports
CpuContext ctx, CpuContext ctx,
RenderTargetDescriptor target) RenderTargetDescriptor target)
{ {
if (!GuestGpu.Current.GuestImageWantsInitialData(target.Address)) if (!VulkanVideoPresenter.GuestImageWantsInitialData(target.Address))
{ {
return; return;
} }
@@ -8120,7 +7941,7 @@ public static partial class AgcExports
if (nonZero) if (nonZero)
{ {
GuestGpu.Current.ProvideGuestImageInitialData(target.Address, initialData); VulkanVideoPresenter.ProvideGuestImageInitialData(target.Address, initialData);
} }
} }
@@ -8417,14 +8238,9 @@ public static partial class AgcExports
ulong packetAddress, ulong packetAddress,
uint packetLength, uint packetLength,
uint opcode, uint opcode,
out ComputeDispatch dispatch, out ComputeDispatch dispatch)
out ulong indirectDimsRetryAddress)
{ {
dispatch = default; dispatch = default;
// Non-zero only when this is an INDIRECT dispatch whose dimensions read as
// zero — meaning the producing GPU dispatch that computes them has not run
// yet. The caller suspends on this address instead of dropping the work.
indirectDimsRetryAddress = 0;
ulong dimensionsAddress; ulong dimensionsAddress;
uint initiator; uint initiator;
string dispatchSource; string dispatchSource;
@@ -8473,17 +8289,6 @@ public static partial class AgcExports
if (dispatchEndX == 0 || dispatchEndY == 0 || dispatchEndZ == 0) if (dispatchEndX == 0 || dispatchEndY == 0 || dispatchEndZ == 0)
{ {
// Indirect dispatches read their dimensions from a guest buffer a
// prior GPU dispatch fills. Zero here means that producer has not run
// yet — signal the caller to suspend on the dims buffer and retry,
// rather than dropping the work (which black-screens GPU-driven games
// like Astro Bot). Direct dispatches carry dims inline, so a zero is
// genuinely malformed and still rejected.
if (opcode == ItDispatchIndirect)
{
indirectDimsRetryAddress = dimensionsAddress;
}
return RejectComputeDispatch( return RejectComputeDispatch(
dimensionsAddress, dimensionsAddress,
initiator, initiator,
@@ -8818,7 +8623,7 @@ public static partial class AgcExports
// still queued, so the clear could erase newly constructed CPU // still queued, so the clear could erase newly constructed CPU
// objects. Waiting on the work sequence also retires preceding // objects. Waiting on the work sequence also retires preceding
// Vulkan writes before the next evaluator snapshot is captured. // Vulkan writes before the next evaluator snapshot is captured.
if (!GuestGpu.Current.WaitForGuestWork(semanticCopySequence)) if (!VulkanVideoPresenter.WaitForGuestWork(semanticCopySequence))
{ {
computeError = computeError =
$"semantic-global-write-sync-timeout sequence={semanticCopySequence}"; $"semantic-global-write-sync-timeout sequence={semanticCopySequence}";
@@ -8836,7 +8641,7 @@ public static partial class AgcExports
localSizeY, localSizeY,
localSizeZ, localSizeZ,
dispatch.WaveLaneCount, dispatch.WaveLaneCount,
_storageBufferOffsetAlignment); VulkanVideoPresenter.GuestStorageBufferOffsetAlignment);
var guestGlobalBufferCount = evaluation.GlobalMemoryBindings.Count; var guestGlobalBufferCount = evaluation.GlobalMemoryBindings.Count;
var totalGlobalBufferCount = _bakeScalars var totalGlobalBufferCount = _bakeScalars
? guestGlobalBufferCount ? guestGlobalBufferCount
@@ -8858,7 +8663,7 @@ public static partial class AgcExports
: guestGlobalBufferCount, : guestGlobalBufferCount,
waveLaneCount: dispatch.WaveLaneCount, waveLaneCount: dispatch.WaveLaneCount,
storageBufferOffsetAlignment: storageBufferOffsetAlignment:
_storageBufferOffsetAlignment)) VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
{ {
DumpCompiledShader( DumpCompiledShader(
"cs", "cs",
@@ -8878,7 +8683,7 @@ public static partial class AgcExports
out _); out _);
var globalMemoryBuffers = var globalMemoryBuffers =
CreateTranslatedComputeGlobalBuffers(evaluation); CreateTranslatedComputeGlobalBuffers(evaluation);
GuestGpu.Current.SubmitComputeDispatch( var workSequence = GuestGpu.Current.SubmitComputeDispatch(
shaderAddress, shaderAddress,
computeShader, computeShader,
textures, textures,
@@ -8897,9 +8702,12 @@ public static partial class AgcExports
dispatch.ThreadCountX, dispatch.ThreadCountX,
dispatch.ThreadCountY, dispatch.ThreadCountY,
dispatch.ThreadCountZ); dispatch.ThreadCountZ);
// Vulkan queue order keeps dependent dispatches coherent. CPU visibility is
// published by explicit PM4 release/write actions instead of per dispatch.
gpuDispatch = true; gpuDispatch = true;
if (writesGlobalMemory &&
!VulkanVideoPresenter.WaitForGuestWork(workSequence))
{
computeError = $"global-write-sync-timeout sequence={workSequence}";
}
} }
} }
@@ -9090,7 +8898,7 @@ public static partial class AgcExports
} }
var destinationAddress = destination.BaseAddress; var destinationAddress = destination.BaseAddress;
workSequence = GuestGpu.Current.SubmitOrderedGuestAction( workSequence = VulkanVideoPresenter.SubmitOrderedGuestAction(
() => () =>
{ {
if (!ctx.Memory.TryWrite(destinationAddress, output)) if (!ctx.Memory.TryWrite(destinationAddress, output))
@@ -9104,7 +8912,7 @@ public static partial class AgcExports
GuestImageWriteTracker.Track( GuestImageWriteTracker.Track(
destinationAddress, destinationAddress,
(ulong)output.Length, (ulong)output.Length,
GuestGpu.Current.CurrentGuestWorkSequenceForDiagnostics, VulkanVideoPresenter.CurrentGuestWorkSequenceForDiagnostics,
"agc.masked-dword-copy"); "agc.masked-dword-copy");
}, },
$"masked_dword_copy dst=0x{destinationAddress:X16} bytes={output.Length}"); $"masked_dword_copy dst=0x{destinationAddress:X16} bytes={output.Length}");
@@ -9793,7 +9601,7 @@ public static partial class AgcExports
pixelInputEnable: psInputEna, pixelInputEnable: psInputEna,
pixelInputAddress: psInputAddr, pixelInputAddress: psInputAddr,
storageBufferOffsetAlignment: storageBufferOffsetAlignment:
_storageBufferOffsetAlignment)) VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
{ {
TraceAgcShader( TraceAgcShader(
$"agc.shader_spirv ps=0x{pixelShaderAddress:X16} " + $"agc.shader_spirv ps=0x{pixelShaderAddress:X16} " +
@@ -11417,35 +11225,4 @@ public static partial class AgcExports
TraceAgc($"agc.driver_unregister_resource handle={resourceHandle}"); TraceAgc($"agc.driver_unregister_resource handle={resourceHandle}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK); return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
} }
// Tessellation-factor ring and hull-shader off-chip buffers are guest-driver
// configuration for on-hardware tessellation memory. Our translator handles
// shader execution directly, so there is no guest-side ring to program: the
// guest driver only needs these to report success so init proceeds. Games
// (e.g. Unity titles) call them during GPU setup and stall if unresolved.
[SysAbiExport(
Nid = "XlNp7jzGiPo",
ExportName = "sceAgcDriverSetTFRing",
Target = Generation.Gen5,
LibraryName = "libSceAgcDriver")]
public static int DriverSetTFRing(CpuContext ctx)
{
TraceAgc(
$"agc.driver_set_tf_ring ring=0x{ctx[CpuRegister.Rdi]:X16} " +
$"size=0x{(uint)ctx[CpuRegister.Rsi]:X8}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "MM4IZSEYytQ",
ExportName = "sceAgcDriverSetHsOffchipParam",
Target = Generation.Gen5,
LibraryName = "libSceAgcDriver")]
public static int DriverSetHsOffchipParam(CpuContext ctx)
{
TraceAgc(
$"agc.driver_set_hs_offchip_param buffer=0x{ctx[CpuRegister.Rdi]:X16} " +
$"param=0x{(uint)ctx[CpuRegister.Rsi]:X8}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
} }
@@ -3,7 +3,6 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using SharpEmu.Libs.Gpu;
using SharpEmu.Libs.Kernel; using SharpEmu.Libs.Kernel;
using SharpEmu.Libs.VideoOut; using SharpEmu.Libs.VideoOut;
using SharpEmu.ShaderCompiler; using SharpEmu.ShaderCompiler;
@@ -27,8 +26,8 @@ internal static class AgcShaderCompilerHooks
internal static void Install() internal static void Install()
{ {
Gen5ShaderScalarEvaluator.FallbackMemoryReader = Gen5ShaderScalarEvaluator.FallbackMemoryReader =
KernelMemoryCompatExports.TryReadShaderGuestMemory; KernelMemoryCompatExports.TryReadTrackedLibcHeap;
Gen5ShaderScalarEvaluator.GlobalMemoryPool = Gen5ShaderScalarEvaluator.GlobalMemoryPool =
GuestDataPool.Shared; VulkanVideoPresenter.GuestDataPool;
} }
} }
+2 -181
View File
@@ -37,26 +37,10 @@ internal static class GpuWaitRegistry
public long RegisteredTicks; public long RegisteredTicks;
public bool StaleReported; public bool StaleReported;
public object? State; 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 object _gate = new();
private static readonly Dictionary<ulong, List<WaitingDcb>> _waiters = 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 public static int Count
{ {
@@ -130,14 +114,8 @@ internal static class GpuWaitRegistry
continue; continue;
} }
var satisfied = list[i].Latched; var value = readValue(address, list[i].Is64Bit);
if (!satisfied) if (value is null || !Compare(list[i], value.Value))
{
var value = readValue(address, list[i].Is64Bit);
satisfied = value is not null && Compare(list[i], value.Value);
}
if (!satisfied)
{ {
continue; continue;
} }
@@ -258,162 +236,6 @@ internal static class GpuWaitRegistry
return matches; 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;
}
/// <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) public static bool Compare(in WaitingDcb waiter, ulong value)
{ {
var masked = value & waiter.Mask; var masked = value & waiter.Mask;
@@ -438,7 +260,6 @@ internal static class GpuWaitRegistry
lock (_gate) lock (_gate)
{ {
_waiters.Clear(); _waiters.Clear();
_lastProduced.Clear();
} }
} }
} }
+17 -10
View File
@@ -6,7 +6,6 @@ using SharpEmu.Libs.Kernel;
using System.Buffers; using System.Buffers;
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using Microsoft.Win32.SafeHandles;
namespace SharpEmu.Libs.Ampr; namespace SharpEmu.Libs.Ampr;
@@ -44,17 +43,17 @@ public static class AmprExports
{ {
public CachedHostFile(string path) public CachedHostFile(string path)
{ {
Handle = File.OpenHandle( Stream = new FileStream(
path, path,
FileMode.Open, FileMode.Open,
FileAccess.Read, FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete, FileShare.ReadWrite | FileShare.Delete,
bufferSize: 1024 * 1024,
FileOptions.RandomAccess); FileOptions.RandomAccess);
Length = RandomAccess.GetLength(Handle);
} }
public SafeFileHandle Handle { get; } public object Gate { get; } = new();
public long Length { get; } public FileStream Stream { get; }
} }
[SysAbiExport( [SysAbiExport(
@@ -736,7 +735,13 @@ public static class AmprExports
return openResult; 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; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} }
@@ -755,10 +760,12 @@ public static class AmprExports
} }
var request = (int)Math.Min((ulong)buffer.Length, size - bytesRead); var request = (int)Math.Min((ulong)buffer.Length, size - bytesRead);
var read = RandomAccess.Read( int read;
cachedFile.Handle, lock (cachedFile.Gate)
buffer.AsSpan(0, request), {
unchecked((long)absoluteOffset)); cachedFile.Stream.Position = unchecked((long)absoluteOffset);
read = cachedFile.Stream.Read(buffer, 0, request);
}
if (read <= 0) if (read <= 0)
{ {
@@ -121,33 +121,6 @@ public static class AppContentExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK; 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) private static bool TryReadUserDefinedParam(uint paramId, out int value)
{ {
value = 0; value = 0;
+1 -88
View File
@@ -14,12 +14,6 @@ public static class AudioOutExports
private static readonly ConcurrentDictionary<int, PortState> Ports = new(); private static readonly ConcurrentDictionary<int, PortState> Ports = new();
private static int _nextPortHandle; 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 sealed class PortState : IDisposable
{ {
private readonly object _paceGate = new(); private readonly object _paceGate = new();
@@ -161,37 +155,6 @@ public static class AudioOutExports
return ctx.SetReturn(0); 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( [SysAbiExport(
Nid = "QOQtbeDqsT4", Nid = "QOQtbeDqsT4",
ExportName = "sceAudioOutOutput", ExportName = "sceAudioOutOutput",
@@ -203,12 +166,7 @@ public static class AudioOutExports
var sourceAddress = ctx[CpuRegister.Rsi]; var sourceAddress = ctx[CpuRegister.Rsi];
if (!Ports.TryGetValue(handle, out var port)) if (!Ports.TryGetValue(handle, out var port))
{ {
// Host shutdown disposes the ports while guest audio threads are return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
// still draining their last buffers; report success so the guest
// winds down without a per-buffer error (and its WARN log flood).
return ctx.SetReturn(_shutdown
? 0
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
} }
if (sourceAddress == 0) if (sourceAddress == 0)
@@ -225,17 +183,6 @@ public static class AudioOutExports
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); 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) if (port.Backend is null)
{ {
port.PaceSilence(); port.PaceSilence();
@@ -319,40 +266,8 @@ public static class AudioOutExports
return ctx.SetReturn(0); 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() public static void ShutdownAllPorts()
{ {
Volatile.Write(ref _shutdown, true);
foreach (var handle in Ports.Keys) foreach (var handle in Ports.Keys)
{ {
if (Ports.TryRemove(handle, out var port)) if (Ports.TryRemove(handle, out var port))
@@ -362,8 +277,6 @@ public static class AudioOutExports
} }
} }
private static bool _shutdown;
private static bool TryGetFormat( private static bool TryGetFormat(
int rawFormat, int rawFormat,
out int channels, out int channels,
@@ -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);
}
+9 -14
View File
@@ -29,9 +29,10 @@ internal static class Bink2MovieBridge
private static bool _availabilityReported; private static bool _availabilityReported;
/// <summary> /// <summary>
/// Returns true only when movie skipping was explicitly requested. Without /// Returns true when the guest should receive a normal "file not found"
/// a host adapter the guest must be allowed to run the Bink implementation /// result for a Bink movie. This is the safe default without a decoder:
/// statically linked into its executable. /// games that treat movies as optional fall through to their next state
/// rather than submitting an empty Bink GPU texture forever.
/// </summary> /// </summary>
internal static bool ShouldSkipGuestMovie(string hostPath) => internal static bool ShouldSkipGuestMovie(string hostPath) =>
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) && hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
@@ -52,18 +53,12 @@ internal static class Bink2MovieBridge
return; return;
} }
var mode = ResolveMode(); if (ResolveMode() == MovieMode.Dummy)
if (mode == MovieMode.Dummy)
{ {
AttachDummyMovieLocked(hostPath); AttachDummyMovieLocked(hostPath);
return; return;
} }
if (mode != MovieMode.Native)
{
return;
}
var adapter = GetAdapterLocked(); var adapter = GetAdapterLocked();
if (adapter is null) if (adapter is null)
{ {
@@ -170,15 +165,16 @@ internal static class Bink2MovieBridge
return MovieMode.Skip; return MovieMode.Skip;
} }
// Prefer the optional host adapter when one is supplied. Otherwise let // With no SDK adapter present, returning "not found" makes optional
// the game's statically linked Bink implementation consume the file. // 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")) || if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE")) ||
EnumerateAdapterCandidates().Any(File.Exists)) EnumerateAdapterCandidates().Any(File.Exists))
{ {
return MovieMode.Native; return MovieMode.Native;
} }
return MovieMode.Guest; return MovieMode.Skip;
} }
private static void AttachDummyMovieLocked(string hostPath) private static void AttachDummyMovieLocked(string hostPath)
@@ -339,7 +335,6 @@ internal static class Bink2MovieBridge
private enum MovieMode private enum MovieMode
{ {
Guest,
Skip, Skip,
Dummy, Dummy,
Native, Native,
-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 // Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Gpu.Metal;
using SharpEmu.Libs.Gpu.Vulkan; using SharpEmu.Libs.Gpu.Vulkan;
namespace SharpEmu.Libs.Gpu; namespace SharpEmu.Libs.Gpu;
@@ -9,39 +8,11 @@ namespace SharpEmu.Libs.Gpu;
/// <summary> /// <summary>
/// Process-wide access point for the guest-GPU backend, mirroring HostPlatform for the /// 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"/>. /// 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 /// Vulkan is the only backend today; Metal/DX12 slot in here.
/// backend (macOS only) while it is being brought up. macOS flips to Metal by default
/// once the presenter reaches parity.
/// </summary> /// </summary>
internal static class GuestGpu 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; public static IGuestGpuBackend Current => Instance.Value;
private static IGuestGpuBackend Create()
{
var requested = Environment.GetEnvironmentVariable("SHARPEMU_GPU_BACKEND");
if (string.IsNullOrEmpty(requested) || requested.Equals("vulkan", StringComparison.OrdinalIgnoreCase))
{
return new VulkanGuestGpuBackend();
}
if (requested.Equals("metal", StringComparison.OrdinalIgnoreCase))
{
if (!OperatingSystem.IsMacOS())
{
Console.Error.WriteLine(
"[LOADER][WARN] SHARPEMU_GPU_BACKEND=metal is only available on macOS; using Vulkan.");
return new VulkanGuestGpuBackend();
}
Console.Error.WriteLine("[LOADER][INFO] GPU backend: Metal (SHARPEMU_GPU_BACKEND).");
return new MetalGuestGpuBackend();
}
Console.Error.WriteLine(
$"[LOADER][WARN] Unknown SHARPEMU_GPU_BACKEND value '{requested}'; using Vulkan.");
return new VulkanGuestGpuBackend();
}
} }
+1 -25
View File
@@ -36,20 +36,6 @@ internal readonly record struct GuestSampler(
uint Word2, uint Word2,
uint Word3); 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);
internal sealed record GuestMemoryBuffer( internal sealed record GuestMemoryBuffer(
ulong BaseAddress, ulong BaseAddress,
byte[] Data, byte[] Data,
@@ -136,22 +122,12 @@ internal readonly record struct GuestBlendState(
WriteMask: 0xFu); 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( internal sealed record GuestRenderState(
IReadOnlyList<GuestBlendState> Blends, IReadOnlyList<GuestBlendState> Blends,
GuestRect? Scissor, GuestRect? Scissor,
GuestViewport? Viewport, GuestViewport? Viewport,
GuestRasterState Raster, GuestRasterState Raster,
GuestDepthState Depth, GuestDepthState Depth)
GuestBlendConstant BlendConstant = default)
{ {
public static GuestRenderState Default { get; } = new( public static GuestRenderState Default { get; } = new(
[GuestBlendState.Default], [GuestBlendState.Default],
-71
View File
@@ -1,7 +1,6 @@
// Copyright (C) 2026 SharpEmu Emulator Project // Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.ShaderCompiler; using SharpEmu.ShaderCompiler;
namespace SharpEmu.Libs.Gpu; namespace SharpEmu.Libs.Gpu;
@@ -18,10 +17,6 @@ namespace SharpEmu.Libs.Gpu;
/// </summary> /// </summary>
internal interface IGuestGpuBackend 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> /// <summary>Starts the presenter (window + device) once; safe to call repeatedly.</summary>
void EnsureStarted(uint width, uint height); 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. /// the guest codes cross the seam and each backend maps them internally.
/// </summary> /// </summary>
bool TryGetRenderTargetOutputKind(uint dataFormat, uint numberType, out Gen5PixelOutputKind outputKind); 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> /// </summary>
internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
{ {
public string BackendName => "Vulkan";
private static readonly IGuestCompiledShader DepthOnlyFragmentShader = private static readonly IGuestCompiledShader DepthOnlyFragmentShader =
new VulkanCompiledGuestShader(SpirvFixedShaders.CreateDepthOnlyFragment()); new VulkanCompiledGuestShader(SpirvFixedShaders.CreateDepthOnlyFragment());
@@ -345,60 +343,6 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
return false; 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) => private static byte[] Spirv(IGuestCompiledShader shader) =>
shader is VulkanCompiledGuestShader vulkanShader shader is VulkanCompiledGuestShader vulkanShader
? vulkanShader.Spirv ? vulkanShader.Spirv
@@ -224,21 +224,6 @@ public static partial class KernelMemoryCompatExports
} }
} }
/// <summary>Removes a guest mount registered by <see cref="RegisterGuestPathMount"/>.</summary>
public static bool UnregisterGuestPathMount(string guestMountPoint)
{
var normalizedMountPoint = NormalizeGuestStatCachePath(guestMountPoint);
if (normalizedMountPoint is null)
{
return false;
}
lock (_guestMountGate)
{
return _guestMounts.Remove(normalizedMountPoint);
}
}
internal static bool TryAllocateHleData( internal static bool TryAllocateHleData(
CpuContext ctx, CpuContext ctx,
ulong length, ulong length,
@@ -1111,7 +1096,6 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
} }
ctx[CpuRegister.Rax] = destination;
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
} }
@@ -6010,7 +5994,7 @@ public static partial class KernelMemoryCompatExports
return highWaterMark; return highWaterMark;
} }
private static unsafe bool TryReadHostMemory(ulong address, Span<byte> destination) private static bool TryReadHostMemory(ulong address, Span<byte> destination)
{ {
if (destination.IsEmpty || !IsHostRangeAccessible(address, (ulong)destination.Length, writeAccess: false)) if (destination.IsEmpty || !IsHostRangeAccessible(address, (ulong)destination.Length, writeAccess: false))
{ {
@@ -6019,7 +6003,9 @@ public static partial class KernelMemoryCompatExports
try try
{ {
new ReadOnlySpan<byte>((void*)address, destination.Length).CopyTo(destination); var temporary = new byte[destination.Length];
Marshal.Copy((nint)address, temporary, 0, temporary.Length);
temporary.AsSpan().CopyTo(destination);
return true; return true;
} }
catch catch
@@ -6059,41 +6045,6 @@ public static partial class KernelMemoryCompatExports
return false; return false;
} }
internal static bool TryReadShaderGuestMemory(
ulong address,
Span<byte> destination)
{
if (destination.IsEmpty)
{
return true;
}
if (TryReadTrackedLibcHeap(address, destination))
{
return true;
}
var length = (ulong)destination.Length;
lock (_memoryGate)
{
if (TryFindVirtualQueryRegionLocked(
address,
findNext: false,
out var region) &&
length <= region.Length &&
address >= region.Address &&
length <= region.Address + region.Length - address)
{
return TryReadHostMemory(address, destination);
}
}
// Direct execution uses guest virtual addresses as host virtual addresses.
// Some native mmap paths predate _mappedRegions tracking, so retain the same
// committed/readable-page fallback used by the libc compatibility layer.
return TryReadHostMemory(address, destination);
}
internal static bool TryReadTrackedLibcHeapGpuAlias( internal static bool TryReadTrackedLibcHeapGpuAlias(
ulong packedAddress, ulong packedAddress,
Span<byte> destination) Span<byte> destination)
@@ -6373,7 +6324,7 @@ public static partial class KernelMemoryCompatExports
return value != 0 && (value & (value - 1)) == 0; return value != 0 && (value & (value - 1)) == 0;
} }
private static unsafe bool TryWriteHostMemory(ulong address, ReadOnlySpan<byte> source) private static bool TryWriteHostMemory(ulong address, ReadOnlySpan<byte> source)
{ {
if (source.IsEmpty || !IsHostRangeAccessible(address, (ulong)source.Length, writeAccess: true)) if (source.IsEmpty || !IsHostRangeAccessible(address, (ulong)source.Length, writeAccess: true))
{ {
@@ -6382,7 +6333,8 @@ public static partial class KernelMemoryCompatExports
try try
{ {
source.CopyTo(new Span<byte>((void*)address, source.Length)); var temporary = source.ToArray();
Marshal.Copy(temporary, 0, (nint)address, temporary.Length);
return true; return true;
} }
catch catch
@@ -6409,37 +6361,20 @@ public static partial class KernelMemoryCompatExports
return false; return false;
} }
var endAddress = address + length - 1; if (!TryQueryHostPage(address, out var startInfo) || !HasRequiredProtection(startInfo.Protect, writeAccess))
var currentAddress = address;
while (currentAddress <= endAddress)
{ {
if (!TryQueryHostPage(currentAddress, out var info) || return false;
!HasRequiredProtection(info.Protect, writeAccess)) }
{
return false;
}
var regionBase = unchecked((ulong)info.BaseAddress); var endAddress = address + length - 1;
var regionSize = (ulong)info.RegionSize; if (endAddress == address)
if (regionSize == 0 || {
regionBase > currentAddress || return true;
ulong.MaxValue - regionBase < regionSize) }
{
return false;
}
var regionEnd = regionBase + regionSize; if (!TryQueryHostPage(endAddress, out var endInfo) || !HasRequiredProtection(endInfo.Protect, writeAccess))
if (regionEnd <= currentAddress) {
{ return false;
return false;
}
if (regionEnd > endAddress)
{
return true;
}
currentAddress = regionEnd;
} }
return true; return true;
@@ -696,14 +696,7 @@ public static class KernelPthreadCompatExports
} }
} }
// pthread_mutex_trylock succeeds whenever the mutex is not currently if (state.OwnerThreadId == 0 && state.Waiters.Count == 0)
// 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.OwnerThreadId = currentThreadId; state.OwnerThreadId = currentThreadId;
state.RecursionCount = 1; state.RecursionCount = 1;
@@ -1241,21 +1234,7 @@ public static class KernelPthreadCompatExports
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle(); var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
lock (mutexState) lock (mutexState)
{ {
if (mutexState.OwnerThreadId == 0 && mutexState.RecursionCount == 0) if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
{
// 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.OwnerThreadId = currentThreadId;
mutexState.RecursionCount = 1;
}
else if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
{ {
return mutexState.OwnerThreadId == currentThreadId return mutexState.OwnerThreadId == currentThreadId
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT ? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT
@@ -1406,31 +1385,6 @@ public static class KernelPthreadCompatExports
bool cooperative, bool cooperative,
string? wakeKey = null) 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);
node.Value.Node = null;
}
node = next;
}
}
var waiter = new PthreadMutexWaiter var waiter = new PthreadMutexWaiter
{ {
ThreadId = threadId, ThreadId = threadId,
@@ -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;
}
}
+103 -448
View File
@@ -3,7 +3,6 @@
using SharpEmu.HLE; using SharpEmu.HLE;
using SharpEmu.Libs.Kernel; using SharpEmu.Libs.Kernel;
using System.Buffers;
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Threading; using System.Threading;
@@ -26,44 +25,24 @@ public static class Ngs2Exports
private static long _nextUid; private static long _nextUid;
private static long _renderCount; private static long _renderCount;
// NGS2 renders one grain of interleaved float32 per sceNgs2SystemRender. private sealed record SystemState(uint Uid);
// 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 RackState(ulong SystemHandle, uint RackId); 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; return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
VoiceIndex = voiceIndex;
} }
public ulong RackHandle { get; } return CreateSystem(ctx, ctx[CpuRegister.Rdx], hostBuffer);
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;
} }
[SysAbiExport( [SysAbiExport(
@@ -79,34 +58,14 @@ public static class Ngs2Exports
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress); return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
} }
if (!TryCreateHandle(ctx, type: 1, ownerHandle: 0, out var handle) || if (!TryCreateHandle(ctx, type: 1, ownerHandle: 0, out var handle))
!ctx.TryWriteUInt64(outHandleAddress, handle))
{ {
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
} }
lock (StateGate) return CreateSystem(ctx, outHandleAddress, handle);
{
Systems[handle] = new SystemState(unchecked((uint)Interlocked.Increment(ref _nextUid)));
}
return SetReturn(ctx, 0);
} }
// 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( [SysAbiExport(
Nid = "u-WrYDaJA3k", Nid = "u-WrYDaJA3k",
ExportName = "sceNgs2SystemDestroy", ExportName = "sceNgs2SystemDestroy",
@@ -135,6 +94,27 @@ public static class Ngs2Exports
return SetReturn(ctx, 0); 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( [SysAbiExport(
Nid = "U546k6orxQo", Nid = "U546k6orxQo",
ExportName = "sceNgs2RackCreateWithAllocator", ExportName = "sceNgs2RackCreateWithAllocator",
@@ -158,29 +138,14 @@ public static class Ngs2Exports
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress); return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
} }
if (!TryCreateHandle(ctx, type: 2, systemHandle, out var handle) || if (!TryCreateHandle(ctx, type: 2, systemHandle, out var handle))
!ctx.TryWriteUInt64(outHandleAddress, handle))
{ {
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
} }
lock (StateGate) return CreateRack(ctx, systemHandle, rackId, outHandleAddress, handle);
{
Racks[handle] = new RackState(systemHandle, rackId);
}
return SetReturn(ctx, 0);
} }
// 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( [SysAbiExport(
Nid = "lCqD7oycmIM", Nid = "lCqD7oycmIM",
ExportName = "sceNgs2RackDestroy", ExportName = "sceNgs2RackDestroy",
@@ -255,217 +220,14 @@ public static class Ngs2Exports
LibraryName = "libSceNgs2")] LibraryName = "libSceNgs2")]
public static int Ngs2VoiceControl(CpuContext ctx) public static int Ngs2VoiceControl(CpuContext ctx)
{ {
var voiceHandle = ctx[CpuRegister.Rdi];
var paramList = ctx[CpuRegister.Rsi];
lock (StateGate) lock (StateGate)
{ {
if (!Voices.ContainsKey(voiceHandle)) return SetReturn(
{ ctx,
return SetReturn(ctx, OrbisNgs2ErrorInvalidVoiceHandle); Voices.ContainsKey(ctx[CpuRegister.Rdi]) ? 0 : 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;
} }
} }
// 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( [SysAbiExport(
Nid = "AbYvTOZ8Pts", Nid = "AbYvTOZ8Pts",
ExportName = "sceNgs2VoiceRunCommands", ExportName = "sceNgs2VoiceRunCommands",
@@ -511,32 +273,11 @@ public static class Ngs2Exports
{ {
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); 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); var count = Interlocked.Increment(ref _renderCount);
if (ShouldTrace() && (count <= 4 || count % 200 == 0)) if (ShouldTrace() && (count <= 4 || count % 10_000 == 0))
{ {
Console.Error.WriteLine( Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.render#{count} system=0x{systemHandle:X16} buffers={bufferInfoCount}"); $"[LOADER][TRACE] ngs2.render#{count} system=0x{systemHandle:X16} buffers={bufferInfoCount}");
@@ -545,135 +286,6 @@ public static class Ngs2Exports
return SetReturn(ctx, 0); 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( [SysAbiExport(
Nid = "pgFAiLR5qT4", Nid = "pgFAiLR5qT4",
ExportName = "sceNgs2SystemQueryBufferSize", ExportName = "sceNgs2SystemQueryBufferSize",
@@ -711,25 +323,7 @@ public static class Ngs2Exports
ExportName = "sceNgs2SystemSetGrainSamples", ExportName = "sceNgs2SystemSetGrainSamples",
Target = Generation.Gen4 | Generation.Gen5, Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")] LibraryName = "libSceNgs2")]
public static int Ngs2SystemSetGrainSamples(CpuContext ctx) public static int Ngs2SystemSetGrainSamples(CpuContext ctx) => ValidateSystem(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);
}
[SysAbiExport( [SysAbiExport(
Nid = "-tbc2SxQD60", 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) private static bool TryCreateHandle(CpuContext ctx, uint type, ulong ownerHandle, out ulong handle)
{ {
handle = 0; 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); 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) private static void TraceNpEntitlementAccess(string message)
{ {
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NP"), "1", StringComparison.Ordinal)) if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NP"), "1", StringComparison.Ordinal))
@@ -197,16 +197,4 @@ public static class NpUniversalDataSystemExports
{ {
return ctx.SetReturn(0, typeof(long)); 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));
}
} }
-61
View File
@@ -65,35 +65,6 @@ public static class PadExports
LibraryName = "libScePad")] LibraryName = "libScePad")]
public static int PadOpenExt(CpuContext ctx) => PadOpenCore(ctx, extended: true); 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 // 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). // ScePadOpenExtParam* plus ports 1/2 (racing titles retry scePadOpenExt(type=2) forever if rejected).
private static int PadOpenCore(CpuContext ctx, bool extended) 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); : 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( [SysAbiExport(
Nid = "YndgXqQVV7c", Nid = "YndgXqQVV7c",
ExportName = "scePadReadState", ExportName = "scePadReadState",
-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);
}
}
+24 -565
View File
@@ -45,530 +45,6 @@ public static class SaveDataExports
_titleId = string.IsNullOrWhiteSpace(titleId) ? null : SanitizePathSegment(titleId.Trim()); _titleId = string.IsNullOrWhiteSpace(titleId) ? null : SanitizePathSegment(titleId.Trim());
_preparedTransactionResources.Clear(); _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( [SysAbiExport(
@@ -745,10 +221,6 @@ public static class SaveDataExports
const string mountPoint = "/savedata0"; const string mountPoint = "/savedata0";
KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, savePath); KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, savePath);
lock (_mountGate)
{
_mounts[mountPoint] = new MountEntry(savePath, dirName, userId);
}
Span<byte> result = stackalloc byte[MountResultSize]; Span<byte> result = stackalloc byte[MountResultSize];
result.Clear(); result.Clear();
@@ -846,20 +318,10 @@ public static class SaveDataExports
LibraryName = "libSceSaveData")] LibraryName = "libSceSaveData")]
public static int SaveDataUmount2(CpuContext ctx) public static int SaveDataUmount2(CpuContext ctx)
{ {
// rdi: SceSaveDataMountPoint* (16-byte mount point string) for umount2. // Unmounting a save directory always succeeds in the stub filesystem;
var mountPointAddress = ctx[CpuRegister.Rdi]; // returning an error here makes the game's save flow stall before it
if (mountPointAddress != 0 && TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint) && // hands control to the title/gameplay state.
!string.IsNullOrEmpty(mountPoint)) TraceSaveData($"umount2 user={unchecked((int)ctx[CpuRegister.Rdi])}");
{
lock (_mountGate)
{
_mounts.Remove(mountPoint);
}
KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint);
TraceSaveData($"umount2 mount='{mountPoint}'");
}
return SetReturn(ctx, 0); return SetReturn(ctx, 0);
} }
@@ -943,12 +405,9 @@ public static class SaveDataExports
private static bool TryWriteParam(CpuContext ctx, ulong address, SaveEntry entry) private static bool TryWriteParam(CpuContext ctx, ulong address, SaveEntry entry)
{ {
var metadata = SaveDataStorage.ReadMetadata(entry.Path);
var param = new byte[SaveDataParamSize]; var param = new byte[SaveDataParamSize];
WriteAscii(param.AsSpan(0x00, 128), metadata.Title); WriteAscii(param.AsSpan(0x00, 128), "Saved Data");
WriteAscii(param.AsSpan(0x80, 128), metadata.SubTitle); WriteAscii(param.AsSpan(0x100, 1024), entry.Name);
WriteAscii(param.AsSpan(0x100, 1024), string.IsNullOrEmpty(metadata.Detail) ? entry.Name : metadata.Detail);
BinaryPrimitives.WriteUInt32LittleEndian(param.AsSpan(0x500), metadata.UserParam);
BinaryPrimitives.WriteInt64LittleEndian( BinaryPrimitives.WriteInt64LittleEndian(
param.AsSpan(0x508, sizeof(long)), param.AsSpan(0x508, sizeof(long)),
new DateTimeOffset(entry.LastWriteUtc).ToUnixTimeSeconds()); new DateTimeOffset(entry.LastWriteUtc).ToUnixTimeSeconds());
@@ -1015,14 +474,11 @@ public static class SaveDataExports
return false; 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) => 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) => private static string ResolveSaveDataMemoryPath(int userId) =>
SaveDataStorage.MemoryPath(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId())); Path.Combine(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId()), "sce_sdmemory", "memory.dat");
private static bool TryReadMemoryData( private static bool TryReadMemoryData(
CpuContext ctx, ulong address, out ulong buffer, out ulong size, out ulong offset) CpuContext ctx, ulong address, out ulong buffer, out ulong size, out ulong offset)
@@ -1034,7 +490,14 @@ public static class SaveDataExports
ctx.TryReadUInt64(address + 0x10, out offset); 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() private static string ResolveConfiguredTitleId()
{ {
@@ -1062,7 +525,12 @@ public static class SaveDataExports
return "default"; 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) private static bool TryReadFixedAscii(CpuContext ctx, ulong address, int length, out string value)
{ {
@@ -1322,17 +790,8 @@ public static class SaveDataExports
return ctx.SetReturn(OrbisSaveDataErrorParameter); return ctx.SetReturn(OrbisSaveDataErrorParameter);
} }
if (!File.Exists(ResolveSaveDataMemoryPath(userId))) return ctx.SetReturn(
{ File.Exists(ResolveSaveDataMemoryPath(userId)) ? 0 : OrbisSaveDataErrorMemoryNotReady);
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);
} }
private static int TransferSaveDataMemory(CpuContext ctx, bool write) 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
{
}
-1
View File
@@ -7,7 +7,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\SharpEmu.HLE\SharpEmu.HLE.csproj" /> <ProjectReference Include="..\SharpEmu.HLE\SharpEmu.HLE.csproj" />
<ProjectReference Include="..\SharpEmu.ShaderCompiler\SharpEmu.ShaderCompiler.csproj" /> <ProjectReference Include="..\SharpEmu.ShaderCompiler\SharpEmu.ShaderCompiler.csproj" />
<ProjectReference Include="..\SharpEmu.ShaderCompiler.Metal\SharpEmu.ShaderCompiler.Metal.csproj" />
<ProjectReference Include="..\SharpEmu.ShaderCompiler.Vulkan\SharpEmu.ShaderCompiler.Vulkan.csproj" /> <ProjectReference Include="..\SharpEmu.ShaderCompiler.Vulkan\SharpEmu.ShaderCompiler.Vulkan.csproj" />
<!-- SysAbi export generator + analyzers (compile-time registry, NID validation). --> <!-- SysAbi export generator + analyzers (compile-time registry, NID validation). -->
<ProjectReference Include="..\SharpEmu.SourceGenerators\SharpEmu.SourceGenerators.csproj" <ProjectReference Include="..\SharpEmu.SourceGenerators\SharpEmu.SourceGenerators.csproj"
@@ -0,0 +1,122 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers;
using System.Numerics;
namespace SharpEmu.Libs.VideoOut;
internal 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;
}
+7 -10
View File
@@ -36,7 +36,6 @@ public static class PerfOverlay
private static long _presentedInWindow; private static long _presentedInWindow;
private static long _submittedInWindow; private static long _submittedInWindow;
private static long _drawsInWindow; private static long _drawsInWindow;
private static long _guestBufferCacheBytes;
// Refreshed once per second so per-frame fills never allocate. // Refreshed once per second so per-frame fills never allocate.
private static long _statsWindowStart = Stopwatch.GetTimestamp(); private static long _statsWindowStart = Stopwatch.GetTimestamp();
@@ -75,8 +74,11 @@ public static class PerfOverlay
if (last != 0) if (last != 0)
{ {
var milliseconds = (now - last) * 1000.0 / Stopwatch.Frequency; var milliseconds = (now - last) * 1000.0 / Stopwatch.Frequency;
_frameMilliseconds[_frameHistoryIndex] = milliseconds; if (milliseconds < 1000.0)
_frameHistoryIndex = (_frameHistoryIndex + 1) % FrameHistorySize; {
_frameMilliseconds[_frameHistoryIndex] = milliseconds;
_frameHistoryIndex = (_frameHistoryIndex + 1) % FrameHistorySize;
}
} }
} }
@@ -86,9 +88,6 @@ public static class PerfOverlay
/// <summary>Called per translated draw/dispatch executed.</summary> /// <summary>Called per translated draw/dispatch executed.</summary>
public static void RecordDraw() => Interlocked.Increment(ref _drawsInWindow); public static void RecordDraw() => Interlocked.Increment(ref _drawsInWindow);
public static void SetGuestBufferCacheBytes(ulong bytes) =>
Interlocked.Exchange(ref _guestBufferCacheBytes, checked((long)bytes));
/// <summary> /// <summary>
/// Rasterizes the panel into a BGRA byte span of PanelWidth x PanelHeight. /// Rasterizes the panel into a BGRA byte span of PanelWidth x PanelHeight.
/// Runs on the render thread. /// Runs on the render thread.
@@ -166,7 +165,7 @@ public static class PerfOverlay
Environment.ProcessorCount; Environment.ProcessorCount;
_lastCpuTime = cpuTime; _lastCpuTime = cpuTime;
var drawsPerFrame = _fps > 0 ? _drawsPerSecond / _fps : 0; var drawsPerFrame = _fps > 0.5 ? _drawsPerSecond / _fps : 0;
var sessionStart = Interlocked.Read(ref _sessionStartTimestamp); var sessionStart = Interlocked.Read(ref _sessionStartTimestamp);
var elapsedSeconds = sessionStart == 0 var elapsedSeconds = sessionStart == 0
? 0L ? 0L
@@ -177,9 +176,7 @@ public static class PerfOverlay
_line1 = $"FPS {_fps:0.0} FLIP {_submittedFps:0.0} {_averageFrameMs:0.0} MS"; _line1 = $"FPS {_fps:0.0} FLIP {_submittedFps:0.0} {_averageFrameMs:0.0} MS";
_line2 = $"DRAWS {_drawsPerSecond:0}/S {drawsPerFrame:0}/F Q {pendingWork}+{inFlightSubmissions}"; _line2 = $"DRAWS {_drawsPerSecond:0}/S {drawsPerFrame:0}/F Q {pendingWork}+{inFlightSubmissions}";
_line3 = $"ALLOC {_allocatedMbPerSecond:0.0} MB/S GC {_gen0PerWindow}/{_gen1PerWindow}/{_gen2PerWindow}"; _line3 = $"ALLOC {_allocatedMbPerSecond:0.0} MB/S GC {_gen0PerWindow}/{_gen1PerWindow}/{_gen2PerWindow}";
var heapMb = GC.GetTotalMemory(false) / (1024 * 1024); _line4 = $"CPU {_cpuPercent:0}% HEAP {GC.GetTotalMemory(false) / (1024 * 1024)} MB F1 HIDE";
var guestBufferMb = Interlocked.Read(ref _guestBufferCacheBytes) / (1024 * 1024);
_line4 = $"MEM {heapMb}M BUF {guestBufferMb}M CPU {_cpuPercent:0}%";
_line5 = $"TIME {elapsedHours:00}:{elapsedMinutes:00}:{elapsedRemainingSeconds:00}"; _line5 = $"TIME {elapsedHours:00}:{elapsedMinutes:00}:{elapsedRemainingSeconds:00}";
} }
} }
+8 -20
View File
@@ -131,14 +131,9 @@ public static class VideoOutExports
return; return;
} }
// macOS can run either backend (Vulkan through MoltenVK, or Metal), so
// name the active one in the title to make which is in use unambiguous.
var backendSuffix = OperatingSystem.IsMacOS()
? $" ({GuestGpu.Current.BackendName})"
: string.Empty;
lock (_stateGate) lock (_stateGate)
{ {
_windowTitle = $"{_windowTitle} · {gpuName.Trim()}{backendSuffix}"; _windowTitle = $"{_windowTitle} · {gpuName.Trim()}";
} }
} }
@@ -171,12 +166,11 @@ public static class VideoOutExports
HostSessionControl.RequestShutdown(reason); HostSessionControl.RequestShutdown(reason);
// A hosted game can still be issuing AGC work after it requests its // A hosted game can still be issuing AGC work after it requests its
// own shutdown. Keep the presenter's resources alive until the GUI // own shutdown. Keep the Vulkan resources alive until the GUI session
// session reaches its guest-safe exit path and disposes the host // reaches its guest-safe exit path and disposes the host surface.
// surface.
if (!embedded) if (!embedded)
{ {
GuestGpu.Current.RequestClose(); VulkanVideoPresenter.RequestClose();
} }
// The embedded GUI owns the process lifetime. A guest shutdown should // The embedded GUI owns the process lifetime. A guest shutdown should
@@ -1076,12 +1070,6 @@ public static class VideoOutExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
} }
// SceVideoOutBufferCategory is a 32-bit enum passed on the stack; the
// upper 32 bits of the slot are stale (games leave GNM magic there), so
// mask before validating. UNCOMPRESSED (0) and COMPRESSED (1) are both
// valid — we present either identically, so accept both.
var category = (uint)categoryRaw;
if (!TryGetPort(handle, out var port)) if (!TryGetPort(handle, out var port))
{ {
return OrbisVideoOutErrorInvalidHandle; return OrbisVideoOutErrorInvalidHandle;
@@ -1102,7 +1090,7 @@ public static class VideoOutExports
return OrbisVideoOutErrorInvalidValue; return OrbisVideoOutErrorInvalidValue;
} }
if (category > 1 || option != 0) if (categoryRaw != 0 || option != 0)
{ {
return OrbisVideoOutErrorInvalidValue; return OrbisVideoOutErrorInvalidValue;
} }
@@ -1221,7 +1209,7 @@ public static class VideoOutExports
{ {
TriggerFlipEvents(); TriggerFlipEvents();
} }
else if (GuestGpu.Current.SubmitOrderedGuestAction( else if (VulkanVideoPresenter.SubmitOrderedGuestAction(
TriggerFlipEvents, TriggerFlipEvents,
$"videoout flip complete handle={handle} index={bufferIndex}") == 0) $"videoout flip complete handle={handle} index={bufferIndex}") == 0)
{ {
@@ -1275,7 +1263,7 @@ public static class VideoOutExports
var elapsedSeconds = (double)elapsedTicks / Stopwatch.Frequency; var elapsedSeconds = (double)elapsedTicks / Stopwatch.Frequency;
var submitted = Interlocked.Exchange(ref _submittedFrameCount, 0); var submitted = Interlocked.Exchange(ref _submittedFrameCount, 0);
var presentedCount = Interlocked.Exchange(ref _presentedFrameCount, 0); var presentedCount = Interlocked.Exchange(ref _presentedFrameCount, 0);
var (draws, drawMs, pipelines, spirvCompiles) = GuestGpu.Current.ReadAndResetPerfCounters(); var (draws, drawMs, pipelines, spirvCompiles) = VulkanVideoPresenter.ReadAndResetPerfCounters();
Console.Error.WriteLine( Console.Error.WriteLine(
$"[LOADER][PERF] videoout submitted_fps={submitted / elapsedSeconds:F1} " + $"[LOADER][PERF] videoout submitted_fps={submitted / elapsedSeconds:F1} " +
$"presented_fps={presentedCount / elapsedSeconds:F1} " + $"presented_fps={presentedCount / elapsedSeconds:F1} " +
@@ -1714,7 +1702,7 @@ public static class VideoOutExports
SceVideoOutPixelFormat2B10G10R10A2Bt2100Pq; SceVideoOutPixelFormat2B10G10R10A2Bt2100Pq;
// Maps the PS5 VideoOut pixel format space to the AGC "guest texture format" tags // Maps the PS5 VideoOut pixel format space to the AGC "guest texture format" tags
// the backend keys its guest-image registry on (see the presenter's // the backend keys its guest-image registry on (see VulkanVideoPresenter.
// GetGuestTextureFormat: format=10 => 56 for 8-bit RGBA variants, format=9 => 9 for 10-bit). // GetGuestTextureFormat: format=10 => 56 for 8-bit RGBA variants, format=9 => 9 for 10-bit).
// Unknown formats default to 56 (8-bit RGBA) with a logged warning so games // Unknown formats default to 56 (8-bit RGBA) with a logged warning so games
// display something rather than silently failing the flip pipeline. // display something rather than silently failing the flip pipeline.
File diff suppressed because it is too large Load Diff
@@ -1,58 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.ShaderCompiler;
namespace SharpEmu.ShaderCompiler.Metal;
// MSL-specific shader artifact types. These stay beside the MSL emitter (not in the
// backend-neutral SharpEmu.ShaderCompiler project): each codegen owns its own
// compiled-shader shape, mirroring Gen5SpirvShader on the Vulkan side.
public enum Gen5MslStage
{
Vertex,
Pixel,
Compute,
}
/// <summary>
/// A translated Metal shader: MSL source text plus the reflection data the Metal
/// backend needs to bind it. Buffer argument indices follow the translation
/// contract documented on <see cref="Gen5MslTranslator"/>: global memory buffers
/// occupy [[buffer(globalBufferBase + i)]] in <see cref="GlobalMemoryBindings"/>
/// order, and compute shaders reserve one trailing slot for the dispatch-limit
/// uniform. Unlike SPIR-V, Metal fixes the threadgroup size at dispatch time, so
/// the size the shader was translated for is carried here.
/// </summary>
/// <remarks>
/// <para><see cref="UniformsBufferIndex"/> is the [[buffer(N)]] slot this stage's
/// SharpEmuUniforms argument was emitted at (globalBufferBase +
/// totalGlobalBufferCount, both translation-time inputs). Stages sharing a draw
/// can disagree — a vertex stage whose guest buffers sit after the pixel
/// stage's has a higher base — so the presenter must bind the uniforms buffer
/// per stage at this exact index rather than assuming one shared slot.</para>
/// <para>Texture slots are global across a draw's stages ([[texture(
/// ImageBindingBase + i)]]). Samplers live in a per-stage argument buffer
/// bound at <see cref="SamplerArgBufferIndex"/> (Metal caps direct
/// [[sampler(N)]] slots at 16 per stage, but shaders sample more), holding one
/// sampler per sampled image. <see cref="SamplerSlots"/> maps this stage's
/// image binding index to its [[id(N)]] entry in that argument buffer, -1 for
/// storage images that take none; <see cref="SamplerCount"/> is the entry
/// count.</para>
/// </remarks>
public sealed record Gen5MslShader(
string Source,
string EntryPoint,
Gen5MslStage Stage,
IReadOnlyList<Gen5GlobalMemoryBinding> GlobalMemoryBindings,
IReadOnlyList<Gen5ImageBinding> ImageBindings,
uint AttributeCount,
IReadOnlyList<Gen5VertexInputBinding> VertexInputs,
uint ThreadgroupSizeX = 1,
uint ThreadgroupSizeY = 1,
uint ThreadgroupSizeZ = 1,
int UniformsBufferIndex = -1,
int ImageBindingBase = 0,
IReadOnlyList<int>? SamplerSlots = null,
int SamplerCount = 0,
int SamplerArgBufferIndex = -1);
File diff suppressed because it is too large Load Diff
@@ -1,888 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Text;
using SharpEmu.ShaderCompiler;
namespace SharpEmu.ShaderCompiler.Metal;
public static partial class Gen5MslTranslator
{
private sealed partial class CompilationContext
{
private const uint ImageDescriptorDwords = 8;
private const uint SamplerDescriptorDwords = 4;
// ---- image resources ----
/// <summary>
/// Classifies every image binding (storage vs sampled, component kind
/// from the descriptor's unified format) and seeds the PC lookup,
/// mirroring DeclareImages on the SPIR-V side. MSL needs no format on
/// the texture type — only the component type and access.
/// </summary>
private void DeclareImageKinds()
{
for (var index = 0; index < _evaluation.ImageBindings.Count; index++)
{
var binding = _evaluation.ImageBindings[index];
_imageBindingByPc.TryAdd(binding.Pc, index);
var isStorage = Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode);
_imageKinds.Add((isStorage, DecodeImageComponentKind(binding.ResourceDescriptor)));
}
// Seed each binding's access from the opcode that defined it; the body
// emission (TryEmitImage) then ORs in the access of every instruction
// that resolves to the same binding, so a load and a store sharing one
// binding correctly become read_write.
_imageBindingReads = new bool[_imageKinds.Count];
_imageBindingWrites = new bool[_imageKinds.Count];
for (var index = 0; index < _evaluation.ImageBindings.Count; index++)
{
MarkImageBindingAccess(index, _evaluation.ImageBindings[index].Opcode);
}
// Assign one sampler per sampled image (storage images take none).
// Computed before body emission because the sample calls reference
// the slots. Samplers live in an argument buffer (see
// EmitImageArguments), so there is no 16-slot cap to dedup against —
// each image keeps its own sampler, matching the SPIR-V/Vulkan path.
_samplerSlots = new int[_imageKinds.Count];
_samplerCount = 0;
for (var index = 0; index < _imageKinds.Count; index++)
{
_samplerSlots[index] = _imageKinds[index].IsStorage ? -1 : _samplerCount++;
}
}
/// <summary>Records that <paramref name="opcode"/> reads and/or writes the
/// storage image at <paramref name="bindingIndex"/>, so EmitImageArguments
/// can pick the minimal Metal access qualifier.</summary>
private void MarkImageBindingAccess(int bindingIndex, string opcode)
{
if ((uint)bindingIndex >= (uint)_imageBindingReads.Length)
{
return;
}
if (opcode.StartsWith("ImageStore", StringComparison.Ordinal))
{
_imageBindingWrites[bindingIndex] = true;
}
else if (opcode.StartsWith("ImageAtomic", StringComparison.Ordinal))
{
_imageBindingReads[bindingIndex] = true;
_imageBindingWrites[bindingIndex] = true;
}
else
{
// ImageLoad/ImageLoadMip and ImageGetResinfo read the texture;
// sampled ops are non-storage and ignore these flags.
_imageBindingReads[bindingIndex] = true;
}
}
/// <summary>"float", "int", or "uint" from the descriptor's unified format.</summary>
private static string DecodeImageComponentKind(IReadOnlyList<uint> descriptor)
{
if (descriptor.Count < 2)
{
return "float";
}
var unifiedFormat = (descriptor[1] >> 20) & 0x1FFu;
if (!Gfx10UnifiedFormat.TryDecode(unifiedFormat, out _, out var numberType))
{
return "float";
}
return numberType switch
{
4 => "uint",
5 => "int",
_ => "float",
};
}
/// <summary>Per image binding: its sampler's [[id(N)]] inside the sampler
/// argument buffer, or -1 for storage images. Set by DeclareImageKinds.</summary>
private int[] _samplerSlots = [];
/// <summary>Number of sampled images (= sampler argument-buffer entries).</summary>
private int _samplerCount;
/// <summary>Buffer slot the sampler argument buffer binds to, past this
/// stage's global buffers, uniforms, and scalar-state buffer.</summary>
private int SamplerArgBufferIndex =>
Math.Max(UniformsBufferIndex, _initialScalarBufferIndex) + 1;
/// <summary>Emits the texture arguments (direct [[texture(N)]] slots, which
/// run to 31 — enough) plus, when the stage samples anything, the sampler
/// argument buffer. Samplers go through an argument buffer rather than
/// [[sampler(N)]] slots because Metal caps those at 16 per stage while
/// real shaders sample more (void Terrarium's scene shader: 17); argument
/// buffers have no such limit on Apple Silicon.</summary>
private void EmitImageArguments(StringBuilder source)
{
for (var index = 0; index < _imageKinds.Count; index++)
{
var (isStorage, kind) = _imageKinds[index];
var textureSlot = _imageBindingBase + index;
if (isStorage)
{
// Minimal access keeps read_write textures under Metal's cap
// of 8 per function: only images that are both read and
// written (or resolve a load and a store to one binding) need
// read_write; the rest are read-only or write-only.
var access = _imageBindingWrites[index]
? (_imageBindingReads[index] ? "read_write" : "write")
: "read";
source.AppendLine(
$" texture2d<{kind}, access::{access}> tex{index} [[texture({textureSlot})]],");
}
else
{
source.AppendLine($" texture2d<{kind}> tex{index} [[texture({textureSlot})]],");
}
}
if (_samplerCount > 0)
{
source.AppendLine(
$" constant Gen5Samplers& sharpemu_samplers [[buffer({SamplerArgBufferIndex})]],");
}
}
/// <summary>Declares the sampler argument-buffer struct at file scope (one
/// sampler per sampled image). Empty when the stage samples nothing.</summary>
private void EmitSamplerArgumentBufferStruct(StringBuilder source)
{
if (_samplerCount == 0)
{
return;
}
source.AppendLine("struct Gen5Samplers");
source.AppendLine("{");
for (var slot = 0; slot < _samplerCount; slot++)
{
source.AppendLine($" sampler smp{slot} [[id({slot})]];");
}
source.AppendLine("};");
source.AppendLine();
}
private bool TryResolveDominatingImageBinding(
Gen5ShaderInstruction instruction,
Gen5ImageControl control,
out int bindingIndex)
{
if (_imageBindingByPc.TryGetValue(instruction.Pc, out bindingIndex) &&
bindingIndex < _imageKinds.Count)
{
return true;
}
var storage = Gen5ShaderTranslator.IsStorageImageOperation(instruction.Opcode);
for (var index = 0; index < _evaluation.ImageBindings.Count; index++)
{
var candidate = _evaluation.ImageBindings[index];
if (candidate.Control.ScalarResource != control.ScalarResource ||
candidate.Control.ScalarSampler != control.ScalarSampler ||
Gen5ShaderTranslator.IsStorageImageOperation(candidate.Opcode) != storage ||
!HasSameScalarDefinitions(
candidate.Pc,
instruction.Pc,
control.ScalarResource,
ImageDescriptorDwords) ||
(UsesSampler(instruction.Opcode) &&
!HasSameScalarDefinitions(
candidate.Pc,
instruction.Pc,
control.ScalarSampler,
SamplerDescriptorDwords)))
{
continue;
}
bindingIndex = index;
_imageBindingByPc.Add(instruction.Pc, index);
return true;
}
bindingIndex = -1;
return false;
}
private bool HasSameScalarDefinitions(
uint candidatePc,
uint targetPc,
uint firstRegister,
uint registerCount)
{
if (firstRegister + registerCount > ScalarRegisterFileCount ||
!_scalarDefinitionsBeforePc.TryGetValue(candidatePc, out var candidate) ||
!_scalarDefinitionsBeforePc.TryGetValue(targetPc, out var target))
{
return false;
}
for (var register = firstRegister;
register < firstRegister + registerCount;
register++)
{
var definition = candidate[register];
if (definition is ConflictingScalarDefinition or UnreachableScalarDefinition ||
target[register] != definition)
{
return false;
}
}
return true;
}
private static bool UsesSampler(string opcode) =>
opcode.StartsWith("ImageSample", StringComparison.Ordinal) ||
opcode.StartsWith("ImageGather", StringComparison.Ordinal);
// ---- image instruction emission ----
private bool TryEmitImage(
Gen5ShaderInstruction instruction,
Gen5ImageControl image,
out string error)
{
error = string.Empty;
if (!TryResolveDominatingImageBinding(instruction, image, out var bindingIndex))
{
error = $"unresolved image binding t=s{image.ScalarResource} s=s{image.ScalarSampler}";
return false;
}
// The resolving instruction may differ from the one that defined the
// binding (a store can dominate a load's binding); fold its access in.
MarkImageBindingAccess(bindingIndex, instruction.Opcode);
var (isStorage, kind) = _imageKinds[bindingIndex];
var texture = $"tex{bindingIndex}";
if (instruction.Opcode == "ImageGetResinfo")
{
var width = Temp("uint", isStorage
? $"{texture}.get_width()"
: $"{texture}.get_width(0)");
var height = Temp("uint", isStorage
? $"{texture}.get_height()"
: $"{texture}.get_height(0)");
uint outputIndex = 0;
for (var component = 0; component < 4; component++)
{
if ((image.Dmask & (1u << component)) == 0)
{
continue;
}
StoreVector(
image.VectorData + outputIndex++,
component switch
{
0 => width,
1 => height,
_ => "1u",
});
}
return true;
}
if (instruction.Opcode is "ImageStore" or "ImageStoreMip")
{
if (!isStorage)
{
error = "image store is not bound as storage";
return false;
}
var x = Temp("int", $"as_type<int>({ImageIntegerAddress(image, 0)})");
var y = Temp("int", $"as_type<int>({ImageIntegerAddress(image, 1)})");
var components = new string[4];
uint sourceIndex = 0;
for (var component = 0; component < 4; component++)
{
components[component] = (image.Dmask & (1u << component)) != 0
? ImageTexelComponent(kind, ImageStoreComponent(image, kind, sourceIndex++))
: kind == "float" ? "0.0f" : "0";
}
// Bounds-checked, EXEC-guarded write.
Line($"if (exec && {x} >= 0 && {y} >= 0 && {x} < (int){texture}.get_width() && {y} < (int){texture}.get_height())");
Line("{");
_indent++;
Line($"{texture}.write({VectorLiteral(kind)}({components[0]}, {components[1]}, {components[2]}, {components[3]}), uint2((uint){x}, (uint){y}));");
_indent--;
Line("}");
return true;
}
if (isStorage && instruction.Opcode is not ("ImageLoad" or "ImageLoadMip"))
{
error = $"unsupported storage image opcode {instruction.Opcode}";
return false;
}
string sampled;
var writeAllComponents = false;
if (instruction.Opcode is "ImageLoad" or "ImageLoadMip")
{
var mip = _evaluation.ImageBindings[bindingIndex].MipLevel ?? 0;
var widthQuery = isStorage ? $"{texture}.get_width()" : $"{texture}.get_width({mip}u)";
var heightQuery = isStorage ? $"{texture}.get_height()" : $"{texture}.get_height({mip}u)";
var x = Temp(
"uint",
$"(uint)clamp(as_type<int>({ImageIntegerAddress(image, 0)}), 0, (int){widthQuery} - 1)");
var y = Temp(
"uint",
$"(uint)clamp(as_type<int>({ImageIntegerAddress(image, 1)}), 0, (int){heightQuery} - 1)");
sampled = Temp(
$"vec<{kind}, 4>",
isStorage
? $"{texture}.read(uint2({x}, {y}))"
: $"{texture}.read(uint2({x}, {y}), {mip}u)");
}
else if (instruction.Opcode.StartsWith("ImageSample", StringComparison.Ordinal))
{
if (!TryEmitImageSample(instruction, image, bindingIndex, kind, out sampled, out error))
{
return false;
}
}
else if (instruction.Opcode.StartsWith("ImageGather4", StringComparison.Ordinal))
{
if (!TryEmitImageGather(instruction, image, bindingIndex, kind, out sampled, out error))
{
return false;
}
writeAllComponents = true;
}
else
{
error = $"unsupported image opcode {instruction.Opcode}";
return false;
}
var outputValues = new List<string>(4);
for (var component = 0; component < 4; component++)
{
if (!writeAllComponents && (image.Dmask & (1u << component)) == 0)
{
continue;
}
var value = $"{sampled}[{component}]";
outputValues.Add(kind == "uint" ? value : AsUInt(value));
}
if (image.D16)
{
for (var index = 0; index < outputValues.Count; index += 2)
{
var low = outputValues[index];
var high = index + 1 < outputValues.Count ? outputValues[index + 1] : "0u";
StoreVector(
image.VectorData + (uint)(index / 2),
PackImageD16(kind, low, high));
}
}
else
{
for (var index = 0; index < outputValues.Count; index++)
{
StoreVector(image.VectorData + (uint)index, outputValues[index]);
}
}
return true;
}
private bool TryEmitImageSample(
Gen5ShaderInstruction instruction,
Gen5ImageControl image,
int bindingIndex,
string kind,
out string sampled,
out string error)
{
sampled = string.Empty;
error = string.Empty;
var opcode = instruction.Opcode;
var texture = $"tex{bindingIndex}";
var samplerName = $"sharpemu_samplers.smp{_samplerSlots[bindingIndex]}";
var hasOffset = opcode.EndsWith("O", StringComparison.Ordinal);
var hasCompare = opcode.Contains("SampleC", StringComparison.Ordinal);
var hasGradients = opcode.Contains("SampleD", StringComparison.Ordinal);
var hasZeroLod = opcode.Contains("Lz", StringComparison.Ordinal);
var hasLod = !hasZeroLod && opcode.Contains("SampleL", StringComparison.Ordinal);
var hasBias = opcode.Contains("SampleB", StringComparison.Ordinal);
// RDNA MIMG address operands are ordered
// {offset}{bias}{z-compare}{derivatives}{body}; SAMPLE_L carries LOD
// as the final body component instead.
var addressCursor = 0;
var offsetX = "0";
var offsetY = "0";
if (hasOffset)
{
addressCursor = AlignFullImageAddress(image, addressCursor);
var packed = Temp(
"int",
$"as_type<int>(v[{image.GetAddressRegister(ImageAddressRegister(image, addressCursor))}])");
offsetX = Temp("int", $"extract_bits({packed}, 0u, 6u)");
offsetY = Temp("int", $"extract_bits({packed}, 8u, 6u)");
addressCursor += ImageFullAddressSlots(image);
}
var bias = hasBias ? Temp("float", ImageFloatAddress(image, addressCursor++)) : "0.0f";
var reference = "0.0f";
if (hasCompare)
{
addressCursor = AlignFullImageAddress(image, addressCursor);
reference = Temp(
"float",
$"as_type<float>(v[{image.GetAddressRegister(ImageAddressRegister(image, addressCursor))}])");
addressCursor += ImageFullAddressSlots(image);
}
var gradientX = "float2(0.0f)";
var gradientY = "float2(0.0f)";
if (hasGradients)
{
gradientX = Temp(
"float2",
$"float2({ImageFloatAddress(image, addressCursor)}, {ImageFloatAddress(image, addressCursor + 1)})");
gradientY = Temp(
"float2",
$"float2({ImageFloatAddress(image, addressCursor + 2)}, {ImageFloatAddress(image, addressCursor + 3)})");
addressCursor += 4;
}
var coordinates = Temp(
"float2",
$"float2({ImageFloatAddress(image, addressCursor)}, {ImageFloatAddress(image, addressCursor + 1)})");
var lod = hasZeroLod
? "0.0f"
: hasLod
? Temp("float", ImageFloatAddress(image, addressCursor + 2))
: bias;
if (hasOffset)
{
// Per-lane texel offsets fold into normalized coordinates using
// the selected mip extent, mirroring the SPIR-V translator
// (Metal sample offsets must be compile-time constants).
var explicitLod = hasGradients || hasZeroLod || hasLod;
var offsetLod = explicitLod && !hasGradients ? lod : "0.0f";
var mipLevel = Temp("uint", $"(uint)max((int)({offsetLod}), 0)");
coordinates = Temp(
"float2",
$"{coordinates} + float2((float){offsetX} / (float){texture}.get_width({mipLevel}), " +
$"(float){offsetY} / (float){texture}.get_height({mipLevel}))");
}
var samplerArguments = hasGradients
? $", gradient2d({gradientX}, {gradientY})"
: hasZeroLod || hasLod
? $", level({lod})"
: hasBias
? $", bias({bias})"
: string.Empty;
sampled = Temp(
$"vec<{kind}, 4>",
$"{texture}.sample({samplerName}, {coordinates}{samplerArguments})");
if (hasCompare)
{
// Manual PCF: reference passes when <= texel, broadcast (r,r,r,1).
var passes = Temp("bool", $"{reference} <= (float){sampled}[0]");
var one = kind == "float" ? "1.0f" : "1";
var zero = kind == "float" ? "0.0f" : "0";
sampled = Temp(
$"vec<{kind}, 4>",
$"{VectorLiteral(kind)}({passes} ? {one} : {zero}, {passes} ? {one} : {zero}, {passes} ? {one} : {zero}, {one})");
}
return true;
}
private bool TryEmitImageGather(
Gen5ShaderInstruction instruction,
Gen5ImageControl image,
int bindingIndex,
string kind,
out string sampled,
out string error)
{
sampled = string.Empty;
error = string.Empty;
var opcode = instruction.Opcode;
var texture = $"tex{bindingIndex}";
var samplerName = $"sharpemu_samplers.smp{_samplerSlots[bindingIndex]}";
var hasOffset = opcode.EndsWith("O", StringComparison.Ordinal);
var hasCompare = opcode.Contains("Gather4C", StringComparison.Ordinal);
var addressCursor = 0;
var offset = "int2(0)";
if (hasOffset)
{
var packed = Temp(
"int",
$"as_type<int>(v[{image.GetAddressRegister(ImageAddressRegister(image, addressCursor))}])");
offset = Temp(
"int2",
$"int2(extract_bits({packed}, 0u, 6u), extract_bits({packed}, 8u, 6u))");
addressCursor += ImageFullAddressSlots(image);
}
var reference = "0.0f";
if (hasCompare)
{
addressCursor = AlignFullImageAddress(image, addressCursor);
reference = Temp(
"float",
$"as_type<float>(v[{image.GetAddressRegister(ImageAddressRegister(image, addressCursor))}])");
addressCursor += ImageFullAddressSlots(image);
}
var coordinates = Temp(
"float2",
$"float2({ImageFloatAddress(image, addressCursor)}, {ImageFloatAddress(image, addressCursor + 1)})");
// The gathered component is selected from the first dmask bit.
uint component = 0;
while (component < 3 && (image.Dmask & (1u << (int)component)) == 0)
{
component++;
}
var componentName = hasCompare ? "x" : component switch
{
0 => "x",
1 => "y",
2 => "z",
_ => "w",
};
sampled = Temp(
$"vec<{kind}, 4>",
$"{texture}.gather({samplerName}, {coordinates}, {offset}, component::{componentName})");
if (hasCompare)
{
var one = kind == "float" ? "1.0f" : "1";
var zero = kind == "float" ? "0.0f" : "0";
var compared = Temp(
$"vec<{kind}, 4>",
$"{VectorLiteral(kind)}(" +
$"{reference} <= (float){sampled}[0] ? {one} : {zero}, " +
$"{reference} <= (float){sampled}[1] ? {one} : {zero}, " +
$"{reference} <= (float){sampled}[2] ? {one} : {zero}, " +
$"{reference} <= (float){sampled}[3] ? {one} : {zero})");
sampled = compared;
}
return true;
}
private static string VectorLiteral(string kind) => $"vec<{kind}, 4>";
private static int ImageAddressRegister(Gen5ImageControl image, int component) =>
image.A16 ? component / 2 : component;
private static int ImageFullAddressSlots(Gen5ImageControl image) =>
image.A16 ? 2 : 1;
private static int AlignFullImageAddress(Gen5ImageControl image, int component) =>
image.A16 ? (component + 1) & ~1 : component;
/// <summary>Float address component, unpacking A16 half pairs.</summary>
private string ImageFloatAddress(Gen5ImageControl image, int component)
{
var register = image.GetAddressRegister(ImageAddressRegister(image, component));
return image.A16
? $"(float)as_type<half2>(v[{register}])[{component & 1}]"
: $"as_type<float>(v[{register}])";
}
/// <summary>Integer address component, unpacking A16 16-bit pairs.</summary>
private string ImageIntegerAddress(Gen5ImageControl image, int component)
{
var register = image.GetAddressRegister(ImageAddressRegister(image, component));
return image.A16
? $"((v[{register}] >> {(component & 1) * 16}) & 0xFFFFu)"
: $"v[{register}]";
}
/// <summary>One store-source component, unpacking D16 halves.</summary>
private string ImageStoreComponent(Gen5ImageControl image, string kind, uint component)
{
if (!image.D16)
{
return $"v[{image.VectorData + component}]";
}
var packed = $"v[{image.VectorData + (component / 2)}]";
if (kind == "float")
{
return AsUInt($"(float)as_type<half2>({packed})[{component & 1}]");
}
var low = $"(({packed} >> {(component & 1) * 16}) & 0xFFFFu)";
return kind == "int"
? $"(uint)extract_bits(as_type<int>({low}), 0u, 16u)"
: low;
}
private static string ImageTexelComponent(string kind, string raw) => kind switch
{
"int" => $"as_type<int>({raw})",
"uint" => raw,
_ => $"as_type<float>({raw})",
};
private string PackImageD16(string kind, string low, string high)
{
if (kind == "float")
{
return $"(((uint)as_type<ushort>(half(as_type<float>({low})))) | (((uint)as_type<ushort>(half(as_type<float>({high})))) << 16))";
}
return $"((({low}) & 0xFFFFu) | ((({high}) & 0xFFFFu) << 16))";
}
// ---- exports ----
private bool TryEmitExport(
Gen5ShaderInstruction instruction,
Gen5ExportControl export,
out string error)
{
error = string.Empty;
if (instruction.Sources.Count < 4)
{
error = "missing export sources";
return false;
}
if (_stage == Gen5MslStage.Vertex)
{
return TryEmitVertexExport(instruction, export);
}
if (_stage != Gen5MslStage.Pixel)
{
// Compute programs have no export interface.
return true;
}
Gen5PixelOutputBinding? binding = null;
foreach (var candidate in _pixelOutputBindings)
{
if (candidate.GuestSlot == export.Target)
{
binding = candidate;
break;
}
}
if (binding is null)
{
return true;
}
var field = $"sharpemu_out.mrt{binding.Value.GuestSlot}";
var componentType = binding.Value.Kind switch
{
Gen5PixelOutputKind.Uint => "uint",
Gen5PixelOutputKind.Sint => "int",
_ => "float",
};
var values = new string[4];
for (var component = 0; component < 4; component++)
{
if ((export.EnableMask & (1u << component)) == 0)
{
values[component] = $"{field}[{component}]";
continue;
}
if (export.Compressed)
{
var packed = $"v[{instruction.Sources[component >> 1].Value}]";
var half = $"(float)as_type<half2>({packed})[{component & 1}]";
values[component] = binding.Value.Kind switch
{
Gen5PixelOutputKind.Uint => $"(uint)({half})",
Gen5PixelOutputKind.Sint => $"(int)({half})",
_ => half,
};
continue;
}
var raw = $"v[{instruction.Sources[component].Value}]";
values[component] = binding.Value.Kind switch
{
Gen5PixelOutputKind.Uint => raw,
Gen5PixelOutputKind.Sint => $"as_type<int>({raw})",
_ => $"as_type<float>({raw})",
};
}
// A lane removed from EXEC keeps the previous output value; killed
// fragments are discarded in the epilogue.
Line($"{field} = exec ? vec<{componentType}, 4>({values[0]}, {values[1]}, {values[2]}, {values[3]}) : {field};");
return true;
}
private bool TryEmitVertexExport(
Gen5ShaderInstruction instruction,
Gen5ExportControl export)
{
// Target 12 is POS0; 32..63 are the param outputs. Everything else
// (other position slots, MRTZ) is ignored like the SPIR-V side.
string field;
if (export.Target == 12)
{
field = "sharpemu_out.sharpemu_position";
}
else if (export.Target is >= 32 and < 64 &&
_vertexOutputs.Contains(export.Target - 32))
{
field = $"sharpemu_out.param{export.Target - 32}";
}
else
{
return true;
}
var values = new string[4];
for (var component = 0; component < 4; component++)
{
if ((export.EnableMask & (1u << component)) == 0)
{
values[component] = component == 3 ? "1.0f" : "0.0f";
continue;
}
if (export.Compressed)
{
var packed = $"v[{instruction.Sources[component >> 1].Value}]";
values[component] = $"(float)as_type<half2>({packed})[{component & 1}]";
continue;
}
values[component] = $"as_type<float>(v[{instruction.Sources[component].Value}])";
}
Line($"{field} = exec ? float4({values[0]}, {values[1]}, {values[2]}, {values[3]}) : {field};");
return true;
}
/// <summary>
/// Vertex attribute fetch: the evaluator captured this buffer load as a
/// fixed-function vertex input, so read the stage_in field instead of
/// guest memory (bound via MTLVertexDescriptor by the backend).
/// </summary>
private bool TryEmitVertexInputFetch(
Gen5BufferMemoryControl control,
Gen5VertexInputBinding input,
out string error)
{
error = string.Empty;
if (control.DwordCount == 0 || control.DwordCount > input.ComponentCount)
{
error =
$"invalid vertex input fetch components={control.DwordCount} " +
$"input={input.ComponentCount}";
return false;
}
for (uint component = 0; component < control.DwordCount; component++)
{
var value = input.ComponentCount == 1
? $"sharpemu_vin.in{input.Location}"
: $"sharpemu_vin.in{input.Location}[{component}]";
StoreVector(control.VectorData + component, AsUInt(value));
}
return true;
}
// ---- interpolation / pixel inputs ----
private bool TryEmitInterpolation(
Gen5ShaderInstruction instruction,
Gen5InterpolationControl interpolation,
out string error)
{
error = string.Empty;
if (_stage != Gen5MslStage.Pixel ||
!_pixelAttributes.Contains(interpolation.Attribute) ||
instruction.Destinations.Count == 0 ||
instruction.Destinations[0].Kind != Gen5OperandKind.VectorRegister)
{
error = "invalid interpolated attribute";
return false;
}
StoreVector(
instruction.Destinations[0].Value,
AsUInt($"sharpemu_in.attr{interpolation.Attribute}[{interpolation.Channel}]"));
return true;
}
/// <summary>
/// Seeds pixel input VGPRs in SPI_PS_INPUT_ADDR compact order: the
/// interpolation slots reserve registers even though V_INTERP reads MSL
/// varyings directly, and the position inputs land in the
/// hardware-selected VGPRs from the fragment coordinate.
/// </summary>
private void EmitPixelInputState(StringBuilder source)
{
uint vgpr = 0;
void Advance(int bit, uint dwordCount)
{
if ((_pixelInputAddress & (1u << bit)) != 0)
{
vgpr += dwordCount;
}
}
void Position(int bit, string component)
{
var mask = 1u << bit;
if ((_pixelInputAddress & mask) == 0)
{
return;
}
if ((_pixelInputEnable & mask) != 0)
{
source.AppendLine(
$" v[{vgpr}] = as_type<uint>(sharpemu_in.sharpemu_frag_coord.{component});");
}
vgpr++;
}
Advance(0, 2); // PERSP_SAMPLE
Advance(1, 2); // PERSP_CENTER
Advance(2, 2); // PERSP_CENTROID
Advance(3, 3); // PERSP_PULL_MODEL
Advance(4, 2); // LINEAR_SAMPLE
Advance(5, 2); // LINEAR_CENTER
Advance(6, 2); // LINEAR_CENTROID
Advance(7, 1); // LINE_STIPPLE
Position(8, "x");
Position(9, "y");
Position(10, "z");
Position(11, "w");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,81 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Globalization;
using System.Text;
namespace SharpEmu.ShaderCompiler.Metal;
/// <summary>
/// The fixed presenter shaders, mirroring SpirvFixedShaders semantically. The
/// MSL lives in Templates/*.msl (authored as real Metal source); this class
/// only substitutes the per-call parameters. Entry point names are stable
/// (Metal forbids "main"); textures and samplers bind at index 0; attributes
/// use the same user(locn) convention as the translated stages.
/// </summary>
public static class MslFixedShaders
{
/// <summary>
/// Fullscreen triangle from the vertex index; every attribute location in
/// 0..attributeCount-1 carries (x, y, 0, 1) so paired fragment stages can
/// read a screen-space UV from any location.
/// </summary>
public static string CreateFullscreenVertex(uint attributeCount)
{
var fields = new StringBuilder();
var stores = new StringBuilder();
for (uint index = 0; index < attributeCount; index++)
{
if (index != 0)
{
fields.AppendLine();
stores.AppendLine();
}
fields.Append($" float4 attr{index} [[user(locn{index})]];");
stores.Append($" out.attr{index} = float4(x, y, 0.0f, 1.0f);");
}
return MslTemplates.Render(
"fullscreen_vertex",
("attribute_fields", fields.ToString()),
("attribute_stores", stores.ToString()));
}
/// <summary>Samples texture 0 at the interpolated location-0 UV.</summary>
public static string CreateCopyFragment() => MslTemplates.Render("copy_fragment");
/// <summary>
/// The presenter's blit stage: samples texture 0 with V flipped, because
/// pairing the shared fullscreen triangle with Metal's y-up NDC puts UV
/// (0,0) at the bottom of the screen while textures keep v=0 at the top.
/// </summary>
public static string CreatePresentFragment() => MslTemplates.Render("present_fragment");
public static string CreateSolidFragment(float red, float green, float blue, float alpha) =>
MslTemplates.Render(
"solid_fragment",
("red", Format(red)),
("green", Format(green)),
("blue", Format(blue)),
("alpha", Format(alpha)));
/// <summary>
/// Diagnostic fragment stage exposing one interpolated vertex output
/// directly as color, isolating fragment translation from interface data.
/// </summary>
public static string CreateAttributeFragment(uint location) =>
MslTemplates.Render(
"attribute_fragment",
("location", location.ToString(CultureInfo.InvariantCulture)));
/// <summary>
/// Output-free fragment stage for fixed-function depth-only passes: the
/// guest has no pixel shader, so no color may be written while depth
/// testing still runs for the translated vertex shader.
/// </summary>
public static string CreateDepthOnlyFragment() => MslTemplates.Render("depth_only_fragment");
private static string Format(float value) =>
value.ToString("0.0######", CultureInfo.InvariantCulture) + "f";
}
@@ -1,55 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Concurrent;
using System.Text;
namespace SharpEmu.ShaderCompiler.Metal;
/// <summary>
/// Loads the static MSL blocks from embedded Templates/*.msl resources and
/// substitutes {{placeholder}} tokens. The static prelude and fixed shaders
/// are authored as real Metal source files; only the per-instruction body
/// emission stays programmatic in the translator.
/// </summary>
internal static class MslTemplates
{
private static readonly ConcurrentDictionary<string, string> _cache = new(StringComparer.Ordinal);
public static string Render(string name, params (string Key, string Value)[] substitutions)
{
var template = _cache.GetOrAdd(name, Load);
if (substitutions.Length == 0)
{
return template;
}
var builder = new StringBuilder(template);
foreach (var (key, value) in substitutions)
{
builder.Replace("{{" + key + "}}", value);
}
var rendered = builder.ToString();
var marker = rendered.IndexOf("{{", StringComparison.Ordinal);
if (marker >= 0)
{
var end = rendered.IndexOf("}}", marker, StringComparison.Ordinal);
var token = end > marker ? rendered[marker..(end + 2)] : "{{...";
throw new InvalidOperationException(
$"template '{name}' has an unsubstituted placeholder {token}");
}
return rendered;
}
private static string Load(string name)
{
var assembly = typeof(MslTemplates).Assembly;
var resourceName = $"SharpEmu.ShaderCompiler.Metal.Templates.{name}.msl";
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"missing embedded MSL template {resourceName}");
using var reader = new StreamReader(stream, Encoding.UTF8);
return reader.ReadToEnd();
}
}
@@ -1,26 +0,0 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
<Project Sdk="Microsoft.NET.Sdk">
<!-- The Metal codegen backend: consumes the backend-neutral shader IR from
SharpEmu.ShaderCompiler and emits Metal Shading Language source text.
Deliberately has no dependency on Metal bindings — emitters produce text;
renderers own APIs (the Metal backend compiles the source via MTLLibrary). -->
<PropertyGroup>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SharpEmu.ShaderCompiler\SharpEmu.ShaderCompiler.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Static MSL blocks (prelude helpers, fixed shaders) authored as real
Metal source; the emitter renders them with placeholder substitution. -->
<EmbeddedResource Include="Templates\**\*.msl" />
</ItemGroup>
</Project>
@@ -1,13 +0,0 @@
#include <metal_stdlib>
using namespace metal;
struct AttributeIn
{
float4 attr{{location}} [[user(locn{{location}})]];
};
fragment float4 attribute_fs(AttributeIn in [[stage_in]])
{
return in.attr{{location}};
}
@@ -1,16 +0,0 @@
#include <metal_stdlib>
using namespace metal;
struct CopyIn
{
float4 attr0 [[user(locn0)]];
};
fragment float4 copy_fs(
CopyIn in [[stage_in]],
texture2d<float> tex0 [[texture(0)]],
sampler smp0 [[sampler(0)]])
{
return tex0.sample(smp0, in.attr0.xy);
}
@@ -1,7 +0,0 @@
#include <metal_stdlib>
using namespace metal;
fragment void depth_only_fs()
{
}
@@ -1,68 +0,0 @@
static constant uint sharpemu_gfx10_formats[128] = {
{{format_table}}
};
static inline void sharpemu_format_layout(uint dfmt, uint component, thread uint& byteOff, thread uint& bitOff, thread uint& bits)
{
byteOff = 0u; bitOff = 0u; bits = 0u;
switch (component * 16u + dfmt)
{
{{layout_cases}}
default: break;
}
}
static inline uint sharpemu_minifloat(uint raw, uint bits)
{
uint mantissaBits = bits - 5u;
uint mantissa = raw & ((1u << mantissaBits) - 1u);
uint exponent = (raw >> mantissaBits) & 0x1Fu;
uint shift = 23u - mantissaBits;
if (exponent == 31u)
{
return 0x7F800000u | (mantissa << shift);
}
if (exponent == 0u)
{
float scale = mantissaBits == 6u ? (1.0f / 1048576.0f) : (1.0f / 524288.0f);
return as_type<uint>((float)mantissa * scale);
}
return ((exponent + 112u) << 23) | (mantissa << shift);
}
static inline uint sharpemu_format_one(uint nfmt)
{
return (nfmt == 4u || nfmt == 5u) ? 1u : 0x3F800000u;
}
static inline uint sharpemu_format_convert(uint raw, uint bits, uint nfmt, uint dfmt)
{
uint lowMask = bits >= 32u ? 0xFFFFFFFFu : ((1u << bits) - 1u);
int signedRaw = extract_bits(as_type<int>(raw), 0u, bits);
switch (nfmt)
{
case 0u: return as_type<uint>((float)raw / (float)lowMask);
case 1u:
{
float snorm = (float)signedRaw / (float)(lowMask >> 1);
return as_type<uint>(fmax(snorm, -1.0f));
}
case 2u: return as_type<uint>((float)raw);
case 3u: return as_type<uint>((float)signedRaw);
case 5u: return (uint)signedRaw;
case 7u:
{
// 10_11_11/11_11_10 packed floats are unsigned mini-floats.
if (dfmt == 6u || dfmt == 7u)
{
return sharpemu_minifloat(raw, bits);
}
if (bits == 16u)
{
return as_type<uint>((float)as_type<half>((ushort)(raw & 0xFFFFu)));
}
return raw;
}
default: return raw;
}
}
@@ -1,19 +0,0 @@
#include <metal_stdlib>
using namespace metal;
struct FullscreenOut
{
float4 position [[position]];
{{attribute_fields}}
};
vertex FullscreenOut fullscreen_vs(uint vertex_id [[vertex_id]])
{
float x = (float)((vertex_id << 1) & 2u);
float y = (float)(vertex_id & 2u);
FullscreenOut out = {};
out.position = float4(x * 2.0f - 1.0f, y * 2.0f - 1.0f, 0.0f, 1.0f);
{{attribute_stores}}
return out;
}
@@ -1,59 +0,0 @@
static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr)
{
if ((addr & 3u) == 0u)
{
return addr + 4u <= bytes ? b[addr >> 2] : 0u;
}
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < 4u; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
return value;
}
static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend)
{
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
if (signExtend && count < 4u)
{
uint shift = 32u - (count * 8u);
value = (uint)(((int)(value << shift)) >> shift);
}
return value;
}
static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count)
{
device uchar* p = (device uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu);
}
}
}
static inline uint sharpemu_ballot(bool value)
{
return {{ballot_return}};
}
static constant float sharpemu_off_i4_table[16] =
{
0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f,
-0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f,
};
@@ -1,16 +0,0 @@
#include <metal_stdlib>
using namespace metal;
struct PresentIn
{
float4 attr0 [[user(locn0)]];
};
fragment float4 present_fs(
PresentIn in [[stage_in]],
texture2d<float> tex0 [[texture(0)]],
sampler smp0 [[sampler(0)]])
{
return tex0.sample(smp0, float2(in.attr0.x, 1.0f - in.attr0.y));
}
@@ -1,8 +0,0 @@
#include <metal_stdlib>
using namespace metal;
fragment float4 solid_fs()
{
return float4({{red}}, {{green}}, {{blue}}, {{alpha}});
}
@@ -953,13 +953,22 @@ public static partial class Gen5SpirvTranslator
case "VPkMulF16": case "VPkMulF16":
case "VPkMinF16": case "VPkMinF16":
case "VPkMaxF16": case "VPkMaxF16":
case "VPkFmaF16":
if (!TryEmitPackedF16(instruction, out result, out error)) if (!TryEmitPackedF16(instruction, out result, out error))
{ {
return false; return false;
} }
break; break;
case "VPkFmaF16":
// Deliberately loud: a fused f16 FMA rounds the product+add once,
// whereas doing the multiply-add in f32 and rounding to f16 at the
// end double-rounds. Concrete miss: fma(0x4100, 0x7522, 0x04EA) is
// 0x7A6B fused but 0x7A6A via f32. Exact emulation (round-to-odd
// f32 product then RNE pack) is a planned follow-up slice.
error =
$"unsupported vop3p opcode {instruction.Opcode} " +
"(fused f16 FMA requires single-rounding; deferred to a later slice)";
return false;
default: default:
error = $"unsupported vector opcode {instruction.Opcode}"; error = $"unsupported vector opcode {instruction.Opcode}";
return false; return false;
@@ -999,9 +1008,8 @@ public static partial class Gen5SpirvTranslator
// even. For add and mul this is bit-exact to a true f16 op (the f32 result // even. For add and mul this is bit-exact to a true f16 op (the f32 result
// rounds losslessly to f16 by the double-rounding theorem; a f16 product even // rounds losslessly to f16 by the double-rounding theorem; a f16 product even
// fits in f32 exactly). min/max carry no rounding, so they are exact once the // fits in f32 exactly). min/max carry no rounding, so they are exact once the
// conversions are. v_pk_fma_f16 cannot be reproduced by a plain f32 // conversions are. v_pk_fma_f16 is intentionally not routed here because a
// multiply-add plus a pack (that double-rounds), so it goes through the // fused f16 FMA cannot be reproduced by an f32 multiply-add plus a pack.
// round-to-odd sequence in EmitPackedF16FusedMultiplyAdd instead.
private bool TryEmitPackedF16( private bool TryEmitPackedF16(
Gen5ShaderInstruction instruction, Gen5ShaderInstruction instruction,
out uint result, out uint result,
@@ -1021,8 +1029,7 @@ public static partial class Gen5SpirvTranslator
return false; return false;
} }
var sourceCount = instruction.Opcode == "VPkFmaF16" ? 3 : 2; for (var index = 0; index < 2; index++)
for (var index = 0; index < sourceCount; index++)
{ {
var source = instruction.Sources[index]; var source = instruction.Sources[index];
if (source.Kind is not (Gen5OperandKind.VectorRegister or Gen5OperandKind.ScalarRegister)) if (source.Kind is not (Gen5OperandKind.VectorRegister or Gen5OperandKind.ScalarRegister))
@@ -1047,12 +1054,6 @@ public static partial class Gen5SpirvTranslator
{ {
var left = EmitPackedF16Operand(instruction, control, 0, highLane); var left = EmitPackedF16Operand(instruction, control, 0, highLane);
var right = EmitPackedF16Operand(instruction, control, 1, highLane); var right = EmitPackedF16Operand(instruction, control, 1, highLane);
if (instruction.Opcode == "VPkFmaF16")
{
var addend = EmitPackedF16Operand(instruction, control, 2, highLane);
return EmitFloatToHalf(EmitPackedF16FusedMultiplyAdd(left, right, addend));
}
var value = instruction.Opcode switch var value = instruction.Opcode switch
{ {
"VPkAddF16" => _module.AddInstruction(SpirvOp.FAdd, _floatType, left, right), "VPkAddF16" => _module.AddInstruction(SpirvOp.FAdd, _floatType, left, right),
@@ -1064,75 +1065,6 @@ public static partial class Gen5SpirvTranslator
return EmitFloatToHalf(Bitcast(_uintType, value)); return EmitFloatToHalf(Bitcast(_uintType, value));
} }
// Fused f16 multiply-add with a single rounding, emulated in f32 without the
// Float16 capability. The f32 product of two widened f16 values is exact
// (11-bit significands, and the exponent stays inside the f32 normal range:
// any non-zero product magnitude is in [2^-48, 2^33]), so only the addition
// rounds. An f32 add then an f16 pack would round twice; instead the add is
// corrected to round-to-odd, which a following round-to-nearest-even pack
// turns into the exactly-once-rounded fused result (innocuous double rounding
// holds because f32 carries 24 significand bits >= 11 + 2).
//
// sum = RN(product + addend); Knuth's 2Sum recovers the exact residual
// (product + addend) - sum from four more RN ops. 2Sum is exact for any two
// finite f32 inputs; no intermediate here can overflow (|product| < 2^33,
// |addend| < 2^16) and none can enter the f32 subnormal range (every finite
// value in play is a multiple of 2^-48 by construction), so implementation
// f32 denorm-flush modes never see a denormal. If the residual says the sum
// was inexact and the sum's significand is even, step one ulp towards the
// true value: consecutive floats have consecutive sign-magnitude encodings,
// so that neighbour is the enclosing float with the odd significand.
//
// Inf/NaN inputs make the residual NaN (e.g. sum - addend = Inf - Inf); the
// ordered compare below is then false and the IEEE sum passes through
// unchanged. A residual of zero also covers the exact-sum case, where the
// parity fix must not fire. Returns the round-to-odd f32 bit pattern.
private uint EmitPackedF16FusedMultiplyAdd(uint left, uint right, uint addend)
{
var product = EmitPreciseFloat(SpirvOp.FMul, left, right);
var sum = EmitPreciseFloat(SpirvOp.FAdd, product, addend);
var productPart = EmitPreciseFloat(SpirvOp.FSub, sum, addend);
var addendPart = EmitPreciseFloat(SpirvOp.FSub, sum, productPart);
var productError = EmitPreciseFloat(SpirvOp.FSub, product, productPart);
var addendError = EmitPreciseFloat(SpirvOp.FSub, addend, addendPart);
var residual = EmitPreciseFloat(SpirvOp.FAdd, productError, addendError);
var sumBits = Bitcast(_uintType, sum);
var residualBits = Bitcast(_uintType, residual);
var inexact = _module.AddInstruction(
SpirvOp.FOrdNotEqual, _boolType, residual, Float(0));
var evenSignificand = Equal(BitwiseAnd(sumBits, UInt(1)), 0);
var adjust = _module.AddInstruction(
SpirvOp.LogicalAnd, _boolType, inexact, evenSignificand);
// Residual sign relative to the sum picks the step direction: same sign
// means the true value lies away from zero (encoding + 1), opposite sign
// means towards zero (encoding - 1). The sum cannot be zero here (any
// inexact sum has magnitude >= 2^-48) and cannot be the largest finite
// value (its significand is odd), so the step never crosses zero or Inf.
var towardZero = IsNotZero(
BitwiseAnd(BitwiseXor(sumBits, residualBits), UInt(0x8000_0000)));
var stepped = SelectU(
towardZero,
ISubU(sumBits, UInt(1)),
IAdd(sumBits, UInt(1)));
return SelectU(adjust, stepped, sumBits);
}
// A float op the driver must evaluate exactly as written. The 2Sum
// residual above is error-free only op by op; without NoContraction
// driver compilers fold the sequence (e.g. contract product+sum into an
// f32 fma and simplify the rebuilt terms), collapsing the residual to
// zero. Observed on AMD RDNA3 Windows: the pinned midpoint case decays
// to the double-rounded result unless every op in the chain is marked.
private uint EmitPreciseFloat(SpirvOp operation, uint left, uint right)
{
var value = _module.AddInstruction(operation, _floatType, left, right);
_module.AddDecoration(value, SpirvDecoration.NoContraction);
return value;
}
// Reads source `index`, selects the half feeding this lane (op_sel / op_sel_hi), // Reads source `index`, selects the half feeding this lane (op_sel / op_sel_hi),
// widens it exactly to f32 and applies the lane's negate modifier (neg_lo / neg_hi). // widens it exactly to f32 and applies the lane's negate modifier (neg_lo / neg_hi).
private uint EmitPackedF16Operand( private uint EmitPackedF16Operand(
@@ -1754,10 +1754,6 @@ public static partial class Gen5SpirvTranslator
"SWaitcnt" or "SWaitcnt" or
"SInstPrefetch" or "SInstPrefetch" or
"STtraceData" or "STtraceData" or
// NGG shaders bracket their exports with s_sendmsg
// (GS_ALLOC_REQ/DEALLOC) to reserve hardware export space;
// exports are translated directly, so the message is moot.
"SSendmsg" or
"VInterpMovF32") "VInterpMovF32")
{ {
return true; return true;
@@ -238,7 +238,6 @@ public enum SpirvDecoration : uint
Binding = 33, Binding = 33,
DescriptorSet = 34, DescriptorSet = 34,
Offset = 35, Offset = 35,
NoContraction = 42,
} }
public enum SpirvBuiltIn : uint public enum SpirvBuiltIn : uint
@@ -2350,8 +2350,7 @@ public static class Gen5ShaderScalarEvaluator
private static bool TryReadUInt32(CpuContext ctx, ulong address, out uint value) private static bool TryReadUInt32(CpuContext ctx, ulong address, out uint value)
{ {
Span<byte> bytes = stackalloc byte[sizeof(uint)]; Span<byte> bytes = stackalloc byte[sizeof(uint)];
if (!ctx.Memory.TryRead(address, bytes) && if (!ctx.Memory.TryRead(address, bytes))
FallbackMemoryReader?.Invoke(address, bytes) != true)
{ {
value = 0; value = 0;
return false; return false;
@@ -1,99 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.ShaderCompiler;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
public sealed class Gen5ScalarMemoryFallbackTests
{
private const ulong ScalarTableAddress = 0x4_4665_4FD0;
private static readonly object FallbackReaderGate = new();
[Fact]
public void ScalarLoadReadsTrackedFallbackMemory()
{
var expected = new uint[]
{
0x4665_4F70,
0x0000_0004,
0x4EA7_FCE0,
0x0000_0004,
};
var table = new byte[expected.Length * sizeof(uint)];
for (var index = 0; index < expected.Length; index++)
{
BinaryPrimitives.WriteUInt32LittleEndian(
table.AsSpan(index * sizeof(uint), sizeof(uint)),
expected[index]);
}
var load = new Gen5ShaderInstruction(
0,
Gen5ShaderEncoding.Smem,
"SLoadDwordx4",
[],
[Gen5Operand.Scalar(0)],
[
Gen5Operand.Scalar(16),
Gen5Operand.Scalar(17),
Gen5Operand.Scalar(18),
Gen5Operand.Scalar(19),
],
new Gen5ScalarMemoryControl(4, 0, null));
var end = new Gen5ShaderInstruction(
8,
Gen5ShaderEncoding.Sopp,
"SEndpgm",
[],
[],
[],
null);
var state = new Gen5ShaderState(
new Gen5ShaderProgram(0, [load, end]),
[unchecked((uint)ScalarTableAddress), (uint)(ScalarTableAddress >> 32)],
null);
var ctx = new CpuContext(new FakeCpuMemory(0x1000, 0x100), Generation.Gen5);
lock (FallbackReaderGate)
{
var previousReader = Gen5ShaderScalarEvaluator.FallbackMemoryReader;
try
{
Gen5ShaderScalarEvaluator.FallbackMemoryReader = ReadFallback;
Assert.True(
Gen5ShaderScalarEvaluator.TryEvaluate(
ctx,
state,
out var evaluation,
out var error),
error);
Assert.Equal(expected, evaluation.ScalarRegisters.Skip(16).Take(4));
}
finally
{
Gen5ShaderScalarEvaluator.FallbackMemoryReader = previousReader;
}
}
bool ReadFallback(ulong address, Span<byte> destination)
{
if (address < ScalarTableAddress)
{
return false;
}
var offset = address - ScalarTableAddress;
if (offset + (ulong)destination.Length > (ulong)table.Length)
{
return false;
}
table.AsSpan((int)offset, destination.Length).CopyTo(destination);
return true;
}
}
}
@@ -1,26 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Cpu.Native;
using Xunit;
namespace SharpEmu.Libs.Tests.Cpu;
public sealed unsafe class JitStubsTests
{
[Fact]
public void FindTlsAccessPatterns_IncludesLastValidOffset()
{
var pattern = JitStubs.TlsAccessPattern;
var code = new byte[pattern.Length + 3];
pattern.CopyTo(code.AsSpan(3));
fixed (byte* codePointer = code)
{
var matches = JitStubs.FindTlsAccessPatterns(codePointer, code.Length);
var match = Assert.Single(matches);
Assert.Equal((nint)(codePointer + 3), match);
}
}
}
@@ -1,153 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Cpu.Native;
using SharpEmu.HLE;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Xunit;
namespace SharpEmu.Libs.Tests.Cpu;
public sealed class MemcpyHleRoutingTests
{
private const string MemcpyNid = "Q3VBxCXhUHs";
private const string MemsetNid = "QrZZdJ8XsX0";
private const string RdtscNid = "-2IRUCO--PM";
[Fact]
public void IsHlePreferredNid_PrefersHleForMemcpy_OnEveryPlatform()
{
Assert.True(
InvokeIsHlePreferredNid(MemcpyNid),
$"memcpy ({MemcpyNid}) must route through HLE on every platform. It was previously " +
"gated behind OperatingSystem.IsWindows(), which left Linux and macOS on the LLE " +
"intrinsic stub and faulted in guest code. Do not reintroduce an OS condition here.");
}
[Fact]
public void IsHlePreferredNid_PrefersHleForMemset()
{
Assert.True(
InvokeIsHlePreferredNid(MemsetNid),
$"memset ({MemsetNid}) must route through HLE on every platform.");
}
[Fact]
public void TryCreateNativeImportIntrinsic_DoesNotClaimMemcpy()
{
if (RuntimeInformation.ProcessArchitecture != Architecture.X64)
{
return;
}
var claimed = InvokeTryCreateNativeImportIntrinsic(MemcpyNid, out var address);
Assert.False(
claimed,
$"memcpy ({MemcpyNid}) must fall through to the HLE trampoline. SetupImportStubs tries " +
"the intrinsic stub before the trampoline, so without an IsHlePreferredNid guard here " +
"the intrinsic claims memcpy and the HLE routing never takes effect.");
Assert.Equal(0, address);
}
[Fact]
public void TryCreateNativeImportIntrinsic_StillClaimsNonHleNids()
{
if (RuntimeInformation.ProcessArchitecture != Architecture.X64)
{
return;
}
var claimed = InvokeTryCreateNativeImportIntrinsic(RdtscNid, out var address);
Assert.True(
claimed,
$"rdtsc ({RdtscNid}) has no HLE handler and must still receive an intrinsic stub. If " +
"this fails the memcpy assertions above may be passing vacuously.");
Assert.NotEqual(0, address);
unsafe
{
Assert.True(HostMemory.Free((void*)address, 0, HostMemory.MEM_RELEASE));
}
}
[Fact]
public void IsHlePreferredNid_DoesNotBranchOnHostOperatingSystem()
{
var method = typeof(DirectExecutionBackend).GetMethod(
"IsHlePreferredNid",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
var callees = ResolveCallees(method);
Assert.DoesNotContain(callees, static name =>
name is "IsWindows" or "IsLinux" or "IsMacOS" or "IsOSPlatform" or "IsFreeBSD");
}
private static HashSet<string> ResolveCallees(MethodBase method)
{
var il = method.GetMethodBody()?.GetILAsByteArray();
Assert.NotNull(il);
var module = method.Module;
var generic = method.DeclaringType?.GetGenericArguments();
var callees = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i + 4 < il.Length; i++)
{
if (il[i] is not (0x28 or 0x6F))
{
continue;
}
var token = BitConverter.ToInt32(il, i + 1);
try
{
var callee = module.ResolveMethod(token, generic, null);
if (callee?.Name is { } name)
{
callees.Add(name);
}
}
catch (ArgumentException)
{
}
}
return callees;
}
private static bool InvokeIsHlePreferredNid(string nid)
{
var method = typeof(DirectExecutionBackend).GetMethod(
"IsHlePreferredNid",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
return (bool)method.Invoke(null, [nid])!;
}
private static bool InvokeTryCreateNativeImportIntrinsic(string nid, out nint address)
{
var backend = (DirectExecutionBackend)RuntimeHelpers.GetUninitializedObject(
typeof(DirectExecutionBackend));
var trampolineList = typeof(DirectExecutionBackend).GetField(
"_importHandlerTrampolines",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(trampolineList);
trampolineList.SetValue(backend, new List<nint>());
var method = typeof(DirectExecutionBackend).GetMethod(
"TryCreateNativeImportIntrinsic",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
object?[] args = [nid, null];
var claimed = (bool)method.Invoke(backend, args)!;
address = (nint)args[1]!;
return claimed;
}
}
@@ -1,180 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
using Xunit;
namespace SharpEmu.Libs.Tests.Kernel;
public sealed class KernelEventQueueCompatExportsTests
{
private const ulong MemoryBase = 0x1_0000_0000;
private const int MemorySize = 0x4000;
[Fact]
public void CreateEqueue_WritesNonZeroHandleAndSucceeds()
{
var (context, outAddress) = NewContextWithOutSlot();
context[CpuRegister.Rdi] = outAddress;
var result = KernelEventQueueCompatExports.KernelCreateEqueue(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
Assert.True(context.TryReadUInt64(outAddress, out var handle));
Assert.NotEqual(0UL, handle);
Assert.True(KernelEventQueueCompatExports.IsValidEqueue(handle));
}
[Fact]
public void CreateEqueue_NullOutAddressReturnsInvalidArgument()
{
var (context, _) = NewContextWithOutSlot();
context[CpuRegister.Rdi] = 0;
var result = KernelEventQueueCompatExports.KernelCreateEqueue(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
}
[Fact]
public void DeleteEqueue_RemovesQueueFromRegistry()
{
var (context, outAddress) = NewContextWithOutSlot();
context[CpuRegister.Rdi] = outAddress;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, KernelEventQueueCompatExports.KernelCreateEqueue(context));
Assert.True(context.TryReadUInt64(outAddress, out var handle));
context[CpuRegister.Rdi] = handle;
var result = KernelEventQueueCompatExports.KernelDeleteEqueue(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
Assert.False(KernelEventQueueCompatExports.IsValidEqueue(handle));
}
[Fact]
public void AddUserEvent_OnUnknownQueueReturnsNotFound()
{
var (context, _) = NewContextWithOutSlot();
const ulong unknownHandle = 0xDEAD_BEEF;
context[CpuRegister.Rdi] = unknownHandle;
context[CpuRegister.Rsi] = 42;
var result = KernelEventQueueCompatExports.KernelAddUserEvent(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND, result);
}
[Fact]
public void AddUserEvent_OnValidQueueSucceeds()
{
var handle = CreateEqueue();
var (context, _) = NewContextWithOutSlot();
context[CpuRegister.Rdi] = handle;
context[CpuRegister.Rsi] = 0x1234;
var result = KernelEventQueueCompatExports.KernelAddUserEvent(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
}
[Fact]
public void TriggerUserEvent_OnUnknownQueueReturnsNotFound()
{
var (context, _) = NewContextWithOutSlot();
context[CpuRegister.Rdi] = 0xDEAD_BEEF;
context[CpuRegister.Rsi] = 1;
context[CpuRegister.Rdx] = 0;
var result = KernelEventQueueCompatExports.KernelTriggerUserEvent(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND, result);
}
[Fact]
public void TriggerUserEvent_OnUnregisteredEventReturnsNotFound()
{
var handle = CreateEqueue();
var (context, _) = NewContextWithOutSlot();
context[CpuRegister.Rdi] = handle;
context[CpuRegister.Rsi] = 0xABCD; // never registered
context[CpuRegister.Rdx] = 0;
var result = KernelEventQueueCompatExports.KernelTriggerUserEvent(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND, result);
}
// Full lifecycle: create -> register user event -> trigger -> wait delivers
// the queued event with the registered ident/filter and the trigger data.
// DequeueEvents runs before the blocking path, so a pre-triggered queue
// returns immediately without touching the guest thread scheduler.
[Fact]
public void CreateAddTriggerWait_DeliversTriggeredUserEvent()
{
const ulong eventIdent = 0x4242;
const ulong triggerData = 0x55AA_55AA;
var handle = CreateEqueue();
// Register the user event on the queue.
var (addCtx, _) = NewContextWithOutSlot();
addCtx[CpuRegister.Rdi] = handle;
addCtx[CpuRegister.Rsi] = eventIdent;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelAddUserEvent(addCtx));
// Trigger it with a distinct data payload.
var (triggerCtx, _) = NewContextWithOutSlot();
triggerCtx[CpuRegister.Rdi] = handle;
triggerCtx[CpuRegister.Rsi] = eventIdent;
triggerCtx[CpuRegister.Rdx] = triggerData;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelTriggerUserEvent(triggerCtx));
// Wait should deliver the single pending event without blocking.
var memory = new FakeCpuMemory(MemoryBase, MemorySize);
var waitContext = new CpuContext(memory, Generation.Gen5);
const ulong eventsAddress = MemoryBase + 0x100;
const ulong outCountAddress = MemoryBase + 0x300;
waitContext[CpuRegister.Rdi] = handle;
waitContext[CpuRegister.Rsi] = eventsAddress;
waitContext[CpuRegister.Rdx] = 1; // capacity
waitContext[CpuRegister.Rcx] = outCountAddress;
waitContext[CpuRegister.R8] = 0; // no timeout -> would block, but event is pending
var result = KernelEventQueueCompatExports.KernelWaitEqueue(waitContext);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
Assert.True(waitContext.TryReadUInt32(outCountAddress, out var delivered));
Assert.Equal(1u, delivered);
// KernelEvent layout (0x20): ident(0x00) filter(0x08) flags(0x0A)
// fflags(0x0C) data(0x10) userdata(0x18).
Span<byte> evt = stackalloc byte[0x20];
Assert.True(memory.TryRead(eventsAddress, evt));
Assert.Equal(eventIdent, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x00..]));
Assert.Equal(KernelEventQueueCompatExports.KernelEventFilterUser,
BinaryPrimitives.ReadInt16LittleEndian(evt[0x08..]));
Assert.Equal(triggerData, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x10..]));
}
private static ulong CreateEqueue()
{
var memory = new FakeCpuMemory(MemoryBase, MemorySize);
var context = new CpuContext(memory, Generation.Gen5);
const ulong outAddress = MemoryBase + 0x10;
context[CpuRegister.Rdi] = outAddress;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelCreateEqueue(context));
Assert.True(context.TryReadUInt64(outAddress, out var handle));
return handle;
}
private static (CpuContext Context, ulong OutAddress) NewContextWithOutSlot()
{
var memory = new FakeCpuMemory(MemoryBase, MemorySize);
var context = new CpuContext(memory, Generation.Gen5);
return (context, MemoryBase + 0x10);
}
}

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