mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-25 20:28:48 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 487bda6a32 | |||
| 09812600a0 | |||
| 3005babab8 | |||
| 09bd4f028b | |||
| 2bda253927 | |||
| 71e5912c75 | |||
| a030cb5a5d | |||
| 336286e588 | |||
| cab001f265 | |||
| bab965e394 | |||
| daaeb6213e | |||
| 94153955b0 | |||
| 6dda6589d0 | |||
| 2ced3af114 | |||
| 18708aa2d3 |
@@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<SharpEmuVersion>0.0.2-beta.3</SharpEmuVersion>
|
||||
<SharpEmuVersion>0.0.2-beta.4</SharpEmuVersion>
|
||||
<Version>$(SharpEmuVersion)</Version>
|
||||
|
||||
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
|
||||
|
||||
@@ -8,6 +8,8 @@ path = [
|
||||
"**/packages.lock.json",
|
||||
"scripts/ps5_names.txt",
|
||||
"src/SharpEmu.GUI/Languages/**",
|
||||
"src/SharpEmu.ShaderCompiler.Metal/Templates/**",
|
||||
"tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/**",
|
||||
"_logs/**",
|
||||
".github/images/**",
|
||||
".github/pull_request_template.md",
|
||||
|
||||
@@ -14,11 +14,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Project Path="src/SharpEmu.Libs/SharpEmu.Libs.csproj" />
|
||||
<Project Path="src/SharpEmu.Logging/SharpEmu.Logging.csproj" />
|
||||
<Project Path="src/SharpEmu.ShaderCompiler/SharpEmu.ShaderCompiler.csproj" />
|
||||
<Project Path="src/SharpEmu.ShaderCompiler.Metal/SharpEmu.ShaderCompiler.Metal.csproj" />
|
||||
<Project Path="src/SharpEmu.ShaderCompiler.Vulkan/SharpEmu.ShaderCompiler.Vulkan.csproj" />
|
||||
<Project Path="src/SharpEmu.SourceGenerators/SharpEmu.SourceGenerators.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj" />
|
||||
<Project Path="tests/SharpEmu.ShaderCompiler.Metal.Tests/SharpEmu.ShaderCompiler.Metal.Tests.csproj" />
|
||||
<Project Path="tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.9 MiB |
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
# Aerolib Catalog
|
||||
|
||||
```bash
|
||||
# NID to export name
|
||||
python scripts/aerolib_catalog.py lookup Zxa0VhQVTsk
|
||||
|
||||
# Export name to NID
|
||||
python scripts/aerolib_catalog.py lookup sceKernelWaitSema
|
||||
|
||||
# Search export names
|
||||
python scripts/aerolib_catalog.py search VideoOut --limit 20
|
||||
|
||||
# Export all NID/name pairs to artifacts/aerolib.txt
|
||||
python scripts/aerolib_catalog.py export
|
||||
```
|
||||
@@ -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
|
||||
the movie without trying to execute the PS5-specific Bink GPU decode path.
|
||||
|
||||
Without an adapter, Bink movies are skipped by default: their open call returns
|
||||
not-found so games that mark cinematics as optional progress to their next
|
||||
state instead of waiting on an empty Bink GPU texture.
|
||||
Without an adapter, Bink files remain visible to the guest and the game's
|
||||
statically linked decoder runs normally. Set SHARPEMU_BINK_MODE=skip only when
|
||||
explicitly testing a title whose cinematics are optional.
|
||||
|
||||
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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.103",
|
||||
"rollForward": "disable"
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (C) 2026 SharpEmu Emulator Project
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
NID_SUFFIX = bytes.fromhex("518d64a635ded8c1e6b039b1c3e55230")
|
||||
NID_PATTERN = re.compile(r"^[A-Za-z0-9+-]{11}$")
|
||||
DEFAULT_NAMES_FILE = Path(__file__).resolve().with_name("ps5_names.txt")
|
||||
DEFAULT_EXPORT_FILE = Path(__file__).resolve().parents[1] / "artifacts" / "aerolib.txt"
|
||||
|
||||
|
||||
def compute_nid(export_name: str) -> str:
|
||||
digest = hashlib.sha1(export_name.encode("utf-8") + NID_SUFFIX).digest()
|
||||
encoded = base64.b64encode(digest[:8][::-1]).decode("ascii")
|
||||
return encoded.rstrip("=").replace("/", "-")
|
||||
|
||||
|
||||
def read_names(path: Path) -> list[str]:
|
||||
try:
|
||||
return [
|
||||
line.strip()
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
except OSError as error:
|
||||
raise SystemExit(f"Unable to read catalog '{path}': {error}") from error
|
||||
|
||||
|
||||
def write_pair(nid: str, export_name: str) -> None:
|
||||
print(f"{nid}\t{export_name}")
|
||||
|
||||
|
||||
def lookup(args: argparse.Namespace) -> int:
|
||||
value = args.value.strip()
|
||||
if NID_PATTERN.fullmatch(value):
|
||||
for export_name in read_names(args.names):
|
||||
if compute_nid(export_name) == value:
|
||||
write_pair(value, export_name)
|
||||
return 0
|
||||
|
||||
print(f"NID not found in catalog: {value}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
names = set(read_names(args.names))
|
||||
write_pair(compute_nid(value), value)
|
||||
if value not in names:
|
||||
print("Warning: export name is not present in the catalog.", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
def search(args: argparse.Namespace) -> int:
|
||||
names = read_names(args.names)
|
||||
if args.regex:
|
||||
try:
|
||||
pattern = re.compile(args.query, 0 if args.case_sensitive else re.IGNORECASE)
|
||||
except re.error as error:
|
||||
print(f"Invalid regular expression: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
matches = (name for name in names if pattern.search(name))
|
||||
elif args.case_sensitive:
|
||||
matches = (name for name in names if args.query in name)
|
||||
else:
|
||||
query = args.query.casefold()
|
||||
matches = (name for name in names if query in name.casefold())
|
||||
|
||||
count = 0
|
||||
for export_name in matches:
|
||||
write_pair(compute_nid(export_name), export_name)
|
||||
count += 1
|
||||
if args.limit and count >= args.limit:
|
||||
break
|
||||
|
||||
if count == 0:
|
||||
print(f"No catalog names matched: {args.query}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def export_catalog(args: argparse.Namespace) -> int:
|
||||
pairs = [(compute_nid(name), name) for name in read_names(args.names)]
|
||||
if args.sort == "nid":
|
||||
pairs.sort(key=lambda pair: (pair[0], pair[1]))
|
||||
elif args.sort == "name":
|
||||
pairs.sort(key=lambda pair: pair[1])
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with args.output.open("w", encoding="utf-8", newline="\n") as output:
|
||||
output.write("# NID\tExportName\n")
|
||||
for nid, export_name in pairs:
|
||||
output.write(f"{nid}\t{export_name}\n")
|
||||
except OSError as error:
|
||||
print(f"Unable to write catalog '{args.output}': {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Wrote {len(pairs)} entries to {args.output}")
|
||||
return 0
|
||||
|
||||
|
||||
def create_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Inspect the SharpEmu PS5 export-name/NID catalog.",
|
||||
epilog=(
|
||||
"Examples:\n"
|
||||
" python scripts/aerolib_catalog.py lookup Zxa0VhQVTsk\n"
|
||||
" python scripts/aerolib_catalog.py lookup sceKernelWaitSema\n"
|
||||
" python scripts/aerolib_catalog.py search VideoOut --limit 20\n"
|
||||
" python scripts/aerolib_catalog.py export"
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--names",
|
||||
type=Path,
|
||||
default=DEFAULT_NAMES_FILE,
|
||||
help=f"source name list (default: {DEFAULT_NAMES_FILE})",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
lookup_parser = subparsers.add_parser(
|
||||
"lookup", help="resolve a NID or calculate the NID for an export name"
|
||||
)
|
||||
lookup_parser.add_argument("value", help="11-character NID or exact export name")
|
||||
lookup_parser.set_defaults(handler=lookup)
|
||||
|
||||
search_parser = subparsers.add_parser(
|
||||
"search", help="find export names and print matching NID/name pairs"
|
||||
)
|
||||
search_parser.add_argument("query", help="name substring or regular expression")
|
||||
search_parser.add_argument(
|
||||
"--limit", type=int, default=50, help="maximum matches; 0 means unlimited"
|
||||
)
|
||||
search_parser.add_argument(
|
||||
"--case-sensitive", action="store_true", help="match case exactly"
|
||||
)
|
||||
search_parser.add_argument(
|
||||
"--regex", action="store_true", help="treat the query as a regular expression"
|
||||
)
|
||||
search_parser.set_defaults(handler=search)
|
||||
|
||||
export_parser = subparsers.add_parser(
|
||||
"export", help="write every NID/name pair to a tab-separated text file"
|
||||
)
|
||||
export_parser.add_argument(
|
||||
"output",
|
||||
type=Path,
|
||||
nargs="?",
|
||||
default=DEFAULT_EXPORT_FILE,
|
||||
help=f"output file (default: {DEFAULT_EXPORT_FILE})",
|
||||
)
|
||||
export_parser.add_argument(
|
||||
"--sort",
|
||||
choices=("source", "nid", "name"),
|
||||
default="nid",
|
||||
help="output ordering (default: nid)",
|
||||
)
|
||||
export_parser.set_defaults(handler=export_catalog)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = create_parser()
|
||||
args = parser.parse_args()
|
||||
return args.handler(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -45,11 +45,6 @@ internal static partial class Program
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
// Avoid blocking full collections while guest and render threads are
|
||||
// running, and establish the GC mode before the runtime reserves the
|
||||
// fixed guest address-space window.
|
||||
System.Runtime.GCSettings.LatencyMode = System.Runtime.GCLatencyMode.SustainedLowLatency;
|
||||
|
||||
try
|
||||
{
|
||||
return Run(args);
|
||||
|
||||
@@ -49,7 +49,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<DebugType>none</DebugType>
|
||||
<DebugSymbols>false</DebugSymbols>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == ''">
|
||||
|
||||
@@ -1,596 +0,0 @@
|
||||
{
|
||||
"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,6 +1410,8 @@ public sealed partial class DirectExecutionBackend
|
||||
"eE4Szl8sil8" or // sceKernelAprSubmitCommandBuffer
|
||||
"qvMUCyyaCSI" or // sceKernelAprSubmitCommandBufferAndGetId
|
||||
"Q2V+iqvjgC0" or // vsnprintf
|
||||
"AV6ipCNa4Rw" or // strcasecmp
|
||||
"viiwFMaNamA" or // strstr
|
||||
"q1cHNfGycLI" or // scePadRead
|
||||
"xk0AcarP3V4" or // scePadOpen
|
||||
"yH17Q6NWtVg" or // sceUserServiceGetEvent
|
||||
@@ -1571,6 +1573,8 @@ public sealed partial class DirectExecutionBackend
|
||||
"WkkeywLJcgU" or // wcslen
|
||||
"Ovb2dSJOAuE" or // strcmp
|
||||
"aesyjrHVWy4" or // strncmp
|
||||
"AV6ipCNa4Rw" or // strcasecmp
|
||||
"viiwFMaNamA" or // strstr
|
||||
"pNtJdE3x49E" or // wcscmp
|
||||
"fV2xHER+bKE" or // wcscoll
|
||||
"E8wCoUEbfzk" or // wcsncmp
|
||||
|
||||
@@ -1291,6 +1291,12 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
private unsafe bool TryCreateNativeImportIntrinsic(string nid, out nint address)
|
||||
{
|
||||
if (IsHlePreferredNid(nid))
|
||||
{
|
||||
address = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (nid == "1jfXLRVzisc" &&
|
||||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_USLEEP"), "1", StringComparison.Ordinal))
|
||||
{
|
||||
@@ -1402,6 +1408,54 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
0x75, 0xE7,
|
||||
0xC3,
|
||||
],
|
||||
"AV6ipCNa4Rw" =>
|
||||
[
|
||||
0x0F, 0xB6, 0x07,
|
||||
0x0F, 0xB6, 0x16,
|
||||
0x8D, 0x48, 0xBF,
|
||||
0x83, 0xF9, 0x19,
|
||||
0x77, 0x03,
|
||||
0x83, 0xC0, 0x20,
|
||||
0x8D, 0x4A, 0xBF,
|
||||
0x83, 0xF9, 0x19,
|
||||
0x77, 0x03,
|
||||
0x83, 0xC2, 0x20,
|
||||
0x29, 0xD0,
|
||||
0x75, 0x0C,
|
||||
0x85, 0xD2,
|
||||
0x74, 0x08,
|
||||
0x48, 0xFF, 0xC7,
|
||||
0x48, 0xFF, 0xC6,
|
||||
0xEB, 0xD4,
|
||||
0xC3,
|
||||
],
|
||||
"viiwFMaNamA" =>
|
||||
[
|
||||
0x0F, 0xB6, 0x16,
|
||||
0x84, 0xD2,
|
||||
0x74, 0x2D,
|
||||
0x0F, 0xB6, 0x07,
|
||||
0x84, 0xC0,
|
||||
0x74, 0x2A,
|
||||
0x38, 0xD0,
|
||||
0x75, 0x1D,
|
||||
0x4C, 0x8D, 0x47, 0x01,
|
||||
0x4C, 0x8D, 0x4E, 0x01,
|
||||
0x41, 0x0F, 0xB6, 0x09,
|
||||
0x84, 0xC9,
|
||||
0x74, 0x12,
|
||||
0x41, 0x38, 0x08,
|
||||
0x75, 0x08,
|
||||
0x49, 0xFF, 0xC0,
|
||||
0x49, 0xFF, 0xC1,
|
||||
0xEB, 0xEB,
|
||||
0x48, 0xFF, 0xC7,
|
||||
0xEB, 0xD3,
|
||||
0x48, 0x89, 0xF8,
|
||||
0xC3,
|
||||
0x31, 0xC0,
|
||||
0xC3,
|
||||
],
|
||||
"pNtJdE3x49E" or "fV2xHER+bKE" =>
|
||||
[
|
||||
0x0F, 0xB7, 0x07,
|
||||
@@ -1466,8 +1520,14 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
"Q3VBxCXhUHs" =>
|
||||
[
|
||||
0x48, 0x89, 0xF8,
|
||||
0x48, 0x89, 0xD1,
|
||||
0xF3, 0xA4,
|
||||
0x48, 0x85, 0xD2,
|
||||
0x74, 0x11,
|
||||
0x44, 0x8A, 0x06,
|
||||
0x44, 0x88, 0x07,
|
||||
0x48, 0xFF, 0xC6,
|
||||
0x48, 0xFF, 0xC7,
|
||||
0x48, 0xFF, 0xCA,
|
||||
0x75, 0xEF,
|
||||
0xC3,
|
||||
],
|
||||
"8zTFvBIAIN8" =>
|
||||
@@ -1601,7 +1661,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
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)
|
||||
@@ -2541,28 +2602,22 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
private unsafe bool TryPatchSse4aExtrqBlend(nint address, byte* source)
|
||||
{
|
||||
// Rosetta does not implement AMD SSE4a EXTRQ. This exact sequence masks
|
||||
// xmm2 to its low 40 bits, then copies the resulting second dword into
|
||||
// xmm0. PEXTRB/PINSRD provides the same observable result in 12 bytes:
|
||||
// extract source byte 4 and insert the zero-extended value into lane 1.
|
||||
ReadOnlySpan<byte> pattern =
|
||||
[
|
||||
0x66, 0x0F, 0x78, 0xC2, 0x28, 0x00,
|
||||
0xC4, 0xE3, 0x79, 0x02, 0xC2, 0x02,
|
||||
];
|
||||
for (var i = 0; i < pattern.Length; i++)
|
||||
// Rosetta does not implement AMD SSE4a EXTRQ. Recognize the compiler's
|
||||
// EXTRQ+blend idiom (against whichever xmm0-xmm7 it allocated) and rewrite
|
||||
// it into an equivalent SSE4.1 sequence. Match/encode is isolated in
|
||||
// Sse4aExtrqBlendPatch so it can be unit-tested; here we only patch bytes.
|
||||
var window = new ReadOnlySpan<byte>(source, Sse4aExtrqBlendPatch.SequenceLength);
|
||||
if (!Sse4aExtrqBlendPatch.TryMatch(window, out var destRegister, out var srcRegister))
|
||||
{
|
||||
if (source[i] != pattern[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
if (!VirtualProtect((void*)address, (nuint)replacement.Length, 64u, &oldProtect))
|
||||
{
|
||||
|
||||
@@ -252,7 +252,7 @@ public static unsafe class JitStubs
|
||||
var pattern = TlsAccessPattern;
|
||||
var end = start + length - pattern.Length;
|
||||
|
||||
for (var ptr = start; ptr < end; ptr++)
|
||||
for (var ptr = start; ptr <= end; ptr++)
|
||||
{
|
||||
if (MatchesPattern(ptr, pattern))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// 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,6 +873,15 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
public bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source)
|
||||
{
|
||||
// A managed write into a page the guest-image write tracker has
|
||||
// protected surfaces as a fatal AccessViolation — the runtime turns
|
||||
// SIGSEGV in managed code into an exception before the resumable
|
||||
// signal bridge can restore access (native guest stores recover
|
||||
// there). Pre-visit the span so tracked pages are unprotected and
|
||||
// their owners dirtied before the copy; guest addresses are
|
||||
// host-identical, matching the tracker's fault addresses.
|
||||
GuestImageWriteTracker.NotifyManagedWrite(virtualAddress, (ulong)source.Length);
|
||||
|
||||
var requiresExclusiveAccess = false;
|
||||
_gate.EnterReadLock();
|
||||
try
|
||||
|
||||
@@ -125,5 +125,48 @@
|
||||
"Dialog.PsExecutables": "ملفات PS التنفيذية",
|
||||
"Dialog.SaveLogFile": "حدد مكان حفظ ملف السجل",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -142,5 +142,32 @@
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Discord.Desc": "Participe da comunidade, obtenha suporte e acompanhe o desenvolvimento.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -125,5 +125,48 @@
|
||||
"Dialog.PsExecutables": "PS-Ausführbare Dateien",
|
||||
"Dialog.SaveLogFile": "Protokolldatei speichern unter",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -125,5 +125,48 @@
|
||||
"Dialog.PsExecutables": "PS-programmer",
|
||||
"Dialog.SaveLogFile": "Vælg hvor logfilen skal gemmes",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -135,5 +135,39 @@
|
||||
"About.Github.LatestCommitDescription": "Último commit en la rama main",
|
||||
"About.Discord.Desc": "Únete a la comunidad, recibe soporte y sigue el desarrollo.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -142,5 +142,32 @@
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Discord.Desc": "Rejoignez la communauté, obtenez de l’aide et suivez le développement.",
|
||||
"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 d’installation.\nNécessaire pour les dumps non empaquetés qui écrivent leurs sauvegardes ou leur configuration sous /app0.",
|
||||
"Options.Env.LogIo.Desc": "Journaliser l’ouverture 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 d’environnement",
|
||||
"PerGame.EnvToggles.Desc": "Remplacer l’ensemble 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."
|
||||
}
|
||||
|
||||
@@ -142,5 +142,32 @@
|
||||
"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.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."
|
||||
}
|
||||
|
||||
@@ -130,5 +130,48 @@
|
||||
"Dialog.PsExecutables": "Eseguibili PS",
|
||||
"Dialog.SaveLogFile": "Scegli dove salvare il file di log",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -125,5 +125,48 @@
|
||||
"Dialog.PsExecutables": "PlayStation 実行ファイル",
|
||||
"Dialog.SaveLogFile": "ログファイルの保存先を選択",
|
||||
"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": "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": "ソースコード、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ビルドが必要です。"
|
||||
}
|
||||
|
||||
@@ -125,5 +125,48 @@
|
||||
"Dialog.PsExecutables": "PlayStation 실행 파일",
|
||||
"Dialog.SaveLogFile": "로그 파일 저장 위치 선택",
|
||||
"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 빌드가 필요합니다."
|
||||
}
|
||||
|
||||
@@ -125,5 +125,48 @@
|
||||
"Dialog.PsExecutables": "PS-uitvoerbare bestanden",
|
||||
"Dialog.SaveLogFile": "Selecteer waar het logbestand moet worden opgeslagen",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -142,5 +142,32 @@
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Discord.Desc": "Junte-se à comunidade, obtenha suporte e acompanhe o desenvolvimento.",
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -169,5 +169,7 @@
|
||||
"Updater.Status.Installing": "Установка обновления…",
|
||||
"Updater.Status.Timeout": "Проверка обновлений превысила лимит времени в 10 секунд.",
|
||||
"Updater.Status.Failed": "Не удалось проверить наличие обновлений.",
|
||||
"Updater.Status.Unsupported": "Автоматическое обновление требует сборку Windows, Linux или macOS x64."
|
||||
"Updater.Status.Unsupported": "Автоматическое обновление требует сборку Windows, Linux или macOS x64.",
|
||||
|
||||
"Updater.Status.ChecksumFailed": "Скачанное обновление не прошло проверку SHA-256."
|
||||
}
|
||||
|
||||
@@ -141,5 +141,33 @@
|
||||
"Updater.Status.Timeout": "Güncelleme denetimi 10 saniye sonra zaman aşımına uğradı.",
|
||||
"Updater.Status.Failed": "Güncellemeler denetlenemedi.",
|
||||
"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!"
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Panel Grid.Row="1">
|
||||
<Panel Grid.Row="1" x:Name="PagesHost">
|
||||
|
||||
<!-- Library page. The tile row gets extra top margin so it sits
|
||||
closer to eye level (PS5 home-screen style) instead of hugging
|
||||
@@ -604,9 +604,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
<!-- This is a native popup rather than an Avalonia overlay because the
|
||||
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"
|
||||
IsOpen="False"
|
||||
PlacementTarget="{Binding #GameView}"
|
||||
PlacementTarget="{Binding #MainContent}"
|
||||
Placement="Center"
|
||||
Topmost="True"
|
||||
ShouldUseOverlayLayer="False"
|
||||
|
||||
@@ -88,6 +88,15 @@ public partial class MainWindow : Window
|
||||
private int _detailLoadGeneration;
|
||||
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.
|
||||
private readonly DispatcherTimer _gamepadTimer;
|
||||
private HostGamepadButtons _previousPadButtons;
|
||||
@@ -111,6 +120,18 @@ public partial class MainWindow : Window
|
||||
{
|
||||
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;
|
||||
ConsoleList.ItemsSource = _consoleLines;
|
||||
_consoleMirror = GuiConsoleMirror.Install((line, isError) =>
|
||||
@@ -134,8 +155,18 @@ public partial class MainWindow : Window
|
||||
};
|
||||
_libraryBlurTimer.Tick += (_, _) => AdvanceLibraryBlur();
|
||||
|
||||
Activated += (_, _) => UpdateSessionBarVisibility();
|
||||
Deactivated += (_, _) => SessionBarPopup.IsOpen = false;
|
||||
// Native popups float above every window on the desktop; they must
|
||||
// follow the launcher into the background or a minimized state.
|
||||
Activated += (_, _) =>
|
||||
{
|
||||
UpdateSessionBarVisibility();
|
||||
SessionLoadingPopup.IsOpen = _sessionLoadingActive;
|
||||
};
|
||||
Deactivated += (_, _) =>
|
||||
{
|
||||
SessionBarPopup.IsOpen = false;
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
};
|
||||
|
||||
TitleBar.PointerPressed += OnTitleBarPointerPressed;
|
||||
GameList.SelectionChanged += (_, _) => UpdateSelectedGame();
|
||||
@@ -398,6 +429,15 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isRunning || _isStopping)
|
||||
{
|
||||
// The game renders inside the launcher window, so the launcher
|
||||
// stays active while playing. The controller belongs to the game
|
||||
// then: no navigation, and Circle/B must never stop the session.
|
||||
_previousPadButtons = pad.Buttons;
|
||||
return;
|
||||
}
|
||||
|
||||
var shoulderPressed = pad.Buttons & ~_previousPadButtons;
|
||||
if ((shoulderPressed & HostGamepadButtons.L1) != 0)
|
||||
{
|
||||
@@ -447,11 +487,6 @@ public partial class MainWindow : Window
|
||||
LaunchSelected();
|
||||
}
|
||||
|
||||
if ((pressed & HostGamepadButtons.Circle) != 0)
|
||||
{
|
||||
StopEmulator();
|
||||
}
|
||||
|
||||
_previousPadButtons = pad.Buttons;
|
||||
}
|
||||
|
||||
@@ -1610,13 +1645,23 @@ public partial class MainWindow : Window
|
||||
base.OnPropertyChanged(change);
|
||||
if (change.Property == WindowStateProperty)
|
||||
{
|
||||
// The XAML WindowState="Maximized" assignment raises this change
|
||||
// during InitializeComponent, before named controls are wired up.
|
||||
if (WindowState == WindowState.Minimized)
|
||||
{
|
||||
_sndPreview.Pause();
|
||||
if (SessionLoadingPopup is { } popup)
|
||||
{
|
||||
popup.IsOpen = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_sndPreview.Resume();
|
||||
if (SessionLoadingPopup is { } popup)
|
||||
{
|
||||
popup.IsOpen = _sessionLoadingActive;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1631,8 +1676,20 @@ public partial class MainWindow : Window
|
||||
var generation = ++_backdropGeneration;
|
||||
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)
|
||||
{
|
||||
ShowDefaultBackdrop();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1649,7 +1706,8 @@ public partial class MainWindow : Window
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return; // undecodable key art: keep the plain background
|
||||
ShowDefaultBackdrop(); // undecodable key art
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1969,6 +2027,7 @@ public partial class MainWindow : Window
|
||||
_awaitingFirstFrame = false;
|
||||
ClearLibraryBlur();
|
||||
MainContent.Margin = new Thickness(0);
|
||||
RestoreGameViewToFull();
|
||||
GameView.Background = Brushes.Black;
|
||||
GameView.IsHitTestVisible = true;
|
||||
_gameSurfaceHost?.SetPresentationVisible(true);
|
||||
@@ -1979,7 +2038,7 @@ public partial class MainWindow : Window
|
||||
ContentToolbar.IsVisible = false;
|
||||
ConsolePanel.IsVisible = false;
|
||||
LaunchBar.IsVisible = false;
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
HideSessionLoading();
|
||||
UpdateSessionBarVisibility();
|
||||
}
|
||||
});
|
||||
@@ -2030,11 +2089,31 @@ 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()
|
||||
{
|
||||
_isStopping = false;
|
||||
_awaitingFirstFrame = true;
|
||||
var host = EnsureGameSurfaceHost();
|
||||
ParkGameViewOffscreen();
|
||||
GameView.IsVisible = true;
|
||||
GameView.Background = Brushes.Transparent;
|
||||
GameView.IsHitTestVisible = false;
|
||||
@@ -2059,7 +2138,7 @@ public partial class MainWindow : Window
|
||||
GameView.IsVisible = false;
|
||||
GameView.IsHitTestVisible = true;
|
||||
SessionBarPopup.IsOpen = false;
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
HideSessionLoading();
|
||||
AnimateLibraryBlur(0, clearWhenComplete: true);
|
||||
MainContent.Margin = new Thickness(32, 24, 32, 20);
|
||||
ContentToolbar.IsVisible = true;
|
||||
@@ -2068,16 +2147,15 @@ public partial class MainWindow : Window
|
||||
LibraryPage.IsVisible = _activePageIndex == 0;
|
||||
LibraryToolbar.IsVisible = _activePageIndex == 0;
|
||||
OptionsPage.IsVisible = _activePageIndex == 1;
|
||||
if (GameList.SelectedItem is GameEntry game && game.Background is not null)
|
||||
{
|
||||
BackdropImage.Opacity = 1;
|
||||
}
|
||||
// Game art when the source still holds it, otherwise the bundled
|
||||
// default; a bare color only when neither is available.
|
||||
BackdropImage.Opacity = BackdropImage.Source is not null ? 1 : 0;
|
||||
}
|
||||
|
||||
private void AnimateLibraryBlur(double targetRadius, bool clearWhenComplete = false)
|
||||
{
|
||||
_libraryBlur ??= new BlurEffect();
|
||||
MainContent.Effect = _libraryBlur;
|
||||
PagesHost.Effect = _libraryBlur;
|
||||
|
||||
_libraryBlurStartRadius = _libraryBlur.Radius;
|
||||
_libraryBlurTargetRadius = Math.Max(0, targetRadius);
|
||||
@@ -2126,7 +2204,7 @@ public partial class MainWindow : Window
|
||||
|
||||
if (_clearLibraryBlurWhenComplete)
|
||||
{
|
||||
MainContent.Effect = null;
|
||||
PagesHost.Effect = null;
|
||||
_libraryBlur = null;
|
||||
_clearLibraryBlurWhenComplete = false;
|
||||
}
|
||||
@@ -2137,14 +2215,21 @@ public partial class MainWindow : Window
|
||||
_libraryBlurTimer.Stop();
|
||||
_libraryBlur = null;
|
||||
_clearLibraryBlurWhenComplete = false;
|
||||
MainContent.Effect = null;
|
||||
PagesHost.Effect = null;
|
||||
}
|
||||
|
||||
private void ShowSessionLoading(string title, string detail)
|
||||
{
|
||||
SessionLoadingTitle.Text = title;
|
||||
SessionLoadingDetail.Text = detail;
|
||||
SessionLoadingPopup.IsOpen = true;
|
||||
_sessionLoadingActive = true;
|
||||
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
|
||||
}
|
||||
|
||||
private void HideSessionLoading()
|
||||
{
|
||||
_sessionLoadingActive = false;
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
}
|
||||
|
||||
private void ReturnToLibraryWhileStopping()
|
||||
@@ -2156,10 +2241,12 @@ public partial class MainWindow : Window
|
||||
|
||||
// Keep the native child alive until the session exits, but hide it
|
||||
// immediately. Destroying it while Vulkan still owns the surface can
|
||||
// crash the GUI; leaving it transparent lets the library recover
|
||||
// while the native closing popup reports teardown progress.
|
||||
// crash the GUI; parking it in the 1x1 corner lets the library
|
||||
// recover — and stay clickable — while the native closing popup
|
||||
// reports teardown progress.
|
||||
_gameSurfaceHost?.SetPresentationVisible(false);
|
||||
_awaitingFirstFrame = false;
|
||||
ParkGameViewOffscreen();
|
||||
GameView.Background = Brushes.Transparent;
|
||||
GameView.IsHitTestVisible = false;
|
||||
SessionBarPopup.IsOpen = false;
|
||||
@@ -2171,7 +2258,7 @@ public partial class MainWindow : Window
|
||||
LibraryPage.IsVisible = _activePageIndex == 0;
|
||||
LibraryToolbar.IsVisible = _activePageIndex == 0;
|
||||
OptionsPage.IsVisible = _activePageIndex == 1;
|
||||
BackdropImage.Opacity = GameList.SelectedItem is GameEntry { Background: not null } ? 1 : 0;
|
||||
BackdropImage.Opacity = BackdropImage.Source is not null ? 1 : 0;
|
||||
UpdateRunButtons();
|
||||
Console.Error.WriteLine("[GUI][INFO] Library restored while embedded session is closing.");
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<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\commit-icon.png" Link="Assets/commit-icon.png" />
|
||||
<AvaloniaResource Include="..\..\assets\images\pic0.png" Link="Assets/pic0.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -51,9 +51,33 @@ public static unsafe class GuestImageWriteTracker
|
||||
private static readonly object _gate = new();
|
||||
private static readonly Dictionary<ulong, TrackedRange> _rangesByAddress = new();
|
||||
|
||||
// Snapshot array read lock-free from the signal handler; rebuilt on every
|
||||
// mutation under the gate. Signal handlers must not take managed locks.
|
||||
private static TrackedRange[] _rangeSnapshot = [];
|
||||
/// <summary>Immutable snapshot read lock-free from the signal handler and
|
||||
/// the managed-write pre-visit; rebuilt on every mutation under the gate
|
||||
/// (signal handlers must not take managed locks). Carrying the overall
|
||||
/// bounds inside the same object keeps the hot-path intersection test
|
||||
/// consistent with the array it guards.</summary>
|
||||
private sealed class RangeSnapshot
|
||||
{
|
||||
public static readonly RangeSnapshot Empty = new([]);
|
||||
|
||||
public readonly TrackedRange[] Ranges;
|
||||
public readonly ulong Start;
|
||||
public readonly ulong End;
|
||||
|
||||
public RangeSnapshot(TrackedRange[] ranges)
|
||||
{
|
||||
Ranges = ranges;
|
||||
Start = ulong.MaxValue;
|
||||
End = 0;
|
||||
foreach (var range in ranges)
|
||||
{
|
||||
Start = Math.Min(Start, range.Start);
|
||||
End = Math.Max(End, range.End);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static RangeSnapshot _rangeSnapshot = RangeSnapshot.Empty;
|
||||
|
||||
private static readonly bool _enabled = !OperatingSystem.IsWindows() &&
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
|
||||
@@ -266,6 +290,17 @@ public static unsafe class GuestImageWriteTracker
|
||||
var end = address > ulong.MaxValue - byteCount
|
||||
? ulong.MaxValue
|
||||
: address + byteCount;
|
||||
|
||||
// Fast rejection for the hot path: this runs on every managed guest
|
||||
// write, and almost none of them touch tracked texture pages. The
|
||||
// bounds live inside the snapshot so they are always consistent with
|
||||
// the ranges the per-page visit below would consult.
|
||||
var snapshot = Volatile.Read(ref _rangeSnapshot);
|
||||
if (snapshot.Ranges.Length == 0 || end <= snapshot.Start || address >= snapshot.End)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var candidate = address;
|
||||
while (candidate < end)
|
||||
{
|
||||
@@ -311,7 +346,7 @@ public static unsafe class GuestImageWriteTracker
|
||||
return false;
|
||||
}
|
||||
|
||||
var ranges = Volatile.Read(ref _rangeSnapshot);
|
||||
var ranges = Volatile.Read(ref _rangeSnapshot).Ranges;
|
||||
var writableStart = ulong.MaxValue;
|
||||
var writableEnd = 0UL;
|
||||
for (var index = 0; index < ranges.Length; index++)
|
||||
@@ -458,7 +493,7 @@ public static unsafe class GuestImageWriteTracker
|
||||
|
||||
private static void RebuildSnapshotLocked()
|
||||
{
|
||||
_rangeSnapshot = _rangesByAddress.Values.ToArray();
|
||||
Volatile.Write(ref _rangeSnapshot, new RangeSnapshot(_rangesByAddress.Values.ToArray()));
|
||||
}
|
||||
|
||||
private static (ulong Start, ulong Length) PageAlign(ulong address, ulong byteCount)
|
||||
|
||||
+332
-109
@@ -14,6 +14,12 @@ namespace SharpEmu.Libs.Agc;
|
||||
|
||||
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
|
||||
static AgcExports()
|
||||
{
|
||||
@@ -562,6 +568,8 @@ public static partial class AgcExports
|
||||
public ulong WorkSequence { get; set; }
|
||||
public ulong SubmissionSequence { get; set; }
|
||||
public bool WaitMonitorRunning { get; set; }
|
||||
public object WaitMonitorSignalGate { get; } = new();
|
||||
public long WaitMonitorSignalVersion { get; set; }
|
||||
}
|
||||
|
||||
private readonly record struct RegisteredAgcResource(
|
||||
@@ -2666,7 +2674,7 @@ public static partial class AgcExports
|
||||
TraceAgc($"agc.driver_submit_dcb packet=0x{packetAddress:X16} addr=0x{commandAddress:X16} dwords={dwordCount}");
|
||||
}
|
||||
|
||||
VulkanVideoPresenter.AttachGuestMemory(ctx.Memory);
|
||||
GuestGpu.Current.AttachGuestMemory(ctx.Memory);
|
||||
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
|
||||
lock (gpuState.Gate)
|
||||
{
|
||||
@@ -2718,7 +2726,7 @@ public static partial class AgcExports
|
||||
$"addr=0x{commandAddress:X16} dwords={dwordCount}");
|
||||
}
|
||||
|
||||
VulkanVideoPresenter.AttachGuestMemory(ctx.Memory);
|
||||
GuestGpu.Current.AttachGuestMemory(ctx.Memory);
|
||||
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
|
||||
lock (gpuState.Gate)
|
||||
{
|
||||
@@ -2946,7 +2954,7 @@ public static partial class AgcExports
|
||||
// guest-memory writes have finished. Put the notification on that same
|
||||
// logical graphics queue instead of approximating completion with a
|
||||
// timer, which can wake Unity while its upload data is still stale.
|
||||
if (VulkanVideoPresenter.SubmitOrderedGuestAction(
|
||||
if (GuestGpu.Current.SubmitOrderedGuestAction(
|
||||
TriggerCompletionEvents,
|
||||
$"agc submit completion {submissionId}") == 0)
|
||||
{
|
||||
@@ -2970,11 +2978,11 @@ public static partial class AgcExports
|
||||
return false;
|
||||
}
|
||||
|
||||
using var guestQueueScope = VulkanVideoPresenter.EnterGuestQueue(
|
||||
using var guestQueueScope = GuestGpu.Current.EnterGuestQueue(
|
||||
state.QueueName,
|
||||
state.ActiveSubmissionId);
|
||||
var windowByteCount = checked((int)(dwordCount * sizeof(uint)));
|
||||
var rented = VulkanVideoPresenter.GuestDataPool.Rent(windowByteCount);
|
||||
var rented = GuestDataPool.Shared.Rent(windowByteCount);
|
||||
try
|
||||
{
|
||||
if (ctx.Memory.TryRead(commandAddress, rented.AsSpan(0, windowByteCount)))
|
||||
@@ -2996,7 +3004,7 @@ public static partial class AgcExports
|
||||
{
|
||||
_dcbWindowBuffer = null;
|
||||
_dcbWindowByteLength = 0;
|
||||
VulkanVideoPresenter.GuestDataPool.Return(rented);
|
||||
GuestDataPool.Shared.Return(rented);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3298,17 +3306,20 @@ public static partial class AgcExports
|
||||
indexed: false);
|
||||
}
|
||||
|
||||
if ((op is ItDispatchDirect or ItDispatchIndirect) &&
|
||||
TryReadComputeDispatch(
|
||||
ctx,
|
||||
state,
|
||||
currentAddress,
|
||||
length,
|
||||
op,
|
||||
out var dispatch))
|
||||
if (op is ItDispatchDirect or ItDispatchIndirect)
|
||||
{
|
||||
state.FrameDispatchCount++;
|
||||
ObserveComputeDispatch(ctx, gpuState, state, dispatch);
|
||||
if (TryReadComputeDispatch(
|
||||
ctx,
|
||||
state,
|
||||
currentAddress,
|
||||
length,
|
||||
op,
|
||||
out var dispatch,
|
||||
out _))
|
||||
{
|
||||
state.FrameDispatchCount++;
|
||||
ObserveComputeDispatch(ctx, gpuState, state, dispatch);
|
||||
}
|
||||
}
|
||||
|
||||
if (op == ItNop &&
|
||||
@@ -3317,7 +3328,7 @@ public static partial class AgcExports
|
||||
TryReadUInt32(ctx, currentAddress + 4, out var waitVideoOutHandle) &&
|
||||
TryReadUInt32(ctx, currentAddress + 8, out var waitDisplayBufferIndex))
|
||||
{
|
||||
var waitSequence = VulkanVideoPresenter.SubmitOrderedGuestFlipWait(
|
||||
var waitSequence = GuestGpu.Current.SubmitOrderedGuestFlipWait(
|
||||
unchecked((int)waitVideoOutHandle),
|
||||
unchecked((int)waitDisplayBufferIndex));
|
||||
TraceAgcShader(
|
||||
@@ -3646,27 +3657,11 @@ public static partial class AgcExports
|
||||
void CompleteAndWake()
|
||||
{
|
||||
CompleteLabelProducer(producer);
|
||||
if (GpuWaitRegistry.Count == 0)
|
||||
lock (gpuState.WaitMonitorSignalGate)
|
||||
{
|
||||
return;
|
||||
gpuState.WaitMonitorSignalVersion++;
|
||||
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()
|
||||
@@ -3677,7 +3672,7 @@ public static partial class AgcExports
|
||||
// wake another queue before that mirror is visible. Queue a
|
||||
// second same-queue ordered action after all immediate follow-up
|
||||
// writes; it fences those writes before publishing the producer.
|
||||
if (VulkanVideoPresenter.SubmitOrderedGuestAction(
|
||||
if (GuestGpu.Current.SubmitOrderedGuestAction(
|
||||
CompleteAndWake,
|
||||
$"{debugName} completion") == 0)
|
||||
{
|
||||
@@ -3685,7 +3680,7 @@ public static partial class AgcExports
|
||||
}
|
||||
}
|
||||
|
||||
if (VulkanVideoPresenter.SubmitOrderedGuestAction(
|
||||
if (GuestGpu.Current.SubmitOrderedGuestAction(
|
||||
ApplyAndQueueCompletion,
|
||||
debugName) == 0)
|
||||
{
|
||||
@@ -3895,11 +3890,11 @@ public static partial class AgcExports
|
||||
TraceAgc(
|
||||
$"agc.acquire_mem_applied queue={queueName} " +
|
||||
$"submission={submissionId} packet=0x{packetAddress:X16} " +
|
||||
$"work_sequence={VulkanVideoPresenter.CurrentGuestWorkSequenceForDiagnostics}");
|
||||
$"work_sequence={GuestGpu.Current.CurrentGuestWorkSequenceForDiagnostics}");
|
||||
}
|
||||
}
|
||||
|
||||
var sequence = VulkanVideoPresenter.SubmitOrderedGuestAction(
|
||||
var sequence = GuestGpu.Current.SubmitOrderedGuestAction(
|
||||
ApplyAcquire,
|
||||
debugName);
|
||||
if (sequence == 0)
|
||||
@@ -4045,7 +4040,7 @@ public static partial class AgcExports
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (address, width, height, byteCount) in VulkanVideoPresenter.GetGuestImageExtents())
|
||||
foreach (var (address, width, height, byteCount) in GuestGpu.Current.GetGuestImageExtents())
|
||||
{
|
||||
if (scopeByteCount != ulong.MaxValue &&
|
||||
!RangesOverlap(address, byteCount, scopeAddress, scopeByteCount))
|
||||
@@ -4066,7 +4061,7 @@ public static partial class AgcExports
|
||||
var pixels = new byte[byteCount];
|
||||
if (ctx.Memory.TryRead(address, pixels))
|
||||
{
|
||||
VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels);
|
||||
GuestGpu.Current.SubmitGuestImageWrite(address, pixels);
|
||||
if (Interlocked.Increment(ref _guestImageSyncTraceCount) <= 64)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
@@ -4109,7 +4104,7 @@ public static partial class AgcExports
|
||||
ulong byteCount,
|
||||
uint? fillValue)
|
||||
{
|
||||
var hasImage = VulkanVideoPresenter.TryGetGuestImageExtent(
|
||||
var hasImage = GuestGpu.Current.TryGetGuestImageExtent(
|
||||
destinationAddress,
|
||||
out var width,
|
||||
out var height,
|
||||
@@ -4133,14 +4128,14 @@ public static partial class AgcExports
|
||||
|
||||
if (fillValue is { } fill)
|
||||
{
|
||||
VulkanVideoPresenter.SubmitGuestImageFill(destinationAddress, fill);
|
||||
GuestGpu.Current.SubmitGuestImageFill(destinationAddress, fill);
|
||||
return;
|
||||
}
|
||||
|
||||
var pixels = new byte[imageBytes];
|
||||
if (ctx.Memory.TryRead(destinationAddress, pixels))
|
||||
{
|
||||
VulkanVideoPresenter.SubmitGuestImageWrite(destinationAddress, pixels);
|
||||
GuestGpu.Current.SubmitGuestImageWrite(destinationAddress, pixels);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4528,6 +4523,17 @@ public static partial class AgcExports
|
||||
? fallbackMs
|
||||
: 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
|
||||
// function for both the AGC NOP-encapsulated (RWaitMem32/64) and the standard
|
||||
// ItWaitRegMem packet layouts.
|
||||
@@ -4592,6 +4598,85 @@ public static partial class AgcExports
|
||||
// Returns true when the DCB should suspend parsing at this wait (its
|
||||
// continuation was registered into GpuWaitRegistry); false to keep parsing
|
||||
// (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(
|
||||
CpuContext ctx,
|
||||
SubmittedDcbState state,
|
||||
@@ -4755,38 +4840,45 @@ public static partial class AgcExports
|
||||
SubmittedGpuState gpuState)
|
||||
{
|
||||
var delayMilliseconds = 1;
|
||||
long observedSignal;
|
||||
lock (gpuState.WaitMonitorSignalGate)
|
||||
{
|
||||
observedSignal = gpuState.WaitMonitorSignalVersion;
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
var madeProgress = false;
|
||||
int resumed;
|
||||
int remaining;
|
||||
lock (gpuState.Gate)
|
||||
{
|
||||
var before = GpuWaitRegistry.CountForMemory(ctx.Memory);
|
||||
if (before == 0)
|
||||
{
|
||||
gpuState.WaitMonitorRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
DrainResumableDcbs(ctx, gpuState, tracePackets: _traceAgc);
|
||||
var after = GpuWaitRegistry.CountForMemory(ctx.Memory);
|
||||
madeProgress = after < before;
|
||||
if (madeProgress)
|
||||
resumed = DrainResumableDcbs(ctx, gpuState, tracePackets: _traceAgc);
|
||||
remaining = GpuWaitRegistry.CountForMemory(ctx.Memory);
|
||||
if (_traceAgc && resumed != 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] agc.wait_monitor_resumed count={before - after} " +
|
||||
$"remaining={after}");
|
||||
$"[LOADER][TRACE] agc.wait_monitor_resumed count={resumed} " +
|
||||
$"remaining={remaining}");
|
||||
}
|
||||
if (after == 0)
|
||||
if (remaining == 0)
|
||||
{
|
||||
gpuState.WaitMonitorRunning = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
delayMilliseconds = madeProgress
|
||||
delayMilliseconds = resumed != 0
|
||||
? 1
|
||||
: Math.Min(delayMilliseconds * 2, 16);
|
||||
Thread.Sleep(delayMilliseconds);
|
||||
lock (gpuState.WaitMonitorSignalGate)
|
||||
{
|
||||
if (gpuState.WaitMonitorSignalVersion == observedSignal)
|
||||
{
|
||||
Monitor.Wait(gpuState.WaitMonitorSignalGate, delayMilliseconds);
|
||||
}
|
||||
|
||||
observedSignal = gpuState.WaitMonitorSignalVersion;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4840,16 +4932,17 @@ public static partial class AgcExports
|
||||
// guest memory (labels are advanced by ReleaseMem/WriteData/DmaData packets
|
||||
// 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.
|
||||
private static void DrainResumableDcbs(
|
||||
private static int DrainResumableDcbs(
|
||||
CpuContext ctx,
|
||||
SubmittedGpuState gpuState,
|
||||
bool tracePackets)
|
||||
{
|
||||
if (!_gpuWaitSuspendEnabled)
|
||||
{
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
|
||||
var resumedCount = 0;
|
||||
for (var pass = 0; pass < 256; pass++)
|
||||
{
|
||||
var woken = GpuWaitRegistry.CollectSatisfied(ctx.Memory, (address, is64Bit) =>
|
||||
@@ -4857,7 +4950,50 @@ public static partial class AgcExports
|
||||
? TryReadUInt64(ctx, address, out var value64) ? value64 : (ulong?)null
|
||||
: TryReadUInt32(ctx, address, out var value32) ? value32 : (ulong?)null);
|
||||
|
||||
if (woken is null)
|
||||
// Indirect-dispatch dimension retries whose deadline elapsed are
|
||||
// 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 &&
|
||||
GpuWaitRegistry.CollectUnreportedStale(
|
||||
@@ -4884,14 +5020,20 @@ public static partial class AgcExports
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
return resumedCount;
|
||||
}
|
||||
|
||||
foreach (var waiter in woken)
|
||||
if (woken is not null)
|
||||
{
|
||||
ResumeSuspendedDcb(ctx, gpuState, waiter, tracePackets);
|
||||
foreach (var waiter in woken)
|
||||
{
|
||||
ResumeSuspendedDcb(ctx, gpuState, waiter, tracePackets);
|
||||
resumedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resumedCount;
|
||||
}
|
||||
|
||||
private static void ResumeSuspendedDcb(
|
||||
@@ -5033,6 +5175,15 @@ public static partial class AgcExports
|
||||
_ => 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)
|
||||
{
|
||||
TraceAgc(
|
||||
@@ -5098,6 +5249,16 @@ public static partial class AgcExports
|
||||
_ => 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)
|
||||
{
|
||||
TraceAgc(
|
||||
@@ -5398,7 +5559,7 @@ public static partial class AgcExports
|
||||
state.KnownRenderTargets[resolveSource.Address] = resolveSource;
|
||||
state.KnownRenderTargets[resolveDestination.Address] = resolveDestination;
|
||||
ProvideRenderTargetInitialData(ctx, resolveSource);
|
||||
if (VulkanVideoPresenter.TrySubmitGuestImageBlit(
|
||||
if (GuestGpu.Current.TrySubmitGuestImageBlit(
|
||||
resolveSource.Address,
|
||||
resolveSource.Width,
|
||||
resolveSource.Height,
|
||||
@@ -5709,7 +5870,7 @@ public static partial class AgcExports
|
||||
var cacheKey = (
|
||||
exportShaderAddress,
|
||||
exportFingerprint,
|
||||
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment);
|
||||
_storageBufferOffsetAlignment);
|
||||
_depthOnlyVertexShaderCache.TryGetValue(cacheKey, out var vertexShader);
|
||||
|
||||
if (vertexShader is null)
|
||||
@@ -5734,7 +5895,7 @@ public static partial class AgcExports
|
||||
: guestGlobalBufferCount + 1,
|
||||
requiredVertexOutputCount: 0,
|
||||
storageBufferOffsetAlignment:
|
||||
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
|
||||
_storageBufferOffsetAlignment))
|
||||
{
|
||||
ReturnPooledEvaluationArrays(exportEvaluation);
|
||||
return false;
|
||||
@@ -5746,7 +5907,7 @@ public static partial class AgcExports
|
||||
exportFingerprint,
|
||||
vertexShader!,
|
||||
exportState.Program);
|
||||
VulkanVideoPresenter.CountSpirvCompilation();
|
||||
GuestGpu.Current.CountShaderCompilation();
|
||||
_depthOnlyVertexShaderCache.TryAdd(cacheKey, vertexShader!);
|
||||
}
|
||||
|
||||
@@ -6032,7 +6193,7 @@ public static partial class AgcExports
|
||||
attributeCount,
|
||||
psInputEna,
|
||||
psInputAddr,
|
||||
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment);
|
||||
_storageBufferOffsetAlignment);
|
||||
|
||||
var guestGlobalBuffers =
|
||||
pixelEvaluation.GlobalMemoryBindings.Count +
|
||||
@@ -6068,7 +6229,7 @@ public static partial class AgcExports
|
||||
pixelInputEnable: psInputEna,
|
||||
pixelInputAddress: psInputAddr,
|
||||
storageBufferOffsetAlignment:
|
||||
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment) ||
|
||||
_storageBufferOffsetAlignment) ||
|
||||
!GuestGpu.Current.TryCompileVertexShader(
|
||||
exportState,
|
||||
exportEvaluation,
|
||||
@@ -6080,7 +6241,7 @@ public static partial class AgcExports
|
||||
scalarRegisterBufferIndex: _bakeScalars ? -1 : guestGlobalBuffers + 1,
|
||||
requiredVertexOutputCount: (int)GetInterpolatedAttributeCount(pixelState),
|
||||
storageBufferOffsetAlignment:
|
||||
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
|
||||
_storageBufferOffsetAlignment))
|
||||
{
|
||||
ReturnPooledEvaluationArrays(exportEvaluation);
|
||||
ReturnPooledEvaluationArrays(pixelEvaluation);
|
||||
@@ -6100,7 +6261,7 @@ public static partial class AgcExports
|
||||
pixelStateFingerprint,
|
||||
compiled.Pixel,
|
||||
pixelState.Program);
|
||||
VulkanVideoPresenter.CountSpirvCompilation();
|
||||
GuestGpu.Current.CountShaderCompilation();
|
||||
_graphicsShaderCache.TryAdd(shaderKey, compiled);
|
||||
}
|
||||
|
||||
@@ -6378,7 +6539,7 @@ public static partial class AgcExports
|
||||
var bytesPerIndex = is32Bit ? sizeof(uint) : sizeof(ushort);
|
||||
var byteOffset = checked((ulong)state.DrawIndexOffset * (uint)bytesPerIndex);
|
||||
var byteCount = checked((int)(indexCount * (uint)bytesPerIndex));
|
||||
var data = VulkanVideoPresenter.GuestDataPool.Rent(byteCount);
|
||||
var data = GuestDataPool.Shared.Rent(byteCount);
|
||||
var span = data.AsSpan(0, byteCount);
|
||||
var address = state.IndexBufferAddress + byteOffset;
|
||||
if (ctx.Memory.TryRead(address, span) ||
|
||||
@@ -6387,7 +6548,7 @@ public static partial class AgcExports
|
||||
return new GuestIndexBuffer(data, byteCount, is32Bit, Pooled: true);
|
||||
}
|
||||
|
||||
VulkanVideoPresenter.GuestDataPool.Return(data);
|
||||
GuestDataPool.Shared.Return(data);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6414,7 +6575,7 @@ public static partial class AgcExports
|
||||
var byteOffset = checked((ulong)state.DrawIndexOffset * (uint)bytesPerIndex);
|
||||
var address = state.IndexBufferAddress + byteOffset;
|
||||
const int chunkBytes = 64 * 1024;
|
||||
var scratch = VulkanVideoPresenter.GuestDataPool.Rent(chunkBytes);
|
||||
var scratch = GuestDataPool.Shared.Rent(chunkBytes);
|
||||
var remaining = drawCount;
|
||||
var maxIndex = 0u;
|
||||
var sawIndex = false;
|
||||
@@ -6456,7 +6617,7 @@ public static partial class AgcExports
|
||||
}
|
||||
finally
|
||||
{
|
||||
VulkanVideoPresenter.GuestDataPool.Return(scratch);
|
||||
GuestDataPool.Shared.Return(scratch);
|
||||
}
|
||||
|
||||
var indexedRecords = sawIndex && maxIndex != uint.MaxValue
|
||||
@@ -6580,7 +6741,7 @@ public static partial class AgcExports
|
||||
{
|
||||
hash = (hash ^ (
|
||||
binding.BaseAddress &
|
||||
(VulkanVideoPresenter.GuestStorageBufferOffsetAlignment - 1))) * prime;
|
||||
(_storageBufferOffsetAlignment - 1))) * prime;
|
||||
}
|
||||
|
||||
if (evaluation.ComputeSystemRegisters is { } computeSystemRegisters)
|
||||
@@ -6670,7 +6831,8 @@ public static partial class AgcExports
|
||||
scissor,
|
||||
DecodeViewport(registers, target.Width, target.Height, scissor),
|
||||
DecodeRasterState(registers),
|
||||
DecodeDepthState(registers));
|
||||
DecodeDepthState(registers),
|
||||
DecodeBlendConstant(registers));
|
||||
}
|
||||
|
||||
private static GuestRenderState CreateRenderState(
|
||||
@@ -6703,7 +6865,8 @@ public static partial class AgcExports
|
||||
scissor,
|
||||
DecodeViewport(registers, target.Width, target.Height, scissor),
|
||||
DecodeRasterState(registers),
|
||||
DecodeDepthState(registers));
|
||||
DecodeDepthState(registers),
|
||||
DecodeBlendConstant(registers));
|
||||
}
|
||||
|
||||
// DB_DEPTH_CONTROL (context register 0x200): Z_ENABLE bit1, Z_WRITE_ENABLE
|
||||
@@ -6808,6 +6971,22 @@ public static partial class AgcExports
|
||||
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(
|
||||
IReadOnlyDictionary<uint, uint> registers,
|
||||
uint slot)
|
||||
@@ -7320,7 +7499,7 @@ public static partial class AgcExports
|
||||
IReadOnlyList<uint> registers,
|
||||
IReadOnlyList<Gen5GlobalMemoryBinding> bindings)
|
||||
{
|
||||
var bytes = VulkanVideoPresenter.GuestDataPool.Rent(
|
||||
var bytes = GuestDataPool.Shared.Rent(
|
||||
GetRuntimeScalarBufferLength(bindings.Count));
|
||||
PackRuntimeScalarStateInto(bytes, registers, bindings);
|
||||
return bytes;
|
||||
@@ -7346,7 +7525,7 @@ public static partial class AgcExports
|
||||
{
|
||||
var byteBias = checked((uint)(
|
||||
bindings[index].BaseAddress &
|
||||
(VulkanVideoPresenter.GuestStorageBufferOffsetAlignment - 1)));
|
||||
(_storageBufferOffsetAlignment - 1)));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
bytes.AsSpan(biasOffset + index * sizeof(uint), sizeof(uint)),
|
||||
byteBias);
|
||||
@@ -7389,7 +7568,7 @@ public static partial class AgcExports
|
||||
{
|
||||
if (binding.DataPooled && returned.Add(binding.Data))
|
||||
{
|
||||
VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
|
||||
GuestDataPool.Shared.Return(binding.Data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7399,7 +7578,7 @@ public static partial class AgcExports
|
||||
{
|
||||
if (binding.DataPooled && returned.Add(binding.Data))
|
||||
{
|
||||
VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
|
||||
GuestDataPool.Shared.Return(binding.Data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7425,7 +7604,7 @@ public static partial class AgcExports
|
||||
{
|
||||
if (binding.DataPooled && returned.Add(binding.Data))
|
||||
{
|
||||
VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
|
||||
GuestDataPool.Shared.Return(binding.Data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7436,7 +7615,7 @@ public static partial class AgcExports
|
||||
{
|
||||
if (binding.DataPooled && returned.Add(binding.Data))
|
||||
{
|
||||
VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
|
||||
GuestDataPool.Shared.Return(binding.Data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7444,7 +7623,7 @@ public static partial class AgcExports
|
||||
if (index && draw.IndexBuffer is { Pooled: true } indexBuffer &&
|
||||
returned.Add(indexBuffer.Data))
|
||||
{
|
||||
VulkanVideoPresenter.GuestDataPool.Return(indexBuffer.Data);
|
||||
GuestDataPool.Shared.Return(indexBuffer.Data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7701,7 +7880,7 @@ public static partial class AgcExports
|
||||
|
||||
if (!isStorage &&
|
||||
descriptor.Address != 0 &&
|
||||
VulkanVideoPresenter.IsGuestImageAvailable(
|
||||
GuestGpu.Current.IsGpuGuestImageAvailable(
|
||||
descriptor.Address,
|
||||
descriptor.Format,
|
||||
descriptor.NumberType))
|
||||
@@ -7730,7 +7909,7 @@ public static partial class AgcExports
|
||||
{
|
||||
var initialPixels = Array.Empty<byte>();
|
||||
var uploadKnown = descriptor.Address != 0 &&
|
||||
VulkanVideoPresenter.IsGuestImageUploadKnown(
|
||||
GuestGpu.Current.IsGuestImageUploadKnown(
|
||||
descriptor.Address,
|
||||
descriptor.Format,
|
||||
descriptor.NumberType);
|
||||
@@ -7811,8 +7990,8 @@ public static partial class AgcExports
|
||||
if (!_textureCopySkipDisabled &&
|
||||
descriptor.Address != 0 &&
|
||||
!SharpEmu.HLE.GuestImageWriteTracker.PeekDirty(descriptor.Address) &&
|
||||
VulkanVideoPresenter.IsTextureContentCached(
|
||||
new VulkanVideoPresenter.TextureContentIdentity(
|
||||
GuestGpu.Current.IsTextureContentCached(
|
||||
new TextureContentIdentity(
|
||||
descriptor.Address,
|
||||
descriptor.Width,
|
||||
descriptor.Height,
|
||||
@@ -7915,7 +8094,7 @@ public static partial class AgcExports
|
||||
CpuContext ctx,
|
||||
RenderTargetDescriptor target)
|
||||
{
|
||||
if (!VulkanVideoPresenter.GuestImageWantsInitialData(target.Address))
|
||||
if (!GuestGpu.Current.GuestImageWantsInitialData(target.Address))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -7941,7 +8120,7 @@ public static partial class AgcExports
|
||||
|
||||
if (nonZero)
|
||||
{
|
||||
VulkanVideoPresenter.ProvideGuestImageInitialData(target.Address, initialData);
|
||||
GuestGpu.Current.ProvideGuestImageInitialData(target.Address, initialData);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8238,9 +8417,14 @@ public static partial class AgcExports
|
||||
ulong packetAddress,
|
||||
uint packetLength,
|
||||
uint opcode,
|
||||
out ComputeDispatch dispatch)
|
||||
out ComputeDispatch dispatch,
|
||||
out ulong indirectDimsRetryAddress)
|
||||
{
|
||||
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;
|
||||
uint initiator;
|
||||
string dispatchSource;
|
||||
@@ -8289,6 +8473,17 @@ public static partial class AgcExports
|
||||
|
||||
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(
|
||||
dimensionsAddress,
|
||||
initiator,
|
||||
@@ -8623,7 +8818,7 @@ public static partial class AgcExports
|
||||
// still queued, so the clear could erase newly constructed CPU
|
||||
// objects. Waiting on the work sequence also retires preceding
|
||||
// Vulkan writes before the next evaluator snapshot is captured.
|
||||
if (!VulkanVideoPresenter.WaitForGuestWork(semanticCopySequence))
|
||||
if (!GuestGpu.Current.WaitForGuestWork(semanticCopySequence))
|
||||
{
|
||||
computeError =
|
||||
$"semantic-global-write-sync-timeout sequence={semanticCopySequence}";
|
||||
@@ -8641,7 +8836,7 @@ public static partial class AgcExports
|
||||
localSizeY,
|
||||
localSizeZ,
|
||||
dispatch.WaveLaneCount,
|
||||
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment);
|
||||
_storageBufferOffsetAlignment);
|
||||
var guestGlobalBufferCount = evaluation.GlobalMemoryBindings.Count;
|
||||
var totalGlobalBufferCount = _bakeScalars
|
||||
? guestGlobalBufferCount
|
||||
@@ -8663,7 +8858,7 @@ public static partial class AgcExports
|
||||
: guestGlobalBufferCount,
|
||||
waveLaneCount: dispatch.WaveLaneCount,
|
||||
storageBufferOffsetAlignment:
|
||||
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
|
||||
_storageBufferOffsetAlignment))
|
||||
{
|
||||
DumpCompiledShader(
|
||||
"cs",
|
||||
@@ -8683,7 +8878,7 @@ public static partial class AgcExports
|
||||
out _);
|
||||
var globalMemoryBuffers =
|
||||
CreateTranslatedComputeGlobalBuffers(evaluation);
|
||||
var workSequence = GuestGpu.Current.SubmitComputeDispatch(
|
||||
GuestGpu.Current.SubmitComputeDispatch(
|
||||
shaderAddress,
|
||||
computeShader,
|
||||
textures,
|
||||
@@ -8702,12 +8897,9 @@ public static partial class AgcExports
|
||||
dispatch.ThreadCountX,
|
||||
dispatch.ThreadCountY,
|
||||
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;
|
||||
if (writesGlobalMemory &&
|
||||
!VulkanVideoPresenter.WaitForGuestWork(workSequence))
|
||||
{
|
||||
computeError = $"global-write-sync-timeout sequence={workSequence}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8898,7 +9090,7 @@ public static partial class AgcExports
|
||||
}
|
||||
|
||||
var destinationAddress = destination.BaseAddress;
|
||||
workSequence = VulkanVideoPresenter.SubmitOrderedGuestAction(
|
||||
workSequence = GuestGpu.Current.SubmitOrderedGuestAction(
|
||||
() =>
|
||||
{
|
||||
if (!ctx.Memory.TryWrite(destinationAddress, output))
|
||||
@@ -8912,7 +9104,7 @@ public static partial class AgcExports
|
||||
GuestImageWriteTracker.Track(
|
||||
destinationAddress,
|
||||
(ulong)output.Length,
|
||||
VulkanVideoPresenter.CurrentGuestWorkSequenceForDiagnostics,
|
||||
GuestGpu.Current.CurrentGuestWorkSequenceForDiagnostics,
|
||||
"agc.masked-dword-copy");
|
||||
},
|
||||
$"masked_dword_copy dst=0x{destinationAddress:X16} bytes={output.Length}");
|
||||
@@ -9601,7 +9793,7 @@ public static partial class AgcExports
|
||||
pixelInputEnable: psInputEna,
|
||||
pixelInputAddress: psInputAddr,
|
||||
storageBufferOffsetAlignment:
|
||||
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
|
||||
_storageBufferOffsetAlignment))
|
||||
{
|
||||
TraceAgcShader(
|
||||
$"agc.shader_spirv ps=0x{pixelShaderAddress:X16} " +
|
||||
@@ -11225,4 +11417,35 @@ public static partial class AgcExports
|
||||
TraceAgc($"agc.driver_unregister_resource handle={resourceHandle}");
|
||||
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,6 +3,7 @@
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using SharpEmu.Libs.Gpu;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
@@ -26,8 +27,8 @@ internal static class AgcShaderCompilerHooks
|
||||
internal static void Install()
|
||||
{
|
||||
Gen5ShaderScalarEvaluator.FallbackMemoryReader =
|
||||
KernelMemoryCompatExports.TryReadTrackedLibcHeap;
|
||||
KernelMemoryCompatExports.TryReadShaderGuestMemory;
|
||||
Gen5ShaderScalarEvaluator.GlobalMemoryPool =
|
||||
VulkanVideoPresenter.GuestDataPool;
|
||||
GuestDataPool.Shared;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,26 @@ internal static class GpuWaitRegistry
|
||||
public long RegisteredTicks;
|
||||
public bool StaleReported;
|
||||
public object? State;
|
||||
// Latched by LatchSatisfiedByValue when a producer wrote a value that
|
||||
// satisfies this waiter. The label is frequently reused (reset to 0 for
|
||||
// the next frame) immediately after the producing write, so re-reading
|
||||
// guest memory at wake time can miss the transient satisfied window.
|
||||
// Latching records satisfaction at the moment of the write instead.
|
||||
public bool Latched;
|
||||
// Non-zero for indirect-dispatch dimension retries: a bounded deadline
|
||||
// (Stopwatch ticks) after which the waiter is resumed even if unsatisfied,
|
||||
// so a legitimately empty indirect dispatch can never stall forever.
|
||||
public long RetryDeadlineTicks;
|
||||
}
|
||||
|
||||
private static readonly object _gate = new();
|
||||
private static readonly Dictionary<ulong, List<WaitingDcb>> _waiters = new();
|
||||
// The last value each label producer wrote. Used only by the deadlock
|
||||
// breaker: our serial submission parser cannot model two GPU queues running
|
||||
// concurrently, so a label written -> reset -> re-waited across queues can
|
||||
// cycle forever even though a real producer did signal it. Keyed by (memory,
|
||||
// address) so distinct guest processes never alias.
|
||||
private static readonly Dictionary<(object, ulong), ulong> _lastProduced = new();
|
||||
|
||||
public static int Count
|
||||
{
|
||||
@@ -114,8 +130,14 @@ internal static class GpuWaitRegistry
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = readValue(address, list[i].Is64Bit);
|
||||
if (value is null || !Compare(list[i], value.Value))
|
||||
var satisfied = list[i].Latched;
|
||||
if (!satisfied)
|
||||
{
|
||||
var value = readValue(address, list[i].Is64Bit);
|
||||
satisfied = value is not null && Compare(list[i], value.Value);
|
||||
}
|
||||
|
||||
if (!satisfied)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -236,6 +258,162 @@ internal static class GpuWaitRegistry
|
||||
return matches;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records satisfaction for every waiter at <paramref name="address"/> whose
|
||||
/// condition is met by <paramref name="value"/> — the value a producer just
|
||||
/// wrote to that label. Called from the ordered producer side effect so a
|
||||
/// same-frame label reset cannot lose the wakeup. The waiters stay registered
|
||||
/// (latched) and are drained by the next CollectSatisfied. Returns true when
|
||||
/// at least one waiter latched, so the caller can trigger a wake pass.
|
||||
/// </summary>
|
||||
public static bool LatchSatisfiedByValue(object memory, ulong address, ulong value)
|
||||
{
|
||||
var latchedAny = false;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_waiters.TryGetValue(address, out var list))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < list.Count; i++)
|
||||
{
|
||||
var waiter = list[i];
|
||||
if (waiter.Latched ||
|
||||
!ReferenceEquals(waiter.Memory, memory) ||
|
||||
!Compare(waiter, value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
waiter.Latched = true;
|
||||
list[i] = waiter;
|
||||
latchedAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
return latchedAny;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes and returns waiters carrying a <see cref="WaitingDcb.RetryDeadlineTicks"/>
|
||||
/// that has elapsed. Used for indirect-dispatch dimension retries: the caller
|
||||
/// resumes them so a genuinely empty dispatch (dims that never become non-zero)
|
||||
/// is dropped after a bounded wait instead of stalling the queue forever.
|
||||
/// </summary>
|
||||
public static List<WaitingDcb>? CollectExpiredRetries(object memory, long nowTicks)
|
||||
{
|
||||
List<WaitingDcb>? expired = null;
|
||||
lock (_gate)
|
||||
{
|
||||
List<ulong>? emptied = null;
|
||||
foreach (var (address, list) in _waiters)
|
||||
{
|
||||
for (var i = list.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var waiter = list[i];
|
||||
if (waiter.RetryDeadlineTicks == 0 ||
|
||||
!ReferenceEquals(waiter.Memory, memory) ||
|
||||
nowTicks < waiter.RetryDeadlineTicks)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
expired ??= new List<WaitingDcb>();
|
||||
expired.Add(waiter);
|
||||
list.RemoveAt(i);
|
||||
}
|
||||
|
||||
if (list.Count == 0)
|
||||
{
|
||||
emptied ??= new List<ulong>();
|
||||
emptied.Add(address);
|
||||
}
|
||||
}
|
||||
|
||||
if (emptied is not null)
|
||||
{
|
||||
foreach (var address in emptied)
|
||||
{
|
||||
_waiters.Remove(address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return expired;
|
||||
}
|
||||
|
||||
/// <summary>Records the value a label producer wrote, for the deadlock
|
||||
/// breaker. Also latches any already-waiting waiter it satisfies.</summary>
|
||||
public static bool RecordProduced(object memory, ulong address, ulong value)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_lastProduced.Count >= 8192)
|
||||
{
|
||||
_lastProduced.Clear();
|
||||
}
|
||||
|
||||
_lastProduced[(memory, address)] = value;
|
||||
}
|
||||
|
||||
return LatchSatisfiedByValue(memory, address, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Breaks cross-queue GPU deadlocks the serial parser cannot avoid: returns
|
||||
/// (and removes) waiters that have been stuck longer than
|
||||
/// <paramref name="minAgeTicks"/> and whose condition is satisfied by the
|
||||
/// last value a real producer wrote to their label — even though guest
|
||||
/// memory has since been reset. Never fabricates a value: a waiter is only
|
||||
/// released when an actual producer signalled it at least once.
|
||||
/// </summary>
|
||||
public static List<WaitingDcb>? CollectDeadlockBroken(
|
||||
object memory,
|
||||
long nowTicks,
|
||||
long minAgeTicks)
|
||||
{
|
||||
List<WaitingDcb>? broken = null;
|
||||
lock (_gate)
|
||||
{
|
||||
List<ulong>? emptied = null;
|
||||
foreach (var (address, list) in _waiters)
|
||||
{
|
||||
for (var i = list.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var waiter = list[i];
|
||||
if (!ReferenceEquals(waiter.Memory, memory) ||
|
||||
nowTicks - waiter.RegisteredTicks < minAgeTicks ||
|
||||
!_lastProduced.TryGetValue((memory, address), out var produced) ||
|
||||
!Compare(waiter, produced))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
broken ??= new List<WaitingDcb>();
|
||||
broken.Add(waiter);
|
||||
list.RemoveAt(i);
|
||||
}
|
||||
|
||||
if (list.Count == 0)
|
||||
{
|
||||
emptied ??= new List<ulong>();
|
||||
emptied.Add(address);
|
||||
}
|
||||
}
|
||||
|
||||
if (emptied is not null)
|
||||
{
|
||||
foreach (var address in emptied)
|
||||
{
|
||||
_waiters.Remove(address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return broken;
|
||||
}
|
||||
|
||||
public static bool Compare(in WaitingDcb waiter, ulong value)
|
||||
{
|
||||
var masked = value & waiter.Mask;
|
||||
@@ -260,6 +438,7 @@ internal static class GpuWaitRegistry
|
||||
lock (_gate)
|
||||
{
|
||||
_waiters.Clear();
|
||||
_lastProduced.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using SharpEmu.Libs.Kernel;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace SharpEmu.Libs.Ampr;
|
||||
|
||||
@@ -43,17 +44,17 @@ public static class AmprExports
|
||||
{
|
||||
public CachedHostFile(string path)
|
||||
{
|
||||
Stream = new FileStream(
|
||||
Handle = File.OpenHandle(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite | FileShare.Delete,
|
||||
bufferSize: 1024 * 1024,
|
||||
FileOptions.RandomAccess);
|
||||
Length = RandomAccess.GetLength(Handle);
|
||||
}
|
||||
|
||||
public object Gate { get; } = new();
|
||||
public FileStream Stream { get; }
|
||||
public SafeFileHandle Handle { get; }
|
||||
public long Length { get; }
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
@@ -735,13 +736,7 @@ public static class AmprExports
|
||||
return openResult;
|
||||
}
|
||||
|
||||
long fileLength;
|
||||
lock (cachedFile.Gate)
|
||||
{
|
||||
fileLength = cachedFile.Stream.Length;
|
||||
}
|
||||
|
||||
if (fileOffset >= (ulong)fileLength)
|
||||
if (fileOffset >= (ulong)cachedFile.Length)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
@@ -760,12 +755,10 @@ public static class AmprExports
|
||||
}
|
||||
|
||||
var request = (int)Math.Min((ulong)buffer.Length, size - bytesRead);
|
||||
int read;
|
||||
lock (cachedFile.Gate)
|
||||
{
|
||||
cachedFile.Stream.Position = unchecked((long)absoluteOffset);
|
||||
read = cachedFile.Stream.Read(buffer, 0, request);
|
||||
}
|
||||
var read = RandomAccess.Read(
|
||||
cachedFile.Handle,
|
||||
buffer.AsSpan(0, request),
|
||||
unchecked((long)absoluteOffset));
|
||||
|
||||
if (read <= 0)
|
||||
{
|
||||
|
||||
@@ -121,6 +121,33 @@ public static class AppContentExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
// Download data is not emulated as a real quota; report a comfortable
|
||||
// fixed amount of free space so titles never take the "storage full" path.
|
||||
[SysAbiExport(
|
||||
Nid = "Gl6w5i0JokY",
|
||||
ExportName = "sceAppContentDownloadDataGetAvailableSpaceKb",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAppContent")]
|
||||
public static int AppContentDownloadDataGetAvailableSpaceKb(CpuContext ctx)
|
||||
{
|
||||
const ulong availableSpaceKb = 1024UL * 1024UL; // 1 GiB
|
||||
var availableSpaceAddress = ctx[CpuRegister.Rsi];
|
||||
if (availableSpaceAddress == 0)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
Span<byte> spaceBytes = stackalloc byte[sizeof(ulong)];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(spaceBytes, availableSpaceKb);
|
||||
if (!ctx.Memory.TryWrite(availableSpaceAddress, spaceBytes))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
private static bool TryReadUserDefinedParam(uint paramId, out int value)
|
||||
{
|
||||
value = 0;
|
||||
|
||||
@@ -14,6 +14,12 @@ public static class AudioOutExports
|
||||
private static readonly ConcurrentDictionary<int, PortState> Ports = new();
|
||||
private static int _nextPortHandle;
|
||||
|
||||
// Diagnostic: confirm sceAudioOutOutput is actually called and whether the
|
||||
// guest submits real samples or silence. Gated so it costs nothing when off.
|
||||
private static readonly bool _traceOutput = string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_LOG_AUDIO_OUT"), "1", StringComparison.Ordinal);
|
||||
private static long _outputCount;
|
||||
|
||||
private sealed class PortState : IDisposable
|
||||
{
|
||||
private readonly object _paceGate = new();
|
||||
@@ -155,6 +161,37 @@ public static class AudioOutExports
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "GrQ9s4IrNaQ",
|
||||
ExportName = "sceAudioOutGetPortState",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAudioOut")]
|
||||
public static int AudioOutGetPortState(CpuContext ctx)
|
||||
{
|
||||
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var stateAddress = ctx[CpuRegister.Rsi];
|
||||
if (stateAddress == 0 || !Ports.TryGetValue(handle, out var port))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
// SceAudioOutPortState: report a connected primary output at full volume
|
||||
// so pacing/mixing code sees a live port. We do no host rerouting, so
|
||||
// rerouteCounter and flag stay zero.
|
||||
Span<byte> state = stackalloc byte[16];
|
||||
state.Clear();
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(state, 1);
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(
|
||||
state[2..], (ushort)port.Channels);
|
||||
state[7] = 127;
|
||||
if (!ctx.Memory.TryWrite(stateAddress, state))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "QOQtbeDqsT4",
|
||||
ExportName = "sceAudioOutOutput",
|
||||
@@ -166,7 +203,12 @@ public static class AudioOutExports
|
||||
var sourceAddress = ctx[CpuRegister.Rsi];
|
||||
if (!Ports.TryGetValue(handle, out var port))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
// Host shutdown disposes the ports while guest audio threads are
|
||||
// still draining their last buffers; report success so the guest
|
||||
// winds down without a per-buffer error (and its WARN log flood).
|
||||
return ctx.SetReturn(_shutdown
|
||||
? 0
|
||||
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
if (sourceAddress == 0)
|
||||
@@ -183,6 +225,17 @@ public static class AudioOutExports
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
if (_traceOutput)
|
||||
{
|
||||
var n = Interlocked.Increment(ref _outputCount);
|
||||
if (n <= 8 || n % 200 == 0)
|
||||
{
|
||||
var peak = PeakAmplitude(source, port.IsFloat, port.BytesPerSample);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] audioout.output#{n} handle={handle} bytes={source.Length} ch={port.Channels} float={port.IsFloat} vol={port.Volume:F2} peak={peak:F4} backend={(port.Backend is null ? "none" : "coreaudio")}");
|
||||
}
|
||||
}
|
||||
|
||||
if (port.Backend is null)
|
||||
{
|
||||
port.PaceSilence();
|
||||
@@ -266,8 +319,40 @@ public static class AudioOutExports
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
// Peak normalized amplitude [0,1] of an interleaved PCM buffer, used only by
|
||||
// the SHARPEMU_LOG_AUDIO_OUT diagnostic to distinguish real audio from silence.
|
||||
private static float PeakAmplitude(ReadOnlySpan<byte> source, bool isFloat, int bytesPerSample)
|
||||
{
|
||||
var peak = 0f;
|
||||
if (isFloat && bytesPerSample == 4)
|
||||
{
|
||||
for (var i = 0; i + 4 <= source.Length; i += 4)
|
||||
{
|
||||
var v = Math.Abs(System.Buffers.Binary.BinaryPrimitives.ReadSingleLittleEndian(source.Slice(i, 4)));
|
||||
if (v > peak)
|
||||
{
|
||||
peak = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (bytesPerSample == 2)
|
||||
{
|
||||
for (var i = 0; i + 2 <= source.Length; i += 2)
|
||||
{
|
||||
var v = Math.Abs(System.Buffers.Binary.BinaryPrimitives.ReadInt16LittleEndian(source.Slice(i, 2)) / 32768f);
|
||||
if (v > peak)
|
||||
{
|
||||
peak = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return peak;
|
||||
}
|
||||
|
||||
public static void ShutdownAllPorts()
|
||||
{
|
||||
Volatile.Write(ref _shutdown, true);
|
||||
foreach (var handle in Ports.Keys)
|
||||
{
|
||||
if (Ports.TryRemove(handle, out var port))
|
||||
@@ -277,6 +362,8 @@ public static class AudioOutExports
|
||||
}
|
||||
}
|
||||
|
||||
private static bool _shutdown;
|
||||
|
||||
private static bool TryGetFormat(
|
||||
int rawFormat,
|
||||
out int channels,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -29,10 +29,9 @@ internal static class Bink2MovieBridge
|
||||
private static bool _availabilityReported;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the guest should receive a normal "file not found"
|
||||
/// result for a Bink movie. This is the safe default without a decoder:
|
||||
/// games that treat movies as optional fall through to their next state
|
||||
/// rather than submitting an empty Bink GPU texture forever.
|
||||
/// Returns true only when movie skipping was explicitly requested. Without
|
||||
/// a host adapter the guest must be allowed to run the Bink implementation
|
||||
/// statically linked into its executable.
|
||||
/// </summary>
|
||||
internal static bool ShouldSkipGuestMovie(string hostPath) =>
|
||||
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
|
||||
@@ -53,12 +52,18 @@ internal static class Bink2MovieBridge
|
||||
return;
|
||||
}
|
||||
|
||||
if (ResolveMode() == MovieMode.Dummy)
|
||||
var mode = ResolveMode();
|
||||
if (mode == MovieMode.Dummy)
|
||||
{
|
||||
AttachDummyMovieLocked(hostPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode != MovieMode.Native)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var adapter = GetAdapterLocked();
|
||||
if (adapter is null)
|
||||
{
|
||||
@@ -165,16 +170,15 @@ internal static class Bink2MovieBridge
|
||||
return MovieMode.Skip;
|
||||
}
|
||||
|
||||
// With no SDK adapter present, returning "not found" makes optional
|
||||
// cinematics advance. Supplying either an explicit path or the normal
|
||||
// side-by-side adapter enables native playback automatically.
|
||||
// Prefer the optional host adapter when one is supplied. Otherwise let
|
||||
// the game's statically linked Bink implementation consume the file.
|
||||
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE")) ||
|
||||
EnumerateAdapterCandidates().Any(File.Exists))
|
||||
{
|
||||
return MovieMode.Native;
|
||||
}
|
||||
|
||||
return MovieMode.Skip;
|
||||
return MovieMode.Guest;
|
||||
}
|
||||
|
||||
private static void AttachDummyMovieLocked(string hostPath)
|
||||
@@ -335,6 +339,7 @@ internal static class Bink2MovieBridge
|
||||
|
||||
private enum MovieMode
|
||||
{
|
||||
Guest,
|
||||
Skip,
|
||||
Dummy,
|
||||
Native,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers;
|
||||
using System.Numerics;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu;
|
||||
|
||||
/// <summary>
|
||||
/// The pool backing AGC-to-presenter ownership transfers, shared by every backend
|
||||
/// (the AGC layer rents, the presenter returns, so both sides must use one pool).
|
||||
/// Guest draw snapshots churn through a small set of 128 KiB-16 MiB size classes
|
||||
/// thousands of times per second; the process-wide shared pool trims and
|
||||
/// repartitions those large arrays aggressively under GC load, causing hundreds of
|
||||
/// MiB/s of replacement byte[] allocations, so this pool is bounded and non-shared.
|
||||
/// </summary>
|
||||
internal static class GuestDataPool
|
||||
{
|
||||
public static ArrayPool<byte> Shared { get; } = new BoundedByteArrayPool(
|
||||
maxArrayLength: 16 * 1024 * 1024,
|
||||
maxCachedBytes: 256UL * 1024 * 1024,
|
||||
maxArraysPerBucket: 8);
|
||||
|
||||
public static void Trim() => ((BoundedByteArrayPool)Shared).Trim();
|
||||
|
||||
private sealed class BoundedByteArrayPool : ArrayPool<byte>
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly int _maxArrayLength;
|
||||
private readonly ulong _maxCachedBytes;
|
||||
private readonly int _maxArraysPerBucket;
|
||||
private readonly Dictionary<int, Stack<byte[]>> _cachedByBucket = [];
|
||||
private readonly HashSet<byte[]> _leases =
|
||||
new(System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
||||
private ulong _cachedBytes;
|
||||
|
||||
public BoundedByteArrayPool(
|
||||
int maxArrayLength,
|
||||
ulong maxCachedBytes,
|
||||
int maxArraysPerBucket)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArrayLength);
|
||||
ArgumentOutOfRangeException.ThrowIfZero(maxCachedBytes);
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArraysPerBucket);
|
||||
_maxArrayLength = maxArrayLength;
|
||||
_maxCachedBytes = maxCachedBytes;
|
||||
_maxArraysPerBucket = maxArraysPerBucket;
|
||||
}
|
||||
|
||||
public override byte[] Rent(int minimumLength)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(minimumLength);
|
||||
var length = GetAllocationLength(minimumLength);
|
||||
byte[]? array = null;
|
||||
lock (_gate)
|
||||
{
|
||||
if (length <= _maxArrayLength &&
|
||||
_cachedByBucket.TryGetValue(length, out var bucket) &&
|
||||
bucket.TryPop(out array))
|
||||
{
|
||||
_cachedBytes -= (ulong)array.LongLength;
|
||||
}
|
||||
|
||||
array ??= new byte[length];
|
||||
_leases.Add(array);
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
|
||||
public override void Return(byte[] array, bool clearArray = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(array);
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_leases.Remove(array))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (clearArray)
|
||||
{
|
||||
Array.Clear(array);
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (array.Length > _maxArrayLength ||
|
||||
!IsBucketLength(array.Length) ||
|
||||
(ulong)array.LongLength > _maxCachedBytes -
|
||||
Math.Min(_cachedBytes, _maxCachedBytes))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_cachedByBucket.TryGetValue(array.Length, out var bucket))
|
||||
{
|
||||
bucket = new Stack<byte[]>();
|
||||
_cachedByBucket.Add(array.Length, bucket);
|
||||
}
|
||||
|
||||
if (bucket.Count >= _maxArraysPerBucket)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bucket.Push(array);
|
||||
_cachedBytes += (ulong)array.LongLength;
|
||||
}
|
||||
}
|
||||
|
||||
public void Trim()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_cachedByBucket.Clear();
|
||||
_cachedBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private int GetAllocationLength(int minimumLength)
|
||||
{
|
||||
if (minimumLength <= 16)
|
||||
{
|
||||
return 16;
|
||||
}
|
||||
|
||||
if (minimumLength > _maxArrayLength)
|
||||
{
|
||||
return minimumLength;
|
||||
}
|
||||
|
||||
return checked((int)BitOperations.RoundUpToPowerOf2((uint)minimumLength));
|
||||
}
|
||||
|
||||
private static bool IsBucketLength(int length) =>
|
||||
length >= 16 && (length & (length - 1)) == 0;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.Gpu.Metal;
|
||||
using SharpEmu.Libs.Gpu.Vulkan;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu;
|
||||
@@ -8,11 +9,39 @@ namespace SharpEmu.Libs.Gpu;
|
||||
/// <summary>
|
||||
/// Process-wide access point for the guest-GPU backend, mirroring HostPlatform for the
|
||||
/// host seam: static HLE export classes resolve the renderer through <see cref="Current"/>.
|
||||
/// Vulkan is the only backend today; Metal/DX12 slot in here.
|
||||
/// Vulkan is the default everywhere; SHARPEMU_GPU_BACKEND=metal opts into the Metal
|
||||
/// backend (macOS only) while it is being brought up. macOS flips to Metal by default
|
||||
/// once the presenter reaches parity.
|
||||
/// </summary>
|
||||
internal static class GuestGpu
|
||||
{
|
||||
private static readonly Lazy<IGuestGpuBackend> Instance = new(static () => new VulkanGuestGpuBackend());
|
||||
private static readonly Lazy<IGuestGpuBackend> Instance = new(Create);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,20 @@ internal readonly record struct GuestSampler(
|
||||
uint Word2,
|
||||
uint Word3);
|
||||
|
||||
/// <summary>Identity of a texture's content in a backend texture cache, keyed
|
||||
/// entirely on raw guest descriptor values; the AGC layer uses it to skip texel
|
||||
/// copies for content the backend already holds.</summary>
|
||||
internal readonly record struct TextureContentIdentity(
|
||||
ulong Address,
|
||||
uint Width,
|
||||
uint Height,
|
||||
uint Format,
|
||||
uint NumberType,
|
||||
uint DstSelect,
|
||||
uint TileMode,
|
||||
uint Pitch,
|
||||
GuestSampler Sampler);
|
||||
|
||||
internal sealed record GuestMemoryBuffer(
|
||||
ulong BaseAddress,
|
||||
byte[] Data,
|
||||
@@ -122,12 +136,22 @@ internal readonly record struct GuestBlendState(
|
||||
WriteMask: 0xFu);
|
||||
}
|
||||
|
||||
/// <summary>CB_BLEND_RED..ALPHA: the constant color referenced by the
|
||||
/// CONSTANT_COLOR / CONSTANT_ALPHA blend factors. One constant serves every
|
||||
/// render target of a draw; the hardware reset value is transparent black.</summary>
|
||||
internal readonly record struct GuestBlendConstant(
|
||||
float Red,
|
||||
float Green,
|
||||
float Blue,
|
||||
float Alpha);
|
||||
|
||||
internal sealed record GuestRenderState(
|
||||
IReadOnlyList<GuestBlendState> Blends,
|
||||
GuestRect? Scissor,
|
||||
GuestViewport? Viewport,
|
||||
GuestRasterState Raster,
|
||||
GuestDepthState Depth)
|
||||
GuestDepthState Depth,
|
||||
GuestBlendConstant BlendConstant = default)
|
||||
{
|
||||
public static GuestRenderState Default { get; } = new(
|
||||
[GuestBlendState.Default],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
|
||||
namespace SharpEmu.Libs.Gpu;
|
||||
@@ -17,6 +18,10 @@ namespace SharpEmu.Libs.Gpu;
|
||||
/// </summary>
|
||||
internal interface IGuestGpuBackend
|
||||
{
|
||||
/// <summary>Human-readable name of this backend ("Metal", "Vulkan"), shown in
|
||||
/// the window title on macOS where either backend can run.</summary>
|
||||
string BackendName { get; }
|
||||
|
||||
/// <summary>Starts the presenter (window + device) once; safe to call repeatedly.</summary>
|
||||
void EnsureStarted(uint width, uint height);
|
||||
|
||||
@@ -188,4 +193,70 @@ internal interface IGuestGpuBackend
|
||||
/// the guest codes cross the seam and each backend maps them internally.
|
||||
/// </summary>
|
||||
bool TryGetRenderTargetOutputKind(uint dataFormat, uint numberType, out Gen5PixelOutputKind outputKind);
|
||||
|
||||
// Guest work ordering. AGC submissions execute on a single backend consumer in
|
||||
// logical guest-queue order; sequences returned here are backend work tickets.
|
||||
// A backend without a running presenter returns 0 from the Submit* methods and
|
||||
// callers fall back to executing inline.
|
||||
|
||||
/// <summary>Scopes subsequent submissions on this thread to a named guest queue.</summary>
|
||||
IDisposable EnterGuestQueue(string queueName, ulong submissionId);
|
||||
|
||||
/// <summary>Enqueues an action at its exact position in the current guest queue;
|
||||
/// returns its work sequence, or 0 when nothing could be enqueued.</summary>
|
||||
long SubmitOrderedGuestAction(Action action, string debugName);
|
||||
|
||||
/// <summary>Preserves sceAgcDcbWaitUntilSafeForRendering in queue order.</summary>
|
||||
long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex);
|
||||
|
||||
/// <summary>Blocks until the given work sequence completes; false on timeout,
|
||||
/// close, or a non-positive sequence.</summary>
|
||||
bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite);
|
||||
|
||||
/// <summary>Sequence currently executing on the guest-work consumer; diagnostics only.</summary>
|
||||
long CurrentGuestWorkSequenceForDiagnostics { get; }
|
||||
|
||||
// Guest image lifecycle beyond presentation: CPU-visible seeding, writes, and
|
||||
// extent queries the AGC layer uses to keep guest memory and backend images
|
||||
// coherent. Addresses and formats are always raw guest values.
|
||||
|
||||
/// <summary>Whether the image exists on the backend or an already-queued upload
|
||||
/// owns its initialization (a pending image may skip a duplicate upload but is
|
||||
/// not yet a valid flip source).</summary>
|
||||
bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType);
|
||||
|
||||
/// <summary>True when the first draw into this address must seed the backend
|
||||
/// image from guest memory (PS5 render targets alias guest memory, so
|
||||
/// CPU-prefilled pixels are visible before the first draw).</summary>
|
||||
bool GuestImageWantsInitialData(ulong address);
|
||||
|
||||
void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels);
|
||||
|
||||
void SubmitGuestImageFill(ulong address, uint fillValue);
|
||||
|
||||
void SubmitGuestImageWrite(ulong address, byte[] pixels);
|
||||
|
||||
bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount);
|
||||
|
||||
IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents();
|
||||
|
||||
/// <summary>Whether the backend's texture cache already holds this content; lets
|
||||
/// the AGC layer skip copying texels out of guest memory on every draw.</summary>
|
||||
bool IsTextureContentCached(in TextureContentIdentity identity);
|
||||
|
||||
/// <summary>Guest memory handle for backend self-healing (cache misses re-read
|
||||
/// texels directly instead of showing a fallback pattern).</summary>
|
||||
void AttachGuestMemory(ICpuMemory memory);
|
||||
|
||||
/// <summary>Alignment the AGC layer must apply to storage-buffer offsets before
|
||||
/// they cross the seam.</summary>
|
||||
ulong GuestStorageBufferOffsetAlignment { get; }
|
||||
|
||||
/// <summary>Counts a guest shader translation for the perf overlay.</summary>
|
||||
void CountShaderCompilation();
|
||||
|
||||
(long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters();
|
||||
|
||||
/// <summary>Asks a running presenter to close its window.</summary>
|
||||
void RequestClose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// 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";
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
// 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();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
// 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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
// 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
@@ -0,0 +1,180 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// 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,6 +15,8 @@ namespace SharpEmu.Libs.Gpu.Vulkan;
|
||||
/// </summary>
|
||||
internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
|
||||
{
|
||||
public string BackendName => "Vulkan";
|
||||
|
||||
private static readonly IGuestCompiledShader DepthOnlyFragmentShader =
|
||||
new VulkanCompiledGuestShader(SpirvFixedShaders.CreateDepthOnlyFragment());
|
||||
|
||||
@@ -343,6 +345,60 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
|
||||
return false;
|
||||
}
|
||||
|
||||
public IDisposable EnterGuestQueue(string queueName, ulong submissionId) =>
|
||||
VulkanVideoPresenter.EnterGuestQueue(queueName, submissionId);
|
||||
|
||||
public long SubmitOrderedGuestAction(Action action, string debugName) =>
|
||||
VulkanVideoPresenter.SubmitOrderedGuestAction(action, debugName);
|
||||
|
||||
public long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex) =>
|
||||
VulkanVideoPresenter.SubmitOrderedGuestFlipWait(videoOutHandle, displayBufferIndex);
|
||||
|
||||
public bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite) =>
|
||||
VulkanVideoPresenter.WaitForGuestWork(workSequence, timeoutMilliseconds);
|
||||
|
||||
public long CurrentGuestWorkSequenceForDiagnostics =>
|
||||
VulkanVideoPresenter.CurrentGuestWorkSequenceForDiagnostics;
|
||||
|
||||
public bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType) =>
|
||||
VulkanVideoPresenter.IsGuestImageUploadKnown(address, format, numberType);
|
||||
|
||||
public bool GuestImageWantsInitialData(ulong address) =>
|
||||
VulkanVideoPresenter.GuestImageWantsInitialData(address);
|
||||
|
||||
public void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels) =>
|
||||
VulkanVideoPresenter.ProvideGuestImageInitialData(address, rgbaPixels);
|
||||
|
||||
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
|
||||
VulkanVideoPresenter.SubmitGuestImageFill(address, fillValue);
|
||||
|
||||
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
|
||||
VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels);
|
||||
|
||||
public bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) =>
|
||||
VulkanVideoPresenter.TryGetGuestImageExtent(address, out width, out height, out byteCount);
|
||||
|
||||
public IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents() =>
|
||||
VulkanVideoPresenter.GetGuestImageExtents();
|
||||
|
||||
public bool IsTextureContentCached(in TextureContentIdentity identity) =>
|
||||
VulkanVideoPresenter.IsTextureContentCached(identity);
|
||||
|
||||
public void AttachGuestMemory(SharpEmu.HLE.ICpuMemory memory) =>
|
||||
VulkanVideoPresenter.AttachGuestMemory(memory);
|
||||
|
||||
public ulong GuestStorageBufferOffsetAlignment =>
|
||||
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment;
|
||||
|
||||
public void CountShaderCompilation() =>
|
||||
VulkanVideoPresenter.CountSpirvCompilation();
|
||||
|
||||
public (long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters() =>
|
||||
VulkanVideoPresenter.ReadAndResetPerfCounters();
|
||||
|
||||
public void RequestClose() =>
|
||||
VulkanVideoPresenter.RequestClose();
|
||||
|
||||
private static byte[] Spirv(IGuestCompiledShader shader) =>
|
||||
shader is VulkanCompiledGuestShader vulkanShader
|
||||
? vulkanShader.Spirv
|
||||
|
||||
@@ -224,6 +224,21 @@ 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(
|
||||
CpuContext ctx,
|
||||
ulong length,
|
||||
@@ -1096,6 +1111,7 @@ public static partial class KernelMemoryCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
ctx[CpuRegister.Rax] = destination;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
@@ -5994,7 +6010,7 @@ public static partial class KernelMemoryCompatExports
|
||||
return highWaterMark;
|
||||
}
|
||||
|
||||
private static bool TryReadHostMemory(ulong address, Span<byte> destination)
|
||||
private static unsafe bool TryReadHostMemory(ulong address, Span<byte> destination)
|
||||
{
|
||||
if (destination.IsEmpty || !IsHostRangeAccessible(address, (ulong)destination.Length, writeAccess: false))
|
||||
{
|
||||
@@ -6003,9 +6019,7 @@ public static partial class KernelMemoryCompatExports
|
||||
|
||||
try
|
||||
{
|
||||
var temporary = new byte[destination.Length];
|
||||
Marshal.Copy((nint)address, temporary, 0, temporary.Length);
|
||||
temporary.AsSpan().CopyTo(destination);
|
||||
new ReadOnlySpan<byte>((void*)address, destination.Length).CopyTo(destination);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
@@ -6045,6 +6059,41 @@ public static partial class KernelMemoryCompatExports
|
||||
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(
|
||||
ulong packedAddress,
|
||||
Span<byte> destination)
|
||||
@@ -6324,7 +6373,7 @@ public static partial class KernelMemoryCompatExports
|
||||
return value != 0 && (value & (value - 1)) == 0;
|
||||
}
|
||||
|
||||
private static bool TryWriteHostMemory(ulong address, ReadOnlySpan<byte> source)
|
||||
private static unsafe bool TryWriteHostMemory(ulong address, ReadOnlySpan<byte> source)
|
||||
{
|
||||
if (source.IsEmpty || !IsHostRangeAccessible(address, (ulong)source.Length, writeAccess: true))
|
||||
{
|
||||
@@ -6333,8 +6382,7 @@ public static partial class KernelMemoryCompatExports
|
||||
|
||||
try
|
||||
{
|
||||
var temporary = source.ToArray();
|
||||
Marshal.Copy(temporary, 0, (nint)address, temporary.Length);
|
||||
source.CopyTo(new Span<byte>((void*)address, source.Length));
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
@@ -6361,20 +6409,37 @@ public static partial class KernelMemoryCompatExports
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryQueryHostPage(address, out var startInfo) || !HasRequiredProtection(startInfo.Protect, writeAccess))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var endAddress = address + length - 1;
|
||||
if (endAddress == address)
|
||||
var currentAddress = address;
|
||||
while (currentAddress <= endAddress)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (!TryQueryHostPage(currentAddress, out var info) ||
|
||||
!HasRequiredProtection(info.Protect, writeAccess))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryQueryHostPage(endAddress, out var endInfo) || !HasRequiredProtection(endInfo.Protect, writeAccess))
|
||||
{
|
||||
return false;
|
||||
var regionBase = unchecked((ulong)info.BaseAddress);
|
||||
var regionSize = (ulong)info.RegionSize;
|
||||
if (regionSize == 0 ||
|
||||
regionBase > currentAddress ||
|
||||
ulong.MaxValue - regionBase < regionSize)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var regionEnd = regionBase + regionSize;
|
||||
if (regionEnd <= currentAddress)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (regionEnd > endAddress)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
currentAddress = regionEnd;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -696,7 +696,14 @@ public static class KernelPthreadCompatExports
|
||||
}
|
||||
}
|
||||
|
||||
if (state.OwnerThreadId == 0 && state.Waiters.Count == 0)
|
||||
// pthread_mutex_trylock succeeds whenever the mutex is not currently
|
||||
// held; unlike the blocking lock it does not queue behind waiters
|
||||
// (POSIX gives it no fairness obligation). Gating trylock on an empty
|
||||
// wait queue is wrong and, worse, lets a single stale/undrainable
|
||||
// waiter wedge a spin-on-trylock loop forever even though the mutex
|
||||
// is free (owner==0). The blocking lock still honours FIFO so real
|
||||
// blocked waiters are not starved by a barging locker.
|
||||
if (state.OwnerThreadId == 0 && (tryOnly || state.Waiters.Count == 0))
|
||||
{
|
||||
state.OwnerThreadId = currentThreadId;
|
||||
state.RecursionCount = 1;
|
||||
@@ -1234,7 +1241,21 @@ public static class KernelPthreadCompatExports
|
||||
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
|
||||
lock (mutexState)
|
||||
{
|
||||
if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
|
||||
if (mutexState.OwnerThreadId == 0 && mutexState.RecursionCount == 0)
|
||||
{
|
||||
// The guest holds the mutex through a path our host-side tracking
|
||||
// never observed — most commonly libkernel's uncontended userspace
|
||||
// fast-path, which locks the mutex word directly without an HLE
|
||||
// call. Real pthread_cond_wait requires the caller to own the
|
||||
// mutex and does not verify it for normal mutexes, so returning
|
||||
// EPERM here is wrong: it spins the guest and, worse, leaves the
|
||||
// mutex held (the unlock below is skipped), wedging every thread
|
||||
// that later blocks on pthread_mutex_lock. Adopt ownership so the
|
||||
// unlock/wait/re-lock cycle is balanced and releases the mutex.
|
||||
mutexState.OwnerThreadId = currentThreadId;
|
||||
mutexState.RecursionCount = 1;
|
||||
}
|
||||
else if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
|
||||
{
|
||||
return mutexState.OwnerThreadId == currentThreadId
|
||||
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT
|
||||
@@ -1385,6 +1406,31 @@ public static class KernelPthreadCompatExports
|
||||
bool cooperative,
|
||||
string? wakeKey = null)
|
||||
{
|
||||
// A guest thread can have at most one pending acquisition on a mutex —
|
||||
// it is either running or blocked on exactly one wait. If a waiter for
|
||||
// this thread is still queued when it comes back for a fresh
|
||||
// acquisition, that entry is a stale leftover the thread abandoned
|
||||
// (most often a cond_timedwait timeout whose re-acquire hand-off was
|
||||
// lost). Stale entries clog the FIFO head with waiters no thread is
|
||||
// blocked on, so the unlock hand-off wakes a dead wake-key and the
|
||||
// mutex wedges permanently (observed deadlocking Hades: several
|
||||
// re-acquire waiters from one thread piled ahead of a live locker).
|
||||
// Prune any prior entry for this thread before enqueueing the new one.
|
||||
if (threadId != 0)
|
||||
{
|
||||
for (var node = state.Waiters.First; node is not null;)
|
||||
{
|
||||
var next = node.Next;
|
||||
if (node.Value.ThreadId == threadId)
|
||||
{
|
||||
state.Waiters.Remove(node);
|
||||
node.Value.Node = null;
|
||||
}
|
||||
|
||||
node = next;
|
||||
}
|
||||
}
|
||||
|
||||
var waiter = new PthreadMutexWaiter
|
||||
{
|
||||
ThreadId = threadId,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Libs.Kernel;
|
||||
|
||||
// libKernel's address-wait primitives (sceKernelSyncOnAddress*) are the PS5's
|
||||
// futex-style wait/wake: a thread parks on a guest address until another thread
|
||||
// wakes that address. Guest runtimes (seen driving Juicy Realm, PPSA19268)
|
||||
// build their own spinlocks/queues on top of it and call the wait in a hot
|
||||
// loop; left unimplemented, every wait returns immediately and the runtime
|
||||
// busy-spins forever (millions of calls, no forward progress).
|
||||
//
|
||||
// This implements wait/wake over the existing cooperative-block scheduler,
|
||||
// keyed on the address. The real primitive takes a compare value so the wait
|
||||
// only sleeps while the address still holds the expected value; that exact
|
||||
// value is not recovered here, so each wait is given a bounded deadline and
|
||||
// treated as a spurious-wakeup-tolerant park: a genuinely missed wake
|
||||
// self-heals when the deadline expires and the guest re-checks its own
|
||||
// condition, which futex callers already tolerate. A matching wake releases
|
||||
// waiters immediately through the same key.
|
||||
public static class KernelSyncOnAddressCompatExports
|
||||
{
|
||||
// Safety-net poll interval. Real releases come from the wake side (generation
|
||||
// bump + WakeBlockedThreads); this only bounds how long a wait that genuinely
|
||||
// raced/missed its wake stays parked before the guest re-evaluates. Kept
|
||||
// large: a short interval turns every parked waiter into a hot re-poll that
|
||||
// steals scheduler bandwidth from the threads that actually make progress
|
||||
// (including the ones that would issue the wake), so it must be a rare last
|
||||
// resort, not a spin substitute.
|
||||
private static readonly TimeSpan WaitSelfHealTimeout = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
// Per-address host gate for the non-cooperative (host main thread) fallback,
|
||||
// which cannot use the guest-thread scheduler's block mechanism.
|
||||
private static readonly ConcurrentDictionary<ulong, object> _hostAddressGates = new();
|
||||
|
||||
// Per-address wake generation. A wait captures the current generation and
|
||||
// its wake predicate stays unsatisfied (keeps the thread parked) until a
|
||||
// wake bumps it. This is what actually holds the thread blocked: a bare
|
||||
// "always satisfied" predicate is treated as an immediate late-arrival by
|
||||
// the dispatcher's race guard and never yields, leaving the guest to
|
||||
// busy-spin. The generation also closes the register-vs-park race for free:
|
||||
// a wake landing in that window bumps the generation, so the predicate is
|
||||
// already satisfied and the guest correctly resumes at once.
|
||||
private static readonly ConcurrentDictionary<ulong, long> _wakeGenerations = new();
|
||||
|
||||
private static long CurrentGeneration(ulong address) =>
|
||||
_wakeGenerations.TryGetValue(address, out var generation) ? generation : 0;
|
||||
|
||||
private static string WakeKey(ulong address) => $"sceKernelSyncOnAddress:{address:X16}";
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "Hc4CaR6JBL0",
|
||||
ExportName = "sceKernelSyncOnAddressWait",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int SyncOnAddressWait(CpuContext ctx)
|
||||
{
|
||||
var address = ctx[CpuRegister.Rdi];
|
||||
if (address == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
var observedGeneration = CurrentGeneration(address);
|
||||
var deadline = GuestThreadExecution.ComputeDeadlineTimestamp(WaitSelfHealTimeout);
|
||||
|
||||
// Cooperative path: stay parked until a wake bumps this address's
|
||||
// generation (or the deadline expires as a self-heal). The guest
|
||||
// re-evaluates its own condition after resuming.
|
||||
if (GuestThreadExecution.RequestCurrentThreadBlock(
|
||||
ctx,
|
||||
"sceKernelSyncOnAddressWait",
|
||||
WakeKey(address),
|
||||
resumeHandler: () => (int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
wakeHandler: () => CurrentGeneration(address) != observedGeneration,
|
||||
deadline))
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
// Non-cooperative caller (host main thread): bounded host wait so a
|
||||
// missed wake self-heals instead of hanging.
|
||||
var gate = _hostAddressGates.GetOrAdd(address, static _ => new object());
|
||||
lock (gate)
|
||||
{
|
||||
if (CurrentGeneration(address) == observedGeneration)
|
||||
{
|
||||
Monitor.Wait(gate, WaitSelfHealTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "q2y-wDIVWZA",
|
||||
ExportName = "sceKernelSyncOnAddressWake",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int SyncOnAddressWake(CpuContext ctx)
|
||||
{
|
||||
var address = ctx[CpuRegister.Rdi];
|
||||
if (address == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
// rsi carries the number of waiters to release (1 = wake-one, a large
|
||||
// value = wake-all); default to all if it looks unset.
|
||||
var requested = unchecked((long)ctx[CpuRegister.Rsi]);
|
||||
var wakeCount = requested is > 0 and < int.MaxValue ? (int)requested : int.MaxValue;
|
||||
|
||||
// Bump the generation first so a wait that has registered but not yet
|
||||
// parked sees the change and resumes instead of missing this wake.
|
||||
_wakeGenerations.AddOrUpdate(address, 1, static (_, current) => current + 1);
|
||||
|
||||
GuestThreadExecution.Scheduler?.WakeBlockedThreads(WakeKey(address), wakeCount);
|
||||
|
||||
if (_hostAddressGates.TryGetValue(address, out var gate))
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
Monitor.PulseAll(gate);
|
||||
}
|
||||
}
|
||||
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
private static int SetReturn(CpuContext ctx, OrbisGen2Result result)
|
||||
{
|
||||
var value = (int)result;
|
||||
ctx[CpuRegister.Rax] = unchecked((ulong)value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Threading;
|
||||
|
||||
@@ -25,24 +26,44 @@ public static class Ngs2Exports
|
||||
private static long _nextUid;
|
||||
private static long _renderCount;
|
||||
|
||||
private sealed record SystemState(uint Uid);
|
||||
private sealed record RackState(ulong SystemHandle, uint RackId);
|
||||
private sealed record VoiceState(ulong RackHandle, uint VoiceIndex);
|
||||
// NGS2 renders one grain of interleaved float32 per sceNgs2SystemRender.
|
||||
// The grain length defaults to 256 frames (matching the 8192-byte AudioOut
|
||||
// buffers games copy it into) until the title overrides it.
|
||||
private const int DefaultGrainSamples = 256;
|
||||
private const double OutputSampleRate = 48000.0;
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "koBbCMvOKWw",
|
||||
ExportName = "sceNgs2SystemCreate",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNgs2")]
|
||||
public static int Ngs2SystemCreate(CpuContext ctx)
|
||||
private sealed class SystemState
|
||||
{
|
||||
var bufferInfoAddress = ctx[CpuRegister.Rsi];
|
||||
if (!TryReadContextBuffer(ctx, bufferInfoAddress, out var hostBuffer))
|
||||
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 class VoiceState
|
||||
{
|
||||
public VoiceState(ulong rackHandle, uint voiceIndex)
|
||||
{
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
RackHandle = rackHandle;
|
||||
VoiceIndex = voiceIndex;
|
||||
}
|
||||
|
||||
return CreateSystem(ctx, ctx[CpuRegister.Rdx], hostBuffer);
|
||||
public ulong RackHandle { get; }
|
||||
public uint VoiceIndex { get; }
|
||||
|
||||
// Software-mixer playback state. Pcm is the fully decoded mono waveform;
|
||||
// Position is a fractional read cursor advanced at the source/output rate
|
||||
// ratio each output frame.
|
||||
public short[]? Pcm { get; set; }
|
||||
public ulong SourceAddr { get; set; }
|
||||
public int SourceRate { get; set; }
|
||||
public double Position { get; set; }
|
||||
public bool Playing { get; set; }
|
||||
public int LoopStart { get; set; } = -1;
|
||||
public int LoopEnd { get; set; }
|
||||
public float Gain { get; set; } = 1f;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
@@ -58,14 +79,34 @@ public static class Ngs2Exports
|
||||
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 CreateSystem(ctx, outHandleAddress, handle);
|
||||
lock (StateGate)
|
||||
{
|
||||
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(
|
||||
Nid = "u-WrYDaJA3k",
|
||||
ExportName = "sceNgs2SystemDestroy",
|
||||
@@ -94,27 +135,6 @@ public static class Ngs2Exports
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "cLV4aiT9JpA",
|
||||
ExportName = "sceNgs2RackCreate",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNgs2")]
|
||||
public static int Ngs2RackCreate(CpuContext ctx)
|
||||
{
|
||||
var bufferInfoAddress = ctx[CpuRegister.Rcx];
|
||||
if (!TryReadContextBuffer(ctx, bufferInfoAddress, out var hostBuffer))
|
||||
{
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
return CreateRack(
|
||||
ctx,
|
||||
ctx[CpuRegister.Rdi],
|
||||
unchecked((uint)ctx[CpuRegister.Rsi]),
|
||||
ctx[CpuRegister.R8],
|
||||
hostBuffer);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "U546k6orxQo",
|
||||
ExportName = "sceNgs2RackCreateWithAllocator",
|
||||
@@ -138,14 +158,29 @@ public static class Ngs2Exports
|
||||
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 CreateRack(ctx, systemHandle, rackId, outHandleAddress, handle);
|
||||
lock (StateGate)
|
||||
{
|
||||
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(
|
||||
Nid = "lCqD7oycmIM",
|
||||
ExportName = "sceNgs2RackDestroy",
|
||||
@@ -220,14 +255,217 @@ public static class Ngs2Exports
|
||||
LibraryName = "libSceNgs2")]
|
||||
public static int Ngs2VoiceControl(CpuContext ctx)
|
||||
{
|
||||
var voiceHandle = ctx[CpuRegister.Rdi];
|
||||
var paramList = ctx[CpuRegister.Rsi];
|
||||
lock (StateGate)
|
||||
{
|
||||
return SetReturn(
|
||||
ctx,
|
||||
Voices.ContainsKey(ctx[CpuRegister.Rdi]) ? 0 : OrbisNgs2ErrorInvalidVoiceHandle);
|
||||
if (!Voices.ContainsKey(voiceHandle))
|
||||
{
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidVoiceHandle);
|
||||
}
|
||||
}
|
||||
|
||||
if (ShouldTrace())
|
||||
{
|
||||
TraceVoiceParamList(ctx, voiceHandle, paramList);
|
||||
}
|
||||
|
||||
HandleVoiceParams(ctx, voiceHandle, paramList);
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
// Parse the SceNgs2VoiceParamHead command list (header = u32 size, u32 id;
|
||||
// params are laid out contiguously) and apply the ones the mixer needs:
|
||||
// the waveform-blocks param arms a voice with decoded PCM, and the port
|
||||
// matrix param carries its output gain.
|
||||
private static void HandleVoiceParams(CpuContext ctx, ulong voiceHandle, ulong paramList)
|
||||
{
|
||||
if (paramList == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var offset = paramList;
|
||||
for (var guard = 0; guard < 32; guard++)
|
||||
{
|
||||
if (!ctx.TryReadUInt32(offset, out var size) ||
|
||||
!ctx.TryReadUInt32(offset + 4, out var id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (id)
|
||||
{
|
||||
case 0x10000001:
|
||||
ApplyWaveformParam(ctx, voiceHandle, offset);
|
||||
break;
|
||||
case 0x20010001:
|
||||
ApplyPortMatrixParam(ctx, voiceHandle, offset);
|
||||
break;
|
||||
}
|
||||
|
||||
// Advance to the next contiguous block; the game normally sends one
|
||||
// param per call (size==whole block), so stop when size is degenerate.
|
||||
if (size < 8 || size > 0x1000)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
offset += (size + 7) & ~7u;
|
||||
}
|
||||
}
|
||||
|
||||
// Waveform-blocks param: the guest pointer at +8 references a "VAGp"
|
||||
// (PS-ADPCM) container. Decode it once and arm the voice for playback.
|
||||
private static void ApplyWaveformParam(CpuContext ctx, ulong voiceHandle, ulong paramOffset)
|
||||
{
|
||||
if (!ctx.TryReadUInt64(paramOffset + 8, out var dataAddr) || dataAddr <= 0x10000)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (StateGate)
|
||||
{
|
||||
if (Voices.TryGetValue(voiceHandle, out var existing) &&
|
||||
existing.SourceAddr == dataAddr && existing.Pcm is not null)
|
||||
{
|
||||
// Same waveform already armed — don't restart it every frame.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Span<byte> header = stackalloc byte[Ngs2VagDecoder.VagHeaderSize];
|
||||
if (!ctx.Memory.TryRead(dataAddr, header) || !Ngs2VagDecoder.IsVag(header))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var declaredSize = (int)BinaryPrimitives.ReadUInt32BigEndian(header[0x0C..]);
|
||||
var totalBytes = Ngs2VagDecoder.VagHeaderSize + Math.Clamp(declaredSize, 0, 8 * 1024 * 1024);
|
||||
var raw = System.Buffers.ArrayPool<byte>.Shared.Rent(totalBytes);
|
||||
try
|
||||
{
|
||||
if (!ctx.Memory.TryRead(dataAddr, raw.AsSpan(0, totalBytes)) ||
|
||||
!Ngs2VagDecoder.TryDecode(raw.AsSpan(0, totalBytes), out var waveform))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (StateGate)
|
||||
{
|
||||
if (!Voices.TryGetValue(voiceHandle, out var voice))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
voice.Pcm = waveform.Samples;
|
||||
voice.SourceAddr = dataAddr;
|
||||
voice.SourceRate = waveform.SampleRate;
|
||||
voice.LoopStart = waveform.LoopStart;
|
||||
voice.LoopEnd = waveform.LoopEnd > 0 ? waveform.LoopEnd : waveform.Samples.Length;
|
||||
voice.Position = 0;
|
||||
voice.Playing = true;
|
||||
}
|
||||
|
||||
if (ShouldTrace())
|
||||
{
|
||||
var peak = 0;
|
||||
for (var i = 0; i < waveform.Samples.Length; i++)
|
||||
{
|
||||
peak = Math.Max(peak, Math.Abs((int)waveform.Samples[i]));
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] ngs2.arm voice=0x{voiceHandle:X16} addr=0x{dataAddr:X} rate={waveform.SampleRate} samples={waveform.Samples.Length} loop={waveform.LoopStart} peak={peak}");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.Buffers.ArrayPool<byte>.Shared.Return(raw);
|
||||
}
|
||||
}
|
||||
|
||||
// Port matrix param: the first float level is a reasonable proxy for the
|
||||
// voice's output gain until per-channel panning is implemented.
|
||||
private static void ApplyPortMatrixParam(CpuContext ctx, ulong voiceHandle, ulong paramOffset)
|
||||
{
|
||||
if (!ctx.TryReadUInt32(paramOffset + 12, out var levelBits))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var level = BitConverter.UInt32BitsToSingle(levelBits);
|
||||
if (!float.IsFinite(level) || level < 0f || level > 8f)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (StateGate)
|
||||
{
|
||||
if (Voices.TryGetValue(voiceHandle, out var voice))
|
||||
{
|
||||
voice.Gain = level;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empirically dump the SceNgs2VoiceParamHead-chained command list so we can
|
||||
// confirm the real struct layout (size/next/id) against public NGS2 sources
|
||||
// before building the software mixer. Assumed header: u16 size, s16 next
|
||||
// (byte offset to the next block, 0 = end), u32 id.
|
||||
private static void TraceVoiceParamList(CpuContext ctx, ulong voiceHandle, ulong paramList)
|
||||
{
|
||||
if (paramList == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Span<byte> peek = stackalloc byte[32];
|
||||
var offset = paramList;
|
||||
for (int guard = 0; guard < 32; guard++)
|
||||
{
|
||||
if (!ctx.TryReadUInt16(offset, out var size) ||
|
||||
!ctx.TryReadUInt16(offset + 2, out var next) ||
|
||||
!ctx.TryReadUInt32(offset + 4, out var id))
|
||||
{
|
||||
Console.Error.WriteLine($"[LOADER][TRACE] ngs2.voiceparam voice=0x{voiceHandle:X16} @0x{offset:X}: unreadable header");
|
||||
return;
|
||||
}
|
||||
|
||||
peek.Clear();
|
||||
var readable = Math.Min((int)Math.Max((ushort)8, size), peek.Length);
|
||||
ctx.Memory.TryRead(offset, peek[..readable]);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] ngs2.voiceparam voice=0x{voiceHandle:X16} id=0x{id:X} size={size} next={unchecked((short)next)} bytes={Convert.ToHexString(peek[..readable])}");
|
||||
|
||||
// For the waveform-blocks param, follow the embedded pointers and
|
||||
// dump the pointed-to bytes so we can tell PCM16 from ATRAC9.
|
||||
if (id == 0x10000001 && Interlocked.Increment(ref _waveformDumps) <= 8)
|
||||
{
|
||||
for (int po = 8; po + 8 <= readable; po += 8)
|
||||
{
|
||||
if (ctx.TryReadUInt64(offset + (ulong)po, out var ptr) && ptr > 0x10000 &&
|
||||
ctx.Memory.TryRead(ptr, peek))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] ngs2.waveform @+{po} ptr=0x{ptr:X} head={Convert.ToHexString(peek)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var advance = unchecked((short)next);
|
||||
if (advance <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
offset += (ulong)advance;
|
||||
}
|
||||
}
|
||||
|
||||
private static long _waveformDumps;
|
||||
private static long _renderInfoDumps;
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "AbYvTOZ8Pts",
|
||||
ExportName = "sceNgs2VoiceRunCommands",
|
||||
@@ -273,11 +511,32 @@ public static class Ngs2Exports
|
||||
{
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
// SceNgs2RenderBufferInfo: {ptr@0, size@8, waveformType@16,
|
||||
// channelsCount@20}. Mix the armed voices into the leading grain
|
||||
// as interleaved float32 — this is what the game copies to
|
||||
// sceAudioOutOutput, so it is where NGS2 audio must appear.
|
||||
var channels = 2;
|
||||
if (ctx.TryReadUInt32(entryAddress + 20, out var declaredChannels) &&
|
||||
declaredChannels is > 0 and <= 8)
|
||||
{
|
||||
channels = (int)declaredChannels;
|
||||
}
|
||||
|
||||
MixVoicesIntoGrain(ctx, systemHandle, bufferAddress, bufferSize, channels);
|
||||
|
||||
if (ShouldTrace() && Interlocked.Increment(ref _renderInfoDumps) <= 4)
|
||||
{
|
||||
Span<byte> rbi = stackalloc byte[RenderBufferInfoSize];
|
||||
ctx.Memory.TryRead(entryAddress, rbi);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] ngs2.renderbufinfo addr=0x{bufferAddress:X} size={bufferSize} ch={channels} raw={Convert.ToHexString(rbi)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var count = Interlocked.Increment(ref _renderCount);
|
||||
if (ShouldTrace() && (count <= 4 || count % 10_000 == 0))
|
||||
if (ShouldTrace() && (count <= 4 || count % 200 == 0))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] ngs2.render#{count} system=0x{systemHandle:X16} buffers={bufferInfoCount}");
|
||||
@@ -286,6 +545,135 @@ public static class Ngs2Exports
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
// Sum every armed voice belonging to this system into the leading grain of
|
||||
// the render buffer as interleaved float32. The buffer was just zeroed, so
|
||||
// this is a plain additive mix; silence stays silence when nothing plays.
|
||||
private static void MixVoicesIntoGrain(
|
||||
CpuContext ctx, ulong systemHandle, ulong bufferAddress, ulong bufferSize, int channels)
|
||||
{
|
||||
int grain;
|
||||
lock (StateGate)
|
||||
{
|
||||
if (!Systems.TryGetValue(systemHandle, out var system))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
grain = system.GrainSamples;
|
||||
}
|
||||
|
||||
var capacityFrames = (int)Math.Min((ulong)grain, bufferSize / (ulong)(channels * sizeof(float)));
|
||||
if (capacityFrames <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var floatCount = capacityFrames * channels;
|
||||
var accum = ArrayPool<float>.Shared.Rent(floatCount);
|
||||
var mixedAnything = false;
|
||||
try
|
||||
{
|
||||
Array.Clear(accum, 0, floatCount);
|
||||
lock (StateGate)
|
||||
{
|
||||
foreach (var pair in Voices)
|
||||
{
|
||||
var voice = pair.Value;
|
||||
if (!voice.Playing || voice.Pcm is null || voice.Pcm.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!Racks.TryGetValue(voice.RackHandle, out var rack) ||
|
||||
rack.SystemHandle != systemHandle)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
MixOneVoice(accum, capacityFrames, channels, voice);
|
||||
mixedAnything = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (mixedAnything)
|
||||
{
|
||||
WriteGrain(ctx, bufferAddress, accum, floatCount);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<float>.Shared.Return(accum);
|
||||
}
|
||||
}
|
||||
|
||||
// Resample one voice from its source rate to 48 kHz (nearest-sample) and add
|
||||
// it to the front stereo pair. Advances the voice cursor and handles loop /
|
||||
// one-shot end. Must be called under StateGate.
|
||||
private static void MixOneVoice(float[] accum, int frames, int channels, VoiceState voice)
|
||||
{
|
||||
var pcm = voice.Pcm!;
|
||||
var loopEnd = voice.LoopEnd > 0 && voice.LoopEnd <= pcm.Length ? voice.LoopEnd : pcm.Length;
|
||||
var loopStart = voice.LoopStart;
|
||||
var step = voice.SourceRate / OutputSampleRate;
|
||||
var gain = voice.Gain / 32768f;
|
||||
var pos = voice.Position;
|
||||
for (var f = 0; f < frames; f++)
|
||||
{
|
||||
var idx = (int)pos;
|
||||
if (idx >= loopEnd)
|
||||
{
|
||||
if (loopStart >= 0 && loopStart < loopEnd)
|
||||
{
|
||||
pos = loopStart;
|
||||
idx = loopStart;
|
||||
}
|
||||
else
|
||||
{
|
||||
voice.Playing = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (idx < 0 || idx >= pcm.Length)
|
||||
{
|
||||
voice.Playing = false;
|
||||
break;
|
||||
}
|
||||
|
||||
var sample = pcm[idx] * gain;
|
||||
var baseIndex = f * channels;
|
||||
accum[baseIndex] += sample;
|
||||
if (channels > 1)
|
||||
{
|
||||
accum[baseIndex + 1] += sample;
|
||||
}
|
||||
|
||||
pos += step;
|
||||
}
|
||||
|
||||
voice.Position = pos;
|
||||
}
|
||||
|
||||
private static void WriteGrain(CpuContext ctx, ulong address, float[] accum, int count)
|
||||
{
|
||||
var bytes = ArrayPool<byte>.Shared.Rent(count * sizeof(float));
|
||||
try
|
||||
{
|
||||
var span = bytes.AsSpan(0, count * sizeof(float));
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var value = Math.Clamp(accum[i], -1f, 1f);
|
||||
BinaryPrimitives.WriteSingleLittleEndian(span.Slice(i * sizeof(float), sizeof(float)), value);
|
||||
}
|
||||
|
||||
ctx.Memory.TryWrite(address, span);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "pgFAiLR5qT4",
|
||||
ExportName = "sceNgs2SystemQueryBufferSize",
|
||||
@@ -323,7 +711,25 @@ public static class Ngs2Exports
|
||||
ExportName = "sceNgs2SystemSetGrainSamples",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNgs2")]
|
||||
public static int Ngs2SystemSetGrainSamples(CpuContext ctx) => ValidateSystem(ctx);
|
||||
public static int Ngs2SystemSetGrainSamples(CpuContext ctx)
|
||||
{
|
||||
var systemHandle = ctx[CpuRegister.Rdi];
|
||||
var grain = unchecked((int)ctx[CpuRegister.Rsi]);
|
||||
lock (StateGate)
|
||||
{
|
||||
if (!Systems.TryGetValue(systemHandle, out var system))
|
||||
{
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidSystemHandle);
|
||||
}
|
||||
|
||||
if (grain > 0 && grain <= 8192)
|
||||
{
|
||||
system.GrainSamples = grain;
|
||||
}
|
||||
}
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "-tbc2SxQD60",
|
||||
@@ -412,67 +818,6 @@ public static class Ngs2Exports
|
||||
}
|
||||
}
|
||||
|
||||
private static int CreateSystem(CpuContext ctx, ulong outHandleAddress, ulong handle)
|
||||
{
|
||||
if (outHandleAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
|
||||
}
|
||||
|
||||
if (handle == 0 || !ctx.TryWriteUInt64(outHandleAddress, handle))
|
||||
{
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
lock (StateGate)
|
||||
{
|
||||
Systems[handle] = new SystemState(unchecked((uint)Interlocked.Increment(ref _nextUid)));
|
||||
}
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
private static int CreateRack(
|
||||
CpuContext ctx,
|
||||
ulong systemHandle,
|
||||
uint rackId,
|
||||
ulong outHandleAddress,
|
||||
ulong handle)
|
||||
{
|
||||
lock (StateGate)
|
||||
{
|
||||
if (!Systems.ContainsKey(systemHandle))
|
||||
{
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidSystemHandle);
|
||||
}
|
||||
}
|
||||
|
||||
if (outHandleAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
|
||||
}
|
||||
|
||||
if (handle == 0 || !ctx.TryWriteUInt64(outHandleAddress, handle))
|
||||
{
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
lock (StateGate)
|
||||
{
|
||||
Racks[handle] = new RackState(systemHandle, rackId);
|
||||
}
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
private static bool TryReadContextBuffer(CpuContext ctx, ulong address, out ulong hostBuffer)
|
||||
{
|
||||
hostBuffer = 0;
|
||||
return address != 0 &&
|
||||
ctx.TryReadUInt64(address, out hostBuffer) &&
|
||||
hostBuffer != 0;
|
||||
}
|
||||
|
||||
private static bool TryCreateHandle(CpuContext ctx, uint type, ulong ownerHandle, out ulong handle)
|
||||
{
|
||||
handle = 0;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// 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,6 +58,36 @@ public static class NpEntitlementAccessExports
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
private const int EmptyAddcontInfoSize = 0x30;
|
||||
|
||||
// Singular lookup of one add-on-content entitlement (rdx = info out). We own
|
||||
// no DLC, so report an empty/zeroed info and success — matching the list
|
||||
// variant's "no entitlements" answer. Dead Cells calls this while loading a
|
||||
// level; leaving it unresolved left the info struct uninitialized.
|
||||
[SysAbiExport(
|
||||
Nid = "xddD23+8TfQ",
|
||||
ExportName = "sceNpEntitlementAccessGetAddcontEntitlementInfo",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNpEntitlementAccess")]
|
||||
public static int NpEntitlementAccessGetAddcontEntitlementInfo(CpuContext ctx)
|
||||
{
|
||||
var infoAddress = ctx[CpuRegister.Rdx];
|
||||
if (infoAddress != 0)
|
||||
{
|
||||
Span<byte> info = stackalloc byte[EmptyAddcontInfoSize];
|
||||
info.Clear();
|
||||
if (!ctx.Memory.TryWrite(infoAddress, info))
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
}
|
||||
|
||||
TraceNpEntitlementAccess(
|
||||
$"get_addcont_info service=0x{ctx[CpuRegister.Rdi]:X16} label=0x{ctx[CpuRegister.Rsi]:X16} " +
|
||||
$"info=0x{infoAddress:X16} -> empty");
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
private static void TraceNpEntitlementAccess(string message)
|
||||
{
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NP"), "1", StringComparison.Ordinal))
|
||||
|
||||
@@ -197,4 +197,16 @@ public static class NpUniversalDataSystemExports
|
||||
{
|
||||
return ctx.SetReturn(0, typeof(long));
|
||||
}
|
||||
|
||||
// Telemetry property setter (event property array, string value). We do not
|
||||
// upload analytics, so accept and drop it — matching the other Set* stubs.
|
||||
[SysAbiExport(
|
||||
Nid = "4llLk7YJRTE",
|
||||
ExportName = "sceNpUniversalDataSystemEventPropertyArraySetString",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNpUniversalDataSystem")]
|
||||
public static int NpUniversalDataSystemEventPropertyArraySetString(CpuContext ctx)
|
||||
{
|
||||
return ctx.SetReturn(0, typeof(long));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,35 @@ public static class PadExports
|
||||
LibraryName = "libScePad")]
|
||||
public static int PadOpenExt(CpuContext ctx) => PadOpenCore(ctx, extended: true);
|
||||
|
||||
// scePadGetHandle(userId, type, index): returns the handle of an already-open
|
||||
// pad without opening a new one. Dead Cells calls it every frame to poll
|
||||
// input; leaving it unresolved returned a garbage handle so the input path
|
||||
// (and the game loop that drives it) misbehaved. Same validation as
|
||||
// scePadOpen — the one primary pad — returning its handle or a not-connected
|
||||
// error, never opening or logging.
|
||||
[SysAbiExport(
|
||||
Nid = "u1GRHp+oWoY",
|
||||
ExportName = "scePadGetHandle",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libScePad")]
|
||||
public static int PadGetHandle(CpuContext ctx)
|
||||
{
|
||||
var userId = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var type = unchecked((int)ctx[CpuRegister.Rsi]);
|
||||
var index = unchecked((int)ctx[CpuRegister.Rdx]);
|
||||
if (!_initialized)
|
||||
{
|
||||
return ctx.SetReturn(OrbisPadErrorNotInitialized);
|
||||
}
|
||||
|
||||
if (userId != PrimaryUserId || type is not (0 or 1 or 2) || index != 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisPadErrorDeviceNotConnected);
|
||||
}
|
||||
|
||||
return ctx.SetReturn(PrimaryPadHandle);
|
||||
}
|
||||
|
||||
// scePadOpen rejects a non-null 4th arg and non-standard ports; scePadOpenExt accepts a
|
||||
// ScePadOpenExtParam* plus ports 1/2 (racing titles retry scePadOpenExt(type=2) forever if rejected).
|
||||
private static int PadOpenCore(CpuContext ctx, bool extended)
|
||||
@@ -216,6 +245,38 @@ public static class PadExports
|
||||
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "AcslpN1jHR8",
|
||||
ExportName = "scePadDeviceClassGetExtendedInformation",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libScePad")]
|
||||
public static int PadDeviceClassGetExtendedInformation(CpuContext ctx)
|
||||
{
|
||||
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var informationAddress = ctx[CpuRegister.Rsi];
|
||||
if (!IsPrimaryPadHandle(handle))
|
||||
{
|
||||
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
|
||||
}
|
||||
|
||||
if (informationAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
// ScePadDeviceClassExtendedInformation: deviceClass 0 = standard pad
|
||||
// (DualSense). We emulate no special peripheral (guitar/drums/wheel), so
|
||||
// the class-data union stays zeroed — the guest treats it as a plain
|
||||
// controller with no extended capabilities.
|
||||
Span<byte> information = stackalloc byte[0x20];
|
||||
information.Clear();
|
||||
BinaryPrimitives.WriteInt32LittleEndian(information[0x00..], 0);
|
||||
|
||||
return ctx.Memory.TryWrite(informationAddress, information)
|
||||
? ctx.SetReturn(0)
|
||||
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "YndgXqQVV7c",
|
||||
ExportName = "scePadReadState",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,530 @@ public static class SaveDataExports
|
||||
_titleId = string.IsNullOrWhiteSpace(titleId) ? null : SanitizePathSegment(titleId.Trim());
|
||||
_preparedTransactionResources.Clear();
|
||||
}
|
||||
|
||||
lock (_eventGate)
|
||||
{
|
||||
_events.Clear();
|
||||
}
|
||||
|
||||
lock (_mountGate)
|
||||
{
|
||||
_mounts.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Additional error codes and the async-event model (see sceSaveDataGetEventResult).
|
||||
private const int OrbisSaveDataErrorBusy = unchecked((int)0x809F0006);
|
||||
private const int OrbisSaveDataErrorNoEvent = unchecked((int)0x809F0008); // NOT_FOUND: no pending event
|
||||
private const int OrbisSaveDataErrorBadMounted = unchecked((int)0x809F0013);
|
||||
// SceSaveDataEventType
|
||||
private const uint EventTypeUmountBackupEnd = 1;
|
||||
private const uint EventTypeBackupEnd = 2;
|
||||
private const uint EventTypeSaveDataMemorySyncEnd = 3;
|
||||
private const int SaveDataEventSize = 0x60;
|
||||
private const int MountInfoSize = 0x40;
|
||||
private const uint DefaultBlockSize = 32768;
|
||||
private const ulong DefaultTotalBlocks = 0x8000; // 1 GiB of 32 KiB blocks
|
||||
|
||||
private static readonly object _eventGate = new();
|
||||
private static readonly Queue<SaveDataEvent> _events = new();
|
||||
private static readonly object _mountGate = new();
|
||||
// mountPoint -> live mount, for umount/IsMounted/GetMountInfo.
|
||||
private static readonly Dictionary<string, MountEntry> _mounts = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly record struct SaveDataEvent(uint Type, int ErrorCode, int UserId, string DirName);
|
||||
private sealed record MountEntry(string SlotDir, string DirName, int UserId);
|
||||
|
||||
private static void EnqueueEvent(uint type, int userId, string dirName, int errorCode = 0)
|
||||
{
|
||||
lock (_eventGate)
|
||||
{
|
||||
_events.Enqueue(new SaveDataEvent(type, errorCode, userId, dirName));
|
||||
}
|
||||
TraceSaveData($"event.enqueue type={type} user={userId} dir='{dirName}' err=0x{errorCode:X}");
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "j8xKtiFj0SY",
|
||||
ExportName = "sceSaveDataGetEventResult",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataGetEventResult(CpuContext ctx)
|
||||
{
|
||||
// rdi: SceSaveDataEventParam* (filter, ignored). rsi: SceSaveDataEvent* out.
|
||||
var eventAddress = ctx[CpuRegister.Rsi];
|
||||
if (eventAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
SaveDataEvent pending;
|
||||
lock (_eventGate)
|
||||
{
|
||||
if (_events.Count == 0)
|
||||
{
|
||||
// No queued completion. Games poll this from a worker; report the
|
||||
// defined "no event" status so the loop keeps polling instead of
|
||||
// acting on an uninitialized event struct.
|
||||
return SetReturn(ctx, OrbisSaveDataErrorNoEvent);
|
||||
}
|
||||
|
||||
pending = _events.Dequeue();
|
||||
}
|
||||
|
||||
Span<byte> ev = stackalloc byte[SaveDataEventSize];
|
||||
ev.Clear();
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(ev[0x00..], pending.Type);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(ev[0x04..], pending.ErrorCode);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(ev[0x08..], pending.UserId);
|
||||
WriteAscii(ev.Slice(0x10, SaveDataDirNameSize), pending.DirName);
|
||||
if (!ctx.Memory.TryWrite(eventAddress, ev))
|
||||
{
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "hsKd5c21sQc",
|
||||
ExportName = "sceSaveDataRegisterEventCallback",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataRegisterEventCallback(CpuContext ctx) => SetReturn(ctx, 0);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "v-AK1AxQhS0",
|
||||
ExportName = "sceSaveDataUnregisterEventCallback",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataUnregisterEventCallback(CpuContext ctx) => SetReturn(ctx, 0);
|
||||
|
||||
// ---- lifecycle ----
|
||||
[SysAbiExport(Nid = "ZkZhskCPXFw", ExportName = "sceSaveDataInitialize", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataInitialize(CpuContext ctx) => SaveDataInitializeCommon(ctx);
|
||||
|
||||
[SysAbiExport(Nid = "l1NmDeDpNGU", ExportName = "sceSaveDataInitialize2", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataInitialize2(CpuContext ctx) => SaveDataInitializeCommon(ctx);
|
||||
|
||||
private static int SaveDataInitializeCommon(CpuContext ctx)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(ResolveSaveDataRoot());
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return SetReturn(ctx, OrbisSaveDataErrorInternal);
|
||||
}
|
||||
}
|
||||
|
||||
[SysAbiExport(Nid = "yKDy8S5yLA0", ExportName = "sceSaveDataTerminate", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataTerminate(CpuContext ctx) => SetReturn(ctx, 0);
|
||||
|
||||
// ---- mount variants (all share the SceSaveDataMount layout) ----
|
||||
[SysAbiExport(Nid = "32HQAQdwM2o", ExportName = "sceSaveDataMount", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataMount(CpuContext ctx) => SaveDataMount3(ctx);
|
||||
|
||||
[SysAbiExport(Nid = "0z45PIH+SNI", ExportName = "sceSaveDataMount2", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataMount2(CpuContext ctx) => SaveDataMount3(ctx);
|
||||
|
||||
[SysAbiExport(Nid = "xz0YMi6BfNk", ExportName = "sceSaveDataMount5", Target = Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataMount5(CpuContext ctx) => SaveDataMount3(ctx);
|
||||
|
||||
[SysAbiExport(Nid = "BMR4F-Uek3E", ExportName = "sceSaveDataUmount", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataUmount(CpuContext ctx) => SaveDataUmount2(ctx);
|
||||
|
||||
[SysAbiExport(Nid = "ieP6jP138Qo", ExportName = "sceSaveDataIsMounted", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataIsMounted(CpuContext ctx)
|
||||
{
|
||||
var outAddress = ctx[CpuRegister.Rsi];
|
||||
int mountCount;
|
||||
lock (_mountGate)
|
||||
{
|
||||
mountCount = _mounts.Count;
|
||||
}
|
||||
|
||||
if (outAddress != 0)
|
||||
{
|
||||
TryWriteUInt32(ctx, outAddress, mountCount > 0 ? 1u : 0u);
|
||||
}
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
[SysAbiExport(Nid = "65VH0Qaaz6s", ExportName = "sceSaveDataGetMountInfo", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataGetMountInfo(CpuContext ctx)
|
||||
{
|
||||
var mountPointAddress = ctx[CpuRegister.Rdi];
|
||||
var infoAddress = ctx[CpuRegister.Rsi];
|
||||
if (mountPointAddress == 0 || infoAddress == 0 ||
|
||||
!TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint))
|
||||
{
|
||||
return SetReturn(ctx, OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
MountEntry? entry;
|
||||
lock (_mountGate)
|
||||
{
|
||||
_mounts.TryGetValue(mountPoint, out entry);
|
||||
}
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return SetReturn(ctx, OrbisSaveDataErrorBadMounted);
|
||||
}
|
||||
|
||||
var used = SafeDirectorySize(entry.SlotDir);
|
||||
var usedBlocks = (ulong)((used + DefaultBlockSize - 1) / DefaultBlockSize);
|
||||
Span<byte> info = stackalloc byte[MountInfoSize];
|
||||
info.Clear();
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(info[0x00..], DefaultTotalBlocks); // blocks
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(info[0x08..], usedBlocks); // freeBlocks slot reused as used
|
||||
return ctx.Memory.TryWrite(infoAddress, info)
|
||||
? SetReturn(ctx, 0)
|
||||
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
// ---- delete ----
|
||||
[SysAbiExport(Nid = "S1GkePI17zQ", ExportName = "sceSaveDataDelete", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataDelete(CpuContext ctx) => SaveDataDeleteCommon(ctx);
|
||||
|
||||
[SysAbiExport(Nid = "SQWusLoK8Pw", ExportName = "sceSaveDataDelete5", Target = Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataDelete5(CpuContext ctx) => SaveDataDeleteCommon(ctx);
|
||||
|
||||
private static int SaveDataDeleteCommon(CpuContext ctx)
|
||||
{
|
||||
// SceSaveDataDelete: +0x00 userId, +0x08 dirName*, ... (dirName drives the slot).
|
||||
var deleteAddress = ctx[CpuRegister.Rdi];
|
||||
if (deleteAddress == 0 ||
|
||||
!TryReadInt32(ctx, deleteAddress, out var userId) ||
|
||||
!ctx.TryReadUInt64(deleteAddress + 0x08, out var dirNameAddress) ||
|
||||
dirNameAddress == 0 ||
|
||||
!TryReadFixedAscii(ctx, dirNameAddress, SaveDataDirNameSize, out var dirName) ||
|
||||
string.IsNullOrWhiteSpace(dirName))
|
||||
{
|
||||
return SetReturn(ctx, OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var slotDir = SaveDataStorage.SlotDir(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId()), dirName);
|
||||
if (!Directory.Exists(slotDir))
|
||||
{
|
||||
return SetReturn(ctx, OrbisSaveDataErrorNotFound);
|
||||
}
|
||||
|
||||
Directory.Delete(slotDir, recursive: true);
|
||||
TraceSaveData($"delete user={userId} dir='{dirName}' path='{slotDir}'");
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return SetReturn(ctx, OrbisSaveDataErrorInternal);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- params (metadata shown in the save UI) ----
|
||||
[SysAbiExport(Nid = "XgvSuIdnMlw", ExportName = "sceSaveDataGetParam", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataGetParam(CpuContext ctx) => TransferParam(ctx, write: false);
|
||||
|
||||
[SysAbiExport(Nid = "85zul--eGXs", ExportName = "sceSaveDataSetParam", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataSetParam(CpuContext ctx) => TransferParam(ctx, write: true);
|
||||
|
||||
private static int TransferParam(CpuContext ctx, bool write)
|
||||
{
|
||||
// rdi: mount-point string (16 bytes). rsi: paramType. rdx: SceSaveDataParam*. rcx: size.
|
||||
var mountPointAddress = ctx[CpuRegister.Rdi];
|
||||
var paramAddress = ctx[CpuRegister.Rdx];
|
||||
if (mountPointAddress == 0 || paramAddress == 0 ||
|
||||
!TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint))
|
||||
{
|
||||
return SetReturn(ctx, OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
MountEntry? entry;
|
||||
lock (_mountGate)
|
||||
{
|
||||
_mounts.TryGetValue(mountPoint, out entry);
|
||||
}
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return SetReturn(ctx, OrbisSaveDataErrorBadMounted);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (write)
|
||||
{
|
||||
Span<byte> raw = stackalloc byte[SaveDataParamSize];
|
||||
if (!ctx.Memory.TryRead(paramAddress, raw))
|
||||
{
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
var metadata = new SaveDataMetadata
|
||||
{
|
||||
Title = ReadAsciiField(raw.Slice(0x00, 128)),
|
||||
SubTitle = ReadAsciiField(raw.Slice(0x80, 128)),
|
||||
Detail = ReadAsciiField(raw.Slice(0x100, 1024)),
|
||||
UserParam = BinaryPrimitives.ReadUInt32LittleEndian(raw[0x500..]),
|
||||
};
|
||||
SaveDataStorage.WriteMetadata(entry.SlotDir, metadata);
|
||||
TraceSaveData($"set_param mount='{mountPoint}' title='{metadata.Title}'");
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
var loaded = SaveDataStorage.ReadMetadata(entry.SlotDir);
|
||||
var param = new byte[SaveDataParamSize];
|
||||
WriteAscii(param.AsSpan(0x00, 128), loaded.Title);
|
||||
WriteAscii(param.AsSpan(0x80, 128), loaded.SubTitle);
|
||||
WriteAscii(param.AsSpan(0x100, 1024), loaded.Detail);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(param.AsSpan(0x500), loaded.UserParam);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(
|
||||
param.AsSpan(0x508),
|
||||
new DateTimeOffset(SafeLastWriteUtc(entry.SlotDir)).ToUnixTimeSeconds());
|
||||
return ctx.Memory.TryWrite(paramAddress, param)
|
||||
? SetReturn(ctx, 0)
|
||||
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return SetReturn(ctx, OrbisSaveDataErrorInternal);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- icons ----
|
||||
[SysAbiExport(Nid = "c88Yy54Mx0w", ExportName = "sceSaveDataSaveIcon", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataSaveIcon(CpuContext ctx) => TransferIconForMount(ctx, write: true);
|
||||
|
||||
[SysAbiExport(Nid = "cGjO3wM3V28", ExportName = "sceSaveDataLoadIcon", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataLoadIcon(CpuContext ctx) => TransferIconForMount(ctx, write: false);
|
||||
|
||||
private static int TransferIconForMount(CpuContext ctx, bool write)
|
||||
{
|
||||
// rdi: mount-point string. rsi: SceSaveDataIcon* {buf@+0x00, bufSize@+0x08, dataSize@+0x10}.
|
||||
var mountPointAddress = ctx[CpuRegister.Rdi];
|
||||
var iconAddress = ctx[CpuRegister.Rsi];
|
||||
if (mountPointAddress == 0 || iconAddress == 0 ||
|
||||
!TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint) ||
|
||||
!ctx.TryReadUInt64(iconAddress + 0x00, out var bufferAddress) ||
|
||||
!ctx.TryReadUInt64(iconAddress + 0x08, out var bufferSize))
|
||||
{
|
||||
return SetReturn(ctx, OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
MountEntry? entry;
|
||||
lock (_mountGate)
|
||||
{
|
||||
_mounts.TryGetValue(mountPoint, out entry);
|
||||
}
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return SetReturn(ctx, OrbisSaveDataErrorBadMounted);
|
||||
}
|
||||
|
||||
var iconPath = SaveDataStorage.IconPath(entry.SlotDir);
|
||||
try
|
||||
{
|
||||
if (write)
|
||||
{
|
||||
var length = checked((int)Math.Min(bufferSize, (ulong)16 * 1024 * 1024));
|
||||
var bytes = ArrayPool<byte>.Shared.Rent(length);
|
||||
try
|
||||
{
|
||||
if (!ctx.Memory.TryRead(bufferAddress, bytes.AsSpan(0, length)))
|
||||
{
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(iconPath)!);
|
||||
File.WriteAllBytes(iconPath, bytes.AsSpan(0, length).ToArray());
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(bytes);
|
||||
}
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
if (!File.Exists(iconPath))
|
||||
{
|
||||
return SetReturn(ctx, OrbisSaveDataErrorNotFound);
|
||||
}
|
||||
|
||||
var data = File.ReadAllBytes(iconPath);
|
||||
var copy = (int)Math.Min((ulong)data.Length, bufferSize);
|
||||
if (bufferAddress != 0 && copy > 0 && !ctx.Memory.TryWrite(bufferAddress, data.AsSpan(0, copy)))
|
||||
{
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
TryWriteUInt32(ctx, iconAddress + 0x10, (uint)data.Length); // dataSize
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return SetReturn(ctx, OrbisSaveDataErrorInternal);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- size / progress / abort ----
|
||||
[SysAbiExport(Nid = "A1ThglSGUwA", ExportName = "sceSaveDataGetAllSize", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataGetAllSize(CpuContext ctx)
|
||||
{
|
||||
var outAddress = ctx[CpuRegister.Rsi];
|
||||
long total = 0;
|
||||
try
|
||||
{
|
||||
var titleRoot = ResolveTitleSaveRoot(0, ResolveConfiguredTitleId());
|
||||
if (Directory.Exists(titleRoot))
|
||||
{
|
||||
total = SafeDirectorySize(titleRoot);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Report zero on an unreadable tree rather than fail the query.
|
||||
}
|
||||
|
||||
if (outAddress != 0)
|
||||
{
|
||||
var kib = (ulong)((total + 1023) / 1024);
|
||||
ctx.TryWriteUInt64(outAddress, kib);
|
||||
}
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
[SysAbiExport(Nid = "ANmSWUiyyGQ", ExportName = "sceSaveDataGetProgress", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataGetProgress(CpuContext ctx)
|
||||
{
|
||||
// Our operations complete synchronously, so any in-flight progress is 100%.
|
||||
var outAddress = ctx[CpuRegister.Rdi];
|
||||
if (outAddress != 0)
|
||||
{
|
||||
Span<byte> progress = stackalloc byte[8];
|
||||
progress.Clear();
|
||||
BinaryPrimitives.WriteSingleLittleEndian(progress, 1.0f);
|
||||
ctx.Memory.TryWrite(outAddress, progress);
|
||||
}
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
[SysAbiExport(Nid = "Wz-4JZfeO9g", ExportName = "sceSaveDataClearProgress", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataClearProgress(CpuContext ctx) => SetReturn(ctx, 0);
|
||||
|
||||
[SysAbiExport(Nid = "dQ2GohUHXzk", ExportName = "sceSaveDataAbort", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataAbort(CpuContext ctx) => SetReturn(ctx, 0);
|
||||
|
||||
[SysAbiExport(Nid = "eBSSNIG6hMk", ExportName = "sceSaveDataGetEventInfo", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataGetEventInfo(CpuContext ctx) => SetReturn(ctx, 0);
|
||||
|
||||
[SysAbiExport(Nid = "52pL2GKkdjA", ExportName = "sceSaveDataSetEventInfo", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataSetEventInfo(CpuContext ctx) => SetReturn(ctx, 0);
|
||||
|
||||
[SysAbiExport(Nid = "Z7z6HXWORJY", ExportName = "sceSaveDataSaveIconByPath", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataSaveIconByPath(CpuContext ctx) => SetReturn(ctx, 0);
|
||||
|
||||
[SysAbiExport(Nid = "SN7rTPHS+Cg", ExportName = "sceSaveDataGetSaveDataCount", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataGetSaveDataCount(CpuContext ctx)
|
||||
{
|
||||
var outAddress = ctx[CpuRegister.Rsi];
|
||||
var count = 0;
|
||||
try
|
||||
{
|
||||
var titleRoot = ResolveTitleSaveRoot(0, ResolveConfiguredTitleId());
|
||||
if (Directory.Exists(titleRoot))
|
||||
{
|
||||
foreach (var dir in Directory.EnumerateDirectories(titleRoot))
|
||||
{
|
||||
if (!string.Equals(Path.GetFileName(dir), "sce_sdmemory", StringComparison.Ordinal))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Report zero on an unreadable tree.
|
||||
}
|
||||
|
||||
if (outAddress != 0)
|
||||
{
|
||||
TryWriteUInt32(ctx, outAddress, (uint)count);
|
||||
}
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
[SysAbiExport(Nid = "pc4guaUPVqA", ExportName = "sceSaveDataGetMountedSaveDataCount", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataGetMountedSaveDataCount(CpuContext ctx)
|
||||
{
|
||||
var outAddress = ctx[CpuRegister.Rsi];
|
||||
int mounted;
|
||||
lock (_mountGate)
|
||||
{
|
||||
mounted = _mounts.Count;
|
||||
}
|
||||
|
||||
if (outAddress != 0)
|
||||
{
|
||||
TryWriteUInt32(ctx, outAddress, (uint)mounted);
|
||||
}
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
// ---- SaveDataMemory v1 aliases (identical arg layout to the v2 forms) ----
|
||||
[SysAbiExport(Nid = "v7AAAMo0Lz4", ExportName = "sceSaveDataSetupSaveDataMemory", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataSetupSaveDataMemory(CpuContext ctx) => SaveDataSetupSaveDataMemory2(ctx);
|
||||
|
||||
[SysAbiExport(Nid = "7Bt5pBC-Aco", ExportName = "sceSaveDataGetSaveDataMemory", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataGetSaveDataMemory(CpuContext ctx) => SaveDataGetSaveDataMemory2(ctx);
|
||||
|
||||
[SysAbiExport(Nid = "h3YURzXGSVQ", ExportName = "sceSaveDataSetSaveDataMemory", Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataSetSaveDataMemory(CpuContext ctx) => SaveDataSetSaveDataMemory2(ctx);
|
||||
|
||||
private static string ReadAsciiField(ReadOnlySpan<byte> field)
|
||||
{
|
||||
var length = field.IndexOf((byte)0);
|
||||
if (length < 0)
|
||||
{
|
||||
length = field.Length;
|
||||
}
|
||||
|
||||
return Encoding.ASCII.GetString(field[..length]);
|
||||
}
|
||||
|
||||
private static long SafeDirectorySize(string root)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.Exists(root) ? GetDirectorySize(root) : 0;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTime SafeLastWriteUtc(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.Exists(path) ? Directory.GetLastWriteTimeUtc(path) : DateTime.UtcNow;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
@@ -221,6 +745,10 @@ public static class SaveDataExports
|
||||
|
||||
const string mountPoint = "/savedata0";
|
||||
KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, savePath);
|
||||
lock (_mountGate)
|
||||
{
|
||||
_mounts[mountPoint] = new MountEntry(savePath, dirName, userId);
|
||||
}
|
||||
|
||||
Span<byte> result = stackalloc byte[MountResultSize];
|
||||
result.Clear();
|
||||
@@ -318,10 +846,20 @@ public static class SaveDataExports
|
||||
LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataUmount2(CpuContext ctx)
|
||||
{
|
||||
// Unmounting a save directory always succeeds in the stub filesystem;
|
||||
// returning an error here makes the game's save flow stall before it
|
||||
// hands control to the title/gameplay state.
|
||||
TraceSaveData($"umount2 user={unchecked((int)ctx[CpuRegister.Rdi])}");
|
||||
// rdi: SceSaveDataMountPoint* (16-byte mount point string) for umount2.
|
||||
var mountPointAddress = ctx[CpuRegister.Rdi];
|
||||
if (mountPointAddress != 0 && TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint) &&
|
||||
!string.IsNullOrEmpty(mountPoint))
|
||||
{
|
||||
lock (_mountGate)
|
||||
{
|
||||
_mounts.Remove(mountPoint);
|
||||
}
|
||||
|
||||
KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint);
|
||||
TraceSaveData($"umount2 mount='{mountPoint}'");
|
||||
}
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
@@ -405,9 +943,12 @@ public static class SaveDataExports
|
||||
|
||||
private static bool TryWriteParam(CpuContext ctx, ulong address, SaveEntry entry)
|
||||
{
|
||||
var metadata = SaveDataStorage.ReadMetadata(entry.Path);
|
||||
var param = new byte[SaveDataParamSize];
|
||||
WriteAscii(param.AsSpan(0x00, 128), "Saved Data");
|
||||
WriteAscii(param.AsSpan(0x100, 1024), entry.Name);
|
||||
WriteAscii(param.AsSpan(0x00, 128), metadata.Title);
|
||||
WriteAscii(param.AsSpan(0x80, 128), metadata.SubTitle);
|
||||
WriteAscii(param.AsSpan(0x100, 1024), string.IsNullOrEmpty(metadata.Detail) ? entry.Name : metadata.Detail);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(param.AsSpan(0x500), metadata.UserParam);
|
||||
BinaryPrimitives.WriteInt64LittleEndian(
|
||||
param.AsSpan(0x508, sizeof(long)),
|
||||
new DateTimeOffset(entry.LastWriteUtc).ToUnixTimeSeconds());
|
||||
@@ -474,11 +1015,14 @@ public static class SaveDataExports
|
||||
return false;
|
||||
}
|
||||
|
||||
// Saves are keyed by title id only (single-user emulation) under
|
||||
// ~/SharpEmu/Saves/<titleId>/; userId is accepted for API fidelity but not
|
||||
// part of the host path.
|
||||
private static string ResolveTitleSaveRoot(int userId, string titleId) =>
|
||||
Path.Combine(ResolveSaveDataRoot(), userId.ToString(), SanitizePathSegment(titleId));
|
||||
SaveDataStorage.TitleRoot(ResolveSaveDataRoot(), titleId);
|
||||
|
||||
private static string ResolveSaveDataMemoryPath(int userId) =>
|
||||
Path.Combine(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId()), "sce_sdmemory", "memory.dat");
|
||||
SaveDataStorage.MemoryPath(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId()));
|
||||
|
||||
private static bool TryReadMemoryData(
|
||||
CpuContext ctx, ulong address, out ulong buffer, out ulong size, out ulong offset)
|
||||
@@ -490,14 +1034,7 @@ public static class SaveDataExports
|
||||
ctx.TryReadUInt64(address + 0x10, out offset);
|
||||
}
|
||||
|
||||
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 ResolveSaveDataRoot() => SaveDataStorage.Root();
|
||||
|
||||
private static string ResolveConfiguredTitleId()
|
||||
{
|
||||
@@ -525,12 +1062,7 @@ public static class SaveDataExports
|
||||
return "default";
|
||||
}
|
||||
|
||||
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 string SanitizePathSegment(string value) => SaveDataStorage.Sanitize(value);
|
||||
|
||||
private static bool TryReadFixedAscii(CpuContext ctx, ulong address, int length, out string value)
|
||||
{
|
||||
@@ -790,8 +1322,17 @@ public static class SaveDataExports
|
||||
return ctx.SetReturn(OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
return ctx.SetReturn(
|
||||
File.Exists(ResolveSaveDataMemoryPath(userId)) ? 0 : OrbisSaveDataErrorMemoryNotReady);
|
||||
if (!File.Exists(ResolveSaveDataMemoryPath(userId)))
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorMemoryNotReady);
|
||||
}
|
||||
|
||||
// The write already reached disk synchronously, but the guest treats
|
||||
// sync as asynchronous and blocks a worker on sceSaveDataGetEventResult
|
||||
// until the SAVE_DATA_MEMORY_SYNC_END event arrives. Post it so that
|
||||
// poll completes (this is what wedged Dead Cells at FLIP 0 in-level).
|
||||
EnqueueEvent(EventTypeSaveDataMemorySyncEnd, userId, string.Empty);
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
private static int TransferSaveDataMemory(CpuContext ctx, bool write)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// 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/<titleId>/<dirName>/</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><slot>/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><root>/<titleId></c>.</summary>
|
||||
public static string TitleRoot(string root, string titleId) =>
|
||||
Path.Combine(root, Sanitize(titleId));
|
||||
|
||||
/// <summary>A single save slot: <c><titleRoot>/<dirName></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
|
||||
{
|
||||
}
|
||||
@@ -7,6 +7,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SharpEmu.HLE\SharpEmu.HLE.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" />
|
||||
<!-- SysAbi export generator + analyzers (compile-time registry, NID validation). -->
|
||||
<ProjectReference Include="..\SharpEmu.SourceGenerators\SharpEmu.SourceGenerators.csproj"
|
||||
|
||||
@@ -1,122 +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.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;
|
||||
}
|
||||
@@ -36,6 +36,7 @@ public static class PerfOverlay
|
||||
private static long _presentedInWindow;
|
||||
private static long _submittedInWindow;
|
||||
private static long _drawsInWindow;
|
||||
private static long _guestBufferCacheBytes;
|
||||
|
||||
// Refreshed once per second so per-frame fills never allocate.
|
||||
private static long _statsWindowStart = Stopwatch.GetTimestamp();
|
||||
@@ -74,11 +75,8 @@ public static class PerfOverlay
|
||||
if (last != 0)
|
||||
{
|
||||
var milliseconds = (now - last) * 1000.0 / Stopwatch.Frequency;
|
||||
if (milliseconds < 1000.0)
|
||||
{
|
||||
_frameMilliseconds[_frameHistoryIndex] = milliseconds;
|
||||
_frameHistoryIndex = (_frameHistoryIndex + 1) % FrameHistorySize;
|
||||
}
|
||||
_frameMilliseconds[_frameHistoryIndex] = milliseconds;
|
||||
_frameHistoryIndex = (_frameHistoryIndex + 1) % FrameHistorySize;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +86,9 @@ public static class PerfOverlay
|
||||
/// <summary>Called per translated draw/dispatch executed.</summary>
|
||||
public static void RecordDraw() => Interlocked.Increment(ref _drawsInWindow);
|
||||
|
||||
public static void SetGuestBufferCacheBytes(ulong bytes) =>
|
||||
Interlocked.Exchange(ref _guestBufferCacheBytes, checked((long)bytes));
|
||||
|
||||
/// <summary>
|
||||
/// Rasterizes the panel into a BGRA byte span of PanelWidth x PanelHeight.
|
||||
/// Runs on the render thread.
|
||||
@@ -165,7 +166,7 @@ public static class PerfOverlay
|
||||
Environment.ProcessorCount;
|
||||
_lastCpuTime = cpuTime;
|
||||
|
||||
var drawsPerFrame = _fps > 0.5 ? _drawsPerSecond / _fps : 0;
|
||||
var drawsPerFrame = _fps > 0 ? _drawsPerSecond / _fps : 0;
|
||||
var sessionStart = Interlocked.Read(ref _sessionStartTimestamp);
|
||||
var elapsedSeconds = sessionStart == 0
|
||||
? 0L
|
||||
@@ -176,7 +177,9 @@ public static class PerfOverlay
|
||||
_line1 = $"FPS {_fps:0.0} FLIP {_submittedFps:0.0} {_averageFrameMs:0.0} MS";
|
||||
_line2 = $"DRAWS {_drawsPerSecond:0}/S {drawsPerFrame:0}/F Q {pendingWork}+{inFlightSubmissions}";
|
||||
_line3 = $"ALLOC {_allocatedMbPerSecond:0.0} MB/S GC {_gen0PerWindow}/{_gen1PerWindow}/{_gen2PerWindow}";
|
||||
_line4 = $"CPU {_cpuPercent:0}% HEAP {GC.GetTotalMemory(false) / (1024 * 1024)} MB F1 HIDE";
|
||||
var heapMb = GC.GetTotalMemory(false) / (1024 * 1024);
|
||||
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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,9 +131,14 @@ public static class VideoOutExports
|
||||
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)
|
||||
{
|
||||
_windowTitle = $"{_windowTitle} · {gpuName.Trim()}";
|
||||
_windowTitle = $"{_windowTitle} · {gpuName.Trim()}{backendSuffix}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,11 +171,12 @@ public static class VideoOutExports
|
||||
HostSessionControl.RequestShutdown(reason);
|
||||
|
||||
// A hosted game can still be issuing AGC work after it requests its
|
||||
// own shutdown. Keep the Vulkan resources alive until the GUI session
|
||||
// reaches its guest-safe exit path and disposes the host surface.
|
||||
// own shutdown. Keep the presenter's resources alive until the GUI
|
||||
// session reaches its guest-safe exit path and disposes the host
|
||||
// surface.
|
||||
if (!embedded)
|
||||
{
|
||||
VulkanVideoPresenter.RequestClose();
|
||||
GuestGpu.Current.RequestClose();
|
||||
}
|
||||
|
||||
// The embedded GUI owns the process lifetime. A guest shutdown should
|
||||
@@ -1070,6 +1076,12 @@ public static class VideoOutExports
|
||||
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))
|
||||
{
|
||||
return OrbisVideoOutErrorInvalidHandle;
|
||||
@@ -1090,7 +1102,7 @@ public static class VideoOutExports
|
||||
return OrbisVideoOutErrorInvalidValue;
|
||||
}
|
||||
|
||||
if (categoryRaw != 0 || option != 0)
|
||||
if (category > 1 || option != 0)
|
||||
{
|
||||
return OrbisVideoOutErrorInvalidValue;
|
||||
}
|
||||
@@ -1209,7 +1221,7 @@ public static class VideoOutExports
|
||||
{
|
||||
TriggerFlipEvents();
|
||||
}
|
||||
else if (VulkanVideoPresenter.SubmitOrderedGuestAction(
|
||||
else if (GuestGpu.Current.SubmitOrderedGuestAction(
|
||||
TriggerFlipEvents,
|
||||
$"videoout flip complete handle={handle} index={bufferIndex}") == 0)
|
||||
{
|
||||
@@ -1263,7 +1275,7 @@ public static class VideoOutExports
|
||||
var elapsedSeconds = (double)elapsedTicks / Stopwatch.Frequency;
|
||||
var submitted = Interlocked.Exchange(ref _submittedFrameCount, 0);
|
||||
var presentedCount = Interlocked.Exchange(ref _presentedFrameCount, 0);
|
||||
var (draws, drawMs, pipelines, spirvCompiles) = VulkanVideoPresenter.ReadAndResetPerfCounters();
|
||||
var (draws, drawMs, pipelines, spirvCompiles) = GuestGpu.Current.ReadAndResetPerfCounters();
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][PERF] videoout submitted_fps={submitted / elapsedSeconds:F1} " +
|
||||
$"presented_fps={presentedCount / elapsedSeconds:F1} " +
|
||||
@@ -1702,7 +1714,7 @@ public static class VideoOutExports
|
||||
SceVideoOutPixelFormat2B10G10R10A2Bt2100Pq;
|
||||
|
||||
// Maps the PS5 VideoOut pixel format space to the AGC "guest texture format" tags
|
||||
// the backend keys its guest-image registry on (see VulkanVideoPresenter.
|
||||
// the backend keys its guest-image registry on (see the presenter's
|
||||
// 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
|
||||
// display something rather than silently failing the flip pipeline.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
// 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
@@ -0,0 +1,888 @@
|
||||
// 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
@@ -0,0 +1,81 @@
|
||||
// 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";
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<!--
|
||||
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>
|
||||
@@ -0,0 +1,13 @@
|
||||
#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}};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#include <metal_stdlib>
|
||||
|
||||
using namespace metal;
|
||||
|
||||
fragment void depth_only_fs()
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
#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));
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#include <metal_stdlib>
|
||||
|
||||
using namespace metal;
|
||||
|
||||
fragment float4 solid_fs()
|
||||
{
|
||||
return float4({{red}}, {{green}}, {{blue}}, {{alpha}});
|
||||
}
|
||||
@@ -953,22 +953,13 @@ public static partial class Gen5SpirvTranslator
|
||||
case "VPkMulF16":
|
||||
case "VPkMinF16":
|
||||
case "VPkMaxF16":
|
||||
case "VPkFmaF16":
|
||||
if (!TryEmitPackedF16(instruction, out result, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
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:
|
||||
error = $"unsupported vector opcode {instruction.Opcode}";
|
||||
return false;
|
||||
@@ -1008,8 +999,9 @@ public static partial class Gen5SpirvTranslator
|
||||
// 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
|
||||
// fits in f32 exactly). min/max carry no rounding, so they are exact once the
|
||||
// conversions are. v_pk_fma_f16 is intentionally not routed here because a
|
||||
// fused f16 FMA cannot be reproduced by an f32 multiply-add plus a pack.
|
||||
// conversions are. v_pk_fma_f16 cannot be reproduced by a plain f32
|
||||
// multiply-add plus a pack (that double-rounds), so it goes through the
|
||||
// round-to-odd sequence in EmitPackedF16FusedMultiplyAdd instead.
|
||||
private bool TryEmitPackedF16(
|
||||
Gen5ShaderInstruction instruction,
|
||||
out uint result,
|
||||
@@ -1029,7 +1021,8 @@ public static partial class Gen5SpirvTranslator
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var index = 0; index < 2; index++)
|
||||
var sourceCount = instruction.Opcode == "VPkFmaF16" ? 3 : 2;
|
||||
for (var index = 0; index < sourceCount; index++)
|
||||
{
|
||||
var source = instruction.Sources[index];
|
||||
if (source.Kind is not (Gen5OperandKind.VectorRegister or Gen5OperandKind.ScalarRegister))
|
||||
@@ -1054,6 +1047,12 @@ public static partial class Gen5SpirvTranslator
|
||||
{
|
||||
var left = EmitPackedF16Operand(instruction, control, 0, 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
|
||||
{
|
||||
"VPkAddF16" => _module.AddInstruction(SpirvOp.FAdd, _floatType, left, right),
|
||||
@@ -1065,6 +1064,75 @@ public static partial class Gen5SpirvTranslator
|
||||
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),
|
||||
// widens it exactly to f32 and applies the lane's negate modifier (neg_lo / neg_hi).
|
||||
private uint EmitPackedF16Operand(
|
||||
|
||||
@@ -1754,6 +1754,10 @@ public static partial class Gen5SpirvTranslator
|
||||
"SWaitcnt" or
|
||||
"SInstPrefetch" 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")
|
||||
{
|
||||
return true;
|
||||
|
||||
@@ -238,6 +238,7 @@ public enum SpirvDecoration : uint
|
||||
Binding = 33,
|
||||
DescriptorSet = 34,
|
||||
Offset = 35,
|
||||
NoContraction = 42,
|
||||
}
|
||||
|
||||
public enum SpirvBuiltIn : uint
|
||||
|
||||
@@ -2350,7 +2350,8 @@ public static class Gen5ShaderScalarEvaluator
|
||||
private static bool TryReadUInt32(CpuContext ctx, ulong address, out uint value)
|
||||
{
|
||||
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;
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// 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
Reference in New Issue
Block a user