mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-25 20:28:48 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0bac470771 | |||
| 37a4798ada | |||
| a9a4f511f0 | |||
| a44ce34a0a | |||
| 41ec56f813 | |||
| aa32d4bad0 | |||
| faf3a397a8 | |||
| 488b285ecb | |||
| 1a4a2902d4 | |||
| f544146d6d | |||
| dbd74654c4 | |||
| 28485b60e2 | |||
| 6b1abc1a38 | |||
| 7494792249 | |||
| 3585519007 | |||
| 8ee0fe8e44 | |||
| 0755ca15f7 | |||
| 74c70cc2c1 | |||
| 73e0ebf446 |
Binary file not shown.
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 82 KiB |
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
## Before submitting
|
||||
|
||||
Please read our contribution guidelines before opening a pull request:
|
||||
|
||||
➡️ [**CONTRIBUTING.md**](CONTRIBUTING.md)
|
||||
|
||||
By opening this pull request, you confirm that you have read and agree to follow the contribution guidelines.
|
||||
|
||||
## Checklist
|
||||
By submitting this PR, you confirm that:
|
||||
|
||||
- [ ] I have read `CONTRIBUTING.md`.
|
||||
- [ ] I tested my changes.
|
||||
- [ ] I wrote this description myself and did not copy AI-generated explanations.
|
||||
- [ ] I listed the game(s) I tested, if applicable.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Copyright (C) 2026 SharpEmu Emulator Project
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Tells the website repo to rebuild when a release is published, so
|
||||
# sharpemu.app/downloads lists the new build within a minute.
|
||||
name: Notify website
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published, released, edited, deleted]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger sharpemu-site rebuild
|
||||
env:
|
||||
TOKEN: ${{ secrets.SITE_DISPATCH_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "SITE_DISPATCH_TOKEN is not set — skipping website rebuild."
|
||||
exit 0
|
||||
fi
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/sharpemu/sharpemu-site/dispatches \
|
||||
-d '{"event_type":"release-published"}'
|
||||
echo "Website rebuild requested."
|
||||
@@ -7,6 +7,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
tags:
|
||||
- "v*"
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "**/*.png"
|
||||
@@ -53,6 +55,11 @@ jobs:
|
||||
release_tag="v${version}"
|
||||
release_name="SharpEmu v${version}"
|
||||
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ] && [ "${GITHUB_REF_NAME}" != "${release_tag}" ]; then
|
||||
echo "Release tag ${GITHUB_REF_NAME} does not match project version ${release_tag}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "short-sha=${short_sha}"
|
||||
echo "safe-ref=${safe_ref}"
|
||||
@@ -203,7 +210,9 @@ jobs:
|
||||
- init
|
||||
- build
|
||||
- build-posix
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
|
||||
# Versioned releases are immutable and tag-driven. Branch and manual runs
|
||||
# still produce Actions artifacts without modifying a published release.
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -213,7 +222,7 @@ jobs:
|
||||
with:
|
||||
path: release
|
||||
|
||||
- name: Create or update release
|
||||
- name: Create release
|
||||
shell: bash
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
@@ -231,26 +240,11 @@ jobs:
|
||||
notes="Automated SharpEmu v${VERSION} build for commit ${GITHUB_SHA}."
|
||||
|
||||
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
|
||||
gh release upload "${RELEASE_TAG}" "${assets[@]}" --clobber
|
||||
gh release edit "${RELEASE_TAG}" --title "${RELEASE_NAME}" --notes "${notes}"
|
||||
else
|
||||
gh release create "${RELEASE_TAG}" "${assets[@]}" \
|
||||
--title "${RELEASE_NAME}" \
|
||||
--notes "${notes}" \
|
||||
--target "${GITHUB_SHA}"
|
||||
echo "Release ${RELEASE_TAG} already exists and will not be modified." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
keep=()
|
||||
for asset_path in "${assets[@]}"; do
|
||||
keep+=("$(basename "${asset_path}")")
|
||||
done
|
||||
mapfile -t release_assets < <(gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name' | sort)
|
||||
for asset in "${release_assets[@]}"; do
|
||||
case "${asset}" in
|
||||
sharpemu-${VERSION}-*.zip|sharpemu-${VERSION}-*.tar.gz)
|
||||
if ! printf '%s\n' "${keep[@]}" | grep -Fxq "${asset}"; then
|
||||
gh release delete-asset "${RELEASE_TAG}" "${asset}" --yes
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
gh release create "${RELEASE_TAG}" "${assets[@]}" \
|
||||
--verify-tag \
|
||||
--title "${RELEASE_NAME}" \
|
||||
--notes "${notes}"
|
||||
|
||||
@@ -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</SharpEmuVersion>
|
||||
<SharpEmuVersion>0.0.2-beta.2</SharpEmuVersion>
|
||||
<Version>$(SharpEmuVersion)</Version>
|
||||
|
||||
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
## Release Script
|
||||
|
||||
SharpEmu releases are prepared and published using `scripts/release.py`.
|
||||
|
||||
The release process consists of two steps:
|
||||
|
||||
1. Prepare the version bump through a pull request.
|
||||
2. Create and push the release tag after the pull request has been merged.
|
||||
|
||||
### Preparing a Release
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python scripts/release.py prepare 0.0.2-beta.2
|
||||
```
|
||||
|
||||
This command will:
|
||||
|
||||
- Verify that the working tree is clean.
|
||||
- Update the local `main` branch.
|
||||
- Create a new branch named `release/0.0.2-beta.2`.
|
||||
- Update `SharpEmuVersion` in `Directory.Build.props`.
|
||||
- Create a version bump commit.
|
||||
- Push the release branch to the remote repository.
|
||||
|
||||
Afterwards, open a pull request from:
|
||||
|
||||
```text
|
||||
release/0.0.2-beta.2
|
||||
```
|
||||
|
||||
into:
|
||||
|
||||
```text
|
||||
main
|
||||
```
|
||||
|
||||
### Publishing a Release
|
||||
|
||||
Once the pull request has been merged, update your local repository:
|
||||
|
||||
```bash
|
||||
git switch main
|
||||
git pull --ff-only
|
||||
```
|
||||
|
||||
Then create and push the release tag:
|
||||
|
||||
```bash
|
||||
python scripts/release.py tag 0.0.2-beta.2
|
||||
```
|
||||
|
||||
This command will:
|
||||
|
||||
- Verify that the working tree is clean.
|
||||
- Confirm that `Directory.Build.props` contains the requested version.
|
||||
- Create an annotated Git tag (`v0.0.2-beta.2`).
|
||||
- Push the tag to the remote repository.
|
||||
|
||||
Pushing the tag automatically triggers the GitHub Release workflow.
|
||||
|
||||
### Version Format
|
||||
|
||||
Specify the version **without** the `v` prefix.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
0.0.2
|
||||
0.0.2-alpha.1
|
||||
0.0.2-beta.1
|
||||
0.0.2-beta.2
|
||||
0.0.2-rc.1
|
||||
```
|
||||
|
||||
The script automatically prefixes the Git tag with `v`.
|
||||
|
||||
### Notes
|
||||
|
||||
- Run `prepare` only from the `main` branch.
|
||||
- Run `tag` only after the version bump pull request has been merged.
|
||||
- Do not create release tags manually before merging the version bump.
|
||||
- Both commands require a clean working tree.
|
||||
- The version in `Directory.Build.props` must exactly match the version passed to the `tag` command.
|
||||
@@ -0,0 +1,408 @@
|
||||
#!/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 re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$")
|
||||
VERSION_ELEMENT_PATTERN = re.compile(
|
||||
r"(<SharpEmuVersion>)([^<]+)(</SharpEmuVersion>)"
|
||||
)
|
||||
|
||||
|
||||
class ReleaseError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def run_git(
|
||||
*args: str,
|
||||
cwd: Path,
|
||||
capture_output: bool = False,
|
||||
) -> str:
|
||||
command = ["git", *args]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=cwd,
|
||||
check=True,
|
||||
text=True,
|
||||
capture_output=capture_output,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise ReleaseError("Git was not found in PATH.") from None
|
||||
except subprocess.CalledProcessError as error:
|
||||
stderr = error.stderr.strip() if error.stderr else ""
|
||||
detail = f"\n{stderr}" if stderr else ""
|
||||
|
||||
raise ReleaseError(
|
||||
f"Git command failed: {' '.join(command)}{detail}"
|
||||
) from error
|
||||
|
||||
return result.stdout.strip() if capture_output else ""
|
||||
|
||||
|
||||
def find_repository_root(script_path: Path) -> Path:
|
||||
root = run_git(
|
||||
"rev-parse",
|
||||
"--show-toplevel",
|
||||
cwd=script_path.resolve().parent,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
return Path(root)
|
||||
|
||||
|
||||
def get_status(repository_root: Path) -> str:
|
||||
return run_git(
|
||||
"status",
|
||||
"--porcelain",
|
||||
cwd=repository_root,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def ensure_clean_worktree(repository_root: Path) -> None:
|
||||
status = get_status(repository_root)
|
||||
|
||||
if status:
|
||||
raise ReleaseError(
|
||||
"The working tree is not clean.\n\n"
|
||||
f"{status}\n\n"
|
||||
"Commit, stash, or remove these changes first."
|
||||
)
|
||||
|
||||
|
||||
def get_current_branch(repository_root: Path) -> str:
|
||||
branch = run_git(
|
||||
"branch",
|
||||
"--show-current",
|
||||
cwd=repository_root,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
if not branch:
|
||||
raise ReleaseError(
|
||||
"HEAD is detached. Switch to a branch before continuing."
|
||||
)
|
||||
|
||||
return branch
|
||||
|
||||
|
||||
def ensure_branch_does_not_exist(
|
||||
repository_root: Path,
|
||||
branch: str,
|
||||
remote: str,
|
||||
) -> None:
|
||||
local_branch = run_git(
|
||||
"branch",
|
||||
"--list",
|
||||
branch,
|
||||
cwd=repository_root,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
if local_branch:
|
||||
raise ReleaseError(f"Branch {branch} already exists locally.")
|
||||
|
||||
remote_branch = run_git(
|
||||
"ls-remote",
|
||||
"--heads",
|
||||
remote,
|
||||
branch,
|
||||
cwd=repository_root,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
if remote_branch:
|
||||
raise ReleaseError(
|
||||
f"Branch {branch} already exists on {remote}."
|
||||
)
|
||||
|
||||
|
||||
def ensure_tag_does_not_exist(
|
||||
repository_root: Path,
|
||||
tag: str,
|
||||
remote: str,
|
||||
) -> None:
|
||||
local_tag = run_git(
|
||||
"tag",
|
||||
"--list",
|
||||
tag,
|
||||
cwd=repository_root,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
if local_tag:
|
||||
raise ReleaseError(f"Tag {tag} already exists locally.")
|
||||
|
||||
remote_tag = run_git(
|
||||
"ls-remote",
|
||||
"--tags",
|
||||
remote,
|
||||
f"refs/tags/{tag}",
|
||||
cwd=repository_root,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
if remote_tag:
|
||||
raise ReleaseError(f"Tag {tag} already exists on {remote}.")
|
||||
|
||||
|
||||
def read_version(props_path: Path) -> str:
|
||||
if not props_path.exists():
|
||||
raise ReleaseError(f"Version file not found: {props_path}")
|
||||
|
||||
content = props_path.read_text(encoding="utf-8")
|
||||
match = VERSION_ELEMENT_PATTERN.search(content)
|
||||
|
||||
if match is None:
|
||||
raise ReleaseError(
|
||||
f"SharpEmuVersion was not found in {props_path.name}."
|
||||
)
|
||||
|
||||
return match.group(2).strip()
|
||||
|
||||
|
||||
def update_version(props_path: Path, version: str) -> str:
|
||||
content = props_path.read_text(encoding="utf-8")
|
||||
current_version = read_version(props_path)
|
||||
|
||||
if current_version == version:
|
||||
raise ReleaseError(
|
||||
f"SharpEmuVersion is already set to {version}."
|
||||
)
|
||||
|
||||
updated_content, replacement_count = VERSION_ELEMENT_PATTERN.subn(
|
||||
rf"\g<1>{version}\g<3>",
|
||||
content,
|
||||
count=1,
|
||||
)
|
||||
|
||||
if replacement_count != 1:
|
||||
raise ReleaseError(
|
||||
"Expected exactly one SharpEmuVersion element."
|
||||
)
|
||||
|
||||
props_path.write_text(
|
||||
updated_content,
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
return current_version
|
||||
|
||||
|
||||
def prepare_release(
|
||||
repository_root: Path,
|
||||
props_path: Path,
|
||||
version: str,
|
||||
remote: str,
|
||||
) -> None:
|
||||
ensure_clean_worktree(repository_root)
|
||||
|
||||
current_branch = get_current_branch(repository_root)
|
||||
|
||||
if current_branch != "main":
|
||||
raise ReleaseError(
|
||||
f"Prepare must be run from main, not {current_branch}."
|
||||
)
|
||||
|
||||
run_git(
|
||||
"pull",
|
||||
"--ff-only",
|
||||
remote,
|
||||
"main",
|
||||
cwd=repository_root,
|
||||
)
|
||||
|
||||
branch = f"release/{version}"
|
||||
ensure_branch_does_not_exist(
|
||||
repository_root,
|
||||
branch,
|
||||
remote,
|
||||
)
|
||||
|
||||
previous_version = read_version(props_path)
|
||||
|
||||
run_git(
|
||||
"switch",
|
||||
"-c",
|
||||
branch,
|
||||
cwd=repository_root,
|
||||
)
|
||||
|
||||
try:
|
||||
update_version(props_path, version)
|
||||
|
||||
relative_props_path = props_path.relative_to(repository_root)
|
||||
|
||||
run_git(
|
||||
"add",
|
||||
relative_props_path.as_posix(),
|
||||
cwd=repository_root,
|
||||
)
|
||||
run_git(
|
||||
"commit",
|
||||
"-m",
|
||||
f"chore: bump version to {version}",
|
||||
cwd=repository_root,
|
||||
)
|
||||
run_git(
|
||||
"push",
|
||||
"-u",
|
||||
remote,
|
||||
branch,
|
||||
cwd=repository_root,
|
||||
)
|
||||
except Exception:
|
||||
print(
|
||||
"\nPrepare failed. The release branch may still exist locally.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise
|
||||
|
||||
print()
|
||||
print(f"Prepared release {previous_version} -> {version}")
|
||||
print(f"Branch pushed: {branch}")
|
||||
print()
|
||||
print("Open a pull request from:")
|
||||
print(f" {branch}")
|
||||
print("into:")
|
||||
print(" main")
|
||||
print()
|
||||
print("After merging the PR, run:")
|
||||
print(f" python scripts/release.py tag {version}")
|
||||
|
||||
|
||||
def create_release_tag(
|
||||
repository_root: Path,
|
||||
props_path: Path,
|
||||
version: str,
|
||||
remote: str,
|
||||
) -> None:
|
||||
ensure_clean_worktree(repository_root)
|
||||
|
||||
current_branch = get_current_branch(repository_root)
|
||||
|
||||
if current_branch != "main":
|
||||
raise ReleaseError(
|
||||
f"Tagging must be run from main, not {current_branch}."
|
||||
)
|
||||
|
||||
run_git(
|
||||
"pull",
|
||||
"--ff-only",
|
||||
remote,
|
||||
"main",
|
||||
cwd=repository_root,
|
||||
)
|
||||
|
||||
current_version = read_version(props_path)
|
||||
|
||||
if current_version != version:
|
||||
raise ReleaseError(
|
||||
"Version mismatch:\n"
|
||||
f" Directory.Build.props: {current_version}\n"
|
||||
f" Requested tag: {version}"
|
||||
)
|
||||
|
||||
tag = f"v{version}"
|
||||
|
||||
ensure_tag_does_not_exist(
|
||||
repository_root,
|
||||
tag,
|
||||
remote,
|
||||
)
|
||||
|
||||
run_git(
|
||||
"tag",
|
||||
"-a",
|
||||
tag,
|
||||
"-m",
|
||||
f"SharpEmu {version}",
|
||||
cwd=repository_root,
|
||||
)
|
||||
run_git(
|
||||
"push",
|
||||
remote,
|
||||
tag,
|
||||
cwd=repository_root,
|
||||
)
|
||||
|
||||
print()
|
||||
print(f"Successfully pushed tag {tag}.")
|
||||
print("The release workflow should start automatically.")
|
||||
|
||||
|
||||
def parse_arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Prepare or tag a SharpEmu release."
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(
|
||||
dest="command",
|
||||
required=True,
|
||||
)
|
||||
|
||||
for command in ("prepare", "tag"):
|
||||
subparser = subparsers.add_parser(command)
|
||||
subparser.add_argument(
|
||||
"version",
|
||||
help="Version without the v prefix, e.g. 0.0.2-beta.2.",
|
||||
)
|
||||
subparser.add_argument(
|
||||
"--remote",
|
||||
default="origin",
|
||||
help="Git remote. Default: origin.",
|
||||
)
|
||||
|
||||
arguments = parser.parse_args()
|
||||
|
||||
if not VERSION_PATTERN.fullmatch(arguments.version):
|
||||
parser.error(
|
||||
"Version must look like 0.0.2, "
|
||||
"0.0.2-beta.2, or 0.0.2-rc.1."
|
||||
)
|
||||
|
||||
return arguments
|
||||
|
||||
|
||||
def main() -> int:
|
||||
arguments = parse_arguments()
|
||||
|
||||
try:
|
||||
repository_root = find_repository_root(Path(__file__))
|
||||
props_path = repository_root / "Directory.Build.props"
|
||||
|
||||
if arguments.command == "prepare":
|
||||
prepare_release(
|
||||
repository_root,
|
||||
props_path,
|
||||
arguments.version,
|
||||
arguments.remote,
|
||||
)
|
||||
else:
|
||||
create_release_tag(
|
||||
repository_root,
|
||||
props_path,
|
||||
arguments.version,
|
||||
arguments.remote,
|
||||
)
|
||||
except ReleaseError as error:
|
||||
print(f"Error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -5127,64 +5127,51 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
BindTlsBase(context);
|
||||
byte* ptr2 = (byte*)ptr;
|
||||
ulong hostRspSlot = (ulong)hostRspStorage;
|
||||
int offset = 0;
|
||||
var emitter = new NativeCodeEmitter(ptr2);
|
||||
|
||||
void Emit(byte value) => ptr2[offset++] = value;
|
||||
void EmitU64(ulong value)
|
||||
{
|
||||
*(ulong*)(ptr2 + offset) = value;
|
||||
offset += sizeof(ulong);
|
||||
}
|
||||
void EmitMovR64Imm(byte rex, byte opcode, ulong value)
|
||||
{
|
||||
Emit(rex);
|
||||
Emit(opcode);
|
||||
EmitU64(value);
|
||||
}
|
||||
|
||||
Emit(0x53); // push rbx
|
||||
Emit(0x55); // push rbp
|
||||
Emit(0x57); // push rdi
|
||||
Emit(0x56); // push rsi
|
||||
Emit(0x41); Emit(0x54); // push r12
|
||||
Emit(0x41); Emit(0x55); // push r13
|
||||
Emit(0x41); Emit(0x56); // push r14
|
||||
Emit(0x41); Emit(0x57); // push r15
|
||||
EmitHostNonvolatileXmmSave(ptr2, ref offset);
|
||||
emitter.Emit(0x53); // push rbx
|
||||
emitter.Emit(0x55); // push rbp
|
||||
emitter.Emit(0x57); // push rdi
|
||||
emitter.Emit(0x56); // push rsi
|
||||
emitter.Emit(0x41); emitter.Emit(0x54); // push r12
|
||||
emitter.Emit(0x41); emitter.Emit(0x55); // push r13
|
||||
emitter.Emit(0x41); emitter.Emit(0x56); // push r14
|
||||
emitter.Emit(0x41); emitter.Emit(0x57); // push r15
|
||||
EmitHostNonvolatileXmmSave(ptr2, ref emitter.Offset);
|
||||
// Restore the fiber's floating-point control environment before
|
||||
// abandoning the host stack. This path is used when a blocked guest
|
||||
// continuation migrates to another managed worker.
|
||||
Emit(0x48); Emit(0x83); Emit(0xEC); Emit(0x08); // sub rsp,8
|
||||
Emit(0xC7); Emit(0x04); Emit(0x24); // mov dword [rsp],imm32
|
||||
*(uint*)(ptr2 + offset) = context.Mxcsr; offset += sizeof(uint);
|
||||
Emit(0x0F); Emit(0xAE); Emit(0x14); Emit(0x24); // ldmxcsr [rsp]
|
||||
Emit(0x66); Emit(0xC7); Emit(0x04); Emit(0x24); // mov word [rsp],imm16
|
||||
*(ushort*)(ptr2 + offset) = context.FpuControlWord; offset += sizeof(ushort);
|
||||
Emit(0xD9); Emit(0x2C); Emit(0x24); // fldcw [rsp]
|
||||
Emit(0x48); Emit(0x83); Emit(0xC4); Emit(0x08); // add rsp,8
|
||||
EmitMovR64Imm(0x49, 0xBA, hostRspSlot); // mov r10, hostRspSlot
|
||||
Emit(0x49); Emit(0x89); Emit(0x22); // mov [r10], rsp
|
||||
EmitMovR64Imm(0x48, 0xB8, context[CpuRegister.Rsp]); // mov rax, guest rsp
|
||||
Emit(0x48); Emit(0x89); Emit(0xC4); // mov rsp, rax
|
||||
Emit(0x48); Emit(0x83); Emit(0xEC); Emit(0x08); // reserve transfer slot
|
||||
EmitMovR64Imm(0x48, 0xB8, entryPoint); // mov rax, entryPoint
|
||||
Emit(0x48); Emit(0x89); Emit(0x04); Emit(0x24); // mov [rsp],rax
|
||||
EmitMovR64Imm(0x48, 0xBB, context[CpuRegister.Rbx]); // mov rbx, imm64
|
||||
EmitMovR64Imm(0x48, 0xBD, context[CpuRegister.Rbp]); // mov rbp, imm64
|
||||
EmitMovR64Imm(0x48, 0xBF, context[CpuRegister.Rdi]); // mov rdi, imm64
|
||||
EmitMovR64Imm(0x48, 0xBE, context[CpuRegister.Rsi]); // mov rsi, imm64
|
||||
EmitMovR64Imm(0x48, 0xBA, context[CpuRegister.Rdx]); // mov rdx, imm64
|
||||
EmitMovR64Imm(0x48, 0xB9, context[CpuRegister.Rcx]); // mov rcx, imm64
|
||||
EmitMovR64Imm(0x49, 0xB8, context[CpuRegister.R8]); // mov r8, imm64
|
||||
EmitMovR64Imm(0x49, 0xB9, context[CpuRegister.R9]); // mov r9, imm64
|
||||
EmitMovR64Imm(0x49, 0xBA, context[CpuRegister.R10]); // mov r10, imm64
|
||||
EmitMovR64Imm(0x49, 0xBC, context[CpuRegister.R12]); // mov r12, imm64
|
||||
EmitMovR64Imm(0x49, 0xBD, context[CpuRegister.R13]); // mov r13, imm64
|
||||
EmitMovR64Imm(0x49, 0xBE, context[CpuRegister.R14]); // mov r14, imm64
|
||||
EmitMovR64Imm(0x49, 0xBF, context[CpuRegister.R15]); // mov r15, imm64
|
||||
EmitMovR64Imm(0x49, 0xBB, context[CpuRegister.R11]); // mov r11, imm64
|
||||
EmitMovR64Imm(0x48, 0xB8, context[CpuRegister.Rax]); // mov rax, imm64
|
||||
Emit(0xC3); // ret through the synthetic transfer slot
|
||||
emitter.Emit(0x48); emitter.Emit(0x83); emitter.Emit(0xEC); emitter.Emit(0x08); // sub rsp,8
|
||||
emitter.Emit(0xC7); emitter.Emit(0x04); emitter.Emit(0x24); // mov dword [rsp],imm32
|
||||
emitter.Emit(context.Mxcsr);
|
||||
emitter.Emit(0x0F); emitter.Emit(0xAE); emitter.Emit(0x14); emitter.Emit(0x24); // ldmxcsr [rsp]
|
||||
emitter.Emit(0x66); emitter.Emit(0xC7); emitter.Emit(0x04); emitter.Emit(0x24); // mov word [rsp],imm16
|
||||
emitter.Emit(context.FpuControlWord);
|
||||
emitter.Emit(0xD9); emitter.Emit(0x2C); emitter.Emit(0x24); // fldcw [rsp]
|
||||
emitter.Emit(0x48); emitter.Emit(0x83); emitter.Emit(0xC4); emitter.Emit(0x08); // add rsp,8
|
||||
emitter.EmitMovR64Immediate(0x49, 0xBA, hostRspSlot); // mov r10, hostRspSlot
|
||||
emitter.Emit(0x49); emitter.Emit(0x89); emitter.Emit(0x22); // mov [r10], rsp
|
||||
emitter.EmitMovR64Immediate(0x48, 0xB8, context[CpuRegister.Rsp]); // mov rax, guest rsp
|
||||
emitter.Emit(0x48); emitter.Emit(0x89); emitter.Emit(0xC4); // mov rsp, rax
|
||||
emitter.Emit(0x48); emitter.Emit(0x83); emitter.Emit(0xEC); emitter.Emit(0x08); // reserve transfer slot
|
||||
emitter.EmitMovR64Immediate(0x48, 0xB8, entryPoint); // mov rax, entryPoint
|
||||
emitter.Emit(0x48); emitter.Emit(0x89); emitter.Emit(0x04); emitter.Emit(0x24); // mov [rsp],rax
|
||||
emitter.EmitMovR64Immediate(0x48, 0xBB, context[CpuRegister.Rbx]); // mov rbx, imm64
|
||||
emitter.EmitMovR64Immediate(0x48, 0xBD, context[CpuRegister.Rbp]); // mov rbp, imm64
|
||||
emitter.EmitMovR64Immediate(0x48, 0xBF, context[CpuRegister.Rdi]); // mov rdi, imm64
|
||||
emitter.EmitMovR64Immediate(0x48, 0xBE, context[CpuRegister.Rsi]); // mov rsi, imm64
|
||||
emitter.EmitMovR64Immediate(0x48, 0xBA, context[CpuRegister.Rdx]); // mov rdx, imm64
|
||||
emitter.EmitMovR64Immediate(0x48, 0xB9, context[CpuRegister.Rcx]); // mov rcx, imm64
|
||||
emitter.EmitMovR64Immediate(0x49, 0xB8, context[CpuRegister.R8]); // mov r8, imm64
|
||||
emitter.EmitMovR64Immediate(0x49, 0xB9, context[CpuRegister.R9]); // mov r9, imm64
|
||||
emitter.EmitMovR64Immediate(0x49, 0xBA, context[CpuRegister.R10]); // mov r10, imm64
|
||||
emitter.EmitMovR64Immediate(0x49, 0xBC, context[CpuRegister.R12]); // mov r12, imm64
|
||||
emitter.EmitMovR64Immediate(0x49, 0xBD, context[CpuRegister.R13]); // mov r13, imm64
|
||||
emitter.EmitMovR64Immediate(0x49, 0xBE, context[CpuRegister.R14]); // mov r14, imm64
|
||||
emitter.EmitMovR64Immediate(0x49, 0xBF, context[CpuRegister.R15]); // mov r15, imm64
|
||||
emitter.EmitMovR64Immediate(0x49, 0xBB, context[CpuRegister.R11]); // mov r11, imm64
|
||||
emitter.EmitMovR64Immediate(0x48, 0xB8, context[CpuRegister.Rax]); // mov rax, imm64
|
||||
emitter.Emit(0xC3); // ret through the synthetic transfer slot
|
||||
ActiveEntryReturnSentinelRip = (ulong)_guestReturnStub;
|
||||
if (returnSlotAddress == 0 || !context.TryWriteUInt64(returnSlotAddress, (ulong)_guestReturnStub))
|
||||
{
|
||||
@@ -5248,6 +5235,45 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
}
|
||||
}
|
||||
|
||||
// The continuation trampoline is rebuilt on every blocked-thread resume.
|
||||
// Keep its tiny writer on the stack: capturing local emit functions create a
|
||||
// managed display-class allocation on this extremely hot path.
|
||||
private unsafe ref struct NativeCodeEmitter(byte* code)
|
||||
{
|
||||
private readonly byte* _code = code;
|
||||
public int Offset;
|
||||
|
||||
public void Emit(byte value)
|
||||
{
|
||||
_code[Offset++] = value;
|
||||
}
|
||||
|
||||
public void Emit(ushort value)
|
||||
{
|
||||
*(ushort*)(_code + Offset) = value;
|
||||
Offset += sizeof(ushort);
|
||||
}
|
||||
|
||||
public void Emit(uint value)
|
||||
{
|
||||
*(uint*)(_code + Offset) = value;
|
||||
Offset += sizeof(uint);
|
||||
}
|
||||
|
||||
private void Emit(ulong value)
|
||||
{
|
||||
*(ulong*)(_code + Offset) = value;
|
||||
Offset += sizeof(ulong);
|
||||
}
|
||||
|
||||
public void EmitMovR64Immediate(byte rex, byte opcode, ulong value)
|
||||
{
|
||||
Emit(rex);
|
||||
Emit(opcode);
|
||||
Emit(value);
|
||||
}
|
||||
}
|
||||
|
||||
private static ulong AlignDown(ulong value, ulong alignment)
|
||||
{
|
||||
if (alignment == 0)
|
||||
|
||||
@@ -197,8 +197,9 @@ public sealed class SelfLoader : ISelfLoader
|
||||
{
|
||||
if (!physicalVm.TryAllocateAtExact(imageBase, totalImageSize, executable: true, out var allocatedBase))
|
||||
{
|
||||
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
|
||||
throw new InvalidOperationException(
|
||||
$"Could not allocate main image at required base 0x{imageBase:X16} (size=0x{totalImageSize:X}).");
|
||||
$"Could not allocate main image at required base 0x{imageBase:X16} (size=0x{totalImageSize:X}): {reason}.");
|
||||
}
|
||||
|
||||
imageBase = allocatedBase;
|
||||
|
||||
@@ -198,6 +198,24 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return true;
|
||||
}
|
||||
|
||||
public string DescribeAddressForDiagnostics(ulong address)
|
||||
{
|
||||
if (!_hostMemory.Query(address, out var info))
|
||||
{
|
||||
return "unable to query host memory at this address";
|
||||
}
|
||||
|
||||
return info.State switch
|
||||
{
|
||||
HostRegionState.Free => "address reports free, but the exact-address reservation still failed",
|
||||
HostRegionState.Reserved =>
|
||||
$"already reserved by another host allocation (base=0x{info.AllocationBase:X16}, size=0x{info.RegionSize:X})",
|
||||
HostRegionState.Committed =>
|
||||
$"already committed by another host allocation (base=0x{info.AllocationBase:X16}, size=0x{info.RegionSize:X}, protect=0x{info.RawProtection:X})",
|
||||
_ => $"in an unexpected host state (raw=0x{info.RawState:X})",
|
||||
};
|
||||
}
|
||||
|
||||
public ulong AllocateAt(ulong desiredAddress, ulong size, bool executable = true, bool allowAlternative = true)
|
||||
{
|
||||
if (size == 0)
|
||||
|
||||
@@ -3729,13 +3729,16 @@ public static partial class AgcExports
|
||||
_labelProducers.Add(producer);
|
||||
}
|
||||
|
||||
foreach (var waiting in GpuWaitRegistry.SnapshotInRange(memory, address, length))
|
||||
if (_traceAgc)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.wait_producer_scheduled label=0x{waiting.Address:X16} " +
|
||||
$"waiters={waiting.Count} producer_seq={producer.Sequence} " +
|
||||
$"queue={producer.QueueName} submission={producer.SubmissionId} " +
|
||||
$"packet=0x{packetAddress:X16} action='{debugName}'");
|
||||
foreach (var waiting in GpuWaitRegistry.SnapshotInRange(memory, address, length))
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.wait_producer_scheduled label=0x{waiting.Address:X16} " +
|
||||
$"waiters={waiting.Count} producer_seq={producer.Sequence} " +
|
||||
$"queue={producer.QueueName} submission={producer.SubmissionId} " +
|
||||
$"packet=0x{packetAddress:X16} action='{debugName}'");
|
||||
}
|
||||
}
|
||||
|
||||
return producer;
|
||||
@@ -3753,16 +3756,19 @@ public static partial class AgcExports
|
||||
producer.Completed = true;
|
||||
}
|
||||
|
||||
foreach (var waiting in GpuWaitRegistry.SnapshotInRange(
|
||||
producer.Memory,
|
||||
producer.Address,
|
||||
producer.Length))
|
||||
if (_traceAgc)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.wait_producer_completed label=0x{waiting.Address:X16} " +
|
||||
$"waiters={waiting.Count} producer_seq={producer.Sequence} " +
|
||||
$"queue={producer.QueueName} submission={producer.SubmissionId} " +
|
||||
$"action='{producer.DebugName}'");
|
||||
foreach (var waiting in GpuWaitRegistry.SnapshotInRange(
|
||||
producer.Memory,
|
||||
producer.Address,
|
||||
producer.Length))
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.wait_producer_completed label=0x{waiting.Address:X16} " +
|
||||
$"waiters={waiting.Count} producer_seq={producer.Sequence} " +
|
||||
$"queue={producer.QueueName} submission={producer.SubmissionId} " +
|
||||
$"action='{producer.DebugName}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3807,6 +3813,14 @@ public static partial class AgcExports
|
||||
}
|
||||
}
|
||||
|
||||
// Producer-backed waits are trace-only. Keep the producer lookup above
|
||||
// because producerless waits are always warned, but do not build the
|
||||
// detailed condition strings when AGC tracing is disabled.
|
||||
if (producer is not null && !_traceAgc)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var prefix = stale ? "agc.wait_stale" : "agc.wait_suspended";
|
||||
var current = currentValue.HasValue
|
||||
? $"0x{currentValue.Value:X16}"
|
||||
@@ -5937,13 +5951,14 @@ public static partial class AgcExports
|
||||
// Every bound color target the shader exports to. Deferred renderers
|
||||
// draw a multi-render-target G-buffer (up to eight slots) in one pass.
|
||||
// Fall back to slot 0 if we cannot match any export to a bound target.
|
||||
var pixelColorExportMasks = pixelState.Program.PixelColorExportMasks;
|
||||
var allBoundTargets = GetRenderTargets(state.CxRegisters);
|
||||
// At most 8 slots; a manual filter avoids the per-draw LINQ iterator/
|
||||
// closure allocations. Slots are distinct, so sorting by slot is stable.
|
||||
var selectedTargets = new List<RenderTargetDescriptor>(allBoundTargets.Count);
|
||||
foreach (var target in allBoundTargets)
|
||||
{
|
||||
if (HasPixelColorExport(pixelState, target.Slot))
|
||||
if (GetPixelColorExportMask(pixelColorExportMasks, target.Slot) != 0)
|
||||
{
|
||||
selectedTargets.Add(target);
|
||||
}
|
||||
@@ -5966,7 +5981,7 @@ public static partial class AgcExports
|
||||
{
|
||||
TraceAgcShader(
|
||||
$"agc.mrt_filter ps=0x{pixelShaderAddress:X16} " +
|
||||
$"bound=[{string.Join(",", allBoundTargets.Select(t => $"s{t.Slot}:0x{t.Address:X}:exp{(HasPixelColorExport(pixelState, t.Slot) ? 1 : 0)}"))}] " +
|
||||
$"bound=[{string.Join(",", allBoundTargets.Select(t => $"s{t.Slot}:0x{t.Address:X}:exp{(GetPixelColorExportMask(pixelColorExportMasks, t.Slot) != 0 ? 1 : 0)}"))}] " +
|
||||
$"kept={renderTargets.Length}");
|
||||
}
|
||||
|
||||
@@ -6087,54 +6102,39 @@ public static partial class AgcExports
|
||||
_graphicsShaderCache.TryAdd(shaderKey, compiled);
|
||||
}
|
||||
|
||||
var imageBindings = pixelEvaluation.ImageBindings
|
||||
.Concat(exportEvaluation.ImageBindings);
|
||||
var textures = new List<TranslatedImageBinding>(
|
||||
pixelEvaluation.ImageBindings.Count +
|
||||
exportEvaluation.ImageBindings.Count);
|
||||
foreach (var binding in imageBindings)
|
||||
if (!TryAppendTranslatedImageBindings(
|
||||
pixelEvaluation.ImageBindings,
|
||||
textures,
|
||||
pixelShaderAddress,
|
||||
exportShaderAddress,
|
||||
out error) ||
|
||||
!TryAppendTranslatedImageBindings(
|
||||
exportEvaluation.ImageBindings,
|
||||
textures,
|
||||
pixelShaderAddress,
|
||||
exportShaderAddress,
|
||||
out error))
|
||||
{
|
||||
if (!TryDecodeTextureDescriptor(binding.ResourceDescriptor, out var texture))
|
||||
{
|
||||
// A garbage/zeroed texture descriptor (from a per-draw descriptor
|
||||
// setup race — the same root as scalar-load-failed) would drop
|
||||
// the whole draw, so Demon's Souls' deferred-lighting/composite
|
||||
// passes that produce the composite's feeder targets never run
|
||||
// and the frame stays black. Substitute a 1x1 fallback binding so
|
||||
// the pass still renders (that one sampled texture reads black)
|
||||
// rather than dropping it entirely. STRICT reverts.
|
||||
if (_strictShaderDescriptors)
|
||||
{
|
||||
error = $"invalid texture descriptor at pc=0x{binding.Pc:X}";
|
||||
ReturnPooledEvaluationArrays(exportEvaluation);
|
||||
ReturnPooledEvaluationArrays(pixelEvaluation);
|
||||
return false;
|
||||
}
|
||||
|
||||
texture = new TextureDescriptor(
|
||||
0, 1, 1, Gen5TextureFormatR8G8B8A8Unorm, 0, 0, 0, 0, 0, 1, 0xFAC);
|
||||
}
|
||||
|
||||
if (_traceAgcShader || _tracePixelShaderAddress == pixelShaderAddress)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][TRACE] " +
|
||||
$"agc.texture_binding ps=0x{pixelShaderAddress:X16} es=0x{exportShaderAddress:X16} " +
|
||||
$"pc=0x{binding.Pc:X} op={binding.Opcode} storage={(Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode) ? 1 : 0)} " +
|
||||
$"decoded={FormatTextureDescriptor(texture)} " +
|
||||
$"raw={FormatShaderDwords(binding.ResourceDescriptor)} sampler={FormatShaderDwords(binding.SamplerDescriptor)}");
|
||||
}
|
||||
textures.Add(
|
||||
new TranslatedImageBinding(
|
||||
texture,
|
||||
Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode),
|
||||
binding.MipLevel ?? 0,
|
||||
binding.SamplerDescriptor));
|
||||
ReturnPooledEvaluationArrays(exportEvaluation);
|
||||
ReturnPooledEvaluationArrays(pixelEvaluation);
|
||||
return false;
|
||||
}
|
||||
|
||||
var globalMemoryBindings = pixelEvaluation.GlobalMemoryBindings
|
||||
.Concat(exportEvaluation.GlobalMemoryBindings)
|
||||
.ToArray();
|
||||
var globalMemoryBindings = new Gen5GlobalMemoryBinding[
|
||||
pixelEvaluation.GlobalMemoryBindings.Count +
|
||||
exportEvaluation.GlobalMemoryBindings.Count];
|
||||
for (var index = 0; index < pixelEvaluation.GlobalMemoryBindings.Count; index++)
|
||||
{
|
||||
globalMemoryBindings[index] = pixelEvaluation.GlobalMemoryBindings[index];
|
||||
}
|
||||
for (var index = 0; index < exportEvaluation.GlobalMemoryBindings.Count; index++)
|
||||
{
|
||||
globalMemoryBindings[pixelEvaluation.GlobalMemoryBindings.Count + index] =
|
||||
exportEvaluation.GlobalMemoryBindings[index];
|
||||
}
|
||||
IReadOnlyList<Gen5VertexInputBinding> vertexInputs =
|
||||
exportEvaluation.VertexInputs ?? [];
|
||||
state.UcRegisters.TryGetValue(VgtPrimitiveType, out var primitiveType);
|
||||
@@ -6149,6 +6149,13 @@ public static partial class AgcExports
|
||||
renderTargets[index].NumberType);
|
||||
}
|
||||
|
||||
var pixelUserDataCount = Math.Min(pixelEvaluation.InitialScalarRegisters.Count, 8);
|
||||
var pixelUserData = new uint[pixelUserDataCount];
|
||||
for (var index = 0; index < pixelUserDataCount; index++)
|
||||
{
|
||||
pixelUserData[index] = pixelEvaluation.InitialScalarRegisters[index];
|
||||
}
|
||||
|
||||
draw = new TranslatedGuestDraw(
|
||||
exportShaderAddress,
|
||||
pixelShaderAddress,
|
||||
@@ -6166,11 +6173,11 @@ public static partial class AgcExports
|
||||
DecodeDepthTarget(state.CxRegisters),
|
||||
guestTargets,
|
||||
ApplyTransparentPremultipliedFillClear(
|
||||
CreateRenderState(state.CxRegisters, renderTargets, pixelState),
|
||||
CreateRenderState(state.CxRegisters, renderTargets, pixelColorExportMasks),
|
||||
textures,
|
||||
vertexInputs,
|
||||
pixelEvaluation.InitialScalarRegisters),
|
||||
pixelEvaluation.InitialScalarRegisters.Take(8).ToArray(),
|
||||
pixelUserData,
|
||||
state.CxRegisters.TryGetValue(CbBlend0Control, out var rawBlend) ? rawBlend : 0,
|
||||
state.CxRegisters.TryGetValue(
|
||||
CbColor0Info + renderTargets.FirstOrDefault().Slot * CbColorRegisterStride,
|
||||
@@ -6182,6 +6189,55 @@ public static partial class AgcExports
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryAppendTranslatedImageBindings(
|
||||
IReadOnlyList<Gen5ImageBinding> bindings,
|
||||
List<TranslatedImageBinding> textures,
|
||||
ulong pixelShaderAddress,
|
||||
ulong exportShaderAddress,
|
||||
out string error)
|
||||
{
|
||||
foreach (var binding in bindings)
|
||||
{
|
||||
if (!TryDecodeTextureDescriptor(binding.ResourceDescriptor, out var texture))
|
||||
{
|
||||
// A garbage/zeroed texture descriptor (from a per-draw descriptor
|
||||
// setup race — the same root as scalar-load-failed) would drop
|
||||
// the whole draw, so deferred-lighting/composite passes that
|
||||
// produce the composite's feeder targets never run. Keep the
|
||||
// existing 1x1 fallback unless strict diagnostics are requested.
|
||||
if (_strictShaderDescriptors)
|
||||
{
|
||||
error = $"invalid texture descriptor at pc=0x{binding.Pc:X}";
|
||||
return false;
|
||||
}
|
||||
|
||||
texture = new TextureDescriptor(
|
||||
0, 1, 1, Gen5TextureFormatR8G8B8A8Unorm, 0, 0, 0, 0, 0, 1, 0xFAC);
|
||||
}
|
||||
|
||||
var isStorage =
|
||||
Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode);
|
||||
if (_traceAgcShader || _tracePixelShaderAddress == pixelShaderAddress)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][TRACE] " +
|
||||
$"agc.texture_binding ps=0x{pixelShaderAddress:X16} es=0x{exportShaderAddress:X16} " +
|
||||
$"pc=0x{binding.Pc:X} op={binding.Opcode} storage={(isStorage ? 1 : 0)} " +
|
||||
$"decoded={FormatTextureDescriptor(texture)} " +
|
||||
$"raw={FormatShaderDwords(binding.ResourceDescriptor)} sampler={FormatShaderDwords(binding.SamplerDescriptor)}");
|
||||
}
|
||||
textures.Add(
|
||||
new TranslatedImageBinding(
|
||||
texture,
|
||||
isStorage,
|
||||
binding.MipLevel ?? 0,
|
||||
binding.SamplerDescriptor));
|
||||
}
|
||||
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int _tracedAstroTitlePixelGlobals;
|
||||
private static int _tracedAstroTitlePixelGlobalProbe;
|
||||
|
||||
@@ -6410,26 +6466,10 @@ public static partial class AgcExports
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasPixelColorExport(Gen5ShaderState state, uint target) =>
|
||||
GetPixelColorExportMask(state, target) != 0;
|
||||
|
||||
private static uint GetPixelColorExportMask(Gen5ShaderState state, uint target)
|
||||
{
|
||||
// Called per render target (twice per draw via CreateRenderState +
|
||||
// HasPixelColorExport); a manual scan avoids the per-call LINQ iterator
|
||||
// and closure allocations. Same result as the previous
|
||||
// Select/OfType/Where/Aggregate chain.
|
||||
var mask = 0u;
|
||||
foreach (var instruction in state.Program.Instructions)
|
||||
{
|
||||
if (instruction.Control is Gen5ExportControl export && export.Target == target)
|
||||
{
|
||||
mask |= export.EnableMask & 0xFu;
|
||||
}
|
||||
}
|
||||
|
||||
return mask;
|
||||
}
|
||||
private static uint GetPixelColorExportMask(uint packedMasks, uint target) =>
|
||||
target < ColorTargetCount
|
||||
? (packedMasks >> (int)(target * 4)) & 0xFu
|
||||
: 0;
|
||||
|
||||
private static uint GetInterpolatedAttributeCount(Gen5ShaderState state)
|
||||
{
|
||||
@@ -6627,7 +6667,7 @@ public static partial class AgcExports
|
||||
private static GuestRenderState CreateRenderState(
|
||||
IReadOnlyDictionary<uint, uint> registers,
|
||||
IReadOnlyList<RenderTargetDescriptor> targets,
|
||||
Gen5ShaderState pixelState)
|
||||
uint pixelColorExportMasks)
|
||||
{
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
@@ -6636,15 +6676,21 @@ public static partial class AgcExports
|
||||
|
||||
var target = targets[0];
|
||||
var scissor = DecodeScissor(registers, target.Width, target.Height);
|
||||
return new GuestRenderState(
|
||||
targets.Select(target =>
|
||||
var blends = new GuestBlendState[targets.Count];
|
||||
for (var index = 0; index < targets.Count; index++)
|
||||
{
|
||||
var blend = DecodeBlendState(registers, targets[index].Slot);
|
||||
blends[index] = blend with
|
||||
{
|
||||
var blend = DecodeBlendState(registers, target.Slot);
|
||||
return blend with
|
||||
{
|
||||
WriteMask = blend.WriteMask & GetPixelColorExportMask(pixelState, target.Slot),
|
||||
};
|
||||
}).ToArray(),
|
||||
WriteMask = blend.WriteMask &
|
||||
GetPixelColorExportMask(
|
||||
pixelColorExportMasks,
|
||||
targets[index].Slot),
|
||||
};
|
||||
}
|
||||
|
||||
return new GuestRenderState(
|
||||
blends,
|
||||
scissor,
|
||||
DecodeViewport(registers, target.Width, target.Height, scissor),
|
||||
DecodeRasterState(registers),
|
||||
|
||||
@@ -52,8 +52,19 @@ internal static class AudioPcmConversion
|
||||
}
|
||||
|
||||
var bits = BinaryPrimitives.ReadInt32LittleEndian(sample);
|
||||
var value = Math.Clamp(BitConverter.Int32BitsToSingle(bits), -1.0f, 1.0f);
|
||||
return checked((short)MathF.Round(value * short.MaxValue));
|
||||
return ConvertFloatSample(BitConverter.Int32BitsToSingle(bits));
|
||||
}
|
||||
|
||||
private static short ConvertFloatSample(float value)
|
||||
{
|
||||
if (float.IsNaN(value))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
value = Math.Clamp(value, -1.0f, 1.0f);
|
||||
var scale = value < 0.0f ? 32768.0f : short.MaxValue;
|
||||
return checked((short)MathF.Round(value * scale));
|
||||
}
|
||||
|
||||
private static short ApplyVolume(short sample, float volume)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Libs.ContentExport;
|
||||
|
||||
// No host media library exists to export captures into, so initialization
|
||||
// reports success.
|
||||
public static class ContentExportExports
|
||||
{
|
||||
[SysAbiExport(
|
||||
Nid = "0GnN4QCgIfs",
|
||||
ExportName = "sceContentExportInit2",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceContentExport")]
|
||||
public static int ContentExportInit2(CpuContext ctx) => ctx.SetReturn(0);
|
||||
}
|
||||
@@ -74,6 +74,85 @@ public static class FontExports
|
||||
public static int CreateRendererWithEdition(CpuContext ctx) =>
|
||||
CreateOpaqueHandle(ctx, ctx[CpuRegister.Rcx], 0x100, magic: 0x0F07);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "3OdRkSjOcog",
|
||||
ExportName = "sceFontBindRenderer",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int BindRenderer(CpuContext ctx) => SetSuccess(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "N1EBMeGhf7E",
|
||||
ExportName = "sceFontSetScalePixel",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int SetScalePixel(CpuContext ctx) => SetSuccess(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "TMtqoFQjjbA",
|
||||
ExportName = "sceFontSetEffectSlant",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int SetEffectSlant(CpuContext ctx) => SetSuccess(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "v0phZwa4R5o",
|
||||
ExportName = "sceFontSetEffectWeight",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int SetEffectWeight(CpuContext ctx) => SetSuccess(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "6vGCkkQJOcI",
|
||||
ExportName = "sceFontSetupRenderScalePixel",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int SetupRenderScalePixel(CpuContext ctx) => SetSuccess(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "lz9y9UFO2UU",
|
||||
ExportName = "sceFontSetupRenderEffectSlant",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int SetupRenderEffectSlant(CpuContext ctx) => SetSuccess(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "XIGorvLusDQ",
|
||||
ExportName = "sceFontSetupRenderEffectWeight",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int SetupRenderEffectWeight(CpuContext ctx) => SetSuccess(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "imxVx8lm+KM",
|
||||
ExportName = "sceFontGetHorizontalLayout",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int GetHorizontalLayout(CpuContext ctx)
|
||||
{
|
||||
var layoutAddress = ctx[CpuRegister.Rsi];
|
||||
if (layoutAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
// Baseline, line advance, decoration extent: the same invented geometry
|
||||
// as GetRenderCharGlyphMetrics.
|
||||
var values = new[] { 12.0f, 16.0f, 0.0f };
|
||||
for (var index = 0; index < values.Length; index++)
|
||||
{
|
||||
if (!TryWriteUInt32(
|
||||
ctx,
|
||||
layoutAddress + (ulong)(index * sizeof(float)),
|
||||
BitConverter.SingleToUInt32Bits(values[index])))
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
}
|
||||
|
||||
return SetSuccess(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "cKYtVmeSTcw",
|
||||
ExportName = "sceFontOpenFontSet",
|
||||
|
||||
@@ -127,6 +127,19 @@ public static class PadExports
|
||||
: ctx.SetReturn(OrbisPadErrorInvalidHandle);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "vDLMoJLde8I",
|
||||
ExportName = "scePadSetTiltCorrectionState",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libScePad")]
|
||||
public static int PadSetTiltCorrectionState(CpuContext ctx)
|
||||
{
|
||||
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
return IsPrimaryPadHandle(handle)
|
||||
? ctx.SetReturn(0)
|
||||
: ctx.SetReturn(OrbisPadErrorInvalidHandle);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "gjP9-KQzoUk",
|
||||
ExportName = "scePadGetControllerInformation",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
@@ -14,6 +15,7 @@ public static class SaveDataExports
|
||||
private const int OrbisSaveDataErrorExists = unchecked((int)0x809F0007);
|
||||
private const int OrbisSaveDataErrorNotFound = unchecked((int)0x809F0008);
|
||||
private const int OrbisSaveDataErrorInternal = unchecked((int)0x809F000B);
|
||||
private const int OrbisSaveDataErrorMemoryNotReady = unchecked((int)0x809F0012);
|
||||
private const int SaveDataTitleIdSize = 10;
|
||||
private const int SaveDataDirNameSize = 32;
|
||||
private const int SaveDataParamSize = 0x530;
|
||||
@@ -29,7 +31,10 @@ public static class SaveDataExports
|
||||
private const uint MountModeCreate = 1u << 2;
|
||||
private const uint MountModeCreate2 = 1u << 5;
|
||||
private const int MountResultSize = 0x40;
|
||||
// Emulator guard against corrupt or misread sizes, not a platform limit.
|
||||
private const ulong SaveDataMemoryMaxSize = 64UL * 1024 * 1024;
|
||||
private static readonly object _stateGate = new();
|
||||
private static readonly object _memoryGate = new();
|
||||
private static readonly HashSet<int> _preparedTransactionResources = [];
|
||||
private static string? _titleId;
|
||||
|
||||
@@ -472,6 +477,19 @@ public static class SaveDataExports
|
||||
private static string ResolveTitleSaveRoot(int userId, string titleId) =>
|
||||
Path.Combine(ResolveSaveDataRoot(), userId.ToString(), SanitizePathSegment(titleId));
|
||||
|
||||
private static string ResolveSaveDataMemoryPath(int userId) =>
|
||||
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)
|
||||
{
|
||||
size = 0;
|
||||
offset = 0;
|
||||
return ctx.TryReadUInt64(address, out buffer) &&
|
||||
ctx.TryReadUInt64(address + 0x08, out size) &&
|
||||
ctx.TryReadUInt64(address + 0x10, out offset);
|
||||
}
|
||||
|
||||
private static string ResolveSaveDataRoot()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
|
||||
@@ -670,4 +688,197 @@ public static class SaveDataExports
|
||||
TraceSaveData($"commit commit=0x{commitAddress:X16}");
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
// Save data memory: a small per-user blob titles read and write without
|
||||
// mounting anything, backed by one zero-filled file per user and title.
|
||||
[SysAbiExport(
|
||||
Nid = "oQySEUfgXRA",
|
||||
ExportName = "sceSaveDataSetupSaveDataMemory2",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataSetupSaveDataMemory2(CpuContext ctx)
|
||||
{
|
||||
var paramAddress = ctx[CpuRegister.Rdi];
|
||||
var resultAddress = ctx[CpuRegister.Rsi];
|
||||
if (paramAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
if (!TryReadInt32(ctx, paramAddress + 0x04, out var userId) ||
|
||||
!ctx.TryReadUInt64(paramAddress + 0x08, out var memorySize))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
if (userId < 0 || memorySize == 0 || memorySize > SaveDataMemoryMaxSize)
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var path = ResolveSaveDataMemoryPath(userId);
|
||||
lock (_memoryGate)
|
||||
{
|
||||
var backing = new FileInfo(path);
|
||||
var existedSize = backing.Exists ? (ulong)backing.Length : 0;
|
||||
|
||||
// The result write comes first so a faulted result pointer
|
||||
// cannot leave created or grown setup state behind.
|
||||
if (resultAddress != 0 && !ctx.TryWriteUInt64(resultAddress, existedSize))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
if (existedSize < memorySize)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
using var stream = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
stream.SetLength((long)memorySize);
|
||||
}
|
||||
|
||||
TraceSaveData($"memory-setup2 user={userId} size=0x{memorySize:X} existed=0x{existedSize:X}");
|
||||
}
|
||||
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorInternal);
|
||||
}
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "QwOO7vegnV8",
|
||||
ExportName = "sceSaveDataGetSaveDataMemory2",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataGetSaveDataMemory2(CpuContext ctx) =>
|
||||
TransferSaveDataMemory(ctx, write: false);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "cduy9v4YmT4",
|
||||
ExportName = "sceSaveDataSetSaveDataMemory2",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataSetSaveDataMemory2(CpuContext ctx) =>
|
||||
TransferSaveDataMemory(ctx, write: true);
|
||||
|
||||
// Writes go straight through to the backing file, so a ready state is
|
||||
// all sync has to confirm.
|
||||
[SysAbiExport(
|
||||
Nid = "wiT9jeC7xPw",
|
||||
ExportName = "sceSaveDataSyncSaveDataMemory",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataSyncSaveDataMemory(CpuContext ctx)
|
||||
{
|
||||
var syncAddress = ctx[CpuRegister.Rdi];
|
||||
if (syncAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
if (!TryReadInt32(ctx, syncAddress, out var userId))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
if (userId < 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
return ctx.SetReturn(
|
||||
File.Exists(ResolveSaveDataMemoryPath(userId)) ? 0 : OrbisSaveDataErrorMemoryNotReady);
|
||||
}
|
||||
|
||||
private static int TransferSaveDataMemory(CpuContext ctx, bool write)
|
||||
{
|
||||
var requestAddress = ctx[CpuRegister.Rdi];
|
||||
if (requestAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
if (!TryReadInt32(ctx, requestAddress, out var userId) ||
|
||||
!ctx.TryReadUInt64(requestAddress + 0x08, out var dataAddress))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
if (userId < 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var path = ResolveSaveDataMemoryPath(userId);
|
||||
lock (_memoryGate)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorMemoryNotReady);
|
||||
}
|
||||
|
||||
if (dataAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
if (!TryReadMemoryData(ctx, dataAddress, out var bufAddress, out var bufSize, out var offset))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
using var stream = new FileStream(
|
||||
path, FileMode.Open, write ? FileAccess.ReadWrite : FileAccess.Read);
|
||||
var length = (ulong)stream.Length;
|
||||
if (bufAddress == 0 || bufSize > length || offset > length - bufSize)
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
// The guarded file length bounds bufSize, so one rented buffer
|
||||
// covers the transfer and a guest fault never partially writes.
|
||||
var buffer = ArrayPool<byte>.Shared.Rent((int)Math.Max(bufSize, 1));
|
||||
try
|
||||
{
|
||||
var span = buffer.AsSpan(0, (int)bufSize);
|
||||
stream.Seek((long)offset, SeekOrigin.Begin);
|
||||
if (write)
|
||||
{
|
||||
if (!ctx.Memory.TryRead(bufAddress, span))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
stream.Write(span);
|
||||
}
|
||||
else
|
||||
{
|
||||
stream.ReadExactly(span);
|
||||
if (!ctx.Memory.TryWrite(bufAddress, span))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
|
||||
TraceSaveData(
|
||||
$"memory-{(write ? "set2" : "get2")} user={userId} offset=0x{offset:X} size=0x{bufSize:X}");
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorInternal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1646,8 +1646,12 @@ public static class VideoOutExports
|
||||
// Maps the PS5 VideoOut pixel format space to the AGC "guest texture format" tags
|
||||
// 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).
|
||||
private static uint MapPixelFormatToGuestTextureFormat(ulong pixelFormat) =>
|
||||
NormalizePixelFormat(pixelFormat) switch
|
||||
// Unknown formats default to 56 (8-bit RGBA) with a logged warning so games
|
||||
// display something rather than silently failing the flip pipeline.
|
||||
private static uint MapPixelFormatToGuestTextureFormat(ulong pixelFormat)
|
||||
{
|
||||
var normalized = NormalizePixelFormat(pixelFormat);
|
||||
var result = normalized switch
|
||||
{
|
||||
SceVideoOutPixelFormatA8R8G8B8Srgb or
|
||||
SceVideoOutPixelFormatA8B8G8R8Srgb or
|
||||
@@ -1665,6 +1669,17 @@ public static class VideoOutExports
|
||||
_ => 0u,
|
||||
};
|
||||
|
||||
if (result == 0u)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] vk: unknown pixel format 0x{pixelFormat:X16} (normalized=0x{normalized:X16}) " +
|
||||
$"— falling back to format 56 (8-bit RGBA). Report this format to the project.");
|
||||
result = 56u;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static bool TryPackRgba8Pixel(
|
||||
ulong pixelFormat,
|
||||
byte red,
|
||||
|
||||
@@ -2775,6 +2775,11 @@ internal static unsafe class VulkanVideoPresenter
|
||||
|
||||
private void LoadDebugUtilsCommands()
|
||||
{
|
||||
if (!_vulkanDebugUtilsEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var setObjectName = _vk.GetDeviceProcAddr(_device, "vkSetDebugUtilsObjectNameEXT");
|
||||
var beginLabel = _vk.GetDeviceProcAddr(_device, "vkCmdBeginDebugUtilsLabelEXT");
|
||||
var endLabel = _vk.GetDeviceProcAddr(_device, "vkCmdEndDebugUtilsLabelEXT");
|
||||
@@ -2877,10 +2882,48 @@ internal static unsafe class VulkanVideoPresenter
|
||||
$"SharpEmu texture 0x{texture.Address:X16} {texture.Width}x{texture.Height} " +
|
||||
$"fmt{texture.Format}/{format}";
|
||||
|
||||
private static bool IsTitleDraw(IReadOnlyList<GuestVertexBuffer> vertexBuffers)
|
||||
{
|
||||
foreach (var buffer in vertexBuffers)
|
||||
{
|
||||
if (buffer.Location == 0 &&
|
||||
buffer.ComponentCount == 4 &&
|
||||
buffer.DataFormat == 10 &&
|
||||
buffer.NumberFormat == 0 &&
|
||||
buffer.Stride == 16 &&
|
||||
buffer.OffsetBytes == 12 &&
|
||||
buffer.Length == 67568)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool AnyTargetAddressMatches(
|
||||
IReadOnlyList<GuestImageResource>? targets,
|
||||
string environmentVariable)
|
||||
{
|
||||
if (targets is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var target in targets)
|
||||
{
|
||||
if (AddressListContains(environmentVariable, target.Address))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private void CreateInstance()
|
||||
{
|
||||
var applicationName = (byte*)SilkMarshal.StringToPtr("SharpEmu");
|
||||
var enableValidation = Environment.GetEnvironmentVariable("SHARPEMU_VK_VALIDATION") == "1";
|
||||
byte* validationLayerName = null;
|
||||
|
||||
try
|
||||
@@ -2906,7 +2949,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
enabledExtensions[index] = extensions[index];
|
||||
}
|
||||
|
||||
if (IsInstanceExtensionAvailable(DebugUtilsExtensionName))
|
||||
if (_vulkanDebugUtilsEnabled &&
|
||||
IsInstanceExtensionAvailable(DebugUtilsExtensionName))
|
||||
{
|
||||
debugUtilsExtension = (byte*)SilkMarshal.StringToPtr(DebugUtilsExtensionName);
|
||||
enabledExtensions[enabledExtensionCount++] = debugUtilsExtension;
|
||||
@@ -2921,11 +2965,12 @@ internal static unsafe class VulkanVideoPresenter
|
||||
instanceCreateFlags |= InstanceCreateFlags.EnumeratePortabilityBitKhr;
|
||||
}
|
||||
|
||||
if (enableValidation && IsInstanceLayerAvailable("VK_LAYER_KHRONOS_validation"))
|
||||
if (_vulkanValidationEnabled &&
|
||||
IsInstanceLayerAvailable("VK_LAYER_KHRONOS_validation"))
|
||||
{
|
||||
validationLayerName = (byte*)SilkMarshal.StringToPtr("VK_LAYER_KHRONOS_validation");
|
||||
}
|
||||
else if (enableValidation)
|
||||
else if (_vulkanValidationEnabled)
|
||||
{
|
||||
Console.Error.WriteLine("[LOADER][WARN] SHARPEMU_VK_VALIDATION=1 but VK_LAYER_KHRONOS_validation not found (Vulkan SDK installed?).");
|
||||
}
|
||||
@@ -4898,6 +4943,13 @@ internal static unsafe class VulkanVideoPresenter
|
||||
IReadOnlyList<GuestImageResource>? renderTargets = null,
|
||||
ulong shaderAddress = 0)
|
||||
{
|
||||
if (!_traceGuestImagesEnabled &&
|
||||
!_traceGuestImageAddressFilterEnabled &&
|
||||
GuestImageTraceInterval() is null)
|
||||
{
|
||||
return Array.Empty<GuestImageResource>();
|
||||
}
|
||||
|
||||
var candidates = new HashSet<GuestImageResource>();
|
||||
foreach (var renderTarget in renderTargets ?? [])
|
||||
{
|
||||
@@ -5140,47 +5192,35 @@ internal static unsafe class VulkanVideoPresenter
|
||||
bool hasDepthAttachment = false,
|
||||
GuestDepthResource? feedbackDepth = null)
|
||||
{
|
||||
var isTitleDraw = draw.VertexBuffers.Any(buffer =>
|
||||
buffer.Location == 0 &&
|
||||
buffer.ComponentCount == 4 &&
|
||||
buffer.DataFormat == 10 &&
|
||||
buffer.NumberFormat == 0 &&
|
||||
buffer.Stride == 16 &&
|
||||
buffer.OffsetBytes == 12 &&
|
||||
buffer.Length == 67568);
|
||||
var forceFullscreenPipeline = Environment.GetEnvironmentVariable(
|
||||
"SHARPEMU_FORCE_FULLSCREEN_PIPELINE") == "1";
|
||||
var forceFullscreenVertex = forceFullscreenPipeline ||
|
||||
Environment.GetEnvironmentVariable(
|
||||
"SHARPEMU_FORCE_FULLSCREEN_VERTEX") == "1" ||
|
||||
isTitleDraw && Environment.GetEnvironmentVariable(
|
||||
"SHARPEMU_FORCE_TITLE_FULLSCREEN_VERTEX") == "1" ||
|
||||
feedbackTargets?.Any(target => AddressListContains(
|
||||
"SHARPEMU_FORCE_FULLSCREEN_VERTEX_TARGETS",
|
||||
target.Address)) == true;
|
||||
var forceRasterState = forceFullscreenPipeline ||
|
||||
Environment.GetEnvironmentVariable(
|
||||
"SHARPEMU_FORCE_DEFAULT_RASTER_STATE") == "1" ||
|
||||
isTitleDraw && Environment.GetEnvironmentVariable(
|
||||
"SHARPEMU_FORCE_TITLE_DEFAULT_RASTER_STATE") == "1" ||
|
||||
feedbackTargets?.Any(target => AddressListContains(
|
||||
"SHARPEMU_FORCE_DEFAULT_RASTER_STATE_TARGETS",
|
||||
target.Address)) == true;
|
||||
var isTitleDraw = IsTitleDraw(draw.VertexBuffers);
|
||||
var forceFullscreenVertex = _forceFullscreenPipeline ||
|
||||
_forceFullscreenVertex ||
|
||||
isTitleDraw && _forceTitleFullscreenVertex ||
|
||||
AnyTargetAddressMatches(
|
||||
feedbackTargets,
|
||||
"SHARPEMU_FORCE_FULLSCREEN_VERTEX_TARGETS");
|
||||
var forceRasterState = _forceFullscreenPipeline ||
|
||||
_forceDefaultRasterState ||
|
||||
isTitleDraw && _forceTitleDefaultRasterState ||
|
||||
AnyTargetAddressMatches(
|
||||
feedbackTargets,
|
||||
"SHARPEMU_FORCE_DEFAULT_RASTER_STATE_TARGETS");
|
||||
var forceTitleSolidFragment =
|
||||
Environment.GetEnvironmentVariable(
|
||||
"SHARPEMU_FORCE_TITLE_SOLID_FRAGMENT") == "1" &&
|
||||
_forceTitleSolidFragment &&
|
||||
isTitleDraw;
|
||||
var forceSolidFragment = forceTitleSolidFragment || forceFullscreenPipeline ||
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_SOLID_FRAGMENT") == "1" ||
|
||||
feedbackTargets?.Any(target => AddressListContains(
|
||||
"SHARPEMU_FORCE_SOLID_FRAGMENT_TARGETS",
|
||||
target.Address)) == true;
|
||||
var forceAttributeFragment = uint.TryParse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_ATTRIBUTE_FRAGMENT"),
|
||||
out var attributeFragmentLocation) &&
|
||||
feedbackTargets?.Any(target => AddressListContains(
|
||||
"SHARPEMU_FORCE_ATTRIBUTE_FRAGMENT_TARGETS",
|
||||
target.Address)) == true;
|
||||
var forceSolidFragment = forceTitleSolidFragment ||
|
||||
_forceFullscreenPipeline ||
|
||||
_forceSolidFragment ||
|
||||
AnyTargetAddressMatches(
|
||||
feedbackTargets,
|
||||
"SHARPEMU_FORCE_SOLID_FRAGMENT_TARGETS");
|
||||
var attributeFragmentLocation =
|
||||
_forceAttributeFragmentLocation.GetValueOrDefault();
|
||||
var forceAttributeFragment =
|
||||
_forceAttributeFragmentLocation.HasValue &&
|
||||
AnyTargetAddressMatches(
|
||||
feedbackTargets,
|
||||
"SHARPEMU_FORCE_ATTRIBUTE_FRAGMENT_TARGETS");
|
||||
var vertexSpirv = forceFullscreenVertex
|
||||
? SpirvFixedShaders.CreateFullscreenVertex(0)
|
||||
: draw.VertexSpirv;
|
||||
@@ -5189,11 +5229,9 @@ internal static unsafe class VulkanVideoPresenter
|
||||
: forceAttributeFragment
|
||||
? SpirvFixedShaders.CreateAttributeFragment(attributeFragmentLocation)
|
||||
: draw.PixelSpirv;
|
||||
var fixedFragmentDumpPath = Environment.GetEnvironmentVariable(
|
||||
"SHARPEMU_DUMP_FIXED_SOLID_FRAGMENT");
|
||||
if (forceSolidFragment && !string.IsNullOrWhiteSpace(fixedFragmentDumpPath))
|
||||
if (forceSolidFragment && !string.IsNullOrWhiteSpace(_fixedFragmentDumpPath))
|
||||
{
|
||||
File.WriteAllBytes(fixedFragmentDumpPath, fragmentSpirv);
|
||||
File.WriteAllBytes(_fixedFragmentDumpPath, fragmentSpirv);
|
||||
}
|
||||
if (draw.RenderState.Blends.Count != renderTargetFormats.Count)
|
||||
{
|
||||
@@ -5243,21 +5281,18 @@ internal static unsafe class VulkanVideoPresenter
|
||||
resources.Raster = GuestRasterState.Default;
|
||||
resources.Depth = GuestDepthState.Default;
|
||||
}
|
||||
if (isTitleDraw && Environment.GetEnvironmentVariable(
|
||||
"SHARPEMU_FORCE_TITLE_DEFAULT_BLEND") == "1")
|
||||
if (isTitleDraw && _forceTitleDefaultBlend)
|
||||
{
|
||||
resources.Blends = Enumerable.Repeat(
|
||||
GuestBlendState.Default,
|
||||
renderTargetFormats.Count).ToArray();
|
||||
}
|
||||
if (isTitleDraw && Environment.GetEnvironmentVariable(
|
||||
"SHARPEMU_FORCE_TITLE_DEFAULT_VIEWPORT_SCISSOR") == "1")
|
||||
if (isTitleDraw && _forceTitleDefaultViewportScissor)
|
||||
{
|
||||
resources.Scissor = null;
|
||||
resources.Viewport = null;
|
||||
}
|
||||
if (isTitleDraw && Environment.GetEnvironmentVariable(
|
||||
"SHARPEMU_FORCE_TITLE_DISABLE_CULL") == "1")
|
||||
if (isTitleDraw && _forceTitleDisableCull)
|
||||
{
|
||||
resources.Raster = resources.Raster with
|
||||
{
|
||||
@@ -5265,13 +5300,11 @@ internal static unsafe class VulkanVideoPresenter
|
||||
CullBack = false,
|
||||
};
|
||||
}
|
||||
if (isTitleDraw && Environment.GetEnvironmentVariable(
|
||||
"SHARPEMU_FORCE_TITLE_DISABLE_DEPTH") == "1")
|
||||
if (isTitleDraw && _forceTitleDisableDepth)
|
||||
{
|
||||
resources.Depth = GuestDepthState.Default;
|
||||
}
|
||||
if (isTitleDraw && Environment.GetEnvironmentVariable(
|
||||
"SHARPEMU_TRACE_TITLE_STATE") == "1")
|
||||
if (isTitleDraw && _traceTitleState)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] vk.title_state " +
|
||||
@@ -7874,7 +7907,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
{
|
||||
ReadOnlySpan<byte> source = guestBuffer.Data.AsSpan(0, guestBuffer.Length);
|
||||
byte[]? forcedVertexColors = null;
|
||||
if (Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_VERTEX_COLOR_WHITE") == "1" &&
|
||||
if (_forceTitleVertexColorWhite &&
|
||||
guestBuffer.Location == 0 &&
|
||||
guestBuffer.ComponentCount == 4 &&
|
||||
guestBuffer.DataFormat == 10 &&
|
||||
@@ -9400,37 +9433,67 @@ internal static unsafe class VulkanVideoPresenter
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetFormats
|
||||
.Select((format, index) => format.IsInteger && work.Draw.RenderState.Blends[index].Enable)
|
||||
.Any(invalid => invalid))
|
||||
for (var index = 0; index < targetFormats.Length; index++)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Vulkan skipped MRT draw with blending enabled for an integer attachment.");
|
||||
ReturnPooledGuestData(work.Draw);
|
||||
return;
|
||||
if (targetFormats[index].IsInteger &&
|
||||
work.Draw.RenderState.Blends[index].Enable)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Vulkan skipped MRT draw with blending enabled for an integer attachment.");
|
||||
ReturnPooledGuestData(work.Draw);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!_supportsIndependentBlend &&
|
||||
work.Draw.RenderState.Blends.Skip(1).Any(blend => blend != work.Draw.RenderState.Blends[0]))
|
||||
if (!_supportsIndependentBlend)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Vulkan skipped MRT draw requiring unsupported independentBlend.");
|
||||
ReturnPooledGuestData(work.Draw);
|
||||
return;
|
||||
for (var index = 1; index < work.Draw.RenderState.Blends.Count; index++)
|
||||
{
|
||||
if (work.Draw.RenderState.Blends[index] !=
|
||||
work.Draw.RenderState.Blends[0])
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Vulkan skipped MRT draw requiring unsupported independentBlend.");
|
||||
ReturnPooledGuestData(work.Draw);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var formats = targetFormats.Select(target => target.Format).ToArray();
|
||||
var formats = new Format[targetFormats.Length];
|
||||
for (var index = 0; index < targetFormats.Length; index++)
|
||||
{
|
||||
formats[index] = targetFormats[index].Format;
|
||||
}
|
||||
|
||||
var targetAddresses = work.Targets
|
||||
.Where(target => target.Address != 0)
|
||||
.Select(target => target.Address)
|
||||
.ToHashSet();
|
||||
if (work.Draw.Textures.Any(texture =>
|
||||
texture.IsStorage && targetAddresses.Contains(texture.Address)))
|
||||
var hasStorageFeedback = false;
|
||||
foreach (var texture in work.Draw.Textures)
|
||||
{
|
||||
if (!texture.IsStorage || texture.Address == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var target in work.Targets)
|
||||
{
|
||||
if (target.Address != 0 && target.Address == texture.Address)
|
||||
{
|
||||
hasStorageFeedback = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasStorageFeedback)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasStorageFeedback)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Vulkan skipped storage render-target feedback loop " +
|
||||
$"targets={string.Join(',', targetAddresses.Select(address => $"0x{address:X16}"))}; " +
|
||||
$"targets={string.Join(',', work.Targets.Where(target => target.Address != 0).Select(target => $"0x{target.Address:X16}"))}; " +
|
||||
"sampled aliases use ordered snapshots");
|
||||
ReturnPooledGuestData(work.Draw);
|
||||
return;
|
||||
@@ -9802,64 +9865,34 @@ internal static unsafe class VulkanVideoPresenter
|
||||
}
|
||||
}
|
||||
|
||||
var guestWritesMode = Environment.GetEnvironmentVariable(
|
||||
"SHARPEMU_TRACE_GUEST_WRITES");
|
||||
var traceAddressWriteOrdinal = 0L;
|
||||
_ = long.TryParse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_WRITE_ORDINAL"),
|
||||
out traceAddressWriteOrdinal);
|
||||
var traceLargeWriteOrdinal = 0L;
|
||||
if (guestWritesMode is not null &&
|
||||
guestWritesMode.StartsWith("large@", StringComparison.Ordinal) &&
|
||||
long.TryParse(
|
||||
guestWritesMode.AsSpan("large@".Length),
|
||||
out var parsedTraceLargeWriteOrdinal) &&
|
||||
parsedTraceLargeWriteOrdinal > 0)
|
||||
{
|
||||
traceLargeWriteOrdinal = parsedTraceLargeWriteOrdinal;
|
||||
}
|
||||
|
||||
var tracePixelSpirv = false;
|
||||
if (int.TryParse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_PIXEL_SPIRV_BYTES"),
|
||||
out var tracePixelSpirvBytes) &&
|
||||
tracePixelSpirvBytes == work.Draw.PixelSpirv.Length)
|
||||
if (_tracePixelSpirvBytes > 0 &&
|
||||
_tracePixelSpirvBytes == work.Draw.PixelSpirv.Length)
|
||||
{
|
||||
var pixelWriteCount = _pixelSpirvWriteCounts.TryGetValue(
|
||||
tracePixelSpirvBytes,
|
||||
_tracePixelSpirvBytes,
|
||||
out var previousPixelWriteCount)
|
||||
? previousPixelWriteCount + 1
|
||||
: 1;
|
||||
_pixelSpirvWriteCounts[tracePixelSpirvBytes] = pixelWriteCount;
|
||||
var tracePixelSpirvOccurrence = 1;
|
||||
_ = int.TryParse(
|
||||
Environment.GetEnvironmentVariable(
|
||||
"SHARPEMU_TRACE_PIXEL_SPIRV_OCCURRENCE"),
|
||||
out tracePixelSpirvOccurrence);
|
||||
_pixelSpirvWriteCounts[_tracePixelSpirvBytes] = pixelWriteCount;
|
||||
tracePixelSpirv =
|
||||
pixelWriteCount == Math.Max(tracePixelSpirvOccurrence, 1);
|
||||
pixelWriteCount == _tracePixelSpirvOccurrence;
|
||||
}
|
||||
var traceTitleDraw =
|
||||
!_tracedTitleDraw &&
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_TITLE_DRAW") == "1" &&
|
||||
work.Draw.VertexBuffers.Any(buffer =>
|
||||
buffer.Location == 0 &&
|
||||
buffer.ComponentCount == 4 &&
|
||||
buffer.DataFormat == 10 &&
|
||||
buffer.NumberFormat == 0 &&
|
||||
buffer.Stride == 16 &&
|
||||
buffer.OffsetBytes == 12 &&
|
||||
buffer.Length == 67568);
|
||||
_traceTitleDrawEnabled &&
|
||||
IsTitleDraw(work.Draw.VertexBuffers);
|
||||
_tracedTitleDraw |= traceTitleDraw;
|
||||
|
||||
foreach (var target in targets)
|
||||
{
|
||||
var traceAddressWrite =
|
||||
ShouldTraceGuestImageWriteForDiagnostics(target.Address);
|
||||
var traceSmallWrites = guestWritesMode == "small" &&
|
||||
var traceSmallWrites = _traceGuestWritesMode == "small" &&
|
||||
target.Width <= 512 && target.Height <= 256;
|
||||
var traceLargeWrites =
|
||||
(guestWritesMode == "large" || traceLargeWriteOrdinal != 0) &&
|
||||
(_traceGuestWritesMode == "large" ||
|
||||
_traceLargeGuestWriteOrdinal != 0) &&
|
||||
target.Width >= 2560 && target.Height >= 1440;
|
||||
if (traceAddressWrite || traceSmallWrites ||
|
||||
traceLargeWrites || tracePixelSpirv || traceTitleDraw)
|
||||
@@ -9872,10 +9905,10 @@ internal static unsafe class VulkanVideoPresenter
|
||||
_tracedGuestWriteCounts[target.Address] = writeCount;
|
||||
var shouldTraceWrite = tracePixelSpirv || traceTitleDraw
|
||||
? true
|
||||
: traceAddressWrite && traceAddressWriteOrdinal > 0
|
||||
? writeCount == traceAddressWriteOrdinal
|
||||
: traceLargeWriteOrdinal != 0
|
||||
? writeCount == traceLargeWriteOrdinal
|
||||
: traceAddressWrite && _traceGuestWriteOrdinal > 0
|
||||
? writeCount == _traceGuestWriteOrdinal
|
||||
: _traceLargeGuestWriteOrdinal != 0
|
||||
? writeCount == _traceLargeGuestWriteOrdinal
|
||||
: writeCount <=
|
||||
(traceLargeWrites ? 2 : traceSmallWrites ? 48 : 3);
|
||||
if (traceAddressWrite || shouldTraceWrite)
|
||||
@@ -12849,6 +12882,20 @@ internal static unsafe class VulkanVideoPresenter
|
||||
// Diagnostics toggles are read once: these run per draw / per cached
|
||||
// texture hit, and env lookups plus string parsing are far too
|
||||
// expensive there (and non-trivially so under Rosetta 2).
|
||||
private static readonly bool _vulkanValidationEnabled =
|
||||
string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_VK_VALIDATION"),
|
||||
"1",
|
||||
StringComparison.Ordinal);
|
||||
// Object names and command labels are useful in RenderDoc and validation
|
||||
// captures, but formatting them and calling the debug-utils driver hooks
|
||||
// for every draw is measurable overhead in normal gameplay.
|
||||
private static readonly bool _vulkanDebugUtilsEnabled =
|
||||
_vulkanValidationEnabled ||
|
||||
string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_VK_DEBUG_LABELS"),
|
||||
"1",
|
||||
StringComparison.Ordinal);
|
||||
private static readonly string? _traceGuestImagesMode =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES");
|
||||
private static readonly bool _traceGuestImagesEnabled =
|
||||
@@ -12912,10 +12959,80 @@ internal static unsafe class VulkanVideoPresenter
|
||||
!string.IsNullOrWhiteSpace(
|
||||
Environment.GetEnvironmentVariable(
|
||||
"SHARPEMU_TRACE_GUEST_IMAGE_ADDRS"));
|
||||
private static readonly bool _forceFullscreenPipeline =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_FULLSCREEN_PIPELINE") == "1";
|
||||
private static readonly bool _forceFullscreenVertex =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_FULLSCREEN_VERTEX") == "1";
|
||||
private static readonly bool _forceTitleFullscreenVertex =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_FULLSCREEN_VERTEX") == "1";
|
||||
private static readonly bool _forceDefaultRasterState =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_DEFAULT_RASTER_STATE") == "1";
|
||||
private static readonly bool _forceTitleDefaultRasterState =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_DEFAULT_RASTER_STATE") == "1";
|
||||
private static readonly bool _forceTitleSolidFragment =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_SOLID_FRAGMENT") == "1";
|
||||
private static readonly bool _forceSolidFragment =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_SOLID_FRAGMENT") == "1";
|
||||
private static readonly uint? _forceAttributeFragmentLocation =
|
||||
uint.TryParse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_ATTRIBUTE_FRAGMENT"),
|
||||
out var forceAttributeFragmentLocation)
|
||||
? forceAttributeFragmentLocation
|
||||
: null;
|
||||
private static readonly string? _fixedFragmentDumpPath =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_DUMP_FIXED_SOLID_FRAGMENT");
|
||||
private static readonly bool _forceTitleDefaultBlend =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_DEFAULT_BLEND") == "1";
|
||||
private static readonly bool _forceTitleDefaultViewportScissor =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_DEFAULT_VIEWPORT_SCISSOR") == "1";
|
||||
private static readonly bool _forceTitleDisableCull =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_DISABLE_CULL") == "1";
|
||||
private static readonly bool _forceTitleDisableDepth =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_DISABLE_DEPTH") == "1";
|
||||
private static readonly bool _traceTitleState =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_TITLE_STATE") == "1";
|
||||
private static readonly bool _forceTitleVertexColorWhite =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_VERTEX_COLOR_WHITE") == "1";
|
||||
private static readonly bool _chunkedDrawsEnabled =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_ENABLE_CHUNKED_DRAWS") == "1";
|
||||
private static readonly string? _traceGuestWritesMode =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_WRITES");
|
||||
private static readonly long _traceGuestWriteOrdinal =
|
||||
long.TryParse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_WRITE_ORDINAL"),
|
||||
out var traceGuestWriteOrdinal)
|
||||
? traceGuestWriteOrdinal
|
||||
: 0;
|
||||
private static readonly long _traceLargeGuestWriteOrdinal =
|
||||
ParseTraceLargeGuestWriteOrdinal(_traceGuestWritesMode);
|
||||
private static readonly int _tracePixelSpirvBytes =
|
||||
int.TryParse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_PIXEL_SPIRV_BYTES"),
|
||||
out var tracePixelSpirvBytes)
|
||||
? tracePixelSpirvBytes
|
||||
: 0;
|
||||
private static readonly int _tracePixelSpirvOccurrence =
|
||||
int.TryParse(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_PIXEL_SPIRV_OCCURRENCE"),
|
||||
out var tracePixelSpirvOccurrence)
|
||||
? Math.Max(tracePixelSpirvOccurrence, 1)
|
||||
: 1;
|
||||
private static readonly bool _traceTitleDrawEnabled =
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_TITLE_DRAW") == "1";
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<
|
||||
string,
|
||||
(bool Wildcard, ulong[] Addresses)> _cachedAddressLists = new();
|
||||
|
||||
private static long ParseTraceLargeGuestWriteOrdinal(string? mode)
|
||||
{
|
||||
return mode is not null &&
|
||||
mode.StartsWith("large@", StringComparison.Ordinal) &&
|
||||
long.TryParse(mode.AsSpan("large@".Length), out var ordinal) &&
|
||||
ordinal > 0
|
||||
? ordinal
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static bool ShouldTraceGuestImageContentsForDiagnostics() =>
|
||||
_traceGuestImagesEnabled;
|
||||
|
||||
@@ -13113,8 +13230,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
// MoltenVK this starves the render thread and makes the guest fall
|
||||
// behind its own flip queue. Vulkan clips a normal fullscreen draw
|
||||
// efficiently; keep tiling only as an explicit driver diagnostic.
|
||||
var maxPixelsPerDraw = Environment.GetEnvironmentVariable(
|
||||
"SHARPEMU_ENABLE_CHUNKED_DRAWS") == "1"
|
||||
var maxPixelsPerDraw = _chunkedDrawsEnabled
|
||||
? 512u * 512u
|
||||
: uint.MaxValue;
|
||||
var rowsPerDraw = Math.Max(
|
||||
|
||||
@@ -325,9 +325,31 @@ public sealed record Gen5ShaderProgram(
|
||||
ulong Address,
|
||||
IReadOnlyList<Gen5ShaderInstruction> Instructions)
|
||||
{
|
||||
private const uint PixelColorTargetCount = 8;
|
||||
private const int PixelColorMaskBits = 4;
|
||||
private readonly uint _pixelColorExportMasks = ComputePixelColorExportMasks(Instructions);
|
||||
private const int ScalarRegisterCount = 256;
|
||||
private IReadOnlySet<uint>? _runtimeScalarRegisters;
|
||||
|
||||
public uint PixelColorExportMasks => _pixelColorExportMasks;
|
||||
|
||||
private static uint ComputePixelColorExportMasks(
|
||||
IReadOnlyList<Gen5ShaderInstruction> instructions)
|
||||
{
|
||||
var masks = 0u;
|
||||
foreach (var instruction in instructions)
|
||||
{
|
||||
if (instruction.Control is Gen5ExportControl export &&
|
||||
export.Target < PixelColorTargetCount)
|
||||
{
|
||||
masks |= (export.EnableMask & 0xFu) <<
|
||||
(int)(export.Target * PixelColorMaskBits);
|
||||
}
|
||||
}
|
||||
|
||||
return masks;
|
||||
}
|
||||
|
||||
public IEnumerable<Gen5ImageControl> ImageResources =>
|
||||
Instructions
|
||||
.Select(instruction => instruction.Control)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.ShaderCompiler;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
public sealed class Gen5ShaderProgramTests
|
||||
{
|
||||
[Fact]
|
||||
public void PixelColorExportMasksPackAllColorTargets()
|
||||
{
|
||||
var program = new Gen5ShaderProgram(
|
||||
0,
|
||||
[
|
||||
Export(0, 0x1),
|
||||
Export(0, 0x4),
|
||||
Export(1, 0x2),
|
||||
Export(2, 0x3),
|
||||
Export(3, 0x4),
|
||||
Export(4, 0x5),
|
||||
Export(5, 0x6),
|
||||
Export(6, 0x7),
|
||||
Export(7, 0x8),
|
||||
Export(8, 0xF),
|
||||
]);
|
||||
|
||||
Assert.Equal(0x8765_4325u, program.PixelColorExportMasks);
|
||||
Assert.Equal(0x8765_4325u, program.PixelColorExportMasks);
|
||||
}
|
||||
|
||||
private static Gen5ShaderInstruction Export(uint target, uint enableMask) => new(
|
||||
0,
|
||||
Gen5ShaderEncoding.Exp,
|
||||
"exp",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
new Gen5ExportControl(target, enableMask, false, false, false));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.Libs.Audio;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Audio;
|
||||
|
||||
public sealed class AudioPcmConversionTests
|
||||
{
|
||||
[Fact]
|
||||
public void FloatFullScaleMapsToSignedPcmEndpoints()
|
||||
{
|
||||
Span<byte> source = stackalloc byte[sizeof(float) * 2];
|
||||
WriteFloat(source, 0, -1.0f);
|
||||
WriteFloat(source, 1, 1.0f);
|
||||
Span<byte> destination = stackalloc byte[AudioPcmConversion.OutputFrameSize];
|
||||
|
||||
AudioPcmConversion.ConvertToStereoPcm16(
|
||||
source,
|
||||
destination,
|
||||
frames: 1,
|
||||
channels: 2,
|
||||
bytesPerSample: sizeof(float),
|
||||
isFloat: true,
|
||||
volume: 1.0f);
|
||||
|
||||
Assert.Equal(short.MinValue, BinaryPrimitives.ReadInt16LittleEndian(destination));
|
||||
Assert.Equal(short.MaxValue, BinaryPrimitives.ReadInt16LittleEndian(destination[2..]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FloatNaNMapsToSilence()
|
||||
{
|
||||
Span<byte> source = stackalloc byte[sizeof(float)];
|
||||
WriteFloat(source, 0, float.NaN);
|
||||
Span<byte> destination = stackalloc byte[AudioPcmConversion.OutputFrameSize];
|
||||
|
||||
AudioPcmConversion.ConvertToStereoPcm16(
|
||||
source,
|
||||
destination,
|
||||
frames: 1,
|
||||
channels: 1,
|
||||
bytesPerSample: sizeof(float),
|
||||
isFloat: true,
|
||||
volume: 1.0f);
|
||||
|
||||
Assert.Equal(0, BinaryPrimitives.ReadInt16LittleEndian(destination));
|
||||
Assert.Equal(0, BinaryPrimitives.ReadInt16LittleEndian(destination[2..]));
|
||||
}
|
||||
|
||||
private static void WriteFloat(Span<byte> destination, int sample, float value) =>
|
||||
BinaryPrimitives.WriteInt32LittleEndian(
|
||||
destination[(sample * sizeof(float))..],
|
||||
BitConverter.SingleToInt32Bits(value));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Font;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Font;
|
||||
|
||||
public sealed class FontExportsTests
|
||||
{
|
||||
private const ulong Base = 0x1_0000_0000;
|
||||
private const ulong LayoutAddress = Base + 0x100;
|
||||
|
||||
private readonly FakeCpuMemory _memory = new(Base, 0x1000);
|
||||
private readonly CpuContext _ctx;
|
||||
|
||||
public FontExportsTests()
|
||||
{
|
||||
_ctx = new CpuContext(_memory, Generation.Gen5);
|
||||
}
|
||||
|
||||
// SceFontHorizontalLayout is three floats; the sentinel directly after
|
||||
// them must survive the call.
|
||||
[Fact]
|
||||
public void GetHorizontalLayout_WritesExactlyThreeFloats()
|
||||
{
|
||||
const uint Sentinel = 0xDEADBEEF;
|
||||
Span<byte> sentinelBytes = stackalloc byte[sizeof(uint)];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(sentinelBytes, Sentinel);
|
||||
Assert.True(_ctx.Memory.TryWrite(LayoutAddress + 12, sentinelBytes));
|
||||
|
||||
_ctx[CpuRegister.Rsi] = LayoutAddress;
|
||||
Assert.Equal(0, FontExports.GetHorizontalLayout(_ctx));
|
||||
|
||||
Span<byte> layout = stackalloc byte[16];
|
||||
Assert.True(_ctx.Memory.TryRead(LayoutAddress, layout));
|
||||
Assert.Equal(12.0f, BinaryPrimitives.ReadSingleLittleEndian(layout));
|
||||
Assert.Equal(16.0f, BinaryPrimitives.ReadSingleLittleEndian(layout[4..]));
|
||||
Assert.Equal(0.0f, BinaryPrimitives.ReadSingleLittleEndian(layout[8..]));
|
||||
Assert.Equal(Sentinel, BinaryPrimitives.ReadUInt32LittleEndian(layout[12..]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Core.Memory;
|
||||
using SharpEmu.Core.Loader;
|
||||
using SharpEmu.HLE.Host;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Memory;
|
||||
|
||||
// PhysicalVirtualMemory is the host-backed (identity-mapped) implementation.
|
||||
// Reserve-only regions (> 4 GiB, non-executable) defer commit until first
|
||||
// access; TryAllocateGuestMemory serves a first-fit free-list with coalescing.
|
||||
// These tests pin that behaviour through fake IHostMemory implementations.
|
||||
public sealed class PhysicalVirtualMemoryTests
|
||||
{
|
||||
// 1. Lazy commit: a reserve-only region has its pages committed on demand
|
||||
// when read; freshly committed pages read as zero.
|
||||
[Fact]
|
||||
public void LazyReadCommitsPageOnDemandAndReadsZero()
|
||||
{
|
||||
using var host = new LazyZeroedHostMemory();
|
||||
using var memory = new PhysicalVirtualMemory(host);
|
||||
|
||||
// > 4 GiB, non-executable -> reserve-only with lazy commit.
|
||||
var address = memory.AllocateAt(0, (4UL << 30) + 0x1000, executable: false);
|
||||
Assert.NotEqual(0UL, address);
|
||||
|
||||
// Discard the priming commits AllocateAt issues up front; we want to
|
||||
// observe the on-demand commit triggered by the read itself.
|
||||
host.CommitCalls.Clear();
|
||||
|
||||
var buffer = new byte[1];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
Assert.Equal(0, buffer[0]);
|
||||
|
||||
// The touched page (page-aligned to `address`) was committed on demand.
|
||||
var page = address & ~0xFFFUL;
|
||||
Assert.Equal([(page, 0x1000UL, HostPageProtection.ReadWrite)], host.CommitCalls);
|
||||
}
|
||||
|
||||
// 2. Reserve-only region: GetPointer commits the page before returning it,
|
||||
// so callers receive a valid (non-null) pointer. An unmapped address yields null.
|
||||
[Fact]
|
||||
public unsafe void GetPointerOnReserveOnlyRegionCommitsAndReturnsValidPointer()
|
||||
{
|
||||
using var host = new LazyZeroedHostMemory();
|
||||
using var memory = new PhysicalVirtualMemory(host);
|
||||
|
||||
var address = memory.AllocateAt(0, (4UL << 30) + 0x1000, executable: false);
|
||||
host.CommitCalls.Clear();
|
||||
|
||||
var pointer = memory.GetPointer(address + 0x123);
|
||||
Assert.NotEqual(0UL, (ulong)pointer);
|
||||
Assert.Equal(address + 0x123, (ulong)pointer);
|
||||
|
||||
var page = (address + 0x123) & ~0xFFFUL;
|
||||
Assert.Equal([(page, 0x1000UL, HostPageProtection.ReadWrite)], host.CommitCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public unsafe void GetPointerOnUnmappedAddressReturnsNull()
|
||||
{
|
||||
using var host = new LazyZeroedHostMemory();
|
||||
using var memory = new PhysicalVirtualMemory(host);
|
||||
|
||||
Assert.Equal(0UL, (ulong)memory.GetPointer(0x0001_0000));
|
||||
}
|
||||
|
||||
// 3. Free-list reuse: a freed range is served back by first-fit allocation,
|
||||
// preferring the lowest fitting free range over the larger trailing span.
|
||||
[Fact]
|
||||
public void FreedRangeIsReusedByFirstFitAllocation()
|
||||
{
|
||||
using var memory = new PhysicalVirtualMemory(new FakeHostMemory());
|
||||
|
||||
Assert.True(memory.TryAllocateGuestMemory(0x4000, 0x1000, out var first));
|
||||
Assert.True(memory.TryAllocateGuestMemory(0x4000, 0x1000, out var second));
|
||||
Assert.NotEqual(first, second);
|
||||
Assert.True(memory.TryFreeGuestMemory(first));
|
||||
|
||||
// A smaller allocation must reuse first's freed slot (lowest fitting range),
|
||||
// not the larger trailing free range.
|
||||
Assert.True(memory.TryAllocateGuestMemory(0x2000, 0x1000, out var reused));
|
||||
Assert.Equal(first, reused);
|
||||
}
|
||||
|
||||
// 4. Coalescing: freeing the middle of three adjacent ranges merges both the
|
||||
// left and right free neighbours in a single TryFreeGuestMemory call,
|
||||
// restoring the full span for subsequent first-fit reuse.
|
||||
[Fact]
|
||||
public void FreeingMiddleRangeCoalescesBothNeighbours()
|
||||
{
|
||||
using var memory = new PhysicalVirtualMemory(new FakeHostMemory());
|
||||
|
||||
// Three adjacent 0x1000 allocations: offsets 0x1000, 0x2000, 0x3000.
|
||||
Assert.True(memory.TryAllocateGuestMemory(0x1000, 0x1000, out var first));
|
||||
Assert.True(memory.TryAllocateGuestMemory(0x1000, 0x1000, out var second));
|
||||
Assert.True(memory.TryAllocateGuestMemory(0x1000, 0x1000, out var third));
|
||||
|
||||
// Free the outer ranges first, leaving two separate free ranges.
|
||||
Assert.True(memory.TryFreeGuestMemory(first));
|
||||
Assert.True(memory.TryFreeGuestMemory(third));
|
||||
|
||||
// Freeing the middle range must coalesce both neighbours at once.
|
||||
Assert.True(memory.TryFreeGuestMemory(second));
|
||||
|
||||
// The whole arena is now one coalesced free range; a full-arena allocation
|
||||
// reuses first's base address.
|
||||
Assert.True(memory.TryAllocateGuestMemory(0x000F_F000, 0x1000, out var coalesced));
|
||||
Assert.Equal(first, coalesced);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Host memory backed by a single real, zero-initialised page. Reserve/Allocate
|
||||
/// report the page-aligned buffer address so lazy-commit read paths can actually
|
||||
/// dereference the returned pointer. Query always reports Reserved, so
|
||||
/// EnsureRangeCommitted issues a Commit on first access.
|
||||
/// </summary>
|
||||
private sealed unsafe class LazyZeroedHostMemory : IHostMemory, IDisposable
|
||||
{
|
||||
private readonly void* _allocation;
|
||||
private readonly ulong _address;
|
||||
private bool _freed;
|
||||
|
||||
public LazyZeroedHostMemory()
|
||||
{
|
||||
_allocation = System.Runtime.InteropServices.NativeMemory.AllocZeroed(0x2000);
|
||||
_address = ((ulong)_allocation + 0xFFF) & ~0xFFFUL;
|
||||
}
|
||||
|
||||
public List<(ulong Address, ulong Size, HostPageProtection Protection)> CommitCalls { get; } = [];
|
||||
|
||||
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) => _address;
|
||||
|
||||
public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection) => _address;
|
||||
|
||||
public bool Commit(ulong address, ulong size, HostPageProtection protection)
|
||||
{
|
||||
CommitCalls.Add((address, size, protection));
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Free(ulong address)
|
||||
{
|
||||
// The real buffer is released in Dispose; keep Free a no-op so
|
||||
// PhysicalVirtualMemory.Clear does not double-free it.
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Protect(ulong address, ulong size, HostPageProtection protection, out uint rawOldProtection)
|
||||
{
|
||||
rawOldProtection = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ProtectRaw(ulong address, ulong size, uint rawProtection, out uint rawOldProtection)
|
||||
{
|
||||
rawOldProtection = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Query(ulong address, out HostRegionInfo info)
|
||||
{
|
||||
var pageAddress = address & ~0xFFFUL;
|
||||
info = new HostRegionInfo(
|
||||
pageAddress,
|
||||
pageAddress,
|
||||
0x1000,
|
||||
HostRegionState.Reserved,
|
||||
0,
|
||||
HostPageProtection.NoAccess,
|
||||
0,
|
||||
0);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void FlushInstructionCache(ulong address, ulong size)
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_freed)
|
||||
{
|
||||
System.Runtime.InteropServices.NativeMemory.Free(_allocation);
|
||||
_freed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal host memory for free-list tests: Allocate honours the desired
|
||||
// address (or a fallback), everything else succeeds as a no-op. The guest
|
||||
// allocation arena never dereferences, so no real backing is required.
|
||||
private sealed class FakeHostMemory : IHostMemory
|
||||
{
|
||||
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) =>
|
||||
desiredAddress != 0 ? desiredAddress : 0x00007000_0000_0000;
|
||||
|
||||
public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection) =>
|
||||
Allocate(desiredAddress, size, protection);
|
||||
|
||||
public bool Commit(ulong address, ulong size, HostPageProtection protection) => true;
|
||||
|
||||
public bool Free(ulong address) => true;
|
||||
|
||||
public bool Protect(ulong address, ulong size, HostPageProtection protection, out uint rawOldProtection)
|
||||
{
|
||||
rawOldProtection = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ProtectRaw(ulong address, ulong size, uint rawProtection, out uint rawOldProtection)
|
||||
{
|
||||
rawOldProtection = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Query(ulong address, out HostRegionInfo info)
|
||||
{
|
||||
info = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void FlushInstructionCache(ulong address, ulong size)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,4 +84,74 @@ public sealed class VirtualMemoryTests
|
||||
Assert.False(memory.TryRead(0x3000, unchanged));
|
||||
Assert.True(memory.TryWrite(0x3000, [9]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadAndTryWriteOnUnmappedAddressReturnFalse()
|
||||
{
|
||||
var memory = new VirtualMemory();
|
||||
memory.Map(0x1000, 0x100, 0, [], ProgramHeaderFlags.Read | ProgramHeaderFlags.Write);
|
||||
|
||||
// Addresses with no covering region fail for both reads and writes.
|
||||
Span<byte> value = stackalloc byte[1];
|
||||
Assert.False(memory.TryRead(0x2000, value));
|
||||
Assert.False(memory.TryWrite(0x2000, value));
|
||||
|
||||
// With no regions mapped at all, every access fails.
|
||||
var empty = new VirtualMemory();
|
||||
Assert.False(empty.TryRead(0x1000, value));
|
||||
Assert.False(empty.TryWrite(0x1000, value));
|
||||
}
|
||||
|
||||
// Zero-length operations succeed on a mapped address and touch nothing, but
|
||||
// still require a mapped address.
|
||||
[Fact]
|
||||
public void ZeroLengthOperationsOnMappedAddressReturnTrue()
|
||||
{
|
||||
var memory = new VirtualMemory();
|
||||
memory.Map(0x1000, 0x100, 0, [0x01], ProgramHeaderFlags.Read | ProgramHeaderFlags.Write);
|
||||
|
||||
Assert.True(memory.TryRead(0x1000, []));
|
||||
Assert.True(memory.TryWrite(0x1000, []));
|
||||
|
||||
Span<byte> value = stackalloc byte[1];
|
||||
Assert.True(memory.TryRead(0x1000, value));
|
||||
Assert.Equal(0x01, value[0]);
|
||||
|
||||
Assert.False(memory.TryRead(0x2000, []));
|
||||
Assert.False(memory.TryWrite(0x2000, []));
|
||||
}
|
||||
|
||||
// A page-aligned region must be accessible at both offset 0 and the final byte.
|
||||
[Fact]
|
||||
public void MappingAtPageBoundarySupportsOffsetZeroAndLastByte()
|
||||
{
|
||||
const ulong pageSize = 0x1000;
|
||||
var memory = new VirtualMemory();
|
||||
memory.Map(pageSize, pageSize, 0, [], ProgramHeaderFlags.Read | ProgramHeaderFlags.Write);
|
||||
|
||||
Assert.True(memory.TryWrite(pageSize, [0x5A]));
|
||||
Assert.True(memory.TryWrite(pageSize + pageSize - 1, [0xA5]));
|
||||
|
||||
Span<byte> head = stackalloc byte[1];
|
||||
Span<byte> tail = stackalloc byte[1];
|
||||
Assert.True(memory.TryRead(pageSize, head));
|
||||
Assert.True(memory.TryRead(pageSize + pageSize - 1, tail));
|
||||
Assert.Equal(0x5A, head[0]);
|
||||
Assert.Equal(0xA5, tail[0]);
|
||||
}
|
||||
|
||||
// A read/write that starts in one region but extends into the unmapped gap
|
||||
// between two non-adjacent regions must fail across the entire range.
|
||||
[Fact]
|
||||
public void ReadWriteAcrossGapBetweenNonAdjacentRegionsReturnsFalse()
|
||||
{
|
||||
const ulong pageSize = 0x1000;
|
||||
var memory = new VirtualMemory();
|
||||
const ProgramHeaderFlags protection = ProgramHeaderFlags.Read | ProgramHeaderFlags.Write;
|
||||
memory.Map(pageSize, pageSize, 0, [], protection);
|
||||
memory.Map(pageSize * 3, pageSize, 0, [], protection);
|
||||
|
||||
Assert.False(memory.TryRead(pageSize, new byte[(int)pageSize * 2]));
|
||||
Assert.False(memory.TryWrite(pageSize, new byte[(int)pageSize * 2]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Pad;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Pad;
|
||||
|
||||
public sealed class PadExportsTests
|
||||
{
|
||||
private const ulong Base = 0x1_0000_0000;
|
||||
private const int InvalidHandle = unchecked((int)0x80920003);
|
||||
|
||||
private readonly FakeCpuMemory _memory = new(Base, 0x1000);
|
||||
private readonly CpuContext _ctx;
|
||||
|
||||
public PadExportsTests()
|
||||
{
|
||||
_ctx = new CpuContext(_memory, Generation.Gen5);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0)]
|
||||
[InlineData(1, 0)]
|
||||
[InlineData(2, InvalidHandle)]
|
||||
[InlineData(-1, InvalidHandle)]
|
||||
public void SetTiltCorrectionState_ValidatesHandle(int handle, int expected)
|
||||
{
|
||||
_ctx[CpuRegister.Rdi] = unchecked((ulong)handle);
|
||||
Assert.Equal(expected, PadExports.PadSetTiltCorrectionState(_ctx));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.SaveData;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.SaveData;
|
||||
|
||||
[CollectionDefinition("SaveDataMemoryState", DisableParallelization = true)]
|
||||
public sealed class SaveDataMemoryStateCollection;
|
||||
|
||||
// The save data memory exports persist to a real backing file whose resolved
|
||||
// path depends on process-wide environment and title configuration. The
|
||||
// fixture pins both and the collection keeps other environment-mutating tests
|
||||
// from running alongside.
|
||||
[Collection("SaveDataMemoryState")]
|
||||
public sealed class SaveDataMemoryExportsTests : IDisposable
|
||||
{
|
||||
private const ulong Base = 0x1_0000_0000;
|
||||
private const ulong SetupParamAddress = Base + 0x100;
|
||||
private const ulong SetupResultAddress = Base + 0x180;
|
||||
private const ulong DataStructAddress = Base + 0x200;
|
||||
private const ulong RequestAddress = Base + 0x280;
|
||||
private const ulong SyncParamAddress = Base + 0x300;
|
||||
private const ulong PayloadAddress = Base + 0x400;
|
||||
private const ulong ReadbackAddress = Base + 0x800;
|
||||
private const ulong LargePayloadAddress = Base + 0x1000;
|
||||
private const ulong LargeReadbackAddress = Base + 0x40000;
|
||||
private const ulong MemorySize = 0x2000;
|
||||
private const ulong UnmappedAddress = Base + 0x100000;
|
||||
private const int MemoryNotReady = unchecked((int)0x809F0012);
|
||||
private const int ParameterError = unchecked((int)0x809F0000);
|
||||
private const int UserId = 0x1001;
|
||||
private const string TitleId = "SDMEMTEST";
|
||||
|
||||
private readonly FakeCpuMemory _memory = new(Base, 0x80000);
|
||||
private readonly CpuContext _ctx;
|
||||
private readonly string _root;
|
||||
private readonly string? _previousRoot;
|
||||
|
||||
public SaveDataMemoryExportsTests()
|
||||
{
|
||||
_ctx = new CpuContext(_memory, Generation.Gen5);
|
||||
_root = Path.Combine(Path.GetTempPath(), $"sharpemu-sdmemory-{Guid.NewGuid():N}");
|
||||
_previousRoot = Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
|
||||
Environment.SetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR", _root);
|
||||
SaveDataExports.ConfigureApplicationInfo(TitleId);
|
||||
}
|
||||
|
||||
private string MemoryPath =>
|
||||
Path.Combine(_root, UserId.ToString(), TitleId, "sce_sdmemory", "memory.dat");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SaveDataExports.ConfigureApplicationInfo(null);
|
||||
Environment.SetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR", _previousRoot);
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Setup_ReportsExistingSizeOnSecondCall()
|
||||
{
|
||||
Assert.Equal(0, Setup());
|
||||
Assert.True(_ctx.TryReadUInt64(SetupResultAddress, out var existedSize));
|
||||
Assert.Equal(0ul, existedSize);
|
||||
|
||||
Assert.Equal(0, Setup());
|
||||
Assert.True(_ctx.TryReadUInt64(SetupResultAddress, out existedSize));
|
||||
Assert.Equal(MemorySize, existedSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetThenGet_RoundTripsThroughBackingFile()
|
||||
{
|
||||
Assert.Equal(0, Setup());
|
||||
|
||||
var payload = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF };
|
||||
Assert.True(_ctx.Memory.TryWrite(PayloadAddress, payload));
|
||||
WriteRequest(PayloadAddress, (ulong)payload.Length, offset: 0x40);
|
||||
Assert.Equal(0, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke()));
|
||||
|
||||
WriteRequest(ReadbackAddress, (ulong)payload.Length, offset: 0x40);
|
||||
Assert.Equal(0, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
|
||||
var readback = new byte[payload.Length];
|
||||
Assert.True(_ctx.Memory.TryRead(ReadbackAddress, readback));
|
||||
Assert.Equal(payload, readback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetThenGet_LargePayload_RoundTrips()
|
||||
{
|
||||
const ulong size = 0x30000;
|
||||
Assert.Equal(0, Setup(0x40000));
|
||||
|
||||
var payload = new byte[size];
|
||||
for (var i = 0; i < payload.Length; i++)
|
||||
{
|
||||
payload[i] = (byte)(i * 31 + 7);
|
||||
}
|
||||
|
||||
Assert.True(_ctx.Memory.TryWrite(LargePayloadAddress, payload));
|
||||
WriteRequest(LargePayloadAddress, size, offset: 0x100);
|
||||
Assert.Equal(0, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke()));
|
||||
|
||||
WriteRequest(LargeReadbackAddress, size, offset: 0x100);
|
||||
Assert.Equal(0, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
|
||||
var readback = new byte[size];
|
||||
Assert.True(_ctx.Memory.TryRead(LargeReadbackAddress, readback));
|
||||
Assert.Equal(payload, readback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Setup_AbsurdSize_ReturnsParameterError()
|
||||
{
|
||||
Assert.Equal(ParameterError, Setup(ulong.MaxValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Setup_InvalidResultPointer_DoesNotCreateBackingFile()
|
||||
{
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT,
|
||||
Setup(resultAddress: UnmappedAddress));
|
||||
Assert.False(File.Exists(MemoryPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Setup_InvalidResultPointer_DoesNotGrowExistingFile()
|
||||
{
|
||||
Assert.Equal(0, Setup(0x1000));
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT,
|
||||
Setup(MemorySize, UnmappedAddress));
|
||||
Assert.Equal(0x1000, new FileInfo(MemoryPath).Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Setup_GrowingExistingMemory_ZeroExtendsAndPreservesContent()
|
||||
{
|
||||
Assert.Equal(0, Setup(0x1000));
|
||||
var payload = new byte[] { 0x11, 0x22 };
|
||||
Assert.True(_ctx.Memory.TryWrite(PayloadAddress, payload));
|
||||
WriteRequest(PayloadAddress, (ulong)payload.Length, offset: 0xFF0);
|
||||
Assert.Equal(0, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke()));
|
||||
|
||||
Assert.Equal(0, Setup(MemorySize));
|
||||
Assert.True(_ctx.TryReadUInt64(SetupResultAddress, out var existedSize));
|
||||
Assert.Equal(0x1000ul, existedSize);
|
||||
|
||||
WriteRequest(ReadbackAddress, 0x20, offset: 0xFF0);
|
||||
Assert.Equal(0, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
|
||||
var readback = new byte[0x20];
|
||||
Assert.True(_ctx.Memory.TryRead(ReadbackAddress, readback));
|
||||
Assert.Equal(payload, readback[..2]);
|
||||
Assert.All(readback[2..], b => Assert.Equal(0, b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSetSync_BeforeSetup_ReturnMemoryNotReady()
|
||||
{
|
||||
WriteRequest(ReadbackAddress, 0x10, offset: 0);
|
||||
Assert.Equal(MemoryNotReady, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
|
||||
Assert.Equal(MemoryNotReady, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke()));
|
||||
Assert.Equal(MemoryNotReady, Sync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAndSync_AfterSetup_Succeed()
|
||||
{
|
||||
Assert.Equal(0, Setup());
|
||||
WriteRequest(ReadbackAddress, 0x10, offset: 0);
|
||||
Assert.Equal(0, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
|
||||
Assert.Equal(0, Sync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_BackingFileRemovedAfterSetup_ReturnsMemoryNotReady()
|
||||
{
|
||||
Assert.Equal(0, Setup());
|
||||
Directory.Delete(_root, recursive: true);
|
||||
WriteRequest(ReadbackAddress, 0x10, offset: 0);
|
||||
Assert.Equal(MemoryNotReady, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MemorySize, 1ul)]
|
||||
[InlineData(0ul, MemorySize + 1)]
|
||||
[InlineData(ulong.MaxValue, 0x10ul)]
|
||||
public void Get_OutOfRange_ReturnsParameterError(ulong offset, ulong size)
|
||||
{
|
||||
Assert.Equal(0, Setup());
|
||||
WriteRequest(ReadbackAddress, size, offset);
|
||||
Assert.Equal(ParameterError, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MemorySize, 1ul)]
|
||||
[InlineData(0ul, MemorySize + 1)]
|
||||
[InlineData(ulong.MaxValue, 0x10ul)]
|
||||
public void Set_OutOfRange_ReturnsParameterError(ulong offset, ulong size)
|
||||
{
|
||||
Assert.Equal(0, Setup());
|
||||
WriteRequest(PayloadAddress, size, offset);
|
||||
Assert.Equal(ParameterError, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke()));
|
||||
}
|
||||
|
||||
private int Setup(ulong memorySize = MemorySize, ulong resultAddress = SetupResultAddress)
|
||||
{
|
||||
Span<byte> param = stackalloc byte[0x40];
|
||||
param.Clear();
|
||||
BinaryPrimitives.WriteInt32LittleEndian(param[0x04..], UserId);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(param[0x08..], memorySize);
|
||||
Assert.True(_ctx.Memory.TryWrite(SetupParamAddress, param));
|
||||
|
||||
_ctx[CpuRegister.Rdi] = SetupParamAddress;
|
||||
_ctx[CpuRegister.Rsi] = resultAddress;
|
||||
return SaveDataExports.SaveDataSetupSaveDataMemory2(_ctx);
|
||||
}
|
||||
|
||||
private int Sync()
|
||||
{
|
||||
Span<byte> param = stackalloc byte[0x28];
|
||||
param.Clear();
|
||||
BinaryPrimitives.WriteInt32LittleEndian(param, UserId);
|
||||
Assert.True(_ctx.Memory.TryWrite(SyncParamAddress, param));
|
||||
|
||||
_ctx[CpuRegister.Rdi] = SyncParamAddress;
|
||||
return SaveDataExports.SaveDataSyncSaveDataMemory(_ctx);
|
||||
}
|
||||
|
||||
private void WriteRequest(ulong bufAddress, ulong bufSize, ulong offset)
|
||||
{
|
||||
Span<byte> data = stackalloc byte[0x18];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(data, bufAddress);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(data[0x08..], bufSize);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(data[0x10..], offset);
|
||||
Assert.True(_ctx.Memory.TryWrite(DataStructAddress, data));
|
||||
|
||||
Span<byte> request = stackalloc byte[0x10];
|
||||
request.Clear();
|
||||
BinaryPrimitives.WriteInt32LittleEndian(request, UserId);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(request[0x08..], DataStructAddress);
|
||||
Assert.True(_ctx.Memory.TryWrite(RequestAddress, request));
|
||||
}
|
||||
|
||||
private CpuContext Invoke()
|
||||
{
|
||||
_ctx[CpuRegister.Rdi] = RequestAddress;
|
||||
return _ctx;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.VideoOut;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the VideoOut pixel-format mapping functions that bridge the PS5 VideoOut
|
||||
/// format space to the AGC guest texture format space. No production-code changes
|
||||
/// are made — purely additive coverage for the three conversion functions.
|
||||
/// </summary>
|
||||
public sealed class VideoOutPixelFormatTests
|
||||
{
|
||||
// ---- GetBytesPerPixel ----
|
||||
|
||||
[Fact]
|
||||
public void GetBytesPerPixel_Known8BitRgbaFormats_Returns4()
|
||||
{
|
||||
Assert.Equal(4u, InvokeGetBytesPerPixel(0x80000000UL)); // A8R8G8B8Srgb
|
||||
Assert.Equal(4u, InvokeGetBytesPerPixel(0x80002200UL)); // A8B8G8R8Srgb
|
||||
Assert.Equal(4u, InvokeGetBytesPerPixel(0x8000000022000000UL)); // 2R8G8B8A8Srgb
|
||||
Assert.Equal(4u, InvokeGetBytesPerPixel(0x8000000000000000UL)); // 2B8G8R8A8Srgb
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBytesPerPixel_Known10BitFormats_Returns4()
|
||||
{
|
||||
Assert.Equal(4u, InvokeGetBytesPerPixel(0x88060000UL)); // A2R10G10B10
|
||||
Assert.Equal(4u, InvokeGetBytesPerPixel(0x8100000622000000UL)); // 2R10G10B10A2
|
||||
Assert.Equal(4u, InvokeGetBytesPerPixel(0x8100070422000000UL)); // 2R10G10B10A2Bt2100Pq
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBytesPerPixel_UnknownFormat_Returns0()
|
||||
{
|
||||
Assert.Equal(0u, InvokeGetBytesPerPixel(0x00000000UL));
|
||||
Assert.Equal(0u, InvokeGetBytesPerPixel(0xDEADBEEFUL));
|
||||
Assert.Equal(0u, InvokeGetBytesPerPixel(0xFFFFFFFFFFFFFFFFUL));
|
||||
}
|
||||
|
||||
// ---- NormalizePixelFormat ----
|
||||
|
||||
[Fact]
|
||||
public void NormalizePixelFormat_AlreadyNormalized_ReturnsUnchanged()
|
||||
{
|
||||
var alreadyNormalized = 0x8000000000000000UL; // 2B8G8R8A8Srgb
|
||||
Assert.Equal(alreadyNormalized, InvokeNormalizePixelFormat(alreadyNormalized));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizePixelFormat_Low32BitsMatch_ReturnsLow32()
|
||||
{
|
||||
var format = 0xDEAD000088060000UL; // high bits garbage, low = A2R10G10B10
|
||||
Assert.Equal(0x88060000UL, InvokeNormalizePixelFormat(format));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizePixelFormat_High32BitsMatch_ReturnsHigh32()
|
||||
{
|
||||
var format = 0x8806000000000001UL; // high = A2R10G10B10, low = unknown
|
||||
Assert.Equal(0x88060000UL, InvokeNormalizePixelFormat(format));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizePixelFormat_CompletelyUnknown_ReturnsOriginal()
|
||||
{
|
||||
var format = 0xCAFEBABECAFEBABEUL;
|
||||
Assert.Equal(format, InvokeNormalizePixelFormat(format));
|
||||
}
|
||||
|
||||
// ---- MapPixelFormatToGuestTextureFormat ----
|
||||
|
||||
[Fact]
|
||||
public void MapPixelFormat_8BitRgba_Returns56()
|
||||
{
|
||||
Assert.Equal(56u, InvokeMapPixelFormat(0x80000000UL));
|
||||
Assert.Equal(56u, InvokeMapPixelFormat(0x8000000022000000UL));
|
||||
Assert.Equal(56u, InvokeMapPixelFormat(0x8000000000000000UL));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapPixelFormat_10BitPacked_Returns9()
|
||||
{
|
||||
Assert.Equal(9u, InvokeMapPixelFormat(0x88060000UL));
|
||||
Assert.Equal(9u, InvokeMapPixelFormat(0x8100000622000000UL));
|
||||
Assert.Equal(9u, InvokeMapPixelFormat(0x8100070422000000UL));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapPixelFormat_Unknown_Returns56()
|
||||
{
|
||||
// Unknown formats now fall back to 56 (8-bit RGBA) so games display
|
||||
// something instead of silently failing the flip.
|
||||
Assert.Equal(56u, InvokeMapPixelFormat(0x00000000UL));
|
||||
Assert.Equal(56u, InvokeMapPixelFormat(0xDEADBEEFUL));
|
||||
}
|
||||
|
||||
// ---- Self-check activation ----
|
||||
|
||||
[Fact]
|
||||
public void StaticConstructor_RunsSelfChecks_WithoutThrowing()
|
||||
{
|
||||
// RunPixelFormatSelfChecks is called from the static constructor.
|
||||
// If the checks fail, Debug.Assert will fail in a debug build.
|
||||
// This test ensures the constructor executed without throwing.
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
// ---- Reflection-based access to internal/private methods ----
|
||||
|
||||
private static uint InvokeGetBytesPerPixel(ulong pixelFormat)
|
||||
{
|
||||
var method = typeof(VideoOutExports)
|
||||
.GetMethod("GetBytesPerPixel", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
return (uint)method!.Invoke(null, [pixelFormat])!;
|
||||
}
|
||||
|
||||
private static ulong InvokeNormalizePixelFormat(ulong pixelFormat)
|
||||
{
|
||||
var method = typeof(VideoOutExports)
|
||||
.GetMethod("NormalizePixelFormat", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
return (ulong)method!.Invoke(null, [pixelFormat])!;
|
||||
}
|
||||
|
||||
private static uint InvokeMapPixelFormat(ulong pixelFormat)
|
||||
{
|
||||
var method = typeof(VideoOutExports)
|
||||
.GetMethod("MapPixelFormatToGuestTextureFormat", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
return (uint)method!.Invoke(null, [pixelFormat])!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user