mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-30 06:29:46 +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:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- "**"
|
- "**"
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- "**/*.md"
|
- "**/*.md"
|
||||||
- "**/*.png"
|
- "**/*.png"
|
||||||
@@ -53,6 +55,11 @@ jobs:
|
|||||||
release_tag="v${version}"
|
release_tag="v${version}"
|
||||||
release_name="SharpEmu 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 "short-sha=${short_sha}"
|
||||||
echo "safe-ref=${safe_ref}"
|
echo "safe-ref=${safe_ref}"
|
||||||
@@ -203,7 +210,9 @@ jobs:
|
|||||||
- init
|
- init
|
||||||
- build
|
- build
|
||||||
- build-posix
|
- 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
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -213,7 +222,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: release
|
path: release
|
||||||
|
|
||||||
- name: Create or update release
|
- name: Create release
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
GH_REPO: ${{ github.repository }}
|
GH_REPO: ${{ github.repository }}
|
||||||
@@ -231,26 +240,11 @@ jobs:
|
|||||||
notes="Automated SharpEmu v${VERSION} build for commit ${GITHUB_SHA}."
|
notes="Automated SharpEmu v${VERSION} build for commit ${GITHUB_SHA}."
|
||||||
|
|
||||||
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
|
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
|
||||||
gh release upload "${RELEASE_TAG}" "${assets[@]}" --clobber
|
echo "Release ${RELEASE_TAG} already exists and will not be modified." >&2
|
||||||
gh release edit "${RELEASE_TAG}" --title "${RELEASE_NAME}" --notes "${notes}"
|
exit 1
|
||||||
else
|
|
||||||
gh release create "${RELEASE_TAG}" "${assets[@]}" \
|
|
||||||
--title "${RELEASE_NAME}" \
|
|
||||||
--notes "${notes}" \
|
|
||||||
--target "${GITHUB_SHA}"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
keep=()
|
gh release create "${RELEASE_TAG}" "${assets[@]}" \
|
||||||
for asset_path in "${assets[@]}"; do
|
--verify-tag \
|
||||||
keep+=("$(basename "${asset_path}")")
|
--title "${RELEASE_NAME}" \
|
||||||
done
|
--notes "${notes}"
|
||||||
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
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<SharpEmuVersion>0.0.2</SharpEmuVersion>
|
<SharpEmuVersion>0.0.2-beta.2</SharpEmuVersion>
|
||||||
<Version>$(SharpEmuVersion)</Version>
|
<Version>$(SharpEmuVersion)</Version>
|
||||||
|
|
||||||
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
|
<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);
|
BindTlsBase(context);
|
||||||
byte* ptr2 = (byte*)ptr;
|
byte* ptr2 = (byte*)ptr;
|
||||||
ulong hostRspSlot = (ulong)hostRspStorage;
|
ulong hostRspSlot = (ulong)hostRspStorage;
|
||||||
int offset = 0;
|
var emitter = new NativeCodeEmitter(ptr2);
|
||||||
|
|
||||||
void Emit(byte value) => ptr2[offset++] = value;
|
emitter.Emit(0x53); // push rbx
|
||||||
void EmitU64(ulong value)
|
emitter.Emit(0x55); // push rbp
|
||||||
{
|
emitter.Emit(0x57); // push rdi
|
||||||
*(ulong*)(ptr2 + offset) = value;
|
emitter.Emit(0x56); // push rsi
|
||||||
offset += sizeof(ulong);
|
emitter.Emit(0x41); emitter.Emit(0x54); // push r12
|
||||||
}
|
emitter.Emit(0x41); emitter.Emit(0x55); // push r13
|
||||||
void EmitMovR64Imm(byte rex, byte opcode, ulong value)
|
emitter.Emit(0x41); emitter.Emit(0x56); // push r14
|
||||||
{
|
emitter.Emit(0x41); emitter.Emit(0x57); // push r15
|
||||||
Emit(rex);
|
EmitHostNonvolatileXmmSave(ptr2, ref emitter.Offset);
|
||||||
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);
|
|
||||||
// Restore the fiber's floating-point control environment before
|
// Restore the fiber's floating-point control environment before
|
||||||
// abandoning the host stack. This path is used when a blocked guest
|
// abandoning the host stack. This path is used when a blocked guest
|
||||||
// continuation migrates to another managed worker.
|
// continuation migrates to another managed worker.
|
||||||
Emit(0x48); Emit(0x83); Emit(0xEC); Emit(0x08); // sub rsp,8
|
emitter.Emit(0x48); emitter.Emit(0x83); emitter.Emit(0xEC); emitter.Emit(0x08); // sub rsp,8
|
||||||
Emit(0xC7); Emit(0x04); Emit(0x24); // mov dword [rsp],imm32
|
emitter.Emit(0xC7); emitter.Emit(0x04); emitter.Emit(0x24); // mov dword [rsp],imm32
|
||||||
*(uint*)(ptr2 + offset) = context.Mxcsr; offset += sizeof(uint);
|
emitter.Emit(context.Mxcsr);
|
||||||
Emit(0x0F); Emit(0xAE); Emit(0x14); Emit(0x24); // ldmxcsr [rsp]
|
emitter.Emit(0x0F); emitter.Emit(0xAE); emitter.Emit(0x14); emitter.Emit(0x24); // ldmxcsr [rsp]
|
||||||
Emit(0x66); Emit(0xC7); Emit(0x04); Emit(0x24); // mov word [rsp],imm16
|
emitter.Emit(0x66); emitter.Emit(0xC7); emitter.Emit(0x04); emitter.Emit(0x24); // mov word [rsp],imm16
|
||||||
*(ushort*)(ptr2 + offset) = context.FpuControlWord; offset += sizeof(ushort);
|
emitter.Emit(context.FpuControlWord);
|
||||||
Emit(0xD9); Emit(0x2C); Emit(0x24); // fldcw [rsp]
|
emitter.Emit(0xD9); emitter.Emit(0x2C); emitter.Emit(0x24); // fldcw [rsp]
|
||||||
Emit(0x48); Emit(0x83); Emit(0xC4); Emit(0x08); // add rsp,8
|
emitter.Emit(0x48); emitter.Emit(0x83); emitter.Emit(0xC4); emitter.Emit(0x08); // add rsp,8
|
||||||
EmitMovR64Imm(0x49, 0xBA, hostRspSlot); // mov r10, hostRspSlot
|
emitter.EmitMovR64Immediate(0x49, 0xBA, hostRspSlot); // mov r10, hostRspSlot
|
||||||
Emit(0x49); Emit(0x89); Emit(0x22); // mov [r10], rsp
|
emitter.Emit(0x49); emitter.Emit(0x89); emitter.Emit(0x22); // mov [r10], rsp
|
||||||
EmitMovR64Imm(0x48, 0xB8, context[CpuRegister.Rsp]); // mov rax, guest rsp
|
emitter.EmitMovR64Immediate(0x48, 0xB8, context[CpuRegister.Rsp]); // mov rax, guest rsp
|
||||||
Emit(0x48); Emit(0x89); Emit(0xC4); // mov rsp, rax
|
emitter.Emit(0x48); emitter.Emit(0x89); emitter.Emit(0xC4); // mov rsp, rax
|
||||||
Emit(0x48); Emit(0x83); Emit(0xEC); Emit(0x08); // reserve transfer slot
|
emitter.Emit(0x48); emitter.Emit(0x83); emitter.Emit(0xEC); emitter.Emit(0x08); // reserve transfer slot
|
||||||
EmitMovR64Imm(0x48, 0xB8, entryPoint); // mov rax, entryPoint
|
emitter.EmitMovR64Immediate(0x48, 0xB8, entryPoint); // mov rax, entryPoint
|
||||||
Emit(0x48); Emit(0x89); Emit(0x04); Emit(0x24); // mov [rsp],rax
|
emitter.Emit(0x48); emitter.Emit(0x89); emitter.Emit(0x04); emitter.Emit(0x24); // mov [rsp],rax
|
||||||
EmitMovR64Imm(0x48, 0xBB, context[CpuRegister.Rbx]); // mov rbx, imm64
|
emitter.EmitMovR64Immediate(0x48, 0xBB, context[CpuRegister.Rbx]); // mov rbx, imm64
|
||||||
EmitMovR64Imm(0x48, 0xBD, context[CpuRegister.Rbp]); // mov rbp, imm64
|
emitter.EmitMovR64Immediate(0x48, 0xBD, context[CpuRegister.Rbp]); // mov rbp, imm64
|
||||||
EmitMovR64Imm(0x48, 0xBF, context[CpuRegister.Rdi]); // mov rdi, imm64
|
emitter.EmitMovR64Immediate(0x48, 0xBF, context[CpuRegister.Rdi]); // mov rdi, imm64
|
||||||
EmitMovR64Imm(0x48, 0xBE, context[CpuRegister.Rsi]); // mov rsi, imm64
|
emitter.EmitMovR64Immediate(0x48, 0xBE, context[CpuRegister.Rsi]); // mov rsi, imm64
|
||||||
EmitMovR64Imm(0x48, 0xBA, context[CpuRegister.Rdx]); // mov rdx, imm64
|
emitter.EmitMovR64Immediate(0x48, 0xBA, context[CpuRegister.Rdx]); // mov rdx, imm64
|
||||||
EmitMovR64Imm(0x48, 0xB9, context[CpuRegister.Rcx]); // mov rcx, imm64
|
emitter.EmitMovR64Immediate(0x48, 0xB9, context[CpuRegister.Rcx]); // mov rcx, imm64
|
||||||
EmitMovR64Imm(0x49, 0xB8, context[CpuRegister.R8]); // mov r8, imm64
|
emitter.EmitMovR64Immediate(0x49, 0xB8, context[CpuRegister.R8]); // mov r8, imm64
|
||||||
EmitMovR64Imm(0x49, 0xB9, context[CpuRegister.R9]); // mov r9, imm64
|
emitter.EmitMovR64Immediate(0x49, 0xB9, context[CpuRegister.R9]); // mov r9, imm64
|
||||||
EmitMovR64Imm(0x49, 0xBA, context[CpuRegister.R10]); // mov r10, imm64
|
emitter.EmitMovR64Immediate(0x49, 0xBA, context[CpuRegister.R10]); // mov r10, imm64
|
||||||
EmitMovR64Imm(0x49, 0xBC, context[CpuRegister.R12]); // mov r12, imm64
|
emitter.EmitMovR64Immediate(0x49, 0xBC, context[CpuRegister.R12]); // mov r12, imm64
|
||||||
EmitMovR64Imm(0x49, 0xBD, context[CpuRegister.R13]); // mov r13, imm64
|
emitter.EmitMovR64Immediate(0x49, 0xBD, context[CpuRegister.R13]); // mov r13, imm64
|
||||||
EmitMovR64Imm(0x49, 0xBE, context[CpuRegister.R14]); // mov r14, imm64
|
emitter.EmitMovR64Immediate(0x49, 0xBE, context[CpuRegister.R14]); // mov r14, imm64
|
||||||
EmitMovR64Imm(0x49, 0xBF, context[CpuRegister.R15]); // mov r15, imm64
|
emitter.EmitMovR64Immediate(0x49, 0xBF, context[CpuRegister.R15]); // mov r15, imm64
|
||||||
EmitMovR64Imm(0x49, 0xBB, context[CpuRegister.R11]); // mov r11, imm64
|
emitter.EmitMovR64Immediate(0x49, 0xBB, context[CpuRegister.R11]); // mov r11, imm64
|
||||||
EmitMovR64Imm(0x48, 0xB8, context[CpuRegister.Rax]); // mov rax, imm64
|
emitter.EmitMovR64Immediate(0x48, 0xB8, context[CpuRegister.Rax]); // mov rax, imm64
|
||||||
Emit(0xC3); // ret through the synthetic transfer slot
|
emitter.Emit(0xC3); // ret through the synthetic transfer slot
|
||||||
ActiveEntryReturnSentinelRip = (ulong)_guestReturnStub;
|
ActiveEntryReturnSentinelRip = (ulong)_guestReturnStub;
|
||||||
if (returnSlotAddress == 0 || !context.TryWriteUInt64(returnSlotAddress, (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)
|
private static ulong AlignDown(ulong value, ulong alignment)
|
||||||
{
|
{
|
||||||
if (alignment == 0)
|
if (alignment == 0)
|
||||||
|
|||||||
@@ -197,8 +197,9 @@ public sealed class SelfLoader : ISelfLoader
|
|||||||
{
|
{
|
||||||
if (!physicalVm.TryAllocateAtExact(imageBase, totalImageSize, executable: true, out var allocatedBase))
|
if (!physicalVm.TryAllocateAtExact(imageBase, totalImageSize, executable: true, out var allocatedBase))
|
||||||
{
|
{
|
||||||
|
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
|
||||||
throw new InvalidOperationException(
|
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;
|
imageBase = allocatedBase;
|
||||||
|
|||||||
@@ -198,6 +198,24 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
|||||||
return true;
|
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)
|
public ulong AllocateAt(ulong desiredAddress, ulong size, bool executable = true, bool allowAlternative = true)
|
||||||
{
|
{
|
||||||
if (size == 0)
|
if (size == 0)
|
||||||
|
|||||||
@@ -3729,6 +3729,8 @@ public static partial class AgcExports
|
|||||||
_labelProducers.Add(producer);
|
_labelProducers.Add(producer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_traceAgc)
|
||||||
|
{
|
||||||
foreach (var waiting in GpuWaitRegistry.SnapshotInRange(memory, address, length))
|
foreach (var waiting in GpuWaitRegistry.SnapshotInRange(memory, address, length))
|
||||||
{
|
{
|
||||||
TraceAgc(
|
TraceAgc(
|
||||||
@@ -3737,6 +3739,7 @@ public static partial class AgcExports
|
|||||||
$"queue={producer.QueueName} submission={producer.SubmissionId} " +
|
$"queue={producer.QueueName} submission={producer.SubmissionId} " +
|
||||||
$"packet=0x{packetAddress:X16} action='{debugName}'");
|
$"packet=0x{packetAddress:X16} action='{debugName}'");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return producer;
|
return producer;
|
||||||
}
|
}
|
||||||
@@ -3753,6 +3756,8 @@ public static partial class AgcExports
|
|||||||
producer.Completed = true;
|
producer.Completed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_traceAgc)
|
||||||
|
{
|
||||||
foreach (var waiting in GpuWaitRegistry.SnapshotInRange(
|
foreach (var waiting in GpuWaitRegistry.SnapshotInRange(
|
||||||
producer.Memory,
|
producer.Memory,
|
||||||
producer.Address,
|
producer.Address,
|
||||||
@@ -3765,6 +3770,7 @@ public static partial class AgcExports
|
|||||||
$"action='{producer.DebugName}'");
|
$"action='{producer.DebugName}'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void TraceWaitProducerState(
|
private static void TraceWaitProducerState(
|
||||||
object memory,
|
object memory,
|
||||||
@@ -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 prefix = stale ? "agc.wait_stale" : "agc.wait_suspended";
|
||||||
var current = currentValue.HasValue
|
var current = currentValue.HasValue
|
||||||
? $"0x{currentValue.Value:X16}"
|
? $"0x{currentValue.Value:X16}"
|
||||||
@@ -5937,13 +5951,14 @@ public static partial class AgcExports
|
|||||||
// Every bound color target the shader exports to. Deferred renderers
|
// Every bound color target the shader exports to. Deferred renderers
|
||||||
// draw a multi-render-target G-buffer (up to eight slots) in one pass.
|
// 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.
|
// 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);
|
var allBoundTargets = GetRenderTargets(state.CxRegisters);
|
||||||
// At most 8 slots; a manual filter avoids the per-draw LINQ iterator/
|
// At most 8 slots; a manual filter avoids the per-draw LINQ iterator/
|
||||||
// closure allocations. Slots are distinct, so sorting by slot is stable.
|
// closure allocations. Slots are distinct, so sorting by slot is stable.
|
||||||
var selectedTargets = new List<RenderTargetDescriptor>(allBoundTargets.Count);
|
var selectedTargets = new List<RenderTargetDescriptor>(allBoundTargets.Count);
|
||||||
foreach (var target in allBoundTargets)
|
foreach (var target in allBoundTargets)
|
||||||
{
|
{
|
||||||
if (HasPixelColorExport(pixelState, target.Slot))
|
if (GetPixelColorExportMask(pixelColorExportMasks, target.Slot) != 0)
|
||||||
{
|
{
|
||||||
selectedTargets.Add(target);
|
selectedTargets.Add(target);
|
||||||
}
|
}
|
||||||
@@ -5966,7 +5981,7 @@ public static partial class AgcExports
|
|||||||
{
|
{
|
||||||
TraceAgcShader(
|
TraceAgcShader(
|
||||||
$"agc.mrt_filter ps=0x{pixelShaderAddress:X16} " +
|
$"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}");
|
$"kept={renderTargets.Length}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6087,54 +6102,39 @@ public static partial class AgcExports
|
|||||||
_graphicsShaderCache.TryAdd(shaderKey, compiled);
|
_graphicsShaderCache.TryAdd(shaderKey, compiled);
|
||||||
}
|
}
|
||||||
|
|
||||||
var imageBindings = pixelEvaluation.ImageBindings
|
|
||||||
.Concat(exportEvaluation.ImageBindings);
|
|
||||||
var textures = new List<TranslatedImageBinding>(
|
var textures = new List<TranslatedImageBinding>(
|
||||||
pixelEvaluation.ImageBindings.Count +
|
pixelEvaluation.ImageBindings.Count +
|
||||||
exportEvaluation.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(exportEvaluation);
|
||||||
ReturnPooledEvaluationArrays(pixelEvaluation);
|
ReturnPooledEvaluationArrays(pixelEvaluation);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
texture = new TextureDescriptor(
|
var globalMemoryBindings = new Gen5GlobalMemoryBinding[
|
||||||
0, 1, 1, Gen5TextureFormatR8G8B8A8Unorm, 0, 0, 0, 0, 0, 1, 0xFAC);
|
pixelEvaluation.GlobalMemoryBindings.Count +
|
||||||
}
|
exportEvaluation.GlobalMemoryBindings.Count];
|
||||||
|
for (var index = 0; index < pixelEvaluation.GlobalMemoryBindings.Count; index++)
|
||||||
if (_traceAgcShader || _tracePixelShaderAddress == pixelShaderAddress)
|
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine(
|
globalMemoryBindings[index] = pixelEvaluation.GlobalMemoryBindings[index];
|
||||||
"[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(
|
for (var index = 0; index < exportEvaluation.GlobalMemoryBindings.Count; index++)
|
||||||
new TranslatedImageBinding(
|
{
|
||||||
texture,
|
globalMemoryBindings[pixelEvaluation.GlobalMemoryBindings.Count + index] =
|
||||||
Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode),
|
exportEvaluation.GlobalMemoryBindings[index];
|
||||||
binding.MipLevel ?? 0,
|
|
||||||
binding.SamplerDescriptor));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var globalMemoryBindings = pixelEvaluation.GlobalMemoryBindings
|
|
||||||
.Concat(exportEvaluation.GlobalMemoryBindings)
|
|
||||||
.ToArray();
|
|
||||||
IReadOnlyList<Gen5VertexInputBinding> vertexInputs =
|
IReadOnlyList<Gen5VertexInputBinding> vertexInputs =
|
||||||
exportEvaluation.VertexInputs ?? [];
|
exportEvaluation.VertexInputs ?? [];
|
||||||
state.UcRegisters.TryGetValue(VgtPrimitiveType, out var primitiveType);
|
state.UcRegisters.TryGetValue(VgtPrimitiveType, out var primitiveType);
|
||||||
@@ -6149,6 +6149,13 @@ public static partial class AgcExports
|
|||||||
renderTargets[index].NumberType);
|
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(
|
draw = new TranslatedGuestDraw(
|
||||||
exportShaderAddress,
|
exportShaderAddress,
|
||||||
pixelShaderAddress,
|
pixelShaderAddress,
|
||||||
@@ -6166,11 +6173,11 @@ public static partial class AgcExports
|
|||||||
DecodeDepthTarget(state.CxRegisters),
|
DecodeDepthTarget(state.CxRegisters),
|
||||||
guestTargets,
|
guestTargets,
|
||||||
ApplyTransparentPremultipliedFillClear(
|
ApplyTransparentPremultipliedFillClear(
|
||||||
CreateRenderState(state.CxRegisters, renderTargets, pixelState),
|
CreateRenderState(state.CxRegisters, renderTargets, pixelColorExportMasks),
|
||||||
textures,
|
textures,
|
||||||
vertexInputs,
|
vertexInputs,
|
||||||
pixelEvaluation.InitialScalarRegisters),
|
pixelEvaluation.InitialScalarRegisters),
|
||||||
pixelEvaluation.InitialScalarRegisters.Take(8).ToArray(),
|
pixelUserData,
|
||||||
state.CxRegisters.TryGetValue(CbBlend0Control, out var rawBlend) ? rawBlend : 0,
|
state.CxRegisters.TryGetValue(CbBlend0Control, out var rawBlend) ? rawBlend : 0,
|
||||||
state.CxRegisters.TryGetValue(
|
state.CxRegisters.TryGetValue(
|
||||||
CbColor0Info + renderTargets.FirstOrDefault().Slot * CbColorRegisterStride,
|
CbColor0Info + renderTargets.FirstOrDefault().Slot * CbColorRegisterStride,
|
||||||
@@ -6182,6 +6189,55 @@ public static partial class AgcExports
|
|||||||
return true;
|
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 _tracedAstroTitlePixelGlobals;
|
||||||
private static int _tracedAstroTitlePixelGlobalProbe;
|
private static int _tracedAstroTitlePixelGlobalProbe;
|
||||||
|
|
||||||
@@ -6410,26 +6466,10 @@ public static partial class AgcExports
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool HasPixelColorExport(Gen5ShaderState state, uint target) =>
|
private static uint GetPixelColorExportMask(uint packedMasks, uint target) =>
|
||||||
GetPixelColorExportMask(state, target) != 0;
|
target < ColorTargetCount
|
||||||
|
? (packedMasks >> (int)(target * 4)) & 0xFu
|
||||||
private static uint GetPixelColorExportMask(Gen5ShaderState state, uint target)
|
: 0;
|
||||||
{
|
|
||||||
// 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 GetInterpolatedAttributeCount(Gen5ShaderState state)
|
private static uint GetInterpolatedAttributeCount(Gen5ShaderState state)
|
||||||
{
|
{
|
||||||
@@ -6627,7 +6667,7 @@ public static partial class AgcExports
|
|||||||
private static GuestRenderState CreateRenderState(
|
private static GuestRenderState CreateRenderState(
|
||||||
IReadOnlyDictionary<uint, uint> registers,
|
IReadOnlyDictionary<uint, uint> registers,
|
||||||
IReadOnlyList<RenderTargetDescriptor> targets,
|
IReadOnlyList<RenderTargetDescriptor> targets,
|
||||||
Gen5ShaderState pixelState)
|
uint pixelColorExportMasks)
|
||||||
{
|
{
|
||||||
if (targets.Count == 0)
|
if (targets.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -6636,15 +6676,21 @@ public static partial class AgcExports
|
|||||||
|
|
||||||
var target = targets[0];
|
var target = targets[0];
|
||||||
var scissor = DecodeScissor(registers, target.Width, target.Height);
|
var scissor = DecodeScissor(registers, target.Width, target.Height);
|
||||||
return new GuestRenderState(
|
var blends = new GuestBlendState[targets.Count];
|
||||||
targets.Select(target =>
|
for (var index = 0; index < targets.Count; index++)
|
||||||
{
|
{
|
||||||
var blend = DecodeBlendState(registers, target.Slot);
|
var blend = DecodeBlendState(registers, targets[index].Slot);
|
||||||
return blend with
|
blends[index] = blend with
|
||||||
{
|
{
|
||||||
WriteMask = blend.WriteMask & GetPixelColorExportMask(pixelState, target.Slot),
|
WriteMask = blend.WriteMask &
|
||||||
|
GetPixelColorExportMask(
|
||||||
|
pixelColorExportMasks,
|
||||||
|
targets[index].Slot),
|
||||||
};
|
};
|
||||||
}).ToArray(),
|
}
|
||||||
|
|
||||||
|
return new GuestRenderState(
|
||||||
|
blends,
|
||||||
scissor,
|
scissor,
|
||||||
DecodeViewport(registers, target.Width, target.Height, scissor),
|
DecodeViewport(registers, target.Width, target.Height, scissor),
|
||||||
DecodeRasterState(registers),
|
DecodeRasterState(registers),
|
||||||
|
|||||||
@@ -52,8 +52,19 @@ internal static class AudioPcmConversion
|
|||||||
}
|
}
|
||||||
|
|
||||||
var bits = BinaryPrimitives.ReadInt32LittleEndian(sample);
|
var bits = BinaryPrimitives.ReadInt32LittleEndian(sample);
|
||||||
var value = Math.Clamp(BitConverter.Int32BitsToSingle(bits), -1.0f, 1.0f);
|
return ConvertFloatSample(BitConverter.Int32BitsToSingle(bits));
|
||||||
return checked((short)MathF.Round(value * short.MaxValue));
|
}
|
||||||
|
|
||||||
|
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)
|
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) =>
|
public static int CreateRendererWithEdition(CpuContext ctx) =>
|
||||||
CreateOpaqueHandle(ctx, ctx[CpuRegister.Rcx], 0x100, magic: 0x0F07);
|
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(
|
[SysAbiExport(
|
||||||
Nid = "cKYtVmeSTcw",
|
Nid = "cKYtVmeSTcw",
|
||||||
ExportName = "sceFontOpenFontSet",
|
ExportName = "sceFontOpenFontSet",
|
||||||
|
|||||||
@@ -127,6 +127,19 @@ public static class PadExports
|
|||||||
: ctx.SetReturn(OrbisPadErrorInvalidHandle);
|
: 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(
|
[SysAbiExport(
|
||||||
Nid = "gjP9-KQzoUk",
|
Nid = "gjP9-KQzoUk",
|
||||||
ExportName = "scePadGetControllerInformation",
|
ExportName = "scePadGetControllerInformation",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
using SharpEmu.HLE;
|
using SharpEmu.HLE;
|
||||||
using SharpEmu.Libs.Kernel;
|
using SharpEmu.Libs.Kernel;
|
||||||
|
using System.Buffers;
|
||||||
using System.Buffers.Binary;
|
using System.Buffers.Binary;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ public static class SaveDataExports
|
|||||||
private const int OrbisSaveDataErrorExists = unchecked((int)0x809F0007);
|
private const int OrbisSaveDataErrorExists = unchecked((int)0x809F0007);
|
||||||
private const int OrbisSaveDataErrorNotFound = unchecked((int)0x809F0008);
|
private const int OrbisSaveDataErrorNotFound = unchecked((int)0x809F0008);
|
||||||
private const int OrbisSaveDataErrorInternal = unchecked((int)0x809F000B);
|
private const int OrbisSaveDataErrorInternal = unchecked((int)0x809F000B);
|
||||||
|
private const int OrbisSaveDataErrorMemoryNotReady = unchecked((int)0x809F0012);
|
||||||
private const int SaveDataTitleIdSize = 10;
|
private const int SaveDataTitleIdSize = 10;
|
||||||
private const int SaveDataDirNameSize = 32;
|
private const int SaveDataDirNameSize = 32;
|
||||||
private const int SaveDataParamSize = 0x530;
|
private const int SaveDataParamSize = 0x530;
|
||||||
@@ -29,7 +31,10 @@ public static class SaveDataExports
|
|||||||
private const uint MountModeCreate = 1u << 2;
|
private const uint MountModeCreate = 1u << 2;
|
||||||
private const uint MountModeCreate2 = 1u << 5;
|
private const uint MountModeCreate2 = 1u << 5;
|
||||||
private const int MountResultSize = 0x40;
|
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 _stateGate = new();
|
||||||
|
private static readonly object _memoryGate = new();
|
||||||
private static readonly HashSet<int> _preparedTransactionResources = [];
|
private static readonly HashSet<int> _preparedTransactionResources = [];
|
||||||
private static string? _titleId;
|
private static string? _titleId;
|
||||||
|
|
||||||
@@ -472,6 +477,19 @@ public static class SaveDataExports
|
|||||||
private static string ResolveTitleSaveRoot(int userId, string titleId) =>
|
private static string ResolveTitleSaveRoot(int userId, string titleId) =>
|
||||||
Path.Combine(ResolveSaveDataRoot(), userId.ToString(), SanitizePathSegment(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()
|
private static string ResolveSaveDataRoot()
|
||||||
{
|
{
|
||||||
var configured = Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
|
var configured = Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
|
||||||
@@ -670,4 +688,197 @@ public static class SaveDataExports
|
|||||||
TraceSaveData($"commit commit=0x{commitAddress:X16}");
|
TraceSaveData($"commit commit=0x{commitAddress:X16}");
|
||||||
return ctx.SetReturn(0);
|
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
|
// Maps the PS5 VideoOut pixel format space to the AGC "guest texture format" tags
|
||||||
// the backend keys its guest-image registry on (see VulkanVideoPresenter.
|
// the backend keys its guest-image registry on (see VulkanVideoPresenter.
|
||||||
// GetGuestTextureFormat: format=10 => 56 for 8-bit RGBA variants, format=9 => 9 for 10-bit).
|
// GetGuestTextureFormat: format=10 => 56 for 8-bit RGBA variants, format=9 => 9 for 10-bit).
|
||||||
private static uint MapPixelFormatToGuestTextureFormat(ulong pixelFormat) =>
|
// Unknown formats default to 56 (8-bit RGBA) with a logged warning so games
|
||||||
NormalizePixelFormat(pixelFormat) switch
|
// 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
|
SceVideoOutPixelFormatA8R8G8B8Srgb or
|
||||||
SceVideoOutPixelFormatA8B8G8R8Srgb or
|
SceVideoOutPixelFormatA8B8G8R8Srgb or
|
||||||
@@ -1665,6 +1669,17 @@ public static class VideoOutExports
|
|||||||
_ => 0u,
|
_ => 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(
|
internal static bool TryPackRgba8Pixel(
|
||||||
ulong pixelFormat,
|
ulong pixelFormat,
|
||||||
byte red,
|
byte red,
|
||||||
|
|||||||
@@ -2775,6 +2775,11 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
|
|
||||||
private void LoadDebugUtilsCommands()
|
private void LoadDebugUtilsCommands()
|
||||||
{
|
{
|
||||||
|
if (!_vulkanDebugUtilsEnabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var setObjectName = _vk.GetDeviceProcAddr(_device, "vkSetDebugUtilsObjectNameEXT");
|
var setObjectName = _vk.GetDeviceProcAddr(_device, "vkSetDebugUtilsObjectNameEXT");
|
||||||
var beginLabel = _vk.GetDeviceProcAddr(_device, "vkCmdBeginDebugUtilsLabelEXT");
|
var beginLabel = _vk.GetDeviceProcAddr(_device, "vkCmdBeginDebugUtilsLabelEXT");
|
||||||
var endLabel = _vk.GetDeviceProcAddr(_device, "vkCmdEndDebugUtilsLabelEXT");
|
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} " +
|
$"SharpEmu texture 0x{texture.Address:X16} {texture.Width}x{texture.Height} " +
|
||||||
$"fmt{texture.Format}/{format}";
|
$"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()
|
private void CreateInstance()
|
||||||
{
|
{
|
||||||
var applicationName = (byte*)SilkMarshal.StringToPtr("SharpEmu");
|
var applicationName = (byte*)SilkMarshal.StringToPtr("SharpEmu");
|
||||||
var enableValidation = Environment.GetEnvironmentVariable("SHARPEMU_VK_VALIDATION") == "1";
|
|
||||||
byte* validationLayerName = null;
|
byte* validationLayerName = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -2906,7 +2949,8 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
enabledExtensions[index] = extensions[index];
|
enabledExtensions[index] = extensions[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsInstanceExtensionAvailable(DebugUtilsExtensionName))
|
if (_vulkanDebugUtilsEnabled &&
|
||||||
|
IsInstanceExtensionAvailable(DebugUtilsExtensionName))
|
||||||
{
|
{
|
||||||
debugUtilsExtension = (byte*)SilkMarshal.StringToPtr(DebugUtilsExtensionName);
|
debugUtilsExtension = (byte*)SilkMarshal.StringToPtr(DebugUtilsExtensionName);
|
||||||
enabledExtensions[enabledExtensionCount++] = debugUtilsExtension;
|
enabledExtensions[enabledExtensionCount++] = debugUtilsExtension;
|
||||||
@@ -2921,11 +2965,12 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
instanceCreateFlags |= InstanceCreateFlags.EnumeratePortabilityBitKhr;
|
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");
|
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?).");
|
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,
|
IReadOnlyList<GuestImageResource>? renderTargets = null,
|
||||||
ulong shaderAddress = 0)
|
ulong shaderAddress = 0)
|
||||||
{
|
{
|
||||||
|
if (!_traceGuestImagesEnabled &&
|
||||||
|
!_traceGuestImageAddressFilterEnabled &&
|
||||||
|
GuestImageTraceInterval() is null)
|
||||||
|
{
|
||||||
|
return Array.Empty<GuestImageResource>();
|
||||||
|
}
|
||||||
|
|
||||||
var candidates = new HashSet<GuestImageResource>();
|
var candidates = new HashSet<GuestImageResource>();
|
||||||
foreach (var renderTarget in renderTargets ?? [])
|
foreach (var renderTarget in renderTargets ?? [])
|
||||||
{
|
{
|
||||||
@@ -5140,47 +5192,35 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
bool hasDepthAttachment = false,
|
bool hasDepthAttachment = false,
|
||||||
GuestDepthResource? feedbackDepth = null)
|
GuestDepthResource? feedbackDepth = null)
|
||||||
{
|
{
|
||||||
var isTitleDraw = draw.VertexBuffers.Any(buffer =>
|
var isTitleDraw = IsTitleDraw(draw.VertexBuffers);
|
||||||
buffer.Location == 0 &&
|
var forceFullscreenVertex = _forceFullscreenPipeline ||
|
||||||
buffer.ComponentCount == 4 &&
|
_forceFullscreenVertex ||
|
||||||
buffer.DataFormat == 10 &&
|
isTitleDraw && _forceTitleFullscreenVertex ||
|
||||||
buffer.NumberFormat == 0 &&
|
AnyTargetAddressMatches(
|
||||||
buffer.Stride == 16 &&
|
feedbackTargets,
|
||||||
buffer.OffsetBytes == 12 &&
|
"SHARPEMU_FORCE_FULLSCREEN_VERTEX_TARGETS");
|
||||||
buffer.Length == 67568);
|
var forceRasterState = _forceFullscreenPipeline ||
|
||||||
var forceFullscreenPipeline = Environment.GetEnvironmentVariable(
|
_forceDefaultRasterState ||
|
||||||
"SHARPEMU_FORCE_FULLSCREEN_PIPELINE") == "1";
|
isTitleDraw && _forceTitleDefaultRasterState ||
|
||||||
var forceFullscreenVertex = forceFullscreenPipeline ||
|
AnyTargetAddressMatches(
|
||||||
Environment.GetEnvironmentVariable(
|
feedbackTargets,
|
||||||
"SHARPEMU_FORCE_FULLSCREEN_VERTEX") == "1" ||
|
"SHARPEMU_FORCE_DEFAULT_RASTER_STATE_TARGETS");
|
||||||
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 forceTitleSolidFragment =
|
var forceTitleSolidFragment =
|
||||||
Environment.GetEnvironmentVariable(
|
_forceTitleSolidFragment &&
|
||||||
"SHARPEMU_FORCE_TITLE_SOLID_FRAGMENT") == "1" &&
|
|
||||||
isTitleDraw;
|
isTitleDraw;
|
||||||
var forceSolidFragment = forceTitleSolidFragment || forceFullscreenPipeline ||
|
var forceSolidFragment = forceTitleSolidFragment ||
|
||||||
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_SOLID_FRAGMENT") == "1" ||
|
_forceFullscreenPipeline ||
|
||||||
feedbackTargets?.Any(target => AddressListContains(
|
_forceSolidFragment ||
|
||||||
"SHARPEMU_FORCE_SOLID_FRAGMENT_TARGETS",
|
AnyTargetAddressMatches(
|
||||||
target.Address)) == true;
|
feedbackTargets,
|
||||||
var forceAttributeFragment = uint.TryParse(
|
"SHARPEMU_FORCE_SOLID_FRAGMENT_TARGETS");
|
||||||
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_ATTRIBUTE_FRAGMENT"),
|
var attributeFragmentLocation =
|
||||||
out var attributeFragmentLocation) &&
|
_forceAttributeFragmentLocation.GetValueOrDefault();
|
||||||
feedbackTargets?.Any(target => AddressListContains(
|
var forceAttributeFragment =
|
||||||
"SHARPEMU_FORCE_ATTRIBUTE_FRAGMENT_TARGETS",
|
_forceAttributeFragmentLocation.HasValue &&
|
||||||
target.Address)) == true;
|
AnyTargetAddressMatches(
|
||||||
|
feedbackTargets,
|
||||||
|
"SHARPEMU_FORCE_ATTRIBUTE_FRAGMENT_TARGETS");
|
||||||
var vertexSpirv = forceFullscreenVertex
|
var vertexSpirv = forceFullscreenVertex
|
||||||
? SpirvFixedShaders.CreateFullscreenVertex(0)
|
? SpirvFixedShaders.CreateFullscreenVertex(0)
|
||||||
: draw.VertexSpirv;
|
: draw.VertexSpirv;
|
||||||
@@ -5189,11 +5229,9 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
: forceAttributeFragment
|
: forceAttributeFragment
|
||||||
? SpirvFixedShaders.CreateAttributeFragment(attributeFragmentLocation)
|
? SpirvFixedShaders.CreateAttributeFragment(attributeFragmentLocation)
|
||||||
: draw.PixelSpirv;
|
: draw.PixelSpirv;
|
||||||
var fixedFragmentDumpPath = Environment.GetEnvironmentVariable(
|
if (forceSolidFragment && !string.IsNullOrWhiteSpace(_fixedFragmentDumpPath))
|
||||||
"SHARPEMU_DUMP_FIXED_SOLID_FRAGMENT");
|
|
||||||
if (forceSolidFragment && !string.IsNullOrWhiteSpace(fixedFragmentDumpPath))
|
|
||||||
{
|
{
|
||||||
File.WriteAllBytes(fixedFragmentDumpPath, fragmentSpirv);
|
File.WriteAllBytes(_fixedFragmentDumpPath, fragmentSpirv);
|
||||||
}
|
}
|
||||||
if (draw.RenderState.Blends.Count != renderTargetFormats.Count)
|
if (draw.RenderState.Blends.Count != renderTargetFormats.Count)
|
||||||
{
|
{
|
||||||
@@ -5243,21 +5281,18 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
resources.Raster = GuestRasterState.Default;
|
resources.Raster = GuestRasterState.Default;
|
||||||
resources.Depth = GuestDepthState.Default;
|
resources.Depth = GuestDepthState.Default;
|
||||||
}
|
}
|
||||||
if (isTitleDraw && Environment.GetEnvironmentVariable(
|
if (isTitleDraw && _forceTitleDefaultBlend)
|
||||||
"SHARPEMU_FORCE_TITLE_DEFAULT_BLEND") == "1")
|
|
||||||
{
|
{
|
||||||
resources.Blends = Enumerable.Repeat(
|
resources.Blends = Enumerable.Repeat(
|
||||||
GuestBlendState.Default,
|
GuestBlendState.Default,
|
||||||
renderTargetFormats.Count).ToArray();
|
renderTargetFormats.Count).ToArray();
|
||||||
}
|
}
|
||||||
if (isTitleDraw && Environment.GetEnvironmentVariable(
|
if (isTitleDraw && _forceTitleDefaultViewportScissor)
|
||||||
"SHARPEMU_FORCE_TITLE_DEFAULT_VIEWPORT_SCISSOR") == "1")
|
|
||||||
{
|
{
|
||||||
resources.Scissor = null;
|
resources.Scissor = null;
|
||||||
resources.Viewport = null;
|
resources.Viewport = null;
|
||||||
}
|
}
|
||||||
if (isTitleDraw && Environment.GetEnvironmentVariable(
|
if (isTitleDraw && _forceTitleDisableCull)
|
||||||
"SHARPEMU_FORCE_TITLE_DISABLE_CULL") == "1")
|
|
||||||
{
|
{
|
||||||
resources.Raster = resources.Raster with
|
resources.Raster = resources.Raster with
|
||||||
{
|
{
|
||||||
@@ -5265,13 +5300,11 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
CullBack = false,
|
CullBack = false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (isTitleDraw && Environment.GetEnvironmentVariable(
|
if (isTitleDraw && _forceTitleDisableDepth)
|
||||||
"SHARPEMU_FORCE_TITLE_DISABLE_DEPTH") == "1")
|
|
||||||
{
|
{
|
||||||
resources.Depth = GuestDepthState.Default;
|
resources.Depth = GuestDepthState.Default;
|
||||||
}
|
}
|
||||||
if (isTitleDraw && Environment.GetEnvironmentVariable(
|
if (isTitleDraw && _traceTitleState)
|
||||||
"SHARPEMU_TRACE_TITLE_STATE") == "1")
|
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
$"[LOADER][TRACE] vk.title_state " +
|
$"[LOADER][TRACE] vk.title_state " +
|
||||||
@@ -7874,7 +7907,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
{
|
{
|
||||||
ReadOnlySpan<byte> source = guestBuffer.Data.AsSpan(0, guestBuffer.Length);
|
ReadOnlySpan<byte> source = guestBuffer.Data.AsSpan(0, guestBuffer.Length);
|
||||||
byte[]? forcedVertexColors = null;
|
byte[]? forcedVertexColors = null;
|
||||||
if (Environment.GetEnvironmentVariable("SHARPEMU_FORCE_TITLE_VERTEX_COLOR_WHITE") == "1" &&
|
if (_forceTitleVertexColorWhite &&
|
||||||
guestBuffer.Location == 0 &&
|
guestBuffer.Location == 0 &&
|
||||||
guestBuffer.ComponentCount == 4 &&
|
guestBuffer.ComponentCount == 4 &&
|
||||||
guestBuffer.DataFormat == 10 &&
|
guestBuffer.DataFormat == 10 &&
|
||||||
@@ -9400,37 +9433,67 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetFormats
|
for (var index = 0; index < targetFormats.Length; index++)
|
||||||
.Select((format, index) => format.IsInteger && work.Draw.RenderState.Blends[index].Enable)
|
{
|
||||||
.Any(invalid => invalid))
|
if (targetFormats[index].IsInteger &&
|
||||||
|
work.Draw.RenderState.Blends[index].Enable)
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine(
|
Console.Error.WriteLine(
|
||||||
"[LOADER][WARN] Vulkan skipped MRT draw with blending enabled for an integer attachment.");
|
"[LOADER][WARN] Vulkan skipped MRT draw with blending enabled for an integer attachment.");
|
||||||
ReturnPooledGuestData(work.Draw);
|
ReturnPooledGuestData(work.Draw);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!_supportsIndependentBlend &&
|
if (!_supportsIndependentBlend)
|
||||||
work.Draw.RenderState.Blends.Skip(1).Any(blend => blend != work.Draw.RenderState.Blends[0]))
|
{
|
||||||
|
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(
|
Console.Error.WriteLine(
|
||||||
"[LOADER][WARN] Vulkan skipped MRT draw requiring unsupported independentBlend.");
|
"[LOADER][WARN] Vulkan skipped MRT draw requiring unsupported independentBlend.");
|
||||||
ReturnPooledGuestData(work.Draw);
|
ReturnPooledGuestData(work.Draw);
|
||||||
return;
|
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
|
var hasStorageFeedback = false;
|
||||||
.Where(target => target.Address != 0)
|
foreach (var texture in work.Draw.Textures)
|
||||||
.Select(target => target.Address)
|
{
|
||||||
.ToHashSet();
|
if (!texture.IsStorage || texture.Address == 0)
|
||||||
if (work.Draw.Textures.Any(texture =>
|
{
|
||||||
texture.IsStorage && targetAddresses.Contains(texture.Address)))
|
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(
|
Console.Error.WriteLine(
|
||||||
$"[LOADER][WARN] Vulkan skipped storage render-target feedback loop " +
|
$"[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");
|
"sampled aliases use ordered snapshots");
|
||||||
ReturnPooledGuestData(work.Draw);
|
ReturnPooledGuestData(work.Draw);
|
||||||
return;
|
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;
|
var tracePixelSpirv = false;
|
||||||
if (int.TryParse(
|
if (_tracePixelSpirvBytes > 0 &&
|
||||||
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_PIXEL_SPIRV_BYTES"),
|
_tracePixelSpirvBytes == work.Draw.PixelSpirv.Length)
|
||||||
out var tracePixelSpirvBytes) &&
|
|
||||||
tracePixelSpirvBytes == work.Draw.PixelSpirv.Length)
|
|
||||||
{
|
{
|
||||||
var pixelWriteCount = _pixelSpirvWriteCounts.TryGetValue(
|
var pixelWriteCount = _pixelSpirvWriteCounts.TryGetValue(
|
||||||
tracePixelSpirvBytes,
|
_tracePixelSpirvBytes,
|
||||||
out var previousPixelWriteCount)
|
out var previousPixelWriteCount)
|
||||||
? previousPixelWriteCount + 1
|
? previousPixelWriteCount + 1
|
||||||
: 1;
|
: 1;
|
||||||
_pixelSpirvWriteCounts[tracePixelSpirvBytes] = pixelWriteCount;
|
_pixelSpirvWriteCounts[_tracePixelSpirvBytes] = pixelWriteCount;
|
||||||
var tracePixelSpirvOccurrence = 1;
|
|
||||||
_ = int.TryParse(
|
|
||||||
Environment.GetEnvironmentVariable(
|
|
||||||
"SHARPEMU_TRACE_PIXEL_SPIRV_OCCURRENCE"),
|
|
||||||
out tracePixelSpirvOccurrence);
|
|
||||||
tracePixelSpirv =
|
tracePixelSpirv =
|
||||||
pixelWriteCount == Math.Max(tracePixelSpirvOccurrence, 1);
|
pixelWriteCount == _tracePixelSpirvOccurrence;
|
||||||
}
|
}
|
||||||
var traceTitleDraw =
|
var traceTitleDraw =
|
||||||
!_tracedTitleDraw &&
|
!_tracedTitleDraw &&
|
||||||
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_TITLE_DRAW") == "1" &&
|
_traceTitleDrawEnabled &&
|
||||||
work.Draw.VertexBuffers.Any(buffer =>
|
IsTitleDraw(work.Draw.VertexBuffers);
|
||||||
buffer.Location == 0 &&
|
|
||||||
buffer.ComponentCount == 4 &&
|
|
||||||
buffer.DataFormat == 10 &&
|
|
||||||
buffer.NumberFormat == 0 &&
|
|
||||||
buffer.Stride == 16 &&
|
|
||||||
buffer.OffsetBytes == 12 &&
|
|
||||||
buffer.Length == 67568);
|
|
||||||
_tracedTitleDraw |= traceTitleDraw;
|
_tracedTitleDraw |= traceTitleDraw;
|
||||||
|
|
||||||
foreach (var target in targets)
|
foreach (var target in targets)
|
||||||
{
|
{
|
||||||
var traceAddressWrite =
|
var traceAddressWrite =
|
||||||
ShouldTraceGuestImageWriteForDiagnostics(target.Address);
|
ShouldTraceGuestImageWriteForDiagnostics(target.Address);
|
||||||
var traceSmallWrites = guestWritesMode == "small" &&
|
var traceSmallWrites = _traceGuestWritesMode == "small" &&
|
||||||
target.Width <= 512 && target.Height <= 256;
|
target.Width <= 512 && target.Height <= 256;
|
||||||
var traceLargeWrites =
|
var traceLargeWrites =
|
||||||
(guestWritesMode == "large" || traceLargeWriteOrdinal != 0) &&
|
(_traceGuestWritesMode == "large" ||
|
||||||
|
_traceLargeGuestWriteOrdinal != 0) &&
|
||||||
target.Width >= 2560 && target.Height >= 1440;
|
target.Width >= 2560 && target.Height >= 1440;
|
||||||
if (traceAddressWrite || traceSmallWrites ||
|
if (traceAddressWrite || traceSmallWrites ||
|
||||||
traceLargeWrites || tracePixelSpirv || traceTitleDraw)
|
traceLargeWrites || tracePixelSpirv || traceTitleDraw)
|
||||||
@@ -9872,10 +9905,10 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
_tracedGuestWriteCounts[target.Address] = writeCount;
|
_tracedGuestWriteCounts[target.Address] = writeCount;
|
||||||
var shouldTraceWrite = tracePixelSpirv || traceTitleDraw
|
var shouldTraceWrite = tracePixelSpirv || traceTitleDraw
|
||||||
? true
|
? true
|
||||||
: traceAddressWrite && traceAddressWriteOrdinal > 0
|
: traceAddressWrite && _traceGuestWriteOrdinal > 0
|
||||||
? writeCount == traceAddressWriteOrdinal
|
? writeCount == _traceGuestWriteOrdinal
|
||||||
: traceLargeWriteOrdinal != 0
|
: _traceLargeGuestWriteOrdinal != 0
|
||||||
? writeCount == traceLargeWriteOrdinal
|
? writeCount == _traceLargeGuestWriteOrdinal
|
||||||
: writeCount <=
|
: writeCount <=
|
||||||
(traceLargeWrites ? 2 : traceSmallWrites ? 48 : 3);
|
(traceLargeWrites ? 2 : traceSmallWrites ? 48 : 3);
|
||||||
if (traceAddressWrite || shouldTraceWrite)
|
if (traceAddressWrite || shouldTraceWrite)
|
||||||
@@ -12849,6 +12882,20 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
// Diagnostics toggles are read once: these run per draw / per cached
|
// Diagnostics toggles are read once: these run per draw / per cached
|
||||||
// texture hit, and env lookups plus string parsing are far too
|
// texture hit, and env lookups plus string parsing are far too
|
||||||
// expensive there (and non-trivially so under Rosetta 2).
|
// 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 =
|
private static readonly string? _traceGuestImagesMode =
|
||||||
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES");
|
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES");
|
||||||
private static readonly bool _traceGuestImagesEnabled =
|
private static readonly bool _traceGuestImagesEnabled =
|
||||||
@@ -12912,10 +12959,80 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
!string.IsNullOrWhiteSpace(
|
!string.IsNullOrWhiteSpace(
|
||||||
Environment.GetEnvironmentVariable(
|
Environment.GetEnvironmentVariable(
|
||||||
"SHARPEMU_TRACE_GUEST_IMAGE_ADDRS"));
|
"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<
|
private static readonly System.Collections.Concurrent.ConcurrentDictionary<
|
||||||
string,
|
string,
|
||||||
(bool Wildcard, ulong[] Addresses)> _cachedAddressLists = new();
|
(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() =>
|
private static bool ShouldTraceGuestImageContentsForDiagnostics() =>
|
||||||
_traceGuestImagesEnabled;
|
_traceGuestImagesEnabled;
|
||||||
|
|
||||||
@@ -13113,8 +13230,7 @@ internal static unsafe class VulkanVideoPresenter
|
|||||||
// MoltenVK this starves the render thread and makes the guest fall
|
// MoltenVK this starves the render thread and makes the guest fall
|
||||||
// behind its own flip queue. Vulkan clips a normal fullscreen draw
|
// behind its own flip queue. Vulkan clips a normal fullscreen draw
|
||||||
// efficiently; keep tiling only as an explicit driver diagnostic.
|
// efficiently; keep tiling only as an explicit driver diagnostic.
|
||||||
var maxPixelsPerDraw = Environment.GetEnvironmentVariable(
|
var maxPixelsPerDraw = _chunkedDrawsEnabled
|
||||||
"SHARPEMU_ENABLE_CHUNKED_DRAWS") == "1"
|
|
||||||
? 512u * 512u
|
? 512u * 512u
|
||||||
: uint.MaxValue;
|
: uint.MaxValue;
|
||||||
var rowsPerDraw = Math.Max(
|
var rowsPerDraw = Math.Max(
|
||||||
|
|||||||
@@ -325,9 +325,31 @@ public sealed record Gen5ShaderProgram(
|
|||||||
ulong Address,
|
ulong Address,
|
||||||
IReadOnlyList<Gen5ShaderInstruction> Instructions)
|
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 const int ScalarRegisterCount = 256;
|
||||||
private IReadOnlySet<uint>? _runtimeScalarRegisters;
|
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 =>
|
public IEnumerable<Gen5ImageControl> ImageResources =>
|
||||||
Instructions
|
Instructions
|
||||||
.Select(instruction => instruction.Control)
|
.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.False(memory.TryRead(0x3000, unchanged));
|
||||||
Assert.True(memory.TryWrite(0x3000, [9]));
|
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