mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-26 04:39:17 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4373f8102e | |||
| 22a38f8849 | |||
| 39387fc6e4 | |||
| befd20f415 | |||
| 24b82a7f1c | |||
| a9a4f511f0 | |||
| a44ce34a0a | |||
| 41ec56f813 | |||
| aa32d4bad0 | |||
| faf3a397a8 | |||
| 488b285ecb | |||
| 1a4a2902d4 | |||
| f544146d6d | |||
| dbd74654c4 | |||
| 28485b60e2 | |||
| 6b1abc1a38 | |||
| 7494792249 | |||
| 3585519007 | |||
| 8ee0fe8e44 | |||
| 0755ca15f7 | |||
| 74c70cc2c1 | |||
| 73e0ebf446 |
Binary file not shown.
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 82 KiB |
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
## Before submitting
|
||||
|
||||
Please read our contribution guidelines before opening a pull request:
|
||||
|
||||
➡️ [**CONTRIBUTING.md**](CONTRIBUTING.md)
|
||||
|
||||
By opening this pull request, you confirm that you have read and agree to follow the contribution guidelines.
|
||||
|
||||
## Checklist
|
||||
By submitting this PR, you confirm that:
|
||||
|
||||
- [ ] I have read `CONTRIBUTING.md`.
|
||||
- [ ] I tested my changes.
|
||||
- [ ] I wrote this description myself and did not copy AI-generated explanations.
|
||||
- [ ] I listed the game(s) I tested, if applicable.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Copyright (C) 2026 SharpEmu Emulator Project
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Tells the website repo to rebuild when a release is published, so
|
||||
# sharpemu.app/downloads lists the new build within a minute.
|
||||
name: Notify website
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published, released, edited, deleted]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger sharpemu-site rebuild
|
||||
env:
|
||||
TOKEN: ${{ secrets.SITE_DISPATCH_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "SITE_DISPATCH_TOKEN is not set — skipping website rebuild."
|
||||
exit 0
|
||||
fi
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/sharpemu/sharpemu-site/dispatches \
|
||||
-d '{"event_type":"release-published"}'
|
||||
echo "Website rebuild requested."
|
||||
@@ -7,6 +7,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
tags:
|
||||
- "v*"
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "**/*.png"
|
||||
@@ -53,6 +55,11 @@ jobs:
|
||||
release_tag="v${version}"
|
||||
release_name="SharpEmu v${version}"
|
||||
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ] && [ "${GITHUB_REF_NAME}" != "${release_tag}" ]; then
|
||||
echo "Release tag ${GITHUB_REF_NAME} does not match project version ${release_tag}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
echo "short-sha=${short_sha}"
|
||||
echo "safe-ref=${safe_ref}"
|
||||
@@ -203,7 +210,9 @@ jobs:
|
||||
- init
|
||||
- build
|
||||
- build-posix
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
|
||||
# Versioned releases are immutable and tag-driven. Branch and manual runs
|
||||
# still produce Actions artifacts without modifying a published release.
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -213,7 +222,7 @@ jobs:
|
||||
with:
|
||||
path: release
|
||||
|
||||
- name: Create or update release
|
||||
- name: Create release
|
||||
shell: bash
|
||||
env:
|
||||
GH_REPO: ${{ github.repository }}
|
||||
@@ -231,26 +240,11 @@ jobs:
|
||||
notes="Automated SharpEmu v${VERSION} build for commit ${GITHUB_SHA}."
|
||||
|
||||
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
|
||||
gh release upload "${RELEASE_TAG}" "${assets[@]}" --clobber
|
||||
gh release edit "${RELEASE_TAG}" --title "${RELEASE_NAME}" --notes "${notes}"
|
||||
else
|
||||
gh release create "${RELEASE_TAG}" "${assets[@]}" \
|
||||
--title "${RELEASE_NAME}" \
|
||||
--notes "${notes}" \
|
||||
--target "${GITHUB_SHA}"
|
||||
echo "Release ${RELEASE_TAG} already exists and will not be modified." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
keep=()
|
||||
for asset_path in "${assets[@]}"; do
|
||||
keep+=("$(basename "${asset_path}")")
|
||||
done
|
||||
mapfile -t release_assets < <(gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name' | sort)
|
||||
for asset in "${release_assets[@]}"; do
|
||||
case "${asset}" in
|
||||
sharpemu-${VERSION}-*.zip|sharpemu-${VERSION}-*.tar.gz)
|
||||
if ! printf '%s\n' "${keep[@]}" | grep -Fxq "${asset}"; then
|
||||
gh release delete-asset "${RELEASE_TAG}" "${asset}" --yes
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
gh release create "${RELEASE_TAG}" "${assets[@]}" \
|
||||
--verify-tag \
|
||||
--title "${RELEASE_NAME}" \
|
||||
--notes "${notes}"
|
||||
|
||||
@@ -5,6 +5,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Contributing
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The pull request template is mandatory.
|
||||
>
|
||||
> Pull requests that do not follow the template or leave the required checklist incomplete will be closed without review, even if the proposed code is technically correct or beneficial. Please review these contribution guidelines before submitting a pull request.
|
||||
|
||||
Contributions are always welcome!
|
||||
|
||||
Before opening a pull request, please keep the following in mind:
|
||||
|
||||
@@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<SharpEmuVersion>0.0.2</SharpEmuVersion>
|
||||
<SharpEmuVersion>0.0.2-beta.2</SharpEmuVersion>
|
||||
<Version>$(SharpEmuVersion)</Version>
|
||||
|
||||
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
|
||||
|
||||
@@ -41,6 +41,16 @@ This project is developed purely for research and educational purposes. There ar
|
||||
SharpEmu focuses exclusively on the PlayStation 5.
|
||||
Our goal is **not** to emulate PS4 games, as there is already an excellent emulator dedicated to that platform: **ShadPS4**.
|
||||
|
||||
## Games Tested
|
||||
|
||||
| Demons Souls Remake | Dreaming Sarah |
|
||||
| :-----------------------------------------------------------: | :--------------------------------------------------------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Void Terrarium | Dead Cells |
|
||||
| :------------------------------------------------------------------------: | :------------------------------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
## Status
|
||||
|
||||
The emulator can currently load the `eboot.bin` of real games, execute native CPU instructions, and partially handle kernel-related functionality. However, several critical components are still missing.
|
||||
@@ -88,17 +98,6 @@ chmod +x ./SharpEmu
|
||||
A Vulkan-capable GPU and current graphics driver are required. The macOS
|
||||
release includes the MoltenVK Vulkan implementation.
|
||||
|
||||
## Games Tested
|
||||
|
||||
| Demons Souls Remake | Dreaming Sarah |
|
||||
| :-----------------------------------------------------------: | :--------------------------------------------------------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
| Void Terrarium | Dead Cells |
|
||||
| :------------------------------------------------------------------------: | :------------------------------------------------------------------: |
|
||||
|  |  |
|
||||
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This project does **not** support or condone piracy.
|
||||
> All games used during development and testing are dumped from consoles that we personally own.
|
||||
|
||||
@@ -5,6 +5,7 @@ path = [
|
||||
"REUSE.toml",
|
||||
"nuget.config",
|
||||
"global.json",
|
||||
"**/packages.lock.json",
|
||||
"scripts/ps5_names.txt",
|
||||
"src/SharpEmu.GUI/Languages/**",
|
||||
"_logs/**",
|
||||
|
||||
@@ -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())
|
||||
+121
-54
@@ -69,10 +69,9 @@ internal static partial class Program
|
||||
}
|
||||
|
||||
args = NormalizeInternalArguments(args, out var isMitigatedChild);
|
||||
if (args.Length == 0 && !isMitigatedChild)
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
// No arguments: open the desktop frontend. Any argument selects
|
||||
// the classic CLI behavior below.
|
||||
return GuiLauncher.Run();
|
||||
}
|
||||
|
||||
@@ -228,7 +227,17 @@ internal static partial class Program
|
||||
return childExitCode;
|
||||
}
|
||||
|
||||
if (!TryParseArguments(args, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath))
|
||||
if (!TryExtractHostSurfaceArgument(args, out var emulatorArgs, out var hostSurface, out var hostSurfaceError))
|
||||
{
|
||||
Console.Error.WriteLine($"[LOADER][ERROR] {hostSurfaceError}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
HostSessionControl.SetEmbeddedHostSurface(
|
||||
hostSurface?.WindowHandle ?? 0,
|
||||
hostSurface?.DisplayHandle ?? 0);
|
||||
|
||||
if (!TryParseArguments(emulatorArgs, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath))
|
||||
{
|
||||
PrintUsage();
|
||||
return 1;
|
||||
@@ -255,66 +264,122 @@ internal static partial class Program
|
||||
|
||||
Console.Error.WriteLine("[DEBUG] Creating runtime...");
|
||||
|
||||
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
|
||||
|
||||
OrbisGen2Result result;
|
||||
ConsoleCancelEventHandler? cancelHandler = null;
|
||||
try
|
||||
{
|
||||
cancelHandler = (_, eventArgs) =>
|
||||
if (hostSurface is not null && !VulkanVideoHost.TryAttachSurface(hostSurface))
|
||||
{
|
||||
eventArgs.Cancel = true;
|
||||
VideoOutExports.NotifyHostInterrupt();
|
||||
};
|
||||
Console.CancelKeyPress += cancelHandler;
|
||||
Console.Error.WriteLine("[LOADER][ERROR] The requested GUI host surface is already active.");
|
||||
return 3;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"[DEBUG] Running: {ebootPath}");
|
||||
result = runtime.Run(ebootPath);
|
||||
Console.Error.WriteLine($"[DEBUG] Result: {result}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DEBUG] Exception: {ex}");
|
||||
Log.Error("SharpEmu failed to run.", ex);
|
||||
return 3;
|
||||
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
|
||||
|
||||
OrbisGen2Result result;
|
||||
ConsoleCancelEventHandler? cancelHandler = null;
|
||||
try
|
||||
{
|
||||
cancelHandler = (_, eventArgs) =>
|
||||
{
|
||||
eventArgs.Cancel = true;
|
||||
VideoOutExports.NotifyHostInterrupt();
|
||||
};
|
||||
Console.CancelKeyPress += cancelHandler;
|
||||
|
||||
Console.Error.WriteLine($"[DEBUG] Running: {ebootPath}");
|
||||
result = runtime.Run(ebootPath);
|
||||
Console.Error.WriteLine($"[DEBUG] Result: {result}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[DEBUG] Exception: {ex}");
|
||||
Log.Error("SharpEmu failed to run.", ex);
|
||||
return 3;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (cancelHandler is not null)
|
||||
{
|
||||
Console.CancelKeyPress -= cancelHandler;
|
||||
}
|
||||
}
|
||||
|
||||
Log.Info($"SharpEmu execution completed. Result={result} (0x{(int)result:X8})");
|
||||
if (!string.IsNullOrWhiteSpace(runtime.LastSessionSummary))
|
||||
{
|
||||
Log.Info(runtime.LastSessionSummary);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(runtime.LastBasicBlockTrace))
|
||||
{
|
||||
Log.Info("BB trace:");
|
||||
Log.Info(runtime.LastBasicBlockTrace);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(runtime.LastMilestoneLog))
|
||||
{
|
||||
Log.Info(runtime.LastMilestoneLog);
|
||||
}
|
||||
|
||||
if (result != OrbisGen2Result.ORBIS_GEN2_OK && !string.IsNullOrWhiteSpace(runtime.LastExecutionDiagnostics))
|
||||
{
|
||||
Log.Warn(runtime.LastExecutionDiagnostics);
|
||||
}
|
||||
|
||||
if (runtimeOptions.ImportTraceLimit > 0 && !string.IsNullOrWhiteSpace(runtime.LastExecutionTrace))
|
||||
{
|
||||
Log.Info("Import trace:");
|
||||
Log.Info(runtime.LastExecutionTrace);
|
||||
}
|
||||
|
||||
return result == OrbisGen2Result.ORBIS_GEN2_OK ? 0 : 4;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (cancelHandler is not null)
|
||||
HostSessionControl.SetEmbeddedHostSurface(0);
|
||||
if (hostSurface is not null)
|
||||
{
|
||||
Console.CancelKeyPress -= cancelHandler;
|
||||
VulkanVideoHost.RequestClose();
|
||||
VulkanVideoHost.DetachSurface(hostSurface);
|
||||
hostSurface.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryExtractHostSurfaceArgument(
|
||||
IReadOnlyList<string> args,
|
||||
out string[] emulatorArgs,
|
||||
out VulkanHostSurface? hostSurface,
|
||||
out string? error)
|
||||
{
|
||||
const string hostSurfacePrefix = "--host-surface=";
|
||||
var remaining = new List<string>(args.Count);
|
||||
hostSurface = null;
|
||||
error = null;
|
||||
foreach (var argument in args)
|
||||
{
|
||||
if (!argument.StartsWith(hostSurfacePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
remaining.Add(argument);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hostSurface is not null)
|
||||
{
|
||||
emulatorArgs = [];
|
||||
error = "more than one GUI host surface was specified";
|
||||
return false;
|
||||
}
|
||||
|
||||
var descriptor = argument[hostSurfacePrefix.Length..];
|
||||
if (!VulkanHostSurface.TryCreateChildProcessSurface(descriptor, out hostSurface, out error))
|
||||
{
|
||||
emulatorArgs = [];
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Log.Info($"SharpEmu execution completed. Result={result} (0x{(int)result:X8})");
|
||||
if (!string.IsNullOrWhiteSpace(runtime.LastSessionSummary))
|
||||
{
|
||||
Log.Info(runtime.LastSessionSummary);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(runtime.LastBasicBlockTrace))
|
||||
{
|
||||
Log.Info("BB trace:");
|
||||
Log.Info(runtime.LastBasicBlockTrace);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(runtime.LastMilestoneLog))
|
||||
{
|
||||
Log.Info(runtime.LastMilestoneLog);
|
||||
}
|
||||
|
||||
if (result != OrbisGen2Result.ORBIS_GEN2_OK && !string.IsNullOrWhiteSpace(runtime.LastExecutionDiagnostics))
|
||||
{
|
||||
Log.Warn(runtime.LastExecutionDiagnostics);
|
||||
}
|
||||
|
||||
if (runtimeOptions.ImportTraceLimit > 0 && !string.IsNullOrWhiteSpace(runtime.LastExecutionTrace))
|
||||
{
|
||||
Log.Info("Import trace:");
|
||||
Log.Info(runtime.LastExecutionTrace);
|
||||
}
|
||||
|
||||
return result == OrbisGen2Result.ORBIS_GEN2_OK ? 0 : 4;
|
||||
emulatorArgs = remaining.ToArray();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void EnsureCliConsole()
|
||||
@@ -411,7 +476,9 @@ internal static partial class Program
|
||||
return handle != 0 && handle != -1;
|
||||
}
|
||||
|
||||
private static string[] NormalizeInternalArguments(string[] args, out bool isMitigatedChild)
|
||||
private static string[] NormalizeInternalArguments(
|
||||
string[] args,
|
||||
out bool isMitigatedChild)
|
||||
{
|
||||
isMitigatedChild = false;
|
||||
var trustedMitigatedChild = string.Equals(
|
||||
|
||||
@@ -54,6 +54,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == ''">
|
||||
<ApplicationIcon>..\..\assets\images\SharpEmu.ico</ApplicationIcon>
|
||||
<Win32Icon>..\..\assets\images\SharpEmu.ico</Win32Icon>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="SharpEmu" />
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Required by Avalonia NativeControlHost on Windows 10 and 11. -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -0,0 +1,588 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.3, )",
|
||||
"resolved": "10.0.3",
|
||||
"contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA=="
|
||||
},
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.25547.20250602",
|
||||
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||
},
|
||||
"Avalonia.BuildServices": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.2",
|
||||
"contentHash": "qHDToxto1e3hci5YqbG9n0Ty8mlp3zBUN5wT66wKqaDVzXyQ0do3EnRILd4Ke9jpvsktaPpgE0YjEk7hornryQ=="
|
||||
},
|
||||
"Avalonia.FreeDesktop": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "aUwv8BNruRUOaUfMu4U3uibIUS60/rSHgGOhd8zBkLkpxY3JFJvgRbeq5ZzHIyKXCuKi18PO00YHAgCarp3wdw==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18",
|
||||
"Tmds.DBus.Protocol": "0.21.3"
|
||||
}
|
||||
},
|
||||
"Avalonia.Native": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"Avalonia.Remote.Protocol": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "vw+6ZfgTuu72dA9aVWn6u56t2nrBd5MoMU0wo/qI9XJAl/c0oYYphIvwLvJP1JorubQY4UE3d0ac8ULBhrGBiA=="
|
||||
},
|
||||
"Avalonia.Skia": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "/B4aXmNRNjG8I5U/a1xJI+bIi0XO6DDzS3mBrIKlVnJRY2CyZiUeESRQXLnIU77Z9TvqkUROs+D47s085YjFtA==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18",
|
||||
"HarfBuzzSharp": "8.3.1.1",
|
||||
"HarfBuzzSharp.NativeAssets.Linux": "8.3.1.1",
|
||||
"HarfBuzzSharp.NativeAssets.WebAssembly": "8.3.1.1",
|
||||
"SkiaSharp": "2.88.9",
|
||||
"SkiaSharp.NativeAssets.Linux": "2.88.9",
|
||||
"SkiaSharp.NativeAssets.WebAssembly": "2.88.9"
|
||||
}
|
||||
},
|
||||
"Avalonia.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "eioUHkM2PeLPETd1aEks3rvb9plbba6buIrNdrqCpwE/qgHKUjvRNBd5mUQfAbGgTLiAes524gB8uUMDhrsJVQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18",
|
||||
"Avalonia.Angle.Windows.Natives": "2.1.25547.20250602"
|
||||
}
|
||||
},
|
||||
"Avalonia.X11": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "m4Ki/G5Dovnq+6QzfS0iGbK8V77Q6oTjToMLOB0CxPCCrl3Oxywh6kIjuGJDPaN6kopMmjxlNShyQf+vPYL+JA==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18",
|
||||
"Avalonia.FreeDesktop": "11.3.18",
|
||||
"Avalonia.Skia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "tLZN66oe/uiRPTZfrCU4i8ScVGwqHNh5MHrXj0yVf4l7Mz0FhTGnQ71RGySROTmdognAs0JtluHkL41pIabWuQ==",
|
||||
"dependencies": {
|
||||
"HarfBuzzSharp.NativeAssets.Win32": "8.3.1.1",
|
||||
"HarfBuzzSharp.NativeAssets.macOS": "8.3.1.1"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.WebAssembly": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "loJweK2u/mH/3C2zBa0ggJlITIszOkK64HLAZB7FUT670dTg965whLFYHDQo69NmC4+d9UN0icLC9VHidXaVCA=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||
},
|
||||
"MicroCom.Runtime": {
|
||||
"type": "Transitive",
|
||||
"resolved": "0.11.0",
|
||||
"contentHash": "MEnrZ3UIiH40hjzMDsxrTyi8dtqB5ziv3iBeeU4bXsL/7NLSal9F1lZKpK+tfBRnUoDSdtcW3KufE4yhATOMCA=="
|
||||
},
|
||||
"Microsoft.DotNet.PlatformAbstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.1.6",
|
||||
"contentHash": "jek4XYaQ/PGUwDKKhwR8K47Uh1189PFzMeLqO83mXrXQVIpARZCcfuDedH50YDTepBkfijCZN5U/vZi++erxtg=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyModel": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.9",
|
||||
"contentHash": "fNGvKct2De8ghm0Bpfq0iWthtzIWabgOTi+gJhNOPhNJIowXNEUE2eZNW/zNCzrHVA3PXg2yZ+3cWZndC2IqYA=="
|
||||
},
|
||||
"Silk.NET.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "D7AT/nnwlB+4RZ84XY8QNGBZMJI5z9l4CSSETIJ1wCfRJzRt/341y3MRZ4HbnFz4r/IGaWOEZr86iE+0/65yyQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.DotNet.PlatformAbstractions": "3.1.6",
|
||||
"Microsoft.Extensions.DependencyModel": "9.0.9"
|
||||
}
|
||||
},
|
||||
"Silk.NET.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "UIs4sH57xlPUNHQ/1bt9rymPWlGy8IMDCNv86h0iM4TOA1CkIx0XM/n/tA4AReh1zQkNrvkxPEdZ3Blvy1dyXg==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Core": "2.23.0",
|
||||
"Ultz.Native.GLFW": "3.4.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Input.Common": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "QbJVV7kFBHEByayXCYdJtXXI9Sp4a+QAf0IdGV6uCWkFYcEmqBYW3aaNGvFOdSwTBDbHL5T/OtOCrGh4qYhk7A==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Windowing.Common": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Input.Glfw": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "KGHYqsv/IQRJtD6dloYh2tN4CkaM40vxM2kj0cGKBoCQiBDYHHhJiyDTyMPx0W7Fz5IgnhnG42ELmIAa0DH69A==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Input.Common": "2.23.0",
|
||||
"Silk.NET.Windowing.Glfw": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Maths": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "r8PdIVzME8EH0qAgbmRPO87I4GfgR2j8TofT7EMuRJDf1QluoQwnVypDoFJjQ2ZBSRsGYk5unYxxogI05Ogsmw=="
|
||||
},
|
||||
"Silk.NET.Windowing.Common": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "ThStSinmY9KQI8DGiF5XEhkLJVnBcgRTBTzL9ijg1wMZAYuckz7ykrNw04fjRm2Gryh6tCNGbvz2XaY0efeFzg==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Core": "2.23.0",
|
||||
"Silk.NET.Maths": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Windowing.Glfw": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "aYBudKmENmvLRn9p15HbdvlQTnnXskcDfTfbYwSb/4fr263rGLwYuDw/txUEc2jihHJiWCp5+75Y7z5wTJWl7g==",
|
||||
"dependencies": {
|
||||
"Silk.NET.GLFW": "2.23.0",
|
||||
"Silk.NET.Windowing.Common": "2.23.0"
|
||||
}
|
||||
},
|
||||
"SkiaSharp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "3MD5VHjXXieSHCleRLuaTXmL2pD0mB7CcOB1x2kA1I4bhptf4e3R27iM93264ZYuAq6mkUyX5XbcxnZvMJYc1Q==",
|
||||
"dependencies": {
|
||||
"SkiaSharp.NativeAssets.Win32": "2.88.9",
|
||||
"SkiaSharp.NativeAssets.macOS": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.WebAssembly": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "kt06RccBHSnAs2wDYdBSfsjIDbY3EpsOVqnlDgKdgvyuRA8ZFDaHRdWNx1VHjGgYzmnFCGiTJBnXFl5BqGwGnA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
},
|
||||
"sharpemu.core": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Iced": "[1.21.0, )",
|
||||
"SharpEmu.HLE": "[0.0.2-beta.2, )",
|
||||
"SharpEmu.Libs": "[0.0.2-beta.2, )",
|
||||
"SharpEmu.Logging": "[0.0.2-beta.2, )"
|
||||
}
|
||||
},
|
||||
"sharpemu.gui": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Avalonia": "[11.3.18, )",
|
||||
"Avalonia.Desktop": "[11.3.18, )",
|
||||
"Avalonia.Fonts.Inter": "[11.3.18, )",
|
||||
"Avalonia.Themes.Fluent": "[11.3.18, )",
|
||||
"SharpEmu.Core": "[0.0.2-beta.2, )",
|
||||
"SharpEmu.Libs": "[0.0.2-beta.2, )",
|
||||
"SharpEmu.Logging": "[0.0.2-beta.2, )",
|
||||
"Tmds.DBus.Protocol": "[0.21.3, )"
|
||||
}
|
||||
},
|
||||
"sharpemu.hle": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SharpEmu.Logging": "[0.0.2-beta.2, )"
|
||||
}
|
||||
},
|
||||
"sharpemu.libs": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SharpEmu.HLE": "[0.0.2-beta.2, )",
|
||||
"SharpEmu.ShaderCompiler": "[0.0.2-beta.2, )",
|
||||
"SharpEmu.ShaderCompiler.Vulkan": "[0.0.2-beta.2, )",
|
||||
"Silk.NET.Input": "[2.23.0, )",
|
||||
"Silk.NET.Vulkan": "[2.23.0, )",
|
||||
"Silk.NET.Vulkan.Extensions.EXT": "[2.23.0, )",
|
||||
"Silk.NET.Vulkan.Extensions.KHR": "[2.23.0, )",
|
||||
"Silk.NET.Windowing": "[2.23.0, )"
|
||||
}
|
||||
},
|
||||
"sharpemu.logging": {
|
||||
"type": "Project"
|
||||
},
|
||||
"sharpemu.shadercompiler": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SharpEmu.HLE": "[0.0.2-beta.2, )"
|
||||
}
|
||||
},
|
||||
"sharpemu.shadercompiler.vulkan": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SharpEmu.ShaderCompiler": "[0.0.2-beta.2, )"
|
||||
}
|
||||
},
|
||||
"Avalonia": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[11.3.18, )",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "2C4UxhWUObWGgYKWic1x5BMMWGJP6SElb91WeOxs+X/iR26rtkqpxFFwwo50FXS9AyYnHfk8QKXDEfe7oT/kZA==",
|
||||
"dependencies": {
|
||||
"Avalonia.BuildServices": "11.3.2",
|
||||
"Avalonia.Remote.Protocol": "11.3.18",
|
||||
"MicroCom.Runtime": "0.11.0"
|
||||
}
|
||||
},
|
||||
"Avalonia.Desktop": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[11.3.18, )",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "bilMPa5vYiis6fbNovb6esKytBnOCEGojBa1XFegLCRHCP6g6PvZwS0XF/YOAGkENRlHG8dI7lohOpQ9bIkq1g==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18",
|
||||
"Avalonia.Native": "11.3.18",
|
||||
"Avalonia.Skia": "11.3.18",
|
||||
"Avalonia.Win32": "11.3.18",
|
||||
"Avalonia.X11": "11.3.18"
|
||||
}
|
||||
},
|
||||
"Avalonia.Fonts.Inter": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[11.3.18, )",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "27u6hB3Y2Ue586yjfeVakberY73VNQXtuKwe/P927XG1QPlhsfmOyifLHDDpSHG85Zl1x/Xv9IZ3+tk9FnjcZQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"Avalonia.Themes.Fluent": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[11.3.18, )",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "+Q/TJoynD0zNuu5w2gD+xcTl7GNKJFxlPYAndRLs/mTDrNbbsvv/271WyIysbMPsXSjCyBDp7RCZzQkpD6x5Bg==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"Iced": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.21.0, )",
|
||||
"resolved": "1.21.0",
|
||||
"contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg=="
|
||||
},
|
||||
"Silk.NET.Input": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "Xzl+tVwAp2eEd8blmGQjmJrsZPnp3PWG0KJjiAQHaY2Zr/ELVWeAROKXmZdCAvexzmte2JVGEy/dxnMycbxlpg==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Input.Common": "2.23.0",
|
||||
"Silk.NET.Input.Glfw": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Vulkan": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "3/irtlSWXZ3eTi8N6nelI6L34NTB8ZJHpqVMNzZx2aX7Ek9YEQ34NoQW8/Tljrtmkg8KRhHW8hKTEzZaKV8PgA==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Core": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Vulkan.Extensions.EXT": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "+Oth189ksRiL6HvGCwIdnsYHawqrbO8y49u1H61z3wsfcHhQZeVDYe/wF5LD7fk3NcdgDvwFD3mLm1QWhdZySw==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Core": "2.23.0",
|
||||
"Silk.NET.Vulkan": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Vulkan.Extensions.KHR": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "uRaf4j+SmH3DumjSSSUbFg33BnsGZUyXGj93O9NgGKZSJN3OTmNmQDxRew+/KiVLcgH6qzbto8aNGZ++j9GFWg==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Core": "2.23.0",
|
||||
"Silk.NET.Vulkan": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Windowing": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "OPNPmt/lRyUKVYrFLQXVxyATqD3MKLc1iY1oKx1/2GppgmZxVZPwN12tekrQ4C7408kgB1L5JD1Wnirqqeb2kg==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Windowing.Common": "2.23.0",
|
||||
"Silk.NET.Windowing.Glfw": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Tmds.DBus.Protocol": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[0.21.3, )",
|
||||
"resolved": "0.21.3",
|
||||
"contentHash": "hDwB8WsQoyALQKqIbwzS68UKdlnafDm4T/DkO/JrA/YIneP/rKv96SxYPVXeh3FP4i/SXfShrYftKLtciJAIlw=="
|
||||
}
|
||||
},
|
||||
"net10.0/linux-x64": {
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.25547.20250602",
|
||||
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||
},
|
||||
"Avalonia.Native": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
}
|
||||
},
|
||||
"net10.0/osx-arm64": {
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.25547.20250602",
|
||||
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||
},
|
||||
"Avalonia.Native": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
}
|
||||
},
|
||||
"net10.0/osx-x64": {
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.25547.20250602",
|
||||
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||
},
|
||||
"Avalonia.Native": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
}
|
||||
},
|
||||
"net10.0/win-x64": {
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.25547.20250602",
|
||||
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||
},
|
||||
"Avalonia.Native": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -693,6 +693,13 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
return _virtualMemory.TryWrite(address, buffer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the disposed native backend left its session state alive
|
||||
/// because guest workers were still executing guest code. The guest
|
||||
/// address space must then stay mapped as well.
|
||||
/// </summary>
|
||||
internal bool NativeSessionLeaked { get; private set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_nativeCpuBackend is IDisposable disposableBackend)
|
||||
@@ -700,6 +707,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
disposableBackend.Dispose();
|
||||
}
|
||||
|
||||
NativeSessionLeaked = _nativeCpuBackend is DirectExecutionBackend { GuestSessionLeaked: true };
|
||||
_nativeCpuBackend = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -718,7 +718,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
private string? _guestThreadYieldReason;
|
||||
|
||||
private bool _forcedGuestExit;
|
||||
private volatile bool _forcedGuestExit;
|
||||
|
||||
private ulong _lastAvTraceRip;
|
||||
|
||||
@@ -917,7 +917,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
private bool ActiveForcedGuestExit
|
||||
{
|
||||
get => HasActiveExecutionThread ? _activeForcedGuestExit : _forcedGuestExit;
|
||||
// Host shutdown is requested from a UI or VideoOut thread. Native guest
|
||||
// workers have their own thread-local execution state, so they must also
|
||||
// observe the backend-wide shutdown flag.
|
||||
get => _forcedGuestExit || (HasActiveExecutionThread && _activeForcedGuestExit);
|
||||
set
|
||||
{
|
||||
if (HasActiveExecutionThread)
|
||||
@@ -3409,6 +3412,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
{
|
||||
returnValue = 0;
|
||||
error = null;
|
||||
if (_forcedGuestExit)
|
||||
{
|
||||
error = "guest execution is shutting down";
|
||||
return false;
|
||||
}
|
||||
if (entryPoint < 65536)
|
||||
{
|
||||
error = $"invalid guest callback entry=0x{entryPoint:X16}";
|
||||
@@ -3655,6 +3663,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
out string? error)
|
||||
{
|
||||
error = null;
|
||||
if (_forcedGuestExit)
|
||||
{
|
||||
error = "guest execution is shutting down";
|
||||
return false;
|
||||
}
|
||||
if (continuation.Rip < 65536 || continuation.Rsp == 0)
|
||||
{
|
||||
error = $"invalid guest continuation rip=0x{continuation.Rip:X16} rsp=0x{continuation.Rsp:X16}";
|
||||
@@ -4685,6 +4698,21 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
|
||||
private void RunGuestThread(GuestThreadState thread, string reason)
|
||||
{
|
||||
if (_forcedGuestExit)
|
||||
{
|
||||
// Host shutdown: never enter guest code again. Teardown is about to
|
||||
// free trampolines and the guest address space, and it only waits
|
||||
// for executors that are already inside a slice.
|
||||
lock (_guestThreadGate)
|
||||
{
|
||||
thread.State = GuestThreadRunState.Faulted;
|
||||
thread.BlockReason = "host shutdown";
|
||||
thread.HostThread = null;
|
||||
thread.ExecutorActive = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_guestThreadGate)
|
||||
{
|
||||
if (!thread.ExecutorActive)
|
||||
@@ -5127,64 +5155,51 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
BindTlsBase(context);
|
||||
byte* ptr2 = (byte*)ptr;
|
||||
ulong hostRspSlot = (ulong)hostRspStorage;
|
||||
int offset = 0;
|
||||
var emitter = new NativeCodeEmitter(ptr2);
|
||||
|
||||
void Emit(byte value) => ptr2[offset++] = value;
|
||||
void EmitU64(ulong value)
|
||||
{
|
||||
*(ulong*)(ptr2 + offset) = value;
|
||||
offset += sizeof(ulong);
|
||||
}
|
||||
void EmitMovR64Imm(byte rex, byte opcode, ulong value)
|
||||
{
|
||||
Emit(rex);
|
||||
Emit(opcode);
|
||||
EmitU64(value);
|
||||
}
|
||||
|
||||
Emit(0x53); // push rbx
|
||||
Emit(0x55); // push rbp
|
||||
Emit(0x57); // push rdi
|
||||
Emit(0x56); // push rsi
|
||||
Emit(0x41); Emit(0x54); // push r12
|
||||
Emit(0x41); Emit(0x55); // push r13
|
||||
Emit(0x41); Emit(0x56); // push r14
|
||||
Emit(0x41); Emit(0x57); // push r15
|
||||
EmitHostNonvolatileXmmSave(ptr2, ref offset);
|
||||
emitter.Emit(0x53); // push rbx
|
||||
emitter.Emit(0x55); // push rbp
|
||||
emitter.Emit(0x57); // push rdi
|
||||
emitter.Emit(0x56); // push rsi
|
||||
emitter.Emit(0x41); emitter.Emit(0x54); // push r12
|
||||
emitter.Emit(0x41); emitter.Emit(0x55); // push r13
|
||||
emitter.Emit(0x41); emitter.Emit(0x56); // push r14
|
||||
emitter.Emit(0x41); emitter.Emit(0x57); // push r15
|
||||
EmitHostNonvolatileXmmSave(ptr2, ref emitter.Offset);
|
||||
// Restore the fiber's floating-point control environment before
|
||||
// abandoning the host stack. This path is used when a blocked guest
|
||||
// continuation migrates to another managed worker.
|
||||
Emit(0x48); Emit(0x83); Emit(0xEC); Emit(0x08); // sub rsp,8
|
||||
Emit(0xC7); Emit(0x04); Emit(0x24); // mov dword [rsp],imm32
|
||||
*(uint*)(ptr2 + offset) = context.Mxcsr; offset += sizeof(uint);
|
||||
Emit(0x0F); Emit(0xAE); Emit(0x14); Emit(0x24); // ldmxcsr [rsp]
|
||||
Emit(0x66); Emit(0xC7); Emit(0x04); Emit(0x24); // mov word [rsp],imm16
|
||||
*(ushort*)(ptr2 + offset) = context.FpuControlWord; offset += sizeof(ushort);
|
||||
Emit(0xD9); Emit(0x2C); Emit(0x24); // fldcw [rsp]
|
||||
Emit(0x48); Emit(0x83); Emit(0xC4); Emit(0x08); // add rsp,8
|
||||
EmitMovR64Imm(0x49, 0xBA, hostRspSlot); // mov r10, hostRspSlot
|
||||
Emit(0x49); Emit(0x89); Emit(0x22); // mov [r10], rsp
|
||||
EmitMovR64Imm(0x48, 0xB8, context[CpuRegister.Rsp]); // mov rax, guest rsp
|
||||
Emit(0x48); Emit(0x89); Emit(0xC4); // mov rsp, rax
|
||||
Emit(0x48); Emit(0x83); Emit(0xEC); Emit(0x08); // reserve transfer slot
|
||||
EmitMovR64Imm(0x48, 0xB8, entryPoint); // mov rax, entryPoint
|
||||
Emit(0x48); Emit(0x89); Emit(0x04); Emit(0x24); // mov [rsp],rax
|
||||
EmitMovR64Imm(0x48, 0xBB, context[CpuRegister.Rbx]); // mov rbx, imm64
|
||||
EmitMovR64Imm(0x48, 0xBD, context[CpuRegister.Rbp]); // mov rbp, imm64
|
||||
EmitMovR64Imm(0x48, 0xBF, context[CpuRegister.Rdi]); // mov rdi, imm64
|
||||
EmitMovR64Imm(0x48, 0xBE, context[CpuRegister.Rsi]); // mov rsi, imm64
|
||||
EmitMovR64Imm(0x48, 0xBA, context[CpuRegister.Rdx]); // mov rdx, imm64
|
||||
EmitMovR64Imm(0x48, 0xB9, context[CpuRegister.Rcx]); // mov rcx, imm64
|
||||
EmitMovR64Imm(0x49, 0xB8, context[CpuRegister.R8]); // mov r8, imm64
|
||||
EmitMovR64Imm(0x49, 0xB9, context[CpuRegister.R9]); // mov r9, imm64
|
||||
EmitMovR64Imm(0x49, 0xBA, context[CpuRegister.R10]); // mov r10, imm64
|
||||
EmitMovR64Imm(0x49, 0xBC, context[CpuRegister.R12]); // mov r12, imm64
|
||||
EmitMovR64Imm(0x49, 0xBD, context[CpuRegister.R13]); // mov r13, imm64
|
||||
EmitMovR64Imm(0x49, 0xBE, context[CpuRegister.R14]); // mov r14, imm64
|
||||
EmitMovR64Imm(0x49, 0xBF, context[CpuRegister.R15]); // mov r15, imm64
|
||||
EmitMovR64Imm(0x49, 0xBB, context[CpuRegister.R11]); // mov r11, imm64
|
||||
EmitMovR64Imm(0x48, 0xB8, context[CpuRegister.Rax]); // mov rax, imm64
|
||||
Emit(0xC3); // ret through the synthetic transfer slot
|
||||
emitter.Emit(0x48); emitter.Emit(0x83); emitter.Emit(0xEC); emitter.Emit(0x08); // sub rsp,8
|
||||
emitter.Emit(0xC7); emitter.Emit(0x04); emitter.Emit(0x24); // mov dword [rsp],imm32
|
||||
emitter.Emit(context.Mxcsr);
|
||||
emitter.Emit(0x0F); emitter.Emit(0xAE); emitter.Emit(0x14); emitter.Emit(0x24); // ldmxcsr [rsp]
|
||||
emitter.Emit(0x66); emitter.Emit(0xC7); emitter.Emit(0x04); emitter.Emit(0x24); // mov word [rsp],imm16
|
||||
emitter.Emit(context.FpuControlWord);
|
||||
emitter.Emit(0xD9); emitter.Emit(0x2C); emitter.Emit(0x24); // fldcw [rsp]
|
||||
emitter.Emit(0x48); emitter.Emit(0x83); emitter.Emit(0xC4); emitter.Emit(0x08); // add rsp,8
|
||||
emitter.EmitMovR64Immediate(0x49, 0xBA, hostRspSlot); // mov r10, hostRspSlot
|
||||
emitter.Emit(0x49); emitter.Emit(0x89); emitter.Emit(0x22); // mov [r10], rsp
|
||||
emitter.EmitMovR64Immediate(0x48, 0xB8, context[CpuRegister.Rsp]); // mov rax, guest rsp
|
||||
emitter.Emit(0x48); emitter.Emit(0x89); emitter.Emit(0xC4); // mov rsp, rax
|
||||
emitter.Emit(0x48); emitter.Emit(0x83); emitter.Emit(0xEC); emitter.Emit(0x08); // reserve transfer slot
|
||||
emitter.EmitMovR64Immediate(0x48, 0xB8, entryPoint); // mov rax, entryPoint
|
||||
emitter.Emit(0x48); emitter.Emit(0x89); emitter.Emit(0x04); emitter.Emit(0x24); // mov [rsp],rax
|
||||
emitter.EmitMovR64Immediate(0x48, 0xBB, context[CpuRegister.Rbx]); // mov rbx, imm64
|
||||
emitter.EmitMovR64Immediate(0x48, 0xBD, context[CpuRegister.Rbp]); // mov rbp, imm64
|
||||
emitter.EmitMovR64Immediate(0x48, 0xBF, context[CpuRegister.Rdi]); // mov rdi, imm64
|
||||
emitter.EmitMovR64Immediate(0x48, 0xBE, context[CpuRegister.Rsi]); // mov rsi, imm64
|
||||
emitter.EmitMovR64Immediate(0x48, 0xBA, context[CpuRegister.Rdx]); // mov rdx, imm64
|
||||
emitter.EmitMovR64Immediate(0x48, 0xB9, context[CpuRegister.Rcx]); // mov rcx, imm64
|
||||
emitter.EmitMovR64Immediate(0x49, 0xB8, context[CpuRegister.R8]); // mov r8, imm64
|
||||
emitter.EmitMovR64Immediate(0x49, 0xB9, context[CpuRegister.R9]); // mov r9, imm64
|
||||
emitter.EmitMovR64Immediate(0x49, 0xBA, context[CpuRegister.R10]); // mov r10, imm64
|
||||
emitter.EmitMovR64Immediate(0x49, 0xBC, context[CpuRegister.R12]); // mov r12, imm64
|
||||
emitter.EmitMovR64Immediate(0x49, 0xBD, context[CpuRegister.R13]); // mov r13, imm64
|
||||
emitter.EmitMovR64Immediate(0x49, 0xBE, context[CpuRegister.R14]); // mov r14, imm64
|
||||
emitter.EmitMovR64Immediate(0x49, 0xBF, context[CpuRegister.R15]); // mov r15, imm64
|
||||
emitter.EmitMovR64Immediate(0x49, 0xBB, context[CpuRegister.R11]); // mov r11, imm64
|
||||
emitter.EmitMovR64Immediate(0x48, 0xB8, context[CpuRegister.Rax]); // mov rax, imm64
|
||||
emitter.Emit(0xC3); // ret through the synthetic transfer slot
|
||||
ActiveEntryReturnSentinelRip = (ulong)_guestReturnStub;
|
||||
if (returnSlotAddress == 0 || !context.TryWriteUInt64(returnSlotAddress, (ulong)_guestReturnStub))
|
||||
{
|
||||
@@ -5248,6 +5263,45 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
}
|
||||
}
|
||||
|
||||
// The continuation trampoline is rebuilt on every blocked-thread resume.
|
||||
// Keep its tiny writer on the stack: capturing local emit functions create a
|
||||
// managed display-class allocation on this extremely hot path.
|
||||
private unsafe ref struct NativeCodeEmitter(byte* code)
|
||||
{
|
||||
private readonly byte* _code = code;
|
||||
public int Offset;
|
||||
|
||||
public void Emit(byte value)
|
||||
{
|
||||
_code[Offset++] = value;
|
||||
}
|
||||
|
||||
public void Emit(ushort value)
|
||||
{
|
||||
*(ushort*)(_code + Offset) = value;
|
||||
Offset += sizeof(ushort);
|
||||
}
|
||||
|
||||
public void Emit(uint value)
|
||||
{
|
||||
*(uint*)(_code + Offset) = value;
|
||||
Offset += sizeof(uint);
|
||||
}
|
||||
|
||||
private void Emit(ulong value)
|
||||
{
|
||||
*(ulong*)(_code + Offset) = value;
|
||||
Offset += sizeof(ulong);
|
||||
}
|
||||
|
||||
public void EmitMovR64Immediate(byte rex, byte opcode, ulong value)
|
||||
{
|
||||
Emit(rex);
|
||||
Emit(opcode);
|
||||
Emit(value);
|
||||
}
|
||||
}
|
||||
|
||||
private static ulong AlignDown(ulong value, ulong alignment)
|
||||
{
|
||||
if (alignment == 0)
|
||||
@@ -5500,7 +5554,14 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
{
|
||||
num6 = CallNativeEntry(ptr);
|
||||
Console.Error.WriteLine($"[LOADER][INFO] Guest returned: {num6}");
|
||||
PumpUntilGuestThreadsIdle(context, "entry_return");
|
||||
// A host stop has already invalidated the session. Draining guest
|
||||
// continuations here can re-enter a blocked HLE call after its owner
|
||||
// has exited, preventing the embedded GUI from receiving its exit
|
||||
// callback.
|
||||
if (!ActiveForcedGuestExit)
|
||||
{
|
||||
PumpUntilGuestThreadsIdle(context, "entry_return");
|
||||
}
|
||||
}
|
||||
catch (AccessViolationException ex)
|
||||
{
|
||||
@@ -6153,8 +6214,68 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool Win32CloseHandle(nint hObject);
|
||||
|
||||
/// <summary>
|
||||
/// Set when <see cref="Dispose"/> intentionally left the native session
|
||||
/// state (import trampolines, TLS, exception handlers) alive because guest
|
||||
/// worker threads were still executing guest code. The owning runtime must
|
||||
/// then keep the guest address space mapped as well; freeing either under a
|
||||
/// running worker faults the whole process, which hosts the GUI launcher.
|
||||
/// </summary>
|
||||
internal bool GuestSessionLeaked { get; private set; }
|
||||
|
||||
private bool WaitForGuestThreadQuiescence(TimeSpan timeout)
|
||||
{
|
||||
var deadline = Stopwatch.GetTimestamp() + (long)(timeout.TotalSeconds * Stopwatch.Frequency);
|
||||
while (true)
|
||||
{
|
||||
var busyCount = 0;
|
||||
string? busyName = null;
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
using (LockGate("WaitForGuestThreadQuiescence"))
|
||||
{
|
||||
foreach (var thread in _guestThreads.Values)
|
||||
{
|
||||
if (thread.ExecutorActive)
|
||||
{
|
||||
busyCount++;
|
||||
busyName ??= thread.Name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (busyCount == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (now >= deadline)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] {busyCount} guest worker(s) (first: '{busyName}') did not leave guest code " +
|
||||
"during shutdown; the native session state stays alive to avoid faulting them.");
|
||||
return false;
|
||||
}
|
||||
|
||||
Thread.Sleep(5);
|
||||
}
|
||||
}
|
||||
|
||||
public unsafe void Dispose()
|
||||
{
|
||||
// Guest workers unwind cooperatively at their next import or block
|
||||
// boundary once the forced-exit flag is set. Everything freed below is
|
||||
// still reachable from a worker inside guest code, so drain the
|
||||
// scheduler first and leak the session rather than fault a straggler.
|
||||
_forcedGuestExit = true;
|
||||
StopReadyThreadDispatcher();
|
||||
StopStallWatchdog();
|
||||
if (!WaitForGuestThreadQuiescence(TimeSpan.FromSeconds(5)))
|
||||
{
|
||||
GuestSessionLeaked = true;
|
||||
return;
|
||||
}
|
||||
|
||||
ClearGuestThreads();
|
||||
if (ReferenceEquals(_posixSignalBackend, this))
|
||||
{
|
||||
// The signal handlers stay installed (they chain to the previous
|
||||
|
||||
@@ -197,8 +197,9 @@ public sealed class SelfLoader : ISelfLoader
|
||||
{
|
||||
if (!physicalVm.TryAllocateAtExact(imageBase, totalImageSize, executable: true, out var allocatedBase))
|
||||
{
|
||||
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
|
||||
throw new InvalidOperationException(
|
||||
$"Could not allocate main image at required base 0x{imageBase:X16} (size=0x{totalImageSize:X}).");
|
||||
$"Could not allocate main image at required base 0x{imageBase:X16} (size=0x{totalImageSize:X}): {reason}.");
|
||||
}
|
||||
|
||||
imageBase = allocatedBase;
|
||||
|
||||
@@ -198,6 +198,24 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return true;
|
||||
}
|
||||
|
||||
public string DescribeAddressForDiagnostics(ulong address)
|
||||
{
|
||||
if (!_hostMemory.Query(address, out var info))
|
||||
{
|
||||
return "unable to query host memory at this address";
|
||||
}
|
||||
|
||||
return info.State switch
|
||||
{
|
||||
HostRegionState.Free => "address reports free, but the exact-address reservation still failed",
|
||||
HostRegionState.Reserved =>
|
||||
$"already reserved by another host allocation (base=0x{info.AllocationBase:X16}, size=0x{info.RegionSize:X})",
|
||||
HostRegionState.Committed =>
|
||||
$"already committed by another host allocation (base=0x{info.AllocationBase:X16}, size=0x{info.RegionSize:X}, protect=0x{info.RawProtection:X})",
|
||||
_ => $"in an unexpected host state (raw=0x{info.RawState:X})",
|
||||
};
|
||||
}
|
||||
|
||||
public ulong AllocateAt(ulong desiredAddress, ulong size, bool executable = true, bool allowAlternative = true)
|
||||
{
|
||||
if (size == 0)
|
||||
|
||||
@@ -189,6 +189,16 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
|
||||
Console.Error.WriteLine($"[RUNTIME] DispatchEntry returned: {result}");
|
||||
Console.Error.WriteLine($"[RUNTIME] Dispatch result: {result}");
|
||||
|
||||
// Stop is a host operation, not an emulation failure. The detailed
|
||||
// trace and session-summary builders can traverse a partially torn
|
||||
// down native backend, delaying the GUI exit callback indefinitely.
|
||||
if (HostSessionControl.IsShutdownRequested)
|
||||
{
|
||||
Console.Error.WriteLine("[RUNTIME] Skipping post-exit diagnostics for host shutdown.");
|
||||
return result;
|
||||
}
|
||||
|
||||
LastExecutionTrace = _cpuDispatcher.LastImportResolutionTrace;
|
||||
LastMilestoneLog = _cpuDispatcher.LastMilestoneLog;
|
||||
LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary);
|
||||
@@ -1152,6 +1162,16 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
disposableDispatcher.Dispose();
|
||||
}
|
||||
|
||||
if (_cpuDispatcher is CpuDispatcher { NativeSessionLeaked: true })
|
||||
{
|
||||
// A guest worker is still inside guest code; unmapping the guest
|
||||
// address space under it would fault the whole process, which
|
||||
// hosts the GUI launcher in embedded sessions.
|
||||
Console.Error.WriteLine(
|
||||
"[RUNTIME] Guest workers were still active at teardown; keeping the guest address space mapped.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_virtualMemory is IDisposable disposableMemory)
|
||||
{
|
||||
disposableMemory.Dispose();
|
||||
|
||||
@@ -29,6 +29,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
|
||||
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
|
||||
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
|
||||
<SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" />
|
||||
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
|
||||
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
|
||||
</Application.Resources>
|
||||
@@ -55,6 +56,22 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Setter Property="Padding" Value="10,3" />
|
||||
</Style>
|
||||
|
||||
<!-- Session status/hotkey badges: the title-id pill geometry with a
|
||||
tinted fill so state (RUNNING) and keys (F11) read at a glance. -->
|
||||
<Style Selector="Border.badge">
|
||||
<Setter Property="CornerRadius" Value="999" />
|
||||
<Setter Property="Padding" Value="8,2" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<Style Selector="Border.badge.running">
|
||||
<Setter Property="Background" Value="#1E46C46B" />
|
||||
<Setter Property="BorderBrush" Value="#5546C46B" />
|
||||
</Style>
|
||||
<Style Selector="Border.badge.key">
|
||||
<Setter Property="Background" Value="#1E58A6FF" />
|
||||
<Setter Property="BorderBrush" Value="#5558A6FF" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.sectionTitle">
|
||||
<Setter Property="FontSize" Value="11" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
|
||||
+331
-316
@@ -1,7 +1,6 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
@@ -10,32 +9,36 @@ using Microsoft.Win32.SafeHandles;
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
/// <summary>
|
||||
/// Launches the SharpEmu CLI as a child process with the same CET/CFG mitigation
|
||||
/// opt-outs the CLI would apply to its own relaunched child, while capturing
|
||||
/// stdout/stderr through pipes. The CLI's internal relaunch is suppressed via
|
||||
/// SHARPEMU_DISABLE_MITIGATION_RELAUNCH so output is not lost to a detached
|
||||
/// console. A kill-on-close job object ties the emulator's lifetime to the GUI.
|
||||
/// Owns an isolated emulator process. Guest virtual memory is fixed-address and
|
||||
/// cannot be reliably reused while guest-created host threads are still alive,
|
||||
/// so the GUI must never execute a game in its own process.
|
||||
/// </summary>
|
||||
internal sealed class EmulatorProcess : IDisposable
|
||||
{
|
||||
private const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000;
|
||||
private const uint CREATE_NO_WINDOW = 0x08000000;
|
||||
private const int STARTF_USESTDHANDLES = 0x00000100;
|
||||
private const uint HANDLE_FLAG_INHERIT = 0x00000001;
|
||||
private const uint INFINITE = 0xFFFFFFFF;
|
||||
private const int PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY = 0x00020007;
|
||||
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
|
||||
private const int JobObjectExtendedLimitInformation = 9;
|
||||
private const ulong PROCESS_CREATION_MITIGATION_POLICY_CONTROL_FLOW_GUARD_ALWAYS_OFF = 0x00000002UL << 40;
|
||||
private const ulong PROCESS_CREATION_MITIGATION_POLICY2_CET_USER_SHADOW_STACKS_ALWAYS_OFF = 0x00000002UL << 28;
|
||||
private const ulong PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF = 0x00000002UL << 32;
|
||||
private const ulong PROCESS_CREATION_MITIGATION_POLICY2_XTENDED_CONTROL_FLOW_GUARD_ALWAYS_OFF = 0x00000002UL << 40;
|
||||
public const int HostStopExitCode = -2;
|
||||
|
||||
private const uint ExtendedStartupInfoPresent = 0x00080000;
|
||||
private const uint CreateNoWindow = 0x08000000;
|
||||
private const int StartfUseStdHandles = 0x00000100;
|
||||
private const uint HandleFlagInherit = 0x00000001;
|
||||
private const uint Infinite = 0xFFFFFFFF;
|
||||
private const int ProcThreadAttributeMitigationPolicy = 0x00020007;
|
||||
private const uint JobObjectLimitKillOnJobClose = 0x00002000;
|
||||
private const int JobObjectExtendedLimitInformationClass = 9;
|
||||
private const string MitigatedChildFlag = "--sharpemu-mitigated-child";
|
||||
private const string MitigatedChildEnvironment = "SHARPEMU_MITIGATED_CHILD";
|
||||
private const ulong ControlFlowGuardAlwaysOff = 0x00000002UL << 40;
|
||||
private const ulong CetUserShadowStacksAlwaysOff = 0x00000002UL << 28;
|
||||
private const ulong UserCetSetContextIpValidationAlwaysOff = 0x00000002UL << 32;
|
||||
|
||||
private static readonly object EnvironmentGate = new();
|
||||
|
||||
private readonly object _sync = new();
|
||||
private nint _processHandle;
|
||||
private nint _jobHandle;
|
||||
private Process? _fallbackProcess;
|
||||
private bool _running;
|
||||
private bool _stopRequested;
|
||||
private bool _disposed;
|
||||
|
||||
public event Action<string, bool>? OutputReceived;
|
||||
@@ -55,29 +58,34 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
|
||||
public void Start(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(exePath);
|
||||
ArgumentNullException.ThrowIfNull(arguments);
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ThrowIfDisposed();
|
||||
if (_running)
|
||||
{
|
||||
throw new InvalidOperationException("The emulator process is already running.");
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
StartWindows(exePath, arguments, workingDirectory);
|
||||
}
|
||||
else
|
||||
{
|
||||
StartFallback(exePath, arguments, workingDirectory);
|
||||
}
|
||||
|
||||
_running = true;
|
||||
_stopRequested = false;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
StartWindows(exePath, arguments, workingDirectory);
|
||||
return;
|
||||
}
|
||||
|
||||
StartFallback(exePath, arguments, workingDirectory);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
nint processHandle;
|
||||
nint jobHandle;
|
||||
Process? fallbackProcess;
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_running)
|
||||
@@ -85,28 +93,35 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer terminating the job: it kills the whole tree, including
|
||||
// any children the emulator spawned, even when the main process
|
||||
// is wedged in a GPU driver call.
|
||||
if (_jobHandle != 0)
|
||||
{
|
||||
_ = TerminateJobObject(_jobHandle, 1);
|
||||
}
|
||||
_stopRequested = true;
|
||||
processHandle = _processHandle;
|
||||
jobHandle = _jobHandle;
|
||||
fallbackProcess = _fallbackProcess;
|
||||
}
|
||||
|
||||
if (_processHandle != 0)
|
||||
{
|
||||
_ = TerminateProcess(_processHandle, 1);
|
||||
}
|
||||
if (jobHandle != 0)
|
||||
{
|
||||
_ = TerminateJobObject(jobHandle, unchecked((uint)HostStopExitCode));
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
if (processHandle != 0)
|
||||
{
|
||||
_ = TerminateProcess(processHandle, unchecked((uint)HostStopExitCode));
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (fallbackProcess is { HasExited: false })
|
||||
{
|
||||
_fallbackProcess?.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Already exited.
|
||||
fallbackProcess.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// The process exited while Stop was handling the request.
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -124,121 +139,206 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
Stop();
|
||||
}
|
||||
|
||||
private void StartWindows(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
|
||||
private void StartFallback(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
|
||||
{
|
||||
// The CLI would otherwise relaunch itself into a mitigated child whose
|
||||
// console output cannot flow through our pipes.
|
||||
Environment.SetEnvironmentVariable("SHARPEMU_DISABLE_MITIGATION_RELAUNCH", "1");
|
||||
|
||||
var securityAttributes = new SECURITY_ATTRIBUTES
|
||||
var startInfo = new ProcessStartInfo(exePath)
|
||||
{
|
||||
nLength = Marshal.SizeOf<SECURITY_ATTRIBUTES>(),
|
||||
bInheritHandle = 1,
|
||||
WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory)
|
||||
? Path.GetDirectoryName(exePath) ?? Environment.CurrentDirectory
|
||||
: workingDirectory,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
|
||||
if (!CreatePipe(out var stdoutRead, out var stdoutWrite, ref securityAttributes, 0) ||
|
||||
!CreatePipe(out var stderrRead, out var stderrWrite, ref securityAttributes, 0))
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create output pipes.");
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
_ = SetHandleInformation(stdoutRead, HANDLE_FLAG_INHERIT, 0);
|
||||
_ = SetHandleInformation(stderrRead, HANDLE_FLAG_INHERIT, 0);
|
||||
var process = Process.Start(startInfo)
|
||||
?? throw new InvalidOperationException("Could not start the emulator process.");
|
||||
process.EnableRaisingEvents = true;
|
||||
process.OutputDataReceived += (_, eventArgs) => ForwardOutput(eventArgs.Data, isError: false);
|
||||
process.ErrorDataReceived += (_, eventArgs) => ForwardOutput(eventArgs.Data, isError: true);
|
||||
process.Exited += (_, _) => OnExited(process.ExitCode);
|
||||
|
||||
var startupInfoEx = new STARTUPINFOEX();
|
||||
startupInfoEx.StartupInfo.cb = Marshal.SizeOf<STARTUPINFOEX>();
|
||||
startupInfoEx.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
|
||||
startupInfoEx.StartupInfo.hStdOutput = stdoutWrite;
|
||||
startupInfoEx.StartupInfo.hStdError = stderrWrite;
|
||||
lock (_sync)
|
||||
{
|
||||
_fallbackProcess = process;
|
||||
_running = true;
|
||||
}
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
}
|
||||
|
||||
private unsafe void StartWindows(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
|
||||
{
|
||||
nint stdoutRead = 0;
|
||||
nint stdoutWrite = 0;
|
||||
nint stderrRead = 0;
|
||||
nint stderrWrite = 0;
|
||||
nint attributeList = 0;
|
||||
nint mitigationPolicies = 0;
|
||||
nint processHandle = 0;
|
||||
nint threadHandle = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var security = new SecurityAttributes
|
||||
{
|
||||
Size = Marshal.SizeOf<SecurityAttributes>(),
|
||||
InheritHandle = 1,
|
||||
};
|
||||
if (!CreatePipe(out stdoutRead, out stdoutWrite, ref security, 0) ||
|
||||
!CreatePipe(out stderrRead, out stderrWrite, ref security, 0))
|
||||
{
|
||||
throw new InvalidOperationException($"Could not create emulator output pipes (Win32 error {Marshal.GetLastWin32Error()}).");
|
||||
}
|
||||
|
||||
if (!SetHandleInformation(stdoutRead, HandleFlagInherit, 0) ||
|
||||
!SetHandleInformation(stderrRead, HandleFlagInherit, 0))
|
||||
{
|
||||
throw new InvalidOperationException($"Could not configure emulator output pipes (Win32 error {Marshal.GetLastWin32Error()}).");
|
||||
}
|
||||
|
||||
nuint attributeListSize = 0;
|
||||
_ = InitializeProcThreadAttributeList(0, 1, 0, ref attributeListSize);
|
||||
attributeList = Marshal.AllocHGlobal((nint)attributeListSize);
|
||||
if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref attributeListSize))
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to initialize the process attribute list.");
|
||||
throw new InvalidOperationException($"Could not initialize process mitigation attributes (Win32 error {Marshal.GetLastWin32Error()}).");
|
||||
}
|
||||
|
||||
startupInfoEx.lpAttributeList = attributeList;
|
||||
|
||||
var policy1 = PROCESS_CREATION_MITIGATION_POLICY_CONTROL_FLOW_GUARD_ALWAYS_OFF;
|
||||
var policy2 =
|
||||
PROCESS_CREATION_MITIGATION_POLICY2_CET_USER_SHADOW_STACKS_ALWAYS_OFF |
|
||||
PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF;
|
||||
|
||||
mitigationPolicies = Marshal.AllocHGlobal(sizeof(ulong) * 2);
|
||||
Marshal.WriteInt64(mitigationPolicies, unchecked((long)policy1));
|
||||
Marshal.WriteInt64(nint.Add(mitigationPolicies, sizeof(long)), unchecked((long)policy2));
|
||||
|
||||
Marshal.WriteInt64(mitigationPolicies, unchecked((long)ControlFlowGuardAlwaysOff));
|
||||
Marshal.WriteInt64(
|
||||
nint.Add(mitigationPolicies, sizeof(long)),
|
||||
unchecked((long)(CetUserShadowStacksAlwaysOff | UserCetSetContextIpValidationAlwaysOff)));
|
||||
if (!UpdateProcThreadAttribute(
|
||||
attributeList,
|
||||
0,
|
||||
PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY,
|
||||
mitigationPolicies,
|
||||
(nuint)(sizeof(ulong) * 2),
|
||||
0,
|
||||
0))
|
||||
attributeList,
|
||||
0,
|
||||
(nint)ProcThreadAttributeMitigationPolicy,
|
||||
mitigationPolicies,
|
||||
(nuint)(sizeof(ulong) * 2),
|
||||
0,
|
||||
0))
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to apply the mitigation policy.");
|
||||
throw new InvalidOperationException($"Could not apply process mitigations (Win32 error {Marshal.GetLastWin32Error()}).");
|
||||
}
|
||||
|
||||
var currentDirectory = workingDirectory ?? Environment.CurrentDirectory;
|
||||
var created = CreateProcessW(
|
||||
exePath,
|
||||
new StringBuilder(BuildCommandLine(exePath, arguments)),
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW,
|
||||
0,
|
||||
currentDirectory,
|
||||
ref startupInfoEx,
|
||||
out var processInfo);
|
||||
var startup = new StartupInfoEx();
|
||||
startup.StartupInfo.Size = Marshal.SizeOf<StartupInfoEx>();
|
||||
startup.StartupInfo.Flags = StartfUseStdHandles;
|
||||
startup.StartupInfo.StdOutput = stdoutWrite;
|
||||
startup.StartupInfo.StdError = stderrWrite;
|
||||
startup.AttributeList = attributeList;
|
||||
|
||||
if (!created)
|
||||
var childArguments = new List<string>(arguments.Count + 1) { MitigatedChildFlag };
|
||||
childArguments.AddRange(arguments);
|
||||
var commandLine = new StringBuilder(BuildCommandLine(exePath, childArguments));
|
||||
ProcessInformation processInfo;
|
||||
lock (EnvironmentGate)
|
||||
{
|
||||
var error = Marshal.GetLastWin32Error();
|
||||
throw new Win32Exception(error, $"Failed to start '{exePath}' with CET/CFG mitigation disabled (Win32 error {error}: {new Win32Exception(error).Message}).");
|
||||
var previousValue = Environment.GetEnvironmentVariable(MitigatedChildEnvironment);
|
||||
try
|
||||
{
|
||||
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
|
||||
if (!CreateProcessW(
|
||||
exePath,
|
||||
commandLine,
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
ExtendedStartupInfoPresent | CreateNoWindow,
|
||||
0,
|
||||
string.IsNullOrWhiteSpace(workingDirectory)
|
||||
? Path.GetDirectoryName(exePath) ?? Environment.CurrentDirectory
|
||||
: workingDirectory,
|
||||
ref startup,
|
||||
out processInfo))
|
||||
{
|
||||
throw new InvalidOperationException($"Could not start the emulator process (Win32 error {Marshal.GetLastWin32Error()}).");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, previousValue);
|
||||
}
|
||||
}
|
||||
|
||||
CloseHandle(processInfo.hThread);
|
||||
_processHandle = processInfo.hProcess;
|
||||
processHandle = processInfo.Process;
|
||||
threadHandle = processInfo.Thread;
|
||||
CloseHandle(stdoutWrite);
|
||||
stdoutWrite = 0;
|
||||
CloseHandle(stderrWrite);
|
||||
stderrWrite = 0;
|
||||
|
||||
_jobHandle = CreateJobObjectW(0, null);
|
||||
if (_jobHandle != 0 &&
|
||||
(!TryEnableKillOnJobClose(_jobHandle) || !AssignProcessToJobObject(_jobHandle, processInfo.hProcess)))
|
||||
var jobHandle = CreateJobObjectW(0, null);
|
||||
if (jobHandle != 0 &&
|
||||
(!TryEnableKillOnJobClose(jobHandle) || !AssignProcessToJobObject(jobHandle, processHandle)))
|
||||
{
|
||||
CloseHandle(_jobHandle);
|
||||
_jobHandle = 0;
|
||||
CloseHandle(jobHandle);
|
||||
jobHandle = 0;
|
||||
}
|
||||
|
||||
StartReaderThread(stdoutRead, isError: false);
|
||||
StartReaderThread(stderrRead, isError: true);
|
||||
StartExitWatcherThread();
|
||||
lock (_sync)
|
||||
{
|
||||
_processHandle = processHandle;
|
||||
_jobHandle = jobHandle;
|
||||
_running = true;
|
||||
}
|
||||
processHandle = 0;
|
||||
|
||||
StartPipeReader(stdoutRead, isError: false);
|
||||
stdoutRead = 0;
|
||||
StartPipeReader(stderrRead, isError: true);
|
||||
stderrRead = 0;
|
||||
StartWindowsExitWatcher(_processHandle);
|
||||
}
|
||||
catch
|
||||
{
|
||||
CloseHandle(stdoutRead);
|
||||
CloseHandle(stderrRead);
|
||||
if (processHandle != 0)
|
||||
{
|
||||
_ = TerminateProcess(processHandle, 1);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The child owns duplicated pipe write ends; closing ours lets the
|
||||
// readers observe EOF when the child exits.
|
||||
CloseHandle(stdoutWrite);
|
||||
CloseHandle(stderrWrite);
|
||||
|
||||
if (threadHandle != 0)
|
||||
{
|
||||
CloseHandle(threadHandle);
|
||||
}
|
||||
if (processHandle != 0)
|
||||
{
|
||||
CloseHandle(processHandle);
|
||||
}
|
||||
if (stdoutRead != 0)
|
||||
{
|
||||
CloseHandle(stdoutRead);
|
||||
}
|
||||
if (stdoutWrite != 0)
|
||||
{
|
||||
CloseHandle(stdoutWrite);
|
||||
}
|
||||
if (stderrRead != 0)
|
||||
{
|
||||
CloseHandle(stderrRead);
|
||||
}
|
||||
if (stderrWrite != 0)
|
||||
{
|
||||
CloseHandle(stderrWrite);
|
||||
}
|
||||
if (attributeList != 0)
|
||||
{
|
||||
DeleteProcThreadAttributeList(attributeList);
|
||||
Marshal.FreeHGlobal(attributeList);
|
||||
}
|
||||
|
||||
if (mitigationPolicies != 0)
|
||||
{
|
||||
Marshal.FreeHGlobal(mitigationPolicies);
|
||||
@@ -246,103 +346,56 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private void StartFallback(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
|
||||
private void StartPipeReader(nint handle, bool isError)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
var readerThread = new Thread(() =>
|
||||
{
|
||||
FileName = exePath,
|
||||
WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
};
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
|
||||
process.OutputDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data is not null)
|
||||
using var safeHandle = new SafeFileHandle(handle, ownsHandle: true);
|
||||
using var stream = new FileStream(safeHandle, FileAccess.Read, 4096, isAsync: false);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
OutputReceived?.Invoke(e.Data, false);
|
||||
ForwardOutput(line, isError);
|
||||
}
|
||||
};
|
||||
process.ErrorDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data is not null)
|
||||
{
|
||||
OutputReceived?.Invoke(e.Data, true);
|
||||
}
|
||||
};
|
||||
process.Exited += (_, _) =>
|
||||
{
|
||||
int exitCode;
|
||||
try
|
||||
{
|
||||
exitCode = process.ExitCode;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
exitCode = -1;
|
||||
}
|
||||
|
||||
OnExited(exitCode);
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
_fallbackProcess = process;
|
||||
}
|
||||
|
||||
private void StartReaderThread(nint readHandle, bool isError)
|
||||
{
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
using var stream = new FileStream(new SafeFileHandle(readHandle, ownsHandle: true), FileAccess.Read);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||
try
|
||||
{
|
||||
while (reader.ReadLine() is { } line)
|
||||
{
|
||||
OutputReceived?.Invoke(line, isError);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Pipe broken on process teardown.
|
||||
}
|
||||
})
|
||||
}, 256 * 1024)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = isError ? "SharpEmu stderr reader" : "SharpEmu stdout reader",
|
||||
};
|
||||
thread.Start();
|
||||
readerThread.Start();
|
||||
}
|
||||
|
||||
private void StartExitWatcherThread()
|
||||
private void StartWindowsExitWatcher(nint processHandle)
|
||||
{
|
||||
var processHandle = _processHandle;
|
||||
var thread = new Thread(() =>
|
||||
var watcher = new Thread(() =>
|
||||
{
|
||||
_ = WaitForSingleObject(processHandle, INFINITE);
|
||||
var exitCode = GetExitCodeProcess(processHandle, out var rawExitCode)
|
||||
? unchecked((int)rawExitCode)
|
||||
: -1;
|
||||
_ = WaitForSingleObject(processHandle, Infinite);
|
||||
var exitCode = 1;
|
||||
if (GetExitCodeProcess(processHandle, out var nativeExitCode))
|
||||
{
|
||||
exitCode = unchecked((int)nativeExitCode);
|
||||
}
|
||||
|
||||
OnExited(exitCode);
|
||||
})
|
||||
}, 128 * 1024)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "SharpEmu exit watcher",
|
||||
};
|
||||
thread.Start();
|
||||
watcher.Start();
|
||||
}
|
||||
|
||||
private void OnExited(int exitCode)
|
||||
private void ForwardOutput(string? line, bool isError)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(line))
|
||||
{
|
||||
OutputReceived?.Invoke(line, isError);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnExited(int nativeExitCode)
|
||||
{
|
||||
int exitCode;
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_running)
|
||||
@@ -350,13 +403,13 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
exitCode = _stopRequested ? HostStopExitCode : nativeExitCode;
|
||||
_running = false;
|
||||
if (_processHandle != 0)
|
||||
{
|
||||
CloseHandle(_processHandle);
|
||||
_processHandle = 0;
|
||||
}
|
||||
|
||||
if (_jobHandle != 0)
|
||||
{
|
||||
CloseHandle(_jobHandle);
|
||||
@@ -372,24 +425,19 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
|
||||
private static bool TryEnableKillOnJobClose(nint jobHandle)
|
||||
{
|
||||
var extendedLimitInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
||||
var info = new JobObjectExtendedLimitInformation
|
||||
{
|
||||
BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION
|
||||
BasicLimitInformation = new JobObjectBasicLimitInformation
|
||||
{
|
||||
LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
LimitFlags = JobObjectLimitKillOnJobClose,
|
||||
},
|
||||
};
|
||||
|
||||
var size = Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>();
|
||||
var size = Marshal.SizeOf<JobObjectExtendedLimitInformation>();
|
||||
var memory = Marshal.AllocHGlobal(size);
|
||||
try
|
||||
{
|
||||
Marshal.StructureToPtr(extendedLimitInfo, memory, false);
|
||||
return SetInformationJobObject(
|
||||
jobHandle,
|
||||
JobObjectExtendedLimitInformation,
|
||||
memory,
|
||||
unchecked((uint)size));
|
||||
Marshal.StructureToPtr(info, memory, false);
|
||||
return SetInformationJobObject(jobHandle, JobObjectExtendedLimitInformationClass, memory, unchecked((uint)size));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -397,128 +445,124 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildCommandLine(string processPath, IReadOnlyList<string> args)
|
||||
private static string BuildCommandLine(string processPath, IReadOnlyList<string> arguments)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append(QuoteArgument(processPath));
|
||||
for (var i = 0; i < args.Count; i++)
|
||||
var builder = new StringBuilder(QuoteArgument(processPath));
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
builder.Append(' ');
|
||||
builder.Append(QuoteArgument(args[i]));
|
||||
builder.Append(QuoteArgument(argument));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string argument)
|
||||
private static string QuoteArgument(string value)
|
||||
{
|
||||
if (argument.Length == 0)
|
||||
if (value.Length == 0)
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
var needsQuotes = false;
|
||||
foreach (var c in argument)
|
||||
if (!value.Any(static c => char.IsWhiteSpace(c) || c == '"'))
|
||||
{
|
||||
if (char.IsWhiteSpace(c) || c == '"')
|
||||
{
|
||||
needsQuotes = true;
|
||||
break;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!needsQuotes)
|
||||
{
|
||||
return argument;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(argument.Length + 2);
|
||||
var builder = new StringBuilder(value.Length + 2);
|
||||
builder.Append('"');
|
||||
|
||||
var backslashCount = 0;
|
||||
foreach (var c in argument)
|
||||
var slashCount = 0;
|
||||
foreach (var character in value)
|
||||
{
|
||||
if (c == '\\')
|
||||
if (character == '\\')
|
||||
{
|
||||
backslashCount++;
|
||||
slashCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '"')
|
||||
if (character == '"')
|
||||
{
|
||||
builder.Append('\\', (backslashCount * 2) + 1);
|
||||
builder.Append('"');
|
||||
backslashCount = 0;
|
||||
builder.Append('\\', (slashCount * 2) + 1);
|
||||
builder.Append(character);
|
||||
slashCount = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (backslashCount > 0)
|
||||
if (slashCount > 0)
|
||||
{
|
||||
builder.Append('\\', backslashCount);
|
||||
backslashCount = 0;
|
||||
builder.Append('\\', slashCount);
|
||||
slashCount = 0;
|
||||
}
|
||||
|
||||
builder.Append(c);
|
||||
builder.Append(character);
|
||||
}
|
||||
|
||||
if (backslashCount > 0)
|
||||
if (slashCount > 0)
|
||||
{
|
||||
builder.Append('\\', backslashCount * 2);
|
||||
builder.Append('\\', slashCount * 2);
|
||||
}
|
||||
|
||||
builder.Append('"');
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct SECURITY_ATTRIBUTES
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
public int nLength;
|
||||
public nint lpSecurityDescriptor;
|
||||
public int bInheritHandle;
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(EmulatorProcess));
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct SecurityAttributes
|
||||
{
|
||||
public int Size;
|
||||
public nint SecurityDescriptor;
|
||||
public int InheritHandle;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct STARTUPINFO
|
||||
private struct StartupInfo
|
||||
{
|
||||
public int cb;
|
||||
public nint lpReserved;
|
||||
public nint lpDesktop;
|
||||
public nint lpTitle;
|
||||
public int dwX;
|
||||
public int dwY;
|
||||
public int dwXSize;
|
||||
public int dwYSize;
|
||||
public int dwXCountChars;
|
||||
public int dwYCountChars;
|
||||
public int dwFillAttribute;
|
||||
public int dwFlags;
|
||||
public short wShowWindow;
|
||||
public short cbReserved2;
|
||||
public nint lpReserved2;
|
||||
public nint hStdInput;
|
||||
public nint hStdOutput;
|
||||
public nint hStdError;
|
||||
public int Size;
|
||||
public nint Reserved;
|
||||
public nint Desktop;
|
||||
public nint Title;
|
||||
public int X;
|
||||
public int Y;
|
||||
public int XSize;
|
||||
public int YSize;
|
||||
public int XCountChars;
|
||||
public int YCountChars;
|
||||
public int FillAttribute;
|
||||
public int Flags;
|
||||
public short ShowWindow;
|
||||
public short Reserved2Count;
|
||||
public nint Reserved2;
|
||||
public nint StdInput;
|
||||
public nint StdOutput;
|
||||
public nint StdError;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct STARTUPINFOEX
|
||||
private struct StartupInfoEx
|
||||
{
|
||||
public STARTUPINFO StartupInfo;
|
||||
public nint lpAttributeList;
|
||||
public StartupInfo StartupInfo;
|
||||
public nint AttributeList;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct PROCESS_INFORMATION
|
||||
private struct ProcessInformation
|
||||
{
|
||||
public nint hProcess;
|
||||
public nint hThread;
|
||||
public int dwProcessId;
|
||||
public int dwThreadId;
|
||||
public nint Process;
|
||||
public nint Thread;
|
||||
public int ProcessId;
|
||||
public int ThreadId;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
|
||||
private struct JobObjectBasicLimitInformation
|
||||
{
|
||||
public long PerProcessUserTimeLimit;
|
||||
public long PerJobUserTimeLimit;
|
||||
@@ -532,7 +576,7 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct IO_COUNTERS
|
||||
private struct IoCounters
|
||||
{
|
||||
public ulong ReadOperationCount;
|
||||
public ulong WriteOperationCount;
|
||||
@@ -543,10 +587,10 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
||||
private struct JobObjectExtendedLimitInformation
|
||||
{
|
||||
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
|
||||
public IO_COUNTERS IoInfo;
|
||||
public JobObjectBasicLimitInformation BasicLimitInformation;
|
||||
public IoCounters IoInfo;
|
||||
public nuint ProcessMemoryLimit;
|
||||
public nuint JobMemoryLimit;
|
||||
public nuint PeakProcessMemoryUsed;
|
||||
@@ -555,66 +599,37 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CreatePipe(
|
||||
out nint hReadPipe,
|
||||
out nint hWritePipe,
|
||||
ref SECURITY_ATTRIBUTES lpPipeAttributes,
|
||||
uint nSize);
|
||||
private static extern bool CreatePipe(out nint readPipe, out nint writePipe, ref SecurityAttributes attributes, uint size);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetHandleInformation(nint hObject, uint dwMask, uint dwFlags);
|
||||
private static extern bool SetHandleInformation(nint handle, uint mask, uint flags);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool InitializeProcThreadAttributeList(
|
||||
nint lpAttributeList,
|
||||
int dwAttributeCount,
|
||||
int dwFlags,
|
||||
ref nuint lpSize);
|
||||
private static extern bool InitializeProcThreadAttributeList(nint list, int count, int flags, ref nuint size);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool UpdateProcThreadAttribute(
|
||||
nint lpAttributeList,
|
||||
uint dwFlags,
|
||||
nint attribute,
|
||||
nint lpValue,
|
||||
nuint cbSize,
|
||||
nint lpPreviousValue,
|
||||
nint lpReturnSize);
|
||||
private static extern bool UpdateProcThreadAttribute(nint list, uint flags, nint attribute, nint value, nuint size, nint previousValue, nint returnSize);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern void DeleteProcThreadAttributeList(nint lpAttributeList);
|
||||
private static extern void DeleteProcThreadAttributeList(nint list);
|
||||
|
||||
[DllImport("kernel32.dll", EntryPoint = "CreateJobObjectW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern nint CreateJobObjectW(nint lpJobAttributes, string? lpName);
|
||||
private static extern nint CreateJobObjectW(nint attributes, string? name);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetInformationJobObject(
|
||||
nint hJob,
|
||||
int jobObjectInfoClass,
|
||||
nint lpJobObjectInfo,
|
||||
uint cbJobObjectInfoLength);
|
||||
private static extern bool SetInformationJobObject(nint job, int infoClass, nint info, uint size);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool AssignProcessToJobObject(nint hJob, nint hProcess);
|
||||
private static extern bool AssignProcessToJobObject(nint job, nint process);
|
||||
|
||||
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CreateProcessW(
|
||||
string applicationName,
|
||||
StringBuilder commandLine,
|
||||
nint processAttributes,
|
||||
nint threadAttributes,
|
||||
[MarshalAs(UnmanagedType.Bool)] bool inheritHandles,
|
||||
uint creationFlags,
|
||||
nint environment,
|
||||
string currentDirectory,
|
||||
ref STARTUPINFOEX startupInfo,
|
||||
out PROCESS_INFORMATION processInformation);
|
||||
private static extern bool CreateProcessW(string applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern uint WaitForSingleObject(nint handle, uint milliseconds);
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
/// <summary>
|
||||
/// Native child surface owned by Avalonia. The isolated emulator process uses
|
||||
/// its platform handle to create the Vulkan presentation surface, keeping the
|
||||
/// guest address space out of the GUI process.
|
||||
/// </summary>
|
||||
public sealed class GameSurfaceHost : NativeControlHost
|
||||
{
|
||||
private const uint SwpNoSize = 0x0001;
|
||||
private const uint SwpNoMove = 0x0002;
|
||||
private const uint SwpNoZOrder = 0x0004;
|
||||
private const uint SwpNoActivate = 0x0010;
|
||||
private const uint SwpShowWindow = 0x0040;
|
||||
private const uint SwpHideWindow = 0x0080;
|
||||
private const uint WsChild = 0x40000000;
|
||||
private const uint WsVisible = 0x10000000;
|
||||
private const uint WsClipSiblings = 0x04000000;
|
||||
private const uint WsClipChildren = 0x02000000;
|
||||
private const uint CsOwnDc = 0x0020;
|
||||
private const uint WmSetCursor = 0x0020;
|
||||
private const uint WmMouseMove = 0x0200;
|
||||
private const int IdcArrow = 32512;
|
||||
private const int CursorHideDelayMs = 2500;
|
||||
|
||||
private VulkanHostSurface? _surface;
|
||||
private nint _windowHandle;
|
||||
private nint _x11Display;
|
||||
private string? _win32ClassName;
|
||||
private WindowProcedure? _windowProcedure;
|
||||
private nint _metalLayer;
|
||||
private bool _presentationVisible = true;
|
||||
private DispatcherTimer? _cursorIdleTimer;
|
||||
private bool _cursorAutoHide;
|
||||
private bool _cursorHidden;
|
||||
private long _lastPointerActivity;
|
||||
|
||||
public GameSurfaceHost()
|
||||
{
|
||||
PropertyChanged += (_, change) =>
|
||||
{
|
||||
if (change.Property == BoundsProperty)
|
||||
{
|
||||
UpdateSurfaceSize();
|
||||
}
|
||||
};
|
||||
LayoutUpdated += (_, _) =>
|
||||
{
|
||||
// Fullscreen can change a monitor's DPI scale without changing
|
||||
// the logical Bounds. Refresh the native child from physical size.
|
||||
UpdateSurfaceSize();
|
||||
|
||||
// NativeControlHost may make its HWND visible again as part of a
|
||||
// later arrange pass. Keep a loading surface hidden until its
|
||||
// child process reports a real first frame.
|
||||
if (!_presentationVisible)
|
||||
{
|
||||
ApplyPresentationVisibility();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public event EventHandler<VulkanHostSurface>? SurfaceAvailable;
|
||||
|
||||
public event EventHandler<VulkanHostSurface>? SurfaceDestroyed;
|
||||
|
||||
public VulkanHostSurface? Surface => _surface;
|
||||
|
||||
public void RefreshSurfaceSize() => UpdateSurfaceSize();
|
||||
|
||||
/// <summary>
|
||||
/// Hides the platform child without detaching the Vulkan surface. This
|
||||
/// allows the launcher to return to its library while guest teardown is
|
||||
/// still finishing on the render thread.
|
||||
/// </summary>
|
||||
public void SetPresentationVisible(bool visible)
|
||||
{
|
||||
_presentationVisible = visible;
|
||||
ApplyPresentationVisibility();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-hides the mouse cursor over the game surface after a short idle
|
||||
/// period; any pointer movement brings it back. Enabling (again) restarts
|
||||
/// the idle countdown, so both "first frame presented" and "entered
|
||||
/// fullscreen" can arm it. Windows-only; a no-op elsewhere.
|
||||
/// </summary>
|
||||
public void SetCursorAutoHide(bool enabled)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cursorAutoHide = enabled;
|
||||
_lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
if (enabled)
|
||||
{
|
||||
_cursorIdleTimer ??= CreateCursorIdleTimer();
|
||||
_cursorIdleTimer.Start();
|
||||
return;
|
||||
}
|
||||
|
||||
_cursorIdleTimer?.Stop();
|
||||
ShowCursorNow();
|
||||
}
|
||||
|
||||
private DispatcherTimer CreateCursorIdleTimer()
|
||||
{
|
||||
var timer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(250),
|
||||
};
|
||||
timer.Tick += (_, _) => HideCursorWhenIdle();
|
||||
return timer;
|
||||
}
|
||||
|
||||
private void HideCursorWhenIdle()
|
||||
{
|
||||
if (!_cursorAutoHide || _cursorHidden || _windowHandle == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var idleMs = (System.Diagnostics.Stopwatch.GetTimestamp() - _lastPointerActivity) *
|
||||
1000 / System.Diagnostics.Stopwatch.Frequency;
|
||||
if (idleMs < CursorHideDelayMs)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Only swallow the cursor while it is actually over the game surface;
|
||||
// hovering launcher chrome (console, toolbar) must keep the arrow.
|
||||
if (!GetCursorPos(out var point) || WindowFromPoint(point) != _windowHandle)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cursorHidden = true;
|
||||
_ = SetCursor(0);
|
||||
}
|
||||
|
||||
private void ShowCursorNow()
|
||||
{
|
||||
if (!_cursorHidden)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cursorHidden = false;
|
||||
_ = SetCursor(LoadCursorW(0, IdcArrow));
|
||||
}
|
||||
|
||||
private void ApplyPresentationVisibility()
|
||||
{
|
||||
if (_windowHandle == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var visible = _presentationVisible;
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
// SW_HIDE can be ignored for a window's initial show state. Force
|
||||
// the state through SetWindowPos so an old child swapchain cannot
|
||||
// remain composed while the next game is loading.
|
||||
var flags = SwpNoSize | SwpNoMove | SwpNoZOrder | SwpNoActivate |
|
||||
(visible ? SwpShowWindow : SwpHideWindow);
|
||||
_ = SetWindowPos(_windowHandle, 0, 0, 0, 0, 0, flags);
|
||||
}
|
||||
else if (OperatingSystem.IsLinux() && _x11Display != 0)
|
||||
{
|
||||
_ = visible
|
||||
? XMapWindow(_x11Display, _windowHandle)
|
||||
: XUnmapWindow(_x11Display, _windowHandle);
|
||||
_ = XFlush(_x11Display);
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
SendBool(_windowHandle, "setHidden:", !visible);
|
||||
}
|
||||
}
|
||||
|
||||
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle control)
|
||||
{
|
||||
PlatformHandle handle;
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
handle = CreateWin32(control);
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
handle = CreateX11(control);
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
handle = CreateMacOS();
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new PlatformNotSupportedException("SharpEmu's embedded Vulkan surface is unsupported on this platform.");
|
||||
}
|
||||
|
||||
UpdateSurfaceSize();
|
||||
if (_surface is { } surface)
|
||||
{
|
||||
SurfaceAvailable?.Invoke(this, surface);
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
protected override void DestroyNativeControlCore(IPlatformHandle control)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
SetCursorAutoHide(false);
|
||||
}
|
||||
|
||||
var surface = _surface;
|
||||
_surface = null;
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
DestroyWin32();
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
DestroyX11();
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
DestroyMacOS();
|
||||
}
|
||||
|
||||
if (surface is not null)
|
||||
{
|
||||
SurfaceDestroyed?.Invoke(this, surface);
|
||||
}
|
||||
}
|
||||
|
||||
private PlatformHandle CreateWin32(IPlatformHandle control)
|
||||
{
|
||||
_win32ClassName = $"SharpEmuGameSurface-{Guid.NewGuid():N}";
|
||||
_windowProcedure = WindowProcedureImpl;
|
||||
var classInfo = new WndClassEx
|
||||
{
|
||||
Size = (uint)Marshal.SizeOf<WndClassEx>(),
|
||||
Style = CsOwnDc,
|
||||
WindowProcedure = Marshal.GetFunctionPointerForDelegate(_windowProcedure),
|
||||
Instance = GetModuleHandleW(null),
|
||||
ClassName = _win32ClassName,
|
||||
};
|
||||
|
||||
if (RegisterClassExW(ref classInfo) == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Could not register the embedded game window class (Win32 error {Marshal.GetLastWin32Error()}).");
|
||||
}
|
||||
|
||||
_windowHandle = CreateWindowExW(
|
||||
0,
|
||||
_win32ClassName,
|
||||
"SharpEmu Game Surface",
|
||||
WsChild | (_presentationVisible ? WsVisible : 0) | WsClipSiblings | WsClipChildren,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
control.Handle,
|
||||
0,
|
||||
classInfo.Instance,
|
||||
0);
|
||||
if (_windowHandle == 0)
|
||||
{
|
||||
var error = Marshal.GetLastWin32Error();
|
||||
_ = UnregisterClassW(_win32ClassName, classInfo.Instance);
|
||||
throw new InvalidOperationException($"Could not create the embedded game window (Win32 error {error}).");
|
||||
}
|
||||
|
||||
_surface = new VulkanHostSurface(
|
||||
VulkanHostSurfaceKind.Win32,
|
||||
_windowHandle,
|
||||
classInfo.Instance);
|
||||
return new PlatformHandle(_windowHandle, "HWND");
|
||||
}
|
||||
|
||||
private PlatformHandle CreateX11(IPlatformHandle control)
|
||||
{
|
||||
_x11Display = XOpenDisplay(0);
|
||||
if (_x11Display == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Could not connect to the X11 server for the embedded game surface.");
|
||||
}
|
||||
|
||||
_windowHandle = XCreateSimpleWindow(
|
||||
_x11Display,
|
||||
control.Handle,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0);
|
||||
if (_windowHandle == 0)
|
||||
{
|
||||
XCloseDisplay(_x11Display);
|
||||
_x11Display = 0;
|
||||
throw new InvalidOperationException("Could not create the X11 embedded game surface.");
|
||||
}
|
||||
|
||||
if (_presentationVisible)
|
||||
{
|
||||
_ = XMapWindow(_x11Display, _windowHandle);
|
||||
}
|
||||
_ = XFlush(_x11Display);
|
||||
_surface = new VulkanHostSurface(VulkanHostSurfaceKind.Xlib, _windowHandle, _x11Display);
|
||||
return new PlatformHandle(_windowHandle, "X11");
|
||||
}
|
||||
|
||||
private PlatformHandle CreateMacOS()
|
||||
{
|
||||
_metalLayer = CreateObjectiveCObject("CAMetalLayer");
|
||||
_windowHandle = CreateObjectiveCObject("NSView");
|
||||
SendBool(_windowHandle, "setWantsLayer:", true);
|
||||
SendPointer(_windowHandle, "setLayer:", _metalLayer);
|
||||
SendBool(_windowHandle, "setHidden:", !_presentationVisible);
|
||||
|
||||
_surface = new VulkanHostSurface(VulkanHostSurfaceKind.Metal, _windowHandle, metalLayerHandle: _metalLayer);
|
||||
return new PlatformHandle(_windowHandle, "NSView");
|
||||
}
|
||||
|
||||
private void UpdateSurfaceSize()
|
||||
{
|
||||
if (_surface is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var renderScale = (VisualRoot as TopLevel)?.RenderScaling ?? 1.0;
|
||||
var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale));
|
||||
var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale));
|
||||
var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height;
|
||||
_surface.UpdatePixelSize(width, height);
|
||||
|
||||
if (!sizeChanged)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows() && _windowHandle != 0)
|
||||
{
|
||||
_ = SetWindowPos(
|
||||
_windowHandle,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
SwpNoMove | SwpNoZOrder | SwpNoActivate);
|
||||
}
|
||||
else if (OperatingSystem.IsLinux() && _x11Display != 0 && _windowHandle != 0)
|
||||
{
|
||||
_ = XResizeWindow(_x11Display, _windowHandle, (uint)width, (uint)height);
|
||||
_ = XFlush(_x11Display);
|
||||
}
|
||||
else if (OperatingSystem.IsMacOS() && _metalLayer != 0)
|
||||
{
|
||||
SendDouble(_metalLayer, "setContentsScale:", renderScale);
|
||||
}
|
||||
}
|
||||
|
||||
private void DestroyWin32()
|
||||
{
|
||||
if (_windowHandle != 0)
|
||||
{
|
||||
_ = DestroyWindow(_windowHandle);
|
||||
_windowHandle = 0;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_win32ClassName))
|
||||
{
|
||||
_ = UnregisterClassW(_win32ClassName, GetModuleHandleW(null));
|
||||
_win32ClassName = null;
|
||||
}
|
||||
|
||||
_windowProcedure = null;
|
||||
}
|
||||
|
||||
private void DestroyX11()
|
||||
{
|
||||
if (_x11Display != 0 && _windowHandle != 0)
|
||||
{
|
||||
_ = XDestroyWindow(_x11Display, _windowHandle);
|
||||
}
|
||||
if (_x11Display != 0)
|
||||
{
|
||||
_ = XCloseDisplay(_x11Display);
|
||||
}
|
||||
|
||||
_windowHandle = 0;
|
||||
_x11Display = 0;
|
||||
}
|
||||
|
||||
private void DestroyMacOS()
|
||||
{
|
||||
if (_windowHandle != 0)
|
||||
{
|
||||
SendVoid(_windowHandle, "release");
|
||||
}
|
||||
if (_metalLayer != 0)
|
||||
{
|
||||
SendVoid(_metalLayer, "release");
|
||||
}
|
||||
|
||||
_windowHandle = 0;
|
||||
_metalLayer = 0;
|
||||
}
|
||||
|
||||
private nint WindowProcedureImpl(nint window, uint message, nint wParam, nint lParam)
|
||||
{
|
||||
if (message == WmMouseMove)
|
||||
{
|
||||
_lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
ShowCursorNow();
|
||||
}
|
||||
else if (message == WmSetCursor && _cursorHidden)
|
||||
{
|
||||
// Win32 re-resolves the cursor on every mouse message; returning
|
||||
// TRUE here keeps the parent chain from restoring the arrow.
|
||||
_ = SetCursor(0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
return DefWindowProcW(window, message, wParam, lParam);
|
||||
}
|
||||
|
||||
private static nint CreateObjectiveCObject(string className)
|
||||
{
|
||||
var classHandle = objc_getClass(className);
|
||||
if (classHandle == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Objective-C class '{className}' is unavailable.");
|
||||
}
|
||||
|
||||
var instance = objc_msgSend_id(classHandle, sel_registerName("alloc"));
|
||||
instance = objc_msgSend_id(instance, sel_registerName("init"));
|
||||
if (instance == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Could not create Objective-C '{className}'.");
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private static void SendVoid(nint receiver, string selector) =>
|
||||
objc_msgSend_void(receiver, sel_registerName(selector));
|
||||
|
||||
private static void SendBool(nint receiver, string selector, bool value) =>
|
||||
objc_msgSend_bool(receiver, sel_registerName(selector), value ? (byte)1 : (byte)0);
|
||||
|
||||
private static void SendPointer(nint receiver, string selector, nint value) =>
|
||||
objc_msgSend_pointer(receiver, sel_registerName(selector), value);
|
||||
|
||||
private static void SendDouble(nint receiver, string selector, double value) =>
|
||||
objc_msgSend_double(receiver, sel_registerName(selector), value);
|
||||
|
||||
private delegate nint WindowProcedure(nint window, uint message, nint wParam, nint lParam);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct WndClassEx
|
||||
{
|
||||
public uint Size;
|
||||
public uint Style;
|
||||
public nint WindowProcedure;
|
||||
public int ClassExtra;
|
||||
public int WindowExtra;
|
||||
public nint Instance;
|
||||
public nint Icon;
|
||||
public nint Cursor;
|
||||
public nint Background;
|
||||
public string? MenuName;
|
||||
public string? ClassName;
|
||||
public nint IconSmall;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", CharSet = CharSet.Unicode)]
|
||||
private static extern nint GetModuleHandleW(string? moduleName);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "RegisterClassExW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern ushort RegisterClassExW(ref WndClassEx classInfo);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "UnregisterClassW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool UnregisterClassW(string className, nint instance);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "CreateWindowExW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern nint CreateWindowExW(
|
||||
uint extendedStyle,
|
||||
string className,
|
||||
string windowName,
|
||||
uint style,
|
||||
int x,
|
||||
int y,
|
||||
int width,
|
||||
int height,
|
||||
nint parent,
|
||||
nint menu,
|
||||
nint instance,
|
||||
nint parameter);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "DestroyWindow", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DestroyWindow(nint window);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "SetWindowPos", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetWindowPos(
|
||||
nint window,
|
||||
nint insertAfter,
|
||||
int x,
|
||||
int y,
|
||||
int width,
|
||||
int height,
|
||||
uint flags);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "DefWindowProcW", CharSet = CharSet.Unicode)]
|
||||
private static extern nint DefWindowProcW(nint window, uint message, nint wParam, nint lParam);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "SetCursor")]
|
||||
private static extern nint SetCursor(nint cursor);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "LoadCursorW", CharSet = CharSet.Unicode)]
|
||||
private static extern nint LoadCursorW(nint instance, nint cursorName);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "GetCursorPos")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool GetCursorPos(out NativePoint point);
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "WindowFromPoint")]
|
||||
private static extern nint WindowFromPoint(NativePoint point);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct NativePoint
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
[DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
|
||||
private static extern nint XOpenDisplay(nint displayName);
|
||||
|
||||
[DllImport("libX11.so.6", EntryPoint = "XCreateSimpleWindow")]
|
||||
private static extern nint XCreateSimpleWindow(
|
||||
nint display,
|
||||
nint parent,
|
||||
int x,
|
||||
int y,
|
||||
uint width,
|
||||
uint height,
|
||||
uint borderWidth,
|
||||
ulong border,
|
||||
ulong background);
|
||||
|
||||
[DllImport("libX11.so.6", EntryPoint = "XMapWindow")]
|
||||
private static extern int XMapWindow(nint display, nint window);
|
||||
|
||||
[DllImport("libX11.so.6", EntryPoint = "XUnmapWindow")]
|
||||
private static extern int XUnmapWindow(nint display, nint window);
|
||||
|
||||
[DllImport("libX11.so.6", EntryPoint = "XResizeWindow")]
|
||||
private static extern int XResizeWindow(nint display, nint window, uint width, uint height);
|
||||
|
||||
[DllImport("libX11.so.6", EntryPoint = "XDestroyWindow")]
|
||||
private static extern int XDestroyWindow(nint display, nint window);
|
||||
|
||||
[DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
|
||||
private static extern int XCloseDisplay(nint display);
|
||||
|
||||
[DllImport("libX11.so.6", EntryPoint = "XFlush")]
|
||||
private static extern int XFlush(nint display);
|
||||
|
||||
[DllImport("/usr/lib/libobjc.A.dylib")]
|
||||
private static extern nint objc_getClass(string name);
|
||||
|
||||
[DllImport("/usr/lib/libobjc.A.dylib")]
|
||||
private static extern nint sel_registerName(string name);
|
||||
|
||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
||||
private static extern nint objc_msgSend_id(nint receiver, nint selector);
|
||||
|
||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
||||
private static extern void objc_msgSend_void(nint receiver, nint selector);
|
||||
|
||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
||||
private static extern void objc_msgSend_bool(nint receiver, nint selector, byte value);
|
||||
|
||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
||||
private static extern void objc_msgSend_pointer(nint receiver, nint selector, nint value);
|
||||
|
||||
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
|
||||
private static extern void objc_msgSend_double(nint receiver, nint selector, double value);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors process-wide console output into the launcher console while
|
||||
/// retaining the original streams for shell users and file logging.
|
||||
/// </summary>
|
||||
internal sealed class GuiConsoleMirror : IDisposable
|
||||
{
|
||||
private readonly TextWriter _originalOut;
|
||||
private readonly TextWriter _originalError;
|
||||
private int _disposed;
|
||||
|
||||
private GuiConsoleMirror(Action<string, bool> writeLine)
|
||||
{
|
||||
_originalOut = Console.Out;
|
||||
_originalError = Console.Error;
|
||||
Console.SetOut(new MirroringWriter(_originalOut, line => writeLine(line, false)));
|
||||
Console.SetError(new MirroringWriter(_originalError, line => writeLine(line, true)));
|
||||
}
|
||||
|
||||
public static GuiConsoleMirror Install(Action<string, bool> writeLine) => new(writeLine);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Console.SetOut(_originalOut);
|
||||
Console.SetError(_originalError);
|
||||
}
|
||||
|
||||
private sealed class MirroringWriter : TextWriter
|
||||
{
|
||||
private readonly TextWriter _inner;
|
||||
private readonly Action<string> _writeLine;
|
||||
private readonly StringBuilder _line = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
public MirroringWriter(TextWriter inner, Action<string> writeLine)
|
||||
{
|
||||
_inner = inner;
|
||||
_writeLine = writeLine;
|
||||
}
|
||||
|
||||
public override Encoding Encoding => _inner.Encoding;
|
||||
|
||||
public override void Write(char value)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_inner.Write(value);
|
||||
Append(value);
|
||||
}
|
||||
}
|
||||
|
||||
public override void Write(string? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_inner.Write(value);
|
||||
foreach (var character in value)
|
||||
{
|
||||
Append(character);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override void WriteLine(string? value)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_inner.WriteLine(value);
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
{
|
||||
_line.Append(value);
|
||||
}
|
||||
|
||||
FlushLine();
|
||||
}
|
||||
}
|
||||
|
||||
private void Append(char value)
|
||||
{
|
||||
if (value == '\r')
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (value == '\n')
|
||||
{
|
||||
FlushLine();
|
||||
return;
|
||||
}
|
||||
|
||||
_line.Append(value);
|
||||
}
|
||||
|
||||
private void FlushLine()
|
||||
{
|
||||
_writeLine(_line.ToString());
|
||||
_line.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="clr-namespace:SharpEmu.GUI"
|
||||
xmlns:primitives="clr-namespace:Avalonia.Controls.Primitives;assembly=Avalonia.Controls"
|
||||
x:Class="SharpEmu.GUI.MainWindow"
|
||||
Title="SharpEmu"
|
||||
Width="1280" Height="820"
|
||||
@@ -18,7 +20,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico"
|
||||
KeyDown="OnKeyDown">
|
||||
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<Grid x:Name="RootLayout" RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- Selected-game backdrop: key art behind the main content, dimmed by
|
||||
a scrim so tiles and text stay readable. Fades on selection. -->
|
||||
@@ -56,13 +58,19 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Grid>
|
||||
|
||||
<!-- Main content -->
|
||||
<Grid Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto">
|
||||
<Grid x:Name="MainContent" Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<!-- The game owns the full client area while running. Session controls
|
||||
use a native popup so they can stay above this native child surface. -->
|
||||
<Border x:Name="GameView" Grid.Row="0" Grid.RowSpan="4" IsVisible="False" Background="#000000" ClipToBounds="True">
|
||||
<Grid x:Name="GameSurfaceContainer" />
|
||||
</Border>
|
||||
|
||||
<!-- Library / Options page switcher, with the library toolbar sharing
|
||||
the same row on the right. Plain buttons (not TabItem) so there is
|
||||
no underline; LB/RB hint chips flank the pair and the gamepad's
|
||||
shoulder buttons actually switch pages from anywhere. -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,20">
|
||||
<Grid x:Name="ContentToolbar" Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,20">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
|
||||
<Border Classes="padHint" VerticalAlignment="Center">
|
||||
<TextBlock Text="LB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
|
||||
@@ -568,7 +576,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Border>
|
||||
|
||||
<!-- Launch bar; capped so it does not sprawl on a maximized window. -->
|
||||
<Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14" MaxWidth="1280">
|
||||
<Border Grid.Row="3" x:Name="LaunchBar" Classes="card" Margin="0,12,0,0" Padding="14" MaxWidth="1280">
|
||||
<StackPanel Spacing="14">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
|
||||
@@ -633,6 +641,66 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- Avalonia's regular overlay layer cannot appear over a native child
|
||||
HWND/X11/Metal surface. Keep the running-session controls in a native
|
||||
popup so the game reaches the bottom status bar without losing Stop. -->
|
||||
<primitives:Popup x:Name="SessionBarPopup"
|
||||
IsOpen="False"
|
||||
PlacementTarget="{Binding #GameView}"
|
||||
Placement="Bottom"
|
||||
VerticalOffset="-66"
|
||||
Topmost="True"
|
||||
ShouldUseOverlayLayer="False"
|
||||
TakesFocusFromNativeControl="False"
|
||||
IsLightDismissEnabled="False">
|
||||
<Border Classes="card" Width="598" Height="58" CornerRadius="16" Padding="14,8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Spacing="3" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock x:Name="SessionGameTitle" Text="GAME RUNNING" FontSize="13" FontWeight="SemiBold"
|
||||
MaxWidth="240" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<Border Classes="badge running" VerticalAlignment="Center">
|
||||
<TextBlock Text="RUNNING" FontSize="9" FontWeight="Bold" LetterSpacing="1"
|
||||
Foreground="{StaticResource SuccessBrush}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
<Border x:Name="SessionF11Badge" Classes="badge key" VerticalAlignment="Center">
|
||||
<TextBlock Text="F11" FontSize="9" FontWeight="Bold"
|
||||
Foreground="{StaticResource InfoBrush}" />
|
||||
</Border>
|
||||
<TextBlock x:Name="SessionHintText" Text="Fullscreen" FontSize="11"
|
||||
Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<Button x:Name="SessionConsoleButton" Classes="ghost" Content="≡ Console" />
|
||||
<Button x:Name="SessionStopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</primitives:Popup>
|
||||
|
||||
<!-- This is a native popup rather than an Avalonia overlay because the
|
||||
emulated Vulkan surface is a native child window. -->
|
||||
<primitives:Popup x:Name="SessionLoadingPopup"
|
||||
IsOpen="False"
|
||||
PlacementTarget="{Binding #GameView}"
|
||||
Placement="Center"
|
||||
Topmost="True"
|
||||
ShouldUseOverlayLayer="False"
|
||||
TakesFocusFromNativeControl="False"
|
||||
IsLightDismissEnabled="False">
|
||||
<Border Classes="card" Width="380" CornerRadius="18" Padding="22,18">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock x:Name="SessionLoadingTitle" Text="Loading game" FontSize="16" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="SessionLoadingDetail" Text="Preparing the emulation session..." FontSize="12"
|
||||
Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
|
||||
<ProgressBar IsIndeterminate="True" Height="5" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</primitives:Popup>
|
||||
|
||||
<!-- Status bar -->
|
||||
<Grid x:Name="StatusBar" Grid.Row="2" Height="32" Background="{StaticResource ChromeBrush}"
|
||||
ColumnDefinitions="*,Auto">
|
||||
|
||||
@@ -12,8 +12,11 @@ using Avalonia.Platform;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using SharpEmu.Core.Cpu;
|
||||
using SharpEmu.Core.Runtime;
|
||||
using SharpEmu.HLE.Host;
|
||||
using SharpEmu.HLE.Host.Windows;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using SharpEmu.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.ObjectModel;
|
||||
@@ -28,6 +31,8 @@ public partial class MainWindow : Window
|
||||
{
|
||||
private const int MaxConsoleLines = 4000;
|
||||
private const int MaxConsoleLinesPerFlush = 500;
|
||||
private const double LaunchBlurRadius = 12;
|
||||
private const double BlurTransitionSeconds = 0.24;
|
||||
|
||||
private static readonly IBrush DefaultLineBrush = new SolidColorBrush(Color.Parse("#C7CFDE"));
|
||||
private static readonly IBrush DimLineBrush = new SolidColorBrush(Color.Parse("#6B7488"));
|
||||
@@ -48,14 +53,26 @@ public partial class MainWindow : Window
|
||||
private readonly List<LogLine> _allConsoleLines = new();
|
||||
private readonly ConcurrentQueue<(string Line, bool IsError)> _pendingLines = new();
|
||||
private readonly DispatcherTimer _consoleFlushTimer;
|
||||
private readonly DispatcherTimer _libraryBlurTimer;
|
||||
private BlurEffect? _libraryBlur;
|
||||
private double _libraryBlurStartRadius;
|
||||
private double _libraryBlurTargetRadius;
|
||||
private long _libraryBlurStartedAt;
|
||||
private bool _clearLibraryBlurWhenComplete;
|
||||
|
||||
private GuiSettings _settings = new();
|
||||
private EmulatorProcess? _emulator;
|
||||
private GameSurfaceHost? _gameSurfaceHost;
|
||||
private ConsoleWindow? _consoleWindow;
|
||||
private GuiConsoleMirror? _consoleMirror;
|
||||
private StreamWriter? _fileLog;
|
||||
private readonly SndPreviewPlayer _sndPreview = new();
|
||||
private string? _emulatorExePath;
|
||||
private PendingLaunch? _pendingLaunch;
|
||||
private bool _gameFullscreen;
|
||||
private bool _isRunning;
|
||||
private bool _isStopping;
|
||||
private bool _awaitingFirstFrame;
|
||||
private int _autoScrollTicks;
|
||||
private int _activePageIndex;
|
||||
private Updater.UpdateInfo? _availableUpdate;
|
||||
@@ -83,12 +100,21 @@ public partial class MainWindow : Window
|
||||
private static readonly HttpClient GithubHttpClient = CreateGithubHttpClient();
|
||||
private string? _latestCommitSha;
|
||||
|
||||
private sealed record PendingLaunch(
|
||||
string EbootPath,
|
||||
string DisplayName,
|
||||
string? TitleId,
|
||||
SharpEmuRuntimeOptions RuntimeOptions);
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
GameList.ItemsSource = _visibleGames;
|
||||
ConsoleList.ItemsSource = _consoleLines;
|
||||
_consoleMirror = GuiConsoleMirror.Install((line, isError) =>
|
||||
_pendingLines.Enqueue((line, isError)));
|
||||
Closed += (_, _) => _emulator?.Stop();
|
||||
|
||||
_consoleFlushTimer = new DispatcherTimer
|
||||
{
|
||||
@@ -101,6 +127,15 @@ public partial class MainWindow : Window
|
||||
};
|
||||
_consoleFlushTimer.Start();
|
||||
|
||||
_libraryBlurTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(16),
|
||||
};
|
||||
_libraryBlurTimer.Tick += (_, _) => AdvanceLibraryBlur();
|
||||
|
||||
Activated += (_, _) => UpdateSessionBarVisibility();
|
||||
Deactivated += (_, _) => SessionBarPopup.IsOpen = false;
|
||||
|
||||
TitleBar.PointerPressed += OnTitleBarPointerPressed;
|
||||
GameList.SelectionChanged += (_, _) => UpdateSelectedGame();
|
||||
GameList.DoubleTapped += (_, _) => LaunchSelected();
|
||||
@@ -111,10 +146,10 @@ public partial class MainWindow : Window
|
||||
RescanButton.Click += async (_, _) => await RescanLibraryAsync();
|
||||
OpenFileButton.Click += async (_, _) => await OpenFileAsync();
|
||||
LaunchButton.Click += (_, _) => LaunchSelected();
|
||||
StopButton.Click += (_, _) => _emulator?.Stop();
|
||||
ClearLogButton.Click += (_, _) => { _consoleLines.Clear(); _allConsoleLines.Clear(); };
|
||||
StopButton.Click += (_, _) => StopEmulator();
|
||||
ClearLogButton.Click += (_, _) => _consoleLines.Clear();
|
||||
SessionStopButton.Click += (_, _) => StopEmulator();
|
||||
SessionConsoleButton.Click += (_, _) => ShowConsoleWindow();
|
||||
CopyLogButton.Click += async (_, _) => await CopyConsoleAsync();
|
||||
DetachConsoleButton.Click += (_, _) => ShowConsoleWindow();
|
||||
LibraryTabButton.Click += (_, _) => SetActivePage(0);
|
||||
@@ -162,6 +197,7 @@ public partial class MainWindow : Window
|
||||
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
|
||||
|
||||
GameList.AddHandler(ContextRequestedEvent, OnGameContextRequested, RoutingStrategies.Tunnel);
|
||||
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
|
||||
CtxLaunch.Click += (_, _) => LaunchSelected();
|
||||
CtxOpenFolder.Click += (_, _) => OpenSelectedGameFolder();
|
||||
CtxCopyPath.Click += async (_, _) =>
|
||||
@@ -683,14 +719,46 @@ public partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPreviewKeyDown(object? sender, KeyEventArgs args)
|
||||
{
|
||||
// While a session is on screen, Enter and Space are game input
|
||||
// (Cross button). Keyboard focus stays on the launcher window, so a
|
||||
// previously clicked, still-focused button (console toggle, session
|
||||
// bar) would also activate and reshape the game view. Swallow the
|
||||
// keys before button activation; the emulator process reads raw key
|
||||
// state and is unaffected. Fullscreen hides those buttons, which is
|
||||
// why this only manifested in windowed sessions.
|
||||
if (_isRunning && GameView.IsVisible &&
|
||||
args.Key is Key.Enter or Key.Space)
|
||||
{
|
||||
args.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnWindowFullScreen(object sender, RoutedEventArgs args)
|
||||
{
|
||||
if (WindowState == WindowState.FullScreen)
|
||||
{
|
||||
WindowState = WindowState.Normal;
|
||||
// Leaving F11 should restore a monitor-sized window with the
|
||||
// launcher chrome, not fall back to the design-time window size.
|
||||
WindowState = WindowState.Maximized;
|
||||
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.PreferSystemChrome;
|
||||
TitleBar.IsVisible = true;
|
||||
StatusBar.IsVisible = true;
|
||||
if (_gameFullscreen)
|
||||
{
|
||||
_gameFullscreen = false;
|
||||
Grid.SetRow(MainContent, 1);
|
||||
Grid.SetRowSpan(MainContent, 1);
|
||||
MainContent.Margin = _isRunning
|
||||
? new Thickness(0)
|
||||
: new Thickness(32, 24, 32, 20);
|
||||
ContentToolbar.IsVisible = !_isRunning;
|
||||
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
||||
LaunchBar.IsVisible = true;
|
||||
QueueGameSurfaceResize();
|
||||
UpdateSessionBarVisibility();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -698,18 +766,45 @@ public partial class MainWindow : Window
|
||||
ExtendClientAreaChromeHints = ExtendClientAreaChromeHints.NoChrome;
|
||||
TitleBar.IsVisible = false;
|
||||
StatusBar.IsVisible = false;
|
||||
if (_isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible)
|
||||
{
|
||||
// The native child receives its new physical Bounds as soon
|
||||
// as this grid spans the monitor. The presenter recreates its
|
||||
// swapchain from that size, rather than stretching 720p.
|
||||
_gameFullscreen = true;
|
||||
// Re-arming restarts the idle countdown, so the cursor also
|
||||
// hides a moment after F11 even without further mouse motion.
|
||||
_gameSurfaceHost?.SetCursorAutoHide(true);
|
||||
Grid.SetRow(MainContent, 0);
|
||||
Grid.SetRowSpan(MainContent, 3);
|
||||
MainContent.Margin = new Thickness(0);
|
||||
ContentToolbar.IsVisible = false;
|
||||
ConsolePanel.IsVisible = false;
|
||||
LaunchBar.IsVisible = false;
|
||||
QueueGameSurfaceResize();
|
||||
UpdateSessionBarVisibility();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void QueueGameSurfaceResize()
|
||||
{
|
||||
Dispatcher.UIThread.Post(
|
||||
() => _gameSurfaceHost?.RefreshSurfaceSize(),
|
||||
DispatcherPriority.Render);
|
||||
}
|
||||
|
||||
private void OnWindowClosing()
|
||||
{
|
||||
_settings.Save();
|
||||
_consoleFlushTimer.Stop();
|
||||
_libraryBlurTimer.Stop();
|
||||
_gamepadTimer.Stop();
|
||||
_sndPreview.Stop();
|
||||
_discord?.Dispose();
|
||||
_consoleWindow?.Close();
|
||||
_emulator?.Dispose();
|
||||
_consoleMirror?.Dispose();
|
||||
DropFileLog();
|
||||
}
|
||||
|
||||
@@ -896,8 +991,8 @@ public partial class MainWindow : Window
|
||||
candidates.Add(_settings.EmulatorPath);
|
||||
}
|
||||
|
||||
// The GUI and the CLI are the same executable: with arguments it runs
|
||||
// the emulator, so the preferred child process is this process itself.
|
||||
// The GUI and CLI share one executable. The selected path is the
|
||||
// isolated child executable and also defines the portable data root.
|
||||
if (Environment.ProcessPath is { } selfPath &&
|
||||
Path.GetFileNameWithoutExtension(selfPath).Equals("SharpEmu", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -1576,89 +1671,18 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (_emulatorExePath is null)
|
||||
{
|
||||
LocateEmulator();
|
||||
if (_emulatorExePath is null)
|
||||
{
|
||||
AppendConsoleLine(Localization.Instance.Get("Launch.ExeNotFound"), ErrorLineBrush);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_sndPreview.Stop();
|
||||
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"--cpu-engine=native",
|
||||
$"--log-level={_settings.LogLevel.ToLowerInvariant()}",
|
||||
};
|
||||
if (_settings.StrictDynlibResolution)
|
||||
{
|
||||
arguments.Add("--strict");
|
||||
}
|
||||
|
||||
if (_settings.ImportTraceLimit > 0)
|
||||
{
|
||||
arguments.Add($"--trace-imports={_settings.ImportTraceLimit}");
|
||||
}
|
||||
|
||||
_consoleLines.Clear();
|
||||
_allConsoleLines.Clear();
|
||||
|
||||
// Let the CLI mirror stdout/stderr itself; it sees loader/native
|
||||
// diagnostics before the GUI pipe reader can filter or batch them.
|
||||
DropFileLog();
|
||||
if (_settings.LogToFile)
|
||||
{
|
||||
string filePath;
|
||||
if (!string.IsNullOrWhiteSpace(_settings.LogFilePath))
|
||||
{
|
||||
if (_settings.OverrideLogFile)
|
||||
{
|
||||
filePath = _settings.LogFilePath;
|
||||
}
|
||||
else
|
||||
{
|
||||
string path = _settings.LogFilePath;
|
||||
string id = string.IsNullOrWhiteSpace(titleId) ? "UNKNOWN" : titleId;
|
||||
foreach (var invalidChar in Path.GetInvalidFileNameChars())
|
||||
{
|
||||
id = id.Replace(invalidChar.ToString(), "");
|
||||
}
|
||||
string identifier = $"{id}-{DateTime.Now:yyyyMMdd-HHmmss}";
|
||||
|
||||
string? dir = Path.GetDirectoryName(path);
|
||||
string? fileName = Path.GetFileNameWithoutExtension(path);
|
||||
string? extension = Path.GetExtension(path);
|
||||
|
||||
string newFileName = $"{fileName}-{identifier}{extension}";
|
||||
filePath = string.IsNullOrEmpty(dir)
|
||||
? newFileName
|
||||
: Path.Combine(dir, newFileName);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
filePath = BuildLogFilePath(titleId) ?? string.Empty;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filePath))
|
||||
{
|
||||
arguments.Add("--log-file");
|
||||
arguments.Add(filePath);
|
||||
AppendConsoleLine(Localization.Instance.Format("Launch.LogFile", filePath), DimLineBrush);
|
||||
}
|
||||
OpenFileLog(titleId);
|
||||
}
|
||||
|
||||
arguments.Add(ebootPath);
|
||||
|
||||
AppendConsoleLine(
|
||||
Localization.Instance.Format("Launch.Command", string.Join(' ', arguments)),
|
||||
DimLineBrush);
|
||||
|
||||
// Apply the enabled switches to this process; both emulator launch paths
|
||||
// (CreateProcessW and Process.Start) inherit it. Clear switches turned
|
||||
// off since the previous launch.
|
||||
// The isolated game child inherits these diagnostics. Keep them on the
|
||||
// launcher process so every platform receives the same launch options.
|
||||
foreach (var staleName in _appliedEnvironmentVariables)
|
||||
{
|
||||
if (!_settings.EnvironmentToggles.Contains(staleName))
|
||||
@@ -1674,34 +1698,41 @@ public partial class MainWindow : Window
|
||||
_appliedEnvironmentVariables.Add(name);
|
||||
}
|
||||
|
||||
var emulator = new EmulatorProcess();
|
||||
emulator.OutputReceived += (line, isError) => _pendingLines.Enqueue((line, isError));
|
||||
emulator.Exited += code => Dispatcher.UIThread.Post(() => OnEmulatorExited(code));
|
||||
|
||||
try
|
||||
if (SharpEmuLog.TryParseLevel(_settings.LogLevel, out var logLevel))
|
||||
{
|
||||
emulator.Start(_emulatorExePath, arguments, Path.GetDirectoryName(ebootPath));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
emulator.Dispose();
|
||||
AppendConsoleLine(Localization.Instance.Format("Launch.StartFailed", ex.Message), ErrorLineBrush);
|
||||
DropFileLog();
|
||||
return;
|
||||
SharpEmuLog.MinimumLevel = logLevel;
|
||||
}
|
||||
|
||||
_emulator = emulator;
|
||||
var runtimeOptions = new SharpEmuRuntimeOptions
|
||||
{
|
||||
CpuEngine = CpuExecutionEngine.NativeOnly,
|
||||
StrictDynlibResolution = _settings.StrictDynlibResolution,
|
||||
ImportTraceLimit = Math.Max(0, _settings.ImportTraceLimit),
|
||||
};
|
||||
|
||||
_isRunning = true;
|
||||
_runningGameName = displayName;
|
||||
_runningGameTitleId = _allGames
|
||||
.FirstOrDefault(game => game.Path.Equals(ebootPath, FilePathComparison))?
|
||||
.TitleId;
|
||||
SessionGameTitle.Text = displayName;
|
||||
_runningGameTitleId = titleId ?? _allGames
|
||||
.FirstOrDefault(game => game.Path.Equals(ebootPath, FilePathComparison))?.TitleId;
|
||||
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
StatusDot.Fill = SuccessLineBrush;
|
||||
StatusText.Text = Localization.Instance.Format("Launch.Running", displayName);
|
||||
StatusBarRight.Text = Localization.Instance.Format("Status.Running", displayName);
|
||||
UpdateRunButtons();
|
||||
UpdateDiscordPresence();
|
||||
|
||||
ShowGameView();
|
||||
_pendingLaunch = new PendingLaunch(
|
||||
Path.GetFullPath(ebootPath),
|
||||
displayName,
|
||||
_runningGameTitleId,
|
||||
runtimeOptions);
|
||||
|
||||
if (_gameSurfaceHost?.Surface is { } surface)
|
||||
{
|
||||
StartPendingSession(surface);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1713,17 +1744,35 @@ public partial class MainWindow : Window
|
||||
/// </summary>
|
||||
private void StopEmulator()
|
||||
{
|
||||
if (!_isRunning)
|
||||
if (!_isRunning || _isStopping)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_emulator?.Stop();
|
||||
if (_emulator is null)
|
||||
{
|
||||
// The native host can be created a moment after Launch. Do not
|
||||
// let that delayed callback start a session the user already
|
||||
// cancelled.
|
||||
_pendingLaunch = null;
|
||||
OnEmulatorExited(0);
|
||||
return;
|
||||
}
|
||||
|
||||
_isStopping = true;
|
||||
StopButton.IsEnabled = false;
|
||||
SessionStopButton.IsEnabled = false;
|
||||
SessionHintText.Text = Localization.Instance.Get("Launch.Stopping");
|
||||
SessionF11Badge.IsVisible = false;
|
||||
ShowSessionLoading("Closing game", "Waiting for the emulation session to exit...");
|
||||
_emulator.Stop();
|
||||
_runningGameName = null;
|
||||
_runningGameTitleId = null;
|
||||
StatusText.Text = Localization.Instance.Get("Launch.Stopping");
|
||||
StatusBarRight.Text = Localization.Instance.Get("Status.Stopping");
|
||||
UpdateDiscordPresence();
|
||||
UpdateSessionBarVisibility();
|
||||
ReturnToLibraryWhileStopping();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1734,7 +1783,7 @@ public partial class MainWindow : Window
|
||||
{
|
||||
try
|
||||
{
|
||||
var exeDirectory = Path.GetDirectoryName(_emulatorExePath);
|
||||
var exeDirectory = Path.GetDirectoryName(_emulatorExePath) ?? AppContext.BaseDirectory;
|
||||
if (string.IsNullOrEmpty(exeDirectory))
|
||||
{
|
||||
return null;
|
||||
@@ -1761,8 +1810,12 @@ public partial class MainWindow : Window
|
||||
{
|
||||
FlushPendingConsoleLines();
|
||||
_isRunning = false;
|
||||
_isStopping = false;
|
||||
_emulator?.Dispose();
|
||||
_emulator = null;
|
||||
_pendingLaunch = null;
|
||||
DisposeGameSurfaceHost();
|
||||
HideGameView();
|
||||
|
||||
var meaningKey = exitCode switch
|
||||
{
|
||||
@@ -1774,13 +1827,20 @@ public partial class MainWindow : Window
|
||||
-1073741819 => "Exit.EmulationError",
|
||||
_ => "Exit.Unknown",
|
||||
};
|
||||
var stoppedByUser = exitCode == EmulatorProcess.HostStopExitCode;
|
||||
var meaning = Localization.Instance.Get(meaningKey);
|
||||
var brush = exitCode == 0 ? SuccessLineBrush : ErrorLineBrush;
|
||||
AppendConsoleLine(Localization.Instance.Format("Launch.ProcessExited", exitCode, meaning), brush);
|
||||
var brush = exitCode == 0 || stoppedByUser ? SuccessLineBrush : ErrorLineBrush;
|
||||
AppendConsoleLine(
|
||||
stoppedByUser
|
||||
? "Game closed by the user."
|
||||
: Localization.Instance.Format("Launch.ProcessExited", exitCode, meaning),
|
||||
brush);
|
||||
CloseFileLogSoon();
|
||||
|
||||
StatusDot.Fill = exitCode == 0 ? (IBrush)SuccessLineBrush : ErrorLineBrush;
|
||||
StatusText.Text = Localization.Instance.Format("Launch.Exited", exitCode, meaning);
|
||||
StatusDot.Fill = exitCode == 0 || stoppedByUser ? (IBrush)SuccessLineBrush : ErrorLineBrush;
|
||||
StatusText.Text = stoppedByUser
|
||||
? "Game closed by the user."
|
||||
: Localization.Instance.Format("Launch.Exited", exitCode, meaning);
|
||||
StatusBarRight.Text = Localization.Instance.Get("Status.Idle");
|
||||
_runningGameName = null;
|
||||
_runningGameTitleId = null;
|
||||
@@ -1788,13 +1848,368 @@ public partial class MainWindow : Window
|
||||
UpdateDiscordPresence();
|
||||
}
|
||||
|
||||
private void StartPendingSession(VulkanHostSurface surface)
|
||||
{
|
||||
if (_pendingLaunch is not { } launch || _emulator is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_emulatorExePath))
|
||||
{
|
||||
AppendConsoleLine(Localization.Instance.Get("Launch.ExeNotFound"), ErrorLineBrush);
|
||||
OnEmulatorExited(3);
|
||||
return;
|
||||
}
|
||||
|
||||
var process = new EmulatorProcess();
|
||||
process.OutputReceived += OnEmulatorOutput;
|
||||
process.Exited += code => Dispatcher.UIThread.Post(() => OnEmulatorExited(code));
|
||||
|
||||
try
|
||||
{
|
||||
var arguments = BuildEmulatorArguments(launch, surface);
|
||||
_emulator = process;
|
||||
_pendingLaunch = null;
|
||||
process.Start(
|
||||
_emulatorExePath,
|
||||
arguments,
|
||||
Path.GetDirectoryName(_emulatorExePath));
|
||||
AppendConsoleLine(
|
||||
Localization.Instance.Format("Launch.Command", launch.EbootPath),
|
||||
DimLineBrush);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_emulator = null;
|
||||
process.Dispose();
|
||||
AppendConsoleLine(
|
||||
Localization.Instance.Format("Launch.StartFailed", exception.Message),
|
||||
ErrorLineBrush);
|
||||
OnEmulatorExited(3);
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> BuildEmulatorArguments(PendingLaunch launch, VulkanHostSurface surface)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"--cpu-engine=native",
|
||||
$"--log-level={_settings.LogLevel}",
|
||||
};
|
||||
if (launch.RuntimeOptions.StrictDynlibResolution)
|
||||
{
|
||||
arguments.Add("--strict");
|
||||
}
|
||||
if (launch.RuntimeOptions.ImportTraceLimit > 0)
|
||||
{
|
||||
arguments.Add($"--trace-imports={launch.RuntimeOptions.ImportTraceLimit}");
|
||||
}
|
||||
|
||||
if (surface.TryGetChildProcessDescriptor(out var descriptor))
|
||||
{
|
||||
arguments.Add($"--host-surface={descriptor}");
|
||||
}
|
||||
else
|
||||
{
|
||||
AppendConsoleLine(
|
||||
"[GUI][WARN] Embedded child surfaces are unavailable on this platform; opening a game window instead.",
|
||||
WarningLineBrush);
|
||||
}
|
||||
|
||||
arguments.Add(launch.EbootPath);
|
||||
return arguments;
|
||||
}
|
||||
|
||||
private void OnEmulatorOutput(string line, bool isError)
|
||||
{
|
||||
_pendingLines.Enqueue((line, isError));
|
||||
if (!line.Contains("[VIDEOOUT][INFO] Hosted splash ready.", StringComparison.Ordinal) &&
|
||||
!line.Contains("[VIDEOOUT][INFO] Hosted first frame presented.", StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (_isRunning && !_isStopping)
|
||||
{
|
||||
_awaitingFirstFrame = false;
|
||||
ClearLibraryBlur();
|
||||
MainContent.Margin = new Thickness(0);
|
||||
GameView.Background = Brushes.Black;
|
||||
GameView.IsHitTestVisible = true;
|
||||
_gameSurfaceHost?.SetPresentationVisible(true);
|
||||
_gameSurfaceHost?.SetCursorAutoHide(true);
|
||||
LibraryPage.IsVisible = false;
|
||||
OptionsPage.IsVisible = false;
|
||||
LibraryToolbar.IsVisible = false;
|
||||
ContentToolbar.IsVisible = false;
|
||||
ConsolePanel.IsVisible = false;
|
||||
LaunchBar.IsVisible = false;
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
UpdateSessionBarVisibility();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private GameSurfaceHost EnsureGameSurfaceHost()
|
||||
{
|
||||
if (_gameSurfaceHost is not null)
|
||||
{
|
||||
return _gameSurfaceHost;
|
||||
}
|
||||
|
||||
var host = new GameSurfaceHost();
|
||||
// Configure this before attaching it to Avalonia so its first native
|
||||
// HWND is hidden while the child process starts.
|
||||
host.SetPresentationVisible(false);
|
||||
host.SurfaceAvailable += (_, surface) =>
|
||||
{
|
||||
if (ReferenceEquals(_gameSurfaceHost, host))
|
||||
{
|
||||
StartPendingSession(surface);
|
||||
}
|
||||
};
|
||||
host.SurfaceDestroyed += (_, surface) => OnGameSurfaceDestroyed(host, surface);
|
||||
_gameSurfaceHost = host;
|
||||
GameSurfaceContainer.Children.Add(host);
|
||||
return host;
|
||||
}
|
||||
|
||||
private void DisposeGameSurfaceHost()
|
||||
{
|
||||
var host = _gameSurfaceHost;
|
||||
if (host is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_gameSurfaceHost = null;
|
||||
host.SetPresentationVisible(false);
|
||||
GameSurfaceContainer.Children.Remove(host);
|
||||
}
|
||||
|
||||
private void OnGameSurfaceDestroyed(GameSurfaceHost host, VulkanHostSurface surface)
|
||||
{
|
||||
if (ReferenceEquals(_gameSurfaceHost, host) && _isRunning)
|
||||
{
|
||||
StopEmulator();
|
||||
}
|
||||
}
|
||||
|
||||
private void ShowGameView()
|
||||
{
|
||||
_isStopping = false;
|
||||
_awaitingFirstFrame = true;
|
||||
var host = EnsureGameSurfaceHost();
|
||||
GameView.IsVisible = true;
|
||||
GameView.Background = Brushes.Transparent;
|
||||
GameView.IsHitTestVisible = false;
|
||||
host.SetPresentationVisible(false);
|
||||
AnimateLibraryBlur(LaunchBlurRadius);
|
||||
SessionHintText.Text = "Fullscreen";
|
||||
SessionF11Badge.IsVisible = true;
|
||||
UpdateSessionBarVisibility();
|
||||
ShowSessionLoading("Loading game", "Preparing the emulation session...");
|
||||
}
|
||||
|
||||
private void HideGameView()
|
||||
{
|
||||
if (_gameFullscreen && WindowState == WindowState.FullScreen)
|
||||
{
|
||||
OnWindowFullScreen(this, new RoutedEventArgs());
|
||||
}
|
||||
|
||||
_gameSurfaceHost?.SetCursorAutoHide(false);
|
||||
_gameSurfaceHost?.SetPresentationVisible(false);
|
||||
_awaitingFirstFrame = false;
|
||||
GameView.IsVisible = false;
|
||||
GameView.IsHitTestVisible = true;
|
||||
SessionBarPopup.IsOpen = false;
|
||||
SessionLoadingPopup.IsOpen = false;
|
||||
AnimateLibraryBlur(0, clearWhenComplete: true);
|
||||
MainContent.Margin = new Thickness(32, 24, 32, 20);
|
||||
ContentToolbar.IsVisible = true;
|
||||
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
||||
LaunchBar.IsVisible = true;
|
||||
LibraryPage.IsVisible = _activePageIndex == 0;
|
||||
LibraryToolbar.IsVisible = _activePageIndex == 0;
|
||||
OptionsPage.IsVisible = _activePageIndex == 1;
|
||||
if (GameList.SelectedItem is GameEntry game && game.Background is not null)
|
||||
{
|
||||
BackdropImage.Opacity = 1;
|
||||
}
|
||||
}
|
||||
|
||||
private void AnimateLibraryBlur(double targetRadius, bool clearWhenComplete = false)
|
||||
{
|
||||
_libraryBlur ??= new BlurEffect();
|
||||
MainContent.Effect = _libraryBlur;
|
||||
|
||||
_libraryBlurStartRadius = _libraryBlur.Radius;
|
||||
_libraryBlurTargetRadius = Math.Max(0, targetRadius);
|
||||
_libraryBlurStartedAt = Stopwatch.GetTimestamp();
|
||||
_clearLibraryBlurWhenComplete = clearWhenComplete && _libraryBlurTargetRadius == 0;
|
||||
|
||||
if (Math.Abs(_libraryBlurStartRadius - _libraryBlurTargetRadius) < 0.01)
|
||||
{
|
||||
CompleteLibraryBlur();
|
||||
return;
|
||||
}
|
||||
|
||||
_libraryBlurTimer.Start();
|
||||
}
|
||||
|
||||
private void AdvanceLibraryBlur()
|
||||
{
|
||||
if (_libraryBlur is null)
|
||||
{
|
||||
_libraryBlurTimer.Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
var elapsed = (Stopwatch.GetTimestamp() - _libraryBlurStartedAt) /
|
||||
(double)Stopwatch.Frequency;
|
||||
var progress = Math.Clamp(elapsed / BlurTransitionSeconds, 0, 1);
|
||||
// Cubic ease-out gives the loading transition a quick response while
|
||||
// keeping the final change of sharpness unobtrusive.
|
||||
var easedProgress = 1 - Math.Pow(1 - progress, 3);
|
||||
_libraryBlur.Radius = _libraryBlurStartRadius +
|
||||
((_libraryBlurTargetRadius - _libraryBlurStartRadius) * easedProgress);
|
||||
|
||||
if (progress >= 1)
|
||||
{
|
||||
CompleteLibraryBlur();
|
||||
}
|
||||
}
|
||||
|
||||
private void CompleteLibraryBlur()
|
||||
{
|
||||
_libraryBlurTimer.Stop();
|
||||
if (_libraryBlur is not null)
|
||||
{
|
||||
_libraryBlur.Radius = _libraryBlurTargetRadius;
|
||||
}
|
||||
|
||||
if (_clearLibraryBlurWhenComplete)
|
||||
{
|
||||
MainContent.Effect = null;
|
||||
_libraryBlur = null;
|
||||
_clearLibraryBlurWhenComplete = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearLibraryBlur()
|
||||
{
|
||||
_libraryBlurTimer.Stop();
|
||||
_libraryBlur = null;
|
||||
_clearLibraryBlurWhenComplete = false;
|
||||
MainContent.Effect = null;
|
||||
}
|
||||
|
||||
private void ShowSessionLoading(string title, string detail)
|
||||
{
|
||||
SessionLoadingTitle.Text = title;
|
||||
SessionLoadingDetail.Text = detail;
|
||||
SessionLoadingPopup.IsOpen = true;
|
||||
}
|
||||
|
||||
private void ReturnToLibraryWhileStopping()
|
||||
{
|
||||
if (_gameFullscreen && WindowState == WindowState.FullScreen)
|
||||
{
|
||||
OnWindowFullScreen(this, new RoutedEventArgs());
|
||||
}
|
||||
|
||||
// Keep the native child alive until the session exits, but hide it
|
||||
// immediately. Destroying it while Vulkan still owns the surface can
|
||||
// crash the GUI; leaving it transparent lets the library recover
|
||||
// while the native closing popup reports teardown progress.
|
||||
_gameSurfaceHost?.SetPresentationVisible(false);
|
||||
_awaitingFirstFrame = false;
|
||||
GameView.Background = Brushes.Transparent;
|
||||
GameView.IsHitTestVisible = false;
|
||||
SessionBarPopup.IsOpen = false;
|
||||
AnimateLibraryBlur(LaunchBlurRadius);
|
||||
MainContent.Margin = new Thickness(32, 24, 32, 20);
|
||||
ContentToolbar.IsVisible = true;
|
||||
ConsolePanel.IsVisible = ConsoleToggle.IsChecked == true && _consoleWindow is null;
|
||||
LaunchBar.IsVisible = true;
|
||||
LibraryPage.IsVisible = _activePageIndex == 0;
|
||||
LibraryToolbar.IsVisible = _activePageIndex == 0;
|
||||
OptionsPage.IsVisible = _activePageIndex == 1;
|
||||
BackdropImage.Opacity = GameList.SelectedItem is GameEntry { Background: not null } ? 1 : 0;
|
||||
UpdateRunButtons();
|
||||
Console.Error.WriteLine("[GUI][INFO] Library restored while embedded session is closing.");
|
||||
}
|
||||
|
||||
private void OpenFileLog(string? titleId)
|
||||
{
|
||||
var filePath = ResolveLogFilePath(titleId);
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
_fileLog = new StreamWriter(filePath, append: false) { AutoFlush = true };
|
||||
AppendConsoleLine(Localization.Instance.Format("Launch.LogFile", filePath), DimLineBrush);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
AppendConsoleLine($"[GUI][WARN] Could not open log file: {exception.Message}", WarningLineBrush);
|
||||
DropFileLog();
|
||||
}
|
||||
}
|
||||
|
||||
private string? ResolveLogFilePath(string? titleId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_settings.LogFilePath))
|
||||
{
|
||||
return BuildLogFilePath(titleId);
|
||||
}
|
||||
|
||||
if (_settings.OverrideLogFile)
|
||||
{
|
||||
return _settings.LogFilePath;
|
||||
}
|
||||
|
||||
var path = _settings.LogFilePath;
|
||||
var id = string.IsNullOrWhiteSpace(titleId) ? "UNKNOWN" : titleId;
|
||||
foreach (var invalid in Path.GetInvalidFileNameChars())
|
||||
{
|
||||
id = id.Replace(invalid.ToString(), string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
var filename = Path.GetFileNameWithoutExtension(path);
|
||||
var extension = Path.GetExtension(path);
|
||||
var timestampedName = $"{filename}-{id}-{DateTime.Now:yyyyMMdd-HHmmss}{extension}";
|
||||
return string.IsNullOrEmpty(directory) ? timestampedName : Path.Combine(directory, timestampedName);
|
||||
}
|
||||
|
||||
private void UpdateRunButtons()
|
||||
{
|
||||
LaunchButton.IsEnabled = !_isRunning && GameList.SelectedItem is GameEntry;
|
||||
StopButton.IsEnabled = _isRunning;
|
||||
StopButton.IsEnabled = _isRunning && !_isStopping;
|
||||
SessionStopButton.IsEnabled = _isRunning && !_isStopping;
|
||||
OpenFileButton.IsEnabled = !_isRunning;
|
||||
}
|
||||
|
||||
private void UpdateSessionBarVisibility()
|
||||
{
|
||||
SessionBarPopup.IsOpen = _isRunning && !_isStopping && !_awaitingFirstFrame && GameView.IsVisible &&
|
||||
!_gameFullscreen && WindowState != WindowState.FullScreen;
|
||||
}
|
||||
|
||||
// ---- Console ----
|
||||
|
||||
private void FlushPendingConsoleLines()
|
||||
|
||||
@@ -17,6 +17,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<!-- Dependency-free; provides the BuildInfo provenance shown in the
|
||||
title bar. -->
|
||||
<ItemGroup>
|
||||
<!-- The GUI owns the native presentation control while each game runs in
|
||||
an isolated emulator process. -->
|
||||
<ProjectReference Include="..\SharpEmu.Core\SharpEmu.Core.csproj" />
|
||||
<ProjectReference Include="..\SharpEmu.Libs\SharpEmu.Libs.csproj" />
|
||||
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -41,18 +45,4 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<LogicalName>Languages.%(Filename)%(Extension)</LogicalName>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
<!-- The controller readers (DualSense raw HID + Xbox XInput) are shared
|
||||
with the emulator's host input backend. They are dependency-free, so
|
||||
they are compiled in directly rather than pulling a reference to all
|
||||
of SharpEmu.HLE into the launcher. -->
|
||||
<ItemGroup>
|
||||
<Compile Include="..\SharpEmu.HLE\Host\HostGamepadState.cs" Link="Input/HostGamepadState.cs" />
|
||||
<Compile Include="..\SharpEmu.HLE\Host\Windows\WindowsHidNative.cs" Link="Input/WindowsHidNative.cs" />
|
||||
<Compile Include="..\SharpEmu.HLE\Host\Windows\WindowsDualSenseReader.cs" Link="Input/WindowsDualSenseReader.cs" />
|
||||
<Compile Include="..\SharpEmu.HLE\Host\Windows\WindowsXInputReader.cs" Link="Input/WindowsXInputReader.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -69,11 +69,118 @@ public sealed class PosixHostInput : IHostInput
|
||||
{
|
||||
// GLFW only delivers key events to the focused window, so a
|
||||
// delivering keyboard implies focus.
|
||||
return _source?.HasKeyboardFocus ?? false;
|
||||
return _source?.HasKeyboardFocus ?? IsEmbeddedX11WindowFocused();
|
||||
}
|
||||
|
||||
public bool IsKeyDown(int virtualKey)
|
||||
{
|
||||
return _source?.IsKeyDown(virtualKey) ?? false;
|
||||
var source = _source;
|
||||
if (source is not null)
|
||||
{
|
||||
return source.IsKeyDown(virtualKey);
|
||||
}
|
||||
|
||||
return IsEmbeddedX11WindowFocused() && IsEmbeddedX11KeyDown(virtualKey);
|
||||
}
|
||||
|
||||
private static bool IsEmbeddedX11WindowFocused()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var display = HostSessionControl.EmbeddedHostDisplay;
|
||||
var window = HostSessionControl.EmbeddedHostWindow;
|
||||
if (display == 0 || window == 0 || XGetInputFocus(display, out var focusedWindow, out _) == 0 || focusedWindow == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return GetTopLevelWindow(display, focusedWindow) == GetTopLevelWindow(display, window);
|
||||
}
|
||||
|
||||
private static bool IsEmbeddedX11KeyDown(int virtualKey)
|
||||
{
|
||||
var display = HostSessionControl.EmbeddedHostDisplay;
|
||||
var keysym = ToX11Keysym(virtualKey);
|
||||
if (display == 0 || keysym == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var keycode = XKeysymToKeycode(display, keysym);
|
||||
if (keycode == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var keymap = new byte[32];
|
||||
XQueryKeymap(display, keymap);
|
||||
return (keymap[keycode >> 3] & (1 << (keycode & 7))) != 0;
|
||||
}
|
||||
|
||||
private static nint GetTopLevelWindow(nint display, nint window)
|
||||
{
|
||||
var current = window;
|
||||
for (var depth = 0; depth < 16; depth++)
|
||||
{
|
||||
if (XQueryTree(display, current, out var root, out var parent, out var children, out _) == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (children != 0)
|
||||
{
|
||||
XFree(children);
|
||||
}
|
||||
|
||||
if (parent == 0 || parent == root)
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static nuint ToX11Keysym(int virtualKey)
|
||||
{
|
||||
return virtualKey switch
|
||||
{
|
||||
0x08 => 0xFF08, // Backspace
|
||||
0x09 => 0xFF09, // Tab
|
||||
0x0D => 0xFF0D, // Return
|
||||
0x1B => 0xFF1B, // Escape
|
||||
0x25 => 0xFF51, // Left
|
||||
0x26 => 0xFF52, // Up
|
||||
0x27 => 0xFF53, // Right
|
||||
0x28 => 0xFF54, // Down
|
||||
>= 0x41 and <= 0x5A => (nuint)virtualKey,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
||||
private static extern int XGetInputFocus(nint display, out nint focus, out int revertTo);
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
||||
private static extern int XQueryKeymap(nint display, [System.Runtime.InteropServices.Out] byte[] keysReturn);
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
||||
private static extern byte XKeysymToKeycode(nint display, nuint keysym);
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
||||
private static extern int XQueryTree(
|
||||
nint display,
|
||||
nint window,
|
||||
out nint root,
|
||||
out nint parent,
|
||||
out nint children,
|
||||
out uint childCount);
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
|
||||
private static extern int XFree(nint data);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace SharpEmu.HLE.Host.Windows;
|
||||
/// Supports USB (input report 0x01) and Bluetooth (extended report 0x31,
|
||||
/// activated by requesting feature report 0x05), with hot-plug retry.
|
||||
/// </summary>
|
||||
internal static class WindowsDualSenseReader
|
||||
public static class WindowsDualSenseReader
|
||||
{
|
||||
private const ushort SonyVendorId = 0x054C;
|
||||
private const ushort DualSenseProductId = 0x0CE6;
|
||||
@@ -35,7 +35,7 @@ internal static class WindowsDualSenseReader
|
||||
private static byte _playerLeds = 0x04; // center LED = player 1
|
||||
|
||||
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
|
||||
internal static void EnsureStarted()
|
||||
public static void EnsureStarted()
|
||||
{
|
||||
// The GUI source-links this reader and calls it directly, without the
|
||||
// host-platform resolution that otherwise guarantees Windows.
|
||||
@@ -61,7 +61,7 @@ internal static class WindowsDualSenseReader
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryGetState(out HostGamepadState state)
|
||||
public static bool TryGetState(out HostGamepadState state)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
|
||||
@@ -67,7 +67,19 @@ internal sealed partial class WindowsHostInput : IHostInput
|
||||
}
|
||||
|
||||
GetWindowThreadProcessId(foregroundWindow, out var processId);
|
||||
return processId == (uint)Environment.ProcessId;
|
||||
if (processId == (uint)Environment.ProcessId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// The GUI runs the emulator in an isolated child process. Its native
|
||||
// Vulkan surface is a child of the GUI window, so the foreground
|
||||
// window belongs to the launcher process rather than this one.
|
||||
var embeddedHostWindow = HostSessionControl.EmbeddedHostWindow;
|
||||
var hostTopLevelWindow = embeddedHostWindow == 0
|
||||
? 0
|
||||
: GetAncestor(embeddedHostWindow, GetAncestorRoot);
|
||||
return hostTopLevelWindow != 0 && foregroundWindow == hostTopLevelWindow;
|
||||
}
|
||||
|
||||
public bool IsKeyDown(int virtualKey) =>
|
||||
@@ -81,4 +93,9 @@ internal sealed partial class WindowsHostInput : IHostInput
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
private static partial uint GetWindowThreadProcessId(nint hWnd, out uint processId);
|
||||
|
||||
[LibraryImport("user32.dll")]
|
||||
private static partial nint GetAncestor(nint hWnd, uint gaFlags);
|
||||
|
||||
private const uint GetAncestorRoot = 2;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace SharpEmu.HLE.Host.Windows;
|
||||
/// <see cref="HostGamepadState"/> conventions. Supports rumble and hot-plug
|
||||
/// retry; the first connected slot (of four) is used.
|
||||
/// </summary>
|
||||
internal static partial class WindowsXInputReader
|
||||
public static partial class WindowsXInputReader
|
||||
{
|
||||
private const uint ErrorSuccess = 0;
|
||||
private const int SlotCount = 4;
|
||||
@@ -43,7 +43,7 @@ internal static partial class WindowsXInputReader
|
||||
private static byte _triggerRight;
|
||||
|
||||
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
|
||||
internal static void EnsureStarted()
|
||||
public static void EnsureStarted()
|
||||
{
|
||||
// The GUI source-links this reader and calls it directly, without the
|
||||
// host-platform resolution that otherwise guarantees Windows.
|
||||
@@ -69,7 +69,7 @@ internal static partial class WindowsXInputReader
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryGetState(out HostGamepadState state)
|
||||
public static bool TryGetState(out HostGamepadState state)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
|
||||
@@ -10,17 +10,88 @@ namespace SharpEmu.HLE;
|
||||
public static class HostSessionControl
|
||||
{
|
||||
private static Action<string>? _shutdownHandler;
|
||||
private static string? _pendingShutdownReason;
|
||||
private static int _shutdownRequested;
|
||||
private static long _embeddedHostWindow;
|
||||
private static long _embeddedHostDisplay;
|
||||
|
||||
/// <summary>
|
||||
/// Indicates that the active host session is being stopped. Runtime code
|
||||
/// uses this to skip expensive post-exit diagnostics before returning the
|
||||
/// GUI to its library.
|
||||
/// </summary>
|
||||
public static bool IsShutdownRequested => Volatile.Read(ref _shutdownRequested) != 0;
|
||||
|
||||
/// <summary>
|
||||
/// Native GUI surface used by an isolated emulator child. Input backends
|
||||
/// use it to treat the launcher window as the active game window.
|
||||
/// </summary>
|
||||
public static nint EmbeddedHostWindow => unchecked((nint)Interlocked.Read(ref _embeddedHostWindow));
|
||||
|
||||
/// <summary>X11 Display* paired with <see cref="EmbeddedHostWindow"/> when available.</summary>
|
||||
public static nint EmbeddedHostDisplay => unchecked((nint)Interlocked.Read(ref _embeddedHostDisplay));
|
||||
|
||||
public static void SetEmbeddedHostSurface(nint window, nint display = 0)
|
||||
{
|
||||
Interlocked.Exchange(ref _embeddedHostDisplay, unchecked((long)display));
|
||||
Interlocked.Exchange(ref _embeddedHostWindow, unchecked((long)window));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a fresh session after the previous guest has fully left its
|
||||
/// execution backend.
|
||||
/// </summary>
|
||||
public static void ResetShutdownRequest()
|
||||
{
|
||||
Interlocked.Exchange(ref _pendingShutdownReason, null);
|
||||
Volatile.Write(ref _shutdownRequested, 0);
|
||||
}
|
||||
|
||||
public static void SetShutdownHandler(Action<string>? handler)
|
||||
{
|
||||
Volatile.Write(ref _shutdownHandler, handler);
|
||||
if (handler is null)
|
||||
{
|
||||
Interlocked.Exchange(ref _pendingShutdownReason, null);
|
||||
return;
|
||||
}
|
||||
|
||||
var pendingReason = Interlocked.Exchange(ref _pendingShutdownReason, null);
|
||||
if (pendingReason is not null)
|
||||
{
|
||||
Invoke(handler, pendingReason);
|
||||
}
|
||||
}
|
||||
|
||||
public static void RequestShutdown(string reason)
|
||||
{
|
||||
Volatile.Write(ref _shutdownRequested, 1);
|
||||
var handler = Volatile.Read(ref _shutdownHandler);
|
||||
if (handler is not null)
|
||||
{
|
||||
Invoke(handler, reason);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop can be pressed while the GUI session is starting. Retain the
|
||||
// request until the native backend installs its cooperative handler.
|
||||
Volatile.Write(ref _pendingShutdownReason, reason);
|
||||
handler = Volatile.Read(ref _shutdownHandler);
|
||||
if (handler is not null)
|
||||
{
|
||||
var pendingReason = Interlocked.Exchange(ref _pendingShutdownReason, null);
|
||||
if (pendingReason is not null)
|
||||
{
|
||||
Invoke(handler, pendingReason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void Invoke(Action<string> handler, string reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
Volatile.Read(ref _shutdownHandler)?.Invoke(reason);
|
||||
handler(reason);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
|
||||
@@ -3729,13 +3729,16 @@ public static partial class AgcExports
|
||||
_labelProducers.Add(producer);
|
||||
}
|
||||
|
||||
foreach (var waiting in GpuWaitRegistry.SnapshotInRange(memory, address, length))
|
||||
if (_traceAgc)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.wait_producer_scheduled label=0x{waiting.Address:X16} " +
|
||||
$"waiters={waiting.Count} producer_seq={producer.Sequence} " +
|
||||
$"queue={producer.QueueName} submission={producer.SubmissionId} " +
|
||||
$"packet=0x{packetAddress:X16} action='{debugName}'");
|
||||
foreach (var waiting in GpuWaitRegistry.SnapshotInRange(memory, address, length))
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.wait_producer_scheduled label=0x{waiting.Address:X16} " +
|
||||
$"waiters={waiting.Count} producer_seq={producer.Sequence} " +
|
||||
$"queue={producer.QueueName} submission={producer.SubmissionId} " +
|
||||
$"packet=0x{packetAddress:X16} action='{debugName}'");
|
||||
}
|
||||
}
|
||||
|
||||
return producer;
|
||||
@@ -3753,16 +3756,19 @@ public static partial class AgcExports
|
||||
producer.Completed = true;
|
||||
}
|
||||
|
||||
foreach (var waiting in GpuWaitRegistry.SnapshotInRange(
|
||||
producer.Memory,
|
||||
producer.Address,
|
||||
producer.Length))
|
||||
if (_traceAgc)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.wait_producer_completed label=0x{waiting.Address:X16} " +
|
||||
$"waiters={waiting.Count} producer_seq={producer.Sequence} " +
|
||||
$"queue={producer.QueueName} submission={producer.SubmissionId} " +
|
||||
$"action='{producer.DebugName}'");
|
||||
foreach (var waiting in GpuWaitRegistry.SnapshotInRange(
|
||||
producer.Memory,
|
||||
producer.Address,
|
||||
producer.Length))
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.wait_producer_completed label=0x{waiting.Address:X16} " +
|
||||
$"waiters={waiting.Count} producer_seq={producer.Sequence} " +
|
||||
$"queue={producer.QueueName} submission={producer.SubmissionId} " +
|
||||
$"action='{producer.DebugName}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3807,6 +3813,14 @@ public static partial class AgcExports
|
||||
}
|
||||
}
|
||||
|
||||
// Producer-backed waits are trace-only. Keep the producer lookup above
|
||||
// because producerless waits are always warned, but do not build the
|
||||
// detailed condition strings when AGC tracing is disabled.
|
||||
if (producer is not null && !_traceAgc)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var prefix = stale ? "agc.wait_stale" : "agc.wait_suspended";
|
||||
var current = currentValue.HasValue
|
||||
? $"0x{currentValue.Value:X16}"
|
||||
@@ -5937,13 +5951,14 @@ public static partial class AgcExports
|
||||
// Every bound color target the shader exports to. Deferred renderers
|
||||
// draw a multi-render-target G-buffer (up to eight slots) in one pass.
|
||||
// Fall back to slot 0 if we cannot match any export to a bound target.
|
||||
var pixelColorExportMasks = pixelState.Program.PixelColorExportMasks;
|
||||
var allBoundTargets = GetRenderTargets(state.CxRegisters);
|
||||
// At most 8 slots; a manual filter avoids the per-draw LINQ iterator/
|
||||
// closure allocations. Slots are distinct, so sorting by slot is stable.
|
||||
var selectedTargets = new List<RenderTargetDescriptor>(allBoundTargets.Count);
|
||||
foreach (var target in allBoundTargets)
|
||||
{
|
||||
if (HasPixelColorExport(pixelState, target.Slot))
|
||||
if (GetPixelColorExportMask(pixelColorExportMasks, target.Slot) != 0)
|
||||
{
|
||||
selectedTargets.Add(target);
|
||||
}
|
||||
@@ -5966,7 +5981,7 @@ public static partial class AgcExports
|
||||
{
|
||||
TraceAgcShader(
|
||||
$"agc.mrt_filter ps=0x{pixelShaderAddress:X16} " +
|
||||
$"bound=[{string.Join(",", allBoundTargets.Select(t => $"s{t.Slot}:0x{t.Address:X}:exp{(HasPixelColorExport(pixelState, t.Slot) ? 1 : 0)}"))}] " +
|
||||
$"bound=[{string.Join(",", allBoundTargets.Select(t => $"s{t.Slot}:0x{t.Address:X}:exp{(GetPixelColorExportMask(pixelColorExportMasks, t.Slot) != 0 ? 1 : 0)}"))}] " +
|
||||
$"kept={renderTargets.Length}");
|
||||
}
|
||||
|
||||
@@ -6087,54 +6102,39 @@ public static partial class AgcExports
|
||||
_graphicsShaderCache.TryAdd(shaderKey, compiled);
|
||||
}
|
||||
|
||||
var imageBindings = pixelEvaluation.ImageBindings
|
||||
.Concat(exportEvaluation.ImageBindings);
|
||||
var textures = new List<TranslatedImageBinding>(
|
||||
pixelEvaluation.ImageBindings.Count +
|
||||
exportEvaluation.ImageBindings.Count);
|
||||
foreach (var binding in imageBindings)
|
||||
if (!TryAppendTranslatedImageBindings(
|
||||
pixelEvaluation.ImageBindings,
|
||||
textures,
|
||||
pixelShaderAddress,
|
||||
exportShaderAddress,
|
||||
out error) ||
|
||||
!TryAppendTranslatedImageBindings(
|
||||
exportEvaluation.ImageBindings,
|
||||
textures,
|
||||
pixelShaderAddress,
|
||||
exportShaderAddress,
|
||||
out error))
|
||||
{
|
||||
if (!TryDecodeTextureDescriptor(binding.ResourceDescriptor, out var texture))
|
||||
{
|
||||
// A garbage/zeroed texture descriptor (from a per-draw descriptor
|
||||
// setup race — the same root as scalar-load-failed) would drop
|
||||
// the whole draw, so Demon's Souls' deferred-lighting/composite
|
||||
// passes that produce the composite's feeder targets never run
|
||||
// and the frame stays black. Substitute a 1x1 fallback binding so
|
||||
// the pass still renders (that one sampled texture reads black)
|
||||
// rather than dropping it entirely. STRICT reverts.
|
||||
if (_strictShaderDescriptors)
|
||||
{
|
||||
error = $"invalid texture descriptor at pc=0x{binding.Pc:X}";
|
||||
ReturnPooledEvaluationArrays(exportEvaluation);
|
||||
ReturnPooledEvaluationArrays(pixelEvaluation);
|
||||
return false;
|
||||
}
|
||||
|
||||
texture = new TextureDescriptor(
|
||||
0, 1, 1, Gen5TextureFormatR8G8B8A8Unorm, 0, 0, 0, 0, 0, 1, 0xFAC);
|
||||
}
|
||||
|
||||
if (_traceAgcShader || _tracePixelShaderAddress == pixelShaderAddress)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][TRACE] " +
|
||||
$"agc.texture_binding ps=0x{pixelShaderAddress:X16} es=0x{exportShaderAddress:X16} " +
|
||||
$"pc=0x{binding.Pc:X} op={binding.Opcode} storage={(Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode) ? 1 : 0)} " +
|
||||
$"decoded={FormatTextureDescriptor(texture)} " +
|
||||
$"raw={FormatShaderDwords(binding.ResourceDescriptor)} sampler={FormatShaderDwords(binding.SamplerDescriptor)}");
|
||||
}
|
||||
textures.Add(
|
||||
new TranslatedImageBinding(
|
||||
texture,
|
||||
Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode),
|
||||
binding.MipLevel ?? 0,
|
||||
binding.SamplerDescriptor));
|
||||
ReturnPooledEvaluationArrays(exportEvaluation);
|
||||
ReturnPooledEvaluationArrays(pixelEvaluation);
|
||||
return false;
|
||||
}
|
||||
|
||||
var globalMemoryBindings = pixelEvaluation.GlobalMemoryBindings
|
||||
.Concat(exportEvaluation.GlobalMemoryBindings)
|
||||
.ToArray();
|
||||
var globalMemoryBindings = new Gen5GlobalMemoryBinding[
|
||||
pixelEvaluation.GlobalMemoryBindings.Count +
|
||||
exportEvaluation.GlobalMemoryBindings.Count];
|
||||
for (var index = 0; index < pixelEvaluation.GlobalMemoryBindings.Count; index++)
|
||||
{
|
||||
globalMemoryBindings[index] = pixelEvaluation.GlobalMemoryBindings[index];
|
||||
}
|
||||
for (var index = 0; index < exportEvaluation.GlobalMemoryBindings.Count; index++)
|
||||
{
|
||||
globalMemoryBindings[pixelEvaluation.GlobalMemoryBindings.Count + index] =
|
||||
exportEvaluation.GlobalMemoryBindings[index];
|
||||
}
|
||||
IReadOnlyList<Gen5VertexInputBinding> vertexInputs =
|
||||
exportEvaluation.VertexInputs ?? [];
|
||||
state.UcRegisters.TryGetValue(VgtPrimitiveType, out var primitiveType);
|
||||
@@ -6149,6 +6149,13 @@ public static partial class AgcExports
|
||||
renderTargets[index].NumberType);
|
||||
}
|
||||
|
||||
var pixelUserDataCount = Math.Min(pixelEvaluation.InitialScalarRegisters.Count, 8);
|
||||
var pixelUserData = new uint[pixelUserDataCount];
|
||||
for (var index = 0; index < pixelUserDataCount; index++)
|
||||
{
|
||||
pixelUserData[index] = pixelEvaluation.InitialScalarRegisters[index];
|
||||
}
|
||||
|
||||
draw = new TranslatedGuestDraw(
|
||||
exportShaderAddress,
|
||||
pixelShaderAddress,
|
||||
@@ -6166,11 +6173,11 @@ public static partial class AgcExports
|
||||
DecodeDepthTarget(state.CxRegisters),
|
||||
guestTargets,
|
||||
ApplyTransparentPremultipliedFillClear(
|
||||
CreateRenderState(state.CxRegisters, renderTargets, pixelState),
|
||||
CreateRenderState(state.CxRegisters, renderTargets, pixelColorExportMasks),
|
||||
textures,
|
||||
vertexInputs,
|
||||
pixelEvaluation.InitialScalarRegisters),
|
||||
pixelEvaluation.InitialScalarRegisters.Take(8).ToArray(),
|
||||
pixelUserData,
|
||||
state.CxRegisters.TryGetValue(CbBlend0Control, out var rawBlend) ? rawBlend : 0,
|
||||
state.CxRegisters.TryGetValue(
|
||||
CbColor0Info + renderTargets.FirstOrDefault().Slot * CbColorRegisterStride,
|
||||
@@ -6182,6 +6189,55 @@ public static partial class AgcExports
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryAppendTranslatedImageBindings(
|
||||
IReadOnlyList<Gen5ImageBinding> bindings,
|
||||
List<TranslatedImageBinding> textures,
|
||||
ulong pixelShaderAddress,
|
||||
ulong exportShaderAddress,
|
||||
out string error)
|
||||
{
|
||||
foreach (var binding in bindings)
|
||||
{
|
||||
if (!TryDecodeTextureDescriptor(binding.ResourceDescriptor, out var texture))
|
||||
{
|
||||
// A garbage/zeroed texture descriptor (from a per-draw descriptor
|
||||
// setup race — the same root as scalar-load-failed) would drop
|
||||
// the whole draw, so deferred-lighting/composite passes that
|
||||
// produce the composite's feeder targets never run. Keep the
|
||||
// existing 1x1 fallback unless strict diagnostics are requested.
|
||||
if (_strictShaderDescriptors)
|
||||
{
|
||||
error = $"invalid texture descriptor at pc=0x{binding.Pc:X}";
|
||||
return false;
|
||||
}
|
||||
|
||||
texture = new TextureDescriptor(
|
||||
0, 1, 1, Gen5TextureFormatR8G8B8A8Unorm, 0, 0, 0, 0, 0, 1, 0xFAC);
|
||||
}
|
||||
|
||||
var isStorage =
|
||||
Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode);
|
||||
if (_traceAgcShader || _tracePixelShaderAddress == pixelShaderAddress)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][TRACE] " +
|
||||
$"agc.texture_binding ps=0x{pixelShaderAddress:X16} es=0x{exportShaderAddress:X16} " +
|
||||
$"pc=0x{binding.Pc:X} op={binding.Opcode} storage={(isStorage ? 1 : 0)} " +
|
||||
$"decoded={FormatTextureDescriptor(texture)} " +
|
||||
$"raw={FormatShaderDwords(binding.ResourceDescriptor)} sampler={FormatShaderDwords(binding.SamplerDescriptor)}");
|
||||
}
|
||||
textures.Add(
|
||||
new TranslatedImageBinding(
|
||||
texture,
|
||||
isStorage,
|
||||
binding.MipLevel ?? 0,
|
||||
binding.SamplerDescriptor));
|
||||
}
|
||||
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int _tracedAstroTitlePixelGlobals;
|
||||
private static int _tracedAstroTitlePixelGlobalProbe;
|
||||
|
||||
@@ -6410,26 +6466,10 @@ public static partial class AgcExports
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasPixelColorExport(Gen5ShaderState state, uint target) =>
|
||||
GetPixelColorExportMask(state, target) != 0;
|
||||
|
||||
private static uint GetPixelColorExportMask(Gen5ShaderState state, uint target)
|
||||
{
|
||||
// Called per render target (twice per draw via CreateRenderState +
|
||||
// HasPixelColorExport); a manual scan avoids the per-call LINQ iterator
|
||||
// and closure allocations. Same result as the previous
|
||||
// Select/OfType/Where/Aggregate chain.
|
||||
var mask = 0u;
|
||||
foreach (var instruction in state.Program.Instructions)
|
||||
{
|
||||
if (instruction.Control is Gen5ExportControl export && export.Target == target)
|
||||
{
|
||||
mask |= export.EnableMask & 0xFu;
|
||||
}
|
||||
}
|
||||
|
||||
return mask;
|
||||
}
|
||||
private static uint GetPixelColorExportMask(uint packedMasks, uint target) =>
|
||||
target < ColorTargetCount
|
||||
? (packedMasks >> (int)(target * 4)) & 0xFu
|
||||
: 0;
|
||||
|
||||
private static uint GetInterpolatedAttributeCount(Gen5ShaderState state)
|
||||
{
|
||||
@@ -6627,7 +6667,7 @@ public static partial class AgcExports
|
||||
private static GuestRenderState CreateRenderState(
|
||||
IReadOnlyDictionary<uint, uint> registers,
|
||||
IReadOnlyList<RenderTargetDescriptor> targets,
|
||||
Gen5ShaderState pixelState)
|
||||
uint pixelColorExportMasks)
|
||||
{
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
@@ -6636,15 +6676,21 @@ public static partial class AgcExports
|
||||
|
||||
var target = targets[0];
|
||||
var scissor = DecodeScissor(registers, target.Width, target.Height);
|
||||
return new GuestRenderState(
|
||||
targets.Select(target =>
|
||||
var blends = new GuestBlendState[targets.Count];
|
||||
for (var index = 0; index < targets.Count; index++)
|
||||
{
|
||||
var blend = DecodeBlendState(registers, targets[index].Slot);
|
||||
blends[index] = blend with
|
||||
{
|
||||
var blend = DecodeBlendState(registers, target.Slot);
|
||||
return blend with
|
||||
{
|
||||
WriteMask = blend.WriteMask & GetPixelColorExportMask(pixelState, target.Slot),
|
||||
};
|
||||
}).ToArray(),
|
||||
WriteMask = blend.WriteMask &
|
||||
GetPixelColorExportMask(
|
||||
pixelColorExportMasks,
|
||||
targets[index].Slot),
|
||||
};
|
||||
}
|
||||
|
||||
return new GuestRenderState(
|
||||
blends,
|
||||
scissor,
|
||||
DecodeViewport(registers, target.Width, target.Height, scissor),
|
||||
DecodeRasterState(registers),
|
||||
|
||||
@@ -52,8 +52,19 @@ internal static class AudioPcmConversion
|
||||
}
|
||||
|
||||
var bits = BinaryPrimitives.ReadInt32LittleEndian(sample);
|
||||
var value = Math.Clamp(BitConverter.Int32BitsToSingle(bits), -1.0f, 1.0f);
|
||||
return checked((short)MathF.Round(value * short.MaxValue));
|
||||
return ConvertFloatSample(BitConverter.Int32BitsToSingle(bits));
|
||||
}
|
||||
|
||||
private static short ConvertFloatSample(float value)
|
||||
{
|
||||
if (float.IsNaN(value))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
value = Math.Clamp(value, -1.0f, 1.0f);
|
||||
var scale = value < 0.0f ? 32768.0f : short.MaxValue;
|
||||
return checked((short)MathF.Round(value * scale));
|
||||
}
|
||||
|
||||
private static short ApplyVolume(short sample, float volume)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Libs.ContentExport;
|
||||
|
||||
// No host media library exists to export captures into, so initialization
|
||||
// reports success.
|
||||
public static class ContentExportExports
|
||||
{
|
||||
[SysAbiExport(
|
||||
Nid = "0GnN4QCgIfs",
|
||||
ExportName = "sceContentExportInit2",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceContentExport")]
|
||||
public static int ContentExportInit2(CpuContext ctx) => ctx.SetReturn(0);
|
||||
}
|
||||
@@ -74,6 +74,85 @@ public static class FontExports
|
||||
public static int CreateRendererWithEdition(CpuContext ctx) =>
|
||||
CreateOpaqueHandle(ctx, ctx[CpuRegister.Rcx], 0x100, magic: 0x0F07);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "3OdRkSjOcog",
|
||||
ExportName = "sceFontBindRenderer",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int BindRenderer(CpuContext ctx) => SetSuccess(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "N1EBMeGhf7E",
|
||||
ExportName = "sceFontSetScalePixel",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int SetScalePixel(CpuContext ctx) => SetSuccess(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "TMtqoFQjjbA",
|
||||
ExportName = "sceFontSetEffectSlant",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int SetEffectSlant(CpuContext ctx) => SetSuccess(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "v0phZwa4R5o",
|
||||
ExportName = "sceFontSetEffectWeight",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int SetEffectWeight(CpuContext ctx) => SetSuccess(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "6vGCkkQJOcI",
|
||||
ExportName = "sceFontSetupRenderScalePixel",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int SetupRenderScalePixel(CpuContext ctx) => SetSuccess(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "lz9y9UFO2UU",
|
||||
ExportName = "sceFontSetupRenderEffectSlant",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int SetupRenderEffectSlant(CpuContext ctx) => SetSuccess(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "XIGorvLusDQ",
|
||||
ExportName = "sceFontSetupRenderEffectWeight",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int SetupRenderEffectWeight(CpuContext ctx) => SetSuccess(ctx);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "imxVx8lm+KM",
|
||||
ExportName = "sceFontGetHorizontalLayout",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int GetHorizontalLayout(CpuContext ctx)
|
||||
{
|
||||
var layoutAddress = ctx[CpuRegister.Rsi];
|
||||
if (layoutAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
// Baseline, line advance, decoration extent: the same invented geometry
|
||||
// as GetRenderCharGlyphMetrics.
|
||||
var values = new[] { 12.0f, 16.0f, 0.0f };
|
||||
for (var index = 0; index < values.Length; index++)
|
||||
{
|
||||
if (!TryWriteUInt32(
|
||||
ctx,
|
||||
layoutAddress + (ulong)(index * sizeof(float)),
|
||||
BitConverter.SingleToUInt32Bits(values[index])))
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
}
|
||||
|
||||
return SetSuccess(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "cKYtVmeSTcw",
|
||||
ExportName = "sceFontOpenFontSet",
|
||||
|
||||
@@ -77,7 +77,10 @@ public static class KernelSemaphoreCompatExports
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
TraceSemaphore($"create handle=0x{handle:X8} name='{name}' attr=0x{attr:X} init={initialCount} max={maxCount}");
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"create handle=0x{handle:X8} name='{name}' attr=0x{attr:X} init={initialCount} max={maxCount}");
|
||||
}
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
@@ -118,7 +121,10 @@ public static class KernelSemaphoreCompatExports
|
||||
_ = TryWriteUInt32(ctx, timeoutAddress, timeoutUsec);
|
||||
}
|
||||
|
||||
TraceSemaphore($"wait handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)}");
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"wait handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)}");
|
||||
}
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
@@ -158,7 +164,10 @@ public static class KernelSemaphoreCompatExports
|
||||
|
||||
if (acquired)
|
||||
{
|
||||
TraceSemaphore($"wait-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"wait-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
||||
}
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
@@ -167,7 +176,10 @@ public static class KernelSemaphoreCompatExports
|
||||
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
|
||||
}
|
||||
|
||||
TraceSemaphore($"wait-timeout handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"wait-timeout handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
||||
}
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
|
||||
}
|
||||
|
||||
@@ -179,7 +191,10 @@ public static class KernelSemaphoreCompatExports
|
||||
WakePredicate,
|
||||
deadline))
|
||||
{
|
||||
TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
|
||||
}
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
@@ -201,9 +216,12 @@ public static class KernelSemaphoreCompatExports
|
||||
: long.MaxValue;
|
||||
lock (semaphore.Gate)
|
||||
{
|
||||
TraceSemaphore(
|
||||
$"wait-host-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} " +
|
||||
$"count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} {FormatCallSite(ctx)}");
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore(
|
||||
$"wait-host-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} " +
|
||||
$"count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} {FormatCallSite(ctx)}");
|
||||
}
|
||||
while (semaphore.Count < needCount)
|
||||
{
|
||||
var remaining = deadlineMs - Environment.TickCount64;
|
||||
@@ -219,8 +237,11 @@ public static class KernelSemaphoreCompatExports
|
||||
|
||||
semaphore.Count -= needCount;
|
||||
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
|
||||
TraceSemaphore(
|
||||
$"wait-host-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore(
|
||||
$"wait-host-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
|
||||
}
|
||||
if (timeoutAddress != 0)
|
||||
{
|
||||
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
|
||||
@@ -253,12 +274,18 @@ public static class KernelSemaphoreCompatExports
|
||||
{
|
||||
if (semaphore.Count < needCount)
|
||||
{
|
||||
TraceSemaphore($"poll-busy handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"poll-busy handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
||||
}
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
|
||||
}
|
||||
|
||||
semaphore.Count -= needCount;
|
||||
TraceSemaphore($"poll handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"poll handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
|
||||
}
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
}
|
||||
@@ -290,7 +317,10 @@ public static class KernelSemaphoreCompatExports
|
||||
semaphore.Count += signalCount;
|
||||
// Wake host-thread waiters parked in the fallback path.
|
||||
Monitor.PulseAll(semaphore.Gate);
|
||||
TraceSemaphore($"signal handle=0x{handle:X8} name='{semaphore.Name}' signal={signalCount} count={semaphore.Count} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"signal handle=0x{handle:X8} name='{semaphore.Name}' signal={signalCount} count={semaphore.Count} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
|
||||
}
|
||||
}
|
||||
|
||||
// Wake cooperatively-blocked guest threads; their wake predicate
|
||||
@@ -326,7 +356,10 @@ public static class KernelSemaphoreCompatExports
|
||||
semaphore.Count = setCount < 0 ? semaphore.InitialCount : setCount;
|
||||
semaphore.WaitingThreads = 0;
|
||||
Monitor.PulseAll(semaphore.Gate);
|
||||
TraceSemaphore($"cancel handle=0x{handle:X8} name='{semaphore.Name}' set={setCount} count={semaphore.Count}");
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"cancel handle=0x{handle:X8} name='{semaphore.Name}' set={setCount} count={semaphore.Count}");
|
||||
}
|
||||
}
|
||||
|
||||
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetSemaphoreWakeKey(handle));
|
||||
@@ -346,7 +379,10 @@ public static class KernelSemaphoreCompatExports
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
|
||||
}
|
||||
|
||||
TraceSemaphore($"delete handle=0x{handle:X8} name='{semaphore.Name}'");
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"delete handle=0x{handle:X8} name='{semaphore.Name}'");
|
||||
}
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
@@ -385,7 +421,10 @@ public static class KernelSemaphoreCompatExports
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
TraceSemaphore($"posix-init address=0x{semaphoreAddress:X16} handle=0x{handle:X8} count={initialCount}");
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"posix-init address=0x{semaphoreAddress:X16} handle=0x{handle:X8} count={initialCount}");
|
||||
}
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
@@ -582,6 +621,11 @@ public static class KernelSemaphoreCompatExports
|
||||
|
||||
private static void TraceSemaphore(string message)
|
||||
{
|
||||
if (!_traceSema)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"[LOADER][TRACE] sema.{message}");
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,19 @@ public static class PadExports
|
||||
: ctx.SetReturn(OrbisPadErrorInvalidHandle);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "vDLMoJLde8I",
|
||||
ExportName = "scePadSetTiltCorrectionState",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libScePad")]
|
||||
public static int PadSetTiltCorrectionState(CpuContext ctx)
|
||||
{
|
||||
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
return IsPrimaryPadHandle(handle)
|
||||
? ctx.SetReturn(0)
|
||||
: ctx.SetReturn(OrbisPadErrorInvalidHandle);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "gjP9-KQzoUk",
|
||||
ExportName = "scePadGetControllerInformation",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
@@ -14,6 +15,7 @@ public static class SaveDataExports
|
||||
private const int OrbisSaveDataErrorExists = unchecked((int)0x809F0007);
|
||||
private const int OrbisSaveDataErrorNotFound = unchecked((int)0x809F0008);
|
||||
private const int OrbisSaveDataErrorInternal = unchecked((int)0x809F000B);
|
||||
private const int OrbisSaveDataErrorMemoryNotReady = unchecked((int)0x809F0012);
|
||||
private const int SaveDataTitleIdSize = 10;
|
||||
private const int SaveDataDirNameSize = 32;
|
||||
private const int SaveDataParamSize = 0x530;
|
||||
@@ -29,7 +31,10 @@ public static class SaveDataExports
|
||||
private const uint MountModeCreate = 1u << 2;
|
||||
private const uint MountModeCreate2 = 1u << 5;
|
||||
private const int MountResultSize = 0x40;
|
||||
// Emulator guard against corrupt or misread sizes, not a platform limit.
|
||||
private const ulong SaveDataMemoryMaxSize = 64UL * 1024 * 1024;
|
||||
private static readonly object _stateGate = new();
|
||||
private static readonly object _memoryGate = new();
|
||||
private static readonly HashSet<int> _preparedTransactionResources = [];
|
||||
private static string? _titleId;
|
||||
|
||||
@@ -472,6 +477,19 @@ public static class SaveDataExports
|
||||
private static string ResolveTitleSaveRoot(int userId, string titleId) =>
|
||||
Path.Combine(ResolveSaveDataRoot(), userId.ToString(), SanitizePathSegment(titleId));
|
||||
|
||||
private static string ResolveSaveDataMemoryPath(int userId) =>
|
||||
Path.Combine(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId()), "sce_sdmemory", "memory.dat");
|
||||
|
||||
private static bool TryReadMemoryData(
|
||||
CpuContext ctx, ulong address, out ulong buffer, out ulong size, out ulong offset)
|
||||
{
|
||||
size = 0;
|
||||
offset = 0;
|
||||
return ctx.TryReadUInt64(address, out buffer) &&
|
||||
ctx.TryReadUInt64(address + 0x08, out size) &&
|
||||
ctx.TryReadUInt64(address + 0x10, out offset);
|
||||
}
|
||||
|
||||
private static string ResolveSaveDataRoot()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
|
||||
@@ -670,4 +688,197 @@ public static class SaveDataExports
|
||||
TraceSaveData($"commit commit=0x{commitAddress:X16}");
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
// Save data memory: a small per-user blob titles read and write without
|
||||
// mounting anything, backed by one zero-filled file per user and title.
|
||||
[SysAbiExport(
|
||||
Nid = "oQySEUfgXRA",
|
||||
ExportName = "sceSaveDataSetupSaveDataMemory2",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataSetupSaveDataMemory2(CpuContext ctx)
|
||||
{
|
||||
var paramAddress = ctx[CpuRegister.Rdi];
|
||||
var resultAddress = ctx[CpuRegister.Rsi];
|
||||
if (paramAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
if (!TryReadInt32(ctx, paramAddress + 0x04, out var userId) ||
|
||||
!ctx.TryReadUInt64(paramAddress + 0x08, out var memorySize))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
if (userId < 0 || memorySize == 0 || memorySize > SaveDataMemoryMaxSize)
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var path = ResolveSaveDataMemoryPath(userId);
|
||||
lock (_memoryGate)
|
||||
{
|
||||
var backing = new FileInfo(path);
|
||||
var existedSize = backing.Exists ? (ulong)backing.Length : 0;
|
||||
|
||||
// The result write comes first so a faulted result pointer
|
||||
// cannot leave created or grown setup state behind.
|
||||
if (resultAddress != 0 && !ctx.TryWriteUInt64(resultAddress, existedSize))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
if (existedSize < memorySize)
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
using var stream = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
stream.SetLength((long)memorySize);
|
||||
}
|
||||
|
||||
TraceSaveData($"memory-setup2 user={userId} size=0x{memorySize:X} existed=0x{existedSize:X}");
|
||||
}
|
||||
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorInternal);
|
||||
}
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "QwOO7vegnV8",
|
||||
ExportName = "sceSaveDataGetSaveDataMemory2",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataGetSaveDataMemory2(CpuContext ctx) =>
|
||||
TransferSaveDataMemory(ctx, write: false);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "cduy9v4YmT4",
|
||||
ExportName = "sceSaveDataSetSaveDataMemory2",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataSetSaveDataMemory2(CpuContext ctx) =>
|
||||
TransferSaveDataMemory(ctx, write: true);
|
||||
|
||||
// Writes go straight through to the backing file, so a ready state is
|
||||
// all sync has to confirm.
|
||||
[SysAbiExport(
|
||||
Nid = "wiT9jeC7xPw",
|
||||
ExportName = "sceSaveDataSyncSaveDataMemory",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataSyncSaveDataMemory(CpuContext ctx)
|
||||
{
|
||||
var syncAddress = ctx[CpuRegister.Rdi];
|
||||
if (syncAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
if (!TryReadInt32(ctx, syncAddress, out var userId))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
if (userId < 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
return ctx.SetReturn(
|
||||
File.Exists(ResolveSaveDataMemoryPath(userId)) ? 0 : OrbisSaveDataErrorMemoryNotReady);
|
||||
}
|
||||
|
||||
private static int TransferSaveDataMemory(CpuContext ctx, bool write)
|
||||
{
|
||||
var requestAddress = ctx[CpuRegister.Rdi];
|
||||
if (requestAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
if (!TryReadInt32(ctx, requestAddress, out var userId) ||
|
||||
!ctx.TryReadUInt64(requestAddress + 0x08, out var dataAddress))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
if (userId < 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var path = ResolveSaveDataMemoryPath(userId);
|
||||
lock (_memoryGate)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorMemoryNotReady);
|
||||
}
|
||||
|
||||
if (dataAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
if (!TryReadMemoryData(ctx, dataAddress, out var bufAddress, out var bufSize, out var offset))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
using var stream = new FileStream(
|
||||
path, FileMode.Open, write ? FileAccess.ReadWrite : FileAccess.Read);
|
||||
var length = (ulong)stream.Length;
|
||||
if (bufAddress == 0 || bufSize > length || offset > length - bufSize)
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
// The guarded file length bounds bufSize, so one rented buffer
|
||||
// covers the transfer and a guest fault never partially writes.
|
||||
var buffer = ArrayPool<byte>.Shared.Rent((int)Math.Max(bufSize, 1));
|
||||
try
|
||||
{
|
||||
var span = buffer.AsSpan(0, (int)bufSize);
|
||||
stream.Seek((long)offset, SeekOrigin.Begin);
|
||||
if (write)
|
||||
{
|
||||
if (!ctx.Memory.TryRead(bufAddress, span))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
stream.Write(span);
|
||||
}
|
||||
else
|
||||
{
|
||||
stream.ReadExactly(span);
|
||||
if (!ctx.Memory.TryWrite(bufAddress, span))
|
||||
{
|
||||
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
|
||||
TraceSaveData(
|
||||
$"memory-{(write ? "set2" : "get2")} user={userId} offset=0x{offset:X} size=0x{bufSize:X}");
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return ctx.SetReturn(OrbisSaveDataErrorInternal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +154,10 @@ public static class PerfOverlay
|
||||
_lastGen2 = gen2;
|
||||
|
||||
var cpuTime = GetProcessCpuTime();
|
||||
_cpuPercent = (cpuTime - _lastCpuTime).TotalSeconds / seconds * 100.0;
|
||||
// Normalize across logical processors (Task Manager convention):
|
||||
// raw process time / wall time reads 100% per fully busy core.
|
||||
_cpuPercent = (cpuTime - _lastCpuTime).TotalSeconds / seconds * 100.0 /
|
||||
Environment.ProcessorCount;
|
||||
_lastCpuTime = cpuTime;
|
||||
|
||||
var drawsPerFrame = _fps > 0.5 ? _drawsPerSecond / _fps : 0;
|
||||
|
||||
@@ -156,15 +156,29 @@ public static class VideoOutExports
|
||||
private static void RequestHostShutdown(string reason)
|
||||
{
|
||||
Console.Error.WriteLine($"[LOADER][INFO] Host shutdown requested: {reason}");
|
||||
VulkanVideoPresenter.RequestClose();
|
||||
var embedded = VulkanVideoHost.IsEmbedded;
|
||||
AudioOutExports.ShutdownAllPorts();
|
||||
Interlocked.Exchange(ref _vblankStopRequested, 1);
|
||||
HostSessionControl.RequestShutdown(reason);
|
||||
ThreadPool.QueueUserWorkItem(static _ =>
|
||||
|
||||
// A hosted game can still be issuing AGC work after it requests its
|
||||
// own shutdown. Keep the Vulkan resources alive until the GUI session
|
||||
// reaches its guest-safe exit path and disposes the host surface.
|
||||
if (!embedded)
|
||||
{
|
||||
Thread.Sleep(2000);
|
||||
Environment.Exit(0);
|
||||
});
|
||||
VulkanVideoPresenter.RequestClose();
|
||||
}
|
||||
|
||||
// The embedded GUI owns the process lifetime. A guest shutdown should
|
||||
// end only that session rather than terminating the launcher itself.
|
||||
if (!embedded)
|
||||
{
|
||||
ThreadPool.QueueUserWorkItem(static _ =>
|
||||
{
|
||||
Thread.Sleep(2000);
|
||||
Environment.Exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class VideoOutPortState
|
||||
@@ -1646,8 +1660,12 @@ public static class VideoOutExports
|
||||
// Maps the PS5 VideoOut pixel format space to the AGC "guest texture format" tags
|
||||
// the backend keys its guest-image registry on (see VulkanVideoPresenter.
|
||||
// GetGuestTextureFormat: format=10 => 56 for 8-bit RGBA variants, format=9 => 9 for 10-bit).
|
||||
private static uint MapPixelFormatToGuestTextureFormat(ulong pixelFormat) =>
|
||||
NormalizePixelFormat(pixelFormat) switch
|
||||
// Unknown formats default to 56 (8-bit RGBA) with a logged warning so games
|
||||
// display something rather than silently failing the flip pipeline.
|
||||
private static uint MapPixelFormatToGuestTextureFormat(ulong pixelFormat)
|
||||
{
|
||||
var normalized = NormalizePixelFormat(pixelFormat);
|
||||
var result = normalized switch
|
||||
{
|
||||
SceVideoOutPixelFormatA8R8G8B8Srgb or
|
||||
SceVideoOutPixelFormatA8B8G8R8Srgb or
|
||||
@@ -1665,6 +1683,17 @@ public static class VideoOutExports
|
||||
_ => 0u,
|
||||
};
|
||||
|
||||
if (result == 0u)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] vk: unknown pixel format 0x{pixelFormat:X16} (normalized=0x{normalized:X16}) " +
|
||||
$"— falling back to format 56 (8-bit RGBA). Report this format to the project.");
|
||||
result = 56u;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static bool TryPackRgba8Pixel(
|
||||
ulong pixelFormat,
|
||||
byte red,
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Libs.VideoOut;
|
||||
|
||||
/// <summary>
|
||||
/// A native child surface owned by a host UI. The presenter consumes this
|
||||
/// directly, avoiding a second top-level GLFW window when the GUI is active.
|
||||
/// </summary>
|
||||
public enum VulkanHostSurfaceKind
|
||||
{
|
||||
Win32,
|
||||
Xlib,
|
||||
Metal,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Platform-native handles required to create a Vulkan presentation surface.
|
||||
/// The GUI owns their lifetime and updates the physical pixel size on resize.
|
||||
/// </summary>
|
||||
public sealed class VulkanHostSurface : IDisposable
|
||||
{
|
||||
private int _pixelWidth;
|
||||
private int _pixelHeight;
|
||||
private int _resizeGeneration;
|
||||
private readonly bool _ownsDisplay;
|
||||
private readonly bool _pollNativeSize;
|
||||
private long _nextNativeSizePoll;
|
||||
|
||||
public VulkanHostSurface(
|
||||
VulkanHostSurfaceKind kind,
|
||||
nint windowHandle,
|
||||
nint displayHandle = 0,
|
||||
nint metalLayerHandle = 0,
|
||||
bool ownsDisplay = false,
|
||||
bool pollNativeSize = false)
|
||||
{
|
||||
Kind = kind;
|
||||
WindowHandle = windowHandle;
|
||||
DisplayHandle = displayHandle;
|
||||
MetalLayerHandle = metalLayerHandle;
|
||||
_ownsDisplay = ownsDisplay;
|
||||
_pollNativeSize = pollNativeSize;
|
||||
}
|
||||
|
||||
public VulkanHostSurfaceKind Kind { get; }
|
||||
|
||||
public nint WindowHandle { get; }
|
||||
|
||||
/// <summary>X11 Display* when <see cref="Kind"/> is <see cref="VulkanHostSurfaceKind.Xlib"/>.</summary>
|
||||
public nint DisplayHandle { get; }
|
||||
|
||||
/// <summary>CAMetalLayer* when <see cref="Kind"/> is <see cref="VulkanHostSurfaceKind.Metal"/>.</summary>
|
||||
public nint MetalLayerHandle { get; }
|
||||
|
||||
public int PixelWidth => Volatile.Read(ref _pixelWidth);
|
||||
|
||||
public int PixelHeight => Volatile.Read(ref _pixelHeight);
|
||||
|
||||
internal int ResizeGeneration => Volatile.Read(ref _resizeGeneration);
|
||||
|
||||
public void UpdatePixelSize(int width, int height)
|
||||
{
|
||||
width = Math.Max(width, 1);
|
||||
height = Math.Max(height, 1);
|
||||
if (Volatile.Read(ref _pixelWidth) == width && Volatile.Read(ref _pixelHeight) == height)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Volatile.Write(ref _pixelWidth, width);
|
||||
Volatile.Write(ref _pixelHeight, height);
|
||||
Interlocked.Increment(ref _resizeGeneration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The child emulator cannot receive Avalonia resize notifications. Poll
|
||||
/// the native host at a bounded rate so embedded child swapchains still
|
||||
/// follow normal resize and F11 transitions.
|
||||
/// </summary>
|
||||
internal void RefreshChildProcessPixelSize()
|
||||
{
|
||||
if (!_pollNativeSize)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
var due = Volatile.Read(ref _nextNativeSizePoll);
|
||||
if (now < due || Interlocked.CompareExchange(
|
||||
ref _nextNativeSizePoll,
|
||||
now + (System.Diagnostics.Stopwatch.Frequency / 8),
|
||||
due) != due)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Kind == VulkanHostSurfaceKind.Win32 && GetClientRect(WindowHandle, out var rect))
|
||||
{
|
||||
UpdatePixelSize(rect.Right - rect.Left, rect.Bottom - rect.Top);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Kind == VulkanHostSurfaceKind.Xlib && DisplayHandle != 0 &&
|
||||
XGetGeometry(
|
||||
DisplayHandle,
|
||||
WindowHandle,
|
||||
out _,
|
||||
out _,
|
||||
out _,
|
||||
out var width,
|
||||
out var height,
|
||||
out _,
|
||||
out _) != 0)
|
||||
{
|
||||
UpdatePixelSize(unchecked((int)width), unchecked((int)height));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a native child handle for a separately hosted emulator
|
||||
/// process. Metal object pointers are process-local, so macOS falls back
|
||||
/// to a standalone child window until an IPC Metal host is implemented.
|
||||
/// </summary>
|
||||
public bool TryGetChildProcessDescriptor(out string descriptor)
|
||||
{
|
||||
descriptor = string.Empty;
|
||||
if (Kind == VulkanHostSurfaceKind.Metal || WindowHandle == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var kind = Kind == VulkanHostSurfaceKind.Win32 ? "win32" : "xlib";
|
||||
descriptor = string.Create(
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
$"{kind}:{unchecked((ulong)WindowHandle):X}:{Math.Max(PixelWidth, 1)}:{Math.Max(PixelHeight, 1)}:{unchecked((ulong)DisplayHandle):X}");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconstructs a surface in the isolated emulator process. X11 clients
|
||||
/// must open their own Display connection; Display* values cannot cross a
|
||||
/// process boundary.
|
||||
/// </summary>
|
||||
public static bool TryCreateChildProcessSurface(
|
||||
string descriptor,
|
||||
out VulkanHostSurface? surface,
|
||||
out string? error)
|
||||
{
|
||||
surface = null;
|
||||
error = null;
|
||||
var parts = descriptor.Split(':');
|
||||
if (parts.Length != 5 ||
|
||||
!ulong.TryParse(parts[1], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var window) ||
|
||||
!int.TryParse(parts[2], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var width) ||
|
||||
!int.TryParse(parts[3], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var height) ||
|
||||
!ulong.TryParse(parts[4], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var nativeDisplay))
|
||||
{
|
||||
error = "invalid host-surface descriptor";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (window == 0 || width <= 0 || height <= 0)
|
||||
{
|
||||
error = "host-surface descriptor has an invalid size or handle";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(parts[0], "win32", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
surface = new VulkanHostSurface(
|
||||
VulkanHostSurfaceKind.Win32,
|
||||
unchecked((nint)window),
|
||||
unchecked((nint)nativeDisplay),
|
||||
pollNativeSize: true);
|
||||
}
|
||||
else if (string.Equals(parts[0], "xlib", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var display = XOpenDisplay(0);
|
||||
if (display == 0)
|
||||
{
|
||||
error = "could not open an X11 display for the host surface";
|
||||
return false;
|
||||
}
|
||||
|
||||
surface = new VulkanHostSurface(
|
||||
VulkanHostSurfaceKind.Xlib,
|
||||
unchecked((nint)window),
|
||||
display,
|
||||
ownsDisplay: true,
|
||||
pollNativeSize: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
error = $"unsupported host-surface kind '{parts[0]}'";
|
||||
return false;
|
||||
}
|
||||
|
||||
surface.UpdatePixelSize(width, height);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsDisplay && DisplayHandle != 0 && OperatingSystem.IsLinux())
|
||||
{
|
||||
_ = XCloseDisplay(DisplayHandle);
|
||||
}
|
||||
}
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
|
||||
private static extern nint XOpenDisplay(nint displayName);
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
|
||||
private static extern int XCloseDisplay(nint display);
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetClientRect", SetLastError = true)]
|
||||
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
|
||||
private static extern bool GetClientRect(nint window, out Rect rect);
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XGetGeometry")]
|
||||
private static extern int XGetGeometry(
|
||||
nint display,
|
||||
nint drawable,
|
||||
out nint root,
|
||||
out int x,
|
||||
out int y,
|
||||
out uint width,
|
||||
out uint height,
|
||||
out uint borderWidth,
|
||||
out uint depth);
|
||||
|
||||
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
|
||||
private struct Rect
|
||||
{
|
||||
public int Left;
|
||||
public int Top;
|
||||
public int Right;
|
||||
public int Bottom;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Small public bridge between a desktop UI and the internal Vulkan
|
||||
/// presenter. Launchers can host a surface without depending on renderer
|
||||
/// submission internals.
|
||||
/// </summary>
|
||||
public static class VulkanVideoHost
|
||||
{
|
||||
/// <summary>
|
||||
/// Raised after the first successful Vulkan present to an embedded host
|
||||
/// surface. UI hosts use this to retire their launch affordance only once
|
||||
/// a real frame can be seen.
|
||||
/// </summary>
|
||||
public static event Action<VulkanHostSurface>? FirstFramePresented
|
||||
{
|
||||
add => VulkanVideoPresenter.FirstHostFramePresented += value;
|
||||
remove => VulkanVideoPresenter.FirstHostFramePresented -= value;
|
||||
}
|
||||
|
||||
public static bool TryAttachSurface(VulkanHostSurface surface) =>
|
||||
VulkanVideoPresenter.TryAttachHostSurface(surface);
|
||||
|
||||
public static void DetachSurface(VulkanHostSurface surface) =>
|
||||
VulkanVideoPresenter.DetachHostSurface(surface);
|
||||
|
||||
public static void RequestClose() => VulkanVideoPresenter.RequestClose();
|
||||
|
||||
public static bool IsEmbedded => VulkanVideoPresenter.UsesHostSurface;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -325,9 +325,31 @@ public sealed record Gen5ShaderProgram(
|
||||
ulong Address,
|
||||
IReadOnlyList<Gen5ShaderInstruction> Instructions)
|
||||
{
|
||||
private const uint PixelColorTargetCount = 8;
|
||||
private const int PixelColorMaskBits = 4;
|
||||
private readonly uint _pixelColorExportMasks = ComputePixelColorExportMasks(Instructions);
|
||||
private const int ScalarRegisterCount = 256;
|
||||
private IReadOnlySet<uint>? _runtimeScalarRegisters;
|
||||
|
||||
public uint PixelColorExportMasks => _pixelColorExportMasks;
|
||||
|
||||
private static uint ComputePixelColorExportMasks(
|
||||
IReadOnlyList<Gen5ShaderInstruction> instructions)
|
||||
{
|
||||
var masks = 0u;
|
||||
foreach (var instruction in instructions)
|
||||
{
|
||||
if (instruction.Control is Gen5ExportControl export &&
|
||||
export.Target < PixelColorTargetCount)
|
||||
{
|
||||
masks |= (export.EnableMask & 0xFu) <<
|
||||
(int)(export.Target * PixelColorMaskBits);
|
||||
}
|
||||
}
|
||||
|
||||
return masks;
|
||||
}
|
||||
|
||||
public IEnumerable<Gen5ImageControl> ImageResources =>
|
||||
Instructions
|
||||
.Select(instruction => instruction.Control)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.ShaderCompiler;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
public sealed class Gen5ShaderProgramTests
|
||||
{
|
||||
[Fact]
|
||||
public void PixelColorExportMasksPackAllColorTargets()
|
||||
{
|
||||
var program = new Gen5ShaderProgram(
|
||||
0,
|
||||
[
|
||||
Export(0, 0x1),
|
||||
Export(0, 0x4),
|
||||
Export(1, 0x2),
|
||||
Export(2, 0x3),
|
||||
Export(3, 0x4),
|
||||
Export(4, 0x5),
|
||||
Export(5, 0x6),
|
||||
Export(6, 0x7),
|
||||
Export(7, 0x8),
|
||||
Export(8, 0xF),
|
||||
]);
|
||||
|
||||
Assert.Equal(0x8765_4325u, program.PixelColorExportMasks);
|
||||
Assert.Equal(0x8765_4325u, program.PixelColorExportMasks);
|
||||
}
|
||||
|
||||
private static Gen5ShaderInstruction Export(uint target, uint enableMask) => new(
|
||||
0,
|
||||
Gen5ShaderEncoding.Exp,
|
||||
"exp",
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
new Gen5ExportControl(target, enableMask, false, false, false));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.Libs.Audio;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Audio;
|
||||
|
||||
public sealed class AudioPcmConversionTests
|
||||
{
|
||||
[Fact]
|
||||
public void FloatFullScaleMapsToSignedPcmEndpoints()
|
||||
{
|
||||
Span<byte> source = stackalloc byte[sizeof(float) * 2];
|
||||
WriteFloat(source, 0, -1.0f);
|
||||
WriteFloat(source, 1, 1.0f);
|
||||
Span<byte> destination = stackalloc byte[AudioPcmConversion.OutputFrameSize];
|
||||
|
||||
AudioPcmConversion.ConvertToStereoPcm16(
|
||||
source,
|
||||
destination,
|
||||
frames: 1,
|
||||
channels: 2,
|
||||
bytesPerSample: sizeof(float),
|
||||
isFloat: true,
|
||||
volume: 1.0f);
|
||||
|
||||
Assert.Equal(short.MinValue, BinaryPrimitives.ReadInt16LittleEndian(destination));
|
||||
Assert.Equal(short.MaxValue, BinaryPrimitives.ReadInt16LittleEndian(destination[2..]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FloatNaNMapsToSilence()
|
||||
{
|
||||
Span<byte> source = stackalloc byte[sizeof(float)];
|
||||
WriteFloat(source, 0, float.NaN);
|
||||
Span<byte> destination = stackalloc byte[AudioPcmConversion.OutputFrameSize];
|
||||
|
||||
AudioPcmConversion.ConvertToStereoPcm16(
|
||||
source,
|
||||
destination,
|
||||
frames: 1,
|
||||
channels: 1,
|
||||
bytesPerSample: sizeof(float),
|
||||
isFloat: true,
|
||||
volume: 1.0f);
|
||||
|
||||
Assert.Equal(0, BinaryPrimitives.ReadInt16LittleEndian(destination));
|
||||
Assert.Equal(0, BinaryPrimitives.ReadInt16LittleEndian(destination[2..]));
|
||||
}
|
||||
|
||||
private static void WriteFloat(Span<byte> destination, int sample, float value) =>
|
||||
BinaryPrimitives.WriteInt32LittleEndian(
|
||||
destination[(sample * sizeof(float))..],
|
||||
BitConverter.SingleToInt32Bits(value));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Font;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Font;
|
||||
|
||||
public sealed class FontExportsTests
|
||||
{
|
||||
private const ulong Base = 0x1_0000_0000;
|
||||
private const ulong LayoutAddress = Base + 0x100;
|
||||
|
||||
private readonly FakeCpuMemory _memory = new(Base, 0x1000);
|
||||
private readonly CpuContext _ctx;
|
||||
|
||||
public FontExportsTests()
|
||||
{
|
||||
_ctx = new CpuContext(_memory, Generation.Gen5);
|
||||
}
|
||||
|
||||
// SceFontHorizontalLayout is three floats; the sentinel directly after
|
||||
// them must survive the call.
|
||||
[Fact]
|
||||
public void GetHorizontalLayout_WritesExactlyThreeFloats()
|
||||
{
|
||||
const uint Sentinel = 0xDEADBEEF;
|
||||
Span<byte> sentinelBytes = stackalloc byte[sizeof(uint)];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(sentinelBytes, Sentinel);
|
||||
Assert.True(_ctx.Memory.TryWrite(LayoutAddress + 12, sentinelBytes));
|
||||
|
||||
_ctx[CpuRegister.Rsi] = LayoutAddress;
|
||||
Assert.Equal(0, FontExports.GetHorizontalLayout(_ctx));
|
||||
|
||||
Span<byte> layout = stackalloc byte[16];
|
||||
Assert.True(_ctx.Memory.TryRead(LayoutAddress, layout));
|
||||
Assert.Equal(12.0f, BinaryPrimitives.ReadSingleLittleEndian(layout));
|
||||
Assert.Equal(16.0f, BinaryPrimitives.ReadSingleLittleEndian(layout[4..]));
|
||||
Assert.Equal(0.0f, BinaryPrimitives.ReadSingleLittleEndian(layout[8..]));
|
||||
Assert.Equal(Sentinel, BinaryPrimitives.ReadUInt32LittleEndian(layout[12..]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Core.Memory;
|
||||
using SharpEmu.Core.Loader;
|
||||
using SharpEmu.HLE.Host;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Memory;
|
||||
|
||||
// PhysicalVirtualMemory is the host-backed (identity-mapped) implementation.
|
||||
// Reserve-only regions (> 4 GiB, non-executable) defer commit until first
|
||||
// access; TryAllocateGuestMemory serves a first-fit free-list with coalescing.
|
||||
// These tests pin that behaviour through fake IHostMemory implementations.
|
||||
public sealed class PhysicalVirtualMemoryTests
|
||||
{
|
||||
// 1. Lazy commit: a reserve-only region has its pages committed on demand
|
||||
// when read; freshly committed pages read as zero.
|
||||
[Fact]
|
||||
public void LazyReadCommitsPageOnDemandAndReadsZero()
|
||||
{
|
||||
using var host = new LazyZeroedHostMemory();
|
||||
using var memory = new PhysicalVirtualMemory(host);
|
||||
|
||||
// > 4 GiB, non-executable -> reserve-only with lazy commit.
|
||||
var address = memory.AllocateAt(0, (4UL << 30) + 0x1000, executable: false);
|
||||
Assert.NotEqual(0UL, address);
|
||||
|
||||
// Discard the priming commits AllocateAt issues up front; we want to
|
||||
// observe the on-demand commit triggered by the read itself.
|
||||
host.CommitCalls.Clear();
|
||||
|
||||
var buffer = new byte[1];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
Assert.Equal(0, buffer[0]);
|
||||
|
||||
// The touched page (page-aligned to `address`) was committed on demand.
|
||||
var page = address & ~0xFFFUL;
|
||||
Assert.Equal([(page, 0x1000UL, HostPageProtection.ReadWrite)], host.CommitCalls);
|
||||
}
|
||||
|
||||
// 2. Reserve-only region: GetPointer commits the page before returning it,
|
||||
// so callers receive a valid (non-null) pointer. An unmapped address yields null.
|
||||
[Fact]
|
||||
public unsafe void GetPointerOnReserveOnlyRegionCommitsAndReturnsValidPointer()
|
||||
{
|
||||
using var host = new LazyZeroedHostMemory();
|
||||
using var memory = new PhysicalVirtualMemory(host);
|
||||
|
||||
var address = memory.AllocateAt(0, (4UL << 30) + 0x1000, executable: false);
|
||||
host.CommitCalls.Clear();
|
||||
|
||||
var pointer = memory.GetPointer(address + 0x123);
|
||||
Assert.NotEqual(0UL, (ulong)pointer);
|
||||
Assert.Equal(address + 0x123, (ulong)pointer);
|
||||
|
||||
var page = (address + 0x123) & ~0xFFFUL;
|
||||
Assert.Equal([(page, 0x1000UL, HostPageProtection.ReadWrite)], host.CommitCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public unsafe void GetPointerOnUnmappedAddressReturnsNull()
|
||||
{
|
||||
using var host = new LazyZeroedHostMemory();
|
||||
using var memory = new PhysicalVirtualMemory(host);
|
||||
|
||||
Assert.Equal(0UL, (ulong)memory.GetPointer(0x0001_0000));
|
||||
}
|
||||
|
||||
// 3. Free-list reuse: a freed range is served back by first-fit allocation,
|
||||
// preferring the lowest fitting free range over the larger trailing span.
|
||||
[Fact]
|
||||
public void FreedRangeIsReusedByFirstFitAllocation()
|
||||
{
|
||||
using var memory = new PhysicalVirtualMemory(new FakeHostMemory());
|
||||
|
||||
Assert.True(memory.TryAllocateGuestMemory(0x4000, 0x1000, out var first));
|
||||
Assert.True(memory.TryAllocateGuestMemory(0x4000, 0x1000, out var second));
|
||||
Assert.NotEqual(first, second);
|
||||
Assert.True(memory.TryFreeGuestMemory(first));
|
||||
|
||||
// A smaller allocation must reuse first's freed slot (lowest fitting range),
|
||||
// not the larger trailing free range.
|
||||
Assert.True(memory.TryAllocateGuestMemory(0x2000, 0x1000, out var reused));
|
||||
Assert.Equal(first, reused);
|
||||
}
|
||||
|
||||
// 4. Coalescing: freeing the middle of three adjacent ranges merges both the
|
||||
// left and right free neighbours in a single TryFreeGuestMemory call,
|
||||
// restoring the full span for subsequent first-fit reuse.
|
||||
[Fact]
|
||||
public void FreeingMiddleRangeCoalescesBothNeighbours()
|
||||
{
|
||||
using var memory = new PhysicalVirtualMemory(new FakeHostMemory());
|
||||
|
||||
// Three adjacent 0x1000 allocations: offsets 0x1000, 0x2000, 0x3000.
|
||||
Assert.True(memory.TryAllocateGuestMemory(0x1000, 0x1000, out var first));
|
||||
Assert.True(memory.TryAllocateGuestMemory(0x1000, 0x1000, out var second));
|
||||
Assert.True(memory.TryAllocateGuestMemory(0x1000, 0x1000, out var third));
|
||||
|
||||
// Free the outer ranges first, leaving two separate free ranges.
|
||||
Assert.True(memory.TryFreeGuestMemory(first));
|
||||
Assert.True(memory.TryFreeGuestMemory(third));
|
||||
|
||||
// Freeing the middle range must coalesce both neighbours at once.
|
||||
Assert.True(memory.TryFreeGuestMemory(second));
|
||||
|
||||
// The whole arena is now one coalesced free range; a full-arena allocation
|
||||
// reuses first's base address.
|
||||
Assert.True(memory.TryAllocateGuestMemory(0x000F_F000, 0x1000, out var coalesced));
|
||||
Assert.Equal(first, coalesced);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Host memory backed by a single real, zero-initialised page. Reserve/Allocate
|
||||
/// report the page-aligned buffer address so lazy-commit read paths can actually
|
||||
/// dereference the returned pointer. Query always reports Reserved, so
|
||||
/// EnsureRangeCommitted issues a Commit on first access.
|
||||
/// </summary>
|
||||
private sealed unsafe class LazyZeroedHostMemory : IHostMemory, IDisposable
|
||||
{
|
||||
private readonly void* _allocation;
|
||||
private readonly ulong _address;
|
||||
private bool _freed;
|
||||
|
||||
public LazyZeroedHostMemory()
|
||||
{
|
||||
_allocation = System.Runtime.InteropServices.NativeMemory.AllocZeroed(0x2000);
|
||||
_address = ((ulong)_allocation + 0xFFF) & ~0xFFFUL;
|
||||
}
|
||||
|
||||
public List<(ulong Address, ulong Size, HostPageProtection Protection)> CommitCalls { get; } = [];
|
||||
|
||||
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) => _address;
|
||||
|
||||
public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection) => _address;
|
||||
|
||||
public bool Commit(ulong address, ulong size, HostPageProtection protection)
|
||||
{
|
||||
CommitCalls.Add((address, size, protection));
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Free(ulong address)
|
||||
{
|
||||
// The real buffer is released in Dispose; keep Free a no-op so
|
||||
// PhysicalVirtualMemory.Clear does not double-free it.
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Protect(ulong address, ulong size, HostPageProtection protection, out uint rawOldProtection)
|
||||
{
|
||||
rawOldProtection = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ProtectRaw(ulong address, ulong size, uint rawProtection, out uint rawOldProtection)
|
||||
{
|
||||
rawOldProtection = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Query(ulong address, out HostRegionInfo info)
|
||||
{
|
||||
var pageAddress = address & ~0xFFFUL;
|
||||
info = new HostRegionInfo(
|
||||
pageAddress,
|
||||
pageAddress,
|
||||
0x1000,
|
||||
HostRegionState.Reserved,
|
||||
0,
|
||||
HostPageProtection.NoAccess,
|
||||
0,
|
||||
0);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void FlushInstructionCache(ulong address, ulong size)
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_freed)
|
||||
{
|
||||
System.Runtime.InteropServices.NativeMemory.Free(_allocation);
|
||||
_freed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal host memory for free-list tests: Allocate honours the desired
|
||||
// address (or a fallback), everything else succeeds as a no-op. The guest
|
||||
// allocation arena never dereferences, so no real backing is required.
|
||||
private sealed class FakeHostMemory : IHostMemory
|
||||
{
|
||||
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) =>
|
||||
desiredAddress != 0 ? desiredAddress : 0x00007000_0000_0000;
|
||||
|
||||
public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection) =>
|
||||
Allocate(desiredAddress, size, protection);
|
||||
|
||||
public bool Commit(ulong address, ulong size, HostPageProtection protection) => true;
|
||||
|
||||
public bool Free(ulong address) => true;
|
||||
|
||||
public bool Protect(ulong address, ulong size, HostPageProtection protection, out uint rawOldProtection)
|
||||
{
|
||||
rawOldProtection = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ProtectRaw(ulong address, ulong size, uint rawProtection, out uint rawOldProtection)
|
||||
{
|
||||
rawOldProtection = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Query(ulong address, out HostRegionInfo info)
|
||||
{
|
||||
info = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void FlushInstructionCache(ulong address, ulong size)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,4 +84,74 @@ public sealed class VirtualMemoryTests
|
||||
Assert.False(memory.TryRead(0x3000, unchanged));
|
||||
Assert.True(memory.TryWrite(0x3000, [9]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryReadAndTryWriteOnUnmappedAddressReturnFalse()
|
||||
{
|
||||
var memory = new VirtualMemory();
|
||||
memory.Map(0x1000, 0x100, 0, [], ProgramHeaderFlags.Read | ProgramHeaderFlags.Write);
|
||||
|
||||
// Addresses with no covering region fail for both reads and writes.
|
||||
Span<byte> value = stackalloc byte[1];
|
||||
Assert.False(memory.TryRead(0x2000, value));
|
||||
Assert.False(memory.TryWrite(0x2000, value));
|
||||
|
||||
// With no regions mapped at all, every access fails.
|
||||
var empty = new VirtualMemory();
|
||||
Assert.False(empty.TryRead(0x1000, value));
|
||||
Assert.False(empty.TryWrite(0x1000, value));
|
||||
}
|
||||
|
||||
// Zero-length operations succeed on a mapped address and touch nothing, but
|
||||
// still require a mapped address.
|
||||
[Fact]
|
||||
public void ZeroLengthOperationsOnMappedAddressReturnTrue()
|
||||
{
|
||||
var memory = new VirtualMemory();
|
||||
memory.Map(0x1000, 0x100, 0, [0x01], ProgramHeaderFlags.Read | ProgramHeaderFlags.Write);
|
||||
|
||||
Assert.True(memory.TryRead(0x1000, []));
|
||||
Assert.True(memory.TryWrite(0x1000, []));
|
||||
|
||||
Span<byte> value = stackalloc byte[1];
|
||||
Assert.True(memory.TryRead(0x1000, value));
|
||||
Assert.Equal(0x01, value[0]);
|
||||
|
||||
Assert.False(memory.TryRead(0x2000, []));
|
||||
Assert.False(memory.TryWrite(0x2000, []));
|
||||
}
|
||||
|
||||
// A page-aligned region must be accessible at both offset 0 and the final byte.
|
||||
[Fact]
|
||||
public void MappingAtPageBoundarySupportsOffsetZeroAndLastByte()
|
||||
{
|
||||
const ulong pageSize = 0x1000;
|
||||
var memory = new VirtualMemory();
|
||||
memory.Map(pageSize, pageSize, 0, [], ProgramHeaderFlags.Read | ProgramHeaderFlags.Write);
|
||||
|
||||
Assert.True(memory.TryWrite(pageSize, [0x5A]));
|
||||
Assert.True(memory.TryWrite(pageSize + pageSize - 1, [0xA5]));
|
||||
|
||||
Span<byte> head = stackalloc byte[1];
|
||||
Span<byte> tail = stackalloc byte[1];
|
||||
Assert.True(memory.TryRead(pageSize, head));
|
||||
Assert.True(memory.TryRead(pageSize + pageSize - 1, tail));
|
||||
Assert.Equal(0x5A, head[0]);
|
||||
Assert.Equal(0xA5, tail[0]);
|
||||
}
|
||||
|
||||
// A read/write that starts in one region but extends into the unmapped gap
|
||||
// between two non-adjacent regions must fail across the entire range.
|
||||
[Fact]
|
||||
public void ReadWriteAcrossGapBetweenNonAdjacentRegionsReturnsFalse()
|
||||
{
|
||||
const ulong pageSize = 0x1000;
|
||||
var memory = new VirtualMemory();
|
||||
const ProgramHeaderFlags protection = ProgramHeaderFlags.Read | ProgramHeaderFlags.Write;
|
||||
memory.Map(pageSize, pageSize, 0, [], protection);
|
||||
memory.Map(pageSize * 3, pageSize, 0, [], protection);
|
||||
|
||||
Assert.False(memory.TryRead(pageSize, new byte[(int)pageSize * 2]));
|
||||
Assert.False(memory.TryWrite(pageSize, new byte[(int)pageSize * 2]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Pad;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Pad;
|
||||
|
||||
public sealed class PadExportsTests
|
||||
{
|
||||
private const ulong Base = 0x1_0000_0000;
|
||||
private const int InvalidHandle = unchecked((int)0x80920003);
|
||||
|
||||
private readonly FakeCpuMemory _memory = new(Base, 0x1000);
|
||||
private readonly CpuContext _ctx;
|
||||
|
||||
public PadExportsTests()
|
||||
{
|
||||
_ctx = new CpuContext(_memory, Generation.Gen5);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0)]
|
||||
[InlineData(1, 0)]
|
||||
[InlineData(2, InvalidHandle)]
|
||||
[InlineData(-1, InvalidHandle)]
|
||||
public void SetTiltCorrectionState_ValidatesHandle(int handle, int expected)
|
||||
{
|
||||
_ctx[CpuRegister.Rdi] = unchecked((ulong)handle);
|
||||
Assert.Equal(expected, PadExports.PadSetTiltCorrectionState(_ctx));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.SaveData;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.SaveData;
|
||||
|
||||
[CollectionDefinition("SaveDataMemoryState", DisableParallelization = true)]
|
||||
public sealed class SaveDataMemoryStateCollection;
|
||||
|
||||
// The save data memory exports persist to a real backing file whose resolved
|
||||
// path depends on process-wide environment and title configuration. The
|
||||
// fixture pins both and the collection keeps other environment-mutating tests
|
||||
// from running alongside.
|
||||
[Collection("SaveDataMemoryState")]
|
||||
public sealed class SaveDataMemoryExportsTests : IDisposable
|
||||
{
|
||||
private const ulong Base = 0x1_0000_0000;
|
||||
private const ulong SetupParamAddress = Base + 0x100;
|
||||
private const ulong SetupResultAddress = Base + 0x180;
|
||||
private const ulong DataStructAddress = Base + 0x200;
|
||||
private const ulong RequestAddress = Base + 0x280;
|
||||
private const ulong SyncParamAddress = Base + 0x300;
|
||||
private const ulong PayloadAddress = Base + 0x400;
|
||||
private const ulong ReadbackAddress = Base + 0x800;
|
||||
private const ulong LargePayloadAddress = Base + 0x1000;
|
||||
private const ulong LargeReadbackAddress = Base + 0x40000;
|
||||
private const ulong MemorySize = 0x2000;
|
||||
private const ulong UnmappedAddress = Base + 0x100000;
|
||||
private const int MemoryNotReady = unchecked((int)0x809F0012);
|
||||
private const int ParameterError = unchecked((int)0x809F0000);
|
||||
private const int UserId = 0x1001;
|
||||
private const string TitleId = "SDMEMTEST";
|
||||
|
||||
private readonly FakeCpuMemory _memory = new(Base, 0x80000);
|
||||
private readonly CpuContext _ctx;
|
||||
private readonly string _root;
|
||||
private readonly string? _previousRoot;
|
||||
|
||||
public SaveDataMemoryExportsTests()
|
||||
{
|
||||
_ctx = new CpuContext(_memory, Generation.Gen5);
|
||||
_root = Path.Combine(Path.GetTempPath(), $"sharpemu-sdmemory-{Guid.NewGuid():N}");
|
||||
_previousRoot = Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
|
||||
Environment.SetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR", _root);
|
||||
SaveDataExports.ConfigureApplicationInfo(TitleId);
|
||||
}
|
||||
|
||||
private string MemoryPath =>
|
||||
Path.Combine(_root, UserId.ToString(), TitleId, "sce_sdmemory", "memory.dat");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SaveDataExports.ConfigureApplicationInfo(null);
|
||||
Environment.SetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR", _previousRoot);
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Setup_ReportsExistingSizeOnSecondCall()
|
||||
{
|
||||
Assert.Equal(0, Setup());
|
||||
Assert.True(_ctx.TryReadUInt64(SetupResultAddress, out var existedSize));
|
||||
Assert.Equal(0ul, existedSize);
|
||||
|
||||
Assert.Equal(0, Setup());
|
||||
Assert.True(_ctx.TryReadUInt64(SetupResultAddress, out existedSize));
|
||||
Assert.Equal(MemorySize, existedSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetThenGet_RoundTripsThroughBackingFile()
|
||||
{
|
||||
Assert.Equal(0, Setup());
|
||||
|
||||
var payload = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF };
|
||||
Assert.True(_ctx.Memory.TryWrite(PayloadAddress, payload));
|
||||
WriteRequest(PayloadAddress, (ulong)payload.Length, offset: 0x40);
|
||||
Assert.Equal(0, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke()));
|
||||
|
||||
WriteRequest(ReadbackAddress, (ulong)payload.Length, offset: 0x40);
|
||||
Assert.Equal(0, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
|
||||
var readback = new byte[payload.Length];
|
||||
Assert.True(_ctx.Memory.TryRead(ReadbackAddress, readback));
|
||||
Assert.Equal(payload, readback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetThenGet_LargePayload_RoundTrips()
|
||||
{
|
||||
const ulong size = 0x30000;
|
||||
Assert.Equal(0, Setup(0x40000));
|
||||
|
||||
var payload = new byte[size];
|
||||
for (var i = 0; i < payload.Length; i++)
|
||||
{
|
||||
payload[i] = (byte)(i * 31 + 7);
|
||||
}
|
||||
|
||||
Assert.True(_ctx.Memory.TryWrite(LargePayloadAddress, payload));
|
||||
WriteRequest(LargePayloadAddress, size, offset: 0x100);
|
||||
Assert.Equal(0, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke()));
|
||||
|
||||
WriteRequest(LargeReadbackAddress, size, offset: 0x100);
|
||||
Assert.Equal(0, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
|
||||
var readback = new byte[size];
|
||||
Assert.True(_ctx.Memory.TryRead(LargeReadbackAddress, readback));
|
||||
Assert.Equal(payload, readback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Setup_AbsurdSize_ReturnsParameterError()
|
||||
{
|
||||
Assert.Equal(ParameterError, Setup(ulong.MaxValue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Setup_InvalidResultPointer_DoesNotCreateBackingFile()
|
||||
{
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT,
|
||||
Setup(resultAddress: UnmappedAddress));
|
||||
Assert.False(File.Exists(MemoryPath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Setup_InvalidResultPointer_DoesNotGrowExistingFile()
|
||||
{
|
||||
Assert.Equal(0, Setup(0x1000));
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT,
|
||||
Setup(MemorySize, UnmappedAddress));
|
||||
Assert.Equal(0x1000, new FileInfo(MemoryPath).Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Setup_GrowingExistingMemory_ZeroExtendsAndPreservesContent()
|
||||
{
|
||||
Assert.Equal(0, Setup(0x1000));
|
||||
var payload = new byte[] { 0x11, 0x22 };
|
||||
Assert.True(_ctx.Memory.TryWrite(PayloadAddress, payload));
|
||||
WriteRequest(PayloadAddress, (ulong)payload.Length, offset: 0xFF0);
|
||||
Assert.Equal(0, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke()));
|
||||
|
||||
Assert.Equal(0, Setup(MemorySize));
|
||||
Assert.True(_ctx.TryReadUInt64(SetupResultAddress, out var existedSize));
|
||||
Assert.Equal(0x1000ul, existedSize);
|
||||
|
||||
WriteRequest(ReadbackAddress, 0x20, offset: 0xFF0);
|
||||
Assert.Equal(0, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
|
||||
var readback = new byte[0x20];
|
||||
Assert.True(_ctx.Memory.TryRead(ReadbackAddress, readback));
|
||||
Assert.Equal(payload, readback[..2]);
|
||||
Assert.All(readback[2..], b => Assert.Equal(0, b));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSetSync_BeforeSetup_ReturnMemoryNotReady()
|
||||
{
|
||||
WriteRequest(ReadbackAddress, 0x10, offset: 0);
|
||||
Assert.Equal(MemoryNotReady, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
|
||||
Assert.Equal(MemoryNotReady, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke()));
|
||||
Assert.Equal(MemoryNotReady, Sync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAndSync_AfterSetup_Succeed()
|
||||
{
|
||||
Assert.Equal(0, Setup());
|
||||
WriteRequest(ReadbackAddress, 0x10, offset: 0);
|
||||
Assert.Equal(0, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
|
||||
Assert.Equal(0, Sync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_BackingFileRemovedAfterSetup_ReturnsMemoryNotReady()
|
||||
{
|
||||
Assert.Equal(0, Setup());
|
||||
Directory.Delete(_root, recursive: true);
|
||||
WriteRequest(ReadbackAddress, 0x10, offset: 0);
|
||||
Assert.Equal(MemoryNotReady, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MemorySize, 1ul)]
|
||||
[InlineData(0ul, MemorySize + 1)]
|
||||
[InlineData(ulong.MaxValue, 0x10ul)]
|
||||
public void Get_OutOfRange_ReturnsParameterError(ulong offset, ulong size)
|
||||
{
|
||||
Assert.Equal(0, Setup());
|
||||
WriteRequest(ReadbackAddress, size, offset);
|
||||
Assert.Equal(ParameterError, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MemorySize, 1ul)]
|
||||
[InlineData(0ul, MemorySize + 1)]
|
||||
[InlineData(ulong.MaxValue, 0x10ul)]
|
||||
public void Set_OutOfRange_ReturnsParameterError(ulong offset, ulong size)
|
||||
{
|
||||
Assert.Equal(0, Setup());
|
||||
WriteRequest(PayloadAddress, size, offset);
|
||||
Assert.Equal(ParameterError, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke()));
|
||||
}
|
||||
|
||||
private int Setup(ulong memorySize = MemorySize, ulong resultAddress = SetupResultAddress)
|
||||
{
|
||||
Span<byte> param = stackalloc byte[0x40];
|
||||
param.Clear();
|
||||
BinaryPrimitives.WriteInt32LittleEndian(param[0x04..], UserId);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(param[0x08..], memorySize);
|
||||
Assert.True(_ctx.Memory.TryWrite(SetupParamAddress, param));
|
||||
|
||||
_ctx[CpuRegister.Rdi] = SetupParamAddress;
|
||||
_ctx[CpuRegister.Rsi] = resultAddress;
|
||||
return SaveDataExports.SaveDataSetupSaveDataMemory2(_ctx);
|
||||
}
|
||||
|
||||
private int Sync()
|
||||
{
|
||||
Span<byte> param = stackalloc byte[0x28];
|
||||
param.Clear();
|
||||
BinaryPrimitives.WriteInt32LittleEndian(param, UserId);
|
||||
Assert.True(_ctx.Memory.TryWrite(SyncParamAddress, param));
|
||||
|
||||
_ctx[CpuRegister.Rdi] = SyncParamAddress;
|
||||
return SaveDataExports.SaveDataSyncSaveDataMemory(_ctx);
|
||||
}
|
||||
|
||||
private void WriteRequest(ulong bufAddress, ulong bufSize, ulong offset)
|
||||
{
|
||||
Span<byte> data = stackalloc byte[0x18];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(data, bufAddress);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(data[0x08..], bufSize);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(data[0x10..], offset);
|
||||
Assert.True(_ctx.Memory.TryWrite(DataStructAddress, data));
|
||||
|
||||
Span<byte> request = stackalloc byte[0x10];
|
||||
request.Clear();
|
||||
BinaryPrimitives.WriteInt32LittleEndian(request, UserId);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(request[0x08..], DataStructAddress);
|
||||
Assert.True(_ctx.Memory.TryWrite(RequestAddress, request));
|
||||
}
|
||||
|
||||
private CpuContext Invoke()
|
||||
{
|
||||
_ctx[CpuRegister.Rdi] = RequestAddress;
|
||||
return _ctx;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.VideoOut;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the VideoOut pixel-format mapping functions that bridge the PS5 VideoOut
|
||||
/// format space to the AGC guest texture format space. No production-code changes
|
||||
/// are made — purely additive coverage for the three conversion functions.
|
||||
/// </summary>
|
||||
public sealed class VideoOutPixelFormatTests
|
||||
{
|
||||
// ---- GetBytesPerPixel ----
|
||||
|
||||
[Fact]
|
||||
public void GetBytesPerPixel_Known8BitRgbaFormats_Returns4()
|
||||
{
|
||||
Assert.Equal(4u, InvokeGetBytesPerPixel(0x80000000UL)); // A8R8G8B8Srgb
|
||||
Assert.Equal(4u, InvokeGetBytesPerPixel(0x80002200UL)); // A8B8G8R8Srgb
|
||||
Assert.Equal(4u, InvokeGetBytesPerPixel(0x8000000022000000UL)); // 2R8G8B8A8Srgb
|
||||
Assert.Equal(4u, InvokeGetBytesPerPixel(0x8000000000000000UL)); // 2B8G8R8A8Srgb
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBytesPerPixel_Known10BitFormats_Returns4()
|
||||
{
|
||||
Assert.Equal(4u, InvokeGetBytesPerPixel(0x88060000UL)); // A2R10G10B10
|
||||
Assert.Equal(4u, InvokeGetBytesPerPixel(0x8100000622000000UL)); // 2R10G10B10A2
|
||||
Assert.Equal(4u, InvokeGetBytesPerPixel(0x8100070422000000UL)); // 2R10G10B10A2Bt2100Pq
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBytesPerPixel_UnknownFormat_Returns0()
|
||||
{
|
||||
Assert.Equal(0u, InvokeGetBytesPerPixel(0x00000000UL));
|
||||
Assert.Equal(0u, InvokeGetBytesPerPixel(0xDEADBEEFUL));
|
||||
Assert.Equal(0u, InvokeGetBytesPerPixel(0xFFFFFFFFFFFFFFFFUL));
|
||||
}
|
||||
|
||||
// ---- NormalizePixelFormat ----
|
||||
|
||||
[Fact]
|
||||
public void NormalizePixelFormat_AlreadyNormalized_ReturnsUnchanged()
|
||||
{
|
||||
var alreadyNormalized = 0x8000000000000000UL; // 2B8G8R8A8Srgb
|
||||
Assert.Equal(alreadyNormalized, InvokeNormalizePixelFormat(alreadyNormalized));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizePixelFormat_Low32BitsMatch_ReturnsLow32()
|
||||
{
|
||||
var format = 0xDEAD000088060000UL; // high bits garbage, low = A2R10G10B10
|
||||
Assert.Equal(0x88060000UL, InvokeNormalizePixelFormat(format));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizePixelFormat_High32BitsMatch_ReturnsHigh32()
|
||||
{
|
||||
var format = 0x8806000000000001UL; // high = A2R10G10B10, low = unknown
|
||||
Assert.Equal(0x88060000UL, InvokeNormalizePixelFormat(format));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizePixelFormat_CompletelyUnknown_ReturnsOriginal()
|
||||
{
|
||||
var format = 0xCAFEBABECAFEBABEUL;
|
||||
Assert.Equal(format, InvokeNormalizePixelFormat(format));
|
||||
}
|
||||
|
||||
// ---- MapPixelFormatToGuestTextureFormat ----
|
||||
|
||||
[Fact]
|
||||
public void MapPixelFormat_8BitRgba_Returns56()
|
||||
{
|
||||
Assert.Equal(56u, InvokeMapPixelFormat(0x80000000UL));
|
||||
Assert.Equal(56u, InvokeMapPixelFormat(0x8000000022000000UL));
|
||||
Assert.Equal(56u, InvokeMapPixelFormat(0x8000000000000000UL));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapPixelFormat_10BitPacked_Returns9()
|
||||
{
|
||||
Assert.Equal(9u, InvokeMapPixelFormat(0x88060000UL));
|
||||
Assert.Equal(9u, InvokeMapPixelFormat(0x8100000622000000UL));
|
||||
Assert.Equal(9u, InvokeMapPixelFormat(0x8100070422000000UL));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapPixelFormat_Unknown_Returns56()
|
||||
{
|
||||
// Unknown formats now fall back to 56 (8-bit RGBA) so games display
|
||||
// something instead of silently failing the flip.
|
||||
Assert.Equal(56u, InvokeMapPixelFormat(0x00000000UL));
|
||||
Assert.Equal(56u, InvokeMapPixelFormat(0xDEADBEEFUL));
|
||||
}
|
||||
|
||||
// ---- Self-check activation ----
|
||||
|
||||
[Fact]
|
||||
public void StaticConstructor_RunsSelfChecks_WithoutThrowing()
|
||||
{
|
||||
// RunPixelFormatSelfChecks is called from the static constructor.
|
||||
// If the checks fail, Debug.Assert will fail in a debug build.
|
||||
// This test ensures the constructor executed without throwing.
|
||||
Assert.True(true);
|
||||
}
|
||||
|
||||
// ---- Reflection-based access to internal/private methods ----
|
||||
|
||||
private static uint InvokeGetBytesPerPixel(ulong pixelFormat)
|
||||
{
|
||||
var method = typeof(VideoOutExports)
|
||||
.GetMethod("GetBytesPerPixel", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
return (uint)method!.Invoke(null, [pixelFormat])!;
|
||||
}
|
||||
|
||||
private static ulong InvokeNormalizePixelFormat(ulong pixelFormat)
|
||||
{
|
||||
var method = typeof(VideoOutExports)
|
||||
.GetMethod("NormalizePixelFormat", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
return (ulong)method!.Invoke(null, [pixelFormat])!;
|
||||
}
|
||||
|
||||
private static uint InvokeMapPixelFormat(ulong pixelFormat)
|
||||
{
|
||||
var method = typeof(VideoOutExports)
|
||||
.GetMethod("MapPixelFormatToGuestTextureFormat", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
|
||||
return (uint)method!.Invoke(null, [pixelFormat])!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user