Compare commits

..

1 Commits

123 changed files with 3397 additions and 20618 deletions
-1
View File
@@ -42,4 +42,3 @@ ehthumbs.db
.vs/
.idea/
packages.lock.json
+1 -1
View File
@@ -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.4</SharpEmuVersion>
<SharpEmuVersion>0.0.2-beta.3</SharpEmuVersion>
<Version>$(SharpEmuVersion)</Version>
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
-2
View File
@@ -8,8 +8,6 @@ path = [
"**/packages.lock.json",
"scripts/ps5_names.txt",
"src/SharpEmu.GUI/Languages/**",
"src/SharpEmu.ShaderCompiler.Metal/Templates/**",
"tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/**",
"_logs/**",
".github/images/**",
".github/pull_request_template.md",
-2
View File
@@ -14,13 +14,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Project Path="src/SharpEmu.Libs/SharpEmu.Libs.csproj" />
<Project Path="src/SharpEmu.Logging/SharpEmu.Logging.csproj" />
<Project Path="src/SharpEmu.ShaderCompiler/SharpEmu.ShaderCompiler.csproj" />
<Project Path="src/SharpEmu.ShaderCompiler.Metal/SharpEmu.ShaderCompiler.Metal.csproj" />
<Project Path="src/SharpEmu.ShaderCompiler.Vulkan/SharpEmu.ShaderCompiler.Vulkan.csproj" />
<Project Path="src/SharpEmu.SourceGenerators/SharpEmu.SourceGenerators.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj" />
<Project Path="tests/SharpEmu.ShaderCompiler.Metal.Tests/SharpEmu.ShaderCompiler.Metal.Tests.csproj" />
<Project Path="tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj" />
</Folder>
</Solution>
-20
View File
@@ -1,20 +0,0 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
# Aerolib Catalog
```bash
# NID to export name
python scripts/aerolib_catalog.py lookup Zxa0VhQVTsk
# Export name to NID
python scripts/aerolib_catalog.py lookup sceKernelWaitSema
# Search export names
python scripts/aerolib_catalog.py search VideoOut --limit 20
# Export all NID/name pairs to artifacts/aerolib.txt
python scripts/aerolib_catalog.py export
```
+3 -3
View File
@@ -14,9 +14,9 @@ available, presents its decoded BGRA frames at the normal guest-flip boundary.
This preserves the game's own timing and lets the host Vulkan presenter display
the movie without trying to execute the PS5-specific Bink GPU decode path.
Without an adapter, Bink files remain visible to the guest and the game's
statically linked decoder runs normally. Set SHARPEMU_BINK_MODE=skip only when
explicitly testing a title whose cinematics are optional.
Without an adapter, Bink movies are skipped by default: their open call returns
not-found so games that mark cinematics as optional progress to their next
state instead of waiting on an empty Bink GPU texture.
Set SHARPEMU_BINK_MODE=dummy to retain the open and show a built-in,
non-decoded placeholder frame. This requires no SDK, but is a visual diagnostic
+1 -1
View File
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.103",
"rollForward": "latestFeature"
"rollForward": "disable"
}
}
-181
View File
@@ -1,181 +0,0 @@
#!/usr/bin/env python3
# Copyright (C) 2026 SharpEmu Emulator Project
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
import argparse
import base64
import hashlib
import re
import sys
from pathlib import Path
NID_SUFFIX = bytes.fromhex("518d64a635ded8c1e6b039b1c3e55230")
NID_PATTERN = re.compile(r"^[A-Za-z0-9+-]{11}$")
DEFAULT_NAMES_FILE = Path(__file__).resolve().with_name("ps5_names.txt")
DEFAULT_EXPORT_FILE = Path(__file__).resolve().parents[1] / "artifacts" / "aerolib.txt"
def compute_nid(export_name: str) -> str:
digest = hashlib.sha1(export_name.encode("utf-8") + NID_SUFFIX).digest()
encoded = base64.b64encode(digest[:8][::-1]).decode("ascii")
return encoded.rstrip("=").replace("/", "-")
def read_names(path: Path) -> list[str]:
try:
return [
line.strip()
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
except OSError as error:
raise SystemExit(f"Unable to read catalog '{path}': {error}") from error
def write_pair(nid: str, export_name: str) -> None:
print(f"{nid}\t{export_name}")
def lookup(args: argparse.Namespace) -> int:
value = args.value.strip()
if NID_PATTERN.fullmatch(value):
for export_name in read_names(args.names):
if compute_nid(export_name) == value:
write_pair(value, export_name)
return 0
print(f"NID not found in catalog: {value}", file=sys.stderr)
return 1
names = set(read_names(args.names))
write_pair(compute_nid(value), value)
if value not in names:
print("Warning: export name is not present in the catalog.", file=sys.stderr)
return 0
def search(args: argparse.Namespace) -> int:
names = read_names(args.names)
if args.regex:
try:
pattern = re.compile(args.query, 0 if args.case_sensitive else re.IGNORECASE)
except re.error as error:
print(f"Invalid regular expression: {error}", file=sys.stderr)
return 2
matches = (name for name in names if pattern.search(name))
elif args.case_sensitive:
matches = (name for name in names if args.query in name)
else:
query = args.query.casefold()
matches = (name for name in names if query in name.casefold())
count = 0
for export_name in matches:
write_pair(compute_nid(export_name), export_name)
count += 1
if args.limit and count >= args.limit:
break
if count == 0:
print(f"No catalog names matched: {args.query}", file=sys.stderr)
return 1
return 0
def export_catalog(args: argparse.Namespace) -> int:
pairs = [(compute_nid(name), name) for name in read_names(args.names)]
if args.sort == "nid":
pairs.sort(key=lambda pair: (pair[0], pair[1]))
elif args.sort == "name":
pairs.sort(key=lambda pair: pair[1])
args.output.parent.mkdir(parents=True, exist_ok=True)
try:
with args.output.open("w", encoding="utf-8", newline="\n") as output:
output.write("# NID\tExportName\n")
for nid, export_name in pairs:
output.write(f"{nid}\t{export_name}\n")
except OSError as error:
print(f"Unable to write catalog '{args.output}': {error}", file=sys.stderr)
return 1
print(f"Wrote {len(pairs)} entries to {args.output}")
return 0
def create_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Inspect the SharpEmu PS5 export-name/NID catalog.",
epilog=(
"Examples:\n"
" python scripts/aerolib_catalog.py lookup Zxa0VhQVTsk\n"
" python scripts/aerolib_catalog.py lookup sceKernelWaitSema\n"
" python scripts/aerolib_catalog.py search VideoOut --limit 20\n"
" python scripts/aerolib_catalog.py export"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--names",
type=Path,
default=DEFAULT_NAMES_FILE,
help=f"source name list (default: {DEFAULT_NAMES_FILE})",
)
subparsers = parser.add_subparsers(dest="command", required=True)
lookup_parser = subparsers.add_parser(
"lookup", help="resolve a NID or calculate the NID for an export name"
)
lookup_parser.add_argument("value", help="11-character NID or exact export name")
lookup_parser.set_defaults(handler=lookup)
search_parser = subparsers.add_parser(
"search", help="find export names and print matching NID/name pairs"
)
search_parser.add_argument("query", help="name substring or regular expression")
search_parser.add_argument(
"--limit", type=int, default=50, help="maximum matches; 0 means unlimited"
)
search_parser.add_argument(
"--case-sensitive", action="store_true", help="match case exactly"
)
search_parser.add_argument(
"--regex", action="store_true", help="treat the query as a regular expression"
)
search_parser.set_defaults(handler=search)
export_parser = subparsers.add_parser(
"export", help="write every NID/name pair to a tab-separated text file"
)
export_parser.add_argument(
"output",
type=Path,
nargs="?",
default=DEFAULT_EXPORT_FILE,
help=f"output file (default: {DEFAULT_EXPORT_FILE})",
)
export_parser.add_argument(
"--sort",
choices=("source", "nid", "name"),
default="nid",
help="output ordering (default: nid)",
)
export_parser.set_defaults(handler=export_catalog)
return parser
def main() -> int:
parser = create_parser()
args = parser.parse_args()
return args.handler(args)
if __name__ == "__main__":
raise SystemExit(main())
+5
View File
@@ -45,6 +45,11 @@ internal static partial class Program
[STAThread]
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);
+1
View File
@@ -49,6 +49,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == ''">
+596
View File
@@ -0,0 +1,596 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.3, )",
"resolved": "10.0.3",
"contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA=="
},
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.BuildServices": {
"type": "Transitive",
"resolved": "11.3.2",
"contentHash": "qHDToxto1e3hci5YqbG9n0Ty8mlp3zBUN5wT66wKqaDVzXyQ0do3EnRILd4Ke9jpvsktaPpgE0YjEk7hornryQ=="
},
"Avalonia.FreeDesktop": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "aUwv8BNruRUOaUfMu4U3uibIUS60/rSHgGOhd8zBkLkpxY3JFJvgRbeq5ZzHIyKXCuKi18PO00YHAgCarp3wdw==",
"dependencies": {
"Avalonia": "11.3.18",
"Tmds.DBus.Protocol": "0.21.3"
}
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"Avalonia.Remote.Protocol": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "vw+6ZfgTuu72dA9aVWn6u56t2nrBd5MoMU0wo/qI9XJAl/c0oYYphIvwLvJP1JorubQY4UE3d0ac8ULBhrGBiA=="
},
"Avalonia.Skia": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "/B4aXmNRNjG8I5U/a1xJI+bIi0XO6DDzS3mBrIKlVnJRY2CyZiUeESRQXLnIU77Z9TvqkUROs+D47s085YjFtA==",
"dependencies": {
"Avalonia": "11.3.18",
"HarfBuzzSharp": "8.3.1.1",
"HarfBuzzSharp.NativeAssets.Linux": "8.3.1.1",
"HarfBuzzSharp.NativeAssets.WebAssembly": "8.3.1.1",
"SkiaSharp": "2.88.9",
"SkiaSharp.NativeAssets.Linux": "2.88.9",
"SkiaSharp.NativeAssets.WebAssembly": "2.88.9"
}
},
"Avalonia.Win32": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "eioUHkM2PeLPETd1aEks3rvb9plbba6buIrNdrqCpwE/qgHKUjvRNBd5mUQfAbGgTLiAes524gB8uUMDhrsJVQ==",
"dependencies": {
"Avalonia": "11.3.18",
"Avalonia.Angle.Windows.Natives": "2.1.25547.20250602"
}
},
"Avalonia.X11": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "m4Ki/G5Dovnq+6QzfS0iGbK8V77Q6oTjToMLOB0CxPCCrl3Oxywh6kIjuGJDPaN6kopMmjxlNShyQf+vPYL+JA==",
"dependencies": {
"Avalonia": "11.3.18",
"Avalonia.FreeDesktop": "11.3.18",
"Avalonia.Skia": "11.3.18"
}
},
"HarfBuzzSharp": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "tLZN66oe/uiRPTZfrCU4i8ScVGwqHNh5MHrXj0yVf4l7Mz0FhTGnQ71RGySROTmdognAs0JtluHkL41pIabWuQ==",
"dependencies": {
"HarfBuzzSharp.NativeAssets.Win32": "8.3.1.1",
"HarfBuzzSharp.NativeAssets.macOS": "8.3.1.1"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.WebAssembly": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "loJweK2u/mH/3C2zBa0ggJlITIszOkK64HLAZB7FUT670dTg965whLFYHDQo69NmC4+d9UN0icLC9VHidXaVCA=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"MicroCom.Runtime": {
"type": "Transitive",
"resolved": "0.11.0",
"contentHash": "MEnrZ3UIiH40hjzMDsxrTyi8dtqB5ziv3iBeeU4bXsL/7NLSal9F1lZKpK+tfBRnUoDSdtcW3KufE4yhATOMCA=="
},
"Microsoft.DotNet.PlatformAbstractions": {
"type": "Transitive",
"resolved": "3.1.6",
"contentHash": "jek4XYaQ/PGUwDKKhwR8K47Uh1189PFzMeLqO83mXrXQVIpARZCcfuDedH50YDTepBkfijCZN5U/vZi++erxtg=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "9.0.9",
"contentHash": "fNGvKct2De8ghm0Bpfq0iWthtzIWabgOTi+gJhNOPhNJIowXNEUE2eZNW/zNCzrHVA3PXg2yZ+3cWZndC2IqYA=="
},
"Silk.NET.Core": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "D7AT/nnwlB+4RZ84XY8QNGBZMJI5z9l4CSSETIJ1wCfRJzRt/341y3MRZ4HbnFz4r/IGaWOEZr86iE+0/65yyQ==",
"dependencies": {
"Microsoft.DotNet.PlatformAbstractions": "3.1.6",
"Microsoft.Extensions.DependencyModel": "9.0.9"
}
},
"Silk.NET.GLFW": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "UIs4sH57xlPUNHQ/1bt9rymPWlGy8IMDCNv86h0iM4TOA1CkIx0XM/n/tA4AReh1zQkNrvkxPEdZ3Blvy1dyXg==",
"dependencies": {
"Silk.NET.Core": "2.23.0",
"Ultz.Native.GLFW": "3.4.0"
}
},
"Silk.NET.Input.Common": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "QbJVV7kFBHEByayXCYdJtXXI9Sp4a+QAf0IdGV6uCWkFYcEmqBYW3aaNGvFOdSwTBDbHL5T/OtOCrGh4qYhk7A==",
"dependencies": {
"Silk.NET.Windowing.Common": "2.23.0"
}
},
"Silk.NET.Input.Glfw": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "KGHYqsv/IQRJtD6dloYh2tN4CkaM40vxM2kj0cGKBoCQiBDYHHhJiyDTyMPx0W7Fz5IgnhnG42ELmIAa0DH69A==",
"dependencies": {
"Silk.NET.Input.Common": "2.23.0",
"Silk.NET.Windowing.Glfw": "2.23.0"
}
},
"Silk.NET.Maths": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "r8PdIVzME8EH0qAgbmRPO87I4GfgR2j8TofT7EMuRJDf1QluoQwnVypDoFJjQ2ZBSRsGYk5unYxxogI05Ogsmw=="
},
"Silk.NET.Windowing.Common": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "ThStSinmY9KQI8DGiF5XEhkLJVnBcgRTBTzL9ijg1wMZAYuckz7ykrNw04fjRm2Gryh6tCNGbvz2XaY0efeFzg==",
"dependencies": {
"Silk.NET.Core": "2.23.0",
"Silk.NET.Maths": "2.23.0"
}
},
"Silk.NET.Windowing.Glfw": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "aYBudKmENmvLRn9p15HbdvlQTnnXskcDfTfbYwSb/4fr263rGLwYuDw/txUEc2jihHJiWCp5+75Y7z5wTJWl7g==",
"dependencies": {
"Silk.NET.GLFW": "2.23.0",
"Silk.NET.Windowing.Common": "2.23.0"
}
},
"SkiaSharp": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "3MD5VHjXXieSHCleRLuaTXmL2pD0mB7CcOB1x2kA1I4bhptf4e3R27iM93264ZYuAq6mkUyX5XbcxnZvMJYc1Q==",
"dependencies": {
"SkiaSharp.NativeAssets.Win32": "2.88.9",
"SkiaSharp.NativeAssets.macOS": "2.88.9"
}
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.WebAssembly": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "kt06RccBHSnAs2wDYdBSfsjIDbY3EpsOVqnlDgKdgvyuRA8ZFDaHRdWNx1VHjGgYzmnFCGiTJBnXFl5BqGwGnA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
},
"sharpemu.core": {
"type": "Project",
"dependencies": {
"Iced": "[1.21.0, )",
"SharpEmu.HLE": "[0.0.2-beta.3, )",
"SharpEmu.Libs": "[0.0.2-beta.3, )",
"SharpEmu.Logging": "[0.0.2-beta.3, )"
}
},
"sharpemu.debugger": {
"type": "Project",
"dependencies": {
"SharpEmu.Core": "[0.0.2-beta.3, )",
"SharpEmu.HLE": "[0.0.2-beta.3, )",
"SharpEmu.Logging": "[0.0.2-beta.3, )"
}
},
"sharpemu.gui": {
"type": "Project",
"dependencies": {
"Avalonia": "[11.3.18, )",
"Avalonia.Desktop": "[11.3.18, )",
"Avalonia.Fonts.Inter": "[11.3.18, )",
"Avalonia.Themes.Fluent": "[11.3.18, )",
"SharpEmu.Core": "[0.0.2-beta.3, )",
"SharpEmu.Libs": "[0.0.2-beta.3, )",
"SharpEmu.Logging": "[0.0.2-beta.3, )",
"Tmds.DBus.Protocol": "[0.21.3, )"
}
},
"sharpemu.hle": {
"type": "Project",
"dependencies": {
"SharpEmu.Logging": "[0.0.2-beta.3, )"
}
},
"sharpemu.libs": {
"type": "Project",
"dependencies": {
"SharpEmu.HLE": "[0.0.2-beta.3, )",
"SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )",
"SharpEmu.ShaderCompiler.Vulkan": "[0.0.2-beta.3, )",
"Silk.NET.Input": "[2.23.0, )",
"Silk.NET.Vulkan": "[2.23.0, )",
"Silk.NET.Vulkan.Extensions.EXT": "[2.23.0, )",
"Silk.NET.Vulkan.Extensions.KHR": "[2.23.0, )",
"Silk.NET.Windowing": "[2.23.0, )"
}
},
"sharpemu.logging": {
"type": "Project"
},
"sharpemu.shadercompiler": {
"type": "Project",
"dependencies": {
"SharpEmu.HLE": "[0.0.2-beta.3, )"
}
},
"sharpemu.shadercompiler.vulkan": {
"type": "Project",
"dependencies": {
"SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )"
}
},
"Avalonia": {
"type": "CentralTransitive",
"requested": "[11.3.18, )",
"resolved": "11.3.18",
"contentHash": "2C4UxhWUObWGgYKWic1x5BMMWGJP6SElb91WeOxs+X/iR26rtkqpxFFwwo50FXS9AyYnHfk8QKXDEfe7oT/kZA==",
"dependencies": {
"Avalonia.BuildServices": "11.3.2",
"Avalonia.Remote.Protocol": "11.3.18",
"MicroCom.Runtime": "0.11.0"
}
},
"Avalonia.Desktop": {
"type": "CentralTransitive",
"requested": "[11.3.18, )",
"resolved": "11.3.18",
"contentHash": "bilMPa5vYiis6fbNovb6esKytBnOCEGojBa1XFegLCRHCP6g6PvZwS0XF/YOAGkENRlHG8dI7lohOpQ9bIkq1g==",
"dependencies": {
"Avalonia": "11.3.18",
"Avalonia.Native": "11.3.18",
"Avalonia.Skia": "11.3.18",
"Avalonia.Win32": "11.3.18",
"Avalonia.X11": "11.3.18"
}
},
"Avalonia.Fonts.Inter": {
"type": "CentralTransitive",
"requested": "[11.3.18, )",
"resolved": "11.3.18",
"contentHash": "27u6hB3Y2Ue586yjfeVakberY73VNQXtuKwe/P927XG1QPlhsfmOyifLHDDpSHG85Zl1x/Xv9IZ3+tk9FnjcZQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"Avalonia.Themes.Fluent": {
"type": "CentralTransitive",
"requested": "[11.3.18, )",
"resolved": "11.3.18",
"contentHash": "+Q/TJoynD0zNuu5w2gD+xcTl7GNKJFxlPYAndRLs/mTDrNbbsvv/271WyIysbMPsXSjCyBDp7RCZzQkpD6x5Bg==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"Iced": {
"type": "CentralTransitive",
"requested": "[1.21.0, )",
"resolved": "1.21.0",
"contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg=="
},
"Silk.NET.Input": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "Xzl+tVwAp2eEd8blmGQjmJrsZPnp3PWG0KJjiAQHaY2Zr/ELVWeAROKXmZdCAvexzmte2JVGEy/dxnMycbxlpg==",
"dependencies": {
"Silk.NET.Input.Common": "2.23.0",
"Silk.NET.Input.Glfw": "2.23.0"
}
},
"Silk.NET.Vulkan": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "3/irtlSWXZ3eTi8N6nelI6L34NTB8ZJHpqVMNzZx2aX7Ek9YEQ34NoQW8/Tljrtmkg8KRhHW8hKTEzZaKV8PgA==",
"dependencies": {
"Silk.NET.Core": "2.23.0"
}
},
"Silk.NET.Vulkan.Extensions.EXT": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "+Oth189ksRiL6HvGCwIdnsYHawqrbO8y49u1H61z3wsfcHhQZeVDYe/wF5LD7fk3NcdgDvwFD3mLm1QWhdZySw==",
"dependencies": {
"Silk.NET.Core": "2.23.0",
"Silk.NET.Vulkan": "2.23.0"
}
},
"Silk.NET.Vulkan.Extensions.KHR": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "uRaf4j+SmH3DumjSSSUbFg33BnsGZUyXGj93O9NgGKZSJN3OTmNmQDxRew+/KiVLcgH6qzbto8aNGZ++j9GFWg==",
"dependencies": {
"Silk.NET.Core": "2.23.0",
"Silk.NET.Vulkan": "2.23.0"
}
},
"Silk.NET.Windowing": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "OPNPmt/lRyUKVYrFLQXVxyATqD3MKLc1iY1oKx1/2GppgmZxVZPwN12tekrQ4C7408kgB1L5JD1Wnirqqeb2kg==",
"dependencies": {
"Silk.NET.Windowing.Common": "2.23.0",
"Silk.NET.Windowing.Glfw": "2.23.0"
}
},
"Tmds.DBus.Protocol": {
"type": "CentralTransitive",
"requested": "[0.21.3, )",
"resolved": "0.21.3",
"contentHash": "hDwB8WsQoyALQKqIbwzS68UKdlnafDm4T/DkO/JrA/YIneP/rKv96SxYPVXeh3FP4i/SXfShrYftKLtciJAIlw=="
}
},
"net10.0/linux-x64": {
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
}
},
"net10.0/osx-arm64": {
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
}
},
"net10.0/osx-x64": {
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
}
},
"net10.0/win-x64": {
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
}
}
}
}
@@ -1,81 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Numerics;
namespace SharpEmu.Core.Cpu.Emulation;
/// <summary>
/// The four Intel SHA-extension operations used by SHA-1 code. This is the arithmetic half of
/// the direct-execution SIGILL fallback used when an x86-64 guest runs under Rosetta 2, which
/// does not expose Intel SHA instructions to translated processes.
/// </summary>
public static class Sha1InstructionEmulator
{
public static Sha1Vector MessageSchedule1(Sha1Vector destination, Sha1Vector source) => new(
destination.Lane0 ^ source.Lane2,
destination.Lane1 ^ source.Lane3,
destination.Lane2 ^ destination.Lane0,
destination.Lane3 ^ destination.Lane1);
public static Sha1Vector MessageSchedule2(Sha1Vector destination, Sha1Vector source)
{
var lane3 = BitOperations.RotateLeft(destination.Lane3 ^ source.Lane2, 1);
return new Sha1Vector(
BitOperations.RotateLeft(destination.Lane0 ^ lane3, 1),
BitOperations.RotateLeft(destination.Lane1 ^ source.Lane0, 1),
BitOperations.RotateLeft(destination.Lane2 ^ source.Lane1, 1),
lane3);
}
public static Sha1Vector NextE(Sha1Vector destination, Sha1Vector source) => new(
source.Lane0,
source.Lane1,
source.Lane2,
unchecked(source.Lane3 + BitOperations.RotateLeft(destination.Lane3, 30)));
public static Sha1Vector FourRounds(Sha1Vector destination, Sha1Vector source, byte function)
{
uint a = destination.Lane3;
uint b = destination.Lane2;
uint c = destination.Lane1;
uint d = destination.Lane0;
uint e = 0;
uint constant = (function & 3) switch
{
0 => 0x5A82_7999u,
1 => 0x6ED9_EBA1u,
2 => 0x8F1B_BCDCu,
_ => 0xCA62_C1D6u,
};
for (var round = 0; round < 4; round++)
{
uint choose = (function & 3) switch
{
0 => (b & c) ^ (~b & d),
2 => (b & c) ^ (b & d) ^ (c & d),
_ => b ^ c ^ d,
};
uint word = round switch
{
0 => source.Lane3,
1 => source.Lane2,
2 => source.Lane1,
_ => source.Lane0,
};
uint next = unchecked(choose + BitOperations.RotateLeft(a, 5) + word + e + constant);
e = d;
d = c;
c = BitOperations.RotateLeft(b, 30);
b = a;
a = next;
}
return new Sha1Vector(d, c, b, a);
}
}
/// <summary>The four little-endian 32-bit lanes of an XMM register.</summary>
public readonly record struct Sha1Vector(uint Lane0, uint Lane1, uint Lane2, uint Lane3);
@@ -1113,12 +1113,7 @@ public sealed partial class DirectExecutionBackend
}
}
private unsafe static bool TryReadHostBytes(ulong address, byte[] buffer) =>
TryReadHostBytes(address, buffer.AsSpan());
// Span overload so signal-handler recovery paths can read into a stackalloc
// buffer instead of allocating managed arrays inside the handler.
private unsafe static bool TryReadHostBytes(ulong address, Span<byte> buffer)
private unsafe static bool TryReadHostBytes(ulong address, byte[] buffer)
{
if (address < 65536)
{
@@ -1142,7 +1137,7 @@ public sealed partial class DirectExecutionBackend
try
{
new ReadOnlySpan<byte>((void*)address, buffer.Length).CopyTo(buffer);
Marshal.Copy((nint)address, buffer, 0, buffer.Length);
return true;
}
catch
@@ -8,21 +8,21 @@ using SharpEmu.Core.Cpu.Emulation;
namespace SharpEmu.Core.Cpu.Native;
// Software fallback for unsupported guest CPU instructions.
// Software fallback for the BMI1/BMI2/ABM general-purpose-register instructions.
//
// Guest code runs natively, so the guest and host share the same virtual address space and the
// same registers (the OS delivers them in the CONTEXT record on a fault). When the host CPU lacks
// one of these extensions it raises #UD instead of executing the opcode; without this the title
// simply aborts. Here we decode the faulting instruction, evaluate it against the trapped register
// and memory state, write the result back into the CONTEXT, step RIP past the instruction and ask
// the OS to continue. BMI1/BMI2/ABM GPR operations and the Intel SHA-1 XMM operations are handled;
// anything else falls through to the existing diagnostics unchanged.
// the OS to continue. Only the register-only BMI/ABM forms are handled; anything else returns false
// and falls through to the existing diagnostics unchanged, so this can never mis-handle an opcode it
// does not fully model.
public sealed partial class DirectExecutionBackend
{
// Windows x64 CONTEXT.EFlags lives just past the segment selectors. The GPR offsets it shares
// with the rest of the backend are the CTX_* constants declared in DirectExecutionBackend.cs.
private const int CTX_EFLAGS = 68;
private const int CTX_XMM0 = 0x1A0;
// STATUS_ILLEGAL_INSTRUCTION (#UD surfaced by the Windows vectored handler).
private const uint StatusIllegalInstruction = 0xC000001Du;
@@ -34,8 +34,6 @@ public sealed partial class DirectExecutionBackend
private static int _bmiSoftwareFallbackAnnounced;
private static long _bmiInstructionsEmulated;
private static int _sha1SoftwareFallbackAnnounced;
private static long _sha1InstructionsEmulated;
private unsafe bool TryRecoverIllegalInstruction(void* contextRecord, ulong rip)
{
@@ -44,11 +42,6 @@ public sealed partial class DirectExecutionBackend
return false;
}
if (TryRecoverSha1Instruction(contextRecord, rip, in instruction))
{
return true;
}
if (instruction.Op0Kind != OpKind.Register ||
!TryGetGprSlot(instruction.Op0Register, out var destOffset, out var size))
{
@@ -79,139 +72,6 @@ public sealed partial class DirectExecutionBackend
return true;
}
private unsafe bool TryRecoverSha1Instruction(
void* contextRecord,
ulong rip,
in Instruction instruction)
{
if ((!OperatingSystem.IsWindows() && !_posixVectorContextAvailable) ||
instruction.Mnemonic is not (Mnemonic.Sha1msg1 or Mnemonic.Sha1msg2 or
Mnemonic.Sha1nexte or Mnemonic.Sha1rnds4) ||
instruction.Op0Kind != OpKind.Register ||
!TryGetXmmIndex(instruction.Op0Register, out var destinationIndex) ||
!TryReadSha1VectorOperand(contextRecord, in instruction, 1, out var source))
{
return false;
}
var destination = ReadXmm(contextRecord, destinationIndex);
Sha1Vector result;
switch (instruction.Mnemonic)
{
case Mnemonic.Sha1msg1:
result = Sha1InstructionEmulator.MessageSchedule1(destination, source);
break;
case Mnemonic.Sha1msg2:
result = Sha1InstructionEmulator.MessageSchedule2(destination, source);
break;
case Mnemonic.Sha1nexte:
result = Sha1InstructionEmulator.NextE(destination, source);
break;
case Mnemonic.Sha1rnds4:
if (instruction.Op2Kind != OpKind.Immediate8)
{
return false;
}
result = Sha1InstructionEmulator.FourRounds(destination, source, instruction.Immediate8);
break;
default:
return false;
}
WriteXmm(contextRecord, destinationIndex, result);
WriteCtxU64(contextRecord, CTX_RIP, rip + (ulong)instruction.Length);
if (!_posixSignalWarmup)
{
Interlocked.Increment(ref _sha1InstructionsEmulated);
if (Interlocked.Exchange(ref _sha1SoftwareFallbackAnnounced, 1) == 0)
{
Console.Error.WriteLine(
"[LOADER][INFO] Host lacks Intel SHA instructions used by the guest; " +
"emulating SHA-1 instructions in software.");
}
}
return true;
}
private unsafe bool TryReadSha1VectorOperand(
void* contextRecord,
in Instruction instruction,
int operandIndex,
out Sha1Vector value)
{
switch (instruction.GetOpKind(operandIndex))
{
case OpKind.Register:
if (TryGetXmmIndex(instruction.GetOpRegister(operandIndex), out var sourceIndex))
{
value = ReadXmm(contextRecord, sourceIndex);
return true;
}
break;
case OpKind.Memory:
if (TryComputeMemoryAddress(contextRecord, in instruction, out var address))
{
Span<byte> bytes = stackalloc byte[16];
if (TryReadHostBytes(address, bytes))
{
value = new Sha1Vector(
BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(0, 4)),
BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(4, 4)),
BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(8, 4)),
BinaryPrimitives.ReadUInt32LittleEndian(bytes.Slice(12, 4)));
return true;
}
}
break;
}
value = default;
return false;
}
private static unsafe Sha1Vector ReadXmm(void* contextRecord, int index)
{
uint* lanes = (uint*)((byte*)contextRecord + CTX_XMM0 + index * 16);
return new Sha1Vector(lanes[0], lanes[1], lanes[2], lanes[3]);
}
private static unsafe void WriteXmm(void* contextRecord, int index, Sha1Vector value)
{
uint* lanes = (uint*)((byte*)contextRecord + CTX_XMM0 + index * 16);
lanes[0] = value.Lane0;
lanes[1] = value.Lane1;
lanes[2] = value.Lane2;
lanes[3] = value.Lane3;
}
private static bool TryGetXmmIndex(Register register, out int index)
{
switch (register)
{
case Register.XMM0: index = 0; return true;
case Register.XMM1: index = 1; return true;
case Register.XMM2: index = 2; return true;
case Register.XMM3: index = 3; return true;
case Register.XMM4: index = 4; return true;
case Register.XMM5: index = 5; return true;
case Register.XMM6: index = 6; return true;
case Register.XMM7: index = 7; return true;
case Register.XMM8: index = 8; return true;
case Register.XMM9: index = 9; return true;
case Register.XMM10: index = 10; return true;
case Register.XMM11: index = 11; return true;
case Register.XMM12: index = 12; return true;
case Register.XMM13: index = 13; return true;
case Register.XMM14: index = 14; return true;
case Register.XMM15: index = 15; return true;
default: index = 0; return false;
}
}
private unsafe bool TryEvaluate(
void* contextRecord,
in Instruction instruction,
@@ -334,37 +194,19 @@ public sealed partial class DirectExecutionBackend
}
}
// A managed allocation inside a signal handler is unsafe: the fault can
// interrupt the GC mid-operation, and re-entering the allocator corrupts
// the thread's allocation context (observed as a hard "Invalid Program" at
// the next managed alloc, amplified by tight SHA-1 recovery loops). These
// thread-static objects are created once per thread and reused so the
// recovery path never allocates on the hot path.
[ThreadStatic]
private static byte[]? _decodeBuffer;
[ThreadStatic]
private static ByteArrayCodeReader? _decodeReader;
[ThreadStatic]
private static Decoder? _decoder;
private unsafe bool TryReadFaultingInstruction(ulong rip, out Instruction instruction)
{
var buffer = _decodeBuffer ??= new byte[MaxInstructionBytes];
var reader = _decodeReader ??= new ByteArrayCodeReader(buffer);
var decoder = _decoder ??= Decoder.Create(64, reader);
// Try the full instruction window first, then shrink so a fault near the end of a mapped
// page (where fewer than 15 bytes are readable) still decodes. The buffer is reused, so
// clear the tail past the readable window to keep decoding deterministic.
// page (where fewer than 15 bytes are readable) still decodes.
foreach (var attempt in DecodeWindowSizes)
{
if (!TryReadHostBytes(rip, buffer.AsSpan(0, attempt)))
var buffer = new byte[attempt];
if (!TryReadHostBytes(rip, buffer))
{
continue;
}
buffer.AsSpan(attempt).Clear();
reader.Position = 0;
var decoder = Decoder.Create(64, new ByteArrayCodeReader(buffer));
decoder.IP = rip;
decoder.Decode(out instruction);
if (instruction.Code != Code.INVALID && instruction.Length > 0 && instruction.Length <= attempt)
@@ -404,8 +246,8 @@ public sealed partial class DirectExecutionBackend
}
var byteCount = size == GprOperandSize.Bits64 ? 8 : 4;
Span<byte> buffer = stackalloc byte[8];
if (!TryReadHostBytes(address, buffer[..byteCount]))
var buffer = new byte[byteCount];
if (!TryReadHostBytes(address, buffer))
{
return false;
}
@@ -630,6 +630,27 @@ public sealed partial class DirectExecutionBackend
cpuContext[CpuRegister.Rax] = 18446744071562199298uL;
}
}
if (GuestThreadExecution.TryConsumeCurrentThreadBlock(
out var blockReason,
out var blockContinuation,
out var hasBlockContinuation,
out var blockWakeKey,
out var blockWaiter,
out var blockDeadlineTimestamp) &&
TryYieldGuestThreadToHostStub(argPackPtr, num, num7, importStubEntry.Nid, blockReason))
{
if (hasBlockContinuation)
{
RegisterBlockedGuestThreadContinuation(
GuestThreadExecution.CurrentGuestThreadHandle,
blockContinuation,
blockWakeKey,
blockWaiter,
blockDeadlineTimestamp);
}
cpuContext[CpuRegister.Rax] = 0uL;
}
if (flag || flag2 || flag3)
{
Console.Error.WriteLine($"[LOADER][TRACE] ImportRet#{num}: nid={importStubEntry.Nid} result={orbisGen2Result} rax=0x{cpuContext[CpuRegister.Rax]:X16}");
@@ -1324,13 +1345,35 @@ public sealed partial class DirectExecutionBackend
}
}
var consumedThreadBlock = GuestThreadExecution.TryConsumeCurrentThreadBlock(
out var blockReason,
out var blockContinuation,
out var hasBlockContinuation,
out var blockWakeKey,
out var blockWaiter,
out var blockDeadlineTimestamp);
if (consumedThreadBlock &&
TryYieldGuestThreadToHostStub(argPackPtr, dispatchIndex, returnRip, importStubEntry.Nid, blockReason))
{
if (hasBlockContinuation)
{
RegisterBlockedGuestThreadContinuation(
GuestThreadExecution.CurrentGuestThreadHandle,
blockContinuation,
blockWakeKey,
blockWaiter,
blockDeadlineTimestamp);
}
cpuContext[CpuRegister.Rax] = 0uL;
}
if (probeLeafReturn)
{
Console.Error.WriteLine(
$"[LOADER][TRACE] leaf-return-probe-exit nid={importStubEntry.Nid} " +
$"original=0x{returnRip:X16} final=0x{*(ulong*)(argPackPtr + 96):X16} " +
$"rsp=0x{leafStackPointer:X16} active_slot=0x{ActiveGuestReturnSlotAddress:X16} " +
$"yield={ActiveGuestThreadYieldRequested}");
$"block={consumedThreadBlock} yield={ActiveGuestThreadYieldRequested}");
}
result = cpuContext[CpuRegister.Rax];
@@ -1367,8 +1410,6 @@ public sealed partial class DirectExecutionBackend
"eE4Szl8sil8" or // sceKernelAprSubmitCommandBuffer
"qvMUCyyaCSI" or // sceKernelAprSubmitCommandBufferAndGetId
"Q2V+iqvjgC0" or // vsnprintf
"AV6ipCNa4Rw" or // strcasecmp
"viiwFMaNamA" or // strstr
"q1cHNfGycLI" or // scePadRead
"xk0AcarP3V4" or // scePadOpen
"yH17Q6NWtVg" or // sceUserServiceGetEvent
@@ -1395,9 +1436,6 @@ public sealed partial class DirectExecutionBackend
var expectedMutexTrylockBusy =
string.Equals(nid, "K-jXhbt2gn4", StringComparison.Ordinal) &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
var expectedSemaphoreTrywaitAgain =
string.Equals(nid, "H2a+IN9TP0E", StringComparison.Ordinal) &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN;
var expectedNetAcceptWouldBlock =
string.Equals(nid, "PIWqhn9oSxc", StringComparison.Ordinal) &&
resultValue == unchecked((int)0x80410123);
@@ -1411,7 +1449,6 @@ public sealed partial class DirectExecutionBackend
!expectedTimedWaitTimeout &&
!expectedEqueueTimeout &&
!expectedMutexTrylockBusy &&
!expectedSemaphoreTrywaitAgain &&
!expectedNetAcceptWouldBlock &&
!expectedUserServiceNoEvent &&
!expectedPrivacyInvalidParameter)
@@ -1534,8 +1571,6 @@ public sealed partial class DirectExecutionBackend
"WkkeywLJcgU" or // wcslen
"Ovb2dSJOAuE" or // strcmp
"aesyjrHVWy4" or // strncmp
"AV6ipCNa4Rw" or // strcasecmp
"viiwFMaNamA" or // strstr
"pNtJdE3x49E" or // wcscmp
"fV2xHER+bKE" or // wcscoll
"E8wCoUEbfzk" or // wcsncmp
@@ -50,15 +50,6 @@ public sealed unsafe partial class DirectExecutionBackend
private const int LinuxUcontextGregsOffset = 40;
private const int LinuxGregsErrOffset = 19 * 8;
// XMM0 starts after the exception/thread states and the floating-point header on Darwin.
// Linux mcontext_t instead stores a pointer to an FXSAVE-compatible _libc_fpstate after
// gregs[23], where XMM0 begins at byte 160.
private const int DarwinMcontextFloatStateOffset = 16 + 21 * 8;
private const int DarwinFloatStateXmmOffset = 168;
private const int LinuxFpregsPointerOffset = 23 * 8;
private const int LinuxFxsaveXmmOffset = 160;
private const int XmmRegisterBlockSize = 16 * 16;
// Byte offsets of the general registers relative to GetPosixRegisterBase,
// ordered to match the contiguous Win64 CONTEXT block CTX_RAX..CTX_RIP
// (rax, rcx, rdx, rbx, rsp, rbp, rsi, rdi, r8..r15, rip). Verified
@@ -79,8 +70,6 @@ public sealed unsafe partial class DirectExecutionBackend
[ThreadStatic]
private static int _posixSignalHandlerDepth;
[ThreadStatic]
private static bool _posixVectorContextAvailable;
private void SetupPosixExceptionHandler()
{
@@ -132,10 +121,8 @@ public sealed unsafe partial class DirectExecutionBackend
{
byte* fakeUcontext = stackalloc byte[512];
new Span<byte>(fakeUcontext, 512).Clear();
// Large enough for the Darwin mcontext64 XMM block the handler now
// round-trips (exception+thread state + float state = 708 bytes).
byte* fakeMcontext = stackalloc byte[768];
new Span<byte>(fakeMcontext, 768).Clear();
byte* fakeMcontext = stackalloc byte[512];
new Span<byte>(fakeMcontext, 512).Clear();
if (OperatingSystem.IsMacOS())
{
*(byte**)(fakeUcontext + DarwinUcontextMcontextOffset) = fakeMcontext;
@@ -167,33 +154,6 @@ public sealed unsafe partial class DirectExecutionBackend
record.ExceptionInformation[1] = 0x70000;
_ = TryHandleLazyCommittedPage(&record, 0, 0);
ChainPreviousPosixAction(0, 0, 0);
// Rosetta can deliver the first unsupported SHA instruction before the
// runtime is in a safe state to JIT the recovery path. Decode and execute
// each supported form once now, while signal handlers are not installed.
byte[][] shaWarmupOpcodes =
{
new byte[] { 0x0F, 0x38, 0xC9, 0xC1 }, // sha1msg1 xmm0, xmm1
new byte[] { 0x0F, 0x38, 0xCA, 0xC1 }, // sha1msg2 xmm0, xmm1
new byte[] { 0x0F, 0x38, 0xC8, 0xC1 }, // sha1nexte xmm0, xmm1
new byte[] { 0x0F, 0x3A, 0xCC, 0xC1, 0x00 }, // sha1rnds4 xmm0, xmm1, 0
};
_posixVectorContextAvailable = true;
try
{
foreach (byte[] opcode in shaWarmupOpcodes)
{
fixed (byte* instructionBytes = opcode)
{
WriteCtxU64(contextRecord, CTX_RIP, (ulong)instructionBytes);
_ = TryRecoverIllegalInstruction(contextRecord, (ulong)instructionBytes);
}
}
}
finally
{
_posixVectorContextAvailable = false;
}
}
finally
{
@@ -291,15 +251,6 @@ public sealed unsafe partial class DirectExecutionBackend
{
WriteCtxU64(contextRecord, CTX_RAX + i * 8, *(ulong*)(registers + offsets[i]));
}
// Only bridge the XMM block for SIGILL, the sole signal whose recovery
// may emulate an XMM instruction (Intel SHA). Copying 256 bytes in and
// out of the mcontext on every SIGSEGV (the hot demand-paging path) is
// pure overhead and needless surface for corruption.
byte* xmmRegisters = signal == PosixSigIll ? GetPosixXmmBase(registers) : null;
if (xmmRegisters != null)
{
CopyXmmRegisterBlock(contextRecord + CTX_XMM0, xmmRegisters);
}
EXCEPTION_RECORD record = default;
record.ExceptionAddress = (void*)ReadCtxU64(contextRecord, CTX_RIP);
@@ -343,21 +294,13 @@ public sealed unsafe partial class DirectExecutionBackend
// every fault anyway, and recovering here avoids dumping the full
// VectoredHandler diagnostics for each recoverable trap.
int disposition = 0;
_posixVectorContextAvailable = xmmRegisters != null;
try
if (_posixRawRecoveryEnabled)
{
if (_posixRawRecoveryEnabled)
{
disposition = TryRecoverUnresolvedSentinel(&pointers);
}
if (disposition != -1 && !_posixSignalWarmup && _posixSignalBackend is { } backend)
{
disposition = backend.VectoredHandler(&pointers);
}
disposition = TryRecoverUnresolvedSentinel(&pointers);
}
finally
if (disposition != -1 && !_posixSignalWarmup && _posixSignalBackend is { } backend)
{
_posixVectorContextAvailable = false;
disposition = backend.VectoredHandler(&pointers);
}
if (traceSignal)
{
@@ -374,10 +317,6 @@ public sealed unsafe partial class DirectExecutionBackend
{
*(ulong*)(registers + offsets[i]) = ReadCtxU64(contextRecord, CTX_RAX + i * 8);
}
if (xmmRegisters != null)
{
CopyXmmRegisterBlock(xmmRegisters, contextRecord + CTX_XMM0);
}
return true;
}
@@ -396,25 +335,6 @@ public sealed unsafe partial class DirectExecutionBackend
return (byte*)ucontext + LinuxUcontextGregsOffset;
}
private static byte* GetPosixXmmBase(byte* registers)
{
if (OperatingSystem.IsMacOS())
{
return registers + DarwinMcontextFloatStateOffset + DarwinFloatStateXmmOffset;
}
byte* fpregs = *(byte**)(registers + LinuxFpregsPointerOffset);
return fpregs == null ? null : fpregs + LinuxFxsaveXmmOffset;
}
private static void CopyXmmRegisterBlock(byte* destination, byte* source)
{
for (var offset = 0; offset < XmmRegisterBlockSize; offset += sizeof(ulong))
{
*(ulong*)(destination + offset) = *(ulong*)(source + offset);
}
}
private static ulong GetPosixFaultAddress(nint siginfo, byte* registers)
{
ulong address = siginfo != 0 ? *(ulong*)((byte*)siginfo + PosixSigInfoAddressOffset) : 0;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -252,7 +252,7 @@ public static unsafe class JitStubs
var pattern = TlsAccessPattern;
var end = start + length - pattern.Length;
for (var ptr = start; ptr <= end; ptr++)
for (var ptr = start; ptr < end; ptr++)
{
if (MatchesPattern(ptr, pattern))
{
@@ -1,115 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System;
namespace SharpEmu.Core.Cpu.Native;
/// <summary>
/// Recognizes Sony's AMD-only SSE4a EXTRQ+blend idiom and rewrites it into an
/// equivalent SSE4.1 sequence. SharpEmu executes guest x86-64 natively, but
/// Rosetta 2 and Intel hosts do not implement SSE4a, so the original opcode
/// raises #UD -> SIGILL. The compiler emits the idiom against whichever XMM
/// register it happens to allocate (Dead Cells uses xmm1, others xmm2), so the
/// source register is read from the ModRM r/m field rather than hard-coded.
///
/// The match/encode logic is deliberately free of native page-patching so it
/// can be unit-tested against handcrafted byte sequences.
/// </summary>
public static class Sse4aExtrqBlendPatch
{
/// <summary>Length in bytes of both the matched idiom and its replacement.</summary>
public const int SequenceLength = 12;
/// <summary>
/// Matches the 12-byte idiom, extracting the destination register D and the
/// source (scratch) register N:
/// <code>
/// EXTRQ xmmN, 0x28, 0x00 ; 66 0F 78 /0 28 00 mask xmmN to low 40 bits
/// VPBLENDD xmmD, xmmD, xmmN, 2 ; C4 E3 vvvv 02 /r 02 copy dword 1 into xmmD
/// </code>
/// N lives in the ModRM r/m field of both instructions; D (the blend
/// destination and src1) lives in the VPBLENDD ModRM reg field and VEX.vvvv.
/// Both are xmm0-xmm7 (the VEX byte1 0xE3 pins R/X/B, so no xmm8-15 extension).
/// The compiler allocates whichever registers it likes — Dead Cells builds use
/// D=xmm0 and D=xmm3, others differ — so both are read from the encoding.
/// </summary>
public static bool TryMatch(ReadOnlySpan<byte> source, out int destRegister, out int srcRegister)
{
destRegister = -1;
srcRegister = -1;
if (source.Length < SequenceLength)
{
return false;
}
// EXTRQ xmmN, 0x28, 0x00 : 66 0F 78, ModRM (mod=11 reg=000 rm=N), 28, 00.
if (source[0] != 0x66 || source[1] != 0x0F || source[2] != 0x78 ||
(source[3] & 0xF8) != 0xC0 || source[4] != 0x28 || source[5] != 0x00)
{
return false;
}
var n = source[3] & 0x07;
// VPBLENDD xmmD, xmmD, xmmN, 2 : C4 E3 <W=0 vvvv=~D L=0 pp=01> 02 ModRM 02.
// VEX.byte2 fixed bits (W, L, pp) must read 0b*0000*01; vvvv encodes ~D.
if (source[6] != 0xC4 || source[7] != 0xE3 || (source[8] & 0x87) != 0x01 ||
source[9] != 0x02 || source[11] != 0x02)
{
return false;
}
var d = (~(source[8] >> 3)) & 0x0F;
if (d > 7)
{
return false;
}
// ModRM: mod=11, reg=D (dest = src1), rm=N (src2 = the masked register).
if (source[10] != (0xC0 | (d << 3) | n))
{
return false;
}
destRegister = d;
srcRegister = n;
return true;
}
/// <summary>
/// Writes the SSE4.1 equivalent into <paramref name="destination"/>:
/// <code>
/// PEXTRB eax, xmmN, 4 ; 66 0F 3A 14 /r 04 extract byte 4 (zero-extended)
/// PINSRD xmmD, eax, 1 ; 66 0F 3A 22 /r 01 insert into xmmD dword lane 1
/// </code>
/// After EXTRQ masks xmmN to its low 40 bits, dword 1 is just byte 4
/// zero-extended, so the two-instruction extract/insert reproduces the exact
/// observable result the AMD idiom left in xmmD. eax is a caller-dead scratch
/// at every site the compiler emits this idiom.
/// </summary>
public static bool TryEncode(int destRegister, int srcRegister, Span<byte> destination)
{
if ((uint)destRegister > 7 || (uint)srcRegister > 7 || destination.Length < SequenceLength)
{
return false;
}
// PEXTRB eax, xmmN, 4 : ModRM (mod=11 reg=N rm=000 -> eax), imm8 = byte index 4.
destination[0] = 0x66;
destination[1] = 0x0F;
destination[2] = 0x3A;
destination[3] = 0x14;
destination[4] = (byte)(0xC0 | (srcRegister << 3));
destination[5] = 0x04;
// PINSRD xmmD, eax, 1 : ModRM (mod=11 reg=D -> xmmD, rm=000 -> eax), lane 1.
destination[6] = 0x66;
destination[7] = 0x0F;
destination[8] = 0x3A;
destination[9] = 0x22;
destination[10] = (byte)(0xC0 | (destRegister << 3));
destination[11] = 0x01;
return true;
}
}
@@ -873,15 +873,6 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
public bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source)
{
// A managed write into a page the guest-image write tracker has
// protected surfaces as a fatal AccessViolation — the runtime turns
// SIGSEGV in managed code into an exception before the resumable
// signal bridge can restore access (native guest stores recover
// there). Pre-visit the span so tracked pages are unprotected and
// their owners dirtied before the copy; guest addresses are
// host-identical, matching the tracker's fault addresses.
GuestImageWriteTracker.NotifyManagedWrite(virtualAddress, (ulong)source.Length);
var requiresExclusiveAccess = false;
_gate.EnterReadLock();
try
+10 -46
View File
@@ -92,11 +92,6 @@ public partial class MainWindow : Window
// plain window color remains the fallback when the asset fails to load.
private Bitmap? _defaultBackdrop;
// Whether the native loading/closing popup should be showing; it is a
// desktop-topmost popup, so it closes while the launcher is in the
// background or minimized and reopens from this flag on activation.
private bool _sessionLoadingActive;
// Controller navigation state.
private readonly DispatcherTimer _gamepadTimer;
private HostGamepadButtons _previousPadButtons;
@@ -155,18 +150,8 @@ public partial class MainWindow : Window
};
_libraryBlurTimer.Tick += (_, _) => AdvanceLibraryBlur();
// Native popups float above every window on the desktop; they must
// follow the launcher into the background or a minimized state.
Activated += (_, _) =>
{
UpdateSessionBarVisibility();
SessionLoadingPopup.IsOpen = _sessionLoadingActive;
};
Deactivated += (_, _) =>
{
SessionBarPopup.IsOpen = false;
SessionLoadingPopup.IsOpen = false;
};
Activated += (_, _) => UpdateSessionBarVisibility();
Deactivated += (_, _) => SessionBarPopup.IsOpen = false;
TitleBar.PointerPressed += OnTitleBarPointerPressed;
GameList.SelectionChanged += (_, _) => UpdateSelectedGame();
@@ -429,15 +414,6 @@ public partial class MainWindow : Window
return;
}
if (_isRunning || _isStopping)
{
// The game renders inside the launcher window, so the launcher
// stays active while playing. The controller belongs to the game
// then: no navigation, and Circle/B must never stop the session.
_previousPadButtons = pad.Buttons;
return;
}
var shoulderPressed = pad.Buttons & ~_previousPadButtons;
if ((shoulderPressed & HostGamepadButtons.L1) != 0)
{
@@ -487,6 +463,11 @@ public partial class MainWindow : Window
LaunchSelected();
}
if ((pressed & HostGamepadButtons.Circle) != 0)
{
StopEmulator();
}
_previousPadButtons = pad.Buttons;
}
@@ -1645,23 +1626,13 @@ public partial class MainWindow : Window
base.OnPropertyChanged(change);
if (change.Property == WindowStateProperty)
{
// The XAML WindowState="Maximized" assignment raises this change
// during InitializeComponent, before named controls are wired up.
if (WindowState == WindowState.Minimized)
{
_sndPreview.Pause();
if (SessionLoadingPopup is { } popup)
{
popup.IsOpen = false;
}
}
else
{
_sndPreview.Resume();
if (SessionLoadingPopup is { } popup)
{
popup.IsOpen = _sessionLoadingActive;
}
}
}
}
@@ -2038,7 +2009,7 @@ public partial class MainWindow : Window
ContentToolbar.IsVisible = false;
ConsolePanel.IsVisible = false;
LaunchBar.IsVisible = false;
HideSessionLoading();
SessionLoadingPopup.IsOpen = false;
UpdateSessionBarVisibility();
}
});
@@ -2138,7 +2109,7 @@ public partial class MainWindow : Window
GameView.IsVisible = false;
GameView.IsHitTestVisible = true;
SessionBarPopup.IsOpen = false;
HideSessionLoading();
SessionLoadingPopup.IsOpen = false;
AnimateLibraryBlur(0, clearWhenComplete: true);
MainContent.Margin = new Thickness(32, 24, 32, 20);
ContentToolbar.IsVisible = true;
@@ -2222,14 +2193,7 @@ public partial class MainWindow : Window
{
SessionLoadingTitle.Text = title;
SessionLoadingDetail.Text = detail;
_sessionLoadingActive = true;
SessionLoadingPopup.IsOpen = IsActive && WindowState != WindowState.Minimized;
}
private void HideSessionLoading()
{
_sessionLoadingActive = false;
SessionLoadingPopup.IsOpen = false;
SessionLoadingPopup.IsOpen = true;
}
private void ReturnToLibraryWhileStopping()
+5 -40
View File
@@ -51,33 +51,9 @@ public static unsafe class GuestImageWriteTracker
private static readonly object _gate = new();
private static readonly Dictionary<ulong, TrackedRange> _rangesByAddress = new();
/// <summary>Immutable snapshot read lock-free from the signal handler and
/// the managed-write pre-visit; rebuilt on every mutation under the gate
/// (signal handlers must not take managed locks). Carrying the overall
/// bounds inside the same object keeps the hot-path intersection test
/// consistent with the array it guards.</summary>
private sealed class RangeSnapshot
{
public static readonly RangeSnapshot Empty = new([]);
public readonly TrackedRange[] Ranges;
public readonly ulong Start;
public readonly ulong End;
public RangeSnapshot(TrackedRange[] ranges)
{
Ranges = ranges;
Start = ulong.MaxValue;
End = 0;
foreach (var range in ranges)
{
Start = Math.Min(Start, range.Start);
End = Math.Max(End, range.End);
}
}
}
private static RangeSnapshot _rangeSnapshot = RangeSnapshot.Empty;
// Snapshot array read lock-free from the signal handler; rebuilt on every
// mutation under the gate. Signal handlers must not take managed locks.
private static TrackedRange[] _rangeSnapshot = [];
private static readonly bool _enabled = !OperatingSystem.IsWindows() &&
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
@@ -290,17 +266,6 @@ public static unsafe class GuestImageWriteTracker
var end = address > ulong.MaxValue - byteCount
? ulong.MaxValue
: address + byteCount;
// Fast rejection for the hot path: this runs on every managed guest
// write, and almost none of them touch tracked texture pages. The
// bounds live inside the snapshot so they are always consistent with
// the ranges the per-page visit below would consult.
var snapshot = Volatile.Read(ref _rangeSnapshot);
if (snapshot.Ranges.Length == 0 || end <= snapshot.Start || address >= snapshot.End)
{
return;
}
var candidate = address;
while (candidate < end)
{
@@ -346,7 +311,7 @@ public static unsafe class GuestImageWriteTracker
return false;
}
var ranges = Volatile.Read(ref _rangeSnapshot).Ranges;
var ranges = Volatile.Read(ref _rangeSnapshot);
var writableStart = ulong.MaxValue;
var writableEnd = 0UL;
for (var index = 0; index < ranges.Length; index++)
@@ -493,7 +458,7 @@ public static unsafe class GuestImageWriteTracker
private static void RebuildSnapshotLocked()
{
Volatile.Write(ref _rangeSnapshot, new RangeSnapshot(_rangesByAddress.Values.ToArray()));
_rangeSnapshot = _rangesByAddress.Values.ToArray();
}
private static (ulong Start, ulong Length) PageAlign(ulong address, ulong byteCount)
-116
View File
@@ -1,116 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.HLE;
/// <summary>
/// Support for HLE synchronization primitives that block the guest thread's
/// host thread in place (inside the HLE call, on a host primitive) instead of
/// capturing a continuation and re-scheduling through the cooperative wake-key
/// machinery. In-place blocking makes block-and-wake atomic — the host
/// primitive owns the race — which removes the lost-wakeup window the
/// continuation path had between block registration and wake delivery.
/// </summary>
public static class GuestThreadBlocking
{
/// <summary>
/// Upper bound on a single host wait while a guest thread is parked. Waits
/// are sliced so parked threads observe <see cref="ShutdownRequested"/>
/// promptly at teardown; a wake via Monitor.Pulse still lands immediately.
/// </summary>
public const int WaitSliceMilliseconds = 50;
private static volatile bool _shutdownRequested;
// Guest thread handle -> what it is parked on. Populated only while a
// thread is blocked (the slow path), read by the stall watchdog so
// in-place-blocked threads are not reported as opaque "Running" threads.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, string> _blockDescriptions = new();
/// <summary>True once emulator teardown has begun; parked guest threads unwind.</summary>
public static bool ShutdownRequested => _shutdownRequested;
/// <summary>Called by the execution backend when guest execution is being torn down.</summary>
public static void RequestShutdown() => _shutdownRequested = true;
/// <summary>Records what the given guest thread is about to park on (diagnostics only).</summary>
public static void NoteBlocked(ulong guestThreadHandle, string description)
{
if (guestThreadHandle != 0)
{
_blockDescriptions[guestThreadHandle] = description;
}
}
/// <summary>Clears the parked-state note recorded by <see cref="NoteBlocked"/>.</summary>
public static void NoteUnblocked(ulong guestThreadHandle)
{
if (guestThreadHandle != 0)
{
_blockDescriptions.TryRemove(guestThreadHandle, out _);
}
}
/// <summary>What the thread is parked on, or null if it is not parked in place.</summary>
public static string? DescribeBlock(ulong guestThreadHandle) =>
_blockDescriptions.TryGetValue(guestThreadHandle, out var description) ? description : null;
/// <summary>All currently parked threads (diagnostics; covers the primary thread too).</summary>
public static KeyValuePair<ulong, string>[] SnapshotBlockDescriptions() => _blockDescriptions.ToArray();
// Interrupt delivery for threads parked in place. A thread blocked inside
// an HLE wait keeps its executor busy, so it never reaches the import-return
// safe point where queued guest exceptions (IL2CPP stop-the-world suspend)
// are delivered. When an exception is queued for such a thread, its handle
// is flagged here; each sliced wait loop calls Checkpoint, which — on the
// thread's OWN host thread, with the wait's gate released — runs the
// registered deliverer (the same safe-point delivery used at import
// boundaries), then the loop re-checks its predicate and re-parks. This is
// the SA_RESTART-style "signal on top of a blocking wait" a real kernel
// provides. Dormant unless an exception is actually pending (empty-check
// fast path), so it adds no cost to normal blocking.
private static readonly System.Collections.Concurrent.ConcurrentDictionary<ulong, byte> _interrupted = new();
/// <summary>Set by the backend: delivers any exception queued for the current guest thread, in place.</summary>
public static Action? DeliverInterruptForCurrentThread { get; set; }
/// <summary>Flags a parked guest thread to deliver a queued exception at its next wait checkpoint.</summary>
public static void RequestInterrupt(ulong guestThreadHandle)
{
if (guestThreadHandle != 0)
{
_interrupted[guestThreadHandle] = 0;
}
}
/// <summary>
/// Called from every sliced wait loop while it holds <paramref name="gate"/>. If an
/// exception is pending for the current guest thread, releases the gate, delivers it on
/// this host thread, then re-acquires the gate so the loop re-checks its predicate.
/// </summary>
public static void Checkpoint(ulong guestThreadHandle, object gate)
{
if (_interrupted.IsEmpty || guestThreadHandle == 0 || !_interrupted.TryRemove(guestThreadHandle, out _))
{
return;
}
var deliver = DeliverInterruptForCurrentThread;
if (deliver is null)
{
return;
}
// Never run guest code (the handler) while holding an HLE gate — the
// handler may re-enter this same primitive. Release across delivery.
Monitor.Exit(gate);
try
{
deliver();
}
finally
{
Monitor.Enter(gate);
}
}
}
+213
View File
@@ -30,6 +30,13 @@ public readonly record struct GuestThreadSnapshot(
/// false leaves it parked. Resume runs later on the woken thread outside that gate, and
/// its return value becomes the guest's RAX for the resumed call.
/// </summary>
public interface IGuestThreadBlockWaiter
{
int Resume();
bool TryWake();
}
public interface IGuestThreadScheduler
{
bool SupportsGuestContextTransfer { get; }
@@ -50,6 +57,10 @@ public interface IGuestThreadScheduler
out ulong returnValue,
out string? error);
void Pump(CpuContext callerContext, string reason);
int WakeBlockedThreads(string wakeKey, int maxCount = int.MaxValue);
/// <summary>
/// Applies a new guest scheduling priority to a live thread, mapping it
/// onto the host thread if one is running. Returns false when the thread
@@ -141,12 +152,46 @@ public readonly record struct GuestCpuContinuation(
public static class GuestThreadExecution
{
private sealed class DelegateGuestThreadBlockWaiter : IGuestThreadBlockWaiter
{
private readonly Func<int> _resume;
private readonly Func<bool> _tryWake;
public DelegateGuestThreadBlockWaiter(Func<int> resume, Func<bool> tryWake)
{
_resume = resume;
_tryWake = tryWake;
}
public int Resume() => _resume();
public bool TryWake() => _tryWake();
}
[ThreadStatic]
private static ulong _currentGuestThreadHandle;
[ThreadStatic]
private static ulong _currentFiberAddress;
[ThreadStatic]
private static string? _pendingBlockReason;
[ThreadStatic]
private static bool _pendingBlockContinuationValid;
[ThreadStatic]
private static GuestCpuContinuation _pendingBlockContinuation;
[ThreadStatic]
private static string? _pendingBlockWakeKey;
[ThreadStatic]
private static IGuestThreadBlockWaiter? _pendingBlockWaiter;
[ThreadStatic]
private static long _pendingBlockDeadlineTimestamp;
[ThreadStatic]
private static bool _pendingEntryExit;
@@ -186,6 +231,12 @@ public static class GuestThreadExecution
{
var previous = _currentGuestThreadHandle;
_currentGuestThreadHandle = threadHandle;
_pendingBlockReason = null;
_pendingBlockContinuationValid = false;
_pendingBlockContinuation = default;
_pendingBlockWakeKey = null;
_pendingBlockWaiter = null;
_pendingBlockDeadlineTimestamp = 0;
_pendingEntryExit = false;
_pendingEntryExitValue = 0;
_pendingEntryExitReason = null;
@@ -201,6 +252,12 @@ public static class GuestThreadExecution
public static void RestoreGuestThread(ulong previousThreadHandle)
{
_currentGuestThreadHandle = previousThreadHandle;
_pendingBlockReason = null;
_pendingBlockContinuationValid = false;
_pendingBlockContinuation = default;
_pendingBlockWakeKey = null;
_pendingBlockWaiter = null;
_pendingBlockDeadlineTimestamp = 0;
_pendingEntryExit = false;
_pendingEntryExitValue = 0;
_pendingEntryExitReason = null;
@@ -224,6 +281,123 @@ public static class GuestThreadExecution
_currentFiberAddress = previousFiberAddress;
}
public static bool RequestCurrentThreadBlock(string reason) => RequestCurrentThreadBlock(null, reason);
public static bool RequestCurrentThreadBlock(
CpuContext? context,
string reason,
string? wakeKey = null,
IGuestThreadBlockWaiter? waiter = null,
long blockDeadlineTimestamp = 0)
{
if (!IsGuestThread)
{
return false;
}
_pendingBlockReason = string.IsNullOrWhiteSpace(reason) ? "guest_thread_blocked" : reason;
_pendingBlockWakeKey = string.IsNullOrWhiteSpace(wakeKey) ? _pendingBlockReason : wakeKey;
_pendingBlockWaiter = waiter;
_pendingBlockDeadlineTimestamp = blockDeadlineTimestamp;
if (context is not null && TryCaptureCurrentBlockContinuation(context, out var continuation))
{
_pendingBlockContinuation = continuation;
_pendingBlockContinuationValid = true;
}
else
{
_pendingBlockContinuation = default;
_pendingBlockContinuationValid = false;
}
return true;
}
// Compatibility bridge for exports that still describe blocked work as a
// resume/wake delegate pair. New hot paths should provide an
// IGuestThreadBlockWaiter directly to avoid allocating closures.
public static bool RequestCurrentThreadBlock(
CpuContext? context,
string reason,
string? wakeKey,
Func<int> resumeHandler,
Func<bool> wakeHandler,
long blockDeadlineTimestamp = 0) =>
RequestCurrentThreadBlock(
context,
reason,
wakeKey,
new DelegateGuestThreadBlockWaiter(resumeHandler, wakeHandler),
blockDeadlineTimestamp);
public static bool TryConsumeCurrentThreadBlock(out string reason)
{
return TryConsumeCurrentThreadBlock(out reason, out _, out _);
}
public static bool TryConsumeCurrentThreadBlock(
out string reason,
out GuestCpuContinuation continuation,
out bool hasContinuation)
{
return TryConsumeCurrentThreadBlock(
out reason,
out continuation,
out hasContinuation,
out _,
out _,
out _);
}
public static bool TryConsumeCurrentThreadBlock(
out string reason,
out GuestCpuContinuation continuation,
out bool hasContinuation,
out string wakeKey,
out IGuestThreadBlockWaiter? waiter)
{
return TryConsumeCurrentThreadBlock(
out reason,
out continuation,
out hasContinuation,
out wakeKey,
out waiter,
out _);
}
public static bool TryConsumeCurrentThreadBlock(
out string reason,
out GuestCpuContinuation continuation,
out bool hasContinuation,
out string wakeKey,
out IGuestThreadBlockWaiter? waiter,
out long blockDeadlineTimestamp)
{
reason = _pendingBlockReason ?? string.Empty;
if (string.IsNullOrEmpty(reason))
{
continuation = default;
hasContinuation = false;
wakeKey = string.Empty;
waiter = null;
blockDeadlineTimestamp = 0;
return false;
}
continuation = _pendingBlockContinuation;
hasContinuation = _pendingBlockContinuationValid;
wakeKey = _pendingBlockWakeKey ?? reason;
waiter = _pendingBlockWaiter;
blockDeadlineTimestamp = _pendingBlockDeadlineTimestamp;
_pendingBlockReason = null;
_pendingBlockContinuation = default;
_pendingBlockContinuationValid = false;
_pendingBlockWakeKey = null;
_pendingBlockWaiter = null;
_pendingBlockDeadlineTimestamp = 0;
return true;
}
public static long ComputeDeadlineTimestamp(TimeSpan timeout)
{
if (timeout <= TimeSpan.Zero)
@@ -243,6 +417,45 @@ public static class GuestThreadExecution
return now + Math.Max(1, ticks);
}
private static bool TryCaptureCurrentBlockContinuation(CpuContext context, out GuestCpuContinuation continuation)
{
if (!TryGetCurrentImportCallFrame(out var frame) ||
frame.ReturnRip < 65536 ||
frame.ResumeRsp == 0 ||
frame.ReturnSlotAddress == 0)
{
continuation = default;
return false;
}
continuation = new GuestCpuContinuation(
frame.ReturnRip,
frame.ResumeRsp,
frame.ReturnSlotAddress,
context.Rflags,
context.FsBase,
context.GsBase,
0,
context[CpuRegister.Rcx],
context[CpuRegister.Rdx],
context[CpuRegister.Rbx],
context[CpuRegister.Rbp],
context[CpuRegister.Rsi],
context[CpuRegister.Rdi],
context[CpuRegister.R8],
context[CpuRegister.R9],
context[CpuRegister.R10],
context[CpuRegister.R11],
context[CpuRegister.R12],
context[CpuRegister.R13],
context[CpuRegister.R14],
context[CpuRegister.R15],
context.FpuControlWord,
context.Mxcsr,
RestoreFullFpuState: false);
return true;
}
public static void RequestCurrentEntryExit(string reason, int status)
{
RequestCurrentEntryExit(reason, unchecked((ulong)(long)status));
+109 -410
View File
@@ -14,12 +14,6 @@ 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()
{
@@ -568,8 +562,6 @@ 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(
@@ -1775,58 +1767,6 @@ public static partial class AgcExports
return ReturnPointer(ctx, commandAddress);
}
// Single-register variant of the SET_SH_REG builders: the register rides
// in rsi as a packed struct (low 16 bits = register offset, high dword =
// byte offset of this dword within a multi-dword register write) and the
// value in edx. Emits the same 3-dword SET_SH_REG packet the plural
// sceAgcCbSetShRegistersDirect path produces per run. Hades calls this
// ~1k times per boot; leaving it unresolved corrupted its DCB stream.
[SysAbiExport(
Nid = "pFLArOT53+w",
ExportName = "sceAgcDcbSetShRegisterDirect",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int DcbSetShRegisterDirect(CpuContext ctx)
{
var commandBufferAddress = ctx[CpuRegister.Rdi];
var packedRegister = ctx[CpuRegister.Rsi];
var value = (uint)ctx[CpuRegister.Rdx];
if (commandBufferAddress == 0)
{
return ReturnPointer(ctx, 0);
}
var offset = (uint)(packedRegister & 0xFFFFu) + (uint)((packedRegister >> 32) >> 2);
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, 3, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(3, ItSetShReg, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 4, offset & 0xFFFFu) ||
!TryWriteUInt32(ctx, commandAddress + 8, value))
{
return ReturnPointer(ctx, 0);
}
TraceAgc($"agc.dcb_set_sh_register_direct buf=0x{commandBufferAddress:X16} reg=0x{offset:X4} value=0x{value:X8}");
return ReturnPointer(ctx, commandAddress);
}
// Size probe for the wait-on-address writer below: same argument prefix
// minus the command buffer, returns the byte size the writer will emit so
// the game can reserve DCB space (7 dwords for a standard WAIT_REG_MEM,
// 6/9 for the 32/64-bit polled-NOP forms).
[SysAbiExport(
Nid = "43WJ08sSugE",
ExportName = "sceAgcDcbWaitOnAddressGetSize",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int DcbWaitOnAddressGetSize(CpuContext ctx)
{
var size = (uint)(ctx[CpuRegister.Rdi] & 0xFF);
var operation = (uint)(ctx[CpuRegister.Rdx] & 0xFF);
var packetDwords = operation is 2 or 3 ? 7u : size == 0 ? 6u : 9u;
ctx[CpuRegister.Rax] = packetDwords * sizeof(uint);
return (int)ctx[CpuRegister.Rax];
}
[SysAbiExport(
Nid = "VmW0Tdpy420",
ExportName = "sceAgcDcbWaitRegMem",
@@ -2676,32 +2616,6 @@ public static partial class AgcExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
// Hands the game the driver-side context id its GPU event-queue packets
// reference. We key events purely on (equeue, ident, filter), so a single
// stable id satisfies the contract; the game only checks the call
// succeeded and threads the id back through later driver calls.
[SysAbiExport(
Nid = "Zw7uUVPulbw",
ExportName = "sceAgcDriverGetEqContextId",
Target = Generation.Gen5,
LibraryName = "libSceAgcDriver")]
public static int DriverGetEqContextId(CpuContext ctx)
{
var contextIdAddress = ctx[CpuRegister.Rdi];
if (contextIdAddress == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
if (!TryWriteUInt32(ctx, contextIdAddress, 1))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TraceAgc($"agc.driver_get_eq_context_id out=0x{contextIdAddress:X16} -> 1");
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "DL2RXaXOy88",
ExportName = "sceAgcDriverDeleteEqEvent",
@@ -2752,7 +2666,7 @@ public static partial class AgcExports
TraceAgc($"agc.driver_submit_dcb packet=0x{packetAddress:X16} addr=0x{commandAddress:X16} dwords={dwordCount}");
}
GuestGpu.Current.AttachGuestMemory(ctx.Memory);
VulkanVideoPresenter.AttachGuestMemory(ctx.Memory);
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
lock (gpuState.Gate)
{
@@ -2804,7 +2718,7 @@ public static partial class AgcExports
$"addr=0x{commandAddress:X16} dwords={dwordCount}");
}
GuestGpu.Current.AttachGuestMemory(ctx.Memory);
VulkanVideoPresenter.AttachGuestMemory(ctx.Memory);
var gpuState = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
lock (gpuState.Gate)
{
@@ -3032,7 +2946,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 (GuestGpu.Current.SubmitOrderedGuestAction(
if (VulkanVideoPresenter.SubmitOrderedGuestAction(
TriggerCompletionEvents,
$"agc submit completion {submissionId}") == 0)
{
@@ -3056,11 +2970,11 @@ public static partial class AgcExports
return false;
}
using var guestQueueScope = GuestGpu.Current.EnterGuestQueue(
using var guestQueueScope = VulkanVideoPresenter.EnterGuestQueue(
state.QueueName,
state.ActiveSubmissionId);
var windowByteCount = checked((int)(dwordCount * sizeof(uint)));
var rented = GuestDataPool.Shared.Rent(windowByteCount);
var rented = VulkanVideoPresenter.GuestDataPool.Rent(windowByteCount);
try
{
if (ctx.Memory.TryRead(commandAddress, rented.AsSpan(0, windowByteCount)))
@@ -3082,7 +2996,7 @@ public static partial class AgcExports
{
_dcbWindowBuffer = null;
_dcbWindowByteLength = 0;
GuestDataPool.Shared.Return(rented);
VulkanVideoPresenter.GuestDataPool.Return(rented);
}
}
@@ -3384,20 +3298,17 @@ public static partial class AgcExports
indexed: false);
}
if (op is ItDispatchDirect or ItDispatchIndirect)
if ((op is ItDispatchDirect or ItDispatchIndirect) &&
TryReadComputeDispatch(
ctx,
state,
currentAddress,
length,
op,
out var dispatch))
{
if (TryReadComputeDispatch(
ctx,
state,
currentAddress,
length,
op,
out var dispatch,
out _))
{
state.FrameDispatchCount++;
ObserveComputeDispatch(ctx, gpuState, state, dispatch);
}
state.FrameDispatchCount++;
ObserveComputeDispatch(ctx, gpuState, state, dispatch);
}
if (op == ItNop &&
@@ -3406,7 +3317,7 @@ public static partial class AgcExports
TryReadUInt32(ctx, currentAddress + 4, out var waitVideoOutHandle) &&
TryReadUInt32(ctx, currentAddress + 8, out var waitDisplayBufferIndex))
{
var waitSequence = GuestGpu.Current.SubmitOrderedGuestFlipWait(
var waitSequence = VulkanVideoPresenter.SubmitOrderedGuestFlipWait(
unchecked((int)waitVideoOutHandle),
unchecked((int)waitDisplayBufferIndex));
TraceAgcShader(
@@ -3735,11 +3646,27 @@ public static partial class AgcExports
void CompleteAndWake()
{
CompleteLabelProducer(producer);
lock (gpuState.WaitMonitorSignalGate)
if (GpuWaitRegistry.Count == 0)
{
gpuState.WaitMonitorSignalVersion++;
Monitor.Pulse(gpuState.WaitMonitorSignalGate);
return;
}
// 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()
@@ -3750,7 +3677,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 (GuestGpu.Current.SubmitOrderedGuestAction(
if (VulkanVideoPresenter.SubmitOrderedGuestAction(
CompleteAndWake,
$"{debugName} completion") == 0)
{
@@ -3758,7 +3685,7 @@ public static partial class AgcExports
}
}
if (GuestGpu.Current.SubmitOrderedGuestAction(
if (VulkanVideoPresenter.SubmitOrderedGuestAction(
ApplyAndQueueCompletion,
debugName) == 0)
{
@@ -3968,11 +3895,11 @@ public static partial class AgcExports
TraceAgc(
$"agc.acquire_mem_applied queue={queueName} " +
$"submission={submissionId} packet=0x{packetAddress:X16} " +
$"work_sequence={GuestGpu.Current.CurrentGuestWorkSequenceForDiagnostics}");
$"work_sequence={VulkanVideoPresenter.CurrentGuestWorkSequenceForDiagnostics}");
}
}
var sequence = GuestGpu.Current.SubmitOrderedGuestAction(
var sequence = VulkanVideoPresenter.SubmitOrderedGuestAction(
ApplyAcquire,
debugName);
if (sequence == 0)
@@ -4118,7 +4045,7 @@ public static partial class AgcExports
return;
}
foreach (var (address, width, height, byteCount) in GuestGpu.Current.GetGuestImageExtents())
foreach (var (address, width, height, byteCount) in VulkanVideoPresenter.GetGuestImageExtents())
{
if (scopeByteCount != ulong.MaxValue &&
!RangesOverlap(address, byteCount, scopeAddress, scopeByteCount))
@@ -4139,7 +4066,7 @@ public static partial class AgcExports
var pixels = new byte[byteCount];
if (ctx.Memory.TryRead(address, pixels))
{
GuestGpu.Current.SubmitGuestImageWrite(address, pixels);
VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels);
if (Interlocked.Increment(ref _guestImageSyncTraceCount) <= 64)
{
Console.Error.WriteLine(
@@ -4182,7 +4109,7 @@ public static partial class AgcExports
ulong byteCount,
uint? fillValue)
{
var hasImage = GuestGpu.Current.TryGetGuestImageExtent(
var hasImage = VulkanVideoPresenter.TryGetGuestImageExtent(
destinationAddress,
out var width,
out var height,
@@ -4206,14 +4133,14 @@ public static partial class AgcExports
if (fillValue is { } fill)
{
GuestGpu.Current.SubmitGuestImageFill(destinationAddress, fill);
VulkanVideoPresenter.SubmitGuestImageFill(destinationAddress, fill);
return;
}
var pixels = new byte[imageBytes];
if (ctx.Memory.TryRead(destinationAddress, pixels))
{
GuestGpu.Current.SubmitGuestImageWrite(destinationAddress, pixels);
VulkanVideoPresenter.SubmitGuestImageWrite(destinationAddress, pixels);
}
}
@@ -4601,17 +4528,6 @@ 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.
@@ -4676,85 +4592,6 @@ 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,
@@ -4918,45 +4755,38 @@ public static partial class AgcExports
SubmittedGpuState gpuState)
{
var delayMilliseconds = 1;
long observedSignal;
lock (gpuState.WaitMonitorSignalGate)
{
observedSignal = gpuState.WaitMonitorSignalVersion;
}
while (true)
{
int resumed;
int remaining;
var madeProgress = false;
lock (gpuState.Gate)
{
resumed = DrainResumableDcbs(ctx, gpuState, tracePackets: _traceAgc);
remaining = GpuWaitRegistry.CountForMemory(ctx.Memory);
if (_traceAgc && resumed != 0)
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)
{
Console.Error.WriteLine(
$"[LOADER][TRACE] agc.wait_monitor_resumed count={resumed} " +
$"remaining={remaining}");
$"[LOADER][TRACE] agc.wait_monitor_resumed count={before - after} " +
$"remaining={after}");
}
if (remaining == 0)
if (after == 0)
{
gpuState.WaitMonitorRunning = false;
return;
}
}
delayMilliseconds = resumed != 0
delayMilliseconds = madeProgress
? 1
: Math.Min(delayMilliseconds * 2, 16);
lock (gpuState.WaitMonitorSignalGate)
{
if (gpuState.WaitMonitorSignalVersion == observedSignal)
{
Monitor.Wait(gpuState.WaitMonitorSignalGate, delayMilliseconds);
}
observedSignal = gpuState.WaitMonitorSignalVersion;
}
Thread.Sleep(delayMilliseconds);
}
}
@@ -5010,17 +4840,16 @@ 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 int DrainResumableDcbs(
private static void DrainResumableDcbs(
CpuContext ctx,
SubmittedGpuState gpuState,
bool tracePackets)
{
if (!_gpuWaitSuspendEnabled)
{
return 0;
return;
}
var resumedCount = 0;
for (var pass = 0; pass < 256; pass++)
{
var woken = GpuWaitRegistry.CollectSatisfied(ctx.Memory, (address, is64Bit) =>
@@ -5028,50 +4857,7 @@ public static partial class AgcExports
? TryReadUInt64(ctx, address, out var value64) ? value64 : (ulong?)null
: TryReadUInt32(ctx, address, out var value32) ? value32 : (ulong?)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 (woken is null)
{
if (_gpuWaitStaleTicks > 0 &&
GpuWaitRegistry.CollectUnreportedStale(
@@ -5098,20 +4884,14 @@ public static partial class AgcExports
}
}
return resumedCount;
return;
}
if (woken is not null)
foreach (var waiter in woken)
{
foreach (var waiter in woken)
{
ResumeSuspendedDcb(ctx, gpuState, waiter, tracePackets);
resumedCount++;
}
ResumeSuspendedDcb(ctx, gpuState, waiter, tracePackets);
}
}
return resumedCount;
}
private static void ResumeSuspendedDcb(
@@ -5253,15 +5033,6 @@ 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(
@@ -5327,16 +5098,6 @@ 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(
@@ -5637,7 +5398,7 @@ public static partial class AgcExports
state.KnownRenderTargets[resolveSource.Address] = resolveSource;
state.KnownRenderTargets[resolveDestination.Address] = resolveDestination;
ProvideRenderTargetInitialData(ctx, resolveSource);
if (GuestGpu.Current.TrySubmitGuestImageBlit(
if (VulkanVideoPresenter.TrySubmitGuestImageBlit(
resolveSource.Address,
resolveSource.Width,
resolveSource.Height,
@@ -5948,7 +5709,7 @@ public static partial class AgcExports
var cacheKey = (
exportShaderAddress,
exportFingerprint,
_storageBufferOffsetAlignment);
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment);
_depthOnlyVertexShaderCache.TryGetValue(cacheKey, out var vertexShader);
if (vertexShader is null)
@@ -5973,7 +5734,7 @@ public static partial class AgcExports
: guestGlobalBufferCount + 1,
requiredVertexOutputCount: 0,
storageBufferOffsetAlignment:
_storageBufferOffsetAlignment))
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
{
ReturnPooledEvaluationArrays(exportEvaluation);
return false;
@@ -5985,7 +5746,7 @@ public static partial class AgcExports
exportFingerprint,
vertexShader!,
exportState.Program);
GuestGpu.Current.CountShaderCompilation();
VulkanVideoPresenter.CountSpirvCompilation();
_depthOnlyVertexShaderCache.TryAdd(cacheKey, vertexShader!);
}
@@ -6271,7 +6032,7 @@ public static partial class AgcExports
attributeCount,
psInputEna,
psInputAddr,
_storageBufferOffsetAlignment);
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment);
var guestGlobalBuffers =
pixelEvaluation.GlobalMemoryBindings.Count +
@@ -6307,7 +6068,7 @@ public static partial class AgcExports
pixelInputEnable: psInputEna,
pixelInputAddress: psInputAddr,
storageBufferOffsetAlignment:
_storageBufferOffsetAlignment) ||
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment) ||
!GuestGpu.Current.TryCompileVertexShader(
exportState,
exportEvaluation,
@@ -6319,7 +6080,7 @@ public static partial class AgcExports
scalarRegisterBufferIndex: _bakeScalars ? -1 : guestGlobalBuffers + 1,
requiredVertexOutputCount: (int)GetInterpolatedAttributeCount(pixelState),
storageBufferOffsetAlignment:
_storageBufferOffsetAlignment))
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
{
ReturnPooledEvaluationArrays(exportEvaluation);
ReturnPooledEvaluationArrays(pixelEvaluation);
@@ -6339,7 +6100,7 @@ public static partial class AgcExports
pixelStateFingerprint,
compiled.Pixel,
pixelState.Program);
GuestGpu.Current.CountShaderCompilation();
VulkanVideoPresenter.CountSpirvCompilation();
_graphicsShaderCache.TryAdd(shaderKey, compiled);
}
@@ -6617,7 +6378,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 = GuestDataPool.Shared.Rent(byteCount);
var data = VulkanVideoPresenter.GuestDataPool.Rent(byteCount);
var span = data.AsSpan(0, byteCount);
var address = state.IndexBufferAddress + byteOffset;
if (ctx.Memory.TryRead(address, span) ||
@@ -6626,7 +6387,7 @@ public static partial class AgcExports
return new GuestIndexBuffer(data, byteCount, is32Bit, Pooled: true);
}
GuestDataPool.Shared.Return(data);
VulkanVideoPresenter.GuestDataPool.Return(data);
return null;
}
@@ -6653,7 +6414,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 = GuestDataPool.Shared.Rent(chunkBytes);
var scratch = VulkanVideoPresenter.GuestDataPool.Rent(chunkBytes);
var remaining = drawCount;
var maxIndex = 0u;
var sawIndex = false;
@@ -6695,7 +6456,7 @@ public static partial class AgcExports
}
finally
{
GuestDataPool.Shared.Return(scratch);
VulkanVideoPresenter.GuestDataPool.Return(scratch);
}
var indexedRecords = sawIndex && maxIndex != uint.MaxValue
@@ -6819,7 +6580,7 @@ public static partial class AgcExports
{
hash = (hash ^ (
binding.BaseAddress &
(_storageBufferOffsetAlignment - 1))) * prime;
(VulkanVideoPresenter.GuestStorageBufferOffsetAlignment - 1))) * prime;
}
if (evaluation.ComputeSystemRegisters is { } computeSystemRegisters)
@@ -6909,8 +6670,7 @@ public static partial class AgcExports
scissor,
DecodeViewport(registers, target.Width, target.Height, scissor),
DecodeRasterState(registers),
DecodeDepthState(registers),
DecodeBlendConstant(registers));
DecodeDepthState(registers));
}
private static GuestRenderState CreateRenderState(
@@ -6943,8 +6703,7 @@ public static partial class AgcExports
scissor,
DecodeViewport(registers, target.Width, target.Height, scissor),
DecodeRasterState(registers),
DecodeDepthState(registers),
DecodeBlendConstant(registers));
DecodeDepthState(registers));
}
// DB_DEPTH_CONTROL (context register 0x200): Z_ENABLE bit1, Z_WRITE_ENABLE
@@ -7049,22 +6808,6 @@ 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)
@@ -7577,7 +7320,7 @@ public static partial class AgcExports
IReadOnlyList<uint> registers,
IReadOnlyList<Gen5GlobalMemoryBinding> bindings)
{
var bytes = GuestDataPool.Shared.Rent(
var bytes = VulkanVideoPresenter.GuestDataPool.Rent(
GetRuntimeScalarBufferLength(bindings.Count));
PackRuntimeScalarStateInto(bytes, registers, bindings);
return bytes;
@@ -7603,7 +7346,7 @@ public static partial class AgcExports
{
var byteBias = checked((uint)(
bindings[index].BaseAddress &
(_storageBufferOffsetAlignment - 1)));
(VulkanVideoPresenter.GuestStorageBufferOffsetAlignment - 1)));
BinaryPrimitives.WriteUInt32LittleEndian(
bytes.AsSpan(biasOffset + index * sizeof(uint), sizeof(uint)),
byteBias);
@@ -7646,7 +7389,7 @@ public static partial class AgcExports
{
if (binding.DataPooled && returned.Add(binding.Data))
{
GuestDataPool.Shared.Return(binding.Data);
VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
}
}
@@ -7656,7 +7399,7 @@ public static partial class AgcExports
{
if (binding.DataPooled && returned.Add(binding.Data))
{
GuestDataPool.Shared.Return(binding.Data);
VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
}
}
}
@@ -7682,7 +7425,7 @@ public static partial class AgcExports
{
if (binding.DataPooled && returned.Add(binding.Data))
{
GuestDataPool.Shared.Return(binding.Data);
VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
}
}
}
@@ -7693,7 +7436,7 @@ public static partial class AgcExports
{
if (binding.DataPooled && returned.Add(binding.Data))
{
GuestDataPool.Shared.Return(binding.Data);
VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
}
}
}
@@ -7701,7 +7444,7 @@ public static partial class AgcExports
if (index && draw.IndexBuffer is { Pooled: true } indexBuffer &&
returned.Add(indexBuffer.Data))
{
GuestDataPool.Shared.Return(indexBuffer.Data);
VulkanVideoPresenter.GuestDataPool.Return(indexBuffer.Data);
}
}
@@ -7958,7 +7701,7 @@ public static partial class AgcExports
if (!isStorage &&
descriptor.Address != 0 &&
GuestGpu.Current.IsGpuGuestImageAvailable(
VulkanVideoPresenter.IsGuestImageAvailable(
descriptor.Address,
descriptor.Format,
descriptor.NumberType))
@@ -7987,7 +7730,7 @@ public static partial class AgcExports
{
var initialPixels = Array.Empty<byte>();
var uploadKnown = descriptor.Address != 0 &&
GuestGpu.Current.IsGuestImageUploadKnown(
VulkanVideoPresenter.IsGuestImageUploadKnown(
descriptor.Address,
descriptor.Format,
descriptor.NumberType);
@@ -8068,8 +7811,8 @@ public static partial class AgcExports
if (!_textureCopySkipDisabled &&
descriptor.Address != 0 &&
!SharpEmu.HLE.GuestImageWriteTracker.PeekDirty(descriptor.Address) &&
GuestGpu.Current.IsTextureContentCached(
new TextureContentIdentity(
VulkanVideoPresenter.IsTextureContentCached(
new VulkanVideoPresenter.TextureContentIdentity(
descriptor.Address,
descriptor.Width,
descriptor.Height,
@@ -8172,7 +7915,7 @@ public static partial class AgcExports
CpuContext ctx,
RenderTargetDescriptor target)
{
if (!GuestGpu.Current.GuestImageWantsInitialData(target.Address))
if (!VulkanVideoPresenter.GuestImageWantsInitialData(target.Address))
{
return;
}
@@ -8198,7 +7941,7 @@ public static partial class AgcExports
if (nonZero)
{
GuestGpu.Current.ProvideGuestImageInitialData(target.Address, initialData);
VulkanVideoPresenter.ProvideGuestImageInitialData(target.Address, initialData);
}
}
@@ -8495,14 +8238,9 @@ public static partial class AgcExports
ulong packetAddress,
uint packetLength,
uint opcode,
out ComputeDispatch dispatch,
out ulong indirectDimsRetryAddress)
out ComputeDispatch dispatch)
{
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;
@@ -8551,17 +8289,6 @@ 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,
@@ -8896,7 +8623,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 (!GuestGpu.Current.WaitForGuestWork(semanticCopySequence))
if (!VulkanVideoPresenter.WaitForGuestWork(semanticCopySequence))
{
computeError =
$"semantic-global-write-sync-timeout sequence={semanticCopySequence}";
@@ -8914,7 +8641,7 @@ public static partial class AgcExports
localSizeY,
localSizeZ,
dispatch.WaveLaneCount,
_storageBufferOffsetAlignment);
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment);
var guestGlobalBufferCount = evaluation.GlobalMemoryBindings.Count;
var totalGlobalBufferCount = _bakeScalars
? guestGlobalBufferCount
@@ -8936,7 +8663,7 @@ public static partial class AgcExports
: guestGlobalBufferCount,
waveLaneCount: dispatch.WaveLaneCount,
storageBufferOffsetAlignment:
_storageBufferOffsetAlignment))
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
{
DumpCompiledShader(
"cs",
@@ -8956,7 +8683,7 @@ public static partial class AgcExports
out _);
var globalMemoryBuffers =
CreateTranslatedComputeGlobalBuffers(evaluation);
GuestGpu.Current.SubmitComputeDispatch(
var workSequence = GuestGpu.Current.SubmitComputeDispatch(
shaderAddress,
computeShader,
textures,
@@ -8975,9 +8702,12 @@ 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}";
}
}
}
@@ -9168,7 +8898,7 @@ public static partial class AgcExports
}
var destinationAddress = destination.BaseAddress;
workSequence = GuestGpu.Current.SubmitOrderedGuestAction(
workSequence = VulkanVideoPresenter.SubmitOrderedGuestAction(
() =>
{
if (!ctx.Memory.TryWrite(destinationAddress, output))
@@ -9182,7 +8912,7 @@ public static partial class AgcExports
GuestImageWriteTracker.Track(
destinationAddress,
(ulong)output.Length,
GuestGpu.Current.CurrentGuestWorkSequenceForDiagnostics,
VulkanVideoPresenter.CurrentGuestWorkSequenceForDiagnostics,
"agc.masked-dword-copy");
},
$"masked_dword_copy dst=0x{destinationAddress:X16} bytes={output.Length}");
@@ -9871,7 +9601,7 @@ public static partial class AgcExports
pixelInputEnable: psInputEna,
pixelInputAddress: psInputAddr,
storageBufferOffsetAlignment:
_storageBufferOffsetAlignment))
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment))
{
TraceAgcShader(
$"agc.shader_spirv ps=0x{pixelShaderAddress:X16} " +
@@ -11495,35 +11225,4 @@ 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,7 +3,6 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using SharpEmu.Libs.Gpu;
using SharpEmu.Libs.Kernel;
using SharpEmu.Libs.VideoOut;
using SharpEmu.ShaderCompiler;
@@ -27,8 +26,8 @@ internal static class AgcShaderCompilerHooks
internal static void Install()
{
Gen5ShaderScalarEvaluator.FallbackMemoryReader =
KernelMemoryCompatExports.TryReadShaderGuestMemory;
KernelMemoryCompatExports.TryReadTrackedLibcHeap;
Gen5ShaderScalarEvaluator.GlobalMemoryPool =
GuestDataPool.Shared;
VulkanVideoPresenter.GuestDataPool;
}
}
+2 -181
View File
@@ -37,26 +37,10 @@ internal static class GpuWaitRegistry
public long RegisteredTicks;
public bool StaleReported;
public object? State;
// Latched by LatchSatisfiedByValue when a producer wrote a value that
// satisfies this waiter. The label is frequently reused (reset to 0 for
// the next frame) immediately after the producing write, so re-reading
// guest memory at wake time can miss the transient satisfied window.
// Latching records satisfaction at the moment of the write instead.
public bool Latched;
// Non-zero for indirect-dispatch dimension retries: a bounded deadline
// (Stopwatch ticks) after which the waiter is resumed even if unsatisfied,
// so a legitimately empty indirect dispatch can never stall forever.
public long RetryDeadlineTicks;
}
private static readonly object _gate = new();
private static readonly Dictionary<ulong, List<WaitingDcb>> _waiters = new();
// The last value each label producer wrote. Used only by the deadlock
// breaker: our serial submission parser cannot model two GPU queues running
// concurrently, so a label written -> reset -> re-waited across queues can
// cycle forever even though a real producer did signal it. Keyed by (memory,
// address) so distinct guest processes never alias.
private static readonly Dictionary<(object, ulong), ulong> _lastProduced = new();
public static int Count
{
@@ -130,14 +114,8 @@ internal static class GpuWaitRegistry
continue;
}
var satisfied = list[i].Latched;
if (!satisfied)
{
var value = readValue(address, list[i].Is64Bit);
satisfied = value is not null && Compare(list[i], value.Value);
}
if (!satisfied)
var value = readValue(address, list[i].Is64Bit);
if (value is null || !Compare(list[i], value.Value))
{
continue;
}
@@ -258,162 +236,6 @@ internal static class GpuWaitRegistry
return matches;
}
/// <summary>
/// Records satisfaction for every waiter at <paramref name="address"/> whose
/// condition is met by <paramref name="value"/> — the value a producer just
/// wrote to that label. Called from the ordered producer side effect so a
/// same-frame label reset cannot lose the wakeup. The waiters stay registered
/// (latched) and are drained by the next CollectSatisfied. Returns true when
/// at least one waiter latched, so the caller can trigger a wake pass.
/// </summary>
public static bool LatchSatisfiedByValue(object memory, ulong address, ulong value)
{
var latchedAny = false;
lock (_gate)
{
if (!_waiters.TryGetValue(address, out var list))
{
return false;
}
for (var i = 0; i < list.Count; i++)
{
var waiter = list[i];
if (waiter.Latched ||
!ReferenceEquals(waiter.Memory, memory) ||
!Compare(waiter, value))
{
continue;
}
waiter.Latched = true;
list[i] = waiter;
latchedAny = true;
}
}
return latchedAny;
}
/// <summary>
/// Removes and returns waiters carrying a <see cref="WaitingDcb.RetryDeadlineTicks"/>
/// that has elapsed. Used for indirect-dispatch dimension retries: the caller
/// resumes them so a genuinely empty dispatch (dims that never become non-zero)
/// is dropped after a bounded wait instead of stalling the queue forever.
/// </summary>
public static List<WaitingDcb>? CollectExpiredRetries(object memory, long nowTicks)
{
List<WaitingDcb>? expired = null;
lock (_gate)
{
List<ulong>? emptied = null;
foreach (var (address, list) in _waiters)
{
for (var i = list.Count - 1; i >= 0; i--)
{
var waiter = list[i];
if (waiter.RetryDeadlineTicks == 0 ||
!ReferenceEquals(waiter.Memory, memory) ||
nowTicks < waiter.RetryDeadlineTicks)
{
continue;
}
expired ??= new List<WaitingDcb>();
expired.Add(waiter);
list.RemoveAt(i);
}
if (list.Count == 0)
{
emptied ??= new List<ulong>();
emptied.Add(address);
}
}
if (emptied is not null)
{
foreach (var address in emptied)
{
_waiters.Remove(address);
}
}
}
return expired;
}
/// <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;
@@ -438,7 +260,6 @@ internal static class GpuWaitRegistry
lock (_gate)
{
_waiters.Clear();
_lastProduced.Clear();
}
}
}
+17 -10
View File
@@ -6,7 +6,6 @@ using SharpEmu.Libs.Kernel;
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Concurrent;
using Microsoft.Win32.SafeHandles;
namespace SharpEmu.Libs.Ampr;
@@ -44,17 +43,17 @@ public static class AmprExports
{
public CachedHostFile(string path)
{
Handle = File.OpenHandle(
Stream = new FileStream(
path,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite | FileShare.Delete,
bufferSize: 1024 * 1024,
FileOptions.RandomAccess);
Length = RandomAccess.GetLength(Handle);
}
public SafeFileHandle Handle { get; }
public long Length { get; }
public object Gate { get; } = new();
public FileStream Stream { get; }
}
[SysAbiExport(
@@ -736,7 +735,13 @@ public static class AmprExports
return openResult;
}
if (fileOffset >= (ulong)cachedFile.Length)
long fileLength;
lock (cachedFile.Gate)
{
fileLength = cachedFile.Stream.Length;
}
if (fileOffset >= (ulong)fileLength)
{
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -755,10 +760,12 @@ public static class AmprExports
}
var request = (int)Math.Min((ulong)buffer.Length, size - bytesRead);
var read = RandomAccess.Read(
cachedFile.Handle,
buffer.AsSpan(0, request),
unchecked((long)absoluteOffset));
int read;
lock (cachedFile.Gate)
{
cachedFile.Stream.Position = unchecked((long)absoluteOffset);
read = cachedFile.Stream.Read(buffer, 0, request);
}
if (read <= 0)
{
@@ -121,33 +121,6 @@ public static class AppContentExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
// Download data is not emulated as a real quota; report a comfortable
// fixed amount of free space so titles never take the "storage full" path.
[SysAbiExport(
Nid = "Gl6w5i0JokY",
ExportName = "sceAppContentDownloadDataGetAvailableSpaceKb",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAppContent")]
public static int AppContentDownloadDataGetAvailableSpaceKb(CpuContext ctx)
{
const ulong availableSpaceKb = 1024UL * 1024UL; // 1 GiB
var availableSpaceAddress = ctx[CpuRegister.Rsi];
if (availableSpaceAddress == 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
Span<byte> spaceBytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(spaceBytes, availableSpaceKb);
if (!ctx.Memory.TryWrite(availableSpaceAddress, spaceBytes))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
private static bool TryReadUserDefinedParam(uint paramId, out int value)
{
value = 0;
+1 -88
View File
@@ -14,12 +14,6 @@ public static class AudioOutExports
private static readonly ConcurrentDictionary<int, PortState> Ports = new();
private static int _nextPortHandle;
// Diagnostic: confirm sceAudioOutOutput is actually called and whether the
// guest submits real samples or silence. Gated so it costs nothing when off.
private static readonly bool _traceOutput = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_AUDIO_OUT"), "1", StringComparison.Ordinal);
private static long _outputCount;
private sealed class PortState : IDisposable
{
private readonly object _paceGate = new();
@@ -161,37 +155,6 @@ public static class AudioOutExports
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "GrQ9s4IrNaQ",
ExportName = "sceAudioOutGetPortState",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAudioOut")]
public static int AudioOutGetPortState(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var stateAddress = ctx[CpuRegister.Rsi];
if (stateAddress == 0 || !Ports.TryGetValue(handle, out var port))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
// SceAudioOutPortState: report a connected primary output at full volume
// so pacing/mixing code sees a live port. We do no host rerouting, so
// rerouteCounter and flag stay zero.
Span<byte> state = stackalloc byte[16];
state.Clear();
System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(state, 1);
System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(
state[2..], (ushort)port.Channels);
state[7] = 127;
if (!ctx.Memory.TryWrite(stateAddress, state))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "QOQtbeDqsT4",
ExportName = "sceAudioOutOutput",
@@ -203,12 +166,7 @@ public static class AudioOutExports
var sourceAddress = ctx[CpuRegister.Rsi];
if (!Ports.TryGetValue(handle, out var port))
{
// Host shutdown disposes the ports while guest audio threads are
// still draining their last buffers; report success so the guest
// winds down without a per-buffer error (and its WARN log flood).
return ctx.SetReturn(_shutdown
? 0
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
if (sourceAddress == 0)
@@ -225,17 +183,6 @@ public static class AudioOutExports
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
if (_traceOutput)
{
var n = Interlocked.Increment(ref _outputCount);
if (n <= 8 || n % 200 == 0)
{
var peak = PeakAmplitude(source, port.IsFloat, port.BytesPerSample);
Console.Error.WriteLine(
$"[LOADER][TRACE] audioout.output#{n} handle={handle} bytes={source.Length} ch={port.Channels} float={port.IsFloat} vol={port.Volume:F2} peak={peak:F4} backend={(port.Backend is null ? "none" : "coreaudio")}");
}
}
if (port.Backend is null)
{
port.PaceSilence();
@@ -319,40 +266,8 @@ public static class AudioOutExports
return ctx.SetReturn(0);
}
// Peak normalized amplitude [0,1] of an interleaved PCM buffer, used only by
// the SHARPEMU_LOG_AUDIO_OUT diagnostic to distinguish real audio from silence.
private static float PeakAmplitude(ReadOnlySpan<byte> source, bool isFloat, int bytesPerSample)
{
var peak = 0f;
if (isFloat && bytesPerSample == 4)
{
for (var i = 0; i + 4 <= source.Length; i += 4)
{
var v = Math.Abs(System.Buffers.Binary.BinaryPrimitives.ReadSingleLittleEndian(source.Slice(i, 4)));
if (v > peak)
{
peak = v;
}
}
}
else if (bytesPerSample == 2)
{
for (var i = 0; i + 2 <= source.Length; i += 2)
{
var v = Math.Abs(System.Buffers.Binary.BinaryPrimitives.ReadInt16LittleEndian(source.Slice(i, 2)) / 32768f);
if (v > peak)
{
peak = v;
}
}
}
return peak;
}
public static void ShutdownAllPorts()
{
Volatile.Write(ref _shutdown, true);
foreach (var handle in Ports.Keys)
{
if (Ports.TryRemove(handle, out var port))
@@ -362,8 +277,6 @@ public static class AudioOutExports
}
}
private static bool _shutdown;
private static bool TryGetFormat(
int rawFormat,
out int channels,
@@ -1,154 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
namespace SharpEmu.Libs.Audio;
// PS5 acoustic-propagation (3D-audio ray/portal/room) module. We do not model
// acoustic propagation; the geometry-driven reverb/occlusion it produces is a
// quality feature, not a correctness gate. Games (e.g. Astro Bot) call it
// during audio init and hard-assert if any entry point is missing:
// ASSERT ... sceAudioPropagationSystemQueryMemory failed : 0x80020002
// The API is placement-style: QueryMemory reports a buffer size, the game
// allocates it, and the "system"/objects live inside that caller-owned buffer,
// so success-returning stubs let init proceed without us owning any state.
public static class AudioPropagationExports
{
private const int Ok = 0;
// QueryMemory reports the working-set size the caller must allocate before
// SystemCreate. rsi points at the out size/alignment; write a modest,
// aligned block so the caller's allocation succeeds.
[SysAbiExport(
Nid = "7xyAxrusLko",
ExportName = "sceAudioPropagationSystemQueryMemory",
Target = Generation.Gen5,
LibraryName = "libSceAudioPropagation")]
public static int SystemQueryMemory(CpuContext ctx)
{
var outAddress = ctx[CpuRegister.Rsi];
if (outAddress != 0)
{
// {size, alignment} — 1 MiB / 256 B covers the caller's allocation.
ctx.TryWriteUInt64(outAddress, 0x10_0000);
ctx.TryWriteUInt64(outAddress + sizeof(ulong), 0x100);
}
return ctx.SetReturn(Ok);
}
[SysAbiExport(Nid = "GrA9ke1QT+E", ExportName = "sceAudioPropagationSystemQueryInfo", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemQueryInfo(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "aNEqtSHdUSo", ExportName = "sceAudioPropagationSystemCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemCreate(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "x5VPqg5iyAk", ExportName = "sceAudioPropagationSystemDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemDestroy(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "ile38Gl-p5M", ExportName = "sceAudioPropagationSystem", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int System(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "cMl3u+7QBBM", ExportName = "sceAudioPropagationSystemMemoryInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemMemoryInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "3B9IabLByyM", ExportName = "sceAudioPropagationSystemOptionInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemOptionInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "B2KI2AachWE", ExportName = "sceAudioPropagationSystemLock", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemLock(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "kIdb+iQUzCs", ExportName = "sceAudioPropagationSystemSetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemSetAttributes(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "VlBT16890mA", ExportName = "sceAudioPropagationSystemSetRays", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemSetRays(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "ht-QXT3zGxo", ExportName = "sceAudioPropagationSystemGetRays", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemGetRays(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "CPLV6G-eXmk", ExportName = "sceAudioPropagationSystemRegisterMaterial", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemRegisterMaterial(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "XKCN4gpeYsM", ExportName = "sceAudioPropagationSystemUnregisterMaterial", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SystemUnregisterMaterial(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "8bI5h8req30", ExportName = "sceAudioPropagationRoomCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int RoomCreate(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "S0JwP2AFTTE", ExportName = "sceAudioPropagationRoomDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int RoomDestroy(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "b-dYXrjSNZU", ExportName = "sceAudioPropagationPortalCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int PortalCreate(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "ZQXE-xS6MTE", ExportName = "sceAudioPropagationPortalDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int PortalDestroy(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "WXMhENV2NcA", ExportName = "sceAudioPropagationPortalSetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int PortalSetAttributes(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "i687TNRF+hw", ExportName = "sceAudioPropagationPortalSettingsInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int PortalSettingsInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "d84otraxt2s", ExportName = "sceAudioPropagationSourceCreate", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceCreate(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "wkseM3LWPuc", ExportName = "sceAudioPropagationSourceDestroy", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceDestroy(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "-wsUTr31yeg", ExportName = "sceAudioPropagationSourceSetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceSetAttributes(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "PBcrVpEqUVY", ExportName = "sceAudioPropagationSourceCalculateAudioPaths", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceCalculateAudioPaths(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "eEeKqFeNI3o", ExportName = "sceAudioPropagationSourceGetAudioPath", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceGetAudioPath(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "G+QLTfyLMYk", ExportName = "sceAudioPropagationSourceGetAudioPathCount", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceGetAudioPathCount(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "aKJZx7wCma8", ExportName = "sceAudioPropagationSourceGetRays", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceGetRays(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "3aEY9tPXGKc", ExportName = "sceAudioPropagationSourceQueryInfo", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceQueryInfo(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "hhz9pITnC8k", ExportName = "sceAudioPropagationSourceRender", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceRender(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "SoKPzY1-3SU", ExportName = "sceAudioPropagationSourceRenderInfoInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceRenderInfoInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "tKSmk2JsMAA", ExportName = "sceAudioPropagationSourceSetAudioPath", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceSetAudioPath(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "5vzOS2pHMFc", ExportName = "sceAudioPropagationSourceSetAudioPaths", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceSetAudioPaths(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "MNmGapXrYRs", ExportName = "sceAudioPropagationSourceSetAudioPathsParamInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int SourceSetAudioPathsParamInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "i-0aUex3zCE", ExportName = "sceAudioPropagationAudioPathInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int AudioPathInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "JZIkSbmt2BE", ExportName = "sceAudioPropagationAudioPathPointInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int AudioPathPointInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "tL2AEPejVQE", ExportName = "sceAudioPropagationPathGetNumPoints", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int PathGetNumPoints(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "2BSFmuKtRss", ExportName = "sceAudioPropagationMaterialInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int MaterialInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "0r2+9UTg1BA", ExportName = "sceAudioPropagationRayInit", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int RayInit(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "BbOT4vBwAjs", ExportName = "sceAudioPropagationResetAttributes", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int ResetAttributes(CpuContext ctx) => ctx.SetReturn(Ok);
[SysAbiExport(Nid = "gCmQm6dvMxw", ExportName = "sceAudioPropagationReportApi", Target = Generation.Gen5, LibraryName = "libSceAudioPropagation")]
public static int ReportApi(CpuContext ctx) => ctx.SetReturn(Ok);
}
+9 -14
View File
@@ -29,9 +29,10 @@ internal static class Bink2MovieBridge
private static bool _availabilityReported;
/// <summary>
/// Returns true only when movie skipping was explicitly requested. Without
/// a host adapter the guest must be allowed to run the Bink implementation
/// statically linked into its executable.
/// Returns true when the guest should receive a normal "file not found"
/// result for a Bink movie. This is the safe default without a decoder:
/// games that treat movies as optional fall through to their next state
/// rather than submitting an empty Bink GPU texture forever.
/// </summary>
internal static bool ShouldSkipGuestMovie(string hostPath) =>
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
@@ -52,18 +53,12 @@ internal static class Bink2MovieBridge
return;
}
var mode = ResolveMode();
if (mode == MovieMode.Dummy)
if (ResolveMode() == MovieMode.Dummy)
{
AttachDummyMovieLocked(hostPath);
return;
}
if (mode != MovieMode.Native)
{
return;
}
var adapter = GetAdapterLocked();
if (adapter is null)
{
@@ -170,15 +165,16 @@ internal static class Bink2MovieBridge
return MovieMode.Skip;
}
// Prefer the optional host adapter when one is supplied. Otherwise let
// the game's statically linked Bink implementation consume the file.
// With no SDK adapter present, returning "not found" makes optional
// cinematics advance. Supplying either an explicit path or the normal
// side-by-side adapter enables native playback automatically.
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE")) ||
EnumerateAdapterCandidates().Any(File.Exists))
{
return MovieMode.Native;
}
return MovieMode.Guest;
return MovieMode.Skip;
}
private static void AttachDummyMovieLocked(string hostPath)
@@ -339,7 +335,6 @@ internal static class Bink2MovieBridge
private enum MovieMode
{
Guest,
Skip,
Dummy,
Native,
-140
View File
@@ -1,140 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers;
using System.Numerics;
namespace SharpEmu.Libs.Gpu;
/// <summary>
/// The pool backing AGC-to-presenter ownership transfers, shared by every backend
/// (the AGC layer rents, the presenter returns, so both sides must use one pool).
/// Guest draw snapshots churn through a small set of 128 KiB-16 MiB size classes
/// thousands of times per second; the process-wide shared pool trims and
/// repartitions those large arrays aggressively under GC load, causing hundreds of
/// MiB/s of replacement byte[] allocations, so this pool is bounded and non-shared.
/// </summary>
internal static class GuestDataPool
{
public static ArrayPool<byte> Shared { get; } = new BoundedByteArrayPool(
maxArrayLength: 16 * 1024 * 1024,
maxCachedBytes: 256UL * 1024 * 1024,
maxArraysPerBucket: 8);
public static void Trim() => ((BoundedByteArrayPool)Shared).Trim();
private sealed class BoundedByteArrayPool : ArrayPool<byte>
{
private readonly object _gate = new();
private readonly int _maxArrayLength;
private readonly ulong _maxCachedBytes;
private readonly int _maxArraysPerBucket;
private readonly Dictionary<int, Stack<byte[]>> _cachedByBucket = [];
private readonly HashSet<byte[]> _leases =
new(System.Collections.Generic.ReferenceEqualityComparer.Instance);
private ulong _cachedBytes;
public BoundedByteArrayPool(
int maxArrayLength,
ulong maxCachedBytes,
int maxArraysPerBucket)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArrayLength);
ArgumentOutOfRangeException.ThrowIfZero(maxCachedBytes);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArraysPerBucket);
_maxArrayLength = maxArrayLength;
_maxCachedBytes = maxCachedBytes;
_maxArraysPerBucket = maxArraysPerBucket;
}
public override byte[] Rent(int minimumLength)
{
ArgumentOutOfRangeException.ThrowIfNegative(minimumLength);
var length = GetAllocationLength(minimumLength);
byte[]? array = null;
lock (_gate)
{
if (length <= _maxArrayLength &&
_cachedByBucket.TryGetValue(length, out var bucket) &&
bucket.TryPop(out array))
{
_cachedBytes -= (ulong)array.LongLength;
}
array ??= new byte[length];
_leases.Add(array);
}
return array;
}
public override void Return(byte[] array, bool clearArray = false)
{
ArgumentNullException.ThrowIfNull(array);
lock (_gate)
{
if (!_leases.Remove(array))
{
return;
}
}
if (clearArray)
{
Array.Clear(array);
}
lock (_gate)
{
if (array.Length > _maxArrayLength ||
!IsBucketLength(array.Length) ||
(ulong)array.LongLength > _maxCachedBytes -
Math.Min(_cachedBytes, _maxCachedBytes))
{
return;
}
if (!_cachedByBucket.TryGetValue(array.Length, out var bucket))
{
bucket = new Stack<byte[]>();
_cachedByBucket.Add(array.Length, bucket);
}
if (bucket.Count >= _maxArraysPerBucket)
{
return;
}
bucket.Push(array);
_cachedBytes += (ulong)array.LongLength;
}
}
public void Trim()
{
lock (_gate)
{
_cachedByBucket.Clear();
_cachedBytes = 0;
}
}
private int GetAllocationLength(int minimumLength)
{
if (minimumLength <= 16)
{
return 16;
}
if (minimumLength > _maxArrayLength)
{
return minimumLength;
}
return checked((int)BitOperations.RoundUpToPowerOf2((uint)minimumLength));
}
private static bool IsBucketLength(int length) =>
length >= 16 && (length & (length - 1)) == 0;
}
}
+2 -31
View File
@@ -1,7 +1,6 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Gpu.Metal;
using SharpEmu.Libs.Gpu.Vulkan;
namespace SharpEmu.Libs.Gpu;
@@ -9,39 +8,11 @@ namespace SharpEmu.Libs.Gpu;
/// <summary>
/// Process-wide access point for the guest-GPU backend, mirroring HostPlatform for the
/// host seam: static HLE export classes resolve the renderer through <see cref="Current"/>.
/// Vulkan is the default everywhere; SHARPEMU_GPU_BACKEND=metal opts into the Metal
/// backend (macOS only) while it is being brought up. macOS flips to Metal by default
/// once the presenter reaches parity.
/// Vulkan is the only backend today; Metal/DX12 slot in here.
/// </summary>
internal static class GuestGpu
{
private static readonly Lazy<IGuestGpuBackend> Instance = new(Create);
private static readonly Lazy<IGuestGpuBackend> Instance = new(static () => new VulkanGuestGpuBackend());
public static IGuestGpuBackend Current => Instance.Value;
private static IGuestGpuBackend Create()
{
var requested = Environment.GetEnvironmentVariable("SHARPEMU_GPU_BACKEND");
if (string.IsNullOrEmpty(requested) || requested.Equals("vulkan", StringComparison.OrdinalIgnoreCase))
{
return new VulkanGuestGpuBackend();
}
if (requested.Equals("metal", StringComparison.OrdinalIgnoreCase))
{
if (!OperatingSystem.IsMacOS())
{
Console.Error.WriteLine(
"[LOADER][WARN] SHARPEMU_GPU_BACKEND=metal is only available on macOS; using Vulkan.");
return new VulkanGuestGpuBackend();
}
Console.Error.WriteLine("[LOADER][INFO] GPU backend: Metal (SHARPEMU_GPU_BACKEND).");
return new MetalGuestGpuBackend();
}
Console.Error.WriteLine(
$"[LOADER][WARN] Unknown SHARPEMU_GPU_BACKEND value '{requested}'; using Vulkan.");
return new VulkanGuestGpuBackend();
}
}
+1 -25
View File
@@ -36,20 +36,6 @@ internal readonly record struct GuestSampler(
uint Word2,
uint Word3);
/// <summary>Identity of a texture's content in a backend texture cache, keyed
/// entirely on raw guest descriptor values; the AGC layer uses it to skip texel
/// copies for content the backend already holds.</summary>
internal readonly record struct TextureContentIdentity(
ulong Address,
uint Width,
uint Height,
uint Format,
uint NumberType,
uint DstSelect,
uint TileMode,
uint Pitch,
GuestSampler Sampler);
internal sealed record GuestMemoryBuffer(
ulong BaseAddress,
byte[] Data,
@@ -136,22 +122,12 @@ internal readonly record struct GuestBlendState(
WriteMask: 0xFu);
}
/// <summary>CB_BLEND_RED..ALPHA: the constant color referenced by the
/// CONSTANT_COLOR / CONSTANT_ALPHA blend factors. One constant serves every
/// render target of a draw; the hardware reset value is transparent black.</summary>
internal readonly record struct GuestBlendConstant(
float Red,
float Green,
float Blue,
float Alpha);
internal sealed record GuestRenderState(
IReadOnlyList<GuestBlendState> Blends,
GuestRect? Scissor,
GuestViewport? Viewport,
GuestRasterState Raster,
GuestDepthState Depth,
GuestBlendConstant BlendConstant = default)
GuestDepthState Depth)
{
public static GuestRenderState Default { get; } = new(
[GuestBlendState.Default],
-71
View File
@@ -1,7 +1,6 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.ShaderCompiler;
namespace SharpEmu.Libs.Gpu;
@@ -18,10 +17,6 @@ namespace SharpEmu.Libs.Gpu;
/// </summary>
internal interface IGuestGpuBackend
{
/// <summary>Human-readable name of this backend ("Metal", "Vulkan"), shown in
/// the window title on macOS where either backend can run.</summary>
string BackendName { get; }
/// <summary>Starts the presenter (window + device) once; safe to call repeatedly.</summary>
void EnsureStarted(uint width, uint height);
@@ -193,70 +188,4 @@ internal interface IGuestGpuBackend
/// the guest codes cross the seam and each backend maps them internally.
/// </summary>
bool TryGetRenderTargetOutputKind(uint dataFormat, uint numberType, out Gen5PixelOutputKind outputKind);
// Guest work ordering. AGC submissions execute on a single backend consumer in
// logical guest-queue order; sequences returned here are backend work tickets.
// A backend without a running presenter returns 0 from the Submit* methods and
// callers fall back to executing inline.
/// <summary>Scopes subsequent submissions on this thread to a named guest queue.</summary>
IDisposable EnterGuestQueue(string queueName, ulong submissionId);
/// <summary>Enqueues an action at its exact position in the current guest queue;
/// returns its work sequence, or 0 when nothing could be enqueued.</summary>
long SubmitOrderedGuestAction(Action action, string debugName);
/// <summary>Preserves sceAgcDcbWaitUntilSafeForRendering in queue order.</summary>
long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex);
/// <summary>Blocks until the given work sequence completes; false on timeout,
/// close, or a non-positive sequence.</summary>
bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite);
/// <summary>Sequence currently executing on the guest-work consumer; diagnostics only.</summary>
long CurrentGuestWorkSequenceForDiagnostics { get; }
// Guest image lifecycle beyond presentation: CPU-visible seeding, writes, and
// extent queries the AGC layer uses to keep guest memory and backend images
// coherent. Addresses and formats are always raw guest values.
/// <summary>Whether the image exists on the backend or an already-queued upload
/// owns its initialization (a pending image may skip a duplicate upload but is
/// not yet a valid flip source).</summary>
bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType);
/// <summary>True when the first draw into this address must seed the backend
/// image from guest memory (PS5 render targets alias guest memory, so
/// CPU-prefilled pixels are visible before the first draw).</summary>
bool GuestImageWantsInitialData(ulong address);
void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels);
void SubmitGuestImageFill(ulong address, uint fillValue);
void SubmitGuestImageWrite(ulong address, byte[] pixels);
bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount);
IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents();
/// <summary>Whether the backend's texture cache already holds this content; lets
/// the AGC layer skip copying texels out of guest memory on every draw.</summary>
bool IsTextureContentCached(in TextureContentIdentity identity);
/// <summary>Guest memory handle for backend self-healing (cache misses re-read
/// texels directly instead of showing a fallback pattern).</summary>
void AttachGuestMemory(ICpuMemory memory);
/// <summary>Alignment the AGC layer must apply to storage-buffer offsets before
/// they cross the seam.</summary>
ulong GuestStorageBufferOffsetAlignment { get; }
/// <summary>Counts a guest shader translation for the perf overlay.</summary>
void CountShaderCompilation();
(long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters();
/// <summary>Asks a running presenter to close its window.</summary>
void RequestClose();
}
@@ -1,28 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Text;
using SharpEmu.ShaderCompiler.Metal;
namespace SharpEmu.Libs.Gpu.Metal;
/// <summary>
/// The Metal backend's compiled shader: MSL source plus the reflection data
/// (<see cref="Gen5MslShader"/>) the presenter needs to create and bind pipeline
/// states. The diagnostics payload is the source text — Metal has no portable
/// binary form until an MTLBinaryArchive is introduced.
/// </summary>
internal sealed class MetalCompiledGuestShader(Gen5MslShader shader) : IGuestCompiledShader
{
private byte[]? _payload;
public Gen5MslShader Shader { get; } = shader;
/// <summary>MTLLibrary handle cached by the presenter after the first
/// runtime compile; the render loop is its only reader and writer.</summary>
internal nint CachedLibrary;
public byte[] Payload => _payload ??= Encoding.UTF8.GetBytes(Shader.Source);
public string PayloadFileExtension => "msl";
}
@@ -1,270 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.ShaderCompiler;
namespace SharpEmu.Libs.Gpu.Metal;
/// <summary>
/// MTLPixelFormat raw values — only the formats the backend maps. Declared here
/// rather than pulled from a binding package: the Metal backend talks to the OS
/// exclusively through objc_msgSend, so ABI constants are owned locally.
/// </summary>
internal enum MtlPixelFormat : uint
{
Invalid = 0,
R8Unorm = 10,
R8Snorm = 12,
R8Uint = 13,
R8Sint = 14,
R16Unorm = 20,
R16Snorm = 22,
R16Uint = 23,
R16Sint = 24,
R16Float = 25,
Rg8Unorm = 30,
Rg8Snorm = 32,
Rg8Uint = 33,
Rg8Sint = 34,
B5G6R5Unorm = 40,
R32Uint = 53,
R32Sint = 54,
R32Float = 55,
Rg16Unorm = 60,
Rg16Uint = 63,
Rg16Sint = 64,
Rg16Float = 65,
Rgba8Unorm = 70,
Rgba8UnormSrgb = 71,
Rgba8Uint = 73,
Rgba8Sint = 74,
Bgra8Unorm = 80,
Bgra8UnormSrgb = 81,
Rgb10A2Unorm = 90,
Rg11B10Float = 92,
Rgb9E5Float = 93,
Bgr10A2Unorm = 94,
Rg32Uint = 103,
Rg32Sint = 104,
Rg32Float = 105,
Rgba16Unorm = 110,
Rgba16Uint = 113,
Rgba16Sint = 114,
Rgba16Float = 115,
Rgba32Uint = 123,
Rgba32Sint = 124,
Rgba32Float = 125,
Bc1Rgba = 130,
Bc1RgbaSrgb = 131,
Bc2Rgba = 132,
Bc2RgbaSrgb = 133,
Bc3Rgba = 134,
Bc3RgbaSrgb = 135,
Bc4RUnorm = 140,
Bc4RSnorm = 141,
Bc5RgUnorm = 142,
Bc5RgSnorm = 143,
Bc6HRgbFloat = 150,
Bc6HRgbUfloat = 151,
Bc7RgbaUnorm = 152,
Bc7RgbaUnormSrgb = 153,
Depth32Float = 252,
}
/// <summary>A sampled-texture format: the Metal pixel format plus the byte
/// layout the upload path needs. <see cref="BlockBytes"/> is nonzero for
/// block-compressed formats (bytes per 4x4 block); otherwise
/// <see cref="BytesPerPixel"/> applies.</summary>
internal readonly record struct MetalTextureFormat(
MtlPixelFormat Format,
uint BytesPerPixel,
uint BlockBytes)
{
public bool IsBlockCompressed => BlockBytes != 0;
}
internal readonly record struct MetalRenderTargetFormat(
MtlPixelFormat Format,
Gen5PixelOutputKind OutputKind)
{
public static uint GetBytesPerPixel(MtlPixelFormat format) =>
format switch
{
MtlPixelFormat.R8Unorm or MtlPixelFormat.R8Uint => 1,
MtlPixelFormat.Rg8Unorm => 2,
MtlPixelFormat.Rg32Float => 8,
MtlPixelFormat.Rgba16Unorm or MtlPixelFormat.Rgba16Uint or
MtlPixelFormat.Rgba16Sint or MtlPixelFormat.Rgba16Float => 8,
MtlPixelFormat.Rgba32Float => 16,
_ => 4,
};
}
/// <summary>
/// Guest texture-descriptor codes to Metal formats, mirroring the Vulkan
/// backend's table case for case so both backends accept the same guest
/// formats. Guest format 9 (2:10:10:10) maps to BGR10A2 — the bit layout that
/// matches Vulkan's A2R10G10B10 pack.
/// </summary>
internal static class MetalGuestFormats
{
/// <summary>Guest sampled-texture format to Metal, mirroring the Vulkan
/// backend's GetTextureFormat case for case (including its RGBA8 fallback
/// for unmapped codes, so unknown formats render something rather than
/// nothing). BC formats upload raw blocks — Mac-family GPUs decode them
/// natively.</summary>
public static MetalTextureFormat DecodeTextureFormat(uint dataFormat, uint numberType)
{
var format = (dataFormat, numberType) switch
{
(1, 0) => MtlPixelFormat.R8Unorm,
(1, 1) => MtlPixelFormat.R8Snorm,
(1, 4) => MtlPixelFormat.R8Uint,
(1, 5) => MtlPixelFormat.R8Sint,
(2, 0) => MtlPixelFormat.R16Unorm,
(2, 1) => MtlPixelFormat.R16Snorm,
(2, 4) => MtlPixelFormat.R16Uint,
(2, 5) => MtlPixelFormat.R16Sint,
(2, 7) => MtlPixelFormat.R16Float,
(3, 0) => MtlPixelFormat.Rg8Unorm,
(3, 1) => MtlPixelFormat.Rg8Snorm,
(3, 4) => MtlPixelFormat.Rg8Uint,
(3, 5) => MtlPixelFormat.Rg8Sint,
(4, 4) => MtlPixelFormat.R32Uint,
(4, 5) => MtlPixelFormat.R32Sint,
(4, 7) => MtlPixelFormat.R32Float,
(5, 0) => MtlPixelFormat.Rg16Unorm,
(5, 4) => MtlPixelFormat.Rg16Uint,
(5, 5) => MtlPixelFormat.Rg16Sint,
(5, 7) => MtlPixelFormat.Rg16Float,
(6, 7) or (7, 7) => MtlPixelFormat.Rg11B10Float,
(8, _) or (9, _) => MtlPixelFormat.Bgr10A2Unorm,
(10, 4) => MtlPixelFormat.Rgba8Uint,
(10, 5) => MtlPixelFormat.Rgba8Sint,
(10, 9) => MtlPixelFormat.Rgba8UnormSrgb,
(11, 4) => MtlPixelFormat.Rg32Uint,
(11, 5) => MtlPixelFormat.Rg32Sint,
(11, 7) => MtlPixelFormat.Rg32Float,
(12, 0) => MtlPixelFormat.Rgba16Unorm,
(12, 4) => MtlPixelFormat.Rgba16Uint,
(12, 5) => MtlPixelFormat.Rgba16Sint,
(12, 7) => MtlPixelFormat.Rgba16Float,
(13, 4) or (14, 4) => MtlPixelFormat.Rgba32Uint,
(13, 5) or (14, 5) => MtlPixelFormat.Rgba32Sint,
(13, _) or (14, _) => MtlPixelFormat.Rgba32Float,
(16, 0) => MtlPixelFormat.B5G6R5Unorm,
(34, 7) => MtlPixelFormat.Rgb9E5Float,
(169, _) => MtlPixelFormat.Bc1Rgba,
(170, _) => MtlPixelFormat.Bc1RgbaSrgb,
(171, _) => MtlPixelFormat.Bc2Rgba,
(172, _) => MtlPixelFormat.Bc2RgbaSrgb,
(173, _) => MtlPixelFormat.Bc3Rgba,
(174, _) => MtlPixelFormat.Bc3RgbaSrgb,
(175, 1) or (176, _) => MtlPixelFormat.Bc4RSnorm,
(175, _) => MtlPixelFormat.Bc4RUnorm,
(177, 1) or (178, _) => MtlPixelFormat.Bc5RgSnorm,
(177, _) => MtlPixelFormat.Bc5RgUnorm,
(179, _) => MtlPixelFormat.Bc6HRgbUfloat,
(180, _) => MtlPixelFormat.Bc6HRgbFloat,
(181, _) => MtlPixelFormat.Bc7RgbaUnorm,
(182, _) => MtlPixelFormat.Bc7RgbaUnormSrgb,
_ => MtlPixelFormat.Rgba8Unorm,
};
var blockBytes = format switch
{
MtlPixelFormat.Bc1Rgba or MtlPixelFormat.Bc1RgbaSrgb or
MtlPixelFormat.Bc4RUnorm or MtlPixelFormat.Bc4RSnorm => 8u,
MtlPixelFormat.Bc2Rgba or MtlPixelFormat.Bc2RgbaSrgb or
MtlPixelFormat.Bc3Rgba or MtlPixelFormat.Bc3RgbaSrgb or
MtlPixelFormat.Bc5RgUnorm or MtlPixelFormat.Bc5RgSnorm or
MtlPixelFormat.Bc6HRgbFloat or MtlPixelFormat.Bc6HRgbUfloat or
MtlPixelFormat.Bc7RgbaUnorm or MtlPixelFormat.Bc7RgbaUnormSrgb => 16u,
_ => 0u,
};
var bytesPerPixel = format switch
{
MtlPixelFormat.R8Unorm or MtlPixelFormat.R8Snorm or
MtlPixelFormat.R8Uint or MtlPixelFormat.R8Sint => 1u,
MtlPixelFormat.R16Unorm or MtlPixelFormat.R16Snorm or
MtlPixelFormat.R16Uint or MtlPixelFormat.R16Sint or
MtlPixelFormat.R16Float or MtlPixelFormat.Rg8Unorm or
MtlPixelFormat.Rg8Snorm or MtlPixelFormat.Rg8Uint or
MtlPixelFormat.Rg8Sint or MtlPixelFormat.B5G6R5Unorm => 2u,
MtlPixelFormat.Rg32Uint or MtlPixelFormat.Rg32Sint or
MtlPixelFormat.Rg32Float or MtlPixelFormat.Rgba16Unorm or
MtlPixelFormat.Rgba16Uint or MtlPixelFormat.Rgba16Sint or
MtlPixelFormat.Rgba16Float => 8u,
MtlPixelFormat.Rgba32Uint or MtlPixelFormat.Rgba32Sint or
MtlPixelFormat.Rgba32Float => 16u,
_ => 4u,
};
return new MetalTextureFormat(format, bytesPerPixel, blockBytes);
}
/// <summary>Source byte footprint of a sampled texture, block-aware —
/// the same math the AGC layer uses to size the texel copy it ships.</summary>
public static ulong GetTextureByteCount(in MetalTextureFormat format, uint width, uint height) =>
format.IsBlockCompressed
? checked(((ulong)width + 3) / 4 * (((ulong)height + 3) / 4) * format.BlockBytes)
: checked((ulong)width * height * format.BytesPerPixel);
public static bool TryDecodeRenderTargetFormat(
uint dataFormat,
uint numberType,
out MetalRenderTargetFormat result)
{
var format = (dataFormat, numberType) switch
{
(4, 4) => MtlPixelFormat.R32Uint,
(4, 5) => MtlPixelFormat.R32Sint,
(4, 7) => MtlPixelFormat.R32Float,
(5, 4) => MtlPixelFormat.Rg16Uint,
(5, 5) => MtlPixelFormat.Rg16Sint,
(5, 7) => MtlPixelFormat.Rg16Float,
(6, 7) or (7, 7) => MtlPixelFormat.Rg11B10Float,
(9, _) => MtlPixelFormat.Bgr10A2Unorm,
(10, 4) => MtlPixelFormat.Rgba8Uint,
(10, 5) => MtlPixelFormat.Rgba8Sint,
(10, 9) => MtlPixelFormat.Rgba8UnormSrgb,
(10, _) => MtlPixelFormat.Rgba8Unorm,
(11, 7) => MtlPixelFormat.Rg32Float,
(12, 4) => MtlPixelFormat.Rgba16Uint,
(12, 5) => MtlPixelFormat.Rgba16Sint,
(12, 7) => MtlPixelFormat.Rgba16Float,
(13, 7) or (14, 7) => MtlPixelFormat.Rgba32Float,
(20, 0) => MtlPixelFormat.R32Uint,
(29, 0) or (4, 0) => MtlPixelFormat.R32Float,
(1, 0) or (36, 0) => MtlPixelFormat.R8Unorm,
(49, 0) => MtlPixelFormat.R8Uint,
(3, 0) => MtlPixelFormat.Rg8Unorm,
(5, 0) => MtlPixelFormat.Rg16Unorm,
(7, 0) => MtlPixelFormat.Rg11B10Float,
(12, 0) => MtlPixelFormat.Rgba16Unorm,
(13, 0) or (14, 0) => MtlPixelFormat.Rgba32Float,
(22, 0) or (71, 0) => MtlPixelFormat.Rgba16Float,
(56, 0) or (62, 0) or (64, 0) => MtlPixelFormat.Rgba8Unorm,
(75, 0) => MtlPixelFormat.Rg32Float,
_ => MtlPixelFormat.Invalid,
};
if (format == MtlPixelFormat.Invalid)
{
result = default;
return false;
}
var outputKind = format switch
{
MtlPixelFormat.R8Uint or MtlPixelFormat.R32Uint or MtlPixelFormat.Rg16Uint or
MtlPixelFormat.Rgba8Uint or MtlPixelFormat.Rgba16Uint => Gen5PixelOutputKind.Uint,
MtlPixelFormat.R32Sint or MtlPixelFormat.Rg16Sint or MtlPixelFormat.Rgba8Sint or
MtlPixelFormat.Rgba16Sint => Gen5PixelOutputKind.Sint,
_ => Gen5PixelOutputKind.Float,
};
result = new MetalRenderTargetFormat(format, outputKind);
return true;
}
}
@@ -1,426 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Metal;
namespace SharpEmu.Libs.Gpu.Metal;
/// <summary>
/// Metal backend for the guest-GPU seam: MSL codegen via
/// SharpEmu.ShaderCompiler.Metal, rendering via the Metal presenter — the full
/// surface (presentation, guest images, ordered flips, translated draws, and
/// compute) with no Vulkan, MoltenVK, or windowing-library dependency.
/// </summary>
internal sealed class MetalGuestGpuBackend : IGuestGpuBackend
{
public string BackendName => "Metal";
private static readonly IGuestCompiledShader DepthOnlyFragmentShader =
new MetalCompiledGuestShader(new Gen5MslShader(
MslFixedShaders.CreateDepthOnlyFragment(),
"depth_only_fs",
Gen5MslStage.Pixel,
[],
[],
AttributeCount: 0,
[]));
public bool TryCompileVertexShader(
Gen5ShaderState state,
Gen5ShaderEvaluation evaluation,
out IGuestCompiledShader? shader,
out string error,
int globalBufferBase = 0,
int totalGlobalBufferCount = -1,
int imageBindingBase = 0,
int scalarRegisterBufferIndex = -1,
int requiredVertexOutputCount = 0,
ulong storageBufferOffsetAlignment = 1)
{
shader = null;
if (!Gen5MslTranslator.TryCompileVertexShader(
state,
evaluation,
out var compiled,
out error,
globalBufferBase,
totalGlobalBufferCount,
imageBindingBase,
scalarRegisterBufferIndex,
requiredVertexOutputCount,
storageBufferOffsetAlignment))
{
return false;
}
shader = new MetalCompiledGuestShader(compiled);
return true;
}
public bool TryCompilePixelShader(
Gen5ShaderState state,
Gen5ShaderEvaluation evaluation,
IReadOnlyList<Gen5PixelOutputBinding> outputs,
out IGuestCompiledShader? shader,
out string error,
int globalBufferBase = 0,
int totalGlobalBufferCount = -1,
int imageBindingBase = 0,
int scalarRegisterBufferIndex = -1,
uint pixelInputEnable = 0,
uint pixelInputAddress = 0,
ulong storageBufferOffsetAlignment = 1)
{
shader = null;
if (!Gen5MslTranslator.TryCompilePixelShader(
state,
evaluation,
outputs,
out var compiled,
out error,
globalBufferBase,
totalGlobalBufferCount,
imageBindingBase,
scalarRegisterBufferIndex,
pixelInputEnable,
pixelInputAddress,
storageBufferOffsetAlignment))
{
return false;
}
shader = new MetalCompiledGuestShader(compiled);
return true;
}
public bool TryCompileComputeShader(
Gen5ShaderState state,
Gen5ShaderEvaluation evaluation,
uint localSizeX,
uint localSizeY,
uint localSizeZ,
out IGuestCompiledShader? shader,
out string error,
int totalGlobalBufferCount = -1,
int initialScalarBufferIndex = -1,
uint waveLaneCount = 32,
ulong storageBufferOffsetAlignment = 1)
{
shader = null;
// Wave64 compute is emulated by the translator: cross-lane ops bridge
// the two 32-wide Apple simdgroups of a guest wave through threadgroup
// scratch, and wave-agnostic kernels run per-thread unchanged.
if (!Gen5MslTranslator.TryCompileComputeShader(
state,
evaluation,
localSizeX,
localSizeY,
localSizeZ,
out var compiled,
out error,
totalGlobalBufferCount,
initialScalarBufferIndex,
waveLaneCount,
storageBufferOffsetAlignment))
{
return false;
}
shader = new MetalCompiledGuestShader(compiled);
return true;
}
public IGuestCompiledShader GetDepthOnlyFragmentShader() =>
DepthOnlyFragmentShader;
public bool TryGetRenderTargetOutputKind(uint dataFormat, uint numberType, out Gen5PixelOutputKind outputKind)
{
if (MetalGuestFormats.TryDecodeRenderTargetFormat(dataFormat, numberType, out var format))
{
outputKind = format.OutputKind;
return true;
}
outputKind = default;
return false;
}
public void EnsureStarted(uint width, uint height) =>
MetalVideoPresenter.EnsureStarted(width, height);
public void HideSplashScreen() =>
MetalVideoPresenter.HideSplashScreen();
public void Submit(byte[] bgraFrame, uint width, uint height) =>
MetalVideoPresenter.Submit(bgraFrame, width, height);
public bool TrySubmitGuestImage(
ulong address,
uint width,
uint height,
uint pitchInPixel) =>
MetalVideoPresenter.TrySubmitGuestImage(address, width, height, pitchInPixel);
public bool TrySubmitOrderedGuestImageFlip(
int videoOutHandle,
int displayBufferIndex,
ulong address,
uint width,
uint height,
uint pitchInPixel) =>
MetalVideoPresenter.TrySubmitOrderedGuestImageFlip(
videoOutHandle,
displayBufferIndex,
address,
width,
height,
pitchInPixel);
public void RegisterKnownDisplayBuffer(ulong address, uint guestFormat) =>
MetalVideoPresenter.RegisterKnownDisplayBuffer(address, guestFormat);
public bool IsGpuGuestImageAvailable(ulong address, uint format, uint numberType) =>
MetalVideoPresenter.IsGuestImageAvailable(address, format, numberType);
public bool TrySubmitGuestImageBlit(
ulong sourceAddress,
uint sourceWidth,
uint sourceHeight,
uint sourceFormat,
uint sourceNumberType,
ulong destinationAddress,
uint destinationWidth,
uint destinationHeight,
uint destinationFormat,
uint destinationNumberType) =>
MetalVideoPresenter.TrySubmitGuestImageBlit(
sourceAddress,
sourceWidth,
sourceHeight,
sourceFormat,
sourceNumberType,
destinationAddress,
destinationWidth,
destinationHeight,
destinationFormat,
destinationNumberType);
public void SubmitGuestDraw(GuestDrawKind drawKind, uint width, uint height) =>
MetalVideoPresenter.SubmitGuestDraw(drawKind, width, height);
public void SubmitTranslatedDraw(
IGuestCompiledShader pixelShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint width,
uint height,
uint attributeCount,
IGuestCompiledShader? vertexShader = null,
uint vertexCount = 3,
uint instanceCount = 1,
uint primitiveType = 4,
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
GuestRenderState? renderState = null) =>
MetalVideoPresenter.SubmitTranslatedDraw(
Msl(pixelShader),
textures,
globalMemoryBuffers,
width,
height,
attributeCount,
vertexShader is null ? null : Msl(vertexShader),
vertexCount,
instanceCount,
primitiveType,
indexBuffer,
vertexBuffers,
renderState);
public void SubmitDepthOnlyTranslatedDraw(
IGuestCompiledShader pixelShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint attributeCount,
GuestDepthTarget depthTarget,
IGuestCompiledShader? vertexShader = null,
uint vertexCount = 3,
uint instanceCount = 1,
uint primitiveType = 4,
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
GuestRenderState? renderState = null,
ulong shaderAddress = 0) =>
MetalVideoPresenter.SubmitDepthOnlyTranslatedDraw(
Msl(pixelShader),
textures,
globalMemoryBuffers,
attributeCount,
depthTarget,
vertexShader is null ? null : Msl(vertexShader),
vertexCount,
instanceCount,
primitiveType,
indexBuffer,
vertexBuffers,
renderState,
shaderAddress);
public void SubmitOffscreenTranslatedDraw(
IGuestCompiledShader pixelShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint attributeCount,
IReadOnlyList<GuestRenderTarget> targets,
IGuestCompiledShader? vertexShader = null,
uint vertexCount = 3,
uint instanceCount = 1,
uint primitiveType = 4,
GuestIndexBuffer? indexBuffer = null,
IReadOnlyList<GuestVertexBuffer>? vertexBuffers = null,
GuestRenderState? renderState = null,
GuestDepthTarget? depthTarget = null,
ulong shaderAddress = 0) =>
MetalVideoPresenter.SubmitOffscreenTranslatedDraw(
Msl(pixelShader),
textures,
globalMemoryBuffers,
attributeCount,
targets,
vertexShader is null ? null : Msl(vertexShader),
vertexCount,
instanceCount,
primitiveType,
indexBuffer,
vertexBuffers,
renderState,
depthTarget,
shaderAddress);
public void SubmitStorageTranslatedDraw(
IGuestCompiledShader pixelShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint attributeCount,
uint width,
uint height,
ulong shaderAddress = 0) =>
MetalVideoPresenter.SubmitStorageTranslatedDraw(
Msl(pixelShader),
textures,
globalMemoryBuffers,
attributeCount,
width,
height,
shaderAddress);
private static MetalCompiledGuestShader Msl(IGuestCompiledShader shader) =>
shader as MetalCompiledGuestShader ??
throw new InvalidOperationException(
$"shader handle of type {shader.GetType().Name} was not compiled by the Metal backend");
public long SubmitComputeDispatch(
ulong shaderAddress,
IGuestCompiledShader computeShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint groupCountX,
uint groupCountY,
uint groupCountZ,
uint baseGroupX,
uint baseGroupY,
uint baseGroupZ,
uint localSizeX,
uint localSizeY,
uint localSizeZ,
bool isIndirect,
bool writesGlobalMemory,
uint threadCountX = uint.MaxValue,
uint threadCountY = uint.MaxValue,
uint threadCountZ = uint.MaxValue)
{
// The translated kernel bakes its threadgroup size; localSize and
// isIndirect are already folded in by the AGC layer before submission.
_ = localSizeX;
_ = localSizeY;
_ = localSizeZ;
_ = isIndirect;
return MetalVideoPresenter.SubmitComputeDispatch(
shaderAddress,
Msl(computeShader),
textures,
globalMemoryBuffers,
groupCountX,
groupCountY,
groupCountZ,
baseGroupX,
baseGroupY,
baseGroupZ,
writesGlobalMemory,
threadCountX,
threadCountY,
threadCountZ);
}
private long _perfShaderCompilations;
public IDisposable EnterGuestQueue(string queueName, ulong submissionId) =>
MetalVideoPresenter.EnterGuestQueue(queueName, submissionId);
public long SubmitOrderedGuestAction(Action action, string debugName) =>
MetalVideoPresenter.SubmitOrderedGuestAction(action, debugName);
public long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex) =>
MetalVideoPresenter.SubmitOrderedGuestFlipWait(videoOutHandle, displayBufferIndex);
public bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite) =>
MetalVideoPresenter.WaitForGuestWork(workSequence, timeoutMilliseconds);
public long CurrentGuestWorkSequenceForDiagnostics =>
MetalVideoPresenter.CurrentGuestWorkSequenceForDiagnostics;
public bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType) =>
MetalVideoPresenter.IsGuestImageUploadKnown(address, format, numberType);
public bool GuestImageWantsInitialData(ulong address) =>
MetalVideoPresenter.GuestImageWantsInitialData(address);
public void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels) =>
MetalVideoPresenter.ProvideGuestImageInitialData(address, rgbaPixels);
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
MetalVideoPresenter.SubmitGuestImageFill(address, fillValue);
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
MetalVideoPresenter.SubmitGuestImageWrite(address, pixels);
public bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) =>
MetalVideoPresenter.TryGetGuestImageExtent(address, out width, out height, out byteCount);
public IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents() =>
MetalVideoPresenter.GetGuestImageExtents();
public bool IsTextureContentCached(in TextureContentIdentity identity) =>
MetalVideoPresenter.IsTextureContentCached(identity);
public void AttachGuestMemory(SharpEmu.HLE.ICpuMemory memory) =>
MetalVideoPresenter.AttachGuestMemory(memory);
// Over-alignment is always valid, and 256 covers every Metal buffer-offset
// requirement (Intel Macs need 256 for constant buffers; Apple GPUs less).
public ulong GuestStorageBufferOffsetAlignment => 256;
public void CountShaderCompilation() =>
Interlocked.Increment(ref _perfShaderCompilations);
public (long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters()
{
var (draws, drawMs, pipelines) = MetalVideoPresenter.ReadAndResetDrawPerfCounters();
return (draws, drawMs, pipelines, Interlocked.Exchange(ref _perfShaderCompilations, 0));
}
public void RequestClose() =>
MetalVideoPresenter.RequestClose();
}
@@ -1,182 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE.Host;
using SharpEmu.HLE.Host.Posix;
namespace SharpEmu.Libs.Gpu.Metal;
/// <summary>
/// Keyboard state sampled from the Metal presenter's window, feeding the POSIX
/// host input seam so pad emulation works like the Vulkan presenter's
/// HostWindowInput. Key events arrive on the AppKit main thread as macOS
/// virtual key codes; pad reads happen on guest threads, so state is guarded.
/// Window gamepads are not surfaced by AppKit — controller support would go
/// through GameController.framework and is out of scope here.
/// </summary>
internal static class MetalHostInput
{
private static readonly object Gate = new();
private static readonly HashSet<ushort> Pressed = new();
private static volatile bool _connected;
/// <summary>Registers this window's keyboard as the host input source.</summary>
public static void Attach()
{
_connected = true;
PosixHostInput.SetSource(new MetalWindowInputSource());
Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
}
// Debug automation: SHARPEMU_METAL_AUTOKEY="12:0x24,15:0x24" presses the
// macOS key code at each elapsed-seconds mark for a few frames, letting
// headless test runs navigate menus without a human at the keyboard.
private static readonly List<(double At, ushort Key, bool[] State)> _autoKeys = ParseAutoKeys();
private static readonly System.Diagnostics.Stopwatch _autoKeyClock =
System.Diagnostics.Stopwatch.StartNew();
private static List<(double, ushort, bool[])> ParseAutoKeys()
{
var keys = new List<(double, ushort, bool[])>();
var spec = Environment.GetEnvironmentVariable("SHARPEMU_METAL_AUTOKEY");
if (string.IsNullOrWhiteSpace(spec))
{
return keys;
}
foreach (var entry in spec.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
var parts = entry.Split(':');
if (parts.Length == 2 &&
double.TryParse(parts[0], out var at) &&
TryParseKeyCode(parts[1], out var key))
{
keys.Add((at, key, new bool[2]));
}
}
return keys;
}
private static bool TryParseKeyCode(string text, out ushort key)
{
return text.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
? ushort.TryParse(text[2..], System.Globalization.NumberStyles.HexNumber, null, out key)
: ushort.TryParse(text, out key);
}
/// <summary>Called once per render frame; fires and releases scripted keys.</summary>
public static void PumpAutoKeys()
{
if (_autoKeys.Count == 0)
{
return;
}
var elapsed = _autoKeyClock.Elapsed.TotalSeconds;
foreach (var (at, key, state) in _autoKeys)
{
if (!state[0] && elapsed >= at)
{
state[0] = true;
KeyDown(key, isRepeat: false);
Console.Error.WriteLine($"[LOADER][INFO] Metal autokey press 0x{key:X} at {elapsed:F1}s");
}
else if (state[0] && !state[1] && elapsed >= at + 0.2)
{
state[1] = true;
KeyUp(key);
}
}
}
public static void KeyDown(ushort keyCode, bool isRepeat)
{
// kVK_F1: parity with the Vulkan window's perf-overlay toggle.
if (keyCode == 0x7A && !isRepeat)
{
VideoOut.PerfOverlay.Toggle();
}
lock (Gate)
{
Pressed.Add(keyCode);
}
}
public static void KeyUp(ushort keyCode)
{
lock (Gate)
{
Pressed.Remove(keyCode);
}
}
private static bool IsKeyCodeDown(ushort keyCode)
{
lock (Gate)
{
return Pressed.Contains(keyCode);
}
}
private sealed class MetalWindowInputSource : IPosixWindowInputSource
{
public bool HasKeyboardFocus => _connected;
public bool IsKeyDown(int virtualKey) =>
TryMapVirtualKey(virtualKey, out var keyCode) && IsKeyCodeDown(keyCode);
public int GetGamepadStates(Span<HostGamepadState> destination) => 0;
public string? DescribeConnectedGamepad() => null;
}
/// <summary>Windows virtual-key semantics (the seam's contract) to macOS
/// kVK virtual key codes, covering the keys pad emulation polls.</summary>
private static bool TryMapVirtualKey(int vk, out ushort keyCode)
{
keyCode = vk switch
{
0x08 => 0x33, // Backspace -> kVK_Delete
0x09 => 0x30, // Tab
0x0D => 0x24, // Enter -> kVK_Return
0x1B => 0x35, // Escape
0x20 => 0x31, // Space
0x25 => 0x7B, // Left
0x26 => 0x7E, // Up
0x27 => 0x7C, // Right
0x28 => 0x7D, // Down
// Letters: macOS ANSI key codes are layout-position based and
// non-contiguous, so map each polled letter explicitly.
0x41 => 0x00, // A
0x42 => 0x0B, // B
0x43 => 0x08, // C
0x44 => 0x02, // D
0x45 => 0x0E, // E
0x46 => 0x03, // F
0x47 => 0x05, // G
0x48 => 0x04, // H
0x49 => 0x22, // I
0x4A => 0x26, // J
0x4B => 0x28, // K
0x4C => 0x25, // L
0x4D => 0x2E, // M
0x4E => 0x2D, // N
0x4F => 0x1F, // O
0x50 => 0x23, // P
0x51 => 0x0C, // Q
0x52 => 0x0F, // R
0x53 => 0x01, // S
0x54 => 0x11, // T
0x55 => 0x20, // U
0x56 => 0x09, // V
0x57 => 0x0D, // W
0x58 => 0x07, // X
0x59 => 0x10, // Y
0x5A => 0x06, // Z
_ => ushort.MaxValue,
};
return keyCode != ushort.MaxValue;
}
}
-430
View File
@@ -1,430 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
namespace SharpEmu.Libs.Gpu.Metal;
// Core Graphics / Metal ABI structs passed by value through objc_msgSend. Struct
// *returns* are deliberately never used: on x86-64 (this process runs under Rosetta
// on Apple silicon) large struct returns switch to objc_msgSend_stret, and avoiding
// them entirely keeps one calling convention everywhere.
[StructLayout(LayoutKind.Sequential)]
internal struct CGRect
{
public double X;
public double Y;
public double Width;
public double Height;
}
[StructLayout(LayoutKind.Sequential)]
internal struct CGSize
{
public double Width;
public double Height;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MtlClearColor
{
public double Red;
public double Green;
public double Blue;
public double Alpha;
}
/// <summary>MTLTextureSwizzleChannels: one MTLTextureSwizzle byte per output
/// channel (Zero=0, One=1, Red=2, Green=3, Blue=4, Alpha=5).</summary>
[StructLayout(LayoutKind.Sequential)]
internal struct MtlTextureSwizzleChannels
{
public byte Red;
public byte Green;
public byte Blue;
public byte Alpha;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MtlRegion
{
public nuint X;
public nuint Y;
public nuint Z;
public nuint Width;
public nuint Height;
public nuint Depth;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MtlSize
{
public nuint Width;
public nuint Height;
public nuint Depth;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MtlOrigin
{
public nuint X;
public nuint Y;
public nuint Z;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MtlScissorRect
{
public nuint X;
public nuint Y;
public nuint Width;
public nuint Height;
}
[StructLayout(LayoutKind.Sequential)]
internal struct MtlViewport
{
public double OriginX;
public double OriginY;
public double Width;
public double Height;
public double ZNear;
public double ZFar;
}
/// <summary>
/// Objective-C runtime access for the Metal presenter: AppKit, QuartzCore, and Metal
/// through objc_msgSend, with one LibraryImport overload per distinct native
/// signature. Dependency-free by design — this plus the OS frameworks is the entire
/// Metal path, which is what keeps it NativeAOT-clean.
/// </summary>
internal static partial class MetalNative
{
private const string CoreFoundation =
"/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation";
[LibraryImport(CoreFoundation)]
public static partial nint CFRunLoopGetMain();
[LibraryImport(CoreFoundation)]
public static partial void CFRunLoopStop(nint runLoop);
private const string ObjCLibrary = "/usr/lib/libobjc.A.dylib";
private const string MetalFramework = "/System/Library/Frameworks/Metal.framework/Metal";
private const string AppKitFramework = "/System/Library/Frameworks/AppKit.framework/AppKit";
private const string QuartzCoreFramework = "/System/Library/Frameworks/QuartzCore.framework/QuartzCore";
private static bool _frameworksLoaded;
/// <summary>
/// Makes the AppKit and QuartzCore classes visible to objc_getClass; Metal is
/// pulled in by its own LibraryImport. Call once before any Class() lookup.
/// </summary>
public static void EnsureFrameworksLoaded()
{
if (_frameworksLoaded)
{
return;
}
NativeLibrary.Load(AppKitFramework);
NativeLibrary.Load(QuartzCoreFramework);
_frameworksLoaded = true;
}
[LibraryImport(MetalFramework)]
public static partial nint MTLCreateSystemDefaultDevice();
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
private static partial nint objc_getClass(string name);
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
private static partial nint sel_registerName(string name);
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
public static partial nint objc_allocateClassPair(nint superclass, string name, nuint extraBytes);
[LibraryImport(ObjCLibrary)]
public static partial void objc_registerClassPair(nint cls);
[LibraryImport(ObjCLibrary, StringMarshalling = StringMarshalling.Utf8)]
[return: MarshalAs(UnmanagedType.I1)]
public static partial bool class_addMethod(nint cls, nint name, nint imp, string types);
[LibraryImport(ObjCLibrary)]
public static partial nint objc_autoreleasePoolPush();
[LibraryImport(ObjCLibrary)]
public static partial void objc_autoreleasePoolPop(nint pool);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector);
/// <summary>objc_msgSend for -gpuResourceID. MTLResourceID is a one-field
/// 8-byte struct, returned in a register on the x86-64 ABI, so it maps to a
/// ulong return — the value written into a Tier 2 argument buffer slot.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial ulong SendGpuResourceId(nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument);
/// <summary>objc_msgSend for a CGRect-returning selector (e.g. -bounds).
/// A 32-byte struct is returned via the x86-64 stret ABI — a hidden
/// pointer to caller storage passed ahead of self/_cmd — so this must not
/// be folded into the plain objc_msgSend overloads.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend_stret")]
public static partial void SendStretRect(out CGRect result, nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument, ref nint error);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint Send(nint receiver, nint selector, nint argument0, nint argument1, ref nint error);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendAtIndex(nint receiver, nint selector, nuint index);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
[return: MarshalAs(UnmanagedType.I1)]
public static partial bool SendBool(nint receiver, nint selector);
/// <summary>One-argument BOOL sends, e.g. respondsToSelector:.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
[return: MarshalAs(UnmanagedType.I1)]
public static partial bool SendBool(nint receiver, nint selector, nint argument);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial double SendDouble(nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoid(nint receiver, nint selector);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoid(nint receiver, nint selector, nint argument);
/// <summary>Two-object-argument void sends, e.g. setObject:forKey:.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoid(nint receiver, nint selector, nint argument0, nint argument1);
/// <summary>performSelectorOnMainThread:withObject:waitUntilDone: — the SEL
/// to perform is itself an argument, followed by the object and the wait
/// flag.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidPerformSelector(
nint receiver,
nint selector,
nint performedSelector,
nint argument,
[MarshalAs(UnmanagedType.I1)] bool waitUntilDone);
/// <summary>setSwizzle: on MTLTextureDescriptor. Four one-byte
/// MTLTextureSwizzle values, passed packed like the framework expects.</summary>
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidSwizzle(
nint receiver,
nint selector,
MtlTextureSwizzleChannels channels);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidBool(nint receiver, nint selector, [MarshalAs(UnmanagedType.I1)] bool argument);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidDouble(nint receiver, nint selector, double argument);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidSize(nint receiver, nint selector, CGSize size);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidRect(nint receiver, nint selector, CGRect rect);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidClearColor(nint receiver, nint selector, MtlClearColor color);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidBlendColor(
nint receiver,
nint selector,
float red,
float green,
float blue,
float alpha);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidViewport(nint receiver, nint selector, MtlViewport viewport);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendSetAtIndex(nint receiver, nint selector, nint value, nuint index);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidCopyTexture(nint receiver, nint selector, nint source, nint destination);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendBuffer(nint receiver, nint selector, nint bytes, nuint length, nuint options);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendNewBuffer(nint receiver, nint selector, nuint length, nuint options);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendCopyTextureToBuffer(
nint receiver,
nint selector,
nint sourceTexture,
nuint sourceSlice,
nuint sourceLevel,
MtlOrigin sourceOrigin,
MtlSize sourceSize,
nint destinationBuffer,
nuint destinationOffset,
nuint destinationBytesPerRow,
nuint destinationBytesPerImage);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendCopyBufferToTexture(
nint receiver,
nint selector,
nint sourceBuffer,
nuint sourceOffset,
nuint sourceBytesPerRow,
nuint sourceBytesPerImage,
MtlSize sourceSize,
nint destinationTexture,
nuint destinationSlice,
nuint destinationLevel,
MtlOrigin destinationOrigin);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendDispatch(
nint receiver,
nint selector,
MtlSize threadgroups,
MtlSize threadsPerThreadgroup);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendSetBuffer(nint receiver, nint selector, nint buffer, nuint offset, nuint index);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendVoidScissor(nint receiver, nint selector, MtlScissorRect rect);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendDrawPrimitivesInstanced(
nint receiver,
nint selector,
nuint primitiveType,
nuint vertexStart,
nuint vertexCount,
nuint instanceCount);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendDrawIndexedPrimitives(
nint receiver,
nint selector,
nuint primitiveType,
nuint indexCount,
nuint indexType,
nint indexBuffer,
nuint indexBufferOffset,
nuint instanceCount);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendTimer(
nint receiver,
nint selector,
double interval,
nint target,
nint timerSelector,
nint userInfo,
[MarshalAs(UnmanagedType.I1)] bool repeats);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendInitFrame(nint receiver, nint selector, CGRect frame);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendInitWindow(
nint receiver,
nint selector,
CGRect contentRect,
nuint styleMask,
nuint backing,
[MarshalAs(UnmanagedType.I1)] bool defer);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendNextEvent(
nint receiver,
nint selector,
ulong eventMask,
nint untilDate,
nint inMode,
[MarshalAs(UnmanagedType.I1)] bool dequeue);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial nint SendTextureDescriptor(
nint receiver,
nint selector,
nuint pixelFormat,
nuint width,
nuint height,
[MarshalAs(UnmanagedType.I1)] bool mipmapped);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendReplaceRegion(
nint receiver,
nint selector,
MtlRegion region,
nuint mipmapLevel,
nint bytes,
nuint bytesPerRow);
[LibraryImport(ObjCLibrary, EntryPoint = "objc_msgSend")]
public static partial void SendDrawPrimitives(
nint receiver,
nint selector,
nuint primitiveType,
nuint vertexStart,
nuint vertexCount);
public static nint Class(string name) => objc_getClass(name);
public static nint Selector(string name) => sel_registerName(name);
/// <summary>Autoreleased NSString — only valid inside an autorelease pool
/// unless the caller retains it.</summary>
public static nint NsString(string value)
{
var utf8 = Marshal.StringToCoTaskMemUTF8(value);
try
{
return Send(Class("NSString"), Selector("stringWithUTF8String:"), utf8);
}
finally
{
Marshal.FreeCoTaskMem(utf8);
}
}
/// <summary>Reads an NSString's UTF-8 contents, or null if the handle is nil.</summary>
public static string? ReadNsString(nint nsString)
{
if (nsString == 0)
{
return null;
}
var utf8 = Send(nsString, Selector("UTF8String"));
return utf8 == 0 ? null : Marshal.PtrToStringUTF8(utf8);
}
public static string DescribeError(nint error)
{
if (error == 0)
{
return "unknown error";
}
var description = Send(error, Selector("localizedDescription"));
var utf8 = Send(description, Selector("UTF8String"));
return Marshal.PtrToStringUTF8(utf8) ?? "unknown error";
}
}
@@ -1,50 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Metal;
// Guest draws and compute dispatches batch into one command buffer per drain
// instead of one per work item, mirroring the Vulkan presenter's batched guest
// commands: commit overhead dominated CPU time for scenes with dozens of draws
// per frame. Ordering inside the batch is by encoder sequence (snapshot blits
// for a draw's feedback reads are encoded before its render pass opens), and
// everything that must observe batched work on the serial queue — flips, image
// writes/blits, CPU-visible write-backs, the present pass — flushes first.
internal static partial class MetalVideoPresenter
{
private static nint _batchCommandBuffer;
private static bool _batchOpen;
/// <summary>Returns the open batch command buffer, opening one on first
/// use. Render thread only, like the drain it serves.</summary>
private static nint BeginBatchedGuestCommands(nint queue)
{
if (_batchOpen)
{
return _batchCommandBuffer;
}
_batchCommandBuffer = MetalNative.Send(queue, MetalNative.Selector("commandBuffer"));
_batchOpen = _batchCommandBuffer != 0;
return _batchCommandBuffer;
}
/// <summary>Commits the open batch (if any), tagging the upload pages and
/// snapshot resources it consumed. Returns the committed command buffer so
/// write-back sites can wait on it, or 0 when nothing was open.</summary>
private static nint FlushBatchedGuestCommands()
{
if (!_batchOpen)
{
return 0;
}
_batchOpen = false;
var commandBuffer = _batchCommandBuffer;
_batchCommandBuffer = 0;
MetalNative.SendVoid(commandBuffer, MetalNative.Selector("commit"));
TagUploadPages(commandBuffer);
TagSnapshotResources(commandBuffer);
return commandBuffer;
}
}
@@ -1,424 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Metal;
// Guest compute dispatches: ordered guest work like draws, with two contracts to
// honor. Storage images are shared live through the guest-image registry so a
// dispatch's writes are visible to later draws, blits, and flips of the same
// address; and CPU-visible buffer writes land back in guest memory before the
// work item completes, which is the ordering point WaitForGuestWork promises.
internal static partial class MetalVideoPresenter
{
private static readonly bool _skipAllCompute =
Environment.GetEnvironmentVariable("SHARPEMU_SKIP_ALL_COMPUTE") == "1";
private static bool _tracedDispatchBase;
private sealed record ComputeGuestDispatch(
ulong ShaderAddress,
MetalCompiledGuestShader Shader,
GuestDrawTexture[] Textures,
GuestMemoryBuffer[] GlobalMemoryBuffers,
uint GroupCountX,
uint GroupCountY,
uint GroupCountZ,
uint BaseGroupX,
uint BaseGroupY,
uint BaseGroupZ,
uint ThreadCountX,
uint ThreadCountY,
uint ThreadCountZ);
private static readonly Dictionary<MetalCompiledGuestShader, nint> _computePipelineCache = new();
public static long SubmitComputeDispatch(
ulong shaderAddress,
MetalCompiledGuestShader computeShader,
IReadOnlyList<GuestDrawTexture> textures,
IReadOnlyList<GuestMemoryBuffer> globalMemoryBuffers,
uint groupCountX,
uint groupCountY,
uint groupCountZ,
uint baseGroupX,
uint baseGroupY,
uint baseGroupZ,
bool writesGlobalMemory,
uint threadCountX,
uint threadCountY,
uint threadCountZ)
{
var hasStorage = false;
foreach (var texture in textures)
{
hasStorage |= texture.IsStorage;
}
if (groupCountX == 0 ||
groupCountY == 0 ||
groupCountZ == 0 ||
(!hasStorage && !writesGlobalMemory))
{
return 0;
}
lock (_gate)
{
if (_closed || _thread is null)
{
return 0;
}
// Storage images a dispatch writes become flip sources and sampled
// inputs for later work, exactly like published render targets.
foreach (var texture in textures)
{
if (!texture.IsStorage || texture.Address == 0)
{
continue;
}
var guestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType);
if (guestFormat != 0)
{
_availableGuestImages[texture.Address] = guestFormat;
}
}
var sequence = EnqueueGuestWorkLocked(
new ComputeGuestDispatch(
shaderAddress,
computeShader,
ToArray(textures),
ToArray(globalMemoryBuffers),
groupCountX,
groupCountY,
groupCountZ,
baseGroupX,
baseGroupY,
baseGroupZ,
threadCountX,
threadCountY,
threadCountZ));
foreach (var texture in textures)
{
if (texture.IsStorage && texture.Address != 0)
{
_guestImageWorkSequences[texture.Address] = sequence;
}
}
return sequence;
}
}
private static void ExecuteComputeDispatch(nint device, nint queue, ComputeGuestDispatch dispatch)
{
if (_skipAllCompute)
{
ReturnPooledComputeData(dispatch);
return;
}
VideoOut.PerfOverlay.RecordDraw();
if ((dispatch.BaseGroupX | dispatch.BaseGroupY | dispatch.BaseGroupZ) != 0 &&
!_tracedDispatchBase)
{
// Metal has no dispatch-base; the translated kernel derives its ids
// from the raw grid position, so a nonzero base computes offset-zero
// work until base support lands in the emitted kernel.
_tracedDispatchBase = true;
Console.Error.WriteLine(
"[LOADER][WARN] Metal compute dispatch with nonzero base group " +
$"({dispatch.BaseGroupX},{dispatch.BaseGroupY},{dispatch.BaseGroupZ}); " +
"executing without the base offset.");
}
if (!TryGetComputePipeline(device, dispatch.Shader, out var pipeline))
{
ReturnPooledComputeData(dispatch);
return;
}
var commandBuffer = BeginBatchedGuestCommands(queue);
// Pre-resolve textures before the compute encoder opens: snapshot
// blits for feedback reads encode into the batch and encoder order
// must place them ahead of this dispatch.
Span<nint> textureHandles = stackalloc nint[dispatch.Textures.Length];
Span<bool> textureOwned = stackalloc bool[dispatch.Textures.Length];
for (var index = 0; index < dispatch.Textures.Length; index++)
{
var descriptor = dispatch.Textures[index];
if (descriptor.IsStorage && descriptor.Address != 0)
{
textureHandles[index] = EnsureStorageImage(device, descriptor)?.Texture ?? 0;
textureOwned[index] = false;
}
else
{
textureHandles[index] = CreateDrawTexture(
device, commandBuffer, descriptor, out var ownedTexture);
textureOwned[index] = ownedTexture;
}
}
var encoder = MetalNative.Send(commandBuffer, MetalNative.Selector("computeCommandEncoder"));
MetalNative.SendVoid(encoder, MetalNative.Selector("setComputePipelineState:"), pipeline);
var writeBackBuffers = new List<(nint Pointer, GuestMemoryBuffer Guest)>();
var selSetBuffer = MetalNative.Selector("setBuffer:offset:atIndex:");
var bufferCount = dispatch.GlobalMemoryBuffers.Length;
Span<uint> boundBytes = stackalloc uint[Math.Max(bufferCount, 1)];
for (var index = 0; index < bufferCount; index++)
{
var guest = dispatch.GlobalMemoryBuffers[index];
var pointer = UploadGlobalBuffer(
device, guest, out var buffer, out var offset, out boundBytes[index]);
MetalNative.SendSetBuffer(encoder, selSetBuffer, buffer, (nuint)offset, (nuint)index);
if (guest.Writable && guest.WriteBackToGuest)
{
writeBackBuffers.Add((pointer, guest));
}
}
// SharpEmuUniforms: the dispatch limit clamps the overshoot threads of the
// last threadgroup row, then each bound buffer's byte length follows
// (including the alignment-bias prefix the shader indexes past).
var shader = dispatch.Shader.Shader;
var uniforms = AllocateUpload(
device,
16 + (Math.Max(bufferCount, 1) * sizeof(uint)),
out var uniformsBuffer,
out var uniformsOffset);
WriteDispatchLimit(uniforms, 0, dispatch.ThreadCountX, dispatch.GroupCountX, shader.ThreadgroupSizeX);
WriteDispatchLimit(uniforms, 4, dispatch.ThreadCountY, dispatch.GroupCountY, shader.ThreadgroupSizeY);
WriteDispatchLimit(uniforms, 8, dispatch.ThreadCountZ, dispatch.GroupCountZ, shader.ThreadgroupSizeZ);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(uniforms[12..], 0);
for (var index = 0; index < bufferCount; index++)
{
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(
uniforms[(16 + (index * sizeof(uint)))..],
boundBytes[index]);
}
// Bind at the stage's declared SharpEmuUniforms slot (see the draw path:
// stages compute their own index from globalBufferBase + total count).
var uniformsIndex = shader.UniformsBufferIndex;
MetalNative.SendSetBuffer(
encoder,
selSetBuffer,
uniformsBuffer,
(nuint)uniformsOffset,
(nuint)(uniformsIndex >= 0 ? uniformsIndex : bufferCount));
var selSetTexture = MetalNative.Selector("setTexture:atIndex:");
for (var index = 0; index < dispatch.Textures.Length; index++)
{
var texture = textureHandles[index];
if (texture != 0)
{
MetalNative.SendSetAtIndex(encoder, selSetTexture, texture, (nuint)index);
if (textureOwned[index])
{
MetalNative.SendVoid(texture, MetalNative.Selector("release"));
}
}
}
// Samplers travel in an argument buffer bound at setBuffer (see the draw
// path), sidestepping Metal's 16-sampler-per-stage cap.
BindSamplerArgumentBuffer(device, encoder, selSetBuffer, dispatch.Shader, dispatch.Textures);
MetalNative.SendDispatch(
encoder,
MetalNative.Selector("dispatchThreadgroups:threadsPerThreadgroup:"),
new MtlSize
{
Width = dispatch.GroupCountX,
Height = dispatch.GroupCountY,
Depth = dispatch.GroupCountZ,
},
new MtlSize
{
Width = Math.Max(shader.ThreadgroupSizeX, 1),
Height = Math.Max(shader.ThreadgroupSizeY, 1),
Depth = Math.Max(shader.ThreadgroupSizeZ, 1),
});
MetalNative.SendVoid(encoder, MetalNative.Selector("endEncoding"));
// CPU-visible writes are ordering points (see the draw path): flush
// the batch and wait so the write-back lands before this work item
// completes. Pure-GPU dispatches stay in the open batch.
if (writeBackBuffers.Count > 0)
{
var committed = FlushBatchedGuestCommands();
MetalNative.SendVoid(committed, MetalNative.Selector("waitUntilCompleted"));
WriteBuffersBackToGuest(writeBackBuffers);
}
foreach (var descriptor in dispatch.Textures)
{
if (!descriptor.IsStorage || descriptor.Address == 0)
{
continue;
}
GuestImage? image;
lock (_gate)
{
_guestImages.TryGetValue(descriptor.Address, out image);
}
if (image is not null)
{
image.MarkContentChanged();
}
}
ReturnPooledComputeData(dispatch);
}
/// <summary>The live, shared storage image for a guest address: dispatches,
/// draws, blits, and flips of the same address all see one texture.</summary>
private static GuestImage? EnsureStorageImage(nint device, GuestDrawTexture descriptor)
{
lock (_gate)
{
if (_guestImages.TryGetValue(descriptor.Address, out var existing))
{
return existing;
}
}
if (descriptor.Width == 0 || descriptor.Height == 0 ||
descriptor.Width > 16384 || descriptor.Height > 16384)
{
return null;
}
var format = MetalGuestFormats.TryDecodeRenderTargetFormat(
descriptor.Format, descriptor.NumberType, out var decoded)
? decoded.Format
: MtlPixelFormat.Rgba8Unorm;
var textureDescriptor = MetalNative.SendTextureDescriptor(
MetalNative.Class("MTLTextureDescriptor"),
MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"),
(nuint)format,
descriptor.Width,
descriptor.Height,
mipmapped: false);
MetalNative.Send(
textureDescriptor,
MetalNative.Selector("setUsage:"),
(nint)(UsageShaderRead | UsageShaderWrite | UsageRenderTarget));
var image = new GuestImage
{
Texture = MetalNative.Send(
device, MetalNative.Selector("newTextureWithDescriptor:"), textureDescriptor),
Width = descriptor.Width,
Height = descriptor.Height,
Format = format,
};
if (image.Texture == 0)
{
return null;
}
var bytesPerPixel = MetalRenderTargetFormat.GetBytesPerPixel(format);
// Snapshot copies arrive in the image's native texel layout; only
// 4-byte texels can be RGBA8 verbatim, wider ones carry native bytes.
if ((ulong)descriptor.RgbaPixels.Length >= (ulong)descriptor.Width * bytesPerPixel)
{
var pitch = descriptor.Pitch != 0
? Math.Max(descriptor.Pitch, descriptor.Width)
: descriptor.Width;
ReplaceTextureContents(
image.Texture, descriptor.Width, descriptor.Height, descriptor.RgbaPixels, pitch, bytesPerPixel);
image.MarkContentChanged();
}
lock (_gate)
{
if (_guestImages.TryGetValue(descriptor.Address, out var raced))
{
MetalNative.SendVoid(image.Texture, MetalNative.Selector("release"));
return raced;
}
_guestImages[descriptor.Address] = image;
_guestImageExtents[descriptor.Address] =
(descriptor.Width, descriptor.Height, (ulong)descriptor.Width * descriptor.Height * bytesPerPixel);
}
return image;
}
private static bool TryGetComputePipeline(nint device, MetalCompiledGuestShader shader, out nint pipeline)
{
lock (_computePipelineCache)
{
if (_computePipelineCache.TryGetValue(shader, out pipeline))
{
return pipeline != 0;
}
}
var function = GetShaderFunction(device, shader);
if (function != 0)
{
nint error = 0;
pipeline = MetalNative.Send(
device,
MetalNative.Selector("newComputePipelineStateWithFunction:error:"),
function,
ref error);
if (pipeline == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Metal compute pipeline creation failed: {MetalNative.DescribeError(error)}");
}
else
{
Interlocked.Increment(ref _perfPipelineCreations);
}
}
else
{
pipeline = 0;
}
lock (_computePipelineCache)
{
_computePipelineCache[shader] = pipeline;
}
return pipeline != 0;
}
private static void WriteDispatchLimit(
Span<byte> uniforms,
int offset,
uint threadCount,
uint groupCount,
uint threadgroupSize)
{
var limit = threadCount != uint.MaxValue
? threadCount
: groupCount * Math.Max(threadgroupSize, 1);
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(
uniforms[offset..],
limit);
}
private static void ReturnPooledComputeData(ComputeGuestDispatch dispatch)
{
foreach (var buffer in dispatch.GlobalMemoryBuffers)
{
if (buffer.Pooled)
{
GuestDataPool.Shared.Return(buffer.Data);
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,180 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Metal;
// Feedback reads (draws sampling a live guest render target or depth image)
// need a fresh ordered snapshot per draw. Creating and destroying an MTLTexture
// — and for depth reads a private staging MTLBuffer — per draw is measurable
// CPU and allocator churn at hundreds of feedback draws per second, so both
// recycle through a pool with the same lifecycle as the upload arena pages:
// acquired snapshots are tagged with the command buffer that samples them at
// commit, and return to the free list once that command buffer completes (the
// command queue is serial, so the earlier snapshot-blit command buffer is
// necessarily complete by then too). Everything here runs on the render thread.
internal static partial class MetalVideoPresenter
{
private const int MaxFreeSnapshotResources = 16;
private sealed class PooledSnapshotResource
{
public nint Handle;
public bool IsBuffer;
/// <summary>Texture identity (unused for buffers).</summary>
public uint Format;
public uint Width;
public uint Height;
public nint Usage;
/// <summary>Buffer capacity in bytes (unused for textures).</summary>
public nuint Capacity;
/// <summary>Retained handle of the command buffer that samples this
/// snapshot; the resource is reusable once it completes.</summary>
public nint LastCommandBuffer;
}
private static readonly List<PooledSnapshotResource> _retiredSnapshotResources = [];
private static readonly List<PooledSnapshotResource> _pendingSnapshotResources = [];
private static readonly List<PooledSnapshotResource> _freeSnapshotResources = [];
/// <summary>Returns completed snapshot resources to the free list; called
/// once per render-loop drain, next to the upload-page recycler.</summary>
private static void RecycleCompletedSnapshotResources()
{
for (var index = _retiredSnapshotResources.Count - 1; index >= 0; index--)
{
var resource = _retiredSnapshotResources[index];
if (resource.LastCommandBuffer != 0)
{
// MTLCommandBufferStatus: Completed = 4, Error = 5.
var status = MetalNative.Send(
resource.LastCommandBuffer, MetalNative.Selector("status"));
if (status < 4)
{
continue;
}
MetalNative.SendVoid(resource.LastCommandBuffer, MetalNative.Selector("release"));
resource.LastCommandBuffer = 0;
}
_retiredSnapshotResources.RemoveAt(index);
if (_freeSnapshotResources.Count < MaxFreeSnapshotResources)
{
_freeSnapshotResources.Add(resource);
}
else
{
MetalNative.SendVoid(resource.Handle, MetalNative.Selector("release"));
}
}
}
/// <summary>Pops a pooled snapshot texture matching the exact identity, or
/// creates one. The returned handle is owned by the pool — callers must not
/// release it, and it must be tagged at the next commit.</summary>
private static nint AcquireSnapshotTexture(
nint device,
MtlPixelFormat format,
uint width,
uint height,
nint usage)
{
for (var index = 0; index < _freeSnapshotResources.Count; index++)
{
var candidate = _freeSnapshotResources[index];
if (!candidate.IsBuffer &&
candidate.Format == (uint)format &&
candidate.Width == width &&
candidate.Height == height &&
candidate.Usage == usage)
{
_freeSnapshotResources.RemoveAt(index);
_pendingSnapshotResources.Add(candidate);
return candidate.Handle;
}
}
var descriptor = MetalNative.SendTextureDescriptor(
MetalNative.Class("MTLTextureDescriptor"),
MetalNative.Selector("texture2DDescriptorWithPixelFormat:width:height:mipmapped:"),
(nuint)format,
width,
height,
mipmapped: false);
MetalNative.Send(descriptor, MetalNative.Selector("setUsage:"), usage);
var handle = MetalNative.Send(
device, MetalNative.Selector("newTextureWithDescriptor:"), descriptor);
if (handle == 0)
{
return 0;
}
_pendingSnapshotResources.Add(new PooledSnapshotResource
{
Handle = handle,
Format = (uint)format,
Width = width,
Height = height,
Usage = usage,
});
return handle;
}
/// <summary>Pops a pooled private-storage staging buffer of at least
/// <paramref name="minimumBytes"/>, or creates one. Pool-owned like
/// <see cref="AcquireSnapshotTexture"/>.</summary>
private static nint AcquireSnapshotBuffer(nint device, nuint minimumBytes)
{
for (var index = 0; index < _freeSnapshotResources.Count; index++)
{
var candidate = _freeSnapshotResources[index];
if (candidate.IsBuffer && candidate.Capacity >= minimumBytes)
{
_freeSnapshotResources.RemoveAt(index);
_pendingSnapshotResources.Add(candidate);
return candidate.Handle;
}
}
// MTLResourceStorageModePrivate = 32: staging never touches the CPU.
var handle = MetalNative.SendNewBuffer(
device, MetalNative.Selector("newBufferWithLength:options:"), minimumBytes, 32);
if (handle == 0)
{
return 0;
}
_pendingSnapshotResources.Add(new PooledSnapshotResource
{
Handle = handle,
IsBuffer = true,
Capacity = minimumBytes,
});
return handle;
}
/// <summary>Marks every snapshot resource acquired since the previous tag
/// as owing its lifetime to <paramref name="commandBuffer"/>. Called at the
/// same commit sites as <see cref="TagUploadPages"/>; a resource acquired
/// for a draw that never committed is tagged by the next commit, which is
/// conservative but safe.</summary>
private static void TagSnapshotResources(nint commandBuffer)
{
if (_pendingSnapshotResources.Count == 0)
{
return;
}
foreach (var resource in _pendingSnapshotResources)
{
resource.LastCommandBuffer = MetalNative.Send(
commandBuffer, MetalNative.Selector("retain"));
_retiredSnapshotResources.Add(resource);
}
_pendingSnapshotResources.Clear();
}
}
@@ -1,179 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Concurrent;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Gpu.Metal;
// Draw textures decoded from guest memory are cached across draws keyed by
// their full descriptor identity, mirroring the Vulkan presenter's texture
// cache: once an identity is marked cached, the AGC submit thread skips the
// guest-memory read/detile/copy entirely (shipping empty texels) and the
// render thread serves the cached MTLTexture — for scenes that sample large
// textures every draw, that per-draw copy dominated both allocation churn
// and CPU time. GuestImageWriteTracker write-protects the source pages, so
// a guest CPU write dirties the address and the entry is evicted at the next
// drain; the following draw ships fresh texels and re-populates the cache.
internal static partial class MetalVideoPresenter
{
private const int MaxCachedDrawTextures = 2048;
/// <summary>Render-thread-only cache of decoded draw textures; each value
/// holds one retain. Committed command buffers retain the textures they
/// reference, so eviction releases immediately without a GPU drain.</summary>
private static readonly Dictionary<TextureContentIdentity, nint> _drawTextureCache = new();
/// <summary>Identities the AGC submit thread may skip texel copies for.
/// Read from the submit thread, written by the render thread.</summary>
private static readonly ConcurrentDictionary<TextureContentIdentity, byte> _cachedDrawTextureIdentities = new();
internal static bool IsTextureContentCached(in TextureContentIdentity identity) =>
_cachedDrawTextureIdentities.ContainsKey(identity);
/// <summary>Builds the same identity the AGC layer checks before skipping
/// a texel copy; the two must agree field-for-field or skips and cache
/// entries would never line up.</summary>
private static TextureContentIdentity GetDrawTextureIdentity(GuestDrawTexture texture) => new(
texture.Address,
texture.Width,
texture.Height,
texture.Format,
texture.NumberType,
texture.DstSelect,
texture.TileMode,
texture.Pitch,
texture.Sampler);
/// <summary>Caching requires the write tracker: without page protection a
/// guest CPU write would never evict the entry and draws would sample
/// stale texels forever. Storage textures are shader-writable on the GPU,
/// so their content identity is not stable either.</summary>
private static bool IsCacheableDrawTexture(GuestDrawTexture texture) =>
GuestImageWriteTracker.Enabled &&
texture.Address != 0 &&
!texture.IsStorage &&
!texture.IsFallback;
private static bool TryGetCachedDrawTexture(GuestDrawTexture texture, out nint handle) =>
_drawTextureCache.TryGetValue(GetDrawTextureIdentity(texture), out handle);
private static void CacheDrawTexture(GuestDrawTexture texture, nint handle)
{
var key = GetDrawTextureIdentity(texture);
if (_drawTextureCache.Remove(key, out var previous))
{
MetalNative.SendVoid(previous, MetalNative.Selector("release"));
}
_ = MetalNative.Send(handle, MetalNative.Selector("retain"));
_drawTextureCache[key] = handle;
_cachedDrawTextureIdentities[key] = 0;
GuestImageWriteTracker.Track(
texture.Address,
(ulong)texture.RgbaPixels.Length,
Volatile.Read(ref _executingGuestWorkSequence),
"metal.texture-cache");
}
/// <summary>Runs once per drain, before any queued draw executes: a draw
/// whose texels the submit thread skipped must never resolve to an entry
/// the guest has since rewritten.</summary>
private static void EvictDirtyCachedDrawTextures()
{
if (_drawTextureCache.Count == 0)
{
return;
}
// Evict by address rather than by identity: several identities can
// share one source address (same texels, different samplers), and
// ConsumeDirty clears the flag on first read — evicting only the
// first identity would leave the others sampling stale texels.
HashSet<ulong>? dirtyAddresses = null;
foreach (var entry in _drawTextureCache)
{
if (dirtyAddresses is not null && dirtyAddresses.Contains(entry.Key.Address))
{
continue;
}
if (GuestImageWriteTracker.ConsumeDirty(entry.Key.Address))
{
(dirtyAddresses ??= []).Add(entry.Key.Address);
}
}
if (dirtyAddresses is null && _drawTextureCache.Count <= MaxCachedDrawTextures)
{
return;
}
if (_drawTextureCache.Count > MaxCachedDrawTextures)
{
foreach (var entry in _drawTextureCache)
{
MetalNative.SendVoid(entry.Value, MetalNative.Selector("release"));
}
_drawTextureCache.Clear();
_cachedDrawTextureIdentities.Clear();
return;
}
List<TextureContentIdentity>? evicted = null;
foreach (var entry in _drawTextureCache)
{
if (dirtyAddresses!.Contains(entry.Key.Address))
{
(evicted ??= []).Add(entry.Key);
}
}
if (evicted is not null)
{
foreach (var key in evicted)
{
if (_drawTextureCache.Remove(key, out var handle))
{
_cachedDrawTextureIdentities.TryRemove(key, out _);
MetalNative.SendVoid(handle, MetalNative.Selector("release"));
}
}
}
foreach (var address in dirtyAddresses!)
{
GuestImageWriteTracker.Rearm(address);
}
}
/// <summary>Self-heal for the skip/eviction race: the submit thread saw a
/// cached identity and skipped the copy, but the entry was evicted before
/// this draw executed. Read the texels directly rather than rendering a
/// fallback texture for the frame, sized with the same block-aware math
/// the draw path expects.</summary>
private static byte[]? TryReadGuestDrawTexturePixels(GuestDrawTexture texture)
{
var memory = _guestMemory;
if (memory is null || texture.Address == 0)
{
return null;
}
var width = Math.Max(texture.Width, 1u);
var height = Math.Max(texture.Height, 1u);
var rowLength = texture.TileMode == 0
? Math.Max(texture.Pitch, width)
: width;
var format = MetalGuestFormats.DecodeTextureFormat(texture.Format, texture.NumberType);
var byteCount = MetalGuestFormats.GetTextureByteCount(format, rowLength, height);
if (byteCount == 0 || byteCount > int.MaxValue)
{
return null;
}
var pixels = new byte[(int)byteCount];
return memory.TryRead(texture.Address, pixels) ? pixels : null;
}
}
@@ -1,162 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.Gpu.Metal;
// Per-draw upload data (guest global buffers, uniforms, vertex and index
// bytes) bump-allocates from shared-storage arena pages bound by offset,
// instead of creating one MTLBuffer and one managed copy per binding per
// draw — which dominated allocation churn (hundreds of MB/s) and held the
// guest flip rate well under the display rate. Pages recycle once the last
// command buffer that referenced them reports completion; everything here
// runs on the render thread, so no state is locked.
internal static partial class MetalVideoPresenter
{
private const int UploadPageBytes = 8 * 1024 * 1024;
// Superset of every Metal bind-offset alignment rule (constant address
// space on Intel Macs is the strictest at 256), and conveniently the
// guest storage-buffer alignment the shader bias contract assumes.
private const int UploadAlignment = 256;
private sealed class UploadPage
{
public nint Buffer;
public nint Contents;
public int Capacity;
public int Offset;
/// <summary>Retained handle of the last command buffer that consumed
/// data from this page; the page is reusable once it completes.</summary>
public nint LastCommandBuffer;
/// <summary>Stamp of the last TagUploadPages call that saw this page,
/// so a commit only re-tags pages it actually touched.</summary>
public int TouchStamp;
}
private static readonly List<UploadPage> _retiredUploadPages = [];
private static readonly Stack<UploadPage> _freeUploadPages = new();
private static readonly List<UploadPage> _touchedUploadPages = [];
private static UploadPage? _currentUploadPage;
private static int _uploadTouchStamp;
/// <summary>Returns completed pages to the free stack. Called once per
/// render-loop drain; completion is polled (command buffer status) rather
/// than block-based so the ObjC interop stays block-free.</summary>
private static void RecycleCompletedUploadPages()
{
for (var index = _retiredUploadPages.Count - 1; index >= 0; index--)
{
var page = _retiredUploadPages[index];
if (page.LastCommandBuffer != 0)
{
// MTLCommandBufferStatus: Completed = 4, Error = 5.
var status = MetalNative.Send(
page.LastCommandBuffer, MetalNative.Selector("status"));
if (status < 4)
{
continue;
}
MetalNative.SendVoid(page.LastCommandBuffer, MetalNative.Selector("release"));
page.LastCommandBuffer = 0;
}
_retiredUploadPages.RemoveAt(index);
if (page.Capacity == UploadPageBytes)
{
page.Offset = 0;
_freeUploadPages.Push(page);
}
else
{
// Oversized one-off allocation; not worth pooling.
MetalNative.SendVoid(page.Buffer, MetalNative.Selector("release"));
}
}
}
/// <summary>Bump-allocates an aligned slice for CPU-written upload data.
/// The returned span is the slice's shared-storage memory; bind the
/// buffer at the returned offset.</summary>
private static unsafe Span<byte> AllocateUpload(
nint device,
int length,
out nint buffer,
out int offset)
{
var page = _currentUploadPage;
var aligned = page is null
? 0
: (page.Offset + UploadAlignment - 1) & ~(UploadAlignment - 1);
if (page is null || aligned + length > page.Capacity)
{
if (page is not null)
{
_retiredUploadPages.Add(page);
}
page = AcquireUploadPage(device, length);
_currentUploadPage = page;
aligned = 0;
}
if (page.TouchStamp != _uploadTouchStamp)
{
page.TouchStamp = _uploadTouchStamp;
_touchedUploadPages.Add(page);
}
buffer = page.Buffer;
offset = aligned;
page.Offset = aligned + length;
return new Span<byte>((void*)(page.Contents + aligned), length);
}
private static UploadPage AcquireUploadPage(nint device, int minimumBytes)
{
if (minimumBytes <= UploadPageBytes && _freeUploadPages.Count > 0)
{
return _freeUploadPages.Pop();
}
var capacity = Math.Max(minimumBytes, UploadPageBytes);
// Options 0 = MTLResourceStorageModeShared: CPU writes are coherent
// and write-backs read the GPU's stores after waitUntilCompleted.
var handle = MetalNative.SendNewBuffer(
device, MetalNative.Selector("newBufferWithLength:options:"), (nuint)capacity, 0);
return new UploadPage
{
Buffer = handle,
Contents = MetalNative.Send(handle, MetalNative.Selector("contents")),
Capacity = capacity,
};
}
/// <summary>Marks every page touched since the previous tag as owing its
/// lifetime to <paramref name="commandBuffer"/>. Called after each commit
/// that consumed arena data.</summary>
private static void TagUploadPages(nint commandBuffer)
{
if (_touchedUploadPages.Count == 0)
{
_uploadTouchStamp++;
return;
}
foreach (var page in _touchedUploadPages)
{
if (page.LastCommandBuffer != 0)
{
MetalNative.SendVoid(page.LastCommandBuffer, MetalNative.Selector("release"));
}
page.LastCommandBuffer = MetalNative.Send(
commandBuffer, MetalNative.Selector("retain"));
}
_touchedUploadPages.Clear();
_uploadTouchStamp++;
}
}
File diff suppressed because it is too large Load Diff
@@ -15,8 +15,6 @@ namespace SharpEmu.Libs.Gpu.Vulkan;
/// </summary>
internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
{
public string BackendName => "Vulkan";
private static readonly IGuestCompiledShader DepthOnlyFragmentShader =
new VulkanCompiledGuestShader(SpirvFixedShaders.CreateDepthOnlyFragment());
@@ -345,60 +343,6 @@ internal sealed class VulkanGuestGpuBackend : IGuestGpuBackend
return false;
}
public IDisposable EnterGuestQueue(string queueName, ulong submissionId) =>
VulkanVideoPresenter.EnterGuestQueue(queueName, submissionId);
public long SubmitOrderedGuestAction(Action action, string debugName) =>
VulkanVideoPresenter.SubmitOrderedGuestAction(action, debugName);
public long SubmitOrderedGuestFlipWait(int videoOutHandle, int displayBufferIndex) =>
VulkanVideoPresenter.SubmitOrderedGuestFlipWait(videoOutHandle, displayBufferIndex);
public bool WaitForGuestWork(long workSequence, int timeoutMilliseconds = Timeout.Infinite) =>
VulkanVideoPresenter.WaitForGuestWork(workSequence, timeoutMilliseconds);
public long CurrentGuestWorkSequenceForDiagnostics =>
VulkanVideoPresenter.CurrentGuestWorkSequenceForDiagnostics;
public bool IsGuestImageUploadKnown(ulong address, uint format, uint numberType) =>
VulkanVideoPresenter.IsGuestImageUploadKnown(address, format, numberType);
public bool GuestImageWantsInitialData(ulong address) =>
VulkanVideoPresenter.GuestImageWantsInitialData(address);
public void ProvideGuestImageInitialData(ulong address, byte[] rgbaPixels) =>
VulkanVideoPresenter.ProvideGuestImageInitialData(address, rgbaPixels);
public void SubmitGuestImageFill(ulong address, uint fillValue) =>
VulkanVideoPresenter.SubmitGuestImageFill(address, fillValue);
public void SubmitGuestImageWrite(ulong address, byte[] pixels) =>
VulkanVideoPresenter.SubmitGuestImageWrite(address, pixels);
public bool TryGetGuestImageExtent(ulong address, out uint width, out uint height, out ulong byteCount) =>
VulkanVideoPresenter.TryGetGuestImageExtent(address, out width, out height, out byteCount);
public IReadOnlyList<(ulong Address, uint Width, uint Height, ulong ByteCount)> GetGuestImageExtents() =>
VulkanVideoPresenter.GetGuestImageExtents();
public bool IsTextureContentCached(in TextureContentIdentity identity) =>
VulkanVideoPresenter.IsTextureContentCached(identity);
public void AttachGuestMemory(SharpEmu.HLE.ICpuMemory memory) =>
VulkanVideoPresenter.AttachGuestMemory(memory);
public ulong GuestStorageBufferOffsetAlignment =>
VulkanVideoPresenter.GuestStorageBufferOffsetAlignment;
public void CountShaderCompilation() =>
VulkanVideoPresenter.CountSpirvCompilation();
public (long Draws, double DrawMs, long Pipelines, long ShaderCompilations) ReadAndResetPerfCounters() =>
VulkanVideoPresenter.ReadAndResetPerfCounters();
public void RequestClose() =>
VulkanVideoPresenter.RequestClose();
private static byte[] Spirv(IGuestCompiledShader shader) =>
shader is VulkanCompiledGuestShader vulkanShader
? vulkanShader.Spirv
-21
View File
@@ -43,25 +43,4 @@ public static class ImeExports
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
// No hardware keyboard is ever connected; zero the caller's info struct so
// it reads as "not connected" rather than uninitialized stack.
[SysAbiExport(
Nid = "VkqLPArfFdc",
ExportName = "sceImeKeyboardGetInfo",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceIme")]
public static int ImeKeyboardGetInfo(CpuContext ctx)
{
var infoAddress = ctx[CpuRegister.Rsi] != 0 ? ctx[CpuRegister.Rsi] : ctx[CpuRegister.Rdi];
if (infoAddress != 0)
{
Span<byte> info = stackalloc byte[0x40];
info.Clear();
_ = ctx.Memory.TryWrite(infoAddress, info);
}
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
}
@@ -5,12 +5,14 @@ using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Text;
using SharpEmu.HLE;
using SharpEmu.Libs.Fiber;
namespace SharpEmu.Libs.Kernel;
public static class KernelEventFlagCompatExports
{
private const int MaxEventFlagNameLength = 31;
private const int HostWaitPumpMilliseconds = 1;
private const uint AttrThreadFifo = 0x01;
private const uint AttrThreadPriority = 0x02;
private const uint AttrSingle = 0x10;
@@ -23,7 +25,8 @@ public static class KernelEventFlagCompatExports
private static readonly ConcurrentDictionary<ulong, EventFlagState> _eventFlags = new();
private static long _nextEventFlagHandle = 1;
// Cached once: gating every call site avoids building the interpolated trace string when disabled.
// Cached once: gating every call site avoids building the interpolated
// trace string (and FormatFrameChain/FormatGuestWaitObject) when disabled.
private static readonly bool _traceEventFlag = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_EVENT_FLAG"), "1", StringComparison.Ordinal);
@@ -125,11 +128,11 @@ public static class KernelEventFlagCompatExports
lock (state.Gate)
{
state.Bits |= pattern;
// Wake threads parked in-place on the gate; each re-checks its pattern.
Monitor.PulseAll(state.Gate);
if (_traceEventFlag) TraceEventFlag($"set handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} ret=0x{returnRip:X16}");
}
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetEventFlagWakeKey(handle));
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
@@ -226,67 +229,134 @@ public static class KernelEventFlagCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
// A zero-microsecond timeout degrades to an instant poll because the
// deadline is already in the past.
var hostDeadlineMs = timeoutAddress != 0
? Environment.TickCount64 + (timeoutUsec == 0
? 0L
: Math.Max(1L, (timeoutUsec + 999L) / 1000L))
: long.MaxValue;
lock (state.Gate)
Monitor.Enter(state.Gate);
try
{
if (TryCompleteSatisfiedWait(ctx, state, pattern, waitMode, resultAddress, out var immediateWaitResult))
{
if (_traceEventFlag) TraceEventFlag($"poll handle=0x{handle:X16} pattern=0x{pattern:X16} mode=0x{waitMode:X2} bits=0x{state.Bits:X16} ret=0x{returnRip:X16}");
return SetReturn(ctx, immediateWaitResult);
}
// In-place block on the flag gate. Monitor.Wait releases the gate
// and parks atomically, so a concurrent SetEventFlag's PulseAll
// cannot be lost between the satisfy check and the park. On wake
// the predicate is re-evaluated (Set semantics on FreeBSD-style
// event flags: OR/AND over the bit pattern, optional clear).
state.WaitingThreads++;
var guestThreadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
if (_traceEventFlag) TraceEventFlag($"wait-block handle=0x{handle:X16} pattern=0x{pattern:X16} waiters={state.WaitingThreads} guest_thread=0x{guestThreadHandle:X16} ret=0x{returnRip:X16}");
GuestThreadBlocking.NoteBlocked(guestThreadHandle, "sceKernelWaitEventFlag");
try
{
while (true)
// Timed waits block on a deadline instead of returning TIMED_OUT
// immediately; a zero-microsecond timeout still degrades to an
// instant poll because the deadline is already in the past.
var deadline = timeoutAddress != 0
? GuestThreadExecution.ComputeDeadlineTimestamp(TimeSpan.FromMicroseconds(timeoutUsec))
: 0;
var hostDeadlineMs = timeoutAddress != 0
? Environment.TickCount64 + (timeoutUsec == 0
? 0L
: Math.Max(1L, (timeoutUsec + 999L) / 1000L))
: long.MaxValue;
var currentGuestThread = GuestThreadExecution.CurrentGuestThreadHandle;
var currentFiber = FiberExports.GetCurrentFiberAddressForDiagnostics(ctx);
var managedThread = Environment.CurrentManagedThreadId;
var blockedWaitResult = OrbisGen2Result.ORBIS_GEN2_OK;
var satisfied = false;
var requestedBlock = GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
"sceKernelWaitEventFlag",
GetEventFlagWakeKey(handle),
() =>
{
if (GuestThreadBlocking.ShutdownRequested)
if (satisfied)
{
if (timeoutAddress != 0) _ = TryWriteUInt32(ctx, timeoutAddress, 0);
_ = TryWriteResultPattern(ctx, resultAddress, state.Bits);
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
return (int)blockedWaitResult;
}
if (TryCompleteSatisfiedWait(ctx, state, pattern, waitMode, resultAddress, out var wokeResult))
{
if (timeoutAddress != 0) _ = TryWriteUInt32(ctx, timeoutAddress, 0);
if (_traceEventFlag) TraceEventFlag($"wait-wake handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} ret=0x{returnRip:X16}");
return SetReturn(ctx, wokeResult);
}
var remaining = hostDeadlineMs - Environment.TickCount64;
if (timeoutAddress != 0 && remaining <= 0)
// Deadline expiry: report timeout with the current bits.
if (timeoutAddress != 0)
{
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
_ = TryWriteResultPattern(ctx, resultAddress, state.Bits);
if (_traceEventFlag) TraceEventFlag($"wait-timeout handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} ret=0x{returnRip:X16}");
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
}
GuestThreadBlocking.Checkpoint(guestThreadHandle, state.Gate);
_ = Monitor.Wait(state.Gate, (int)Math.Min(remaining, GuestThreadBlocking.WaitSliceMilliseconds));
_ = TryWriteResultPattern(ctx, resultAddress, state.Bits);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
},
() =>
{
if (!TryPrepareBlockedWait(
ctx,
state,
pattern,
waitMode,
resultAddress,
out var preparedResult))
{
return false;
}
blockedWaitResult = preparedResult;
satisfied = true;
return true;
},
deadline);
if (_traceEventFlag) TraceEventFlag($"wait-unsatisfied handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} guest_thread=0x{currentGuestThread:X16} fiber=0x{currentFiber:X16} managed={managedThread} block={requestedBlock} ret=0x{returnRip:X16} frames={FormatFrameChain(ctx)}");
if (_traceEventFlag) TraceEventFlag($"wait-object handle=0x{handle:X16} name='{state.Name}' {FormatGuestWaitObject(ctx)}");
if (!requestedBlock)
{
var scheduler = GuestThreadExecution.Scheduler;
if (scheduler is null)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN);
}
state.WaitingThreads++;
if (_traceEventFlag) TraceEventFlag($"wait-pump handle=0x{handle:X16} pattern=0x{pattern:X16} waiters={state.WaitingThreads} guest_thread=0x{currentGuestThread:X16} fiber=0x{currentFiber:X16} managed={managedThread} ret=0x{returnRip:X16}");
var releaseWaiter = true;
try
{
while (true)
{
Monitor.Exit(state.Gate);
try
{
scheduler.Pump(ctx, "sceKernelWaitEventFlag");
}
finally
{
Monitor.Enter(state.Gate);
}
if (TryCompleteSatisfiedWait(ctx, state, pattern, waitMode, resultAddress, out var pumpedWaitResult))
{
state.WaitingThreads = Math.Max(0, state.WaitingThreads - 1);
releaseWaiter = false;
if (_traceEventFlag) TraceEventFlag($"wait-wake handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} waiters={state.WaitingThreads} ret=0x{returnRip:X16}");
return SetReturn(ctx, pumpedWaitResult);
}
var remaining = hostDeadlineMs - Environment.TickCount64;
if (timeoutAddress != 0 && remaining <= 0)
{
state.WaitingThreads = Math.Max(0, state.WaitingThreads - 1);
releaseWaiter = false;
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
_ = TryWriteResultPattern(ctx, resultAddress, state.Bits);
if (_traceEventFlag) TraceEventFlag($"wait-timeout handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} ret=0x{returnRip:X16}");
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
}
Monitor.Wait(state.Gate, (int)Math.Min(remaining, HostWaitPumpMilliseconds));
}
}
finally
{
if (releaseWaiter)
{
state.WaitingThreads = Math.Max(0, state.WaitingThreads - 1);
}
}
}
finally
{
state.WaitingThreads = Math.Max(0, state.WaitingThreads - 1);
GuestThreadBlocking.NoteUnblocked(guestThreadHandle);
}
state.WaitingThreads++;
if (_traceEventFlag) TraceEventFlag($"wait-block handle=0x{handle:X16} pattern=0x{pattern:X16} waiters={state.WaitingThreads} guest_thread=0x{currentGuestThread:X16} fiber=0x{currentFiber:X16} managed={managedThread} ret=0x{returnRip:X16}");
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
finally
{
Monitor.Exit(state.Gate);
}
}
@@ -385,6 +455,41 @@ public static class KernelEventFlagCompatExports
return true;
}
private static bool TryPrepareBlockedWait(
CpuContext ctx,
EventFlagState state,
ulong pattern,
uint waitMode,
ulong resultAddress,
out OrbisGen2Result result)
{
lock (state.Gate)
{
result = OrbisGen2Result.ORBIS_GEN2_OK;
if (!IsSatisfied(state.Bits, pattern, waitMode))
{
return false;
}
if (!TryWriteResultPattern(ctx, resultAddress, state.Bits))
{
result = OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
else
{
ApplyClearMode(state, pattern, waitMode);
}
state.WaitingThreads = Math.Max(0, state.WaitingThreads - 1);
if (_traceEventFlag) TraceEventFlag(
$"wait-wake pattern=0x{pattern:X16} mode=0x{waitMode:X2} bits=0x{state.Bits:X16} waiters={state.WaitingThreads}");
return true;
}
}
private static string GetEventFlagWakeKey(ulong handle) =>
$"event_flag:0x{handle:X16}";
private static bool TryWriteResultPattern(CpuContext ctx, ulong address, ulong bits) =>
address == 0 || ctx.TryWriteUInt64(address, bits);
@@ -414,6 +519,19 @@ public static class KernelEventFlagCompatExports
return true;
}
private static bool TryReadByte(CpuContext ctx, ulong address, out byte value)
{
Span<byte> buffer = stackalloc byte[1];
if (!ctx.Memory.TryRead(address, buffer))
{
value = 0;
return false;
}
value = buffer[0];
return true;
}
private static bool TryWriteUInt32(CpuContext ctx, ulong address, uint value)
{
Span<byte> buffer = stackalloc byte[sizeof(uint)];
@@ -466,4 +584,95 @@ public static class KernelEventFlagCompatExports
? frame.ReturnRip
: 0UL;
private static string FormatFrameChain(CpuContext ctx)
{
Span<ulong> returns = stackalloc ulong[4];
var count = 0;
var frame = ctx[CpuRegister.Rbp];
for (var index = 0; index < returns.Length && frame != 0; index++)
{
if (!ctx.TryReadUInt64(frame, out var nextFrame) ||
!ctx.TryReadUInt64(frame + sizeof(ulong), out var returnAddress))
{
break;
}
returns[count++] = returnAddress;
if (nextFrame <= frame)
{
break;
}
frame = nextFrame;
}
return count switch
{
0 => "none",
1 => $"0x{returns[0]:X16}",
2 => $"0x{returns[0]:X16},0x{returns[1]:X16}",
3 => $"0x{returns[0]:X16},0x{returns[1]:X16},0x{returns[2]:X16}",
_ => $"0x{returns[0]:X16},0x{returns[1]:X16},0x{returns[2]:X16},0x{returns[3]:X16}",
};
}
private static string FormatGuestWaitObject(CpuContext ctx)
{
var r12 = ctx[CpuRegister.R12];
var r13 = ctx[CpuRegister.R13];
var objectAddress = r12 != 0
? r12
: r13 >= 0xA8
? r13 - 0xA8
: 0;
var builder = new StringBuilder(256);
builder.Append($"r12=0x{r12:X16} r13=0x{r13:X16}");
if (objectAddress == 0)
{
return builder.ToString();
}
builder.Append($" obj=0x{objectAddress:X16}");
AppendUInt32(builder, ctx, objectAddress + 0x58, "o58");
AppendUInt32(builder, ctx, objectAddress + 0x5C, "o5C");
AppendUInt64(builder, ctx, objectAddress + 0x60, "o60");
AppendByte(builder, ctx, objectAddress + 0x6C, "state6C");
AppendByte(builder, ctx, objectAddress + 0x6D, "o6D");
AppendByte(builder, ctx, objectAddress + 0xA0, "waitA0");
AppendByte(builder, ctx, objectAddress + 0xA1, "stateA1");
AppendByte(builder, ctx, objectAddress + 0xA2, "oA2");
AppendUInt64(builder, ctx, objectAddress + 0xA8, "eventA8");
if (r13 != 0)
{
AppendUInt64(builder, ctx, r13, "r13_0");
AppendUInt64(builder, ctx, r13 + 8, "r13_8");
}
return builder.ToString();
}
private static void AppendByte(StringBuilder builder, CpuContext ctx, ulong address, string name)
{
if (TryReadByte(ctx, address, out var value))
{
builder.Append($" {name}=0x{value:X2}");
}
}
private static void AppendUInt32(StringBuilder builder, CpuContext ctx, ulong address, string name)
{
if (TryReadUInt32(ctx, address, out var value))
{
builder.Append($" {name}=0x{value:X8}");
}
}
private static void AppendUInt64(StringBuilder builder, CpuContext ctx, ulong address, string name)
{
if (TryReadUInt64(ctx, address, out var value))
{
builder.Append($" {name}=0x{value:X16}");
}
}
}
@@ -93,6 +93,19 @@ public static class KernelEventQueueCompatExports
}
}
private sealed class EqueueWaiter : IGuestThreadBlockWaiter
{
public required CpuContext Ctx { get; init; }
public required ulong Handle { get; init; }
public required ulong EventsAddress { get; init; }
public required int EventCapacity { get; init; }
public required ulong OutCountAddress { get; init; }
public int Resume() => ResumeWaitEqueue(Ctx, Handle, EventsAddress, EventCapacity, OutCountAddress);
public bool TryWake() => HasPendingEvents(Handle);
}
[SysAbiExport(
Nid = "D0OdFMjp46I",
ExportName = "sceKernelCreateEqueue",
@@ -136,10 +149,10 @@ public static class KernelEventQueueCompatExports
_eventQueues.Remove(handle);
_pendingEvents.Remove(handle);
_registeredEvents.Remove(handle);
// Wake any thread parked on this queue so it observes the deletion.
Monitor.PulseAll(_eventQueueGate);
}
_wakeKeys.TryRemove(handle, out _);
TraceEventQueue(ctx, "delete", handle);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -399,81 +412,59 @@ public static class KernelEventQueueCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
// No events ready: block this host thread in place on the queue gate.
// Monitor.Wait releases the gate and parks atomically, so an
// EnqueueEvent/TriggerDisplayEvent PulseAll issued the instant after
// the emptiness check cannot be lost. kqueue/kevent semantics: sleep
// until an event matching a registration is delivered or the timeout
// (usec, infinite when the arg pointer is null) lapses; a zero timeout
// degrades to an instant poll.
long deadline;
if (timeoutAddress == 0)
{
deadline = long.MaxValue;
}
else if (timeoutUsec == 0)
{
deadline = 0;
}
else
{
deadline = Environment.TickCount64 + Math.Max(1L, timeoutUsec / 1000L);
}
TraceEventQueue(ctx, "wait-block", handle);
var guestThreadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
GuestThreadBlocking.NoteBlocked(guestThreadHandle, "sceKernelWaitEqueue");
try
{
lock (_eventQueueGate)
{
while (true)
if (timeoutAddress == 0 &&
GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
"sceKernelWaitEqueue",
GetEventQueueWakeKey(handle),
new EqueueWaiter
{
if ((_pendingEvents.TryGetValue(handle, out var queue) && queue.Count != 0) ||
!_eventQueues.Contains(handle) ||
GuestThreadBlocking.ShutdownRequested)
{
break;
}
var remaining = deadline - Environment.TickCount64;
if (timeoutAddress != 0 && remaining <= 0)
{
break;
}
var slice = timeoutAddress == 0
? GuestThreadBlocking.WaitSliceMilliseconds
: (int)Math.Min(remaining, GuestThreadBlocking.WaitSliceMilliseconds);
GuestThreadBlocking.Checkpoint(guestThreadHandle, _eventQueueGate);
_ = Monitor.Wait(_eventQueueGate, slice);
}
}
}
finally
Ctx = ctx,
Handle = handle,
EventsAddress = eventsAddress,
EventCapacity = eventCapacity,
OutCountAddress = outCountAddress,
}))
{
GuestThreadBlocking.NoteUnblocked(guestThreadHandle);
}
deliveredCount = DequeueEvents(ctx, handle, eventsAddress, eventCapacity);
if (outCountAddress != 0 && !TryWriteUInt32(ctx, outCountAddress, (uint)deliveredCount))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
if (deliveredCount > 0)
{
TraceEventQueue(ctx, "wait-deliver", handle);
TraceEventQueue(ctx, "wait-block", handle);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (timeoutAddress != 0)
if (timeoutAddress != 0 && ctx.TryReadUInt64(timeoutAddress, out var timeoutRaw))
{
var timeoutMicros = timeoutRaw & 0xFFFF_FFFFUL;
var deadline = Environment.TickCount64 +
Math.Max(1L, (long)Math.Min(timeoutMicros / 1000, int.MaxValue));
lock (_eventQueueGate)
{
while (!HasPendingEvents(handle))
{
var remaining = deadline - Environment.TickCount64;
if (remaining <= 0)
{
break;
}
Monitor.Wait(_eventQueueGate, (int)Math.Min(remaining, 100));
}
}
deliveredCount = DequeueEvents(ctx, handle, eventsAddress, eventCapacity);
if (outCountAddress != 0 && !TryWriteUInt32(ctx, outCountAddress, (uint)deliveredCount))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
if (deliveredCount > 0)
{
TraceEventQueue(ctx, "wait-timed-deliver", handle);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
TraceEventQueue(ctx, "wait-timeout", handle);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
}
// Reached only on queue deletion or teardown; the guest sees zero events.
TraceEventQueue(ctx, "wait", handle);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -807,6 +798,32 @@ public static class KernelEventQueueCompatExports
return triggered;
}
private static int ResumeWaitEqueue(
CpuContext ctx,
ulong handle,
ulong eventsAddress,
int eventCapacity,
ulong outCountAddress)
{
var deliveredCount = DequeueEvents(ctx, handle, eventsAddress, eventCapacity);
if (outCountAddress != 0 && !TryWriteUInt32(ctx, outCountAddress, (uint)deliveredCount))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
return deliveredCount > 0
? (int)OrbisGen2Result.ORBIS_GEN2_OK
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
}
private static bool HasPendingEvents(ulong handle)
{
lock (_eventQueueGate)
{
return _pendingEvents.TryGetValue(handle, out var events) && events.Count != 0;
}
}
private static void QueueOrUpdateEvent(
KernelEventDeque queue,
KernelQueuedEvent queuedEvent)
@@ -824,16 +841,16 @@ public static class KernelEventQueueCompatExports
};
}
// Wake threads parked in-place on the queue gate; each re-checks for a
// matching pending event. The handle is unused (all queues share one gate)
// but kept in the signature so call sites read intent-fully.
// Wake keys are formatted once per handle: WakeEventQueue runs on every event
// enqueue (vblank/flip edges included), so formatting there is steady string churn.
private static readonly ConcurrentDictionary<ulong, string> _wakeKeys = new();
private static string GetEventQueueWakeKey(ulong handle) =>
_wakeKeys.GetOrAdd(handle, static h => $"sceKernelWaitEqueue:{h:X16}");
private static void WakeEventQueue(ulong handle)
{
_ = handle;
lock (_eventQueueGate)
{
Monitor.PulseAll(_eventQueueGate);
}
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetEventQueueWakeKey(handle));
}
private static int DequeueEvents(CpuContext ctx, ulong handle, ulong eventsAddress, int eventCapacity)
@@ -224,21 +224,6 @@ public static partial class KernelMemoryCompatExports
}
}
/// <summary>Removes a guest mount registered by <see cref="RegisterGuestPathMount"/>.</summary>
public static bool UnregisterGuestPathMount(string guestMountPoint)
{
var normalizedMountPoint = NormalizeGuestStatCachePath(guestMountPoint);
if (normalizedMountPoint is null)
{
return false;
}
lock (_guestMountGate)
{
return _guestMounts.Remove(normalizedMountPoint);
}
}
internal static bool TryAllocateHleData(
CpuContext ctx,
ulong length,
@@ -1111,7 +1096,6 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
ctx[CpuRegister.Rax] = destination;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -1819,15 +1803,6 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
// POSIX alias; same Orbis result convention as the other posix-named
// file exports in this module (mkdir/rmdir/open).
[SysAbiExport(
Nid = "VAzswvTOCzI",
ExportName = "unlink",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixUnlink(CpuContext ctx) => KernelUnlink(ctx);
[SysAbiExport(
Nid = "AUXVxWeJU-A",
ExportName = "sceKernelUnlink",
@@ -6019,7 +5994,7 @@ public static partial class KernelMemoryCompatExports
return highWaterMark;
}
private static unsafe bool TryReadHostMemory(ulong address, Span<byte> destination)
private static bool TryReadHostMemory(ulong address, Span<byte> destination)
{
if (destination.IsEmpty || !IsHostRangeAccessible(address, (ulong)destination.Length, writeAccess: false))
{
@@ -6028,7 +6003,9 @@ public static partial class KernelMemoryCompatExports
try
{
new ReadOnlySpan<byte>((void*)address, destination.Length).CopyTo(destination);
var temporary = new byte[destination.Length];
Marshal.Copy((nint)address, temporary, 0, temporary.Length);
temporary.AsSpan().CopyTo(destination);
return true;
}
catch
@@ -6068,41 +6045,6 @@ 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)
@@ -6382,7 +6324,7 @@ public static partial class KernelMemoryCompatExports
return value != 0 && (value & (value - 1)) == 0;
}
private static unsafe bool TryWriteHostMemory(ulong address, ReadOnlySpan<byte> source)
private static bool TryWriteHostMemory(ulong address, ReadOnlySpan<byte> source)
{
if (source.IsEmpty || !IsHostRangeAccessible(address, (ulong)source.Length, writeAccess: true))
{
@@ -6391,7 +6333,8 @@ public static partial class KernelMemoryCompatExports
try
{
source.CopyTo(new Span<byte>((void*)address, source.Length));
var temporary = source.ToArray();
Marshal.Copy(temporary, 0, (nint)address, temporary.Length);
return true;
}
catch
@@ -6418,37 +6361,20 @@ public static partial class KernelMemoryCompatExports
return false;
}
var endAddress = address + length - 1;
var currentAddress = address;
while (currentAddress <= endAddress)
if (!TryQueryHostPage(address, out var startInfo) || !HasRequiredProtection(startInfo.Protect, writeAccess))
{
if (!TryQueryHostPage(currentAddress, out var info) ||
!HasRequiredProtection(info.Protect, writeAccess))
{
return false;
}
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 endAddress = address + length - 1;
if (endAddress == address)
{
return true;
}
var regionEnd = regionBase + regionSize;
if (regionEnd <= currentAddress)
{
return false;
}
if (regionEnd > endAddress)
{
return true;
}
currentAddress = regionEnd;
if (!TryQueryHostPage(endAddress, out var endInfo) || !HasRequiredProtection(endInfo.Protect, writeAccess))
{
return false;
}
return true;
@@ -37,35 +37,55 @@ public static class KernelPthreadCompatExports
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREAD_CONDS"), "1", StringComparison.Ordinal);
private static readonly HashSet<ulong>? _tracePthreadMutexFilter = ParseTraceAddressFilter(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_PTHREAD_MUTEX_FILTER"));
private static long _nextSynchronizationWaiterId;
// Blocking model: waiters block their own host thread in place via
// Monitor.Wait on the state object (mutexes) or SyncRoot (condvars).
// Block-and-wake is therefore atomic — no waiter queues, wake keys, or
// continuation hand-offs, and no lost-wakeup window between a thread
// deciding to block and registering as blocked.
private sealed class PthreadMutexState
{
public ulong OwnerThreadId { get; set; }
public int RecursionCount { get; set; }
public int Type { get; set; } = MutexTypeErrorCheck;
public int Protocol { get; set; }
// Threads currently blocked in PthreadMutexLockCore; destroy reports BUSY while nonzero.
public int WaiterCount { get; set; }
public LinkedList<PthreadMutexWaiter> Waiters { get; } = new();
}
private sealed class PthreadMutexWaiter
{
public required ulong ThreadId { get; init; }
public required string WakeKey { get; init; }
public required bool Cooperative { get; init; }
public LinkedListNode<PthreadMutexWaiter>? Node { get; set; }
public int Granted;
}
private sealed class PthreadCondState
{
public object SyncRoot { get; } = new();
public LinkedList<PthreadCondWaiter> WaiterQueue { get; } = new();
public ulong SignalEpoch { get; set; }
public int Waiters { get; set; }
// Signals produced but not yet consumed by a waiter. A signal only
// increments this when an unserved waiter exists (POSIX: signaling an
// empty condvar is a no-op), so stale signals cannot accumulate.
public int SignalsPending { get; set; }
}
private sealed class PthreadCondWaiter
{
public required ulong ThreadId { get; init; }
public required PthreadMutexState MutexState { get; init; }
public required string WakeKey { get; init; }
public required bool Cooperative { get; init; }
public bool PosixErrors { get; init; }
public LinkedListNode<PthreadCondWaiter>? Node { get; set; }
public PthreadMutexWaiter? MutexWaiter { get; set; }
public Timer? TimeoutTimer { get; set; }
// 0 = waiting, 1 = signaled, 2 = timed out.
public int CompletionState { get; set; }
}
private readonly record struct PthreadMutexAttrState(int Type, int Protocol);
static KernelPthreadCompatExports()
{
RunSynchronizationSelfChecks();
}
[SysAbiExport(
Nid = "aI+OeCz8xrQ",
ExportName = "scePthreadSelf",
@@ -119,13 +139,6 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "9vyP6Z7bqzc",
ExportName = "pthread_rename_np",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixPthreadRenameNp(CpuContext ctx) => PthreadRename(ctx);
[SysAbiExport(
Nid = "GBUY7ywdULE",
ExportName = "scePthreadRename",
@@ -610,7 +623,7 @@ public static class KernelPthreadCompatExports
lock (state)
{
if (state.OwnerThreadId != 0 || state.RecursionCount != 0 || state.WaiterCount != 0)
if (state.OwnerThreadId != 0 || state.RecursionCount != 0 || state.Waiters.Count != 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
@@ -640,6 +653,10 @@ public static class KernelPthreadCompatExports
}
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
var canCooperativelyBlock = !tryOnly &&
GuestThreadExecution.IsGuestThread &&
GuestThreadExecution.TryGetCurrentImportCallFrame(out _);
PthreadMutexWaiter? waiter = null;
lock (state)
{
if (state.OwnerThreadId == currentThreadId)
@@ -679,7 +696,7 @@ public static class KernelPthreadCompatExports
}
}
if (state.OwnerThreadId == 0)
if (state.OwnerThreadId == 0 && state.Waiters.Count == 0)
{
state.OwnerThreadId = currentThreadId;
state.RecursionCount = 1;
@@ -693,39 +710,24 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
// Contended: block this host thread in place until the owner
// releases. Monitor.Wait atomically releases the state lock and
// parks, so an unlock's PulseAll cannot be missed. Waits are
// sliced only so teardown can unwind parked threads.
waiter = EnqueueMutexWaiterLocked(state, currentThreadId, canCooperativelyBlock);
}
if (canCooperativelyBlock && waiter is not null &&
GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
"pthread_mutex_lock",
waiter.WakeKey,
() => CompleteBlockedMutexLock(ctx, mutexAddress, resolvedAddress, state, waiter),
() => TryGrantBlockedMutexLock(ctx, mutexAddress, resolvedAddress, state, waiter)))
{
TracePthreadMutex(ctx, "lock-block", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
GuestThreadBlocking.NoteBlocked(currentThreadId, "pthread_mutex_lock");
state.WaiterCount++;
try
{
while (state.OwnerThreadId != 0)
{
if (GuestThreadBlocking.ShutdownRequested)
{
TracePthreadMutex(ctx, "lock-shutdown", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN;
}
GuestThreadBlocking.Checkpoint(currentThreadId, state);
_ = Monitor.Wait(state, GuestThreadBlocking.WaitSliceMilliseconds);
}
state.OwnerThreadId = currentThreadId;
state.RecursionCount = 1;
}
finally
{
state.WaiterCount--;
GuestThreadBlocking.NoteUnblocked(currentThreadId);
}
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
var hostResult = WaitForHostMutexLock(state, waiter!);
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, hostResult);
return hostResult;
}
private static int PthreadMutexUnlockCore(CpuContext ctx, ulong mutexAddress, bool requireOwner)
@@ -742,6 +744,7 @@ public static class KernelPthreadCompatExports
}
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
string? nextWakeKey = null;
lock (state)
{
if (state.RecursionCount <= 0)
@@ -760,13 +763,18 @@ public static class KernelPthreadCompatExports
if (state.RecursionCount == 0)
{
state.OwnerThreadId = 0;
if (state.WaiterCount != 0)
{
Monitor.PulseAll(state);
}
nextWakeKey = state.Waiters.First?.Value.Cooperative == true
? state.Waiters.First.Value.WakeKey
: null;
Monitor.PulseAll(state);
}
}
if (nextWakeKey is not null)
{
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWakeKey, 1);
}
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -1183,7 +1191,7 @@ public static class KernelPthreadCompatExports
lock (state.SyncRoot)
{
if (state.Waiters != 0)
if (state.WaiterQueue.Count != 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
@@ -1226,21 +1234,7 @@ public static class KernelPthreadCompatExports
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
lock (mutexState)
{
if (mutexState.OwnerThreadId == 0 && mutexState.RecursionCount == 0)
{
// The guest holds the mutex through a path our host-side tracking
// never observed — most commonly libkernel's uncontended userspace
// fast-path, which locks the mutex word directly without an HLE
// call. Real pthread_cond_wait requires the caller to own the
// mutex and does not verify it for normal mutexes, so returning
// EPERM here is wrong: it spins the guest and, worse, leaves the
// mutex held (the unlock below is skipped), wedging every thread
// that later blocks on pthread_mutex_lock. Adopt ownership so the
// unlock/wait/re-lock cycle is balanced and releases the mutex.
mutexState.OwnerThreadId = currentThreadId;
mutexState.RecursionCount = 1;
}
else if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
{
return mutexState.OwnerThreadId == currentThreadId
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT
@@ -1248,80 +1242,94 @@ public static class KernelPthreadCompatExports
}
}
var signaled = false;
var cooperative = GuestThreadExecution.IsGuestThread &&
GuestThreadExecution.TryGetCurrentImportCallFrame(out _);
var waiter = new PthreadCondWaiter
{
ThreadId = currentThreadId,
MutexState = mutexState,
Cooperative = cooperative,
PosixErrors = posixErrors,
WakeKey = cooperative
? $"pthread_cond_waiter:{Interlocked.Increment(ref _nextSynchronizationWaiterId)}"
: string.Empty,
};
lock (state.SyncRoot)
{
waiter.Node = state.WaiterQueue.AddLast(waiter);
state.Waiters++;
TracePthreadCond("wait-enter", condAddress, mutexAddress, state, timed, (int)OrbisGen2Result.ORBIS_GEN2_OK);
// POSIX atomicity: we are registered as a waiter (Waiters++ under
// SyncRoot) before the mutex is released, so a signal issued the
// instant the mutex unlocks already counts us and lands in
// SignalsPending — checked before the first Monitor.Wait. No
// window exists where a wake can be lost.
var unlockResult = PthreadMutexUnlockCore(ctx, mutexAddress, requireOwner: true);
if (unlockResult != (int)OrbisGen2Result.ORBIS_GEN2_OK)
{
state.Waiters--;
RemoveCondWaiterLocked(state, waiter);
TracePthreadCond("wait-unlock-fail", condAddress, mutexAddress, state, timed, unlockResult);
return unlockResult;
}
var deadline = timed
? GuestThreadExecution.ComputeDeadlineTimestamp(GetCondWaitTimeout(timeoutUsec))
: long.MaxValue;
GuestThreadBlocking.NoteBlocked(currentThreadId, timed ? "pthread_cond_timedwait" : "pthread_cond_wait");
try
if (cooperative && timed)
{
while (state.SignalsPending == 0 && !GuestThreadBlocking.ShutdownRequested)
{
var remaining = timed
? GetRemainingTimeout(deadline)
: TimeSpan.FromMilliseconds(GuestThreadBlocking.WaitSliceMilliseconds);
if (timed && remaining <= TimeSpan.Zero)
waiter.TimeoutTimer = new Timer(
static callbackState =>
{
break;
}
if (remaining > TimeSpan.FromMilliseconds(GuestThreadBlocking.WaitSliceMilliseconds))
{
remaining = TimeSpan.FromMilliseconds(GuestThreadBlocking.WaitSliceMilliseconds);
}
GuestThreadBlocking.Checkpoint(currentThreadId, state.SyncRoot);
_ = Monitor.Wait(state.SyncRoot, remaining);
}
}
finally
{
GuestThreadBlocking.NoteUnblocked(currentThreadId);
}
if (state.SignalsPending > 0)
{
state.SignalsPending--;
signaled = true;
}
state.Waiters--;
if (state.SignalsPending > state.Waiters)
{
// A timed-out waiter left a signal unconsumed with nobody
// remaining to take it; drop it so a future wait does not
// observe a phantom wake (signals on an empty condvar are
// no-ops on real hardware).
state.SignalsPending = state.Waiters;
var (condState, condWaiter) = ((PthreadCondState, PthreadCondWaiter))callbackState!;
CompleteCondWaiter(condState, condWaiter, timedOut: true);
},
(state, waiter),
GetCondWaitTimeout(timeoutUsec),
Timeout.InfiniteTimeSpan);
}
}
// POSIX guarantees the mutex is re-acquired on every return path,
// signaled or timed out. Blocks in place like any other locker.
_ = PthreadMutexLockCore(ctx, mutexAddress, tryOnly: false);
if (cooperative &&
GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
timed ? "pthread_cond_timedwait" : "pthread_cond_wait",
waiter.WakeKey,
() => CompleteBlockedCondWait(ctx, condAddress, mutexAddress, state, waiter),
() => TryGrantCondWaiterMutex(waiter)))
{
TracePthreadCond("wait-block", condAddress, mutexAddress, state, timed, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
var waitResult = signaled || !timed
? (int)OrbisGen2Result.ORBIS_GEN2_OK
: CondTimedOutResult(posixErrors);
TracePthreadCond(signaled ? "wait-exit" : "wait-exit-timeout", condAddress, mutexAddress, state, timed, waitResult);
// Non-guest callers have no resumable CPU continuation. Park only
// those host-side compatibility callers, preserving the same FIFO
// mutex reacquisition rules as cooperative guest waiters.
lock (state.SyncRoot)
{
var deadline = timed
? GuestThreadExecution.ComputeDeadlineTimestamp(GetCondWaitTimeout(timeoutUsec))
: long.MaxValue;
while (waiter.CompletionState == 0)
{
if (!timed)
{
Monitor.Wait(state.SyncRoot);
continue;
}
var remaining = GetRemainingTimeout(deadline);
if (remaining <= TimeSpan.Zero || !Monitor.Wait(state.SyncRoot, remaining))
{
CompleteCondWaiterLocked(state, waiter, timedOut: true);
break;
}
}
}
if (waiter.MutexWaiter is null)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
_ = WaitForHostMutexLock(mutexState, waiter.MutexWaiter);
var waitResult = waiter.CompletionState == 2
? CondTimedOutResult(waiter)
: (int)OrbisGen2Result.ORBIS_GEN2_OK;
TracePthreadCond(waiter.CompletionState == 2 ? "wait-exit-timeout" : "wait-exit", condAddress, mutexAddress, state, timed, waitResult);
return waitResult;
}
@@ -1337,36 +1345,280 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
List<PthreadCondWaiter>? completedWaiters = null;
lock (state.SyncRoot)
{
state.SignalEpoch++;
if (broadcast)
for (var node = state.WaiterQueue.First; node is not null;)
{
state.SignalsPending = state.Waiters;
}
else if (state.SignalsPending < state.Waiters)
{
// Only count a signal an unserved waiter can consume; signaling
// an empty condvar is a no-op per POSIX.
state.SignalsPending++;
}
var next = node.Next;
var waiter = node.Value;
if (waiter.CompletionState == 0 && CompleteCondWaiterLocked(state, waiter, timedOut: false))
{
(completedWaiters ??= new List<PthreadCondWaiter>()).Add(waiter);
if (!broadcast)
{
break;
}
}
if (state.Waiters != 0)
{
Monitor.PulseAll(state.SyncRoot);
node = next;
}
TracePthreadCond(broadcast ? "broadcast" : "signal", condAddress, mutexAddress: 0, state, timed: false, (int)OrbisGen2Result.ORBIS_GEN2_OK);
}
if (completedWaiters is not null)
{
foreach (var waiter in completedWaiters)
{
WakeCooperativeWaiter(waiter);
}
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
private static int CondTimedOutResult(bool posixErrors) =>
posixErrors
private static PthreadMutexWaiter EnqueueMutexWaiterLocked(
PthreadMutexState state,
ulong threadId,
bool cooperative,
string? wakeKey = null)
{
var waiter = new PthreadMutexWaiter
{
ThreadId = threadId,
Cooperative = cooperative,
WakeKey = cooperative
? wakeKey ?? $"pthread_mutex_waiter:{Interlocked.Increment(ref _nextSynchronizationWaiterId)}"
: string.Empty,
};
waiter.Node = state.Waiters.AddLast(waiter);
return waiter;
}
[Conditional("DEBUG")]
private static void RunSynchronizationSelfChecks()
{
var mutex = new PthreadMutexState();
PthreadMutexWaiter first;
PthreadMutexWaiter second;
lock (mutex)
{
first = EnqueueMutexWaiterLocked(mutex, 0x101, cooperative: false);
second = EnqueueMutexWaiterLocked(mutex, 0x202, cooperative: false);
Debug.Assert(!TryGrantMutexWaiterLocked(mutex, second), "A mutex waiter bypassed FIFO order.");
Debug.Assert(TryGrantMutexWaiterLocked(mutex, first), "The FIFO mutex head was not granted.");
Debug.Assert(mutex.OwnerThreadId == first.ThreadId && mutex.RecursionCount == 1, "Mutex ownership was not transferred atomically.");
mutex.OwnerThreadId = 0;
mutex.RecursionCount = 0;
Debug.Assert(TryGrantMutexWaiterLocked(mutex, second), "The second mutex waiter was not granted after release.");
}
var cond = new PthreadCondState();
var condMutex = new PthreadMutexState();
var condWaiter = new PthreadCondWaiter
{
ThreadId = 0x303,
MutexState = condMutex,
WakeKey = string.Empty,
Cooperative = false,
};
lock (cond.SyncRoot)
{
condWaiter.Node = cond.WaiterQueue.AddLast(condWaiter);
cond.Waiters++;
Debug.Assert(CompleteCondWaiterLocked(cond, condWaiter, timedOut: false), "A condition waiter was not completed.");
Debug.Assert(cond.WaiterQueue.Count == 0 && cond.Waiters == 0 && condWaiter.MutexWaiter is not null, "Condition completion did not atomically queue mutex reacquisition.");
}
}
private static bool TryGrantMutexWaiterLocked(PthreadMutexState state, PthreadMutexWaiter waiter)
{
if (Volatile.Read(ref waiter.Granted) != 0)
{
return true;
}
if (state.OwnerThreadId != 0 ||
waiter.Node is null ||
!ReferenceEquals(state.Waiters.First, waiter.Node))
{
return false;
}
state.Waiters.Remove(waiter.Node);
waiter.Node = null;
state.OwnerThreadId = waiter.ThreadId;
state.RecursionCount = 1;
Volatile.Write(ref waiter.Granted, 1);
Monitor.PulseAll(state);
return true;
}
private static int WaitForHostMutexLock(PthreadMutexState state, PthreadMutexWaiter waiter)
{
lock (state)
{
while (!TryGrantMutexWaiterLocked(state, waiter))
{
Monitor.Wait(state);
}
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
private static bool TryGrantBlockedMutexLock(
CpuContext ctx,
ulong mutexAddress,
ulong resolvedAddress,
PthreadMutexState state,
PthreadMutexWaiter waiter)
{
var granted = false;
lock (state)
{
granted = TryGrantMutexWaiterLocked(state, waiter);
}
TracePthreadMutex(
ctx,
granted ? "lock-reserve" : "lock-reserve-busy",
mutexAddress,
resolvedAddress,
state,
waiter.ThreadId,
granted ? (int)OrbisGen2Result.ORBIS_GEN2_OK : (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
return granted;
}
private static int CompleteBlockedMutexLock(
CpuContext ctx,
ulong mutexAddress,
ulong resolvedAddress,
PthreadMutexState state,
PthreadMutexWaiter waiter)
{
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
if (Volatile.Read(ref waiter.Granted) == 1)
{
TracePthreadMutex(ctx, "lock-resume", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
TracePthreadMutex(ctx, "lock-resume-ungranted", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
private static bool CompleteCondWaiterLocked(
PthreadCondState state,
PthreadCondWaiter waiter,
bool timedOut)
{
if (waiter.CompletionState != 0)
{
return false;
}
waiter.CompletionState = timedOut ? 2 : 1;
RemoveCondWaiterLocked(state, waiter);
waiter.TimeoutTimer?.Dispose();
waiter.TimeoutTimer = null;
lock (waiter.MutexState)
{
waiter.MutexWaiter = EnqueueMutexWaiterLocked(
waiter.MutexState,
waiter.ThreadId,
waiter.Cooperative,
waiter.WakeKey);
}
Monitor.PulseAll(state.SyncRoot);
return true;
}
private static void CompleteCondWaiter(
PthreadCondState state,
PthreadCondWaiter waiter,
bool timedOut)
{
var completed = false;
lock (state.SyncRoot)
{
completed = CompleteCondWaiterLocked(state, waiter, timedOut);
}
if (completed)
{
WakeCooperativeWaiter(waiter);
}
}
private static void RemoveCondWaiterLocked(PthreadCondState state, PthreadCondWaiter waiter)
{
if (waiter.Node is not null)
{
state.WaiterQueue.Remove(waiter.Node);
waiter.Node = null;
state.Waiters = Math.Max(0, state.Waiters - 1);
}
}
private static bool TryGrantCondWaiterMutex(PthreadCondWaiter waiter)
{
var mutexWaiter = waiter.MutexWaiter;
if (waiter.CompletionState == 0 || mutexWaiter is null)
{
return false;
}
lock (waiter.MutexState)
{
return TryGrantMutexWaiterLocked(waiter.MutexState, mutexWaiter);
}
}
private static int CompleteBlockedCondWait(
CpuContext ctx,
ulong condAddress,
ulong mutexAddress,
PthreadCondState state,
PthreadCondWaiter waiter)
{
waiter.TimeoutTimer?.Dispose();
waiter.TimeoutTimer = null;
var result = waiter.MutexWaiter is not null &&
Volatile.Read(ref waiter.MutexWaiter.Granted) == 1
? (waiter.CompletionState == 2
? CondTimedOutResult(waiter)
: (int)OrbisGen2Result.ORBIS_GEN2_OK)
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
TracePthreadCond(
waiter.CompletionState == 2 ? "wait-resume-timeout" : "wait-resume",
condAddress,
mutexAddress,
state,
waiter.CompletionState == 2,
result);
_ = ctx;
return result;
}
private static int CondTimedOutResult(PthreadCondWaiter waiter) =>
waiter.PosixErrors
? 60 // ETIMEDOUT on Orbis/FreeBSD; pthread APIs return errno directly.
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
private static void WakeCooperativeWaiter(PthreadCondWaiter waiter)
{
if (waiter.Cooperative)
{
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(waiter.WakeKey, 1);
}
}
private static TimeSpan GetCondWaitTimeout(uint timeoutUsec)
{
if (timeoutUsec == 0)
@@ -91,6 +91,10 @@ public static class KernelPthreadExtendedCompatExports
public PthreadAttrState Attributes { get; set; } = PthreadAttrState.Default;
}
// On the outer class deliberately: a static on the nested state class gives it a type
// initializer that first runs on a guest thread and fail-fasts the CLR.
private static long _nextRwlockWakeId;
private sealed class PthreadRwlockState
{
public object SyncRoot { get; } = new();
@@ -101,6 +105,8 @@ public static class KernelPthreadExtendedCompatExports
public ulong WriterThreadId { get; set; }
public int WaitingWriters { get; set; }
// See PthreadMutexState.WakeKey.
public string WakeKey { get; } = "pthread_rwlock#" + Interlocked.Increment(ref _nextRwlockWakeId).ToString("X");
public int GetReaderCount(ulong threadId)
{
@@ -162,6 +168,17 @@ public static class KernelPthreadExtendedCompatExports
}
}
private sealed class RwlockWaiter : IGuestThreadBlockWaiter
{
public required PthreadRwlockState Rwlock { get; init; }
public required ulong ThreadId { get; init; }
public required bool Write { get; init; }
public int Resume() => (int)OrbisGen2Result.ORBIS_GEN2_OK;
public bool TryWake() => TryAcquireBlockedRwlock(Rwlock, ThreadId, Write);
}
private readonly record struct TlsKeyState(ulong Destructor);
private readonly record struct PthreadAttrState(
@@ -1167,6 +1184,7 @@ public static class KernelPthreadExtendedCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED;
}
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(rwlock.WakeKey);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -1461,30 +1479,35 @@ public static class KernelPthreadExtendedCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
// In-place block: Monitor.Wait releases SyncRoot and parks
// atomically, so an unlock's PulseAll cannot be lost. Sliced
// only so teardown can unwind parked threads.
rwlock.WaitingWriters++;
GuestThreadBlocking.NoteBlocked(currentThreadId, "pthread_rwlock_wrlock");
var transferredToScheduler = false;
try
{
if (GuestThreadExecution.IsGuestThread &&
GuestThreadExecution.TryGetCurrentImportCallFrame(out _) &&
GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
"pthread_rwlock_wrlock",
rwlock.WakeKey,
new RwlockWaiter { Rwlock = rwlock, ThreadId = currentThreadId, Write = true }))
{
transferredToScheduler = true;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
while (rwlock.WriterThreadId != 0 || rwlock.ReaderTotalCount != 0 || rwlock.CompatWriterTotalCount != 0)
{
if (GuestThreadBlocking.ShutdownRequested)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN;
}
GuestThreadBlocking.Checkpoint(currentThreadId, rwlock.SyncRoot);
_ = Monitor.Wait(rwlock.SyncRoot, GuestThreadBlocking.WaitSliceMilliseconds);
Monitor.Wait(rwlock.SyncRoot);
}
rwlock.WriterThreadId = currentThreadId;
}
finally
{
rwlock.WaitingWriters = Math.Max(0, rwlock.WaitingWriters - 1);
GuestThreadBlocking.NoteUnblocked(currentThreadId);
if (!transferredToScheduler)
{
rwlock.WaitingWriters = Math.Max(0, rwlock.WaitingWriters - 1);
}
}
}
else
@@ -1494,26 +1517,20 @@ public static class KernelPthreadExtendedCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
}
if (ReaderMustWaitForRwlock(rwlock, currentThreadId))
while (ReaderMustWaitForRwlock(rwlock, currentThreadId))
{
GuestThreadBlocking.NoteBlocked(currentThreadId, "pthread_rwlock_rdlock");
try
if (GuestThreadExecution.IsGuestThread &&
GuestThreadExecution.TryGetCurrentImportCallFrame(out _) &&
GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
"pthread_rwlock_rdlock",
rwlock.WakeKey,
new RwlockWaiter { Rwlock = rwlock, ThreadId = currentThreadId, Write = false }))
{
while (ReaderMustWaitForRwlock(rwlock, currentThreadId))
{
if (GuestThreadBlocking.ShutdownRequested)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN;
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
GuestThreadBlocking.Checkpoint(currentThreadId, rwlock.SyncRoot);
_ = Monitor.Wait(rwlock.SyncRoot, GuestThreadBlocking.WaitSliceMilliseconds);
}
}
finally
{
GuestThreadBlocking.NoteUnblocked(currentThreadId);
}
Monitor.Wait(rwlock.SyncRoot);
}
if (rwlock.WriterThreadId != 0 ||
@@ -1530,6 +1547,33 @@ public static class KernelPthreadExtendedCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
private static bool TryAcquireBlockedRwlock(PthreadRwlockState rwlock, ulong currentThreadId, bool write)
{
lock (rwlock.SyncRoot)
{
if (write)
{
if (rwlock.WriterThreadId != 0 || rwlock.ReaderTotalCount != 0 || rwlock.CompatWriterTotalCount != 0)
{
return false;
}
DetectRwlockWriterConflict(0, rwlock, currentThreadId, "wrlock-resume");
rwlock.WriterThreadId = currentThreadId;
rwlock.WaitingWriters = Math.Max(0, rwlock.WaitingWriters - 1);
return true;
}
if (ReaderMustWaitForRwlock(rwlock, currentThreadId))
{
return false;
}
rwlock.AddReader(currentThreadId);
return true;
}
}
// Call while holding lock(rwlock.SyncRoot): an existing reader/writer here means a
// writer would share the rwlock with another holder — a data race.
private static void DetectRwlockWriterConflict(ulong resolvedAddress, PthreadRwlockState rwlock, ulong currentThreadId, string site)
@@ -1560,6 +1604,8 @@ public static class KernelPthreadExtendedCompatExports
rwlock.GetReaderCount(currentThreadId) == 0;
}
private static string GetRwlockWakeKey(ulong rwlockAddress) => $"pthread_rwlock:0x{rwlockAddress:X16}";
public static string? DumpRwlockStateForStall(ulong rwlockAddress)
{
PthreadRwlockState? rwlock;
@@ -97,6 +97,8 @@ public static class KernelRuntimeCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
GuestThreadExecution.Scheduler?.Pump(ctx, "sceKernelUsleep");
if (micros < 1000)
{
// Guest worker pools use usleep(1) as a polling backoff. Do not turn
@@ -1302,30 +1304,6 @@ public static class KernelRuntimeCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
// Same (pc, flags, out-info) contract as sceKernelGetModuleInfoForUnwind,
// surfaced through libSceSysmodule on Gen5; the unwinder threads whichever
// one the module's libc was linked against.
[SysAbiExport(
Nid = "4fU5yvOkVG4",
ExportName = "sceSysmoduleGetModuleInfoForUnwind",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceSysmodule")]
public static int SysmoduleGetModuleInfoForUnwind(CpuContext ctx) => KernelGetModuleInfoForUnwind(ctx);
// libc unwinder predicate: is this PC the kernel signal-return trampoline?
// Guest signal returns do not run through a guest-visible trampoline here,
// so no PC is ever one — report false and let the frame unwind normally.
[SysAbiExport(
Nid = "crb5j7mkk1c",
ExportName = "_is_signal_return",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libc")]
public static int IsSignalReturn(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "nu4a0-arQis",
ExportName = "sceKernelAioInitializeParam",
@@ -2119,6 +2097,7 @@ public static class KernelRuntimeCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
GuestThreadExecution.Scheduler?.Pump(ctx, posix ? "nanosleep" : "sceKernelNanosleep");
var totalTicks = tvSec * TimeSpan.TicksPerSecond + Math.Max(tvNsec / 100L, 1L);
try
{
@@ -17,6 +17,8 @@ public static class KernelSemaphoreCompatExports
private sealed class KernelSemaphoreState
{
public required string Name { get; init; }
// Formatted once at creation; signal/wait/cancel/delete all wake through this key.
public required string WakeKey { get; init; }
public required int InitialCount { get; init; }
public required int MaxCount { get; init; }
public int Count { get; set; }
@@ -63,6 +65,7 @@ public static class KernelSemaphoreCompatExports
_semaphores[handle] = new KernelSemaphoreState
{
Name = name,
WakeKey = GetSemaphoreWakeKey(handle),
InitialCount = initialCount,
MaxCount = maxCount,
Count = initialCount,
@@ -108,79 +111,148 @@ public static class KernelSemaphoreCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
// In-place block on the semaphore gate. Monitor.Wait releases the gate
// and parks atomically, so a concurrent SignalSema's PulseAll cannot be
// lost between the count check and the park. Semantics mirror FreeBSD
// ksem / sem_wait: acquire when count>=need, else sleep until posted or
// the deadline lapses. Waits are sliced only so teardown can unwind.
var deadlineMs = timeoutAddress != 0
? Environment.TickCount64 + Math.Max(1L, timeoutUsec / 1000L)
: long.MaxValue;
lock (semaphore.Gate)
{
if (semaphore.Count < needCount)
if (semaphore.Count >= needCount)
{
semaphore.WaitingThreads++;
semaphore.Count -= needCount;
if (timeoutAddress != 0)
{
_ = TryWriteUInt32(ctx, timeoutAddress, timeoutUsec);
}
if (_traceSema)
{
TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
}
var guestThreadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
GuestThreadBlocking.NoteBlocked(guestThreadHandle, "sceKernelWaitSema");
try
{
while (semaphore.Count < needCount)
{
if (GuestThreadBlocking.ShutdownRequested)
{
if (timeoutAddress != 0)
{
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
}
var remaining = deadlineMs - Environment.TickCount64;
if (timeoutAddress != 0 && remaining <= 0)
{
if (_traceSema)
{
TraceSemaphore($"wait-timeout handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
}
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
}
GuestThreadBlocking.Checkpoint(guestThreadHandle, semaphore.Gate);
_ = Monitor.Wait(semaphore.Gate, (int)Math.Min(remaining, GuestThreadBlocking.WaitSliceMilliseconds));
}
}
finally
{
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
GuestThreadBlocking.NoteUnblocked(guestThreadHandle);
TraceSemaphore($"wait handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
semaphore.Count -= needCount;
semaphore.WaitingThreads++;
}
// Block cooperatively: the wake predicate atomically acquires the
// tokens (so a wake commits the acquisition), while the resume
// handler distinguishes a real acquisition from a deadline expiry.
var acquired = false;
var deadline = timeoutAddress != 0
? GuestThreadExecution.ComputeDeadlineTimestamp(TimeSpan.FromMicroseconds(timeoutUsec))
: 0;
bool WakePredicate()
{
lock (semaphore.Gate)
{
if (semaphore.Count >= needCount)
{
semaphore.Count -= needCount;
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
acquired = true;
return true;
}
return false;
}
}
int ResumeWait()
{
if (timeoutAddress != 0)
{
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
}
if (acquired)
{
if (_traceSema)
{
TraceSemaphore($"wait-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
lock (semaphore.Gate)
{
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
}
if (_traceSema)
{
TraceSemaphore($"wait handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)}");
TraceSemaphore($"wait-timeout handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
}
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
}
if (GuestThreadExecution.RequestCurrentThreadBlock(
ctx,
"sceKernelWaitSema",
GetSemaphoreWakeKey(handle),
ResumeWait,
WakePredicate,
deadline))
{
if (_traceSema)
{
TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
// Not a guest thread (or no scheduler): fall back to a host-thread
// wait so the semantics still hold on non-cooperative callers.
return WaitSemaphoreOnHostThread(ctx, semaphore, handle, needCount, timeoutAddress, timeoutUsec);
}
private static int WaitSemaphoreOnHostThread(
CpuContext ctx,
KernelSemaphoreState semaphore,
uint handle,
int needCount,
ulong timeoutAddress,
uint timeoutUsec)
{
var deadlineMs = timeoutAddress != 0
? Environment.TickCount64 + Math.Max(1L, timeoutUsec / 1000L)
: long.MaxValue;
lock (semaphore.Gate)
{
if (_traceSema)
{
TraceSemaphore(
$"wait-host-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} " +
$"count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} {FormatCallSite(ctx)}");
}
while (semaphore.Count < needCount)
{
var remaining = deadlineMs - Environment.TickCount64;
if (timeoutAddress != 0 && remaining <= 0)
{
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
}
Monitor.Wait(semaphore.Gate, (int)Math.Min(remaining, 100));
}
semaphore.Count -= needCount;
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
if (_traceSema)
{
TraceSemaphore(
$"wait-host-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
}
if (timeoutAddress != 0)
{
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
}
private static string GetSemaphoreWakeKey(uint handle) => $"sceKernelWaitSema:{handle:X8}";
[SysAbiExport(
Nid = "12wOHk8ywb0",
ExportName = "sceKernelPollSema",
@@ -243,7 +315,7 @@ public static class KernelSemaphoreCompatExports
}
semaphore.Count += signalCount;
// Wake threads parked in-place on the gate; each re-checks the count.
// Wake host-thread waiters parked in the fallback path.
Monitor.PulseAll(semaphore.Gate);
if (_traceSema)
{
@@ -251,6 +323,9 @@ public static class KernelSemaphoreCompatExports
}
}
// Wake cooperatively-blocked guest threads; their wake predicate
// acquires the tokens atomically, so this respects the new count.
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetSemaphoreWakeKey(handle));
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
@@ -279,6 +354,7 @@ public static class KernelSemaphoreCompatExports
}
semaphore.Count = setCount < 0 ? semaphore.InitialCount : setCount;
semaphore.WaitingThreads = 0;
Monitor.PulseAll(semaphore.Gate);
if (_traceSema)
{
@@ -286,6 +362,7 @@ public static class KernelSemaphoreCompatExports
}
}
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetSemaphoreWakeKey(handle));
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
@@ -333,6 +410,7 @@ public static class KernelSemaphoreCompatExports
_semaphores[handle] = new KernelSemaphoreState
{
Name = $"posix@0x{semaphoreAddress:X16}",
WakeKey = GetSemaphoreWakeKey(handle),
InitialCount = initialCount,
MaxCount = int.MaxValue,
Count = initialCount,
@@ -350,22 +428,6 @@ public static class KernelSemaphoreCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "GEnUkDZoUwY",
ExportName = "scePthreadSemInit",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemInit(CpuContext ctx)
{
// scePthreadSemInit(sem, flag, value, name) seems to only support private semaphores
if (ctx[CpuRegister.Rsi] != 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
return PosixSemInit(ctx);
}
[SysAbiExport(
Nid = "YCV5dGGBcCo",
ExportName = "sem_wait",
@@ -384,13 +446,6 @@ public static class KernelSemaphoreCompatExports
return KernelWaitSema(ctx);
}
[SysAbiExport(
Nid = "C36iRE0F5sE",
ExportName = "scePthreadSemWait",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemWait(CpuContext ctx) => PosixSemWait(ctx);
[SysAbiExport(
Nid = "WBWzsRifCEA",
ExportName = "sem_trywait",
@@ -408,19 +463,6 @@ public static class KernelSemaphoreCompatExports
return KernelPollSema(ctx, handle, 1);
}
[SysAbiExport(
Nid = "H2a+IN9TP0E",
ExportName = "scePthreadSemTrywait",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemTryWait(CpuContext ctx)
{
var result = PosixSemTryWait(ctx);
return result == (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
? SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN)
: result;
}
[SysAbiExport(
Nid = "w5IHyvahg-o",
ExportName = "sem_timedwait",
@@ -457,13 +499,6 @@ public static class KernelSemaphoreCompatExports
return KernelSignalSema(ctx, handle, 1);
}
[SysAbiExport(
Nid = "aishVAiFaYM",
ExportName = "scePthreadSemPost",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemPost(CpuContext ctx) => PosixSemPost(ctx);
[SysAbiExport(
Nid = "Bq+LRV-N6Hk",
ExportName = "sem_getvalue",
@@ -514,13 +549,6 @@ public static class KernelSemaphoreCompatExports
return result;
}
[SysAbiExport(
Nid = "Vwc+L05e6oE",
ExportName = "scePthreadSemDestroy",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemDestroy(CpuContext ctx) => PosixSemDestroy(ctx);
private static bool TryGetPosixSemaphoreHandle(CpuContext ctx, ulong semaphoreAddress, out uint handle)
{
handle = 0;
@@ -1,127 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Concurrent;
using System.Threading;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Kernel;
// libKernel's address-wait primitives (sceKernelSyncOnAddress*) are the PS5's
// futex-style wait/wake: a thread parks on a guest address until another thread
// wakes that address. Guest runtimes (seen driving Juicy Realm, PPSA19268)
// build their own spinlocks/queues on top of it and call the wait in a hot
// loop; left unimplemented, every wait returns immediately and the runtime
// busy-spins forever (millions of calls, no forward progress).
//
// Waits block in place on a per-address gate (see GuestThreadBlocking):
// Monitor.Wait releases the gate and parks atomically, so a wake's generation
// bump + PulseAll cannot be lost between the generation check and the park.
// The real primitive takes a compare value so the wait only sleeps while the
// address still holds the expected value; that exact value is not recovered
// here, so each wait is bounded by a self-heal deadline and treated as a
// spurious-wakeup-tolerant park: the guest re-checks its own condition after
// resuming, which futex callers already tolerate. Wake-one degrades to
// wake-all for the same reason (each resumed waiter re-evaluates).
public static class KernelSyncOnAddressCompatExports
{
// Safety-net bound. Real releases come from the wake side; this only limits
// how long a wait that genuinely raced/missed its wake stays parked before
// the guest re-evaluates. Kept large: a short bound turns every parked
// waiter into a hot re-poll that steals CPU from the threads that would
// issue the wake, so it must be a rare last resort, not a spin substitute.
private static readonly TimeSpan WaitSelfHealTimeout = TimeSpan.FromMilliseconds(100);
private static readonly ConcurrentDictionary<ulong, object> _addressGates = new();
// Per-address wake generation. A wait captures the current generation and
// stays parked while it is unchanged; a wake bumps it first, then pulses
// the gate, so a wait between its generation check and its park still
// observes the bump (the check happens under the gate).
private static readonly ConcurrentDictionary<ulong, long> _wakeGenerations = new();
private static long CurrentGeneration(ulong address) =>
_wakeGenerations.TryGetValue(address, out var generation) ? generation : 0;
[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 gate = _addressGates.GetOrAdd(address, static _ => new object());
var deadlineMs = Environment.TickCount64 + (long)WaitSelfHealTimeout.TotalMilliseconds;
var guestThreadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
GuestThreadBlocking.NoteBlocked(guestThreadHandle, "sceKernelSyncOnAddressWait");
try
{
lock (gate)
{
while (CurrentGeneration(address) == observedGeneration &&
!GuestThreadBlocking.ShutdownRequested)
{
var remaining = deadlineMs - Environment.TickCount64;
if (remaining <= 0)
{
// Self-heal: resume and let the guest re-check its condition.
break;
}
GuestThreadBlocking.Checkpoint(guestThreadHandle, gate);
_ = Monitor.Wait(gate, (int)Math.Min(remaining, GuestThreadBlocking.WaitSliceMilliseconds));
}
}
}
finally
{
GuestThreadBlocking.NoteUnblocked(guestThreadHandle);
}
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);
}
// Bump the generation first so a wait that has checked but not yet
// parked (it holds the gate for both) observes the change; then pulse
// parked waiters. rsi's wake count degrades to wake-all — resumed
// waiters re-evaluate their own condition, which futex callers tolerate.
_wakeGenerations.AddOrUpdate(address, 1, static (_, current) => current + 1);
if (_addressGates.TryGetValue(address, out var gate))
{
lock (gate)
{
Monitor.PulseAll(gate);
}
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
private static int SetReturn(CpuContext ctx, OrbisGen2Result result)
{
var value = (int)result;
ctx[CpuRegister.Rax] = unchecked((ulong)value);
return value;
}
}
+103 -448
View File
@@ -3,7 +3,6 @@
using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
using System.Buffers;
using System.Buffers.Binary;
using System.Threading;
@@ -26,44 +25,24 @@ public static class Ngs2Exports
private static long _nextUid;
private static long _renderCount;
// NGS2 renders one grain of interleaved float32 per sceNgs2SystemRender.
// The grain length defaults to 256 frames (matching the 8192-byte AudioOut
// buffers games copy it into) until the title overrides it.
private const int DefaultGrainSamples = 256;
private const double OutputSampleRate = 48000.0;
private sealed class SystemState
{
public SystemState(uint uid) => Uid = uid;
public uint Uid { get; }
public int GrainSamples { get; set; } = DefaultGrainSamples;
}
private sealed record SystemState(uint Uid);
private sealed record RackState(ulong SystemHandle, uint RackId);
private sealed record VoiceState(ulong RackHandle, uint VoiceIndex);
private sealed class VoiceState
[SysAbiExport(
Nid = "koBbCMvOKWw",
ExportName = "sceNgs2SystemCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2SystemCreate(CpuContext ctx)
{
public VoiceState(ulong rackHandle, uint voiceIndex)
var bufferInfoAddress = ctx[CpuRegister.Rsi];
if (!TryReadContextBuffer(ctx, bufferInfoAddress, out var hostBuffer))
{
RackHandle = rackHandle;
VoiceIndex = voiceIndex;
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
public ulong RackHandle { get; }
public uint VoiceIndex { get; }
// Software-mixer playback state. Pcm is the fully decoded mono waveform;
// Position is a fractional read cursor advanced at the source/output rate
// ratio each output frame.
public short[]? Pcm { get; set; }
public ulong SourceAddr { get; set; }
public int SourceRate { get; set; }
public double Position { get; set; }
public bool Playing { get; set; }
public int LoopStart { get; set; } = -1;
public int LoopEnd { get; set; }
public float Gain { get; set; } = 1f;
return CreateSystem(ctx, ctx[CpuRegister.Rdx], hostBuffer);
}
[SysAbiExport(
@@ -79,34 +58,14 @@ public static class Ngs2Exports
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
}
if (!TryCreateHandle(ctx, type: 1, ownerHandle: 0, out var handle) ||
!ctx.TryWriteUInt64(outHandleAddress, handle))
if (!TryCreateHandle(ctx, type: 1, ownerHandle: 0, out var handle))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
lock (StateGate)
{
Systems[handle] = new SystemState(unchecked((uint)Interlocked.Increment(ref _nextUid)));
}
return SetReturn(ctx, 0);
return CreateSystem(ctx, outHandleAddress, handle);
}
// Non-allocator create: identical to the WithAllocator form for our purposes.
// The only signature difference is the caller-supplied buffer info in rsi
// (vs an allocator callback); the system option (rdi) and out-handle (rdx)
// sit at the same argument positions, so we reuse the same implementation.
// Dead Cells uses these variants — leaving sceNgs2SystemCreate unresolved
// gave the game a garbage system handle, so every later rack/voice call
// failed and it polled sceNgs2VoiceGetState forever, freezing at FLIP 0.
[SysAbiExport(
Nid = "koBbCMvOKWw",
ExportName = "sceNgs2SystemCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2SystemCreate(CpuContext ctx) => Ngs2SystemCreateWithAllocator(ctx);
[SysAbiExport(
Nid = "u-WrYDaJA3k",
ExportName = "sceNgs2SystemDestroy",
@@ -135,6 +94,27 @@ public static class Ngs2Exports
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "cLV4aiT9JpA",
ExportName = "sceNgs2RackCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2RackCreate(CpuContext ctx)
{
var bufferInfoAddress = ctx[CpuRegister.Rcx];
if (!TryReadContextBuffer(ctx, bufferInfoAddress, out var hostBuffer))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
return CreateRack(
ctx,
ctx[CpuRegister.Rdi],
unchecked((uint)ctx[CpuRegister.Rsi]),
ctx[CpuRegister.R8],
hostBuffer);
}
[SysAbiExport(
Nid = "U546k6orxQo",
ExportName = "sceNgs2RackCreateWithAllocator",
@@ -158,29 +138,14 @@ public static class Ngs2Exports
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
}
if (!TryCreateHandle(ctx, type: 2, systemHandle, out var handle) ||
!ctx.TryWriteUInt64(outHandleAddress, handle))
if (!TryCreateHandle(ctx, type: 2, systemHandle, out var handle))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
lock (StateGate)
{
Racks[handle] = new RackState(systemHandle, rackId);
}
return SetReturn(ctx, 0);
return CreateRack(ctx, systemHandle, rackId, outHandleAddress, handle);
}
// Non-allocator rack create: system handle (rdi), rack id (rsi) and the
// out-handle (r8) share the WithAllocator argument layout, so reuse it.
[SysAbiExport(
Nid = "cLV4aiT9JpA",
ExportName = "sceNgs2RackCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2RackCreate(CpuContext ctx) => Ngs2RackCreateWithAllocator(ctx);
[SysAbiExport(
Nid = "lCqD7oycmIM",
ExportName = "sceNgs2RackDestroy",
@@ -255,217 +220,14 @@ public static class Ngs2Exports
LibraryName = "libSceNgs2")]
public static int Ngs2VoiceControl(CpuContext ctx)
{
var voiceHandle = ctx[CpuRegister.Rdi];
var paramList = ctx[CpuRegister.Rsi];
lock (StateGate)
{
if (!Voices.ContainsKey(voiceHandle))
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidVoiceHandle);
}
}
if (ShouldTrace())
{
TraceVoiceParamList(ctx, voiceHandle, paramList);
}
HandleVoiceParams(ctx, voiceHandle, paramList);
return SetReturn(ctx, 0);
}
// Parse the SceNgs2VoiceParamHead command list (header = u32 size, u32 id;
// params are laid out contiguously) and apply the ones the mixer needs:
// the waveform-blocks param arms a voice with decoded PCM, and the port
// matrix param carries its output gain.
private static void HandleVoiceParams(CpuContext ctx, ulong voiceHandle, ulong paramList)
{
if (paramList == 0)
{
return;
}
var offset = paramList;
for (var guard = 0; guard < 32; guard++)
{
if (!ctx.TryReadUInt32(offset, out var size) ||
!ctx.TryReadUInt32(offset + 4, out var id))
{
return;
}
switch (id)
{
case 0x10000001:
ApplyWaveformParam(ctx, voiceHandle, offset);
break;
case 0x20010001:
ApplyPortMatrixParam(ctx, voiceHandle, offset);
break;
}
// Advance to the next contiguous block; the game normally sends one
// param per call (size==whole block), so stop when size is degenerate.
if (size < 8 || size > 0x1000)
{
return;
}
offset += (size + 7) & ~7u;
return SetReturn(
ctx,
Voices.ContainsKey(ctx[CpuRegister.Rdi]) ? 0 : OrbisNgs2ErrorInvalidVoiceHandle);
}
}
// Waveform-blocks param: the guest pointer at +8 references a "VAGp"
// (PS-ADPCM) container. Decode it once and arm the voice for playback.
private static void ApplyWaveformParam(CpuContext ctx, ulong voiceHandle, ulong paramOffset)
{
if (!ctx.TryReadUInt64(paramOffset + 8, out var dataAddr) || dataAddr <= 0x10000)
{
return;
}
lock (StateGate)
{
if (Voices.TryGetValue(voiceHandle, out var existing) &&
existing.SourceAddr == dataAddr && existing.Pcm is not null)
{
// Same waveform already armed — don't restart it every frame.
return;
}
}
Span<byte> header = stackalloc byte[Ngs2VagDecoder.VagHeaderSize];
if (!ctx.Memory.TryRead(dataAddr, header) || !Ngs2VagDecoder.IsVag(header))
{
return;
}
var declaredSize = (int)BinaryPrimitives.ReadUInt32BigEndian(header[0x0C..]);
var totalBytes = Ngs2VagDecoder.VagHeaderSize + Math.Clamp(declaredSize, 0, 8 * 1024 * 1024);
var raw = System.Buffers.ArrayPool<byte>.Shared.Rent(totalBytes);
try
{
if (!ctx.Memory.TryRead(dataAddr, raw.AsSpan(0, totalBytes)) ||
!Ngs2VagDecoder.TryDecode(raw.AsSpan(0, totalBytes), out var waveform))
{
return;
}
lock (StateGate)
{
if (!Voices.TryGetValue(voiceHandle, out var voice))
{
return;
}
voice.Pcm = waveform.Samples;
voice.SourceAddr = dataAddr;
voice.SourceRate = waveform.SampleRate;
voice.LoopStart = waveform.LoopStart;
voice.LoopEnd = waveform.LoopEnd > 0 ? waveform.LoopEnd : waveform.Samples.Length;
voice.Position = 0;
voice.Playing = true;
}
if (ShouldTrace())
{
var peak = 0;
for (var i = 0; i < waveform.Samples.Length; i++)
{
peak = Math.Max(peak, Math.Abs((int)waveform.Samples[i]));
}
Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.arm voice=0x{voiceHandle:X16} addr=0x{dataAddr:X} rate={waveform.SampleRate} samples={waveform.Samples.Length} loop={waveform.LoopStart} peak={peak}");
}
}
finally
{
System.Buffers.ArrayPool<byte>.Shared.Return(raw);
}
}
// Port matrix param: the first float level is a reasonable proxy for the
// voice's output gain until per-channel panning is implemented.
private static void ApplyPortMatrixParam(CpuContext ctx, ulong voiceHandle, ulong paramOffset)
{
if (!ctx.TryReadUInt32(paramOffset + 12, out var levelBits))
{
return;
}
var level = BitConverter.UInt32BitsToSingle(levelBits);
if (!float.IsFinite(level) || level < 0f || level > 8f)
{
return;
}
lock (StateGate)
{
if (Voices.TryGetValue(voiceHandle, out var voice))
{
voice.Gain = level;
}
}
}
// Empirically dump the SceNgs2VoiceParamHead-chained command list so we can
// confirm the real struct layout (size/next/id) against public NGS2 sources
// before building the software mixer. Assumed header: u16 size, s16 next
// (byte offset to the next block, 0 = end), u32 id.
private static void TraceVoiceParamList(CpuContext ctx, ulong voiceHandle, ulong paramList)
{
if (paramList == 0)
{
return;
}
Span<byte> peek = stackalloc byte[32];
var offset = paramList;
for (int guard = 0; guard < 32; guard++)
{
if (!ctx.TryReadUInt16(offset, out var size) ||
!ctx.TryReadUInt16(offset + 2, out var next) ||
!ctx.TryReadUInt32(offset + 4, out var id))
{
Console.Error.WriteLine($"[LOADER][TRACE] ngs2.voiceparam voice=0x{voiceHandle:X16} @0x{offset:X}: unreadable header");
return;
}
peek.Clear();
var readable = Math.Min((int)Math.Max((ushort)8, size), peek.Length);
ctx.Memory.TryRead(offset, peek[..readable]);
Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.voiceparam voice=0x{voiceHandle:X16} id=0x{id:X} size={size} next={unchecked((short)next)} bytes={Convert.ToHexString(peek[..readable])}");
// For the waveform-blocks param, follow the embedded pointers and
// dump the pointed-to bytes so we can tell PCM16 from ATRAC9.
if (id == 0x10000001 && Interlocked.Increment(ref _waveformDumps) <= 8)
{
for (int po = 8; po + 8 <= readable; po += 8)
{
if (ctx.TryReadUInt64(offset + (ulong)po, out var ptr) && ptr > 0x10000 &&
ctx.Memory.TryRead(ptr, peek))
{
Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.waveform @+{po} ptr=0x{ptr:X} head={Convert.ToHexString(peek)}");
}
}
}
var advance = unchecked((short)next);
if (advance <= 0)
{
return;
}
offset += (ulong)advance;
}
}
private static long _waveformDumps;
private static long _renderInfoDumps;
[SysAbiExport(
Nid = "AbYvTOZ8Pts",
ExportName = "sceNgs2VoiceRunCommands",
@@ -511,32 +273,11 @@ public static class Ngs2Exports
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
// SceNgs2RenderBufferInfo: {ptr@0, size@8, waveformType@16,
// channelsCount@20}. Mix the armed voices into the leading grain
// as interleaved float32 — this is what the game copies to
// sceAudioOutOutput, so it is where NGS2 audio must appear.
var channels = 2;
if (ctx.TryReadUInt32(entryAddress + 20, out var declaredChannels) &&
declaredChannels is > 0 and <= 8)
{
channels = (int)declaredChannels;
}
MixVoicesIntoGrain(ctx, systemHandle, bufferAddress, bufferSize, channels);
if (ShouldTrace() && Interlocked.Increment(ref _renderInfoDumps) <= 4)
{
Span<byte> rbi = stackalloc byte[RenderBufferInfoSize];
ctx.Memory.TryRead(entryAddress, rbi);
Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.renderbufinfo addr=0x{bufferAddress:X} size={bufferSize} ch={channels} raw={Convert.ToHexString(rbi)}");
}
}
}
var count = Interlocked.Increment(ref _renderCount);
if (ShouldTrace() && (count <= 4 || count % 200 == 0))
if (ShouldTrace() && (count <= 4 || count % 10_000 == 0))
{
Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.render#{count} system=0x{systemHandle:X16} buffers={bufferInfoCount}");
@@ -545,135 +286,6 @@ public static class Ngs2Exports
return SetReturn(ctx, 0);
}
// Sum every armed voice belonging to this system into the leading grain of
// the render buffer as interleaved float32. The buffer was just zeroed, so
// this is a plain additive mix; silence stays silence when nothing plays.
private static void MixVoicesIntoGrain(
CpuContext ctx, ulong systemHandle, ulong bufferAddress, ulong bufferSize, int channels)
{
int grain;
lock (StateGate)
{
if (!Systems.TryGetValue(systemHandle, out var system))
{
return;
}
grain = system.GrainSamples;
}
var capacityFrames = (int)Math.Min((ulong)grain, bufferSize / (ulong)(channels * sizeof(float)));
if (capacityFrames <= 0)
{
return;
}
var floatCount = capacityFrames * channels;
var accum = ArrayPool<float>.Shared.Rent(floatCount);
var mixedAnything = false;
try
{
Array.Clear(accum, 0, floatCount);
lock (StateGate)
{
foreach (var pair in Voices)
{
var voice = pair.Value;
if (!voice.Playing || voice.Pcm is null || voice.Pcm.Length == 0)
{
continue;
}
if (!Racks.TryGetValue(voice.RackHandle, out var rack) ||
rack.SystemHandle != systemHandle)
{
continue;
}
MixOneVoice(accum, capacityFrames, channels, voice);
mixedAnything = true;
}
}
if (mixedAnything)
{
WriteGrain(ctx, bufferAddress, accum, floatCount);
}
}
finally
{
ArrayPool<float>.Shared.Return(accum);
}
}
// Resample one voice from its source rate to 48 kHz (nearest-sample) and add
// it to the front stereo pair. Advances the voice cursor and handles loop /
// one-shot end. Must be called under StateGate.
private static void MixOneVoice(float[] accum, int frames, int channels, VoiceState voice)
{
var pcm = voice.Pcm!;
var loopEnd = voice.LoopEnd > 0 && voice.LoopEnd <= pcm.Length ? voice.LoopEnd : pcm.Length;
var loopStart = voice.LoopStart;
var step = voice.SourceRate / OutputSampleRate;
var gain = voice.Gain / 32768f;
var pos = voice.Position;
for (var f = 0; f < frames; f++)
{
var idx = (int)pos;
if (idx >= loopEnd)
{
if (loopStart >= 0 && loopStart < loopEnd)
{
pos = loopStart;
idx = loopStart;
}
else
{
voice.Playing = false;
break;
}
}
if (idx < 0 || idx >= pcm.Length)
{
voice.Playing = false;
break;
}
var sample = pcm[idx] * gain;
var baseIndex = f * channels;
accum[baseIndex] += sample;
if (channels > 1)
{
accum[baseIndex + 1] += sample;
}
pos += step;
}
voice.Position = pos;
}
private static void WriteGrain(CpuContext ctx, ulong address, float[] accum, int count)
{
var bytes = ArrayPool<byte>.Shared.Rent(count * sizeof(float));
try
{
var span = bytes.AsSpan(0, count * sizeof(float));
for (var i = 0; i < count; i++)
{
var value = Math.Clamp(accum[i], -1f, 1f);
BinaryPrimitives.WriteSingleLittleEndian(span.Slice(i * sizeof(float), sizeof(float)), value);
}
ctx.Memory.TryWrite(address, span);
}
finally
{
ArrayPool<byte>.Shared.Return(bytes);
}
}
[SysAbiExport(
Nid = "pgFAiLR5qT4",
ExportName = "sceNgs2SystemQueryBufferSize",
@@ -711,25 +323,7 @@ public static class Ngs2Exports
ExportName = "sceNgs2SystemSetGrainSamples",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2SystemSetGrainSamples(CpuContext ctx)
{
var systemHandle = ctx[CpuRegister.Rdi];
var grain = unchecked((int)ctx[CpuRegister.Rsi]);
lock (StateGate)
{
if (!Systems.TryGetValue(systemHandle, out var system))
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidSystemHandle);
}
if (grain > 0 && grain <= 8192)
{
system.GrainSamples = grain;
}
}
return SetReturn(ctx, 0);
}
public static int Ngs2SystemSetGrainSamples(CpuContext ctx) => ValidateSystem(ctx);
[SysAbiExport(
Nid = "-tbc2SxQD60",
@@ -818,6 +412,67 @@ public static class Ngs2Exports
}
}
private static int CreateSystem(CpuContext ctx, ulong outHandleAddress, ulong handle)
{
if (outHandleAddress == 0)
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
}
if (handle == 0 || !ctx.TryWriteUInt64(outHandleAddress, handle))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
lock (StateGate)
{
Systems[handle] = new SystemState(unchecked((uint)Interlocked.Increment(ref _nextUid)));
}
return SetReturn(ctx, 0);
}
private static int CreateRack(
CpuContext ctx,
ulong systemHandle,
uint rackId,
ulong outHandleAddress,
ulong handle)
{
lock (StateGate)
{
if (!Systems.ContainsKey(systemHandle))
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidSystemHandle);
}
}
if (outHandleAddress == 0)
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
}
if (handle == 0 || !ctx.TryWriteUInt64(outHandleAddress, handle))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
lock (StateGate)
{
Racks[handle] = new RackState(systemHandle, rackId);
}
return SetReturn(ctx, 0);
}
private static bool TryReadContextBuffer(CpuContext ctx, ulong address, out ulong hostBuffer)
{
hostBuffer = 0;
return address != 0 &&
ctx.TryReadUInt64(address, out hostBuffer) &&
hostBuffer != 0;
}
private static bool TryCreateHandle(CpuContext ctx, uint type, ulong ownerHandle, out ulong handle)
{
handle = 0;
-150
View File
@@ -1,150 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
namespace SharpEmu.Libs.Ngs2;
// Clean-room PS-ADPCM ("VAG") decoder. NGS2 sampler voices point at waveforms
// wrapped in the classic Sony "VAGp" container: a 48-byte big-endian header
// followed by 16-byte ADPCM frames (2-byte predictor/shift + flags, then 14
// bytes = 28 nibbles = 28 samples). The predictor coefficient table and the
// nibble decode are the publicly documented PSX SPU ADPCM algorithm.
public static class Ngs2VagDecoder
{
// Standard PS-ADPCM predictor filters (scaled by 1/64).
private static readonly int[] Coeff0 = { 0, 60, 115, 98, 122 };
private static readonly int[] Coeff1 = { 0, 0, -52, -55, -60 };
public const int VagHeaderSize = 0x30;
private const uint VagMagic = 0x56414770; // "VAGp"
public readonly struct Waveform
{
public Waveform(short[] samples, int sampleRate, int loopStart, int loopEnd)
{
Samples = samples;
SampleRate = sampleRate;
LoopStart = loopStart;
LoopEnd = loopEnd;
}
public short[] Samples { get; }
public int SampleRate { get; }
public int LoopStart { get; } // -1 when the waveform does not loop
public int LoopEnd { get; }
}
// True when the buffer begins with a recognizable "VAGp" container header.
public static bool IsVag(ReadOnlySpan<byte> data) =>
data.Length >= VagHeaderSize &&
BinaryPrimitives.ReadUInt32BigEndian(data) == VagMagic;
// Decode a full "VAGp" container into mono PCM16. Returns false when the
// header is missing/short so callers can skip unsupported formats safely.
public static bool TryDecode(ReadOnlySpan<byte> data, out Waveform waveform)
{
waveform = default;
if (!IsVag(data))
{
return false;
}
// Header (big-endian): +0x0C dataSize, +0x10 sampleRate.
var declaredSize = (int)BinaryPrimitives.ReadUInt32BigEndian(data[0x0C..]);
var sampleRate = (int)BinaryPrimitives.ReadUInt32BigEndian(data[0x10..]);
if (sampleRate <= 0)
{
sampleRate = 48000;
}
var body = data[VagHeaderSize..];
// Trust the declared payload size when it fits; otherwise decode what we
// actually have (some tools pad or under-report).
var available = body.Length - (body.Length % 16);
var frameBytes = declaredSize > 0 && declaredSize <= available ? declaredSize - (declaredSize % 16) : available;
if (frameBytes <= 0)
{
return false;
}
waveform = Decode(body[..frameBytes], sampleRate);
return waveform.Samples.Length > 0;
}
// Decode raw 16-byte-framed PS-ADPCM (no container header) into PCM16 and
// resolve loop points from the per-frame flag bytes.
public static Waveform Decode(ReadOnlySpan<byte> frames, int sampleRate)
{
var frameCount = frames.Length / 16;
var samples = new short[frameCount * 28];
var loopStart = -1;
var loopEnd = -1;
var hist1 = 0;
var hist2 = 0;
var outIndex = 0;
var ended = false;
for (var frame = 0; frame < frameCount && !ended; frame++)
{
var offset = frame * 16;
var header = frames[offset];
var shift = header & 0x0F;
var filter = (header >> 4) & 0x0F;
if (filter > 4)
{
filter = 0;
}
// Per-frame loop marker (exact PS-ADPCM values, not bit masks):
// 3 = loop start, 6 = loop end + jump back, 1/7 = one-shot end.
var flags = frames[offset + 1];
var blockStart = outIndex;
if (flags == 0x03)
{
loopStart = blockStart;
}
var f0 = Coeff0[filter];
var f1 = Coeff1[filter];
for (var i = 0; i < 14; i++)
{
var d = frames[offset + 2 + i];
for (var nibble = 0; nibble < 2; nibble++)
{
var raw = nibble == 0 ? d & 0x0F : d >> 4;
// Sign-extend the 4-bit sample into the top nibble, then scale.
var s = (short)(raw << 12) >> shift;
var predicted = (hist1 * f0 + hist2 * f1) >> 6;
var sample = Math.Clamp(s + predicted, short.MinValue, short.MaxValue);
samples[outIndex++] = (short)sample;
hist2 = hist1;
hist1 = sample;
}
}
if (flags == 0x06)
{
loopEnd = outIndex;
}
else if (flags == 0x01 || flags == 0x07)
{
ended = true;
}
}
// Trim to the samples we actually decoded (a one-shot end marker can stop
// us before the declared frame count).
if (outIndex != samples.Length)
{
Array.Resize(ref samples, outIndex);
}
if (loopStart >= 0 && loopEnd <= loopStart)
{
loopEnd = outIndex;
}
return new Waveform(samples, sampleRate, loopStart, loopEnd);
}
}
@@ -58,36 +58,6 @@ public static class NpEntitlementAccessExports
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
private const int EmptyAddcontInfoSize = 0x30;
// Singular lookup of one add-on-content entitlement (rdx = info out). We own
// no DLC, so report an empty/zeroed info and success — matching the list
// variant's "no entitlements" answer. Dead Cells calls this while loading a
// level; leaving it unresolved left the info struct uninitialized.
[SysAbiExport(
Nid = "xddD23+8TfQ",
ExportName = "sceNpEntitlementAccessGetAddcontEntitlementInfo",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNpEntitlementAccess")]
public static int NpEntitlementAccessGetAddcontEntitlementInfo(CpuContext ctx)
{
var infoAddress = ctx[CpuRegister.Rdx];
if (infoAddress != 0)
{
Span<byte> info = stackalloc byte[EmptyAddcontInfoSize];
info.Clear();
if (!ctx.Memory.TryWrite(infoAddress, info))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
TraceNpEntitlementAccess(
$"get_addcont_info service=0x{ctx[CpuRegister.Rdi]:X16} label=0x{ctx[CpuRegister.Rsi]:X16} " +
$"info=0x{infoAddress:X16} -> empty");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
private static void TraceNpEntitlementAccess(string message)
{
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NP"), "1", StringComparison.Ordinal))
@@ -197,16 +197,4 @@ public static class NpUniversalDataSystemExports
{
return ctx.SetReturn(0, typeof(long));
}
// Telemetry property setter (event property array, string value). We do not
// upload analytics, so accept and drop it — matching the other Set* stubs.
[SysAbiExport(
Nid = "4llLk7YJRTE",
ExportName = "sceNpUniversalDataSystemEventPropertyArraySetString",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNpUniversalDataSystem")]
public static int NpUniversalDataSystemEventPropertyArraySetString(CpuContext ctx)
{
return ctx.SetReturn(0, typeof(long));
}
}
-61
View File
@@ -65,35 +65,6 @@ public static class PadExports
LibraryName = "libScePad")]
public static int PadOpenExt(CpuContext ctx) => PadOpenCore(ctx, extended: true);
// scePadGetHandle(userId, type, index): returns the handle of an already-open
// pad without opening a new one. Dead Cells calls it every frame to poll
// input; leaving it unresolved returned a garbage handle so the input path
// (and the game loop that drives it) misbehaved. Same validation as
// scePadOpen — the one primary pad — returning its handle or a not-connected
// error, never opening or logging.
[SysAbiExport(
Nid = "u1GRHp+oWoY",
ExportName = "scePadGetHandle",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePad")]
public static int PadGetHandle(CpuContext ctx)
{
var userId = unchecked((int)ctx[CpuRegister.Rdi]);
var type = unchecked((int)ctx[CpuRegister.Rsi]);
var index = unchecked((int)ctx[CpuRegister.Rdx]);
if (!_initialized)
{
return ctx.SetReturn(OrbisPadErrorNotInitialized);
}
if (userId != PrimaryUserId || type is not (0 or 1 or 2) || index != 0)
{
return ctx.SetReturn(OrbisPadErrorDeviceNotConnected);
}
return ctx.SetReturn(PrimaryPadHandle);
}
// scePadOpen rejects a non-null 4th arg and non-standard ports; scePadOpenExt accepts a
// ScePadOpenExtParam* plus ports 1/2 (racing titles retry scePadOpenExt(type=2) forever if rejected).
private static int PadOpenCore(CpuContext ctx, bool extended)
@@ -245,38 +216,6 @@ public static class PadExports
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
[SysAbiExport(
Nid = "AcslpN1jHR8",
ExportName = "scePadDeviceClassGetExtendedInformation",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePad")]
public static int PadDeviceClassGetExtendedInformation(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var informationAddress = ctx[CpuRegister.Rsi];
if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
if (informationAddress == 0)
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
// ScePadDeviceClassExtendedInformation: deviceClass 0 = standard pad
// (DualSense). We emulate no special peripheral (guitar/drums/wheel), so
// the class-data union stays zeroed — the guest treats it as a plain
// controller with no extended capabilities.
Span<byte> information = stackalloc byte[0x20];
information.Clear();
BinaryPrimitives.WriteInt32LittleEndian(information[0x00..], 0);
return ctx.Memory.TryWrite(informationAddress, information)
? ctx.SetReturn(0)
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
[SysAbiExport(
Nid = "YndgXqQVV7c",
ExportName = "scePadReadState",
-39
View File
@@ -1,39 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Security.Cryptography;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Random;
public static class RandomExports
{
private const int RandomErrorInvalid = unchecked((int)0x817C0016);
private const int MaxRandomBytes = 64;
[SysAbiExport(
Nid = "PI7jIZj4pcE",
ExportName = "sceRandomGetRandomNumber",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceRandom")]
public static int RandomGetRandomNumber(CpuContext ctx)
{
var destination = ctx[CpuRegister.Rdi];
var size = ctx[CpuRegister.Rsi];
if ((destination == 0 && size != 0) || size > MaxRandomBytes)
{
return ctx.SetReturn(RandomErrorInvalid);
}
if (size == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
Span<byte> bytes = stackalloc byte[(int)size];
RandomNumberGenerator.Fill(bytes);
return ctx.Memory.TryWrite(destination, bytes)
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK)
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
+24 -565
View File
@@ -45,530 +45,6 @@ public static class SaveDataExports
_titleId = string.IsNullOrWhiteSpace(titleId) ? null : SanitizePathSegment(titleId.Trim());
_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(
@@ -745,10 +221,6 @@ public static class SaveDataExports
const string mountPoint = "/savedata0";
KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, savePath);
lock (_mountGate)
{
_mounts[mountPoint] = new MountEntry(savePath, dirName, userId);
}
Span<byte> result = stackalloc byte[MountResultSize];
result.Clear();
@@ -846,20 +318,10 @@ public static class SaveDataExports
LibraryName = "libSceSaveData")]
public static int SaveDataUmount2(CpuContext ctx)
{
// rdi: SceSaveDataMountPoint* (16-byte mount point string) for umount2.
var mountPointAddress = ctx[CpuRegister.Rdi];
if (mountPointAddress != 0 && TryReadFixedAscii(ctx, mountPointAddress, 16, out var mountPoint) &&
!string.IsNullOrEmpty(mountPoint))
{
lock (_mountGate)
{
_mounts.Remove(mountPoint);
}
KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint);
TraceSaveData($"umount2 mount='{mountPoint}'");
}
// Unmounting a save directory always succeeds in the stub filesystem;
// returning an error here makes the game's save flow stall before it
// hands control to the title/gameplay state.
TraceSaveData($"umount2 user={unchecked((int)ctx[CpuRegister.Rdi])}");
return SetReturn(ctx, 0);
}
@@ -943,12 +405,9 @@ public static class SaveDataExports
private static bool TryWriteParam(CpuContext ctx, ulong address, SaveEntry entry)
{
var metadata = SaveDataStorage.ReadMetadata(entry.Path);
var param = new byte[SaveDataParamSize];
WriteAscii(param.AsSpan(0x00, 128), metadata.Title);
WriteAscii(param.AsSpan(0x80, 128), metadata.SubTitle);
WriteAscii(param.AsSpan(0x100, 1024), string.IsNullOrEmpty(metadata.Detail) ? entry.Name : metadata.Detail);
BinaryPrimitives.WriteUInt32LittleEndian(param.AsSpan(0x500), metadata.UserParam);
WriteAscii(param.AsSpan(0x00, 128), "Saved Data");
WriteAscii(param.AsSpan(0x100, 1024), entry.Name);
BinaryPrimitives.WriteInt64LittleEndian(
param.AsSpan(0x508, sizeof(long)),
new DateTimeOffset(entry.LastWriteUtc).ToUnixTimeSeconds());
@@ -1015,14 +474,11 @@ public static class SaveDataExports
return false;
}
// Saves are keyed by title id only (single-user emulation) under
// ~/SharpEmu/Saves/<titleId>/; userId is accepted for API fidelity but not
// part of the host path.
private static string ResolveTitleSaveRoot(int userId, string titleId) =>
SaveDataStorage.TitleRoot(ResolveSaveDataRoot(), titleId);
Path.Combine(ResolveSaveDataRoot(), userId.ToString(), SanitizePathSegment(titleId));
private static string ResolveSaveDataMemoryPath(int userId) =>
SaveDataStorage.MemoryPath(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId()));
Path.Combine(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId()), "sce_sdmemory", "memory.dat");
private static bool TryReadMemoryData(
CpuContext ctx, ulong address, out ulong buffer, out ulong size, out ulong offset)
@@ -1034,7 +490,14 @@ public static class SaveDataExports
ctx.TryReadUInt64(address + 0x10, out offset);
}
private static string ResolveSaveDataRoot() => SaveDataStorage.Root();
private static string ResolveSaveDataRoot()
{
var configured = Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
var root = string.IsNullOrWhiteSpace(configured)
? Path.Combine(AppContext.BaseDirectory, "user", "savedata")
: configured;
return Path.GetFullPath(root);
}
private static string ResolveConfiguredTitleId()
{
@@ -1062,7 +525,12 @@ public static class SaveDataExports
return "default";
}
private static string SanitizePathSegment(string value) => SaveDataStorage.Sanitize(value);
private static string SanitizePathSegment(string value)
{
var invalid = Path.GetInvalidFileNameChars();
var sanitized = new string(value.Select(ch => invalid.Contains(ch) ? '_' : ch).ToArray());
return string.IsNullOrWhiteSpace(sanitized) ? "default" : sanitized;
}
private static bool TryReadFixedAscii(CpuContext ctx, ulong address, int length, out string value)
{
@@ -1322,17 +790,8 @@ public static class SaveDataExports
return ctx.SetReturn(OrbisSaveDataErrorParameter);
}
if (!File.Exists(ResolveSaveDataMemoryPath(userId)))
{
return ctx.SetReturn(OrbisSaveDataErrorMemoryNotReady);
}
// The write already reached disk synchronously, but the guest treats
// sync as asynchronous and blocks a worker on sceSaveDataGetEventResult
// until the SAVE_DATA_MEMORY_SYNC_END event arrives. Post it so that
// poll completes (this is what wedged Dead Cells at FLIP 0 in-level).
EnqueueEvent(EventTypeSaveDataMemorySyncEnd, userId, string.Empty);
return ctx.SetReturn(0);
return ctx.SetReturn(
File.Exists(ResolveSaveDataMemoryPath(userId)) ? 0 : OrbisSaveDataErrorMemoryNotReady);
}
private static int TransferSaveDataMemory(CpuContext ctx, bool write)
@@ -1,130 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SharpEmu.Libs.SaveData;
/// <summary>
/// Host-side layout and metadata for PS5 save data. Saves live under
/// <c>~/SharpEmu/Saves/&lt;titleId&gt;/&lt;dirName&gt;/</c> (overridable via
/// <c>SHARPEMU_SAVEDATA_DIR</c>); the game's files are written directly inside a
/// slot through the mounted <c>/savedata0</c> filesystem, and the PS5 UI
/// metadata (title/subtitle/detail/userParam) plus icon live under
/// <c>&lt;slot&gt;/sce_sys/</c>. This type is pure filesystem logic with no guest
/// interop so the path and metadata handling can be unit-tested.
/// </summary>
public static class SaveDataStorage
{
/// <summary>Root of all saves: the env override, else <c>~/SharpEmu/Saves</c>.</summary>
public static string Root(string? overrideDir = null)
{
var configured = overrideDir ?? Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
var root = string.IsNullOrWhiteSpace(configured)
? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"SharpEmu",
"Saves")
: configured;
return Path.GetFullPath(root);
}
/// <summary>Per-title directory: <c>&lt;root&gt;/&lt;titleId&gt;</c>.</summary>
public static string TitleRoot(string root, string titleId) =>
Path.Combine(root, Sanitize(titleId));
/// <summary>A single save slot: <c>&lt;titleRoot&gt;/&lt;dirName&gt;</c>.</summary>
public static string SlotDir(string titleRoot, string dirName) =>
Path.Combine(titleRoot, Sanitize(dirName));
/// <summary>The SaveDataMemory blob shared by a title.</summary>
public static string MemoryPath(string titleRoot) =>
Path.Combine(titleRoot, "sce_sdmemory", "memory.dat");
public static string ParamPath(string slotDir) =>
Path.Combine(slotDir, "sce_sys", "param.json");
public static string IconPath(string slotDir) =>
Path.Combine(slotDir, "sce_sys", "icon0.png");
/// <summary>
/// Replaces characters that are invalid in a host path segment. Empty or
/// all-invalid input collapses to "default" so a bad guest name can never
/// escape the save root or produce an empty segment.
/// </summary>
public static string Sanitize(string value)
{
if (string.IsNullOrEmpty(value))
{
return "default";
}
var invalid = Path.GetInvalidFileNameChars();
Span<char> buffer = value.Length <= 128 ? stackalloc char[value.Length] : new char[value.Length];
for (var i = 0; i < value.Length; i++)
{
var ch = value[i];
buffer[i] = Array.IndexOf(invalid, ch) >= 0 ? '_' : ch;
}
var sanitized = new string(buffer).Trim();
return string.IsNullOrWhiteSpace(sanitized) ? "default" : sanitized;
}
/// <summary>Reads a slot's metadata, or defaults if none has been written.</summary>
public static SaveDataMetadata ReadMetadata(string slotDir)
{
var path = ParamPath(slotDir);
if (File.Exists(path))
{
try
{
var parsed = JsonSerializer.Deserialize(File.ReadAllText(path), SaveDataMetadataContext.Default.SaveDataMetadata);
if (parsed is not null)
{
return parsed;
}
}
catch (Exception exception) when (exception is JsonException or IOException or UnauthorizedAccessException)
{
// Fall through to defaults on a corrupt or unreadable metadata file.
}
}
return SaveDataMetadata.CreateDefault(Path.GetFileName(slotDir.TrimEnd(Path.DirectorySeparatorChar)));
}
/// <summary>Writes a slot's metadata, creating <c>sce_sys/</c> as needed.</summary>
public static void WriteMetadata(string slotDir, SaveDataMetadata metadata)
{
var path = ParamPath(slotDir);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, JsonSerializer.Serialize(metadata, SaveDataMetadataContext.Default.SaveDataMetadata));
}
}
/// <summary>PS5 save-slot metadata surfaced by sceSaveDataGetParam / the save UI.</summary>
public sealed record SaveDataMetadata
{
[JsonPropertyName("title")]
public string Title { get; init; } = "Saved Data";
[JsonPropertyName("subTitle")]
public string SubTitle { get; init; } = string.Empty;
[JsonPropertyName("detail")]
public string Detail { get; init; } = string.Empty;
[JsonPropertyName("userParam")]
public uint UserParam { get; init; }
public static SaveDataMetadata CreateDefault(string dirName) =>
new() { Title = string.IsNullOrWhiteSpace(dirName) ? "Saved Data" : dirName };
}
[JsonSerializable(typeof(SaveDataMetadata))]
[JsonSourceGenerationOptions(WriteIndented = true)]
internal sealed partial class SaveDataMetadataContext : JsonSerializerContext
{
}
-1
View File
@@ -7,7 +7,6 @@ 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"
@@ -0,0 +1,122 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers;
using System.Numerics;
namespace SharpEmu.Libs.VideoOut;
internal sealed class BoundedByteArrayPool : ArrayPool<byte>
{
private readonly object _gate = new();
private readonly int _maxArrayLength;
private readonly ulong _maxCachedBytes;
private readonly int _maxArraysPerBucket;
private readonly Dictionary<int, Stack<byte[]>> _cachedByBucket = [];
private readonly HashSet<byte[]> _leases =
new(System.Collections.Generic.ReferenceEqualityComparer.Instance);
private ulong _cachedBytes;
public BoundedByteArrayPool(
int maxArrayLength,
ulong maxCachedBytes,
int maxArraysPerBucket)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArrayLength);
ArgumentOutOfRangeException.ThrowIfZero(maxCachedBytes);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArraysPerBucket);
_maxArrayLength = maxArrayLength;
_maxCachedBytes = maxCachedBytes;
_maxArraysPerBucket = maxArraysPerBucket;
}
public override byte[] Rent(int minimumLength)
{
ArgumentOutOfRangeException.ThrowIfNegative(minimumLength);
var length = GetAllocationLength(minimumLength);
byte[]? array = null;
lock (_gate)
{
if (length <= _maxArrayLength &&
_cachedByBucket.TryGetValue(length, out var bucket) &&
bucket.TryPop(out array))
{
_cachedBytes -= (ulong)array.LongLength;
}
array ??= new byte[length];
_leases.Add(array);
}
return array;
}
public override void Return(byte[] array, bool clearArray = false)
{
ArgumentNullException.ThrowIfNull(array);
lock (_gate)
{
if (!_leases.Remove(array))
{
return;
}
}
if (clearArray)
{
Array.Clear(array);
}
lock (_gate)
{
if (array.Length > _maxArrayLength ||
!IsBucketLength(array.Length) ||
(ulong)array.LongLength > _maxCachedBytes -
Math.Min(_cachedBytes, _maxCachedBytes))
{
return;
}
if (!_cachedByBucket.TryGetValue(array.Length, out var bucket))
{
bucket = new Stack<byte[]>();
_cachedByBucket.Add(array.Length, bucket);
}
if (bucket.Count >= _maxArraysPerBucket)
{
return;
}
bucket.Push(array);
_cachedBytes += (ulong)array.LongLength;
}
}
public void Trim()
{
lock (_gate)
{
_cachedByBucket.Clear();
_cachedBytes = 0;
}
}
private int GetAllocationLength(int minimumLength)
{
if (minimumLength <= 16)
{
return 16;
}
if (minimumLength > _maxArrayLength)
{
return minimumLength;
}
return checked((int)BitOperations.RoundUpToPowerOf2((uint)minimumLength));
}
private static bool IsBucketLength(int length) =>
length >= 16 && (length & (length - 1)) == 0;
}
+7 -10
View File
@@ -36,7 +36,6 @@ public static class PerfOverlay
private static long _presentedInWindow;
private static long _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();
@@ -75,8 +74,11 @@ public static class PerfOverlay
if (last != 0)
{
var milliseconds = (now - last) * 1000.0 / Stopwatch.Frequency;
_frameMilliseconds[_frameHistoryIndex] = milliseconds;
_frameHistoryIndex = (_frameHistoryIndex + 1) % FrameHistorySize;
if (milliseconds < 1000.0)
{
_frameMilliseconds[_frameHistoryIndex] = milliseconds;
_frameHistoryIndex = (_frameHistoryIndex + 1) % FrameHistorySize;
}
}
}
@@ -86,9 +88,6 @@ 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.
@@ -166,7 +165,7 @@ public static class PerfOverlay
Environment.ProcessorCount;
_lastCpuTime = cpuTime;
var drawsPerFrame = _fps > 0 ? _drawsPerSecond / _fps : 0;
var drawsPerFrame = _fps > 0.5 ? _drawsPerSecond / _fps : 0;
var sessionStart = Interlocked.Read(ref _sessionStartTimestamp);
var elapsedSeconds = sessionStart == 0
? 0L
@@ -177,9 +176,7 @@ 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}";
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}%";
_line4 = $"CPU {_cpuPercent:0}% HEAP {GC.GetTotalMemory(false) / (1024 * 1024)} MB F1 HIDE";
_line5 = $"TIME {elapsedHours:00}:{elapsedMinutes:00}:{elapsedRemainingSeconds:00}";
}
}
+8 -20
View File
@@ -131,14 +131,9 @@ 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()}{backendSuffix}";
_windowTitle = $"{_windowTitle} · {gpuName.Trim()}";
}
}
@@ -171,12 +166,11 @@ public static class VideoOutExports
HostSessionControl.RequestShutdown(reason);
// A hosted game can still be issuing AGC work after it requests its
// own shutdown. Keep the presenter's resources alive until the GUI
// session reaches its guest-safe exit path and disposes the host
// surface.
// own shutdown. Keep the Vulkan resources alive until the GUI session
// reaches its guest-safe exit path and disposes the host surface.
if (!embedded)
{
GuestGpu.Current.RequestClose();
VulkanVideoPresenter.RequestClose();
}
// The embedded GUI owns the process lifetime. A guest shutdown should
@@ -1076,12 +1070,6 @@ public static class VideoOutExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
// 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;
@@ -1102,7 +1090,7 @@ public static class VideoOutExports
return OrbisVideoOutErrorInvalidValue;
}
if (category > 1 || option != 0)
if (categoryRaw != 0 || option != 0)
{
return OrbisVideoOutErrorInvalidValue;
}
@@ -1221,7 +1209,7 @@ public static class VideoOutExports
{
TriggerFlipEvents();
}
else if (GuestGpu.Current.SubmitOrderedGuestAction(
else if (VulkanVideoPresenter.SubmitOrderedGuestAction(
TriggerFlipEvents,
$"videoout flip complete handle={handle} index={bufferIndex}") == 0)
{
@@ -1275,7 +1263,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) = GuestGpu.Current.ReadAndResetPerfCounters();
var (draws, drawMs, pipelines, spirvCompiles) = VulkanVideoPresenter.ReadAndResetPerfCounters();
Console.Error.WriteLine(
$"[LOADER][PERF] videoout submitted_fps={submitted / elapsedSeconds:F1} " +
$"presented_fps={presentedCount / elapsedSeconds:F1} " +
@@ -1714,7 +1702,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 the presenter's
// the backend keys its guest-image registry on (see VulkanVideoPresenter.
// GetGuestTextureFormat: format=10 => 56 for 8-bit RGBA variants, format=9 => 9 for 10-bit).
// 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
@@ -1,58 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.ShaderCompiler;
namespace SharpEmu.ShaderCompiler.Metal;
// MSL-specific shader artifact types. These stay beside the MSL emitter (not in the
// backend-neutral SharpEmu.ShaderCompiler project): each codegen owns its own
// compiled-shader shape, mirroring Gen5SpirvShader on the Vulkan side.
public enum Gen5MslStage
{
Vertex,
Pixel,
Compute,
}
/// <summary>
/// A translated Metal shader: MSL source text plus the reflection data the Metal
/// backend needs to bind it. Buffer argument indices follow the translation
/// contract documented on <see cref="Gen5MslTranslator"/>: global memory buffers
/// occupy [[buffer(globalBufferBase + i)]] in <see cref="GlobalMemoryBindings"/>
/// order, and compute shaders reserve one trailing slot for the dispatch-limit
/// uniform. Unlike SPIR-V, Metal fixes the threadgroup size at dispatch time, so
/// the size the shader was translated for is carried here.
/// </summary>
/// <remarks>
/// <para><see cref="UniformsBufferIndex"/> is the [[buffer(N)]] slot this stage's
/// SharpEmuUniforms argument was emitted at (globalBufferBase +
/// totalGlobalBufferCount, both translation-time inputs). Stages sharing a draw
/// can disagree — a vertex stage whose guest buffers sit after the pixel
/// stage's has a higher base — so the presenter must bind the uniforms buffer
/// per stage at this exact index rather than assuming one shared slot.</para>
/// <para>Texture slots are global across a draw's stages ([[texture(
/// ImageBindingBase + i)]]). Samplers live in a per-stage argument buffer
/// bound at <see cref="SamplerArgBufferIndex"/> (Metal caps direct
/// [[sampler(N)]] slots at 16 per stage, but shaders sample more), holding one
/// sampler per sampled image. <see cref="SamplerSlots"/> maps this stage's
/// image binding index to its [[id(N)]] entry in that argument buffer, -1 for
/// storage images that take none; <see cref="SamplerCount"/> is the entry
/// count.</para>
/// </remarks>
public sealed record Gen5MslShader(
string Source,
string EntryPoint,
Gen5MslStage Stage,
IReadOnlyList<Gen5GlobalMemoryBinding> GlobalMemoryBindings,
IReadOnlyList<Gen5ImageBinding> ImageBindings,
uint AttributeCount,
IReadOnlyList<Gen5VertexInputBinding> VertexInputs,
uint ThreadgroupSizeX = 1,
uint ThreadgroupSizeY = 1,
uint ThreadgroupSizeZ = 1,
int UniformsBufferIndex = -1,
int ImageBindingBase = 0,
IReadOnlyList<int>? SamplerSlots = null,
int SamplerCount = 0,
int SamplerArgBufferIndex = -1);
File diff suppressed because it is too large Load Diff
@@ -1,888 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Text;
using SharpEmu.ShaderCompiler;
namespace SharpEmu.ShaderCompiler.Metal;
public static partial class Gen5MslTranslator
{
private sealed partial class CompilationContext
{
private const uint ImageDescriptorDwords = 8;
private const uint SamplerDescriptorDwords = 4;
// ---- image resources ----
/// <summary>
/// Classifies every image binding (storage vs sampled, component kind
/// from the descriptor's unified format) and seeds the PC lookup,
/// mirroring DeclareImages on the SPIR-V side. MSL needs no format on
/// the texture type — only the component type and access.
/// </summary>
private void DeclareImageKinds()
{
for (var index = 0; index < _evaluation.ImageBindings.Count; index++)
{
var binding = _evaluation.ImageBindings[index];
_imageBindingByPc.TryAdd(binding.Pc, index);
var isStorage = Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode);
_imageKinds.Add((isStorage, DecodeImageComponentKind(binding.ResourceDescriptor)));
}
// Seed each binding's access from the opcode that defined it; the body
// emission (TryEmitImage) then ORs in the access of every instruction
// that resolves to the same binding, so a load and a store sharing one
// binding correctly become read_write.
_imageBindingReads = new bool[_imageKinds.Count];
_imageBindingWrites = new bool[_imageKinds.Count];
for (var index = 0; index < _evaluation.ImageBindings.Count; index++)
{
MarkImageBindingAccess(index, _evaluation.ImageBindings[index].Opcode);
}
// Assign one sampler per sampled image (storage images take none).
// Computed before body emission because the sample calls reference
// the slots. Samplers live in an argument buffer (see
// EmitImageArguments), so there is no 16-slot cap to dedup against —
// each image keeps its own sampler, matching the SPIR-V/Vulkan path.
_samplerSlots = new int[_imageKinds.Count];
_samplerCount = 0;
for (var index = 0; index < _imageKinds.Count; index++)
{
_samplerSlots[index] = _imageKinds[index].IsStorage ? -1 : _samplerCount++;
}
}
/// <summary>Records that <paramref name="opcode"/> reads and/or writes the
/// storage image at <paramref name="bindingIndex"/>, so EmitImageArguments
/// can pick the minimal Metal access qualifier.</summary>
private void MarkImageBindingAccess(int bindingIndex, string opcode)
{
if ((uint)bindingIndex >= (uint)_imageBindingReads.Length)
{
return;
}
if (opcode.StartsWith("ImageStore", StringComparison.Ordinal))
{
_imageBindingWrites[bindingIndex] = true;
}
else if (opcode.StartsWith("ImageAtomic", StringComparison.Ordinal))
{
_imageBindingReads[bindingIndex] = true;
_imageBindingWrites[bindingIndex] = true;
}
else
{
// ImageLoad/ImageLoadMip and ImageGetResinfo read the texture;
// sampled ops are non-storage and ignore these flags.
_imageBindingReads[bindingIndex] = true;
}
}
/// <summary>"float", "int", or "uint" from the descriptor's unified format.</summary>
private static string DecodeImageComponentKind(IReadOnlyList<uint> descriptor)
{
if (descriptor.Count < 2)
{
return "float";
}
var unifiedFormat = (descriptor[1] >> 20) & 0x1FFu;
if (!Gfx10UnifiedFormat.TryDecode(unifiedFormat, out _, out var numberType))
{
return "float";
}
return numberType switch
{
4 => "uint",
5 => "int",
_ => "float",
};
}
/// <summary>Per image binding: its sampler's [[id(N)]] inside the sampler
/// argument buffer, or -1 for storage images. Set by DeclareImageKinds.</summary>
private int[] _samplerSlots = [];
/// <summary>Number of sampled images (= sampler argument-buffer entries).</summary>
private int _samplerCount;
/// <summary>Buffer slot the sampler argument buffer binds to, past this
/// stage's global buffers, uniforms, and scalar-state buffer.</summary>
private int SamplerArgBufferIndex =>
Math.Max(UniformsBufferIndex, _initialScalarBufferIndex) + 1;
/// <summary>Emits the texture arguments (direct [[texture(N)]] slots, which
/// run to 31 — enough) plus, when the stage samples anything, the sampler
/// argument buffer. Samplers go through an argument buffer rather than
/// [[sampler(N)]] slots because Metal caps those at 16 per stage while
/// real shaders sample more (void Terrarium's scene shader: 17); argument
/// buffers have no such limit on Apple Silicon.</summary>
private void EmitImageArguments(StringBuilder source)
{
for (var index = 0; index < _imageKinds.Count; index++)
{
var (isStorage, kind) = _imageKinds[index];
var textureSlot = _imageBindingBase + index;
if (isStorage)
{
// Minimal access keeps read_write textures under Metal's cap
// of 8 per function: only images that are both read and
// written (or resolve a load and a store to one binding) need
// read_write; the rest are read-only or write-only.
var access = _imageBindingWrites[index]
? (_imageBindingReads[index] ? "read_write" : "write")
: "read";
source.AppendLine(
$" texture2d<{kind}, access::{access}> tex{index} [[texture({textureSlot})]],");
}
else
{
source.AppendLine($" texture2d<{kind}> tex{index} [[texture({textureSlot})]],");
}
}
if (_samplerCount > 0)
{
source.AppendLine(
$" constant Gen5Samplers& sharpemu_samplers [[buffer({SamplerArgBufferIndex})]],");
}
}
/// <summary>Declares the sampler argument-buffer struct at file scope (one
/// sampler per sampled image). Empty when the stage samples nothing.</summary>
private void EmitSamplerArgumentBufferStruct(StringBuilder source)
{
if (_samplerCount == 0)
{
return;
}
source.AppendLine("struct Gen5Samplers");
source.AppendLine("{");
for (var slot = 0; slot < _samplerCount; slot++)
{
source.AppendLine($" sampler smp{slot} [[id({slot})]];");
}
source.AppendLine("};");
source.AppendLine();
}
private bool TryResolveDominatingImageBinding(
Gen5ShaderInstruction instruction,
Gen5ImageControl control,
out int bindingIndex)
{
if (_imageBindingByPc.TryGetValue(instruction.Pc, out bindingIndex) &&
bindingIndex < _imageKinds.Count)
{
return true;
}
var storage = Gen5ShaderTranslator.IsStorageImageOperation(instruction.Opcode);
for (var index = 0; index < _evaluation.ImageBindings.Count; index++)
{
var candidate = _evaluation.ImageBindings[index];
if (candidate.Control.ScalarResource != control.ScalarResource ||
candidate.Control.ScalarSampler != control.ScalarSampler ||
Gen5ShaderTranslator.IsStorageImageOperation(candidate.Opcode) != storage ||
!HasSameScalarDefinitions(
candidate.Pc,
instruction.Pc,
control.ScalarResource,
ImageDescriptorDwords) ||
(UsesSampler(instruction.Opcode) &&
!HasSameScalarDefinitions(
candidate.Pc,
instruction.Pc,
control.ScalarSampler,
SamplerDescriptorDwords)))
{
continue;
}
bindingIndex = index;
_imageBindingByPc.Add(instruction.Pc, index);
return true;
}
bindingIndex = -1;
return false;
}
private bool HasSameScalarDefinitions(
uint candidatePc,
uint targetPc,
uint firstRegister,
uint registerCount)
{
if (firstRegister + registerCount > ScalarRegisterFileCount ||
!_scalarDefinitionsBeforePc.TryGetValue(candidatePc, out var candidate) ||
!_scalarDefinitionsBeforePc.TryGetValue(targetPc, out var target))
{
return false;
}
for (var register = firstRegister;
register < firstRegister + registerCount;
register++)
{
var definition = candidate[register];
if (definition is ConflictingScalarDefinition or UnreachableScalarDefinition ||
target[register] != definition)
{
return false;
}
}
return true;
}
private static bool UsesSampler(string opcode) =>
opcode.StartsWith("ImageSample", StringComparison.Ordinal) ||
opcode.StartsWith("ImageGather", StringComparison.Ordinal);
// ---- image instruction emission ----
private bool TryEmitImage(
Gen5ShaderInstruction instruction,
Gen5ImageControl image,
out string error)
{
error = string.Empty;
if (!TryResolveDominatingImageBinding(instruction, image, out var bindingIndex))
{
error = $"unresolved image binding t=s{image.ScalarResource} s=s{image.ScalarSampler}";
return false;
}
// The resolving instruction may differ from the one that defined the
// binding (a store can dominate a load's binding); fold its access in.
MarkImageBindingAccess(bindingIndex, instruction.Opcode);
var (isStorage, kind) = _imageKinds[bindingIndex];
var texture = $"tex{bindingIndex}";
if (instruction.Opcode == "ImageGetResinfo")
{
var width = Temp("uint", isStorage
? $"{texture}.get_width()"
: $"{texture}.get_width(0)");
var height = Temp("uint", isStorage
? $"{texture}.get_height()"
: $"{texture}.get_height(0)");
uint outputIndex = 0;
for (var component = 0; component < 4; component++)
{
if ((image.Dmask & (1u << component)) == 0)
{
continue;
}
StoreVector(
image.VectorData + outputIndex++,
component switch
{
0 => width,
1 => height,
_ => "1u",
});
}
return true;
}
if (instruction.Opcode is "ImageStore" or "ImageStoreMip")
{
if (!isStorage)
{
error = "image store is not bound as storage";
return false;
}
var x = Temp("int", $"as_type<int>({ImageIntegerAddress(image, 0)})");
var y = Temp("int", $"as_type<int>({ImageIntegerAddress(image, 1)})");
var components = new string[4];
uint sourceIndex = 0;
for (var component = 0; component < 4; component++)
{
components[component] = (image.Dmask & (1u << component)) != 0
? ImageTexelComponent(kind, ImageStoreComponent(image, kind, sourceIndex++))
: kind == "float" ? "0.0f" : "0";
}
// Bounds-checked, EXEC-guarded write.
Line($"if (exec && {x} >= 0 && {y} >= 0 && {x} < (int){texture}.get_width() && {y} < (int){texture}.get_height())");
Line("{");
_indent++;
Line($"{texture}.write({VectorLiteral(kind)}({components[0]}, {components[1]}, {components[2]}, {components[3]}), uint2((uint){x}, (uint){y}));");
_indent--;
Line("}");
return true;
}
if (isStorage && instruction.Opcode is not ("ImageLoad" or "ImageLoadMip"))
{
error = $"unsupported storage image opcode {instruction.Opcode}";
return false;
}
string sampled;
var writeAllComponents = false;
if (instruction.Opcode is "ImageLoad" or "ImageLoadMip")
{
var mip = _evaluation.ImageBindings[bindingIndex].MipLevel ?? 0;
var widthQuery = isStorage ? $"{texture}.get_width()" : $"{texture}.get_width({mip}u)";
var heightQuery = isStorage ? $"{texture}.get_height()" : $"{texture}.get_height({mip}u)";
var x = Temp(
"uint",
$"(uint)clamp(as_type<int>({ImageIntegerAddress(image, 0)}), 0, (int){widthQuery} - 1)");
var y = Temp(
"uint",
$"(uint)clamp(as_type<int>({ImageIntegerAddress(image, 1)}), 0, (int){heightQuery} - 1)");
sampled = Temp(
$"vec<{kind}, 4>",
isStorage
? $"{texture}.read(uint2({x}, {y}))"
: $"{texture}.read(uint2({x}, {y}), {mip}u)");
}
else if (instruction.Opcode.StartsWith("ImageSample", StringComparison.Ordinal))
{
if (!TryEmitImageSample(instruction, image, bindingIndex, kind, out sampled, out error))
{
return false;
}
}
else if (instruction.Opcode.StartsWith("ImageGather4", StringComparison.Ordinal))
{
if (!TryEmitImageGather(instruction, image, bindingIndex, kind, out sampled, out error))
{
return false;
}
writeAllComponents = true;
}
else
{
error = $"unsupported image opcode {instruction.Opcode}";
return false;
}
var outputValues = new List<string>(4);
for (var component = 0; component < 4; component++)
{
if (!writeAllComponents && (image.Dmask & (1u << component)) == 0)
{
continue;
}
var value = $"{sampled}[{component}]";
outputValues.Add(kind == "uint" ? value : AsUInt(value));
}
if (image.D16)
{
for (var index = 0; index < outputValues.Count; index += 2)
{
var low = outputValues[index];
var high = index + 1 < outputValues.Count ? outputValues[index + 1] : "0u";
StoreVector(
image.VectorData + (uint)(index / 2),
PackImageD16(kind, low, high));
}
}
else
{
for (var index = 0; index < outputValues.Count; index++)
{
StoreVector(image.VectorData + (uint)index, outputValues[index]);
}
}
return true;
}
private bool TryEmitImageSample(
Gen5ShaderInstruction instruction,
Gen5ImageControl image,
int bindingIndex,
string kind,
out string sampled,
out string error)
{
sampled = string.Empty;
error = string.Empty;
var opcode = instruction.Opcode;
var texture = $"tex{bindingIndex}";
var samplerName = $"sharpemu_samplers.smp{_samplerSlots[bindingIndex]}";
var hasOffset = opcode.EndsWith("O", StringComparison.Ordinal);
var hasCompare = opcode.Contains("SampleC", StringComparison.Ordinal);
var hasGradients = opcode.Contains("SampleD", StringComparison.Ordinal);
var hasZeroLod = opcode.Contains("Lz", StringComparison.Ordinal);
var hasLod = !hasZeroLod && opcode.Contains("SampleL", StringComparison.Ordinal);
var hasBias = opcode.Contains("SampleB", StringComparison.Ordinal);
// RDNA MIMG address operands are ordered
// {offset}{bias}{z-compare}{derivatives}{body}; SAMPLE_L carries LOD
// as the final body component instead.
var addressCursor = 0;
var offsetX = "0";
var offsetY = "0";
if (hasOffset)
{
addressCursor = AlignFullImageAddress(image, addressCursor);
var packed = Temp(
"int",
$"as_type<int>(v[{image.GetAddressRegister(ImageAddressRegister(image, addressCursor))}])");
offsetX = Temp("int", $"extract_bits({packed}, 0u, 6u)");
offsetY = Temp("int", $"extract_bits({packed}, 8u, 6u)");
addressCursor += ImageFullAddressSlots(image);
}
var bias = hasBias ? Temp("float", ImageFloatAddress(image, addressCursor++)) : "0.0f";
var reference = "0.0f";
if (hasCompare)
{
addressCursor = AlignFullImageAddress(image, addressCursor);
reference = Temp(
"float",
$"as_type<float>(v[{image.GetAddressRegister(ImageAddressRegister(image, addressCursor))}])");
addressCursor += ImageFullAddressSlots(image);
}
var gradientX = "float2(0.0f)";
var gradientY = "float2(0.0f)";
if (hasGradients)
{
gradientX = Temp(
"float2",
$"float2({ImageFloatAddress(image, addressCursor)}, {ImageFloatAddress(image, addressCursor + 1)})");
gradientY = Temp(
"float2",
$"float2({ImageFloatAddress(image, addressCursor + 2)}, {ImageFloatAddress(image, addressCursor + 3)})");
addressCursor += 4;
}
var coordinates = Temp(
"float2",
$"float2({ImageFloatAddress(image, addressCursor)}, {ImageFloatAddress(image, addressCursor + 1)})");
var lod = hasZeroLod
? "0.0f"
: hasLod
? Temp("float", ImageFloatAddress(image, addressCursor + 2))
: bias;
if (hasOffset)
{
// Per-lane texel offsets fold into normalized coordinates using
// the selected mip extent, mirroring the SPIR-V translator
// (Metal sample offsets must be compile-time constants).
var explicitLod = hasGradients || hasZeroLod || hasLod;
var offsetLod = explicitLod && !hasGradients ? lod : "0.0f";
var mipLevel = Temp("uint", $"(uint)max((int)({offsetLod}), 0)");
coordinates = Temp(
"float2",
$"{coordinates} + float2((float){offsetX} / (float){texture}.get_width({mipLevel}), " +
$"(float){offsetY} / (float){texture}.get_height({mipLevel}))");
}
var samplerArguments = hasGradients
? $", gradient2d({gradientX}, {gradientY})"
: hasZeroLod || hasLod
? $", level({lod})"
: hasBias
? $", bias({bias})"
: string.Empty;
sampled = Temp(
$"vec<{kind}, 4>",
$"{texture}.sample({samplerName}, {coordinates}{samplerArguments})");
if (hasCompare)
{
// Manual PCF: reference passes when <= texel, broadcast (r,r,r,1).
var passes = Temp("bool", $"{reference} <= (float){sampled}[0]");
var one = kind == "float" ? "1.0f" : "1";
var zero = kind == "float" ? "0.0f" : "0";
sampled = Temp(
$"vec<{kind}, 4>",
$"{VectorLiteral(kind)}({passes} ? {one} : {zero}, {passes} ? {one} : {zero}, {passes} ? {one} : {zero}, {one})");
}
return true;
}
private bool TryEmitImageGather(
Gen5ShaderInstruction instruction,
Gen5ImageControl image,
int bindingIndex,
string kind,
out string sampled,
out string error)
{
sampled = string.Empty;
error = string.Empty;
var opcode = instruction.Opcode;
var texture = $"tex{bindingIndex}";
var samplerName = $"sharpemu_samplers.smp{_samplerSlots[bindingIndex]}";
var hasOffset = opcode.EndsWith("O", StringComparison.Ordinal);
var hasCompare = opcode.Contains("Gather4C", StringComparison.Ordinal);
var addressCursor = 0;
var offset = "int2(0)";
if (hasOffset)
{
var packed = Temp(
"int",
$"as_type<int>(v[{image.GetAddressRegister(ImageAddressRegister(image, addressCursor))}])");
offset = Temp(
"int2",
$"int2(extract_bits({packed}, 0u, 6u), extract_bits({packed}, 8u, 6u))");
addressCursor += ImageFullAddressSlots(image);
}
var reference = "0.0f";
if (hasCompare)
{
addressCursor = AlignFullImageAddress(image, addressCursor);
reference = Temp(
"float",
$"as_type<float>(v[{image.GetAddressRegister(ImageAddressRegister(image, addressCursor))}])");
addressCursor += ImageFullAddressSlots(image);
}
var coordinates = Temp(
"float2",
$"float2({ImageFloatAddress(image, addressCursor)}, {ImageFloatAddress(image, addressCursor + 1)})");
// The gathered component is selected from the first dmask bit.
uint component = 0;
while (component < 3 && (image.Dmask & (1u << (int)component)) == 0)
{
component++;
}
var componentName = hasCompare ? "x" : component switch
{
0 => "x",
1 => "y",
2 => "z",
_ => "w",
};
sampled = Temp(
$"vec<{kind}, 4>",
$"{texture}.gather({samplerName}, {coordinates}, {offset}, component::{componentName})");
if (hasCompare)
{
var one = kind == "float" ? "1.0f" : "1";
var zero = kind == "float" ? "0.0f" : "0";
var compared = Temp(
$"vec<{kind}, 4>",
$"{VectorLiteral(kind)}(" +
$"{reference} <= (float){sampled}[0] ? {one} : {zero}, " +
$"{reference} <= (float){sampled}[1] ? {one} : {zero}, " +
$"{reference} <= (float){sampled}[2] ? {one} : {zero}, " +
$"{reference} <= (float){sampled}[3] ? {one} : {zero})");
sampled = compared;
}
return true;
}
private static string VectorLiteral(string kind) => $"vec<{kind}, 4>";
private static int ImageAddressRegister(Gen5ImageControl image, int component) =>
image.A16 ? component / 2 : component;
private static int ImageFullAddressSlots(Gen5ImageControl image) =>
image.A16 ? 2 : 1;
private static int AlignFullImageAddress(Gen5ImageControl image, int component) =>
image.A16 ? (component + 1) & ~1 : component;
/// <summary>Float address component, unpacking A16 half pairs.</summary>
private string ImageFloatAddress(Gen5ImageControl image, int component)
{
var register = image.GetAddressRegister(ImageAddressRegister(image, component));
return image.A16
? $"(float)as_type<half2>(v[{register}])[{component & 1}]"
: $"as_type<float>(v[{register}])";
}
/// <summary>Integer address component, unpacking A16 16-bit pairs.</summary>
private string ImageIntegerAddress(Gen5ImageControl image, int component)
{
var register = image.GetAddressRegister(ImageAddressRegister(image, component));
return image.A16
? $"((v[{register}] >> {(component & 1) * 16}) & 0xFFFFu)"
: $"v[{register}]";
}
/// <summary>One store-source component, unpacking D16 halves.</summary>
private string ImageStoreComponent(Gen5ImageControl image, string kind, uint component)
{
if (!image.D16)
{
return $"v[{image.VectorData + component}]";
}
var packed = $"v[{image.VectorData + (component / 2)}]";
if (kind == "float")
{
return AsUInt($"(float)as_type<half2>({packed})[{component & 1}]");
}
var low = $"(({packed} >> {(component & 1) * 16}) & 0xFFFFu)";
return kind == "int"
? $"(uint)extract_bits(as_type<int>({low}), 0u, 16u)"
: low;
}
private static string ImageTexelComponent(string kind, string raw) => kind switch
{
"int" => $"as_type<int>({raw})",
"uint" => raw,
_ => $"as_type<float>({raw})",
};
private string PackImageD16(string kind, string low, string high)
{
if (kind == "float")
{
return $"(((uint)as_type<ushort>(half(as_type<float>({low})))) | (((uint)as_type<ushort>(half(as_type<float>({high})))) << 16))";
}
return $"((({low}) & 0xFFFFu) | ((({high}) & 0xFFFFu) << 16))";
}
// ---- exports ----
private bool TryEmitExport(
Gen5ShaderInstruction instruction,
Gen5ExportControl export,
out string error)
{
error = string.Empty;
if (instruction.Sources.Count < 4)
{
error = "missing export sources";
return false;
}
if (_stage == Gen5MslStage.Vertex)
{
return TryEmitVertexExport(instruction, export);
}
if (_stage != Gen5MslStage.Pixel)
{
// Compute programs have no export interface.
return true;
}
Gen5PixelOutputBinding? binding = null;
foreach (var candidate in _pixelOutputBindings)
{
if (candidate.GuestSlot == export.Target)
{
binding = candidate;
break;
}
}
if (binding is null)
{
return true;
}
var field = $"sharpemu_out.mrt{binding.Value.GuestSlot}";
var componentType = binding.Value.Kind switch
{
Gen5PixelOutputKind.Uint => "uint",
Gen5PixelOutputKind.Sint => "int",
_ => "float",
};
var values = new string[4];
for (var component = 0; component < 4; component++)
{
if ((export.EnableMask & (1u << component)) == 0)
{
values[component] = $"{field}[{component}]";
continue;
}
if (export.Compressed)
{
var packed = $"v[{instruction.Sources[component >> 1].Value}]";
var half = $"(float)as_type<half2>({packed})[{component & 1}]";
values[component] = binding.Value.Kind switch
{
Gen5PixelOutputKind.Uint => $"(uint)({half})",
Gen5PixelOutputKind.Sint => $"(int)({half})",
_ => half,
};
continue;
}
var raw = $"v[{instruction.Sources[component].Value}]";
values[component] = binding.Value.Kind switch
{
Gen5PixelOutputKind.Uint => raw,
Gen5PixelOutputKind.Sint => $"as_type<int>({raw})",
_ => $"as_type<float>({raw})",
};
}
// A lane removed from EXEC keeps the previous output value; killed
// fragments are discarded in the epilogue.
Line($"{field} = exec ? vec<{componentType}, 4>({values[0]}, {values[1]}, {values[2]}, {values[3]}) : {field};");
return true;
}
private bool TryEmitVertexExport(
Gen5ShaderInstruction instruction,
Gen5ExportControl export)
{
// Target 12 is POS0; 32..63 are the param outputs. Everything else
// (other position slots, MRTZ) is ignored like the SPIR-V side.
string field;
if (export.Target == 12)
{
field = "sharpemu_out.sharpemu_position";
}
else if (export.Target is >= 32 and < 64 &&
_vertexOutputs.Contains(export.Target - 32))
{
field = $"sharpemu_out.param{export.Target - 32}";
}
else
{
return true;
}
var values = new string[4];
for (var component = 0; component < 4; component++)
{
if ((export.EnableMask & (1u << component)) == 0)
{
values[component] = component == 3 ? "1.0f" : "0.0f";
continue;
}
if (export.Compressed)
{
var packed = $"v[{instruction.Sources[component >> 1].Value}]";
values[component] = $"(float)as_type<half2>({packed})[{component & 1}]";
continue;
}
values[component] = $"as_type<float>(v[{instruction.Sources[component].Value}])";
}
Line($"{field} = exec ? float4({values[0]}, {values[1]}, {values[2]}, {values[3]}) : {field};");
return true;
}
/// <summary>
/// Vertex attribute fetch: the evaluator captured this buffer load as a
/// fixed-function vertex input, so read the stage_in field instead of
/// guest memory (bound via MTLVertexDescriptor by the backend).
/// </summary>
private bool TryEmitVertexInputFetch(
Gen5BufferMemoryControl control,
Gen5VertexInputBinding input,
out string error)
{
error = string.Empty;
if (control.DwordCount == 0 || control.DwordCount > input.ComponentCount)
{
error =
$"invalid vertex input fetch components={control.DwordCount} " +
$"input={input.ComponentCount}";
return false;
}
for (uint component = 0; component < control.DwordCount; component++)
{
var value = input.ComponentCount == 1
? $"sharpemu_vin.in{input.Location}"
: $"sharpemu_vin.in{input.Location}[{component}]";
StoreVector(control.VectorData + component, AsUInt(value));
}
return true;
}
// ---- interpolation / pixel inputs ----
private bool TryEmitInterpolation(
Gen5ShaderInstruction instruction,
Gen5InterpolationControl interpolation,
out string error)
{
error = string.Empty;
if (_stage != Gen5MslStage.Pixel ||
!_pixelAttributes.Contains(interpolation.Attribute) ||
instruction.Destinations.Count == 0 ||
instruction.Destinations[0].Kind != Gen5OperandKind.VectorRegister)
{
error = "invalid interpolated attribute";
return false;
}
StoreVector(
instruction.Destinations[0].Value,
AsUInt($"sharpemu_in.attr{interpolation.Attribute}[{interpolation.Channel}]"));
return true;
}
/// <summary>
/// Seeds pixel input VGPRs in SPI_PS_INPUT_ADDR compact order: the
/// interpolation slots reserve registers even though V_INTERP reads MSL
/// varyings directly, and the position inputs land in the
/// hardware-selected VGPRs from the fragment coordinate.
/// </summary>
private void EmitPixelInputState(StringBuilder source)
{
uint vgpr = 0;
void Advance(int bit, uint dwordCount)
{
if ((_pixelInputAddress & (1u << bit)) != 0)
{
vgpr += dwordCount;
}
}
void Position(int bit, string component)
{
var mask = 1u << bit;
if ((_pixelInputAddress & mask) == 0)
{
return;
}
if ((_pixelInputEnable & mask) != 0)
{
source.AppendLine(
$" v[{vgpr}] = as_type<uint>(sharpemu_in.sharpemu_frag_coord.{component});");
}
vgpr++;
}
Advance(0, 2); // PERSP_SAMPLE
Advance(1, 2); // PERSP_CENTER
Advance(2, 2); // PERSP_CENTROID
Advance(3, 3); // PERSP_PULL_MODEL
Advance(4, 2); // LINEAR_SAMPLE
Advance(5, 2); // LINEAR_CENTER
Advance(6, 2); // LINEAR_CENTROID
Advance(7, 1); // LINE_STIPPLE
Position(8, "x");
Position(9, "y");
Position(10, "z");
Position(11, "w");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,81 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Globalization;
using System.Text;
namespace SharpEmu.ShaderCompiler.Metal;
/// <summary>
/// The fixed presenter shaders, mirroring SpirvFixedShaders semantically. The
/// MSL lives in Templates/*.msl (authored as real Metal source); this class
/// only substitutes the per-call parameters. Entry point names are stable
/// (Metal forbids "main"); textures and samplers bind at index 0; attributes
/// use the same user(locn) convention as the translated stages.
/// </summary>
public static class MslFixedShaders
{
/// <summary>
/// Fullscreen triangle from the vertex index; every attribute location in
/// 0..attributeCount-1 carries (x, y, 0, 1) so paired fragment stages can
/// read a screen-space UV from any location.
/// </summary>
public static string CreateFullscreenVertex(uint attributeCount)
{
var fields = new StringBuilder();
var stores = new StringBuilder();
for (uint index = 0; index < attributeCount; index++)
{
if (index != 0)
{
fields.AppendLine();
stores.AppendLine();
}
fields.Append($" float4 attr{index} [[user(locn{index})]];");
stores.Append($" out.attr{index} = float4(x, y, 0.0f, 1.0f);");
}
return MslTemplates.Render(
"fullscreen_vertex",
("attribute_fields", fields.ToString()),
("attribute_stores", stores.ToString()));
}
/// <summary>Samples texture 0 at the interpolated location-0 UV.</summary>
public static string CreateCopyFragment() => MslTemplates.Render("copy_fragment");
/// <summary>
/// The presenter's blit stage: samples texture 0 with V flipped, because
/// pairing the shared fullscreen triangle with Metal's y-up NDC puts UV
/// (0,0) at the bottom of the screen while textures keep v=0 at the top.
/// </summary>
public static string CreatePresentFragment() => MslTemplates.Render("present_fragment");
public static string CreateSolidFragment(float red, float green, float blue, float alpha) =>
MslTemplates.Render(
"solid_fragment",
("red", Format(red)),
("green", Format(green)),
("blue", Format(blue)),
("alpha", Format(alpha)));
/// <summary>
/// Diagnostic fragment stage exposing one interpolated vertex output
/// directly as color, isolating fragment translation from interface data.
/// </summary>
public static string CreateAttributeFragment(uint location) =>
MslTemplates.Render(
"attribute_fragment",
("location", location.ToString(CultureInfo.InvariantCulture)));
/// <summary>
/// Output-free fragment stage for fixed-function depth-only passes: the
/// guest has no pixel shader, so no color may be written while depth
/// testing still runs for the translated vertex shader.
/// </summary>
public static string CreateDepthOnlyFragment() => MslTemplates.Render("depth_only_fragment");
private static string Format(float value) =>
value.ToString("0.0######", CultureInfo.InvariantCulture) + "f";
}
@@ -1,55 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Concurrent;
using System.Text;
namespace SharpEmu.ShaderCompiler.Metal;
/// <summary>
/// Loads the static MSL blocks from embedded Templates/*.msl resources and
/// substitutes {{placeholder}} tokens. The static prelude and fixed shaders
/// are authored as real Metal source files; only the per-instruction body
/// emission stays programmatic in the translator.
/// </summary>
internal static class MslTemplates
{
private static readonly ConcurrentDictionary<string, string> _cache = new(StringComparer.Ordinal);
public static string Render(string name, params (string Key, string Value)[] substitutions)
{
var template = _cache.GetOrAdd(name, Load);
if (substitutions.Length == 0)
{
return template;
}
var builder = new StringBuilder(template);
foreach (var (key, value) in substitutions)
{
builder.Replace("{{" + key + "}}", value);
}
var rendered = builder.ToString();
var marker = rendered.IndexOf("{{", StringComparison.Ordinal);
if (marker >= 0)
{
var end = rendered.IndexOf("}}", marker, StringComparison.Ordinal);
var token = end > marker ? rendered[marker..(end + 2)] : "{{...";
throw new InvalidOperationException(
$"template '{name}' has an unsubstituted placeholder {token}");
}
return rendered;
}
private static string Load(string name)
{
var assembly = typeof(MslTemplates).Assembly;
var resourceName = $"SharpEmu.ShaderCompiler.Metal.Templates.{name}.msl";
using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"missing embedded MSL template {resourceName}");
using var reader = new StreamReader(stream, Encoding.UTF8);
return reader.ReadToEnd();
}
}
@@ -1,26 +0,0 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
<Project Sdk="Microsoft.NET.Sdk">
<!-- The Metal codegen backend: consumes the backend-neutral shader IR from
SharpEmu.ShaderCompiler and emits Metal Shading Language source text.
Deliberately has no dependency on Metal bindings — emitters produce text;
renderers own APIs (the Metal backend compiles the source via MTLLibrary). -->
<PropertyGroup>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SharpEmu.ShaderCompiler\SharpEmu.ShaderCompiler.csproj" />
</ItemGroup>
<ItemGroup>
<!-- Static MSL blocks (prelude helpers, fixed shaders) authored as real
Metal source; the emitter renders them with placeholder substitution. -->
<EmbeddedResource Include="Templates\**\*.msl" />
</ItemGroup>
</Project>
@@ -1,13 +0,0 @@
#include <metal_stdlib>
using namespace metal;
struct AttributeIn
{
float4 attr{{location}} [[user(locn{{location}})]];
};
fragment float4 attribute_fs(AttributeIn in [[stage_in]])
{
return in.attr{{location}};
}
@@ -1,16 +0,0 @@
#include <metal_stdlib>
using namespace metal;
struct CopyIn
{
float4 attr0 [[user(locn0)]];
};
fragment float4 copy_fs(
CopyIn in [[stage_in]],
texture2d<float> tex0 [[texture(0)]],
sampler smp0 [[sampler(0)]])
{
return tex0.sample(smp0, in.attr0.xy);
}
@@ -1,7 +0,0 @@
#include <metal_stdlib>
using namespace metal;
fragment void depth_only_fs()
{
}
@@ -1,68 +0,0 @@
static constant uint sharpemu_gfx10_formats[128] = {
{{format_table}}
};
static inline void sharpemu_format_layout(uint dfmt, uint component, thread uint& byteOff, thread uint& bitOff, thread uint& bits)
{
byteOff = 0u; bitOff = 0u; bits = 0u;
switch (component * 16u + dfmt)
{
{{layout_cases}}
default: break;
}
}
static inline uint sharpemu_minifloat(uint raw, uint bits)
{
uint mantissaBits = bits - 5u;
uint mantissa = raw & ((1u << mantissaBits) - 1u);
uint exponent = (raw >> mantissaBits) & 0x1Fu;
uint shift = 23u - mantissaBits;
if (exponent == 31u)
{
return 0x7F800000u | (mantissa << shift);
}
if (exponent == 0u)
{
float scale = mantissaBits == 6u ? (1.0f / 1048576.0f) : (1.0f / 524288.0f);
return as_type<uint>((float)mantissa * scale);
}
return ((exponent + 112u) << 23) | (mantissa << shift);
}
static inline uint sharpemu_format_one(uint nfmt)
{
return (nfmt == 4u || nfmt == 5u) ? 1u : 0x3F800000u;
}
static inline uint sharpemu_format_convert(uint raw, uint bits, uint nfmt, uint dfmt)
{
uint lowMask = bits >= 32u ? 0xFFFFFFFFu : ((1u << bits) - 1u);
int signedRaw = extract_bits(as_type<int>(raw), 0u, bits);
switch (nfmt)
{
case 0u: return as_type<uint>((float)raw / (float)lowMask);
case 1u:
{
float snorm = (float)signedRaw / (float)(lowMask >> 1);
return as_type<uint>(fmax(snorm, -1.0f));
}
case 2u: return as_type<uint>((float)raw);
case 3u: return as_type<uint>((float)signedRaw);
case 5u: return (uint)signedRaw;
case 7u:
{
// 10_11_11/11_11_10 packed floats are unsigned mini-floats.
if (dfmt == 6u || dfmt == 7u)
{
return sharpemu_minifloat(raw, bits);
}
if (bits == 16u)
{
return as_type<uint>((float)as_type<half>((ushort)(raw & 0xFFFFu)));
}
return raw;
}
default: return raw;
}
}
@@ -1,19 +0,0 @@
#include <metal_stdlib>
using namespace metal;
struct FullscreenOut
{
float4 position [[position]];
{{attribute_fields}}
};
vertex FullscreenOut fullscreen_vs(uint vertex_id [[vertex_id]])
{
float x = (float)((vertex_id << 1) & 2u);
float y = (float)(vertex_id & 2u);
FullscreenOut out = {};
out.position = float4(x * 2.0f - 1.0f, y * 2.0f - 1.0f, 0.0f, 1.0f);
{{attribute_stores}}
return out;
}
@@ -1,59 +0,0 @@
static inline uint sharpemu_load_word(device uint* b, uint bytes, uint addr)
{
if ((addr & 3u) == 0u)
{
return addr + 4u <= bytes ? b[addr >> 2] : 0u;
}
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < 4u; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
return value;
}
static inline uint sharpemu_load_bytes(device uint* b, uint bytes, uint addr, uint count, bool signExtend)
{
uint value = 0u;
device const uchar* p = (device const uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
value |= (uint)p[addr + i] << (i * 8u);
}
}
if (signExtend && count < 4u)
{
uint shift = 32u - (count * 8u);
value = (uint)(((int)(value << shift)) >> shift);
}
return value;
}
static inline void sharpemu_store_bytes(device uint* b, uint bytes, uint addr, uint value, uint count)
{
device uchar* p = (device uchar*)b;
for (uint i = 0u; i < count; i++)
{
if (addr + i < bytes)
{
p[addr + i] = (uchar)((value >> (i * 8u)) & 0xFFu);
}
}
}
static inline uint sharpemu_ballot(bool value)
{
return {{ballot_return}};
}
static constant float sharpemu_off_i4_table[16] =
{
0.0f, 0.0625f, 0.1250f, 0.1875f, 0.2500f, 0.3125f, 0.3750f, 0.4375f,
-0.5000f, -0.4375f, -0.3750f, -0.3125f, -0.2500f, -0.1875f, -0.1250f, -0.0625f,
};
@@ -1,16 +0,0 @@
#include <metal_stdlib>
using namespace metal;
struct PresentIn
{
float4 attr0 [[user(locn0)]];
};
fragment float4 present_fs(
PresentIn in [[stage_in]],
texture2d<float> tex0 [[texture(0)]],
sampler smp0 [[sampler(0)]])
{
return tex0.sample(smp0, float2(in.attr0.x, 1.0f - in.attr0.y));
}
@@ -1,8 +0,0 @@
#include <metal_stdlib>
using namespace metal;
fragment float4 solid_fs()
{
return float4({{red}}, {{green}}, {{blue}}, {{alpha}});
}
@@ -953,13 +953,22 @@ public static partial class Gen5SpirvTranslator
case "VPkMulF16":
case "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;
@@ -999,9 +1008,8 @@ public static partial class Gen5SpirvTranslator
// even. For add and mul this is bit-exact to a true f16 op (the f32 result
// 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 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.
// 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.
private bool TryEmitPackedF16(
Gen5ShaderInstruction instruction,
out uint result,
@@ -1021,8 +1029,7 @@ public static partial class Gen5SpirvTranslator
return false;
}
var sourceCount = instruction.Opcode == "VPkFmaF16" ? 3 : 2;
for (var index = 0; index < sourceCount; index++)
for (var index = 0; index < 2; index++)
{
var source = instruction.Sources[index];
if (source.Kind is not (Gen5OperandKind.VectorRegister or Gen5OperandKind.ScalarRegister))
@@ -1047,12 +1054,6 @@ public static partial class Gen5SpirvTranslator
{
var left = EmitPackedF16Operand(instruction, control, 0, highLane);
var 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),
@@ -1064,75 +1065,6 @@ 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,10 +1754,6 @@ 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,7 +238,6 @@ public enum SpirvDecoration : uint
Binding = 33,
DescriptorSet = 34,
Offset = 35,
NoContraction = 42,
}
public enum SpirvBuiltIn : uint
@@ -2350,8 +2350,7 @@ 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) &&
FallbackMemoryReader?.Invoke(address, bytes) != true)
if (!ctx.Memory.TryRead(address, bytes))
{
value = 0;
return false;
@@ -1,99 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.ShaderCompiler;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
public sealed class Gen5ScalarMemoryFallbackTests
{
private const ulong ScalarTableAddress = 0x4_4665_4FD0;
private static readonly object FallbackReaderGate = new();
[Fact]
public void ScalarLoadReadsTrackedFallbackMemory()
{
var expected = new uint[]
{
0x4665_4F70,
0x0000_0004,
0x4EA7_FCE0,
0x0000_0004,
};
var table = new byte[expected.Length * sizeof(uint)];
for (var index = 0; index < expected.Length; index++)
{
BinaryPrimitives.WriteUInt32LittleEndian(
table.AsSpan(index * sizeof(uint), sizeof(uint)),
expected[index]);
}
var load = new Gen5ShaderInstruction(
0,
Gen5ShaderEncoding.Smem,
"SLoadDwordx4",
[],
[Gen5Operand.Scalar(0)],
[
Gen5Operand.Scalar(16),
Gen5Operand.Scalar(17),
Gen5Operand.Scalar(18),
Gen5Operand.Scalar(19),
],
new Gen5ScalarMemoryControl(4, 0, null));
var end = new Gen5ShaderInstruction(
8,
Gen5ShaderEncoding.Sopp,
"SEndpgm",
[],
[],
[],
null);
var state = new Gen5ShaderState(
new Gen5ShaderProgram(0, [load, end]),
[unchecked((uint)ScalarTableAddress), (uint)(ScalarTableAddress >> 32)],
null);
var ctx = new CpuContext(new FakeCpuMemory(0x1000, 0x100), Generation.Gen5);
lock (FallbackReaderGate)
{
var previousReader = Gen5ShaderScalarEvaluator.FallbackMemoryReader;
try
{
Gen5ShaderScalarEvaluator.FallbackMemoryReader = ReadFallback;
Assert.True(
Gen5ShaderScalarEvaluator.TryEvaluate(
ctx,
state,
out var evaluation,
out var error),
error);
Assert.Equal(expected, evaluation.ScalarRegisters.Skip(16).Take(4));
}
finally
{
Gen5ShaderScalarEvaluator.FallbackMemoryReader = previousReader;
}
}
bool ReadFallback(ulong address, Span<byte> destination)
{
if (address < ScalarTableAddress)
{
return false;
}
var offset = address - ScalarTableAddress;
if (offset + (ulong)destination.Length > (ulong)table.Length)
{
return false;
}
table.AsSpan((int)offset, destination.Length).CopyTo(destination);
return true;
}
}
}
@@ -0,0 +1,112 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Reflection;
using SharpEmu.Core.Cpu.Native;
using SharpEmu.HLE;
using Xunit;
namespace SharpEmu.Libs.Tests.Cpu;
public sealed class GuestThreadBlockWaiterRepresentationTests
{
[Fact]
public void SchedulerStoresOnlyTheWaiterObjectRepresentation()
{
var stateType = typeof(DirectExecutionBackend).GetNestedType(
"GuestThreadState",
BindingFlags.NonPublic);
Assert.NotNull(stateType);
var waiterProperty = stateType.GetProperty(
"BlockWaiter",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
Assert.NotNull(waiterProperty);
Assert.Equal(typeof(IGuestThreadBlockWaiter), waiterProperty.PropertyType);
Assert.Null(stateType.GetProperty(
"BlockResumeHandler",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic));
Assert.Null(stateType.GetProperty(
"BlockWakeHandler",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic));
var registerMethods = typeof(DirectExecutionBackend)
.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic)
.Where(method => method.Name == "RegisterBlockedGuestThreadContinuation")
.ToArray();
var registerMethod = Assert.Single(registerMethods);
Assert.Contains(
registerMethod.GetParameters(),
parameter => parameter.ParameterType == typeof(IGuestThreadBlockWaiter));
Assert.DoesNotContain(
registerMethod.GetParameters(),
parameter => IsFuncParameter(parameter.ParameterType));
var consumeMethods = typeof(GuestThreadExecution)
.GetMethods(BindingFlags.Static | BindingFlags.Public)
.Where(method => method.Name == nameof(GuestThreadExecution.TryConsumeCurrentThreadBlock));
Assert.DoesNotContain(
consumeMethods.SelectMany(method => method.GetParameters()),
parameter => IsFuncParameter(parameter.ParameterType));
}
[Fact]
public void DelegateCompatibilityBridgeIsConsumedAsOneWaiterObject()
{
var previousThread = GuestThreadExecution.EnterGuestThread(0x1234);
try
{
var canWake = false;
var wakeCalls = 0;
var resumeCalls = 0;
Assert.True(GuestThreadExecution.RequestCurrentThreadBlock(
context: null,
reason: "test_wait",
wakeKey: "test_waiter:1",
resumeHandler: () =>
{
resumeCalls++;
return 42;
},
wakeHandler: () =>
{
wakeCalls++;
return canWake;
}));
Assert.True(GuestThreadExecution.TryConsumeCurrentThreadBlock(
out var reason,
out _,
out var hasContinuation,
out var wakeKey,
out IGuestThreadBlockWaiter? waiter,
out var deadline));
Assert.Equal("test_wait", reason);
Assert.Equal("test_waiter:1", wakeKey);
Assert.False(hasContinuation);
Assert.Equal(0, deadline);
Assert.NotNull(waiter);
Assert.False(waiter.TryWake());
canWake = true;
Assert.True(waiter.TryWake());
Assert.Equal(42, waiter.Resume());
Assert.Equal(2, wakeCalls);
Assert.Equal(1, resumeCalls);
}
finally
{
GuestThreadExecution.RestoreGuestThread(previousThread);
}
}
private static bool IsFuncParameter(Type parameterType)
{
var type = parameterType.IsByRef
? parameterType.GetElementType()
: parameterType;
return type is not null &&
type.IsGenericType &&
type.GetGenericTypeDefinition() == typeof(Func<>);
}
}
@@ -1,26 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Cpu.Native;
using Xunit;
namespace SharpEmu.Libs.Tests.Cpu;
public sealed unsafe class JitStubsTests
{
[Fact]
public void FindTlsAccessPatterns_IncludesLastValidOffset()
{
var pattern = JitStubs.TlsAccessPattern;
var code = new byte[pattern.Length + 3];
pattern.CopyTo(code.AsSpan(3));
fixed (byte* codePointer = code)
{
var matches = JitStubs.FindTlsAccessPatterns(codePointer, code.Length);
var match = Assert.Single(matches);
Assert.Equal((nint)(codePointer + 3), match);
}
}
}
@@ -1,153 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Cpu.Native;
using SharpEmu.HLE;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Xunit;
namespace SharpEmu.Libs.Tests.Cpu;
public sealed class MemcpyHleRoutingTests
{
private const string MemcpyNid = "Q3VBxCXhUHs";
private const string MemsetNid = "QrZZdJ8XsX0";
private const string RdtscNid = "-2IRUCO--PM";
[Fact]
public void IsHlePreferredNid_PrefersHleForMemcpy_OnEveryPlatform()
{
Assert.True(
InvokeIsHlePreferredNid(MemcpyNid),
$"memcpy ({MemcpyNid}) must route through HLE on every platform. It was previously " +
"gated behind OperatingSystem.IsWindows(), which left Linux and macOS on the LLE " +
"intrinsic stub and faulted in guest code. Do not reintroduce an OS condition here.");
}
[Fact]
public void IsHlePreferredNid_PrefersHleForMemset()
{
Assert.True(
InvokeIsHlePreferredNid(MemsetNid),
$"memset ({MemsetNid}) must route through HLE on every platform.");
}
[Fact]
public void TryCreateNativeImportIntrinsic_DoesNotClaimMemcpy()
{
if (RuntimeInformation.ProcessArchitecture != Architecture.X64)
{
return;
}
var claimed = InvokeTryCreateNativeImportIntrinsic(MemcpyNid, out var address);
Assert.False(
claimed,
$"memcpy ({MemcpyNid}) must fall through to the HLE trampoline. SetupImportStubs tries " +
"the intrinsic stub before the trampoline, so without an IsHlePreferredNid guard here " +
"the intrinsic claims memcpy and the HLE routing never takes effect.");
Assert.Equal(0, address);
}
[Fact]
public void TryCreateNativeImportIntrinsic_StillClaimsNonHleNids()
{
if (RuntimeInformation.ProcessArchitecture != Architecture.X64)
{
return;
}
var claimed = InvokeTryCreateNativeImportIntrinsic(RdtscNid, out var address);
Assert.True(
claimed,
$"rdtsc ({RdtscNid}) has no HLE handler and must still receive an intrinsic stub. If " +
"this fails the memcpy assertions above may be passing vacuously.");
Assert.NotEqual(0, address);
unsafe
{
Assert.True(HostMemory.Free((void*)address, 0, HostMemory.MEM_RELEASE));
}
}
[Fact]
public void IsHlePreferredNid_DoesNotBranchOnHostOperatingSystem()
{
var method = typeof(DirectExecutionBackend).GetMethod(
"IsHlePreferredNid",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
var callees = ResolveCallees(method);
Assert.DoesNotContain(callees, static name =>
name is "IsWindows" or "IsLinux" or "IsMacOS" or "IsOSPlatform" or "IsFreeBSD");
}
private static HashSet<string> ResolveCallees(MethodBase method)
{
var il = method.GetMethodBody()?.GetILAsByteArray();
Assert.NotNull(il);
var module = method.Module;
var generic = method.DeclaringType?.GetGenericArguments();
var callees = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i + 4 < il.Length; i++)
{
if (il[i] is not (0x28 or 0x6F))
{
continue;
}
var token = BitConverter.ToInt32(il, i + 1);
try
{
var callee = module.ResolveMethod(token, generic, null);
if (callee?.Name is { } name)
{
callees.Add(name);
}
}
catch (ArgumentException)
{
}
}
return callees;
}
private static bool InvokeIsHlePreferredNid(string nid)
{
var method = typeof(DirectExecutionBackend).GetMethod(
"IsHlePreferredNid",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.NotNull(method);
return (bool)method.Invoke(null, [nid])!;
}
private static bool InvokeTryCreateNativeImportIntrinsic(string nid, out nint address)
{
var backend = (DirectExecutionBackend)RuntimeHelpers.GetUninitializedObject(
typeof(DirectExecutionBackend));
var trampolineList = typeof(DirectExecutionBackend).GetField(
"_importHandlerTrampolines",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(trampolineList);
trampolineList.SetValue(backend, new List<nint>());
var method = typeof(DirectExecutionBackend).GetMethod(
"TryCreateNativeImportIntrinsic",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
object?[] args = [nid, null];
var claimed = (bool)method.Invoke(backend, args)!;
address = (nint)args[1]!;
return claimed;
}
}
@@ -1,56 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Cpu.Emulation;
using Xunit;
namespace SharpEmu.Libs.Tests.Cpu;
public sealed class Sha1InstructionEmulatorTests
{
private static readonly Sha1Vector Destination = new(
0x0123_4567u, 0x89AB_CDEFu, 0x0F1E_2D3Cu, 0x4B5A_6978u);
private static readonly Sha1Vector Source = new(
0xFEDC_BA98u, 0x7654_3210u, 0xF0E1_D2C3u, 0xB4A5_9687u);
[Fact]
public void MessageSchedule1_MatchesIntelLaneSemantics()
{
Assert.Equal(
new Sha1Vector(0xF1C2_97A4u, 0x3D0E_5B68u, 0x0E3D_685Bu, 0xC2F1_A497u),
Sha1InstructionEmulator.MessageSchedule1(Destination, Source));
}
[Fact]
public void MessageSchedule2_MatchesIntelLaneSemantics()
{
Assert.Equal(
new Sha1Vector(0xECA8_6420u, 0xEEEE_EEEEu, 0xF294_3E58u, 0x7777_7777u),
Sha1InstructionEmulator.MessageSchedule2(Destination, Source));
}
[Fact]
public void NextE_MatchesIntelLaneSemantics()
{
Assert.Equal(
new Sha1Vector(0xFEDC_BA98u, 0x7654_3210u, 0xF0E1_D2C3u, 0xC77C_30E5u),
Sha1InstructionEmulator.NextE(Destination, Source));
}
[Theory]
[InlineData(0, 0x20E8_2326u, 0x911F_2CA8u, 0xECE0_593Fu, 0x0C1C_11FBu)]
[InlineData(1, 0x4598_D5B9u, 0x7BA0_0411u, 0x464E_3C51u, 0xF514_1B52u)]
[InlineData(2, 0xEE0E_73F6u, 0x2509_A67Bu, 0x26C6_85CCu, 0x0097_5845u)]
[InlineData(3, 0x9C7B_0B46u, 0xAEC8_EB49u, 0x8FD5_A2B7u, 0xFD49_9AECu)]
public void FourRounds_MatchesAllFourSha1Functions(
byte function,
uint lane0,
uint lane1,
uint lane2,
uint lane3)
{
Assert.Equal(
new Sha1Vector(lane0, lane1, lane2, lane3),
Sha1InstructionEmulator.FourRounds(Destination, Source, function));
}
}
@@ -1,180 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
using Xunit;
namespace SharpEmu.Libs.Tests.Kernel;
public sealed class KernelEventQueueCompatExportsTests
{
private const ulong MemoryBase = 0x1_0000_0000;
private const int MemorySize = 0x4000;
[Fact]
public void CreateEqueue_WritesNonZeroHandleAndSucceeds()
{
var (context, outAddress) = NewContextWithOutSlot();
context[CpuRegister.Rdi] = outAddress;
var result = KernelEventQueueCompatExports.KernelCreateEqueue(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
Assert.True(context.TryReadUInt64(outAddress, out var handle));
Assert.NotEqual(0UL, handle);
Assert.True(KernelEventQueueCompatExports.IsValidEqueue(handle));
}
[Fact]
public void CreateEqueue_NullOutAddressReturnsInvalidArgument()
{
var (context, _) = NewContextWithOutSlot();
context[CpuRegister.Rdi] = 0;
var result = KernelEventQueueCompatExports.KernelCreateEqueue(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
}
[Fact]
public void DeleteEqueue_RemovesQueueFromRegistry()
{
var (context, outAddress) = NewContextWithOutSlot();
context[CpuRegister.Rdi] = outAddress;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, KernelEventQueueCompatExports.KernelCreateEqueue(context));
Assert.True(context.TryReadUInt64(outAddress, out var handle));
context[CpuRegister.Rdi] = handle;
var result = KernelEventQueueCompatExports.KernelDeleteEqueue(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
Assert.False(KernelEventQueueCompatExports.IsValidEqueue(handle));
}
[Fact]
public void AddUserEvent_OnUnknownQueueReturnsNotFound()
{
var (context, _) = NewContextWithOutSlot();
const ulong unknownHandle = 0xDEAD_BEEF;
context[CpuRegister.Rdi] = unknownHandle;
context[CpuRegister.Rsi] = 42;
var result = KernelEventQueueCompatExports.KernelAddUserEvent(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND, result);
}
[Fact]
public void AddUserEvent_OnValidQueueSucceeds()
{
var handle = CreateEqueue();
var (context, _) = NewContextWithOutSlot();
context[CpuRegister.Rdi] = handle;
context[CpuRegister.Rsi] = 0x1234;
var result = KernelEventQueueCompatExports.KernelAddUserEvent(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
}
[Fact]
public void TriggerUserEvent_OnUnknownQueueReturnsNotFound()
{
var (context, _) = NewContextWithOutSlot();
context[CpuRegister.Rdi] = 0xDEAD_BEEF;
context[CpuRegister.Rsi] = 1;
context[CpuRegister.Rdx] = 0;
var result = KernelEventQueueCompatExports.KernelTriggerUserEvent(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND, result);
}
[Fact]
public void TriggerUserEvent_OnUnregisteredEventReturnsNotFound()
{
var handle = CreateEqueue();
var (context, _) = NewContextWithOutSlot();
context[CpuRegister.Rdi] = handle;
context[CpuRegister.Rsi] = 0xABCD; // never registered
context[CpuRegister.Rdx] = 0;
var result = KernelEventQueueCompatExports.KernelTriggerUserEvent(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND, result);
}
// Full lifecycle: create -> register user event -> trigger -> wait delivers
// the queued event with the registered ident/filter and the trigger data.
// DequeueEvents runs before the blocking path, so a pre-triggered queue
// returns immediately without touching the guest thread scheduler.
[Fact]
public void CreateAddTriggerWait_DeliversTriggeredUserEvent()
{
const ulong eventIdent = 0x4242;
const ulong triggerData = 0x55AA_55AA;
var handle = CreateEqueue();
// Register the user event on the queue.
var (addCtx, _) = NewContextWithOutSlot();
addCtx[CpuRegister.Rdi] = handle;
addCtx[CpuRegister.Rsi] = eventIdent;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelAddUserEvent(addCtx));
// Trigger it with a distinct data payload.
var (triggerCtx, _) = NewContextWithOutSlot();
triggerCtx[CpuRegister.Rdi] = handle;
triggerCtx[CpuRegister.Rsi] = eventIdent;
triggerCtx[CpuRegister.Rdx] = triggerData;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelTriggerUserEvent(triggerCtx));
// Wait should deliver the single pending event without blocking.
var memory = new FakeCpuMemory(MemoryBase, MemorySize);
var waitContext = new CpuContext(memory, Generation.Gen5);
const ulong eventsAddress = MemoryBase + 0x100;
const ulong outCountAddress = MemoryBase + 0x300;
waitContext[CpuRegister.Rdi] = handle;
waitContext[CpuRegister.Rsi] = eventsAddress;
waitContext[CpuRegister.Rdx] = 1; // capacity
waitContext[CpuRegister.Rcx] = outCountAddress;
waitContext[CpuRegister.R8] = 0; // no timeout -> would block, but event is pending
var result = KernelEventQueueCompatExports.KernelWaitEqueue(waitContext);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
Assert.True(waitContext.TryReadUInt32(outCountAddress, out var delivered));
Assert.Equal(1u, delivered);
// KernelEvent layout (0x20): ident(0x00) filter(0x08) flags(0x0A)
// fflags(0x0C) data(0x10) userdata(0x18).
Span<byte> evt = stackalloc byte[0x20];
Assert.True(memory.TryRead(eventsAddress, evt));
Assert.Equal(eventIdent, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x00..]));
Assert.Equal(KernelEventQueueCompatExports.KernelEventFilterUser,
BinaryPrimitives.ReadInt16LittleEndian(evt[0x08..]));
Assert.Equal(triggerData, BinaryPrimitives.ReadUInt64LittleEndian(evt[0x10..]));
}
private static ulong CreateEqueue()
{
var memory = new FakeCpuMemory(MemoryBase, MemorySize);
var context = new CpuContext(memory, Generation.Gen5);
const ulong outAddress = MemoryBase + 0x10;
context[CpuRegister.Rdi] = outAddress;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelEventQueueCompatExports.KernelCreateEqueue(context));
Assert.True(context.TryReadUInt64(outAddress, out var handle));
return handle;
}
private static (CpuContext Context, ulong OutAddress) NewContextWithOutSlot()
{
var memory = new FakeCpuMemory(MemoryBase, MemorySize);
var context = new CpuContext(memory, Generation.Gen5);
return (context, MemoryBase + 0x10);
}
}
@@ -164,149 +164,4 @@ public sealed class KernelMemoryCompatExportsTests
Assert.Equal(0, KernelMemoryCompatExports.KernelReleaseDirectMemory(context));
}
[Fact]
public void MapNamedFlexibleMemory_NullInOutPointerReturnsInvalidArgument()
{
var memory = new FakeCpuMemory(0x1_0000_0000, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = 0;
context[CpuRegister.Rsi] = 0x1000;
context[CpuRegister.Rdx] = 0x03; // CPU read|write
context[CpuRegister.Rcx] = 0;
var result = KernelMemoryCompatExports.KernelMapNamedFlexibleMemory(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
}
[Fact]
public void MapNamedFlexibleMemory_ZeroLengthReturnsInvalidArgument()
{
const ulong memoryBase = 0x1_0000_0000;
const ulong inOutAddress = memoryBase + 0x100;
var memory = new FakeCpuMemory(memoryBase, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
memory.TryWrite(inOutAddress, BitConverter.GetBytes(0UL));
context[CpuRegister.Rdi] = inOutAddress;
context[CpuRegister.Rsi] = 0;
context[CpuRegister.Rdx] = 0x03;
context[CpuRegister.Rcx] = 0;
var result = KernelMemoryCompatExports.KernelMapNamedFlexibleMemory(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
}
[Fact]
public void MapNamedFlexibleMemory_UnreadableInOutPointerReturnsMemoryFault()
{
// The in-out pointer points outside the FakeCpuMemory backing store, so
// the first TryReadUInt64 must fail before any reservation is attempted.
const ulong memoryBase = 0x1_0000_0000;
const ulong unreachableInOut = memoryBase + 0x10_0000;
var memory = new FakeCpuMemory(memoryBase, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = unreachableInOut;
context[CpuRegister.Rsi] = 0x1000;
context[CpuRegister.Rdx] = 0x03;
context[CpuRegister.Rcx] = 0;
var result = KernelMemoryCompatExports.KernelMapNamedFlexibleMemory(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT, result);
}
[Fact]
public void Mprotect_ZeroAddressReturnsInvalidArgument()
{
var memory = new FakeCpuMemory(0x1_0000_0000, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = 0;
context[CpuRegister.Rsi] = 0x4000;
context[CpuRegister.Rdx] = 0x03;
var result = KernelMemoryCompatExports.KernelMprotect(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
}
[Fact]
public void Mprotect_ZeroLengthReturnsInvalidArgument()
{
const ulong memoryBase = 0x1_0000_0000;
var memory = new FakeCpuMemory(memoryBase, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = memoryBase;
context[CpuRegister.Rsi] = 0;
context[CpuRegister.Rdx] = 0x03;
var result = KernelMemoryCompatExports.KernelMprotect(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
}
[Fact]
public void Mprotect_UnmappedRangeReturnsNotFound()
{
// A plausible guest address that FakeCpuMemory does not back and that
// has no host reservation. TryProtectHostRange calls VirtualProtect,
// which fails on an unmapped range, yielding NOT_FOUND rather than
// mutating protection or throwing.
const ulong unmappedAddress = 0x2_0000_0000;
var memory = new FakeCpuMemory(0x1_0000_0000, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = unmappedAddress;
context[CpuRegister.Rsi] = 0x4000;
context[CpuRegister.Rdx] = 0x03;
var result = KernelMemoryCompatExports.KernelMprotect(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND, result);
}
[Fact]
public void Munmap_ZeroAddressReturnsInvalidArgument()
{
var memory = new FakeCpuMemory(0x1_0000_0000, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = 0;
context[CpuRegister.Rsi] = 0x4000;
var result = KernelMemoryCompatExports.KernelMunmap(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
}
[Fact]
public void Munmap_OverflowRangeReturnsInvalidArgument()
{
// address + length would overflow; KernelMunmap guards this explicitly
// before touching any region accounting.
var memory = new FakeCpuMemory(0x1_0000_0000, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = ulong.MaxValue - 0x10;
context[CpuRegister.Rsi] = 0x20;
var result = KernelMemoryCompatExports.KernelMunmap(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
}
[Fact]
public void Munmap_UnmappedRangeReturnsNotFound()
{
// No flexible region is registered at this address and FakeCpuMemory
// does not back it, so both physicallyBacked and removedRegions are
// empty and the export reports NOT_FOUND.
const ulong unmappedAddress = 0x2_0000_0000;
var memory = new FakeCpuMemory(0x1_0000_0000, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = unmappedAddress;
context[CpuRegister.Rsi] = 0x4000;
var result = KernelMemoryCompatExports.KernelMunmap(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND, result);
}
}
@@ -1,137 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.LibcInternal;
using Xunit;
namespace SharpEmu.Libs.Tests.LibcInternal;
public sealed class LibcInternalExportsTests
{
private const ulong Base = 0x3_0000_0000;
private const ulong InfoAddress = Base + 0x100;
private const ulong ExpectedInfoSize = 32;
[Fact]
public void HeapGetTraceInfo_NullPointer_ReturnsInvalidArgument()
{
var memory = new FakeCpuMemory(Base, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = 0;
var result = LibcInternalExports.LibcHeapGetTraceInfo(context);
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
result);
Assert.Equal(0UL, context[CpuRegister.Rax]);
}
[Fact]
public void HeapGetTraceInfo_WrongSize_ReturnsInvalidArgument()
{
var memory = new FakeCpuMemory(Base, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
Span<byte> sizeBytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(
sizeBytes,
ExpectedInfoSize - 1);
Assert.True(memory.TryWrite(InfoAddress, sizeBytes));
context[CpuRegister.Rdi] = InfoAddress;
var result = LibcInternalExports.LibcHeapGetTraceInfo(context);
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
result);
Assert.Equal(0UL, context[CpuRegister.Rax]);
}
[Fact]
public void HeapGetTraceInfo_ValidBuffer_WritesStablePointers()
{
var memory = new FakeCpuMemory(Base, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
Span<byte> sizeBytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(
sizeBytes,
ExpectedInfoSize);
Assert.True(memory.TryWrite(InfoAddress, sizeBytes));
context[CpuRegister.Rdi] = InfoAddress;
var firstResult =
LibcInternalExports.LibcHeapGetTraceInfo(context);
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
firstResult);
Assert.Equal(0UL, context[CpuRegister.Rax]);
Assert.True(
context.TryReadUInt64(
InfoAddress + 16,
out var firstMaskAddress));
Assert.True(
context.TryReadUInt64(
InfoAddress + 24,
out var firstTableAddress));
Assert.NotEqual(0UL, firstMaskAddress);
Assert.Equal(firstMaskAddress + 8UL, firstTableAddress);
var secondResult =
LibcInternalExports.LibcHeapGetTraceInfo(context);
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
secondResult);
Assert.True(
context.TryReadUInt64(
InfoAddress + 16,
out var secondMaskAddress));
Assert.True(
context.TryReadUInt64(
InfoAddress + 24,
out var secondTableAddress));
Assert.Equal(firstMaskAddress, secondMaskAddress);
Assert.Equal(firstTableAddress, secondTableAddress);
}
[Fact]
public void HeapGetTraceInfo_TruncatedOutput_ReturnsMemoryFault()
{
var memory = new FakeCpuMemory(Base, 31);
var context = new CpuContext(memory, Generation.Gen5);
Span<byte> sizeBytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(
sizeBytes,
ExpectedInfoSize);
Assert.True(memory.TryWrite(Base, sizeBytes));
context[CpuRegister.Rdi] = Base;
var result = LibcInternalExports.LibcHeapGetTraceInfo(context);
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT,
result);
Assert.Equal(0UL, context[CpuRegister.Rax]);
}
}

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