Compare commits
145 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 | |||
| 459ae7e3f7 | |||
| 8f405caebe | |||
| dabf723b3e | |||
| 2129a12684 | |||
| 2db1fae282 | |||
| 33f96252da | |||
| f7981a7ed7 | |||
| e10efa3ae1 | |||
| 16a2131b67 | |||
| 9883a9445d | |||
| f2d9051358 | |||
| 53d414e096 | |||
| 52d2874fa8 | |||
| c5a82c1065 | |||
| a8be1daca4 | |||
| 26dbad8ac8 | |||
| 2196a1f786 | |||
| 8db2c0622d | |||
| 53da00fa89 | |||
| b4b95014f1 | |||
| 7b91c964fc | |||
| 9bacb883f1 | |||
| 864cbb0fa0 | |||
| ad5a7d3799 | |||
| f69fdd4027 | |||
| 9eef39549a | |||
| 90651f26a9 | |||
| b5930465f2 | |||
| 83303e953e | |||
| 92497689ab | |||
| de7973af55 | |||
| 3da7ae7d70 | |||
| 1be009ce40 | |||
| ad92ab30fd | |||
| 5f97d2edc4 | |||
| 5c8ace82c0 | |||
| 341c2a0cb6 | |||
| 320dbcacba | |||
| 30fdd8d6ed | |||
| c69ac6ddab | |||
| fa2616d224 | |||
| 7b86a91dfa | |||
| 72645cb373 | |||
| 2ad9836d13 | |||
| 62e1775c5c | |||
| 6dacd59a08 | |||
| 9d88542efd | |||
| 373100a6b0 | |||
| f23161be9a | |||
| 081760be3f | |||
| e604fb606d | |||
| df53ff59d9 | |||
| 4c35831cb8 | |||
| 90fdd20f9a | |||
| ae5ef0abe7 | |||
| 5e2c21edf1 | |||
| 3fb9d4db1c | |||
| de13735972 | |||
| fc0efca297 | |||
| 2a9a261913 | |||
| 290f5fd3d7 | |||
| c06c70cad7 | |||
| 5e54250752 | |||
| be6a6a5935 | |||
| caf859cc52 | |||
| d2f3511002 | |||
| 90a5d5176f | |||
| 28a43e09c7 | |||
| 093cfa1f3e | |||
| d8397b022e | |||
| 85cc2b9892 | |||
| 293194c40b | |||
| 1f09de8896 | |||
| ddc452b4fc | |||
| 61a97baf85 | |||
| e80f96ecf5 | |||
| d49c0f1f10 | |||
| 1d33ef90fc | |||
| 26a570633c | |||
| e4f89445b9 | |||
| 1254cc1564 | |||
| 503b3f4d6b | |||
| 6b37ab54f2 | |||
| a84d2344fb | |||
| 787d3a1efb | |||
| 884584da67 | |||
| d43edc865a | |||
| cf6964710a | |||
| 4f028d0483 | |||
| 4db98bd8fe | |||
| 4600a2ed1f | |||
| c5c5ee1f36 | |||
| b48b1a5e09 | |||
| 511a01e03a | |||
| aaebfe017b | |||
| 9cdc8550ec | |||
| d6fccedab8 | |||
| fed7a6d062 | |||
| 50f78f3713 | |||
| 63b440efcd | |||
| 0565d01744 | |||
| 56bf00e9a5 | |||
| 46b729c5b4 | |||
| 4b7df8623a | |||
| 3c2134474d | |||
| 298ef01809 | |||
| 2060dacaf1 | |||
| f73c9e8c3f | |||
| 3d2c30b151 | |||
| 61d28e9e08 | |||
| 4bd42795c7 | |||
| c03ca32a02 | |||
| 6e2878f2ff | |||
| 2e09b015bc | |||
| 7b9efd1539 | |||
| fbf2c2d00a | |||
| 8c1507777c | |||
| 8ebc43e758 | |||
| 5aadb7495a | |||
| cdee77521e | |||
| d2fca37716 | |||
| 5bee81df70 | |||
| d4bb282c49 |
@@ -88,7 +88,7 @@ body:
|
||||
attributes:
|
||||
label: Last Milestone / First Error
|
||||
description: Paste the **last useful milestone line** or the **first `WARNING`, `ERROR`, or `CRITICAL` line connected to the stop** from the log. `DEBUG`/`TRACE` lines do not count unless a real error follows them.
|
||||
placeholder: e.g. CpuEngine: native-only OR [ERROR] Native backend FAILED: ...
|
||||
placeholder: "e.g. CpuEngine: native-only OR [ERROR] Native backend FAILED: ..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 86 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."
|
||||
@@ -0,0 +1,120 @@
|
||||
# Copyright (C) 2026 SharpEmu Emulator Project
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Posts (and keeps updated) a single PR comment linking the per-platform build
|
||||
# artifacts once "Build and Release" finishes.
|
||||
#
|
||||
# This is a workflow_run workflow on purpose: PRs from forks run "Build and
|
||||
# Release" with a read-only GITHUB_TOKEN and cannot comment. workflow_run runs
|
||||
# afterwards in the base-repo context with a read-write token and does not check
|
||||
# out untrusted fork code, so it can comment safely. Because of that, GitHub only
|
||||
# triggers it from the copy on the default branch — it does nothing until merged
|
||||
# to main.
|
||||
name: PR Build Links
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build and Release"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
name: Post artifact links
|
||||
runs-on: ubuntu-latest
|
||||
# Only for successful PR builds — artifacts exist only when the build passed.
|
||||
if: >-
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
steps:
|
||||
- name: Post or update the artifact-links comment
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const run = context.payload.workflow_run;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
// Resolve the PR. Same-repo PRs are in workflow_run.pull_requests, but
|
||||
// fork PRs leave it empty AND their head commit is not on a base-repo
|
||||
// branch, so listPullRequestsAssociatedWithCommit misses them too. Match
|
||||
// the open PR by its head "owner:branch" instead, which works for forks.
|
||||
let prNumber;
|
||||
if (run.pull_requests && run.pull_requests.length > 0) {
|
||||
prNumber = run.pull_requests[0].number;
|
||||
} else {
|
||||
let candidates = [];
|
||||
const headOwner = run.head_repository && run.head_repository.owner
|
||||
? run.head_repository.owner.login
|
||||
: null;
|
||||
if (headOwner && run.head_branch) {
|
||||
const byHead = await github.rest.pulls.list({
|
||||
owner, repo, state: 'open',
|
||||
head: `${headOwner}:${run.head_branch}`, per_page: 10,
|
||||
});
|
||||
candidates = byHead.data;
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
const byCommit = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner, repo, commit_sha: run.head_sha,
|
||||
});
|
||||
candidates = byCommit.data.filter(pr => pr.state === 'open');
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
core.info('No open PR for this build; nothing to comment.');
|
||||
return;
|
||||
}
|
||||
prNumber = candidates[0].number;
|
||||
}
|
||||
|
||||
// Collect the per-platform artifacts the build produced.
|
||||
const artifacts = await github.paginate(
|
||||
github.rest.actions.listWorkflowRunArtifacts,
|
||||
{ owner, repo, run_id: run.id, per_page: 100 },
|
||||
);
|
||||
const platforms = [
|
||||
{ key: 'win-x64', label: 'Windows (win-x64)' },
|
||||
{ key: 'linux-x64', label: 'Linux (linux-x64)' },
|
||||
{ key: 'osx-x64', label: 'macOS (osx-x64)' },
|
||||
];
|
||||
const rows = [];
|
||||
for (const platform of platforms) {
|
||||
const artifact = artifacts.find(a => a.name.includes(platform.key));
|
||||
if (!artifact) {
|
||||
continue;
|
||||
}
|
||||
const url = `https://github.com/${owner}/${repo}/actions/runs/${run.id}/artifacts/${artifact.id}`;
|
||||
rows.push(`| ${platform.label} | [\`${artifact.name}\`](${url}) |`);
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
core.info('No platform artifacts on this run; nothing to comment.');
|
||||
return;
|
||||
}
|
||||
|
||||
const marker = '<!-- pr-build-links -->';
|
||||
const body = [
|
||||
marker,
|
||||
`### 📦 Build artifacts — \`${run.head_sha.substring(0, 7)}\``,
|
||||
'',
|
||||
'| Platform | Download |',
|
||||
'| --- | --- |',
|
||||
...rows,
|
||||
'',
|
||||
`From [build run #${run.run_number}](${run.html_url}). ` +
|
||||
'Downloads require a GitHub login and expire after 90 days.',
|
||||
].join('\n');
|
||||
|
||||
// Upsert one comment so repeated builds refresh it in place.
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: prNumber, per_page: 100,
|
||||
});
|
||||
const existing = comments.find(c => c.body && c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
|
||||
} else {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
|
||||
}
|
||||
@@ -7,6 +7,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
tags:
|
||||
- "v*"
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "**/*.png"
|
||||
@@ -34,29 +36,36 @@ jobs:
|
||||
name: Init
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
archive-name: ${{ steps.vars.outputs.archive-name }}
|
||||
artifact-name: ${{ steps.vars.outputs.artifact-name }}
|
||||
release-name: ${{ steps.vars.outputs.release-name }}
|
||||
release-tag: ${{ steps.vars.outputs.release-tag }}
|
||||
safe-ref: ${{ steps.vars.outputs.safe-ref }}
|
||||
short-sha: ${{ steps.vars.outputs.short-sha }}
|
||||
version: ${{ steps.vars.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Compute workflow variables
|
||||
id: vars
|
||||
shell: bash
|
||||
run: |
|
||||
short_sha="${GITHUB_SHA::7}"
|
||||
safe_ref="$(echo "${GITHUB_REF_NAME}" | tr '[:upper:]' '[:lower:]' | sed 's#[^a-z0-9._-]#-#g')"
|
||||
archive_name="sharpemu-win64-${short_sha}.zip"
|
||||
artifact_name="sharpemu-win64-${short_sha}"
|
||||
release_tag="win64-${safe_ref}-${short_sha}"
|
||||
release_name="SharpEmu win64 ${short_sha}"
|
||||
version="$(python3 -c 'import sys, xml.etree.ElementTree as ET; version = ET.parse("Directory.Build.props").getroot().findtext(".//SharpEmuVersion"); sys.exit("Directory.Build.props is missing SharpEmuVersion") if not version or not version.strip() else print(version.strip())')"
|
||||
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 "archive-name=${archive_name}"
|
||||
echo "artifact-name=${artifact_name}"
|
||||
echo "safe-ref=${safe_ref}"
|
||||
echo "release-tag=${release_tag}"
|
||||
echo "release-name=${release_name}"
|
||||
echo "version=${version}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
reuse:
|
||||
@@ -92,14 +101,23 @@ jobs:
|
||||
cache: true
|
||||
cache-dependency-path: |
|
||||
Directory.Packages.props
|
||||
src/**/packages.lock.json
|
||||
Directory.Build.props
|
||||
|
||||
- name: Restore solution
|
||||
run: dotnet restore SharpEmu.slnx --locked-mode
|
||||
run: dotnet restore SharpEmu.slnx
|
||||
|
||||
- name: Build solution
|
||||
run: dotnet build SharpEmu.slnx -c Release --no-restore
|
||||
|
||||
# Runs every test project in the solution; a test failure fails the build, as
|
||||
# does an aerolib.bin generation failure in the build step above (the MSBuild
|
||||
# task logs an error and returns false).
|
||||
- name: Run tests
|
||||
run: dotnet test SharpEmu.slnx -c Release --no-build --verbosity normal
|
||||
|
||||
- name: Validate synthetic shaders
|
||||
run: dotnet run --project tools/SharpEmu.Tools.ShaderDump/SharpEmu.Tools.ShaderDump.csproj -c Release -- artifacts/shader-dump
|
||||
|
||||
- name: Publish win-x64 CLI
|
||||
run: dotnet publish src/SharpEmu.CLI/SharpEmu.CLI.csproj -c Release -r win-x64 --self-contained true --no-restore -p:PublishDir="${env:PUBLISH_DIR}"
|
||||
|
||||
@@ -107,7 +125,8 @@ jobs:
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path $env:RELEASE_DIR -Force | Out-Null
|
||||
|
||||
$archivePath = Join-Path $env:RELEASE_DIR "${{ needs.init.outputs.archive-name }}"
|
||||
$archiveName = "sharpemu-${{ needs.init.outputs.version }}-win-x64.zip"
|
||||
$archivePath = Join-Path $env:RELEASE_DIR $archiveName
|
||||
if (Test-Path $archivePath) {
|
||||
Remove-Item $archivePath -Force
|
||||
}
|
||||
@@ -117,8 +136,72 @@ jobs:
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ needs.init.outputs.artifact-name }}
|
||||
path: ${{ env.RELEASE_DIR }}\${{ needs.init.outputs.archive-name }}
|
||||
name: sharpemu-win-x64-${{ needs.init.outputs.short-sha }}
|
||||
path: ${{ env.RELEASE_DIR }}\sharpemu-${{ needs.init.outputs.version }}-win-x64.zip
|
||||
if-no-files-found: error
|
||||
|
||||
build-posix:
|
||||
name: Build ${{ matrix.rid }}
|
||||
needs:
|
||||
- init
|
||||
- reuse
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
rid: linux-x64
|
||||
- os: macos-latest
|
||||
rid: osx-x64
|
||||
env:
|
||||
DOTNET_NOLOGO: true
|
||||
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
|
||||
PUBLISH_DIR: ${{ github.workspace }}/artifacts/publish/${{ matrix.rid }}
|
||||
RELEASE_DIR: ${{ github.workspace }}/artifacts/release
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup .NET SDK
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.0.103
|
||||
cache: true
|
||||
cache-dependency-path: |
|
||||
Directory.Packages.props
|
||||
Directory.Build.props
|
||||
|
||||
- name: Restore solution
|
||||
run: dotnet restore SharpEmu.slnx
|
||||
|
||||
- name: Build solution
|
||||
run: dotnet build SharpEmu.slnx -c Release --no-restore
|
||||
|
||||
# Also runs on Linux/macOS: the aerolib.bin MSBuild task does platform-sensitive
|
||||
# path handling, so a cross-platform generation regression surfaces here.
|
||||
- name: Run tests
|
||||
run: dotnet test SharpEmu.slnx -c Release --no-build --verbosity normal
|
||||
|
||||
- name: Publish ${{ matrix.rid }} CLI
|
||||
run: dotnet publish src/SharpEmu.CLI/SharpEmu.CLI.csproj -c Release -r ${{ matrix.rid }} --self-contained true --no-restore -p:PublishDir="$PUBLISH_DIR"
|
||||
|
||||
- name: Stage MoltenVK next to the build
|
||||
if: matrix.rid == 'osx-x64'
|
||||
run: scripts/fetch-macos-moltenvk.sh "$PUBLISH_DIR"
|
||||
|
||||
- name: Create release archive
|
||||
run: |
|
||||
mkdir -p "$RELEASE_DIR"
|
||||
# tar keeps the executable bit, which zip would drop.
|
||||
tar -czf "$RELEASE_DIR/sharpemu-${{ needs.init.outputs.version }}-${{ matrix.rid }}.tar.gz" \
|
||||
-C "$PUBLISH_DIR" .
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: sharpemu-${{ matrix.rid }}-${{ needs.init.outputs.short-sha }}
|
||||
path: ${{ env.RELEASE_DIR }}/sharpemu-${{ needs.init.outputs.version }}-${{ matrix.rid }}.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
@@ -126,35 +209,42 @@ jobs:
|
||||
needs:
|
||||
- init
|
||||
- build
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
|
||||
- build-posix
|
||||
# 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
|
||||
steps:
|
||||
- name: Download build artifact
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ${{ needs.init.outputs.artifact-name }}
|
||||
path: release
|
||||
|
||||
- name: Create or update release
|
||||
- name: Create release
|
||||
shell: bash
|
||||
env:
|
||||
ARCHIVE_NAME: ${{ needs.init.outputs.archive-name }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_NAME: ${{ needs.init.outputs.release-name }}
|
||||
RELEASE_TAG: ${{ needs.init.outputs.release-tag }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
asset_path="release/${ARCHIVE_NAME}"
|
||||
notes="Automated Windows build for commit ${GITHUB_SHA}."
|
||||
mapfile -t assets < <(find release -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release assets found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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}" "${asset_path}" --clobber
|
||||
gh release edit "${RELEASE_TAG}" --title "${RELEASE_NAME}" --notes "${notes}"
|
||||
else
|
||||
gh release create "${RELEASE_TAG}" "${asset_path}" \
|
||||
--title "${RELEASE_NAME}" \
|
||||
--notes "${notes}" \
|
||||
--target "${GITHUB_SHA}"
|
||||
echo "Release ${RELEASE_TAG} already exists and will not be modified." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gh release create "${RELEASE_TAG}" "${assets[@]}" \
|
||||
--verify-tag \
|
||||
--title "${RELEASE_NAME}" \
|
||||
--notes "${notes}"
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
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:
|
||||
|
||||
- Keep PRs small and focused on a single topic.
|
||||
- Discuss large architectural changes before implementing them.
|
||||
- Follow the project's existing coding style.
|
||||
- Do not submit generated code unless you fully understand and can maintain it.
|
||||
- Do not introduce Sony proprietary code, firmware, keys, decrypted assets, or other copyrighted PlayStation materials.
|
||||
- All reverse engineering should be based on publicly available information, clean-room techniques, or your own original research.
|
||||
- Game-specific hacks should be avoided whenever possible. Prefer generic implementations that improve overall compatibility.
|
||||
- New features should not break existing behavior.
|
||||
- Ensure the project builds successfully before submitting a PR.
|
||||
|
||||
If you're unsure about a design decision, feel free to open a discussion or draft PR first.
|
||||
|
||||
## AI-Assisted Contributions
|
||||
|
||||
AI-assisted development is welcome and may be used for research, reverse engineering, code generation, or documentation.
|
||||
|
||||
However, contributors are expected to fully understand every line of code they submit. By opening a pull request, you confirm that you are able to explain, modify, debug, and maintain the submitted code without relying on the AI that generated it.
|
||||
|
||||
When submitting an AI-assisted PR:
|
||||
|
||||
- Clearly explain **what the change does**, **why it is needed**, and **what problem it solves**, using your own words.
|
||||
- Describe **how you verified the change**, including the games, applications, or test cases used.
|
||||
- Avoid excessive product-level logging. Use logging only when it provides meaningful diagnostic value.
|
||||
- Comments should document design decisions or implementation details in your own words. Avoid generic AI-generated comments that merely restate what the code already does.
|
||||
- Be prepared to answer review questions about the implementation. "The AI generated it" is not considered a sufficient explanation.
|
||||
- Large AI-generated changes without a clear understanding of the implementation are unlikely to be accepted.
|
||||
- If the implementation cannot be reasonably explained during code review, the pull request may be rejected regardless of whether it works.
|
||||
|
||||
The quality, correctness, maintainability, and long-term ownership of the submitted code remain the responsibility of the contributor.
|
||||
|
||||
## Coding Style
|
||||
|
||||
SharpEmu follows a consistent coding style across the project. Please ensure your contributions match the existing style.
|
||||
|
||||
- Use **4 spaces** for indentation (no tabs).
|
||||
- Use **2 spaces** for XML-based files (e.g. `.csproj`, `.props`, `.targets`, `.xml`, `yml`, GitHub workflow files where applicable).
|
||||
- Respect the project's `.editorconfig`.
|
||||
- Ensure every text file ends with a **single trailing newline**
|
||||
- Avoid formatting-only commits unless they are the purpose of the PR.
|
||||
- Keep naming, formatting, and file organization consistent with the surrounding code.
|
||||
- Prefer small, focused changes over large refactors.
|
||||
|
||||
### REUSE Compliance
|
||||
|
||||
This repository follows the REUSE Specification.
|
||||
|
||||
Every new file must contain the appropriate SPDX license header. Pull requests that do not comply with the project's REUSE requirements will fail CI and will not be merged.
|
||||
|
||||
### Recommended Development Environment
|
||||
|
||||
The repository includes an `.editorconfig` and Visual Studio solution files.
|
||||
|
||||
For the best experience, we recommend using:
|
||||
|
||||
- Visual Studio Code with;
|
||||
- C#
|
||||
- C# Dev Kit
|
||||
|
||||
Most editors that support `.editorconfig` will automatically apply the project's formatting rules.
|
||||
@@ -9,7 +9,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
<SharpEmuVersion>0.0.2-beta.2</SharpEmuVersion>
|
||||
<Version>$(SharpEmuVersion)</Version>
|
||||
|
||||
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
|
||||
|
||||
|
||||
@@ -12,11 +12,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.18" />
|
||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.18" />
|
||||
<PackageVersion Include="Iced" Version="1.21.0" />
|
||||
<PackageVersion Include="Microsoft.Build.Framework" Version="17.14.8" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageVersion Include="Silk.NET.Input" Version="2.23.0" />
|
||||
<PackageVersion Include="Silk.NET.Vulkan" Version="2.23.0" />
|
||||
<PackageVersion Include="Silk.NET.Vulkan.Extensions.EXT" Version="2.23.0" />
|
||||
<PackageVersion Include="Silk.NET.Vulkan.Extensions.KHR" Version="2.23.0" />
|
||||
<PackageVersion Include="Silk.NET.Windowing" Version="2.23.0" />
|
||||
<!-- Transitive of Avalonia.Desktop; pinned to fix GHSA-xrw6-gwf8-vvr9 -->
|
||||
<PackageVersion Include="Tmds.DBus.Protocol" Version="0.21.3" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -25,8 +25,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
> Currently the primary development target is Windows.
|
||||
> [!NOTE]
|
||||
> SharpEmu supports Windows x64, Linux x64, and macOS x64. Apple Silicon Macs
|
||||
> can run the macOS x64 build through Rosetta 2.
|
||||
|
||||
> [!WARNING]
|
||||
> SharpEmu is an experimental PS5 emulator developed from scratch in C#. The current focus is on accuracy and infrastructure setup rather than game-specific compatibility.
|
||||
@@ -40,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.
|
||||
@@ -59,33 +70,33 @@ Current capabilities include:
|
||||
|
||||
Some games have reached like `sceVideoOut` and AGC stages.
|
||||
|
||||
Currently the project primarily targets Windows. Cross-platform support (Linux and macOS) is planned, but development is currently focused on Windows to simplify early-stage debugging and iteration.
|
||||
SharpEmu supports Windows, Linux, and macOS hosts. Video output uses Vulkan on
|
||||
Windows and Linux, and MoltenVK on macOS. Platform support is still experimental,
|
||||
so compatibility and performance vary by game, operating system, and GPU driver.
|
||||
|
||||
## Using
|
||||
|
||||
* Build or Publish project or download in release tab.
|
||||
* Open Powershell.
|
||||
* Run Emulator GUI.
|
||||
* Or command: `.\SharpEmu "eboot.bin" 2>&1 | Tee-Object -FilePath "log.txt"`
|
||||
Download the release archive for your operating system, extract it, and launch
|
||||
SharpEmu with the path to a legally obtained game's `eboot.bin`.
|
||||
|
||||
## Games Tested
|
||||
Windows PowerShell:
|
||||
|
||||
* **Demon's Souls Remake**
|
||||
* [Demon's Souls [PPSA01341]](https://github.com/par274/sharpemu/issues/2)
|
||||
* Demon's Souls is now video loop. Shaders are ready to be converted to SPIR-V/Vulkan. We are continuing our work on this.
|
||||

|
||||
```powershell
|
||||
.\SharpEmu.exe "C:\path\to\game\eboot.bin" 2>&1 |
|
||||
Tee-Object -FilePath "SharpEmu.log"
|
||||
```
|
||||
|
||||
* **Poppy Playtime Chapter 1**
|
||||
* [Poppy Playtime Chapter 1 [PPSA20591]](https://github.com/par274/sharpemu/issues/3)
|
||||
Linux and macOS:
|
||||
|
||||
* **SILENT HILL: The Short Message**
|
||||
* [SILENT HILL: The Short Message [PPSA10112]](https://github.com/par274/sharpemu/issues/4)
|
||||
```bash
|
||||
chmod +x ./SharpEmu
|
||||
|
||||
* **Dreaming Sarah**
|
||||
* [Dreaming Sarah [PPSA02929]](https://github.com/par274/sharpemu/issues/9)
|
||||
* Real texture rendering for this game;
|
||||

|
||||
./SharpEmu "/path/to/game/eboot.bin" 2>&1 |
|
||||
tee SharpEmu.log
|
||||
```
|
||||
|
||||
A Vulkan-capable GPU and current graphics driver are required. The macOS
|
||||
release includes the MoltenVK Vulkan implementation.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This project does **not** support or condone piracy.
|
||||
@@ -94,8 +105,8 @@ Currently the project primarily targets Windows. Cross-platform support (Linux a
|
||||
|
||||
## Build
|
||||
|
||||
1. Install the **.NET SDK**.
|
||||
2. Clone the repository: `git clone https://github.com/par274/sharpemu.git`
|
||||
1. Install the .NET SDK version specified in [`global.json`](./global.json).
|
||||
2. Clone the repository: `git clone https://github.com/sharpemu/sharpemu.git`
|
||||
3. Open the solution file (`SharpEmu.slnx`) in **VSCode**.
|
||||
4. Build the project: `dotnet build` or `dotnet publish`
|
||||
5. Build artifacts will be located in the `artifacts` directory.
|
||||
@@ -121,4 +132,17 @@ Provided valuable references for filesystem handling and low-level C# implementa
|
||||
|
||||
# License
|
||||
|
||||
- [**GPL-2.0 license**](https://github.com/par274/sharpemu/blob/main/LICENSE)
|
||||
- [**GPL-2.0 license**](https://github.com/sharpemu/sharpemu/blob/main/LICENSE)
|
||||
|
||||
## Contributing
|
||||
|
||||
Before opening an issue or pull request, please read our contribution guidelines:
|
||||
|
||||
**[CONTRIBUTING.md](./CONTRIBUTING.md)**
|
||||
|
||||
The guide covers:
|
||||
- Coding style and formatting
|
||||
- AI-assisted contributions
|
||||
- Pull request expectations
|
||||
- Testing guidelines
|
||||
- Legal and reverse engineering policy
|
||||
|
||||
@@ -7,7 +7,7 @@ path = [
|
||||
"global.json",
|
||||
"**/packages.lock.json",
|
||||
"scripts/ps5_names.txt",
|
||||
"src/SharpEmu.HLE/Aerolib/aerolib.bin",
|
||||
"src/SharpEmu.GUI/Languages/**",
|
||||
"_logs/**",
|
||||
".github/images/**",
|
||||
"assets/images/**"
|
||||
|
||||
@@ -11,5 +11,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Project Path="src/SharpEmu.HLE/SharpEmu.HLE.csproj" />
|
||||
<Project Path="src/SharpEmu.Libs/SharpEmu.Libs.csproj" />
|
||||
<Project Path="src/SharpEmu.Logging/SharpEmu.Logging.csproj" />
|
||||
<Project Path="src/SharpEmu.ShaderCompiler/SharpEmu.ShaderCompiler.csproj" />
|
||||
<Project Path="src/SharpEmu.ShaderCompiler.Vulkan/SharpEmu.ShaderCompiler.Vulkan.csproj" />
|
||||
<Project Path="src/SharpEmu.SourceGenerators/SharpEmu.SourceGenerators.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj" />
|
||||
<Project Path="tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
|
After Width: | Height: | Size: 653 B |
|
After Width: | Height: | Size: 698 B |
|
After Width: | Height: | Size: 802 B |
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,46 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
# Bink 2 bridge
|
||||
|
||||
Demon's Souls plays Bink 2 (.bk2) files through a Bink implementation linked
|
||||
directly into eboot.bin. It does not use libSceVideodec, therefore an HLE video
|
||||
decoder cannot observe or replace those frames.
|
||||
|
||||
SharpEmu observes successful guest .bk2 opens and, when a Bink bridge is
|
||||
available, presents its decoded BGRA frames at the normal guest-flip boundary.
|
||||
This preserves the game's own timing and lets the host Vulkan presenter display
|
||||
the movie without trying to execute the PS5-specific Bink GPU decode path.
|
||||
|
||||
Without an adapter, Bink movies are skipped by default: their open call returns
|
||||
not-found so games that mark cinematics as optional progress to their next
|
||||
state instead of waiting on an empty Bink GPU texture.
|
||||
|
||||
Set SHARPEMU_BINK_MODE=dummy to retain the open and show a built-in,
|
||||
non-decoded placeholder frame. This requires no SDK, but is a visual diagnostic
|
||||
only; it does not decode the movie or alter its game logic. Set
|
||||
SHARPEMU_BINK_MODE=native to force native bridge mode.
|
||||
|
||||
## Supplying the adapter
|
||||
|
||||
Bink 2 is proprietary. Obtain a compatible Mac Bink 2 SDK from RAD Game Tools,
|
||||
then compile sharpemu_bink2_bridge.c against the SDK's bink.h and Mac library.
|
||||
The adapter deliberately contains only a three-function C ABI so the managed
|
||||
emulator never depends on RAD's private binary ABI.
|
||||
|
||||
Place the resulting libsharpemu_bink2_bridge.dylib next to the SharpEmu
|
||||
executable, or point to it explicitly:
|
||||
|
||||
SHARPEMU_BINK2_BRIDGE=/absolute/path/libsharpemu_bink2_bridge.dylib \
|
||||
./SharpEmu /path/to/eboot.bin
|
||||
|
||||
The expected exports are sharpemu_bink2_open_utf8,
|
||||
sharpemu_bink2_decode_next_bgra, and sharpemu_bink2_close. The supplied
|
||||
adapter opens one movie, exposes BGRA pixels, and advances after each decoded
|
||||
frame. The managed side validates dimensions and retains ownership of the
|
||||
destination buffer.
|
||||
|
||||
If the bridge is absent in native mode, SharpEmu logs one informational line
|
||||
and retains the existing guest rendering path.
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SharpEmu Emulator Project
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*
|
||||
* Build this small adapter with a licensed RAD Bink 2 SDK. The SDK and its
|
||||
* headers are not distributed by SharpEmu. See docs/bink2-bridge.md.
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include "bink.h"
|
||||
|
||||
typedef struct sharpemu_bink2_info {
|
||||
uint32_t width;
|
||||
uint32_t height;
|
||||
uint32_t frames_per_second_numerator;
|
||||
uint32_t frames_per_second_denominator;
|
||||
} sharpemu_bink2_info;
|
||||
|
||||
int sharpemu_bink2_open_utf8(const char *path, HBINK *movie, sharpemu_bink2_info *info) {
|
||||
HBINK bink;
|
||||
if (!path || !movie || !info) return 0;
|
||||
|
||||
bink = BinkOpen(path, 0);
|
||||
if (!bink) return 0;
|
||||
|
||||
*movie = bink;
|
||||
info->width = bink->Width;
|
||||
info->height = bink->Height;
|
||||
info->frames_per_second_numerator = bink->FrameRate;
|
||||
info->frames_per_second_denominator = bink->FrameRateDiv;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int sharpemu_bink2_decode_next_bgra(HBINK movie, uint8_t *destination,
|
||||
uint32_t stride, uint32_t destination_bytes) {
|
||||
uint64_t needed;
|
||||
if (!movie || !destination || stride < movie->Width * 4) return 0;
|
||||
needed = (uint64_t)stride * movie->Height;
|
||||
if (needed > destination_bytes) return 0;
|
||||
|
||||
/* Async Bink I/O has not filled the next frame yet; retry on the next host present. */
|
||||
if (BinkWait(movie)) return 0;
|
||||
BinkDoFrame(movie);
|
||||
BinkCopyToBuffer(movie, destination, stride, movie->Height, 0, 0, BINKSURFACE32RA);
|
||||
BinkNextFrame(movie);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void sharpemu_bink2_close(HBINK movie) {
|
||||
if (movie) BinkClose(movie);
|
||||
}
|
||||
@@ -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,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright (C) 2026 SharpEmu Emulator Project
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# Downloads the official (universal x86_64+arm64) MoltenVK dylib and stages
|
||||
# it next to a SharpEmu build as libvulkan.1.dylib. The macOS build runs as
|
||||
# an x86-64 process under Rosetta 2, so Homebrew's arm64-only Vulkan
|
||||
# libraries cannot be used; the presenter looks for this app-local copy.
|
||||
#
|
||||
# Usage: scripts/fetch-macos-moltenvk.sh [output-dir]
|
||||
# (default output: artifacts/bin/Debug/net10.0/osx-x64)
|
||||
set -euo pipefail
|
||||
|
||||
MVK_VERSION="${MVK_VERSION:-v1.4.0}"
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUT_DIR="${1:-$REPO_ROOT/artifacts/bin/Debug/net10.0/osx-x64}"
|
||||
|
||||
if [[ ! -d "$OUT_DIR" ]]; then
|
||||
echo "output directory does not exist: $OUT_DIR (build first?)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||
|
||||
echo ">> Downloading MoltenVK $MVK_VERSION..."
|
||||
curl -sL -o "$WORK_DIR/mvk.tar" \
|
||||
"https://github.com/KhronosGroup/MoltenVK/releases/download/$MVK_VERSION/MoltenVK-macos.tar"
|
||||
tar -xf "$WORK_DIR/mvk.tar" -C "$WORK_DIR" \
|
||||
MoltenVK/MoltenVK/dynamic/dylib/macOS/libMoltenVK.dylib
|
||||
|
||||
DYLIB="$WORK_DIR/MoltenVK/MoltenVK/dynamic/dylib/macOS/libMoltenVK.dylib"
|
||||
file "$DYLIB" | grep -q x86_64 || { echo "downloaded dylib lacks x86_64 slice" >&2; exit 3; }
|
||||
|
||||
cp "$DYLIB" "$OUT_DIR/libMoltenVK.dylib"
|
||||
cp "$DYLIB" "$OUT_DIR/libvulkan.1.dylib"
|
||||
echo ">> Staged libMoltenVK.dylib + libvulkan.1.dylib in $OUT_DIR"
|
||||
@@ -1,53 +0,0 @@
|
||||
# Copyright (C) 2026 SharpEmu Emulator Project
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import struct
|
||||
import hashlib
|
||||
from base64 import b64encode as base64enc
|
||||
from binascii import unhexlify as uhx
|
||||
from pathlib import Path
|
||||
|
||||
NAMES = 'scripts/ps5_names.txt'
|
||||
OUTPUT = 'src/SharpEmu.HLE/Aerolib/aerolib.bin'
|
||||
|
||||
def name2nid(name):
|
||||
symbol = hashlib.sha1(name.encode() + uhx('518D64A635DED8C1E6B039B1C3E55230')).digest()
|
||||
id_val = struct.unpack('<Q', symbol[:8])[0]
|
||||
nid = base64enc(uhx('%016x' % id_val), b'+-').rstrip(b'=')
|
||||
return nid.decode('utf-8')
|
||||
|
||||
def generate():
|
||||
names_path = Path(NAMES)
|
||||
output_path = Path(OUTPUT)
|
||||
|
||||
entries = []
|
||||
with open(names_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
name = line.strip()
|
||||
if name:
|
||||
nid = name2nid(name)
|
||||
entries.append((nid, name))
|
||||
|
||||
print(f"Found {len(entries)} entries")
|
||||
|
||||
data = bytearray()
|
||||
data.extend(struct.pack('<I', len(entries)))
|
||||
|
||||
for nid, name in entries:
|
||||
nid_bytes = nid.encode('utf-8')
|
||||
name_bytes = name.encode('utf-8')
|
||||
data.append(len(nid_bytes))
|
||||
data.extend(nid_bytes)
|
||||
data.extend(struct.pack('<H', len(name_bytes)))
|
||||
data.extend(name_bytes)
|
||||
|
||||
with open(output_path, 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
print(f"Generated: {output_path} ({len(data):,} bytes)")
|
||||
print(f"Total entries: {len(entries)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate()
|
||||
@@ -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())
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright (C) 2026 SharpEmu Emulator Project
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# Smoke-tests the linux-x64 build inside an amd64 container. Useful from any
|
||||
# host (including Apple Silicon, where Docker runs the amd64 image under
|
||||
# emulation) to confirm the cross-platform layer keeps working on Linux.
|
||||
#
|
||||
# Usage: scripts/test-linux-docker.sh /path/to/eboot.bin
|
||||
set -euo pipefail
|
||||
|
||||
GAME_PATH="${1:-}"
|
||||
if [[ -z "$GAME_PATH" || ! -f "$GAME_PATH" ]]; then
|
||||
echo "usage: $0 <path-to-eboot.bin>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
GAME_DIR="$(cd "$(dirname "$GAME_PATH")" && pwd)"
|
||||
GAME_FILE="$(basename "$GAME_PATH")"
|
||||
PUBLISH_DIR="$REPO_ROOT/artifacts/publish/SharpEmu.CLI/Debug/net10.0/linux-x64"
|
||||
|
||||
echo ">> Publishing linux-x64 self-contained build..."
|
||||
dotnet publish "$REPO_ROOT/src/SharpEmu.CLI" \
|
||||
-c Debug -r linux-x64 --self-contained -p:PublishSingleFile=false
|
||||
|
||||
echo ">> Running inside linux/amd64 container..."
|
||||
docker run --rm --platform linux/amd64 \
|
||||
-v "$PUBLISH_DIR":/app:ro \
|
||||
-v "$GAME_DIR":/game:ro \
|
||||
mcr.microsoft.com/dotnet/runtime-deps:10.0 \
|
||||
/app/SharpEmu --log-level=info "/game/$GAME_FILE"
|
||||
@@ -5,15 +5,19 @@ using SharpEmu.Core.Runtime;
|
||||
using SharpEmu.Core.Cpu;
|
||||
using SharpEmu.GUI;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.VideoOut;
|
||||
using SharpEmu.Logging;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SharpEmu.CLI;
|
||||
|
||||
internal static partial class Program
|
||||
{
|
||||
private static readonly SharpEmuLogger Log = SharpEmuLog.For("SharpEmu.CLI");
|
||||
private static readonly object ConsoleMirrorSync = new();
|
||||
private static StreamWriter? _consoleMirrorFile;
|
||||
private const int DefaultImportTraceLimit = 32;
|
||||
private const string MitigatedChildFlag = "--sharpemu-mitigated-child";
|
||||
private const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000;
|
||||
@@ -21,11 +25,15 @@ internal static partial class Program
|
||||
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 int STARTF_USESTDHANDLES = 0x00000100;
|
||||
private const uint HANDLE_FLAG_INHERIT = 0x00000001;
|
||||
private const string MitigatedChildEnvironment = "SHARPEMU_MITIGATED_CHILD";
|
||||
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;
|
||||
private const int ATTACH_PARENT_PROCESS = -1;
|
||||
private const int STD_INPUT_HANDLE = -10;
|
||||
private const int STD_OUTPUT_HANDLE = -11;
|
||||
private const int STD_ERROR_HANDLE = -12;
|
||||
private const uint GENERIC_READ = 0x80000000;
|
||||
@@ -37,23 +45,33 @@ internal static partial class Program
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
// Avoid blocking full collections while guest and render threads are
|
||||
// running, and establish the GC mode before the runtime reserves the
|
||||
// fixed guest address-space window.
|
||||
System.Runtime.GCSettings.LatencyMode = System.Runtime.GCLatencyMode.SustainedLowLatency;
|
||||
|
||||
try
|
||||
{
|
||||
return Run(args);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DropConsoleFileMirror();
|
||||
SharpEmuLog.Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private static int Run(string[] args)
|
||||
{
|
||||
args = NormalizeInternalArguments(args, out var isMitigatedChild);
|
||||
if (args.Length == 0 && !isMitigatedChild)
|
||||
if (Updater.TryApply(args, out var updateExitCode))
|
||||
{
|
||||
return updateExitCode;
|
||||
}
|
||||
|
||||
args = NormalizeInternalArguments(args, out var isMitigatedChild);
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
// No arguments: open the desktop frontend. Any argument selects
|
||||
// the classic CLI behavior below.
|
||||
return GuiLauncher.Run();
|
||||
}
|
||||
|
||||
@@ -61,7 +79,147 @@ internal static partial class Program
|
||||
// itself to a console before the first write.
|
||||
EnsureCliConsole();
|
||||
UseUtf8ConsoleOutput();
|
||||
if (isMitigatedChild && TryGetLogFileArgument(args, out var earlyLogFilePath))
|
||||
{
|
||||
TryEnableConsoleFileMirror(earlyLogFilePath);
|
||||
}
|
||||
|
||||
if (!CheckHostArchitecture())
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS() || OperatingSystem.IsLinux())
|
||||
{
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
ConfigureMoltenVkDefaults();
|
||||
PreloadMacVulkanLoader();
|
||||
}
|
||||
|
||||
// GLFW requires window creation and event processing on the
|
||||
// process main thread: AppKit demands it on macOS, and X11 has a
|
||||
// single event queue that must be serviced from the main thread
|
||||
// (a window created and polled off it may never map, which showed
|
||||
// as a running game with no visible window on Linux). Emulation
|
||||
// moves to a worker thread and the main thread services the window
|
||||
// work the video presenter posts. Windows keeps a per-thread event
|
||||
// queue, so its window stays on the presenter's own thread.
|
||||
var exitCode = 0;
|
||||
HostMainThread.Enable();
|
||||
var emulation = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
exitCode = RunEmulator(args, isMitigatedChild);
|
||||
}
|
||||
finally
|
||||
{
|
||||
HostMainThread.Shutdown();
|
||||
}
|
||||
}, 32 * 1024 * 1024)
|
||||
{
|
||||
Name = "SharpEmu Emulation",
|
||||
};
|
||||
emulation.Start();
|
||||
HostMainThread.Pump();
|
||||
emulation.Join();
|
||||
return exitCode;
|
||||
}
|
||||
|
||||
return RunEmulator(args, isMitigatedChild);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The supported host execution model, checked before any emulation
|
||||
/// starts: the CPU backend executes guest x86-64 code natively, so the
|
||||
/// host process must be x86-64 — win-x64/linux-x64 on x64 hardware, or
|
||||
/// osx-x64 under Rosetta 2 on Apple Silicon (Rosetta translates the
|
||||
/// whole process, so it still reports as X64 here). An arm64 process
|
||||
/// (e.g. the osx-arm64 build) can browse the GUI but cannot run games;
|
||||
/// failing up front distinguishes that from MoltenVK, signal-handler,
|
||||
/// or guest-memory startup problems.
|
||||
/// </summary>
|
||||
private static bool CheckHostArchitecture()
|
||||
{
|
||||
if (RuntimeInformation.ProcessArchitecture == Architecture.X64)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][ERROR] Unsupported process architecture " +
|
||||
$"{RuntimeInformation.ProcessArchitecture}: guest code executes " +
|
||||
"natively, so SharpEmu must run as an x86-64 process.");
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][ERROR] On Apple Silicon, use the osx-x64 build under " +
|
||||
"Rosetta 2 (install with: softwareupdate --install-rosetta).");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies MoltenVK performance defaults before the Vulkan loader is
|
||||
/// loaded. Existing user-provided values always take precedence.
|
||||
/// </summary>
|
||||
private static void ConfigureMoltenVkDefaults()
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = MacSetEnv("MVK_CONFIG_SYNCHRONOUS_QUEUE_SUBMITS", "0", 0);
|
||||
_ = MacSetEnv("MVK_CONFIG_SHOULD_MAXIMIZE_CONCURRENT_COMPILATION", "1", 0);
|
||||
_ = MacSetEnv("MVK_CONFIG_USE_METAL_ARGUMENT_BUFFERS", "1", 0);
|
||||
_ = MacSetEnv("MVK_CONFIG_RESUME_LOST_DEVICE", "1", 0);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Failed to set MoltenVK defaults: {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes a Vulkan loader visible to GLFW's dlopen("libvulkan.1.dylib").
|
||||
/// Homebrew's Vulkan libraries are arm64-only and cannot load into this
|
||||
/// x86-64 (Rosetta 2) process, so a universal libMoltenVK.dylib placed
|
||||
/// next to the executable (named libvulkan.1.dylib) is preloaded here;
|
||||
/// dyld then resolves GLFW's bare-name dlopen to the loaded image.
|
||||
/// </summary>
|
||||
private static void PreloadMacVulkanLoader()
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "libvulkan.1.dylib"),
|
||||
Path.Combine(AppContext.BaseDirectory, "libMoltenVK.dylib"),
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
".sharpemu", "x64lib", "libvulkan.1.dylib"),
|
||||
};
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (File.Exists(candidate) && NativeLibrary.TryLoad(candidate, out _))
|
||||
{
|
||||
Console.Error.WriteLine($"[LOADER][INFO] Vulkan loader preloaded: {candidate}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (NativeLibrary.TryLoad("libvulkan.1.dylib", out _))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] No x86-64 Vulkan loader found; video output will be unavailable. " +
|
||||
"Place a universal libMoltenVK.dylib (from the MoltenVK releases) next to SharpEmu " +
|
||||
"as libvulkan.1.dylib.");
|
||||
}
|
||||
|
||||
private static int RunEmulator(string[] args, bool isMitigatedChild)
|
||||
{
|
||||
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
|
||||
|
||||
if (!isMitigatedChild && TryRunMitigatedChild(args, out var childExitCode))
|
||||
@@ -69,15 +227,31 @@ internal static partial class Program
|
||||
return childExitCode;
|
||||
}
|
||||
|
||||
if (!TryParseArguments(args, out var ebootPath, out var runtimeOptions, out var logLevel))
|
||||
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;
|
||||
}
|
||||
|
||||
if (!isMitigatedChild && !string.IsNullOrWhiteSpace(logFilePath))
|
||||
{
|
||||
TryEnableConsoleFileMirror(logFilePath);
|
||||
}
|
||||
|
||||
SharpEmuLog.MinimumLevel = logLevel;
|
||||
|
||||
Log.Info(BuildInfo.Banner);
|
||||
Log.Info(HostSystemInfo.Summary);
|
||||
|
||||
ebootPath = Path.GetFullPath(ebootPath);
|
||||
Console.Error.WriteLine($"[DEBUG] Full path: {ebootPath}");
|
||||
@@ -90,51 +264,122 @@ internal static partial class Program
|
||||
|
||||
Console.Error.WriteLine("[DEBUG] Creating runtime...");
|
||||
|
||||
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
|
||||
|
||||
OrbisGen2Result result;
|
||||
try
|
||||
{
|
||||
Console.Error.WriteLine($"[DEBUG] Running: {ebootPath}");
|
||||
result = runtime.Run(ebootPath);
|
||||
Console.Error.WriteLine($"[DEBUG] Result: {result}");
|
||||
if (hostSurface is not null && !VulkanVideoHost.TryAttachSurface(hostSurface))
|
||||
{
|
||||
Console.Error.WriteLine("[LOADER][ERROR] The requested GUI host surface is already active.");
|
||||
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;
|
||||
}
|
||||
catch (Exception ex)
|
||||
finally
|
||||
{
|
||||
Console.Error.WriteLine($"[DEBUG] Exception: {ex}");
|
||||
Log.Error("SharpEmu failed to run.", ex);
|
||||
return 3;
|
||||
HostSessionControl.SetEmbeddedHostSurface(0);
|
||||
if (hostSurface is not null)
|
||||
{
|
||||
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()
|
||||
@@ -231,9 +476,15 @@ 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(
|
||||
Environment.GetEnvironmentVariable(MitigatedChildEnvironment),
|
||||
"1",
|
||||
StringComparison.Ordinal);
|
||||
if (args.Length == 0)
|
||||
{
|
||||
return args;
|
||||
@@ -244,7 +495,7 @@ internal static partial class Program
|
||||
{
|
||||
if (string.Equals(arg, MitigatedChildFlag, StringComparison.Ordinal))
|
||||
{
|
||||
isMitigatedChild = true;
|
||||
isMitigatedChild = trustedMitigatedChild;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -283,9 +534,11 @@ internal static partial class Program
|
||||
var commandLine = BuildCommandLine(processPath, childArgs);
|
||||
var startupInfoEx = new STARTUPINFOEX();
|
||||
startupInfoEx.StartupInfo.cb = Marshal.SizeOf<STARTUPINFOEX>();
|
||||
ConfigureInheritedStdHandles(ref startupInfoEx.StartupInfo);
|
||||
|
||||
nint attributeList = 0;
|
||||
nint mitigationPolicies = 0;
|
||||
var previousChildEnvironment = Environment.GetEnvironmentVariable(MitigatedChildEnvironment);
|
||||
try
|
||||
{
|
||||
nuint attributeListSize = 0;
|
||||
@@ -293,7 +546,9 @@ internal static partial class Program
|
||||
attributeList = Marshal.AllocHGlobal((nint)attributeListSize);
|
||||
if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref attributeListSize))
|
||||
{
|
||||
return false;
|
||||
childExitCode = 5;
|
||||
Console.Error.WriteLine($"[ERROR] Failed to initialize mitigation attributes: {Marshal.GetLastWin32Error()}");
|
||||
return true;
|
||||
}
|
||||
|
||||
startupInfoEx.lpAttributeList = attributeList;
|
||||
@@ -301,8 +556,7 @@ internal static partial class Program
|
||||
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 |
|
||||
PROCESS_CREATION_MITIGATION_POLICY2_XTENDED_CONTROL_FLOW_GUARD_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));
|
||||
@@ -317,24 +571,31 @@ internal static partial class Program
|
||||
0,
|
||||
0))
|
||||
{
|
||||
return false;
|
||||
childExitCode = 5;
|
||||
Console.Error.WriteLine($"[ERROR] Failed to apply mitigation attributes: {Marshal.GetLastWin32Error()}");
|
||||
return true;
|
||||
}
|
||||
|
||||
var cmdLineBuilder = new StringBuilder(commandLine);
|
||||
nint jobHandle = 0;
|
||||
if (!CreateProcessW(
|
||||
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
|
||||
var created = CreateProcessW(
|
||||
processPath,
|
||||
cmdLineBuilder,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
true,
|
||||
EXTENDED_STARTUPINFO_PRESENT,
|
||||
0,
|
||||
Environment.CurrentDirectory,
|
||||
ref startupInfoEx,
|
||||
out var processInfo))
|
||||
out var processInfo);
|
||||
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, previousChildEnvironment);
|
||||
if (!created)
|
||||
{
|
||||
return false;
|
||||
childExitCode = 5;
|
||||
Console.Error.WriteLine($"[ERROR] Failed to launch mitigated child process: {Marshal.GetLastWin32Error()}");
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
@@ -388,6 +649,8 @@ internal static partial class Program
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, previousChildEnvironment);
|
||||
|
||||
if (attributeList != 0)
|
||||
{
|
||||
DeleteProcThreadAttributeList(attributeList);
|
||||
@@ -401,6 +664,238 @@ internal static partial class Program
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetLogFileArgument(IReadOnlyList<string> args, out string path)
|
||||
{
|
||||
for (var i = 0; i < args.Count; i++)
|
||||
{
|
||||
var argument = args[i];
|
||||
if (string.Equals(argument, "--log-file", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (i + 1 < args.Count &&
|
||||
!string.IsNullOrWhiteSpace(args[i + 1]) &&
|
||||
!args[i + 1].StartsWith("--", StringComparison.Ordinal) &&
|
||||
ShouldConsumeLogFilePath(args, i + 1))
|
||||
{
|
||||
path = args[i + 1];
|
||||
return true;
|
||||
}
|
||||
|
||||
path = BuildDefaultLogFilePath(TryFindEbootPathToken(args));
|
||||
return true;
|
||||
}
|
||||
|
||||
const string logFilePrefix = "--log-file=";
|
||||
if (argument.StartsWith(logFilePrefix, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(argument[logFilePrefix.Length..]))
|
||||
{
|
||||
path = argument[logFilePrefix.Length..];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
path = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string BuildDefaultLogFilePath(string? ebootPath)
|
||||
{
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
var logsDirectory = Path.Combine(baseDirectory, "user", "logs");
|
||||
var name = TryReadTitleId(ebootPath) ?? "UNKNOWN";
|
||||
|
||||
foreach (var invalid in Path.GetInvalidFileNameChars())
|
||||
{
|
||||
name = name.Replace(invalid, '_');
|
||||
}
|
||||
|
||||
return Path.Combine(logsDirectory, $"{name}-{DateTime.Now:yyyyMMdd-HHmmss}.log");
|
||||
}
|
||||
|
||||
private static string? TryReadTitleId(string? ebootPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ebootPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(ebootPath));
|
||||
if (string.IsNullOrEmpty(directory))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var paramPath in new[]
|
||||
{
|
||||
Path.Combine(directory, "sce_sys", "param.json"),
|
||||
Path.Combine(directory, "param.json"),
|
||||
})
|
||||
{
|
||||
if (!File.Exists(paramPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(paramPath);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
if (document.RootElement.TryGetProperty("titleId", out var titleIdElement) &&
|
||||
titleIdElement.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var titleId = titleIdElement.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(titleId))
|
||||
{
|
||||
return titleId.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Logging should never block launch; unknown title ids use a stable fallback.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? TryFindEbootPathToken(IReadOnlyList<string> args)
|
||||
{
|
||||
for (var i = args.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var argument = args[i];
|
||||
if (string.IsNullOrWhiteSpace(argument) ||
|
||||
argument.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return argument;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool ShouldConsumeLogFilePath(IReadOnlyList<string> args, int candidateIndex)
|
||||
{
|
||||
var candidate = args[candidateIndex];
|
||||
if (LooksLikeLogFilePath(candidate))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
for (var i = candidateIndex + 1; i < args.Count; i++)
|
||||
{
|
||||
var argument = args[i];
|
||||
if (!string.IsNullOrWhiteSpace(argument) &&
|
||||
!argument.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool LooksLikeLogFilePath(string path)
|
||||
{
|
||||
var extension = Path.GetExtension(path);
|
||||
return string.Equals(extension, ".log", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static void TryEnableConsoleFileMirror(string path)
|
||||
{
|
||||
lock (ConsoleMirrorSync)
|
||||
{
|
||||
if (_consoleMirrorFile is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.ReadWrite,
|
||||
bufferSize: 4096,
|
||||
FileOptions.SequentialScan);
|
||||
_consoleMirrorFile = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false))
|
||||
{
|
||||
AutoFlush = true,
|
||||
};
|
||||
|
||||
Console.SetOut(new TeeTextWriter(Console.Out, _consoleMirrorFile));
|
||||
Console.SetError(new TeeTextWriter(Console.Error, _consoleMirrorFile));
|
||||
Console.Error.WriteLine($"[DEBUG] Log file: {Path.GetFullPath(path)}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[WARN] Could not open log file '{path}': {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void DropConsoleFileMirror()
|
||||
{
|
||||
lock (ConsoleMirrorSync)
|
||||
{
|
||||
try
|
||||
{
|
||||
_consoleMirrorFile?.Flush();
|
||||
_consoleMirrorFile?.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
_consoleMirrorFile = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureInheritedStdHandles(ref STARTUPINFO startupInfo)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var input = GetStdHandle(STD_INPUT_HANDLE);
|
||||
var output = GetStdHandle(STD_OUTPUT_HANDLE);
|
||||
var error = GetStdHandle(STD_ERROR_HANDLE);
|
||||
if (!IsHandleValid(output) && !IsHandleValid(error))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsHandleValid(input))
|
||||
{
|
||||
_ = SetHandleInformation(input, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);
|
||||
startupInfo.hStdInput = input;
|
||||
}
|
||||
|
||||
if (IsHandleValid(output))
|
||||
{
|
||||
_ = SetHandleInformation(output, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);
|
||||
startupInfo.hStdOutput = output;
|
||||
}
|
||||
|
||||
if (IsHandleValid(error))
|
||||
{
|
||||
_ = SetHandleInformation(error, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT);
|
||||
startupInfo.hStdError = error;
|
||||
}
|
||||
|
||||
startupInfo.dwFlags |= STARTF_USESTDHANDLES;
|
||||
}
|
||||
|
||||
private static string BuildCommandLine(string processPath, IReadOnlyList<string> args)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
@@ -503,27 +998,30 @@ internal static partial class Program
|
||||
|
||||
private static void PrintUsage()
|
||||
{
|
||||
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--log-level=<level>] <path-to-eboot.bin>");
|
||||
Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug ""E:\Games\...\eboot.bin""");
|
||||
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--log-level=<level>] [--log-file[=<path>]] <path-to-eboot.bin>");
|
||||
Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin""");
|
||||
}
|
||||
|
||||
private static bool TryParseArguments(
|
||||
string[] args,
|
||||
out string ebootPath,
|
||||
out SharpEmuRuntimeOptions runtimeOptions,
|
||||
out LogLevel logLevel)
|
||||
out LogLevel logLevel,
|
||||
out string? logFilePath)
|
||||
{
|
||||
if (args.Length == 0)
|
||||
{
|
||||
ebootPath = string.Empty;
|
||||
runtimeOptions = default;
|
||||
logLevel = SharpEmuLog.MinimumLevel;
|
||||
logFilePath = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
var strictDynlibResolution = false;
|
||||
var importTraceLimit = 0;
|
||||
var cpuEngine = CpuExecutionEngine.NativeOnly;
|
||||
logFilePath = null;
|
||||
logLevel = SharpEmuLog.MinimumLevel;
|
||||
var pathTokens = new List<string>(args.Length);
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
@@ -553,6 +1051,7 @@ internal static partial class Program
|
||||
{
|
||||
ebootPath = string.Empty;
|
||||
runtimeOptions = default;
|
||||
logFilePath = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -566,6 +1065,7 @@ internal static partial class Program
|
||||
{
|
||||
ebootPath = string.Empty;
|
||||
runtimeOptions = default;
|
||||
logFilePath = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -573,6 +1073,23 @@ internal static partial class Program
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(argument, "--log-file", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (i + 1 < args.Length &&
|
||||
!string.IsNullOrWhiteSpace(args[i + 1]) &&
|
||||
!args[i + 1].StartsWith("--", StringComparison.Ordinal) &&
|
||||
ShouldConsumeLogFilePath(args, i + 1))
|
||||
{
|
||||
logFilePath = args[++i];
|
||||
}
|
||||
else
|
||||
{
|
||||
logFilePath = BuildDefaultLogFilePath(TryFindEbootPathToken(args));
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const string logLevelPrefix = "--log-level=";
|
||||
if (argument.StartsWith(logLevelPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -596,6 +1113,7 @@ internal static partial class Program
|
||||
ebootPath = string.Empty;
|
||||
runtimeOptions = default;
|
||||
logLevel = SharpEmuLog.MinimumLevel;
|
||||
logFilePath = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -618,11 +1136,27 @@ internal static partial class Program
|
||||
continue;
|
||||
}
|
||||
|
||||
const string logFilePrefix = "--log-file=";
|
||||
if (argument.StartsWith(logFilePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
logFilePath = argument[logFilePrefix.Length..];
|
||||
if (string.IsNullOrWhiteSpace(logFilePath))
|
||||
{
|
||||
ebootPath = string.Empty;
|
||||
runtimeOptions = default;
|
||||
logLevel = SharpEmuLog.MinimumLevel;
|
||||
return false;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (argument.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
ebootPath = string.Empty;
|
||||
runtimeOptions = default;
|
||||
logLevel = SharpEmuLog.MinimumLevel;
|
||||
logFilePath = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -634,6 +1168,7 @@ internal static partial class Program
|
||||
ebootPath = string.Empty;
|
||||
runtimeOptions = default;
|
||||
logLevel = SharpEmuLog.MinimumLevel;
|
||||
logFilePath = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -735,6 +1270,56 @@ internal static partial class Program
|
||||
public nuint PeakJobMemoryUsed;
|
||||
}
|
||||
|
||||
private sealed class TeeTextWriter : TextWriter
|
||||
{
|
||||
private readonly TextWriter _primary;
|
||||
private readonly TextWriter _mirror;
|
||||
|
||||
public TeeTextWriter(TextWriter primary, TextWriter mirror)
|
||||
{
|
||||
_primary = primary;
|
||||
_mirror = mirror;
|
||||
}
|
||||
|
||||
public override Encoding Encoding => _primary.Encoding;
|
||||
|
||||
public override void Write(char value)
|
||||
{
|
||||
lock (ConsoleMirrorSync)
|
||||
{
|
||||
_primary.Write(value);
|
||||
_mirror.Write(value);
|
||||
}
|
||||
}
|
||||
|
||||
public override void Write(string? value)
|
||||
{
|
||||
lock (ConsoleMirrorSync)
|
||||
{
|
||||
_primary.Write(value);
|
||||
_mirror.Write(value);
|
||||
}
|
||||
}
|
||||
|
||||
public override void WriteLine(string? value)
|
||||
{
|
||||
lock (ConsoleMirrorSync)
|
||||
{
|
||||
_primary.WriteLine(value);
|
||||
_mirror.WriteLine(value);
|
||||
}
|
||||
}
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
lock (ConsoleMirrorSync)
|
||||
{
|
||||
_primary.Flush();
|
||||
_mirror.Flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool InitializeProcThreadAttributeList(
|
||||
@@ -819,6 +1404,10 @@ internal static partial class Program
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetStdHandle(int stdHandle, nint handle);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetHandleInformation(nint handle, uint mask, uint flags);
|
||||
|
||||
[DllImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
private static extern nint CreateFileW(
|
||||
string fileName,
|
||||
@@ -828,4 +1417,7 @@ internal static partial class Program
|
||||
uint creationDisposition,
|
||||
uint flagsAndAttributes,
|
||||
nint templateFile);
|
||||
|
||||
[DllImport("libSystem", EntryPoint = "setenv")]
|
||||
private static extern int MacSetEnv(string name, string value, int overwrite);
|
||||
}
|
||||
|
||||
@@ -16,14 +16,32 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
console window; CLI mode re-attaches to the parent terminal's console. -->
|
||||
<OutputType>WinExe</OutputType>
|
||||
<AssemblyName>SharpEmu</AssemblyName>
|
||||
<RuntimeIdentifiers>win-x64;linux-x64;osx-arm64</RuntimeIdentifiers>
|
||||
<!-- osx-x64 is the macOS target: the CPU backend executes guest x86-64
|
||||
natively, so on Apple Silicon it runs under Rosetta 2. -->
|
||||
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<Version>0.0.1</Version>
|
||||
<!-- Server GC reserves one large heap range per logical processor. On
|
||||
high-core-count hosts those ranges can overlap fixed PS5 image bases
|
||||
before the guest address space is established. Workstation GC avoids
|
||||
that collision and keeps the emulator's required mappings available. -->
|
||||
<ServerGarbageCollection>false</ServerGarbageCollection>
|
||||
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
|
||||
<TieredPGO>true</TieredPGO>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Background GC's write-watch revisit calls FlushProcessWriteBuffers,
|
||||
which on macOS uses thread_get_register_pointer_values; under Rosetta 2
|
||||
that Mach call can stall indefinitely on threads executing translated
|
||||
guest code, wedging the whole runtime (every allocating thread then
|
||||
blocks behind the never-finishing GC). Non-concurrent GC never takes
|
||||
that path. Windows and Linux keep concurrent GC. -->
|
||||
<PropertyGroup Condition="$([System.String]::Copy('$(RuntimeIdentifier)').StartsWith('osx'))">
|
||||
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
|
||||
@@ -36,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>
|
||||
@@ -48,6 +67,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<TargetPath>LICENSE.txt</TargetPath>
|
||||
<Visible>False</Visible>
|
||||
</Content>
|
||||
<Content Include="..\SharpEmu.GUI\Languages\*.json" Condition="'$(Configuration)' != 'Release'">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<TargetPath>Languages\%(Filename)%(Extension)</TargetPath>
|
||||
<Visible>False</Visible>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Keep glfw as a loose file next to the executable; every other native
|
||||
@@ -55,7 +79,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Target Name="KeepGlfwOutsideSingleFile" AfterTargets="ComputeResolvedFilesToPublishList">
|
||||
<ItemGroup>
|
||||
<_GlfwPublishFiles Include="@(ResolvedFileToPublish)"
|
||||
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw'))" />
|
||||
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
|
||||
<ResolvedFileToPublish Remove="@(_GlfwPublishFiles)" />
|
||||
<ResolvedFileToPublish Include="@(_GlfwPublishFiles)">
|
||||
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
||||
|
||||
@@ -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>
|
||||
@@ -135,6 +135,23 @@
|
||||
"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",
|
||||
@@ -199,9 +216,9 @@
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Iced": "[1.21.0, )",
|
||||
"SharpEmu.HLE": "[1.0.0, )",
|
||||
"SharpEmu.Libs": "[1.0.0, )",
|
||||
"SharpEmu.Logging": "[1.0.0, )"
|
||||
"SharpEmu.HLE": "[0.0.2-beta.2, )",
|
||||
"SharpEmu.Libs": "[0.0.2-beta.2, )",
|
||||
"SharpEmu.Logging": "[0.0.2-beta.2, )"
|
||||
}
|
||||
},
|
||||
"sharpemu.gui": {
|
||||
@@ -211,17 +228,25 @@
|
||||
"Avalonia.Desktop": "[11.3.18, )",
|
||||
"Avalonia.Fonts.Inter": "[11.3.18, )",
|
||||
"Avalonia.Themes.Fluent": "[11.3.18, )",
|
||||
"SharpEmu.Logging": "[1.0.0, )",
|
||||
"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"
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SharpEmu.Logging": "[0.0.2-beta.2, )"
|
||||
}
|
||||
},
|
||||
"sharpemu.libs": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SharpEmu.HLE": "[1.0.0, )",
|
||||
"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, )",
|
||||
@@ -231,6 +256,18 @@
|
||||
"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, )",
|
||||
@@ -279,6 +316,16 @@
|
||||
"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, )",
|
||||
@@ -431,6 +478,59 @@
|
||||
"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",
|
||||
|
||||
@@ -7,29 +7,33 @@ using SharpEmu.Core.Cpu.Native;
|
||||
using SharpEmu.Core.Loader;
|
||||
using SharpEmu.Core.Memory;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Logging;
|
||||
|
||||
namespace SharpEmu.Core.Cpu;
|
||||
|
||||
public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
{
|
||||
private static readonly SharpEmuLogger Log = SharpEmuLog.For("Dispatcher");
|
||||
|
||||
private enum EntryFrameKind
|
||||
{
|
||||
ProcessEntry,
|
||||
ModuleInitializer,
|
||||
}
|
||||
|
||||
private const ulong StackBaseAddress = 0x7FFF_F000_0000UL;
|
||||
// The top of the x86-64 user address space (0x7FFD..0x7FFF) is only
|
||||
// freely mappable on Windows; on macOS/Linux it hosts the dyld shared
|
||||
// cache / vdso and (under Rosetta 2) the translator runtime, so POSIX
|
||||
// hosts use the equivalent layout one slot lower at 0x6FFx.
|
||||
private static readonly ulong StackBaseAddress = OperatingSystem.IsWindows() ? 0x7FFF_F000_0000UL : 0x6FFF_F000_0000UL;
|
||||
private const ulong StackSize = 0x0020_0000UL;
|
||||
private const ulong TlsBaseAddress = 0x7FFE_0000_0000UL;
|
||||
private static readonly ulong TlsBaseAddress = OperatingSystem.IsWindows() ? 0x7FFE_0000_0000UL : 0x6FFE_0000_0000UL;
|
||||
private const ulong TlsSize = 0x0001_0000UL;
|
||||
private const ulong TlsPrefixSize = 0x0000_1000UL;
|
||||
private const ulong BootstrapStubBaseAddress = 0x7FFD_F000_0000UL;
|
||||
private const ulong BootstrapPayloadBaseAddress = 0x7FFD_E000_0000UL;
|
||||
private const ulong DynlibFallbackStubBaseAddress = 0x7FFD_D000_0000UL;
|
||||
private const ulong ReturnToHostStubBaseAddress = 0x7FFD_C000_0000UL;
|
||||
// The static TLS blocks live at negative offsets from the TCB (FreeBSD
|
||||
// amd64 variant II). Keep every host in sync with GuestTlsTemplate's
|
||||
// startup reservation; PS5 modules routinely reach beyond one host page.
|
||||
private const ulong TlsPrefixSize = GuestTlsTemplate.StartupStaticTlsReservation;
|
||||
private static readonly ulong BootstrapStubBaseAddress = OperatingSystem.IsWindows() ? 0x7FFD_F000_0000UL : 0x6FFD_F000_0000UL;
|
||||
private static readonly ulong BootstrapPayloadBaseAddress = OperatingSystem.IsWindows() ? 0x7FFD_E000_0000UL : 0x6FFD_E000_0000UL;
|
||||
private static readonly ulong DynlibFallbackStubBaseAddress = OperatingSystem.IsWindows() ? 0x7FFD_D000_0000UL : 0x6FFD_D000_0000UL;
|
||||
private static readonly ulong ReturnToHostStubBaseAddress = OperatingSystem.IsWindows() ? 0x7FFD_C000_0000UL : 0x6FFD_C000_0000UL;
|
||||
private const ulong BootstrapRegionSize = 0x0000_1000UL;
|
||||
private const ulong ReturnToHostStubStride = 0x0100_0000UL;
|
||||
private const ulong BootstrapPayloadResultOffset = 0x28UL;
|
||||
@@ -83,8 +87,8 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
string processImageName = "eboot.bin",
|
||||
CpuExecutionOptions executionOptions = default)
|
||||
{
|
||||
Log.Debug("=== DispatchEntry START ===");
|
||||
Log.Debug($"entryPoint=0x{entryPoint:X16}, generation={generation}");
|
||||
Console.Error.WriteLine("[DISPATCHER] === DispatchEntry START ===");
|
||||
Console.Error.WriteLine($"[DISPATCHER] entryPoint=0x{entryPoint:X16}, generation={generation}");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -92,7 +96,8 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Critical($"FATAL EXCEPTION in DispatchEntry: {ex.GetType().Name}: {ex.Message}", ex);
|
||||
Console.Error.WriteLine($"[DISPATCHER] FATAL EXCEPTION in DispatchEntry: {ex.GetType().Name}: {ex.Message}");
|
||||
Console.Error.WriteLine($"[DISPATCHER] Stack trace: {ex.StackTrace}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -105,8 +110,8 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
string moduleName = "module",
|
||||
CpuExecutionOptions executionOptions = default)
|
||||
{
|
||||
Log.Debug("=== DispatchModuleInitializer START ===");
|
||||
Log.Debug($"moduleInit=0x{entryPoint:X16}, generation={generation}, module={moduleName}");
|
||||
Console.Error.WriteLine("[DISPATCHER] === DispatchModuleInitializer START ===");
|
||||
Console.Error.WriteLine($"[DISPATCHER] moduleInit=0x{entryPoint:X16}, generation={generation}, module={moduleName}");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -121,7 +126,8 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Critical($"FATAL EXCEPTION in DispatchModuleInitializer: {ex.GetType().Name}: {ex.Message}", ex);
|
||||
Console.Error.WriteLine($"[DISPATCHER] FATAL EXCEPTION in DispatchModuleInitializer: {ex.GetType().Name}: {ex.Message}");
|
||||
Console.Error.WriteLine($"[DISPATCHER] Stack trace: {ex.StackTrace}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -135,7 +141,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
CpuExecutionOptions executionOptions = default,
|
||||
EntryFrameKind frameKind = EntryFrameKind.ProcessEntry)
|
||||
{
|
||||
Log.Debug("DispatchEntryCore STARTING...");
|
||||
Console.Error.WriteLine("[DISPATCHER] DispatchEntryCore STARTING...");
|
||||
|
||||
LastEntryPoint = entryPoint;
|
||||
LastTrapInfo = null;
|
||||
@@ -307,7 +313,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
LastMilestoneLog,
|
||||
Environment.NewLine,
|
||||
$"CpuEngine native-only failed: {backendError}");
|
||||
Log.Error($"Native backend FAILED: {backendError}");
|
||||
Console.Error.WriteLine($"[DISPATCHER] Native backend FAILED: {backendError}");
|
||||
return FailEarly(
|
||||
OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_IMPLEMENTED,
|
||||
CpuExitReason.NativeBackendUnavailable);
|
||||
@@ -366,11 +372,19 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
|
||||
private static bool InitializeTls(CpuContext context, ulong tlsBase)
|
||||
{
|
||||
return context.TryWriteUInt64(tlsBase - 0xF0, 0) &&
|
||||
context.TryWriteUInt64(tlsBase + 0x00, tlsBase) &&
|
||||
context.TryWriteUInt64(tlsBase + 0x10, tlsBase) &&
|
||||
context.TryWriteUInt64(tlsBase + 0x28, 0xC0DEC0DECAFEBABEUL) &&
|
||||
context.TryWriteUInt64(tlsBase + 0x60, tlsBase);
|
||||
if (!context.TryWriteUInt64(tlsBase - 0xF0, 0) ||
|
||||
!context.TryWriteUInt64(tlsBase + 0x00, tlsBase) ||
|
||||
!context.TryWriteUInt64(tlsBase + 0x10, tlsBase) ||
|
||||
!context.TryWriteUInt64(tlsBase + 0x28, 0xC0DEC0DECAFEBA00UL) ||
|
||||
!context.TryWriteUInt64(tlsBase + 0x60, tlsBase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Seed the static TLS block below the thread pointer with the main
|
||||
// module's initialized thread-locals (variant II layout).
|
||||
SharpEmu.HLE.GuestTlsTemplate.SeedThreadBlock(context, tlsBase);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool InitializeGuestFrameChainSentinel(CpuContext context)
|
||||
@@ -396,35 +410,54 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
ulong programExitHandlerAddress)
|
||||
{
|
||||
var imageName = string.IsNullOrWhiteSpace(processImageName) ? "eboot.bin" : processImageName;
|
||||
var encodedNameLength = Encoding.UTF8.GetByteCount(imageName);
|
||||
Span<byte> argv0Buffer = encodedNameLength + 1 <= 512
|
||||
? stackalloc byte[encodedNameLength + 1]
|
||||
: new byte[encodedNameLength + 1];
|
||||
if (Encoding.UTF8.GetBytes(imageName.AsSpan(), argv0Buffer) != encodedNameLength)
|
||||
var arguments = new List<string>(3) { imageName };
|
||||
var configuredArguments = Environment.GetEnvironmentVariable("SHARPEMU_GUEST_ARGS");
|
||||
if (!string.IsNullOrWhiteSpace(configuredArguments))
|
||||
{
|
||||
return false;
|
||||
// The PS5 entry-parameter ABI exposes three inline argv pointers.
|
||||
// Two compatibility arguments are therefore safe without changing
|
||||
// the fixed 0x20-byte structure expected by existing titles.
|
||||
var compatibilityArguments = configuredArguments.Split(
|
||||
(char[]?)null,
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
arguments.AddRange(compatibilityArguments.Take(2));
|
||||
}
|
||||
|
||||
argv0Buffer[encodedNameLength] = 0;
|
||||
var cursor = context[CpuRegister.Rsp];
|
||||
|
||||
var argv0Address = AlignDown(cursor - (ulong)argv0Buffer.Length, 16);
|
||||
if (!context.Memory.TryWrite(argv0Address, argv0Buffer))
|
||||
var argumentAddresses = new ulong[arguments.Count];
|
||||
for (var index = arguments.Count - 1; index >= 0; index--)
|
||||
{
|
||||
return false;
|
||||
var encoded = Encoding.UTF8.GetBytes(arguments[index] + '\0');
|
||||
cursor = AlignDown(cursor - (ulong)encoded.Length, 16);
|
||||
if (!context.Memory.TryWrite(cursor, encoded))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
argumentAddresses[index] = cursor;
|
||||
}
|
||||
|
||||
const ulong entryParamsSize = 0x20;
|
||||
var entryParamsAddress = AlignDown(argv0Address - entryParamsSize, 16);
|
||||
if (!context.TryWriteUInt32(entryParamsAddress + 0x00, 1) ||
|
||||
!context.TryWriteUInt32(entryParamsAddress + 0x04, 0) ||
|
||||
!context.TryWriteUInt64(entryParamsAddress + 0x08, argv0Address) ||
|
||||
!context.TryWriteUInt64(entryParamsAddress + 0x10, 0) ||
|
||||
!context.TryWriteUInt64(entryParamsAddress + 0x18, 0))
|
||||
var entryParamsAddress = AlignDown(cursor - entryParamsSize, 16);
|
||||
if (!TryWriteUInt32(context, entryParamsAddress + 0x00, (uint)arguments.Count) ||
|
||||
!TryWriteUInt32(context, entryParamsAddress + 0x04, 0) ||
|
||||
!context.TryWriteUInt64(entryParamsAddress + 0x08, argumentAddresses[0]) ||
|
||||
!context.TryWriteUInt64(
|
||||
entryParamsAddress + 0x10,
|
||||
argumentAddresses.Length > 1 ? argumentAddresses[1] : 0) ||
|
||||
!context.TryWriteUInt64(
|
||||
entryParamsAddress + 0x18,
|
||||
argumentAddresses.Length > 2 ? argumentAddresses[2] : 0))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (arguments.Count > 1)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[DISPATCHER] Guest arguments: {string.Join(' ', arguments.Skip(1))}");
|
||||
}
|
||||
|
||||
var entryStackPointer = entryParamsAddress - sizeof(ulong);
|
||||
if (!context.TryWriteUInt64(entryStackPointer, 0))
|
||||
{
|
||||
@@ -457,6 +490,13 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
return value & ~(alignment - 1);
|
||||
}
|
||||
|
||||
private static bool TryWriteUInt32(CpuContext context, ulong address, uint value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[sizeof(uint)];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
|
||||
return context.Memory.TryWrite(address, buffer);
|
||||
}
|
||||
|
||||
private static string BuildEntryFrameDiagnostic(
|
||||
ulong entryPoint,
|
||||
CpuContext context,
|
||||
@@ -653,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)
|
||||
@@ -660,6 +707,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
disposableBackend.Dispose();
|
||||
}
|
||||
|
||||
NativeSessionLeaked = _nativeCpuBackend is DirectExecutionBackend { GuestSessionLeaked: true };
|
||||
_nativeCpuBackend = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Numerics;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Emulation;
|
||||
|
||||
/// <summary>
|
||||
/// Pure software implementations of the BMI1, BMI2 and ABM general-purpose-register
|
||||
/// bit-manipulation instructions.
|
||||
///
|
||||
/// The direct-execution backend runs guest PS5 code natively on the host CPU. The PS5's
|
||||
/// Zen 2 cores implement BMI1/BMI2/ABM, but a host CPU that predates those extensions raises
|
||||
/// #UD (STATUS_ILLEGAL_INSTRUCTION) when it meets one of these opcodes. This class provides the
|
||||
/// register-only arithmetic so the exception handler can finish the instruction in software and
|
||||
/// resume, instead of aborting the title.
|
||||
///
|
||||
/// The methods deliberately operate on plain integers rather than on the OS CONTEXT record so the
|
||||
/// semantics can be unit-tested in isolation; the unsafe register/memory plumbing lives in the
|
||||
/// backend adapter. Each method returns the result already masked to the operand width and, where
|
||||
/// the instruction is defined to touch flags, updates <paramref name="eflags"/> in place. Flags
|
||||
/// documented as "undefined" by the vendor manuals are left untouched so behaviour stays
|
||||
/// deterministic across hosts.
|
||||
/// </summary>
|
||||
public static class BmiInstructionEmulator
|
||||
{
|
||||
private const uint FlagCarry = 1u << 0;
|
||||
private const uint FlagZero = 1u << 6;
|
||||
private const uint FlagSign = 1u << 7;
|
||||
private const uint FlagOverflow = 1u << 11;
|
||||
|
||||
private static ulong WidthMask(GprOperandSize size) =>
|
||||
size == GprOperandSize.Bits64 ? ulong.MaxValue : 0xFFFF_FFFFUL;
|
||||
|
||||
private static int WidthBits(GprOperandSize size) => (int)size;
|
||||
|
||||
private static bool SignSet(ulong value, GprOperandSize size) =>
|
||||
size == GprOperandSize.Bits64 ? (value >> 63) != 0 : (value & 0x8000_0000UL) != 0;
|
||||
|
||||
private static uint WithFlag(uint eflags, uint flag, bool set) =>
|
||||
set ? eflags | flag : eflags & ~flag;
|
||||
|
||||
// Applies the CF/ZF/SF/OF set shared by ANDN/BLS*/BZHI: OF is always cleared, ZF and SF follow
|
||||
// the result, and the caller supplies CF because each instruction defines it differently.
|
||||
private static uint ApplyLogicFlags(uint eflags, ulong result, GprOperandSize size, bool carry)
|
||||
{
|
||||
eflags = WithFlag(eflags, FlagCarry, carry);
|
||||
eflags = WithFlag(eflags, FlagZero, result == 0);
|
||||
eflags = WithFlag(eflags, FlagSign, SignSet(result, size));
|
||||
eflags = WithFlag(eflags, FlagOverflow, false);
|
||||
return eflags;
|
||||
}
|
||||
|
||||
/// <summary>ANDN: <c>dest = (~src1) & src2</c>. CF and OF are cleared.</summary>
|
||||
public static ulong Andn(ulong src1, ulong src2, GprOperandSize size, ref uint eflags)
|
||||
{
|
||||
var result = (~src1 & src2) & WidthMask(size);
|
||||
eflags = ApplyLogicFlags(eflags, result, size, carry: false);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>BLSI: isolate the lowest set bit, <c>dest = (-src) & src</c>. CF = (src != 0).</summary>
|
||||
public static ulong Blsi(ulong src, GprOperandSize size, ref uint eflags)
|
||||
{
|
||||
var mask = WidthMask(size);
|
||||
var s = src & mask;
|
||||
var result = ((0UL - s) & s) & mask;
|
||||
eflags = ApplyLogicFlags(eflags, result, size, carry: s != 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>BLSMSK: mask up to and including the lowest set bit, <c>dest = (src - 1) ^ src</c>. CF = (src == 0).</summary>
|
||||
public static ulong Blsmsk(ulong src, GprOperandSize size, ref uint eflags)
|
||||
{
|
||||
var mask = WidthMask(size);
|
||||
var s = src & mask;
|
||||
var result = ((s - 1) ^ s) & mask;
|
||||
eflags = ApplyLogicFlags(eflags, result, size, carry: s == 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>BLSR: reset the lowest set bit, <c>dest = (src - 1) & src</c>. CF = (src == 0).</summary>
|
||||
public static ulong Blsr(ulong src, GprOperandSize size, ref uint eflags)
|
||||
{
|
||||
var mask = WidthMask(size);
|
||||
var s = src & mask;
|
||||
var result = ((s - 1) & s) & mask;
|
||||
eflags = ApplyLogicFlags(eflags, result, size, carry: s == 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BEXTR: extract <c>len</c> bits of <paramref name="src"/> starting at bit <c>start</c>, where
|
||||
/// start = control[7:0] and len = control[15:8]. Only ZF (per result) and cleared CF/OF are defined.
|
||||
/// </summary>
|
||||
public static ulong Bextr(ulong src, ulong control, GprOperandSize size, ref uint eflags)
|
||||
{
|
||||
var bits = WidthBits(size);
|
||||
var start = (int)(control & 0xFF);
|
||||
var length = (int)((control >> 8) & 0xFF);
|
||||
|
||||
ulong result;
|
||||
if (start >= bits)
|
||||
{
|
||||
result = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
var shifted = (src & WidthMask(size)) >> start;
|
||||
if (length == 0)
|
||||
{
|
||||
result = 0;
|
||||
}
|
||||
else if (length >= bits)
|
||||
{
|
||||
result = shifted;
|
||||
}
|
||||
else
|
||||
{
|
||||
result = shifted & ((1UL << length) - 1);
|
||||
}
|
||||
}
|
||||
|
||||
result &= WidthMask(size);
|
||||
eflags = WithFlag(eflags, FlagZero, result == 0);
|
||||
eflags = WithFlag(eflags, FlagCarry, false);
|
||||
eflags = WithFlag(eflags, FlagOverflow, false);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// BZHI: zero the bits of <paramref name="src"/> from bit position <c>index[7:0]</c> upward.
|
||||
/// CF is set when the requested position is at or beyond the operand width.
|
||||
/// </summary>
|
||||
public static ulong Bzhi(ulong src, ulong index, GprOperandSize size, ref uint eflags)
|
||||
{
|
||||
var bits = WidthBits(size);
|
||||
var mask = WidthMask(size);
|
||||
var s = src & mask;
|
||||
var n = (int)(index & 0xFF);
|
||||
|
||||
ulong result;
|
||||
bool carry;
|
||||
if (n >= bits)
|
||||
{
|
||||
result = s;
|
||||
carry = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
result = s & ((1UL << n) - 1);
|
||||
carry = false;
|
||||
}
|
||||
|
||||
eflags = ApplyLogicFlags(eflags, result, size, carry);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>TZCNT: count trailing zero bits. If src == 0 the result is the operand width and CF is set.</summary>
|
||||
public static ulong Tzcnt(ulong src, GprOperandSize size, ref uint eflags)
|
||||
{
|
||||
var bits = WidthBits(size);
|
||||
var s = src & WidthMask(size);
|
||||
|
||||
ulong result;
|
||||
bool carry;
|
||||
if (s == 0)
|
||||
{
|
||||
result = (ulong)bits;
|
||||
carry = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
result = (ulong)(size == GprOperandSize.Bits64
|
||||
? BitOperations.TrailingZeroCount(s)
|
||||
: BitOperations.TrailingZeroCount((uint)s));
|
||||
carry = false;
|
||||
}
|
||||
|
||||
eflags = WithFlag(eflags, FlagCarry, carry);
|
||||
eflags = WithFlag(eflags, FlagZero, result == 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>LZCNT: count leading zero bits. If src == 0 the result is the operand width and CF is set.</summary>
|
||||
public static ulong Lzcnt(ulong src, GprOperandSize size, ref uint eflags)
|
||||
{
|
||||
var bits = WidthBits(size);
|
||||
var s = src & WidthMask(size);
|
||||
|
||||
ulong result;
|
||||
bool carry;
|
||||
if (s == 0)
|
||||
{
|
||||
result = (ulong)bits;
|
||||
carry = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
result = (ulong)(size == GprOperandSize.Bits64
|
||||
? BitOperations.LeadingZeroCount(s)
|
||||
: BitOperations.LeadingZeroCount((uint)s));
|
||||
carry = false;
|
||||
}
|
||||
|
||||
eflags = WithFlag(eflags, FlagCarry, carry);
|
||||
eflags = WithFlag(eflags, FlagZero, result == 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>RORX: rotate <paramref name="src"/> right by <paramref name="count"/> (masked to the operand width). No flags.</summary>
|
||||
public static ulong Rorx(ulong src, int count, GprOperandSize size)
|
||||
{
|
||||
var bits = WidthBits(size);
|
||||
var mask = WidthMask(size);
|
||||
var s = src & mask;
|
||||
var rotate = count & (bits - 1);
|
||||
if (rotate == 0)
|
||||
{
|
||||
return s;
|
||||
}
|
||||
|
||||
return ((s >> rotate) | (s << (bits - rotate))) & mask;
|
||||
}
|
||||
|
||||
/// <summary>SARX: arithmetic shift right by <paramref name="count"/> (masked to the operand width). No flags.</summary>
|
||||
public static ulong Sarx(ulong src, int count, GprOperandSize size)
|
||||
{
|
||||
var bits = WidthBits(size);
|
||||
var mask = WidthMask(size);
|
||||
var shift = count & (bits - 1);
|
||||
if (size == GprOperandSize.Bits64)
|
||||
{
|
||||
return (ulong)((long)src >> shift);
|
||||
}
|
||||
|
||||
return (ulong)(uint)((int)(uint)src >> shift) & mask;
|
||||
}
|
||||
|
||||
/// <summary>SHLX: logical shift left by <paramref name="count"/> (masked to the operand width). No flags.</summary>
|
||||
public static ulong Shlx(ulong src, int count, GprOperandSize size)
|
||||
{
|
||||
var bits = WidthBits(size);
|
||||
var mask = WidthMask(size);
|
||||
var shift = count & (bits - 1);
|
||||
return (src << shift) & mask;
|
||||
}
|
||||
|
||||
/// <summary>SHRX: logical shift right by <paramref name="count"/> (masked to the operand width). No flags.</summary>
|
||||
public static ulong Shrx(ulong src, int count, GprOperandSize size)
|
||||
{
|
||||
var bits = WidthBits(size);
|
||||
var mask = WidthMask(size);
|
||||
var s = src & mask;
|
||||
var shift = count & (bits - 1);
|
||||
return s >> shift;
|
||||
}
|
||||
|
||||
/// <summary>PDEP: deposit contiguous low bits of <paramref name="src"/> into the positions selected by <paramref name="mask"/>. No flags.</summary>
|
||||
public static ulong Pdep(ulong src, ulong mask, GprOperandSize size)
|
||||
{
|
||||
var bits = WidthBits(size);
|
||||
var selector = mask & WidthMask(size);
|
||||
ulong result = 0;
|
||||
var bit = 0;
|
||||
for (var i = 0; i < bits; i++)
|
||||
{
|
||||
var position = 1UL << i;
|
||||
if ((selector & position) != 0)
|
||||
{
|
||||
if (((src >> bit) & 1UL) != 0)
|
||||
{
|
||||
result |= position;
|
||||
}
|
||||
|
||||
bit++;
|
||||
}
|
||||
}
|
||||
|
||||
return result & WidthMask(size);
|
||||
}
|
||||
|
||||
/// <summary>PEXT: gather the bits of <paramref name="src"/> selected by <paramref name="mask"/> into contiguous low bits. No flags.</summary>
|
||||
public static ulong Pext(ulong src, ulong mask, GprOperandSize size)
|
||||
{
|
||||
var bits = WidthBits(size);
|
||||
var selector = mask & WidthMask(size);
|
||||
ulong result = 0;
|
||||
var bit = 0;
|
||||
for (var i = 0; i < bits; i++)
|
||||
{
|
||||
if ((selector & (1UL << i)) != 0)
|
||||
{
|
||||
if (((src >> i) & 1UL) != 0)
|
||||
{
|
||||
result |= 1UL << bit;
|
||||
}
|
||||
|
||||
bit++;
|
||||
}
|
||||
}
|
||||
|
||||
return result & WidthMask(size);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Emulation;
|
||||
|
||||
/// <summary>
|
||||
/// Operand width for the emulated general-purpose-register instructions. The numeric value is the
|
||||
/// bit width, so it can double as the "count leading/trailing zeros of an all-zero source" result.
|
||||
/// </summary>
|
||||
public enum GprOperandSize
|
||||
{
|
||||
Bits32 = 32,
|
||||
Bits64 = 64,
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using SharpEmu.Core.Cpu.Disasm;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Logging;
|
||||
|
||||
@@ -16,6 +17,55 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
private static readonly ConcurrentDictionary<ulong, byte> _knownExecutablePages = new();
|
||||
|
||||
private static readonly bool _perfHleHistogram =
|
||||
string.Equals(System.Environment.GetEnvironmentVariable("SHARPEMU_PERF_HLE"), "1", System.StringComparison.Ordinal);
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _perfHleCounts = new();
|
||||
private static long _perfHleTotal;
|
||||
private static long _perfHleDispatchTicks;
|
||||
|
||||
private static void RecordPerfHleDispatchTime(long ticks)
|
||||
{
|
||||
var total = System.Threading.Interlocked.Add(ref _perfHleDispatchTicks, ticks);
|
||||
var calls = System.Threading.Interlocked.Read(ref _perfHleTotal);
|
||||
if (calls > 0 && calls % 500000 == 0)
|
||||
{
|
||||
var avgUs = (double)total / System.Diagnostics.Stopwatch.Frequency * 1_000_000.0 / calls;
|
||||
System.Console.Error.WriteLine($"[PERF][HLE] managed_dispatch_avg={avgUs:F3}us total_managed_s={(double)total / System.Diagnostics.Stopwatch.Frequency:F2}");
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly bool _perfHleNoDict =
|
||||
string.Equals(System.Environment.GetEnvironmentVariable("SHARPEMU_PERF_HLE_NODICT"), "1", System.StringComparison.Ordinal);
|
||||
|
||||
private static void RecordPerfHleCall(string name)
|
||||
{
|
||||
var total = System.Threading.Interlocked.Increment(ref _perfHleTotal);
|
||||
if (!_perfHleNoDict)
|
||||
{
|
||||
_perfHleCounts.AddOrUpdate(name, 1, static (_, v) => v + 1);
|
||||
}
|
||||
|
||||
if (total % 500000 == 0 && !_perfHleNoDict)
|
||||
{
|
||||
// Snapshot via foreach (a safe moving enumerator) before sorting.
|
||||
// LINQ over a ConcurrentDictionary uses ICollection.CopyTo, which
|
||||
// throws ArgumentException if another thread adds a key between the
|
||||
// Count read and the copy — that exception was being swallowed into
|
||||
// a CPU_TRAP return and crashing the guest.
|
||||
var snapshot = new System.Collections.Generic.List<System.Collections.Generic.KeyValuePair<string, long>>(_perfHleCounts.Count + 16);
|
||||
foreach (var kvp in _perfHleCounts)
|
||||
{
|
||||
snapshot.Add(kvp);
|
||||
}
|
||||
|
||||
var top = snapshot
|
||||
.OrderByDescending(kvp => kvp.Value)
|
||||
.Take(20)
|
||||
.Select(kvp => $"{kvp.Key}={kvp.Value}");
|
||||
System.Console.Error.WriteLine($"[PERF][HLE] total={total} top: {string.Join(", ", top)}");
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordRecentImportTrace(
|
||||
long dispatchIndex,
|
||||
string nid,
|
||||
@@ -24,15 +74,18 @@ public sealed partial class DirectExecutionBackend
|
||||
ulong arg1,
|
||||
ulong arg2)
|
||||
{
|
||||
_recentImportTrace[_recentImportTraceWriteIndex] = new RecentImportTraceEntry(
|
||||
var trace = _recentImportTrace;
|
||||
trace[_recentImportTraceWriteIndex] = new RecentImportTraceEntry(
|
||||
dispatchIndex,
|
||||
nid,
|
||||
returnRip,
|
||||
arg0,
|
||||
arg1,
|
||||
arg2);
|
||||
_recentImportTraceWriteIndex = (_recentImportTraceWriteIndex + 1) % _recentImportTrace.Length;
|
||||
if (_recentImportTraceCount < _recentImportTrace.Length)
|
||||
arg2,
|
||||
GuestThreadExecution.CurrentGuestThreadHandle,
|
||||
Environment.CurrentManagedThreadId);
|
||||
_recentImportTraceWriteIndex = (_recentImportTraceWriteIndex + 1) % trace.Length;
|
||||
if (_recentImportTraceCount < trace.Length)
|
||||
{
|
||||
_recentImportTraceCount++;
|
||||
}
|
||||
@@ -40,20 +93,21 @@ public sealed partial class DirectExecutionBackend
|
||||
|
||||
private void DumpRecentImportTrace()
|
||||
{
|
||||
if (_recentImportTraceCount == 0)
|
||||
var trace = _recentImportTrace;
|
||||
if (trace is null || _recentImportTraceCount == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Log.Info($" Recent import calls ({_recentImportTraceCount}):");
|
||||
int num = (_recentImportTraceWriteIndex - _recentImportTraceCount + _recentImportTrace.Length) % _recentImportTrace.Length;
|
||||
Log.Info($" Recent import calls for managed={Environment.CurrentManagedThreadId} guest=0x{GuestThreadExecution.CurrentGuestThreadHandle:X16} ({_recentImportTraceCount}):");
|
||||
int num = (_recentImportTraceWriteIndex - _recentImportTraceCount + trace.Length) % trace.Length;
|
||||
for (int i = 0; i < _recentImportTraceCount; i++)
|
||||
{
|
||||
int num2 = (num + i) % _recentImportTrace.Length;
|
||||
var entry = _recentImportTrace[num2];
|
||||
int num2 = (num + i) % trace.Length;
|
||||
var entry = trace[num2];
|
||||
if (!string.IsNullOrEmpty(entry.Nid))
|
||||
{
|
||||
Log.Info(
|
||||
$" #{entry.DispatchIndex} nid={entry.Nid} ret=0x{entry.ReturnRip:X16} " +
|
||||
$" #{entry.DispatchIndex} managed={entry.ManagedThreadId} guest=0x{entry.GuestThreadHandle:X16} nid={entry.Nid} ret=0x{entry.ReturnRip:X16} " +
|
||||
$"rdi=0x{entry.Arg0:X16} rsi=0x{entry.Arg1:X16} rdx=0x{entry.Arg2:X16}");
|
||||
}
|
||||
}
|
||||
@@ -119,6 +173,50 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
return;
|
||||
}
|
||||
const int preludeSize = 192;
|
||||
Span<byte> prelude = stackalloc byte[preludeSize];
|
||||
if (returnRip >= preludeSize && cpuContext.Memory.TryRead(returnRip - preludeSize, prelude))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] Import#{dispatchIndex} pre-return bytes @0x{returnRip - preludeSize:X16}: " +
|
||||
BitConverter.ToString(prelude.ToArray()).Replace("-", " "));
|
||||
|
||||
List<DecodedInst>? bestCallChain = null;
|
||||
var preludeAddress = returnRip - preludeSize;
|
||||
for (var startOffset = 0; startOffset < preludeSize; startOffset++)
|
||||
{
|
||||
var cursor = preludeAddress + (ulong)startOffset;
|
||||
var candidate = new List<DecodedInst>();
|
||||
while (cursor < returnRip && candidate.Count < 96 &&
|
||||
IcedDecoder.TryReadGuestBytes(cpuContext.Memory, cursor, 15, out var instructionBytes) &&
|
||||
IcedDecoder.TryDecode(cursor, instructionBytes, out var instruction) &&
|
||||
instruction.Length > 0 &&
|
||||
cursor + (ulong)instruction.Length <= returnRip)
|
||||
{
|
||||
candidate.Add(instruction);
|
||||
cursor += (ulong)instruction.Length;
|
||||
}
|
||||
|
||||
if (cursor == returnRip &&
|
||||
candidate.Count > 0 &&
|
||||
string.Equals(candidate[^1].Mnemonic, "Call", StringComparison.OrdinalIgnoreCase) &&
|
||||
(bestCallChain is null || candidate.Count > bestCallChain.Count))
|
||||
{
|
||||
bestCallChain = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestCallChain is not null)
|
||||
{
|
||||
Console.Error.WriteLine($"[LOADER][TRACE] Import#{dispatchIndex} pre-return disassembly:");
|
||||
foreach (var instruction in bestCallChain.TakeLast(32))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] 0x{instruction.Rip:X16}: {instruction.Text} " +
|
||||
$"bytes={IcedDecoder.FormatBytes(instruction.Bytes)}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Span<byte> destination = stackalloc byte[128];
|
||||
if (!cpuContext.Memory.TryRead(returnRip, destination))
|
||||
{
|
||||
@@ -235,6 +333,28 @@ public sealed partial class DirectExecutionBackend
|
||||
return value == 65534 || value == 4294967294u || value == 18446744073709551614uL;
|
||||
}
|
||||
|
||||
private static ulong ParseOptionalHexAddress(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var text = value.Trim();
|
||||
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
text = text[2..];
|
||||
}
|
||||
|
||||
return ulong.TryParse(
|
||||
text,
|
||||
System.Globalization.NumberStyles.HexNumber,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var address)
|
||||
? address
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static bool IsPlausibleReturnAddress(ulong address)
|
||||
{
|
||||
return address >= 12884901888L && address < 17592186044416L && !IsUnresolvedSentinel(address);
|
||||
|
||||
@@ -17,9 +17,17 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
private const ulong LazyCommitWindowBytes = 0x0200_0000UL;
|
||||
private static int _lazyCommitTraceCount;
|
||||
private static int _guestAllocatorHoleRecoveries;
|
||||
private static int _auxiliaryThreadExecuteFaultRecoveries;
|
||||
|
||||
private unsafe void SetupExceptionHandler()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
SetupPosixExceptionHandler();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_RAW_HANDLER"), "1", StringComparison.Ordinal))
|
||||
{
|
||||
_rawExceptionHandlerStub = CreateExceptionHandlerTrampoline(RawVectoredHandlerPtrManaged);
|
||||
@@ -102,11 +110,29 @@ public sealed partial class DirectExecutionBackend
|
||||
|
||||
ulong rip = ReadCtxU64(contextRecord, 248);
|
||||
ulong rsp = ReadCtxU64(contextRecord, 152);
|
||||
if (TryRecoverGuestInt41(exceptionCode, contextRecord, rip))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (TryRecoverAuxiliaryThreadExecuteFault(exceptionRecord, contextRecord, rip))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (exceptionCode == 3221225477u && TryHandleLazyCommittedPage(exceptionRecord, rip, rsp))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (exceptionCode == 3221225477u &&
|
||||
TryRecoverGuestAllocatorHole(exceptionRecord, contextRecord, rip))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (exceptionCode == StatusIllegalInstruction &&
|
||||
TryRecoverIllegalInstruction(contextRecord, rip))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (IsBenignHostDebugException(exceptionCode))
|
||||
{
|
||||
return -1;
|
||||
@@ -152,6 +178,35 @@ public sealed partial class DirectExecutionBackend
|
||||
Console.Error.WriteLine($"[LOADER][INFO] Code: 0x{exceptionCode:X8}");
|
||||
Console.Error.WriteLine($"[LOADER][INFO] Exception Address: 0x{exceptionAddress:X16}");
|
||||
Console.Error.WriteLine($"[LOADER][INFO] RIP: 0x{rip:X16}");
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] Host thread: managed={Environment.CurrentManagedThreadId} " +
|
||||
$"name='{Thread.CurrentThread.Name ?? "<unnamed>"}'");
|
||||
if (_activeGuestThreadState is { } activeGuestThread)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] Guest thread: handle=0x{activeGuestThread.ThreadHandle:X16} " +
|
||||
$"name='{activeGuestThread.Name}' state={activeGuestThread.State} " +
|
||||
$"last_import={activeGuestThread.LastImportNid ?? "<none>"} " +
|
||||
$"last_ret=0x{activeGuestThread.LastReturnRip:X16}");
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] Last import registers: " +
|
||||
$"rax=0x{Volatile.Read(ref activeGuestThread.LastImportRax):X16} " +
|
||||
$"result_valid={Volatile.Read(ref activeGuestThread.LastImportResultValid) != 0} " +
|
||||
$"rdi=0x{activeGuestThread.LastImportRdi:X16} " +
|
||||
$"rsi=0x{activeGuestThread.LastImportRsi:X16} " +
|
||||
$"rdx=0x{activeGuestThread.LastImportRdx:X16} " +
|
||||
$"rcx=0x{activeGuestThread.LastImportRcx:X16} " +
|
||||
$"r8=0x{activeGuestThread.LastImportR8:X16} " +
|
||||
$"r9=0x{activeGuestThread.LastImportR9:X16}");
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] Last import stack args: " +
|
||||
$"0=0x{activeGuestThread.LastImportStack0:X16} " +
|
||||
$"1=0x{activeGuestThread.LastImportStack1:X16} " +
|
||||
$"2=0x{activeGuestThread.LastImportStack2:X16} " +
|
||||
$"3=0x{activeGuestThread.LastImportStack3:X16} " +
|
||||
$"4=0x{activeGuestThread.LastImportStack4:X16} " +
|
||||
$"5=0x{activeGuestThread.LastImportStack5:X16}");
|
||||
}
|
||||
if (TryFormatNearestRuntimeSymbol(rip, out string symbol))
|
||||
{
|
||||
Console.Error.WriteLine("[LOADER][INFO] RIP symbol: " + symbol);
|
||||
@@ -196,20 +251,50 @@ public sealed partial class DirectExecutionBackend
|
||||
|
||||
}
|
||||
|
||||
try
|
||||
Console.Error.WriteLine("[LOADER][INFO] Stack qwords (RSP..):");
|
||||
for (int i = 0; i < 16; i++)
|
||||
{
|
||||
Console.Error.WriteLine("[LOADER][INFO] Stack qwords (RSP..):");
|
||||
for (int i = 0; i < 16; i++)
|
||||
ulong stackAddr = rsp + (ulong)(i * 8);
|
||||
if (!TryReadHostQword(stackAddr, out ulong value))
|
||||
{
|
||||
ulong stackAddr = rsp + (ulong)(i * 8);
|
||||
ulong value = (ulong)Marshal.ReadInt64((nint)stackAddr);
|
||||
Console.Error.WriteLine($"[LOADER][INFO] [rsp+0x{i * 8:X2}] @0x{stackAddr:X16} = 0x{value:X16}");
|
||||
Console.Error.WriteLine("[LOADER][WARNING] Could not read stack qwords.");
|
||||
break;
|
||||
}
|
||||
Console.Error.WriteLine($"[LOADER][INFO] [rsp+0x{i * 8:X2}] @0x{stackAddr:X16} = 0x{value:X16}");
|
||||
}
|
||||
|
||||
if (string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_DUMP_FAULT_STACK_WINDOW"),
|
||||
"1",
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
Console.Error.WriteLine("[LOADER][INFO] Full fault stack window (RSP-0x300..RSP+0x100):");
|
||||
var windowStart = rsp >= 0x300 ? rsp - 0x300 : 0;
|
||||
for (var stackAddr = windowStart; stackAddr < rsp + 0x100; stackAddr += 8)
|
||||
{
|
||||
if (!TryReadHostQword(stackAddr, out var value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var relative = unchecked((long)(stackAddr - rsp));
|
||||
var relativeText = relative >= 0
|
||||
? $"+0x{relative:X}"
|
||||
: $"-0x{-relative:X}";
|
||||
var symbolText = TryFormatNearestRuntimeSymbol(value, out var stackSymbol)
|
||||
? $" [{stackSymbol}]"
|
||||
: string.Empty;
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] [rsp{relativeText}] " +
|
||||
$"@0x{stackAddr:X16} = 0x{value:X16}{symbolText}");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.Error.WriteLine("[LOADER][WARNING] Could not read stack qwords.");
|
||||
}
|
||||
|
||||
DumpPointerWindow("fault-register-rbx", rbx, 0x60);
|
||||
DumpPointerWindow("fault-register-rsi", rsi, 0x60);
|
||||
DumpPointerWindow("fault-register-rdi", rdi, 0x60);
|
||||
DumpPointerWindow("fault-register-r13", r13, 0x60);
|
||||
DumpPointerWindow("fault-register-r14", r14, 0x60);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -217,12 +302,15 @@ public sealed partial class DirectExecutionBackend
|
||||
ulong frame = rbp;
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
if (frame < 140733193388032L || frame > 140737488355327L)
|
||||
if (frame < 0x10000)
|
||||
{
|
||||
break;
|
||||
}
|
||||
ulong next = (ulong)Marshal.ReadInt64((nint)frame);
|
||||
ulong ret = (ulong)Marshal.ReadInt64((nint)(frame + 8));
|
||||
if (!TryReadHostQword(frame, out ulong next) || !TryReadHostQword(frame + 8, out ulong ret))
|
||||
{
|
||||
Console.Error.WriteLine("[LOADER][WARNING] Could not walk RBP frame chain.");
|
||||
break;
|
||||
}
|
||||
string extra = TryFormatNearestRuntimeSymbol(ret, out string retSym) ? $" [{retSym}]" : string.Empty;
|
||||
Console.Error.WriteLine($"[LOADER][INFO] frame#{i}: rbp=0x{frame:X16} ret=0x{ret:X16}{extra} next=0x{next:X16}");
|
||||
if (next <= frame)
|
||||
@@ -245,10 +333,9 @@ public sealed partial class DirectExecutionBackend
|
||||
Console.Error.WriteLine("[LOADER][ERROR] - Guest code called an unmapped import");
|
||||
Console.Error.WriteLine("[LOADER][ERROR] - Guest code accessed unmapped memory");
|
||||
Console.Error.WriteLine("[LOADER][ERROR] - Need to implement HLE for this NID");
|
||||
try
|
||||
byte[] code = new byte[16];
|
||||
if (TryReadHostBytes(rip, code))
|
||||
{
|
||||
byte[] code = new byte[16];
|
||||
Marshal.Copy((nint)rip, code, 0, code.Length);
|
||||
Console.Error.WriteLine("[LOADER][INFO] Code at RIP: " + BitConverter.ToString(code).Replace("-", " "));
|
||||
if (code[0] == 100)
|
||||
{
|
||||
@@ -264,25 +351,46 @@ public sealed partial class DirectExecutionBackend
|
||||
Console.Error.WriteLine($"[LOADER][INFO] RBP: 0x{rbp:X16} (mod 16 = {rbp % 16})");
|
||||
Console.Error.WriteLine($"[LOADER][INFO] RSP: 0x{rsp:X16} (mod 16 = {rsp % 16})");
|
||||
}
|
||||
if (rip > 16)
|
||||
byte[] before = new byte[16];
|
||||
if (rip > 16 && TryReadHostBytes(rip - 16, before))
|
||||
{
|
||||
byte[] before = new byte[16];
|
||||
Marshal.Copy((nint)(rip - 16), before, 0, before.Length);
|
||||
Console.Error.WriteLine("[LOADER][INFO] Code before RIP: " + BitConverter.ToString(before).Replace("-", " "));
|
||||
}
|
||||
if (rip > 32)
|
||||
byte[] window = new byte[64];
|
||||
if (rip > 32 && TryReadHostBytes(rip - 32, window))
|
||||
{
|
||||
byte[] window = new byte[64];
|
||||
Marshal.Copy((nint)(rip - 32), window, 0, window.Length);
|
||||
Console.Error.WriteLine("[LOADER][INFO] Code window [RIP-0x20..]: " + BitConverter.ToString(window).Replace("-", " "));
|
||||
}
|
||||
for (var stackIndex = 0; stackIndex < 16; stackIndex++)
|
||||
{
|
||||
byte[] stackSlot = new byte[8];
|
||||
if (!TryReadHostBytes(rsp + (ulong)(stackIndex * 8), stackSlot))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var candidate = BitConverter.ToUInt64(stackSlot);
|
||||
if (candidate < _entryPoint || candidate >= _entryPoint + 0x10000000 || candidate < 24)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
byte[] callSiteWindow = new byte[48];
|
||||
if (TryReadHostBytes(candidate - 24, callSiteWindow))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] Stack guest-code candidate [rsp+0x{stackIndex * 8:X2}]=0x{candidate:X16}, bytes [-0x18..]: " +
|
||||
BitConverter.ToString(callSiteWindow).Replace("-", " "));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine("[LOADER][ERROR] Could not read code at RIP");
|
||||
}
|
||||
DumpRecentImportTrace();
|
||||
DumpGuestDisasmDiagnostics(rip, rbp);
|
||||
DumpGuestDisasmDiagnostics(rip, rbp, rsp);
|
||||
DumpGuestRegisterWindowDiagnostics(
|
||||
rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp,
|
||||
r8, r9, r10, r11, r12, r13, r14, r15);
|
||||
DumpGuestReferenceDiagnostics();
|
||||
DumpGuestPointerWindowDiagnostics();
|
||||
break;
|
||||
@@ -290,8 +398,20 @@ public sealed partial class DirectExecutionBackend
|
||||
Console.Error.WriteLine("[LOADER][WARNING] Type: Breakpoint (int3)");
|
||||
Console.Error.WriteLine("[LOADER][WARNING] Unexpected breakpoint in direct-bridge mode");
|
||||
break;
|
||||
case 1073741845u:
|
||||
Console.Error.WriteLine("[LOADER][ERROR] Type: Abort (SIGABRT)");
|
||||
DumpRecentImportTrace();
|
||||
DumpGuestDisasmDiagnostics(rip, rbp, rsp);
|
||||
break;
|
||||
case 3221225501u:
|
||||
Console.Error.WriteLine("[LOADER][INFO] Type: Illegal Instruction");
|
||||
byte[] illegalCode = new byte[16];
|
||||
if (TryReadHostBytes(rip, illegalCode))
|
||||
{
|
||||
Console.Error.WriteLine("[LOADER][INFO] Code at RIP: " + BitConverter.ToString(illegalCode).Replace("-", " "));
|
||||
}
|
||||
DumpRecentImportTrace();
|
||||
DumpGuestDisasmDiagnostics(rip, rbp, rsp);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -305,6 +425,108 @@ public sealed partial class DirectExecutionBackend
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe bool TryRecoverAuxiliaryThreadExecuteFault(
|
||||
EXCEPTION_RECORD* exceptionRecord,
|
||||
void* contextRecord,
|
||||
ulong rip)
|
||||
{
|
||||
if (exceptionRecord->ExceptionCode != 3221225477u ||
|
||||
rip >= 0x0000000800000000UL ||
|
||||
_activeGuestThreadState is not { Name: "tbb_thead" } activeThread)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hostExit = ActiveEntryReturnSentinelRip;
|
||||
if (hostExit < 0x10000)
|
||||
{
|
||||
hostExit = unchecked((ulong)_guestReturnStub);
|
||||
}
|
||||
if (hostExit < 0x10000)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Could not recover auxiliary TBB execute fault: target=0x{rip:X16} " +
|
||||
$"active_exit=0x{ActiveEntryReturnSentinelRip:X16} guest_return_stub=0x{unchecked((ulong)_guestReturnStub):X16}");
|
||||
return false;
|
||||
}
|
||||
|
||||
_ = TryPatchActiveGuestReturnSlot(hostExit);
|
||||
WriteCtxU64(contextRecord, 120, 0);
|
||||
WriteCtxU64(contextRecord, 248, hostExit);
|
||||
var recovery = Interlocked.Increment(ref _auxiliaryThreadExecuteFaultRecoveries);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Recovered auxiliary TBB execute fault #{recovery}: " +
|
||||
$"thread=0x{activeThread.ThreadHandle:X16} target=0x{rip:X16} -> host_exit=0x{hostExit:X16}");
|
||||
return true;
|
||||
}
|
||||
|
||||
private unsafe bool TryRecoverGuestInt41(uint exceptionCode, void* contextRecord, ulong rip)
|
||||
{
|
||||
if (!_ignoreGuestInt41 || exceptionCode != 3221225477u || rip < 0x10000)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] opcode = new byte[2];
|
||||
if (!TryReadHostBytes(rip, opcode) || opcode[0] != 0xCD || opcode[1] != 0x41)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var count = Interlocked.Increment(ref _ignoredGuestInt41Count);
|
||||
WriteCtxU64(contextRecord, 248, rip + 2);
|
||||
if (count <= 16 || count % 65536 == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (SHARPEMU_IGNORE_INT41=1)");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private unsafe static bool TryRecoverGuestAllocatorHole(
|
||||
EXCEPTION_RECORD* exceptionRecord,
|
||||
void* contextRecord,
|
||||
ulong rip)
|
||||
{
|
||||
if (string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_GUEST_ALLOCATOR_HOLE_RECOVERY"),
|
||||
"1",
|
||||
StringComparison.Ordinal) ||
|
||||
exceptionRecord->NumberParameters < 2 ||
|
||||
exceptionRecord->ExceptionInformation[0] != 0 ||
|
||||
exceptionRecord->ExceptionInformation[1] != 8 ||
|
||||
ReadCtxU64(contextRecord, CTX_RDI) != 0 ||
|
||||
rip < 0x10000)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Demon's Souls occasionally leaves an empty payload in a locked pool
|
||||
// tree node. The allocator dereferences payload+8 before reaching its
|
||||
// existing empty-pool fallback. Match the instruction stream instead of
|
||||
// a title-specific absolute address, then resume at that fallback so the
|
||||
// lock is released and the allocator can try its next backing pool.
|
||||
const ulong allocatorHoleSignature = 0x634CFF568D08778BUL;
|
||||
if (*(ulong*)rip != allocatorHoleSignature || *((byte*)rip + 8) != 0xF2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const ulong emptyPoolFallbackDelta = 0x8E;
|
||||
WriteCtxU64(contextRecord, CTX_RIP, rip + emptyPoolFallbackDelta);
|
||||
var recovery = Interlocked.Increment(ref _guestAllocatorHoleRecoveries);
|
||||
if (recovery <= 16 || (recovery & (recovery - 1)) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Guest allocator empty-node adapter recovery #{recovery}: " +
|
||||
$"rip=0x{rip:X16} -> 0x{rip + emptyPoolFallbackDelta:X16}");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsBenignHostDebugException(uint exceptionCode)
|
||||
{
|
||||
return exceptionCode is DBG_PRINTEXCEPTION_C or DBG_PRINTEXCEPTION_WIDE_C or MS_VC_THREADNAME_EXCEPTION;
|
||||
@@ -384,7 +606,7 @@ public sealed partial class DirectExecutionBackend
|
||||
}
|
||||
}
|
||||
|
||||
private void DumpGuestDisasmDiagnostics(ulong rip, ulong rbp)
|
||||
private void DumpGuestDisasmDiagnostics(ulong rip, ulong rbp, ulong rsp)
|
||||
{
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_DISASM"), "1", StringComparison.Ordinal))
|
||||
{
|
||||
@@ -396,12 +618,20 @@ public sealed partial class DirectExecutionBackend
|
||||
DumpGuestInstructionStream("fault-prelude", rip - 0x20, 24);
|
||||
}
|
||||
|
||||
// Optimized guest code frequently omits frame pointers. The return
|
||||
// address at RSP is then more useful than an RBP walk and identifies the
|
||||
// exact call site that supplied the faulting arguments.
|
||||
if (TryReadHostQword(rsp, out var stackReturn) && stackReturn >= 0x60)
|
||||
{
|
||||
DumpGuestInstructionStream("stack-return-prelude", stackReturn - 0x60, 40);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
ulong frame = rbp;
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
if (frame < 140733193388032L || frame > 140737488355327L)
|
||||
if (frame < 0x10000)
|
||||
{
|
||||
break;
|
||||
}
|
||||
@@ -545,6 +775,55 @@ public sealed partial class DirectExecutionBackend
|
||||
}
|
||||
}
|
||||
|
||||
private void DumpGuestRegisterWindowDiagnostics(
|
||||
ulong rax,
|
||||
ulong rbx,
|
||||
ulong rcx,
|
||||
ulong rdx,
|
||||
ulong rsi,
|
||||
ulong rdi,
|
||||
ulong rbp,
|
||||
ulong rsp,
|
||||
ulong r8,
|
||||
ulong r9,
|
||||
ulong r10,
|
||||
ulong r11,
|
||||
ulong r12,
|
||||
ulong r13,
|
||||
ulong r14,
|
||||
ulong r15)
|
||||
{
|
||||
if (!string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_LOG_REGISTER_WINDOWS"),
|
||||
"1",
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// A register can be the only surviving reference to the object or
|
||||
// argument array that caused a native guest fault. Capture a compact
|
||||
// window while the process is alive so the post-mortem log can
|
||||
// distinguish an absent object from a partially initialized one.
|
||||
var registers = new (string Name, ulong Value)[]
|
||||
{
|
||||
("rax", rax), ("rbx", rbx), ("rcx", rcx), ("rdx", rdx),
|
||||
("rsi", rsi), ("rdi", rdi), ("rbp", rbp), ("rsp", rsp),
|
||||
("r8", r8), ("r9", r9), ("r10", r10), ("r11", r11),
|
||||
("r12", r12), ("r13", r13), ("r14", r14), ("r15", r15),
|
||||
};
|
||||
var seen = new HashSet<ulong>();
|
||||
foreach (var (name, value) in registers)
|
||||
{
|
||||
if (value < 0x10000 || !seen.Add(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
DumpPointerWindow($"register-{name}", value, 0x80);
|
||||
}
|
||||
}
|
||||
|
||||
private void ScanExecutableRegionForTargetReferences(
|
||||
ulong regionBase,
|
||||
ulong regionEnd,
|
||||
@@ -812,6 +1091,61 @@ public sealed partial class DirectExecutionBackend
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadHostQword(ulong address, out ulong value)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
// A stray read inside the signal handler would raise a nested
|
||||
// SIGSEGV and kill the process before diagnostics finish, so
|
||||
// probe the region table instead of relying on try/catch.
|
||||
return TryReadStackU64(address, out value);
|
||||
}
|
||||
|
||||
value = 0;
|
||||
try
|
||||
{
|
||||
value = (ulong)Marshal.ReadInt64((nint)address);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe static bool TryReadHostBytes(ulong address, byte[] buffer)
|
||||
{
|
||||
if (address < 65536)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
// See TryReadHostQword: probe every touched page before reading.
|
||||
ulong end = address + (ulong)buffer.Length;
|
||||
for (ulong page = address & 0xFFFFFFFFFFFFF000uL; page < end; page += 4096)
|
||||
{
|
||||
if (VirtualQuery((void*)page, out var mbi, (nuint)sizeof(MEMORY_BASIC_INFORMATION64)) == 0 ||
|
||||
mbi.State != MEM_COMMIT ||
|
||||
!IsReadableProtection(mbi.Protect))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Marshal.Copy((nint)address, buffer, 0, buffer.Length);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private string FormatPointerWithNearestSymbol(ulong value)
|
||||
{
|
||||
string text = $"0x{value:X16}";
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Threading;
|
||||
using Iced.Intel;
|
||||
using SharpEmu.Core.Cpu.Emulation;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native;
|
||||
|
||||
// Software fallback for the BMI1/BMI2/ABM general-purpose-register instructions.
|
||||
//
|
||||
// Guest code runs natively, so the guest and host share the same virtual address space and the
|
||||
// same registers (the OS delivers them in the CONTEXT record on a fault). When the host CPU lacks
|
||||
// one of these extensions it raises #UD instead of executing the opcode; without this the title
|
||||
// simply aborts. Here we decode the faulting instruction, evaluate it against the trapped register
|
||||
// and memory state, write the result back into the CONTEXT, step RIP past the instruction and ask
|
||||
// the OS to continue. Only the register-only BMI/ABM forms are handled; anything else returns false
|
||||
// and falls through to the existing diagnostics unchanged, so this can never mis-handle an opcode it
|
||||
// does not fully model.
|
||||
public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
// Windows x64 CONTEXT.EFlags lives just past the segment selectors. The GPR offsets it shares
|
||||
// with the rest of the backend are the CTX_* constants declared in DirectExecutionBackend.cs.
|
||||
private const int CTX_EFLAGS = 68;
|
||||
|
||||
// STATUS_ILLEGAL_INSTRUCTION (#UD surfaced by the Windows vectored handler).
|
||||
private const uint StatusIllegalInstruction = 0xC000001Du;
|
||||
|
||||
private const int MaxInstructionBytes = 15;
|
||||
|
||||
// Instruction-window sizes tried in turn so a fault near a page boundary still decodes.
|
||||
private static readonly int[] DecodeWindowSizes = { MaxInstructionBytes, 11, 8, 4, 2 };
|
||||
|
||||
private static int _bmiSoftwareFallbackAnnounced;
|
||||
private static long _bmiInstructionsEmulated;
|
||||
|
||||
private unsafe bool TryRecoverIllegalInstruction(void* contextRecord, ulong rip)
|
||||
{
|
||||
if (!TryReadFaultingInstruction(rip, out var instruction))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (instruction.Op0Kind != OpKind.Register ||
|
||||
!TryGetGprSlot(instruction.Op0Register, out var destOffset, out var size))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryEvaluate(contextRecord, in instruction, size, out var result, out var flagsChanged, out var eflags))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, destOffset, result);
|
||||
if (flagsChanged)
|
||||
{
|
||||
WriteCtxU32(contextRecord, CTX_EFLAGS, eflags);
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, CTX_RIP, rip + (ulong)instruction.Length);
|
||||
|
||||
Interlocked.Increment(ref _bmiInstructionsEmulated);
|
||||
if (Interlocked.Exchange(ref _bmiSoftwareFallbackAnnounced, 1) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Host lacks a BMI/ABM extension used by the guest; " +
|
||||
"emulating those instructions in software.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private unsafe bool TryEvaluate(
|
||||
void* contextRecord,
|
||||
in Instruction instruction,
|
||||
GprOperandSize size,
|
||||
out ulong result,
|
||||
out bool flagsChanged,
|
||||
out uint eflags)
|
||||
{
|
||||
result = 0;
|
||||
flagsChanged = false;
|
||||
eflags = ReadCtxU32(contextRecord, CTX_EFLAGS);
|
||||
|
||||
switch (instruction.Mnemonic)
|
||||
{
|
||||
case Mnemonic.Andn:
|
||||
if (!TryReadOperand(contextRecord, in instruction, 1, size, out var andnSrc1) ||
|
||||
!TryReadOperand(contextRecord, in instruction, 2, size, out var andnSrc2))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = BmiInstructionEmulator.Andn(andnSrc1, andnSrc2, size, ref eflags);
|
||||
flagsChanged = true;
|
||||
return true;
|
||||
|
||||
case Mnemonic.Blsi:
|
||||
case Mnemonic.Blsmsk:
|
||||
case Mnemonic.Blsr:
|
||||
if (!TryReadOperand(contextRecord, in instruction, 1, size, out var blsSrc))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = instruction.Mnemonic switch
|
||||
{
|
||||
Mnemonic.Blsi => BmiInstructionEmulator.Blsi(blsSrc, size, ref eflags),
|
||||
Mnemonic.Blsmsk => BmiInstructionEmulator.Blsmsk(blsSrc, size, ref eflags),
|
||||
_ => BmiInstructionEmulator.Blsr(blsSrc, size, ref eflags),
|
||||
};
|
||||
flagsChanged = true;
|
||||
return true;
|
||||
|
||||
case Mnemonic.Bextr:
|
||||
if (!TryReadOperand(contextRecord, in instruction, 1, size, out var bextrSrc) ||
|
||||
!TryReadOperand(contextRecord, in instruction, 2, size, out var bextrControl))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = BmiInstructionEmulator.Bextr(bextrSrc, bextrControl, size, ref eflags);
|
||||
flagsChanged = true;
|
||||
return true;
|
||||
|
||||
case Mnemonic.Bzhi:
|
||||
if (!TryReadOperand(contextRecord, in instruction, 1, size, out var bzhiSrc) ||
|
||||
!TryReadOperand(contextRecord, in instruction, 2, size, out var bzhiIndex))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = BmiInstructionEmulator.Bzhi(bzhiSrc, bzhiIndex, size, ref eflags);
|
||||
flagsChanged = true;
|
||||
return true;
|
||||
|
||||
case Mnemonic.Tzcnt:
|
||||
case Mnemonic.Lzcnt:
|
||||
if (!TryReadOperand(contextRecord, in instruction, 1, size, out var cntSrc))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = instruction.Mnemonic == Mnemonic.Tzcnt
|
||||
? BmiInstructionEmulator.Tzcnt(cntSrc, size, ref eflags)
|
||||
: BmiInstructionEmulator.Lzcnt(cntSrc, size, ref eflags);
|
||||
flagsChanged = true;
|
||||
return true;
|
||||
|
||||
case Mnemonic.Rorx:
|
||||
if (instruction.Op2Kind != OpKind.Immediate8 ||
|
||||
!TryReadOperand(contextRecord, in instruction, 1, size, out var rorxSrc))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = BmiInstructionEmulator.Rorx(rorxSrc, instruction.Immediate8, size);
|
||||
return true;
|
||||
|
||||
case Mnemonic.Sarx:
|
||||
case Mnemonic.Shlx:
|
||||
case Mnemonic.Shrx:
|
||||
if (!TryReadOperand(contextRecord, in instruction, 1, size, out var shiftSrc) ||
|
||||
!TryReadOperand(contextRecord, in instruction, 2, size, out var shiftCount))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = instruction.Mnemonic switch
|
||||
{
|
||||
Mnemonic.Sarx => BmiInstructionEmulator.Sarx(shiftSrc, (int)shiftCount, size),
|
||||
Mnemonic.Shlx => BmiInstructionEmulator.Shlx(shiftSrc, (int)shiftCount, size),
|
||||
_ => BmiInstructionEmulator.Shrx(shiftSrc, (int)shiftCount, size),
|
||||
};
|
||||
return true;
|
||||
|
||||
case Mnemonic.Pdep:
|
||||
case Mnemonic.Pext:
|
||||
if (!TryReadOperand(contextRecord, in instruction, 1, size, out var packSrc) ||
|
||||
!TryReadOperand(contextRecord, in instruction, 2, size, out var packMask))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
result = instruction.Mnemonic == Mnemonic.Pdep
|
||||
? BmiInstructionEmulator.Pdep(packSrc, packMask, size)
|
||||
: BmiInstructionEmulator.Pext(packSrc, packMask, size);
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe bool TryReadFaultingInstruction(ulong rip, out Instruction instruction)
|
||||
{
|
||||
// Try the full instruction window first, then shrink so a fault near the end of a mapped
|
||||
// page (where fewer than 15 bytes are readable) still decodes.
|
||||
foreach (var attempt in DecodeWindowSizes)
|
||||
{
|
||||
var buffer = new byte[attempt];
|
||||
if (!TryReadHostBytes(rip, buffer))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var decoder = Decoder.Create(64, new ByteArrayCodeReader(buffer));
|
||||
decoder.IP = rip;
|
||||
decoder.Decode(out instruction);
|
||||
if (instruction.Code != Code.INVALID && instruction.Length > 0 && instruction.Length <= attempt)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
instruction = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private unsafe bool TryReadOperand(
|
||||
void* contextRecord,
|
||||
in Instruction instruction,
|
||||
int operandIndex,
|
||||
GprOperandSize size,
|
||||
out ulong value)
|
||||
{
|
||||
value = 0;
|
||||
switch (instruction.GetOpKind(operandIndex))
|
||||
{
|
||||
case OpKind.Register:
|
||||
if (!TryGetGprSlot(instruction.GetOpRegister(operandIndex), out var offset, out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var raw = ReadCtxU64(contextRecord, offset);
|
||||
value = size == GprOperandSize.Bits64 ? raw : raw & 0xFFFF_FFFFUL;
|
||||
return true;
|
||||
|
||||
case OpKind.Memory:
|
||||
if (!TryComputeMemoryAddress(contextRecord, in instruction, out var address))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var byteCount = size == GprOperandSize.Bits64 ? 8 : 4;
|
||||
var buffer = new byte[byteCount];
|
||||
if (!TryReadHostBytes(address, buffer))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
value = byteCount == 8
|
||||
? BinaryPrimitives.ReadUInt64LittleEndian(buffer)
|
||||
: BinaryPrimitives.ReadUInt32LittleEndian(buffer);
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private unsafe bool TryComputeMemoryAddress(void* contextRecord, in Instruction instruction, out ulong address)
|
||||
{
|
||||
address = 0;
|
||||
|
||||
// FS/GS-relative operands need the guest segment base, which is not modelled here.
|
||||
if (instruction.SegmentPrefix != Register.None)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (instruction.IsIPRelativeMemoryOperand)
|
||||
{
|
||||
address = instruction.IPRelativeMemoryAddress;
|
||||
return true;
|
||||
}
|
||||
|
||||
var effective = instruction.MemoryDisplacement64;
|
||||
if (instruction.MemoryBase != Register.None)
|
||||
{
|
||||
if (!TryGetGpr64Offset(instruction.MemoryBase, out var baseOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
effective += ReadCtxU64(contextRecord, baseOffset);
|
||||
}
|
||||
|
||||
if (instruction.MemoryIndex != Register.None)
|
||||
{
|
||||
if (!TryGetGpr64Offset(instruction.MemoryIndex, out var indexOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
effective += ReadCtxU64(contextRecord, indexOffset) * (ulong)instruction.MemoryIndexScale;
|
||||
}
|
||||
|
||||
address = effective;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Maps a 32- or 64-bit GPR to its CONTEXT offset and reports the operand width it implies.
|
||||
private static bool TryGetGprSlot(Register register, out int offset, out GprOperandSize size)
|
||||
{
|
||||
switch (register)
|
||||
{
|
||||
case Register.EAX: offset = CTX_RAX; size = GprOperandSize.Bits32; return true;
|
||||
case Register.ECX: offset = CTX_RCX; size = GprOperandSize.Bits32; return true;
|
||||
case Register.EDX: offset = CTX_RDX; size = GprOperandSize.Bits32; return true;
|
||||
case Register.EBX: offset = CTX_RBX; size = GprOperandSize.Bits32; return true;
|
||||
case Register.ESP: offset = CTX_RSP; size = GprOperandSize.Bits32; return true;
|
||||
case Register.EBP: offset = CTX_RBP; size = GprOperandSize.Bits32; return true;
|
||||
case Register.ESI: offset = CTX_RSI; size = GprOperandSize.Bits32; return true;
|
||||
case Register.EDI: offset = CTX_RDI; size = GprOperandSize.Bits32; return true;
|
||||
case Register.R8D: offset = CTX_R8; size = GprOperandSize.Bits32; return true;
|
||||
case Register.R9D: offset = CTX_R9; size = GprOperandSize.Bits32; return true;
|
||||
case Register.R10D: offset = CTX_R10; size = GprOperandSize.Bits32; return true;
|
||||
case Register.R11D: offset = CTX_R11; size = GprOperandSize.Bits32; return true;
|
||||
case Register.R12D: offset = CTX_R12; size = GprOperandSize.Bits32; return true;
|
||||
case Register.R13D: offset = CTX_R13; size = GprOperandSize.Bits32; return true;
|
||||
case Register.R14D: offset = CTX_R14; size = GprOperandSize.Bits32; return true;
|
||||
case Register.R15D: offset = CTX_R15; size = GprOperandSize.Bits32; return true;
|
||||
default:
|
||||
if (TryGetGpr64Offset(register, out offset))
|
||||
{
|
||||
size = GprOperandSize.Bits64;
|
||||
return true;
|
||||
}
|
||||
|
||||
size = GprOperandSize.Bits32;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetGpr64Offset(Register register, out int offset)
|
||||
{
|
||||
switch (register)
|
||||
{
|
||||
case Register.RAX: offset = CTX_RAX; return true;
|
||||
case Register.RCX: offset = CTX_RCX; return true;
|
||||
case Register.RDX: offset = CTX_RDX; return true;
|
||||
case Register.RBX: offset = CTX_RBX; return true;
|
||||
case Register.RSP: offset = CTX_RSP; return true;
|
||||
case Register.RBP: offset = CTX_RBP; return true;
|
||||
case Register.RSI: offset = CTX_RSI; return true;
|
||||
case Register.RDI: offset = CTX_RDI; return true;
|
||||
case Register.R8: offset = CTX_R8; return true;
|
||||
case Register.R9: offset = CTX_R9; return true;
|
||||
case Register.R10: offset = CTX_R10; return true;
|
||||
case Register.R11: offset = CTX_R11; return true;
|
||||
case Register.R12: offset = CTX_R12; return true;
|
||||
case Register.R13: offset = CTX_R13; return true;
|
||||
case Register.R14: offset = CTX_R14; return true;
|
||||
case Register.R15: offset = CTX_R15; return true;
|
||||
default: offset = 0; return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,660 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native;
|
||||
|
||||
public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
// Guest entry stubs must not run above CLR-managed frames on a CLR-created thread:
|
||||
// the suspension/stack-walk machinery then has to traverse a frame chain that is
|
||||
// interleaved with guest stubs carrying no CLR unwind info, and any window in which
|
||||
// the thread-mode bookkeeping disagrees with the actual stack (import dispatch, VEH
|
||||
// redirection) fail-fasts the runtime — the audio_output_thread ~7th-cycle
|
||||
// "attempted to call a UnmanagedCallersOnly method from managed code" crash.
|
||||
//
|
||||
// Guest execution therefore runs on dedicated raw OS threads whose run loop is
|
||||
// emitted native code. While guest code executes there is not a single managed
|
||||
// frame on the thread and it sits in preemptive mode, so the GC ignores it
|
||||
// entirely. The only managed activity on the thread is the reverse-P/Invoke import
|
||||
// dispatch and the per-run prologue/epilogue, which the CLR supports on foreign
|
||||
// threads (lazy attach, preemptive between calls). The managed orchestrator
|
||||
// (RunGuestThread / the main execution thread) parks in a preemptive wait for the
|
||||
// duration of the run, so its own frame chain is never walked across guest frames.
|
||||
private static readonly bool NativeGuestWorkersDisabled =
|
||||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_NATIVE_GUEST_WORKERS"), "1", StringComparison.Ordinal);
|
||||
|
||||
private readonly object _nativeWorkerGate = new();
|
||||
private readonly List<NativeGuestExecutor> _allNativeWorkers = new();
|
||||
private readonly Stack<NativeGuestExecutor> _idleNativeWorkers = new();
|
||||
private bool _nativeWorkersDisposed;
|
||||
private int _nativeWorkerCreationFailedLogged;
|
||||
|
||||
private const uint StackSizeParamIsAReservation = 0x00010000u;
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern nint CreateThread(
|
||||
nint lpThreadAttributes,
|
||||
nuint dwStackSize,
|
||||
nint lpStartAddress,
|
||||
nint lpParameter,
|
||||
uint dwCreationFlags,
|
||||
out uint lpThreadId);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern uint WaitForSingleObject(nint hHandle, uint dwMilliseconds);
|
||||
|
||||
// Runs an emitted guest entry stub. Preferred path is a pooled native worker
|
||||
// thread; falls back to the historical inline calli (guest frames above this
|
||||
// thread's managed frames) when workers are disabled or unavailable.
|
||||
//
|
||||
// Callers set the Active* thread-statics before emitting the stub and read the
|
||||
// yield/forced-exit flags right after this returns, so the worker outcome is
|
||||
// copied back into this thread's statics before returning.
|
||||
private unsafe int RunGuestEntryStub(void* entryStub, ulong hostRspSlot)
|
||||
{
|
||||
var worker = RentNativeGuestExecutor();
|
||||
if (worker is null)
|
||||
{
|
||||
TlsSetValue(_hostRspSlotTlsIndex, (nint)hostRspSlot);
|
||||
return CallNativeEntry(entryStub);
|
||||
}
|
||||
try
|
||||
{
|
||||
var state = _activeGuestThreadState;
|
||||
var nativeReturn = worker.Run(
|
||||
_activeCpuContext!,
|
||||
state,
|
||||
GuestThreadExecution.CurrentGuestThreadHandle,
|
||||
_activeEntryReturnSentinelRip,
|
||||
_activeGuestReturnSlotAddress,
|
||||
(nint)hostRspSlot,
|
||||
(nint)entryStub,
|
||||
state?.AffinityMask ?? 0,
|
||||
out var yieldRequested,
|
||||
out var yieldReason,
|
||||
out var forcedExit);
|
||||
_activeGuestThreadYieldRequested = yieldRequested;
|
||||
_activeGuestThreadYieldReason = yieldReason;
|
||||
_activeForcedGuestExit = forcedExit;
|
||||
return nativeReturn;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReturnNativeGuestExecutor(worker);
|
||||
}
|
||||
}
|
||||
|
||||
private NativeGuestExecutor? RentNativeGuestExecutor()
|
||||
{
|
||||
// NativeGuestExecutor emits a Win32 wait loop and creates it with
|
||||
// kernel32!CreateThread. POSIX hosts use the established inline entry
|
||||
// path until the worker loop has a pthread/eventfd implementation.
|
||||
if (!OperatingSystem.IsWindows() || NativeGuestWorkersDisabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
lock (_nativeWorkerGate)
|
||||
{
|
||||
if (_nativeWorkersDisposed)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (_idleNativeWorkers.Count > 0)
|
||||
{
|
||||
return _idleNativeWorkers.Pop();
|
||||
}
|
||||
}
|
||||
var worker = NativeGuestExecutor.TryCreate(this);
|
||||
if (worker is null)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _nativeWorkerCreationFailedLogged, 1) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Failed to create a native guest worker thread; falling back to inline guest execution.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
lock (_nativeWorkerGate)
|
||||
{
|
||||
if (_nativeWorkersDisposed)
|
||||
{
|
||||
worker.Dispose();
|
||||
return null;
|
||||
}
|
||||
_allNativeWorkers.Add(worker);
|
||||
}
|
||||
return worker;
|
||||
}
|
||||
|
||||
private void ReturnNativeGuestExecutor(NativeGuestExecutor worker)
|
||||
{
|
||||
lock (_nativeWorkerGate)
|
||||
{
|
||||
if (!_nativeWorkersDisposed)
|
||||
{
|
||||
_idleNativeWorkers.Push(worker);
|
||||
return;
|
||||
}
|
||||
}
|
||||
worker.Dispose();
|
||||
}
|
||||
|
||||
private void DisposeNativeGuestExecutors()
|
||||
{
|
||||
NativeGuestExecutor[] workers;
|
||||
lock (_nativeWorkerGate)
|
||||
{
|
||||
if (_nativeWorkersDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_nativeWorkersDisposed = true;
|
||||
workers = _allNativeWorkers.ToArray();
|
||||
_allNativeWorkers.Clear();
|
||||
_idleNativeWorkers.Clear();
|
||||
}
|
||||
foreach (var worker in workers)
|
||||
{
|
||||
worker.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// A pooled raw OS thread that executes guest entry stubs. The run loop is emitted
|
||||
// native code (no CLR unwind info required — nothing ever unwinds through it):
|
||||
//
|
||||
// loop: WaitForSingleObject(work);
|
||||
// if (*stopFlag) { SetEvent(done); ExitThread(0); }
|
||||
// rax = RunPrologue(self); // managed: binds per-run ambient, returns stub
|
||||
// if (rax != 0) eax = rax(); // guest entry stub — zero managed frames below
|
||||
// RunEpilogue(self, eax); // managed: captures outcome, restores ambient
|
||||
// SetEvent(done); goto loop;
|
||||
//
|
||||
// Workers carry no per-guest identity of their own: the prologue rebinds guest TLS,
|
||||
// the host-RSP slot, the Active* thread-statics and the GuestThreadExecution ambient
|
||||
// on every run, so a worker can be reused for any guest thread.
|
||||
private sealed unsafe class NativeGuestExecutor : IDisposable
|
||||
{
|
||||
private const uint LoopStubSize = 512u;
|
||||
private const uint WorkerStackReservation = 4u * 1024u * 1024u;
|
||||
|
||||
private static nint _waitForSingleObjectAddress;
|
||||
private static nint _setEventAddress;
|
||||
private static nint _exitThreadAddress;
|
||||
|
||||
private readonly DirectExecutionBackend _backend;
|
||||
// Windows uses AutoResetEvent (its SafeWaitHandle is a real kernel
|
||||
// event the emitted loop can wait on); POSIX uses worker-event
|
||||
// semaphores shared the same way via PosixHostStubs.
|
||||
private readonly AutoResetEvent? _workAvailable;
|
||||
private readonly AutoResetEvent? _workCompleted;
|
||||
private nint _workSemaphore;
|
||||
private nint _doneSemaphore;
|
||||
|
||||
// RunPrologue/RunEpilogue compile to the host ABI (SysV on POSIX); the
|
||||
// emitted loop calls them with Win64 registers, so POSIX routes the
|
||||
// calls through register-shuffling thunks (shared by all workers).
|
||||
private static nint _posixPrologueThunk;
|
||||
private static nint _posixEpilogueThunk;
|
||||
private static readonly object PosixThunkGate = new();
|
||||
private GCHandle _selfHandle;
|
||||
private void* _controlBlock;
|
||||
private void* _loopStub;
|
||||
private nint _threadHandle;
|
||||
private uint _nativeThreadId;
|
||||
|
||||
// Single in-flight run; publication is ordered by the work/done event pair.
|
||||
private CpuContext? _runContext;
|
||||
private GuestThreadState? _runState;
|
||||
private ulong _runGuestThreadHandle;
|
||||
private ulong _runSentinelRip;
|
||||
private ulong _runReturnSlotAddress;
|
||||
private nint _runHostRspSlot;
|
||||
private nint _runEntryStub;
|
||||
private ulong _runAffinityMask;
|
||||
private int _runNativeResult;
|
||||
private bool _runYieldRequested;
|
||||
private string? _runYieldReason;
|
||||
private bool _runForcedExit;
|
||||
private bool _runPrologueFailed;
|
||||
|
||||
// Prologue -> epilogue carry, only touched on the worker thread.
|
||||
private DirectExecutionBackend? _prevBackend;
|
||||
private CpuContext? _prevContext;
|
||||
private ulong _prevSentinel;
|
||||
private ulong _prevReturnSlot;
|
||||
private bool _prevForcedExit;
|
||||
private bool _prevYieldRequested;
|
||||
private string? _prevYieldReason;
|
||||
private GuestThreadState? _prevState;
|
||||
private ulong _prevGuestThreadHandle;
|
||||
private nint _prevHostRspSlot;
|
||||
private int _prevHostThreadId;
|
||||
private bool _entered;
|
||||
|
||||
private NativeGuestExecutor(DirectExecutionBackend backend)
|
||||
{
|
||||
_backend = backend;
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
_workAvailable = new AutoResetEvent(false);
|
||||
_workCompleted = new AutoResetEvent(false);
|
||||
}
|
||||
}
|
||||
|
||||
public static NativeGuestExecutor? TryCreate(DirectExecutionBackend backend)
|
||||
{
|
||||
if (!EnsureKernel32Exports())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var executor = new NativeGuestExecutor(backend);
|
||||
if (!executor.Initialize())
|
||||
{
|
||||
executor.Dispose();
|
||||
return null;
|
||||
}
|
||||
return executor;
|
||||
}
|
||||
|
||||
private static bool EnsureKernel32Exports()
|
||||
{
|
||||
if (_exitThreadAddress != 0)
|
||||
{
|
||||
return _waitForSingleObjectAddress != 0 && _setEventAddress != 0;
|
||||
}
|
||||
nint kernel32 = GetModuleHandle("kernel32.dll");
|
||||
if (kernel32 == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_waitForSingleObjectAddress = GetProcAddress(kernel32, "WaitForSingleObject");
|
||||
_setEventAddress = GetProcAddress(kernel32, "SetEvent");
|
||||
_exitThreadAddress = GetProcAddress(kernel32, "ExitThread");
|
||||
return _waitForSingleObjectAddress != 0 && _setEventAddress != 0 && _exitThreadAddress != 0;
|
||||
}
|
||||
|
||||
private bool Initialize()
|
||||
{
|
||||
_selfHandle = GCHandle.Alloc(this);
|
||||
_controlBlock = VirtualAlloc(null, 4096u, 12288u, 4u);
|
||||
if (_controlBlock == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_loopStub = VirtualAlloc(null, LoopStubSize, 12288u, 64u);
|
||||
if (_loopStub == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var prologuePtr = (nint)(delegate* unmanaged<nint, nint>)&RunPrologue;
|
||||
var epiloguePtr = (nint)(delegate* unmanaged<nint, int, void>)&RunEpilogue;
|
||||
var executorHandle = GCHandle.ToIntPtr(_selfHandle);
|
||||
nint workHandle;
|
||||
nint doneHandle;
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
workHandle = _workAvailable!.SafeWaitHandle.DangerousGetHandle();
|
||||
doneHandle = _workCompleted!.SafeWaitHandle.DangerousGetHandle();
|
||||
}
|
||||
else
|
||||
{
|
||||
lock (PosixThunkGate)
|
||||
{
|
||||
if (_posixPrologueThunk == 0)
|
||||
{
|
||||
_posixPrologueThunk = PosixHostStubs.CreateWin64ToSysVThunk(prologuePtr);
|
||||
_posixEpilogueThunk = PosixHostStubs.CreateWin64ToSysVThunk(epiloguePtr);
|
||||
}
|
||||
}
|
||||
prologuePtr = _posixPrologueThunk;
|
||||
epiloguePtr = _posixEpilogueThunk;
|
||||
_workSemaphore = PosixHostStubs.CreateWorkerEvent();
|
||||
_doneSemaphore = PosixHostStubs.CreateWorkerEvent();
|
||||
if (_workSemaphore == 0 || _doneSemaphore == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
workHandle = _workSemaphore;
|
||||
doneHandle = _doneSemaphore;
|
||||
}
|
||||
|
||||
byte* code = (byte*)_loopStub;
|
||||
int offset = 0;
|
||||
void Emit(byte value) => code[offset++] = value;
|
||||
void EmitMovRcxImm64(ulong value)
|
||||
{
|
||||
Emit(0x48); Emit(0xB9);
|
||||
*(ulong*)(code + offset) = value;
|
||||
offset += sizeof(ulong);
|
||||
}
|
||||
void EmitMovRaxImm64(ulong value)
|
||||
{
|
||||
Emit(0x48); Emit(0xB8);
|
||||
*(ulong*)(code + offset) = value;
|
||||
offset += sizeof(ulong);
|
||||
}
|
||||
void EmitCallRax()
|
||||
{
|
||||
Emit(0xFF); Emit(0xD0);
|
||||
}
|
||||
void EmitSetDoneEvent()
|
||||
{
|
||||
EmitMovRcxImm64((ulong)doneHandle);
|
||||
EmitMovRaxImm64((ulong)_setEventAddress);
|
||||
EmitCallRax();
|
||||
}
|
||||
|
||||
// Thread entry leaves RSP ≡ 8 (mod 16); after this every call below happens
|
||||
// at RSP ≡ 0 with shadow space available.
|
||||
Emit(0x48); Emit(0x83); Emit(0xEC); Emit(0x28); // sub rsp, 0x28
|
||||
int loopStart = offset;
|
||||
EmitMovRcxImm64((ulong)workHandle);
|
||||
Emit(0xBA); // mov edx, INFINITE
|
||||
*(uint*)(code + offset) = 0xFFFFFFFFu;
|
||||
offset += sizeof(uint);
|
||||
EmitMovRaxImm64((ulong)_waitForSingleObjectAddress);
|
||||
EmitCallRax();
|
||||
EmitMovRaxImm64((ulong)_controlBlock);
|
||||
Emit(0x83); Emit(0x38); Emit(0x00); // cmp dword [rax], 0
|
||||
Emit(0x0F); Emit(0x85); // jne stop
|
||||
int stopJump = offset;
|
||||
offset += sizeof(int);
|
||||
EmitMovRcxImm64((ulong)executorHandle);
|
||||
EmitMovRaxImm64((ulong)prologuePtr);
|
||||
EmitCallRax(); // rax = entry stub, or 0 on failure
|
||||
Emit(0x48); Emit(0x85); Emit(0xC0); // test rax, rax
|
||||
Emit(0x0F); Emit(0x84); // je skipEntry
|
||||
int skipJump = offset;
|
||||
offset += sizeof(int);
|
||||
EmitCallRax(); // guest entry stub -> eax
|
||||
int skipEntryOffset = offset;
|
||||
Emit(0x89); Emit(0xC2); // mov edx, eax
|
||||
EmitMovRcxImm64((ulong)executorHandle);
|
||||
EmitMovRaxImm64((ulong)epiloguePtr);
|
||||
EmitCallRax();
|
||||
EmitSetDoneEvent();
|
||||
Emit(0xE9); // jmp loop
|
||||
*(int*)(code + offset) = loopStart - (offset + sizeof(int));
|
||||
offset += sizeof(int);
|
||||
int stopOffset = offset;
|
||||
// Signal done so a Run() racing teardown cannot park forever; a stopped
|
||||
// worker is never re-rented, so the stale signal is unobservable.
|
||||
EmitSetDoneEvent();
|
||||
Emit(0x31); Emit(0xC9); // xor ecx, ecx
|
||||
EmitMovRaxImm64((ulong)_exitThreadAddress);
|
||||
EmitCallRax();
|
||||
Emit(0xCC); // int3 (ExitThread never returns)
|
||||
*(int*)(code + stopJump) = stopOffset - (stopJump + sizeof(int));
|
||||
*(int*)(code + skipJump) = skipEntryOffset - (skipJump + sizeof(int));
|
||||
|
||||
uint oldProtect = 0;
|
||||
if (!VirtualProtect(_loopStub, LoopStubSize, 32u, &oldProtect))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
FlushInstructionCache(GetCurrentProcess(), _loopStub, LoopStubSize);
|
||||
_threadHandle = CreateThread(
|
||||
0,
|
||||
WorkerStackReservation,
|
||||
(nint)_loopStub,
|
||||
0,
|
||||
StackSizeParamIsAReservation,
|
||||
out _nativeThreadId);
|
||||
if (_threadHandle == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (LogThreadMode)
|
||||
{
|
||||
TraceThreadMode($"worker_created native_tid={_nativeThreadId} loop=0x{(ulong)_loopStub:X16}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public int Run(
|
||||
CpuContext context,
|
||||
GuestThreadState? state,
|
||||
ulong guestThreadHandle,
|
||||
ulong sentinelRip,
|
||||
ulong returnSlotAddress,
|
||||
nint hostRspSlot,
|
||||
nint entryStub,
|
||||
ulong affinityMask,
|
||||
out bool yieldRequested,
|
||||
out string? yieldReason,
|
||||
out bool forcedExit)
|
||||
{
|
||||
_runContext = context;
|
||||
_runState = state;
|
||||
_runGuestThreadHandle = guestThreadHandle;
|
||||
_runSentinelRip = sentinelRip;
|
||||
_runReturnSlotAddress = returnSlotAddress;
|
||||
_runHostRspSlot = hostRspSlot;
|
||||
_runEntryStub = entryStub;
|
||||
_runAffinityMask = affinityMask;
|
||||
_runPrologueFailed = true;
|
||||
_runYieldRequested = false;
|
||||
_runYieldReason = null;
|
||||
_runForcedExit = false;
|
||||
SignalWorkAvailable();
|
||||
WaitWorkCompleted();
|
||||
_runContext = null;
|
||||
_runState = null;
|
||||
yieldRequested = _runYieldRequested;
|
||||
yieldReason = _runYieldReason;
|
||||
forcedExit = _runForcedExit;
|
||||
if (_runPrologueFailed)
|
||||
{
|
||||
throw new InvalidOperationException("Native guest worker failed to bind the run ambient (prologue fault)");
|
||||
}
|
||||
return _runNativeResult;
|
||||
}
|
||||
|
||||
private void SignalWorkAvailable()
|
||||
{
|
||||
if (_workAvailable is not null)
|
||||
{
|
||||
_workAvailable.Set();
|
||||
return;
|
||||
}
|
||||
|
||||
_ = PosixHostStubs.SignalWorkerEvent(_workSemaphore);
|
||||
}
|
||||
|
||||
private void WaitWorkCompleted()
|
||||
{
|
||||
if (_workCompleted is not null)
|
||||
{
|
||||
_workCompleted.WaitOne();
|
||||
return;
|
||||
}
|
||||
|
||||
_ = PosixHostStubs.WaitWorkerEvent(_doneSemaphore, -1);
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly]
|
||||
private static nint RunPrologue(nint executorHandle)
|
||||
{
|
||||
try
|
||||
{
|
||||
var executor = (NativeGuestExecutor)GCHandle.FromIntPtr(executorHandle).Target!;
|
||||
return executor.EnterRun();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][ERROR] Native guest worker prologue failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly]
|
||||
private static void RunEpilogue(nint executorHandle, int nativeResult)
|
||||
{
|
||||
try
|
||||
{
|
||||
var executor = (NativeGuestExecutor)GCHandle.FromIntPtr(executorHandle).Target!;
|
||||
executor.ExitRun(nativeResult);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
try
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][ERROR] Native guest worker epilogue failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nint EnterRun()
|
||||
{
|
||||
var backend = _backend;
|
||||
_prevBackend = _activeExecutionBackend;
|
||||
_prevContext = _activeCpuContext;
|
||||
_prevSentinel = _activeEntryReturnSentinelRip;
|
||||
_prevReturnSlot = _activeGuestReturnSlotAddress;
|
||||
_prevForcedExit = _activeForcedGuestExit;
|
||||
_prevYieldRequested = _activeGuestThreadYieldRequested;
|
||||
_prevYieldReason = _activeGuestThreadYieldReason;
|
||||
_prevState = _activeGuestThreadState;
|
||||
_prevHostRspSlot = TlsGetValue(backend._hostRspSlotTlsIndex);
|
||||
_prevGuestThreadHandle = GuestThreadExecution.EnterGuestThread(_runGuestThreadHandle);
|
||||
_entered = true;
|
||||
_activeExecutionBackend = backend;
|
||||
_activeCpuContext = _runContext;
|
||||
_activeEntryReturnSentinelRip = _runSentinelRip;
|
||||
_activeGuestReturnSlotAddress = _runReturnSlotAddress;
|
||||
_activeForcedGuestExit = false;
|
||||
_activeGuestThreadYieldRequested = false;
|
||||
_activeGuestThreadYieldReason = null;
|
||||
_activeGuestThreadState = _runState;
|
||||
backend.BindTlsBase(_runContext!);
|
||||
TlsSetValue(backend._hostRspSlotTlsIndex, _runHostRspSlot);
|
||||
if (_runState is { } state)
|
||||
{
|
||||
_prevHostThreadId = Volatile.Read(ref state.HostThreadId);
|
||||
Volatile.Write(ref state.HostThreadId, unchecked((int)GetCurrentThreadId()));
|
||||
}
|
||||
if (_runAffinityMask != 0)
|
||||
{
|
||||
backend.ApplyGuestThreadAffinity(_runAffinityMask);
|
||||
}
|
||||
_runPrologueFailed = false;
|
||||
if (LogThreadMode)
|
||||
{
|
||||
TraceThreadMode(
|
||||
$"worker_enter guest=0x{_runGuestThreadHandle:X16} stub=0x{(ulong)_runEntryStub:X16}");
|
||||
}
|
||||
return _runEntryStub;
|
||||
}
|
||||
|
||||
private void ExitRun(int nativeResult)
|
||||
{
|
||||
_runNativeResult = nativeResult;
|
||||
_runYieldRequested = _activeGuestThreadYieldRequested;
|
||||
_runYieldReason = _activeGuestThreadYieldReason;
|
||||
_runForcedExit = _activeForcedGuestExit;
|
||||
if (!_entered)
|
||||
{
|
||||
return;
|
||||
}
|
||||
_entered = false;
|
||||
if (_runState is { } state)
|
||||
{
|
||||
Volatile.Write(ref state.HostThreadId, _prevHostThreadId);
|
||||
}
|
||||
TlsSetValue(_backend._hostRspSlotTlsIndex, _prevHostRspSlot);
|
||||
GuestThreadExecution.RestoreGuestThread(_prevGuestThreadHandle);
|
||||
_activeExecutionBackend = _prevBackend;
|
||||
_activeCpuContext = _prevContext;
|
||||
_activeEntryReturnSentinelRip = _prevSentinel;
|
||||
_activeGuestReturnSlotAddress = _prevReturnSlot;
|
||||
_activeForcedGuestExit = _prevForcedExit;
|
||||
_activeGuestThreadYieldRequested = _prevYieldRequested;
|
||||
_activeGuestThreadYieldReason = _prevYieldReason;
|
||||
_activeGuestThreadState = _prevState;
|
||||
_prevBackend = null;
|
||||
_prevContext = null;
|
||||
_prevState = null;
|
||||
_prevYieldReason = null;
|
||||
if (LogThreadMode)
|
||||
{
|
||||
TraceThreadMode(
|
||||
$"worker_exit guest=0x{_runGuestThreadHandle:X16} result=0x{nativeResult:X8} yield={_runYieldRequested}");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_controlBlock != null)
|
||||
{
|
||||
*(int*)_controlBlock = 1;
|
||||
}
|
||||
try
|
||||
{
|
||||
SignalWorkAvailable();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
var exited = _threadHandle == 0;
|
||||
if (_threadHandle != 0)
|
||||
{
|
||||
exited = WaitForSingleObject(_threadHandle, 1000u) == 0u;
|
||||
CloseHandle(_threadHandle);
|
||||
_threadHandle = 0;
|
||||
}
|
||||
if (!exited)
|
||||
{
|
||||
// The worker is still parked in guest code (teardown should have unwound
|
||||
// it first). Leak the stub, control block, events and GC handle rather
|
||||
// than have the thread execute freed memory.
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Native guest worker tid={_nativeThreadId} did not stop; leaking its run loop.");
|
||||
return;
|
||||
}
|
||||
if (_loopStub != null)
|
||||
{
|
||||
VirtualFree(_loopStub, 0u, 32768u);
|
||||
_loopStub = null;
|
||||
}
|
||||
if (_controlBlock != null)
|
||||
{
|
||||
VirtualFree(_controlBlock, 0u, 32768u);
|
||||
_controlBlock = null;
|
||||
}
|
||||
if (_selfHandle.IsAllocated)
|
||||
{
|
||||
_selfHandle.Free();
|
||||
}
|
||||
_workAvailable?.Dispose();
|
||||
_workCompleted?.Dispose();
|
||||
if (_workSemaphore != 0)
|
||||
{
|
||||
PosixHostStubs.DestroyWorkerEvent(_workSemaphore);
|
||||
_workSemaphore = 0;
|
||||
}
|
||||
if (_doneSemaphore != 0)
|
||||
{
|
||||
PosixHostStubs.DestroyWorkerEvent(_doneSemaphore);
|
||||
_doneSemaphore = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native;
|
||||
|
||||
public sealed unsafe partial class DirectExecutionBackend
|
||||
{
|
||||
// POSIX bridge for the Windows vectored-exception-handler logic. A
|
||||
// sigaction(SIGSEGV/SIGBUS/SIGILL) handler rebuilds the EXCEPTION_POINTERS
|
||||
// view the shared handlers expect (Win64 CONTEXT register offsets) from
|
||||
// the signal's mcontext, runs the same recovery chain the VEH path uses
|
||||
// (unresolved-import trap sentinels, demand-paging of lazily-committed
|
||||
// guest pages, fault diagnostics), and writes register changes back into
|
||||
// the mcontext so sigreturn resumes the repaired guest. Unrecovered
|
||||
// faults are forwarded to the previously installed handler so the .NET
|
||||
// runtime keeps turning its own faults into managed exceptions.
|
||||
|
||||
private const int PosixSigIll = 4;
|
||||
private const int PosixSigTrap = 5;
|
||||
private const int PosixSigAbort = 6;
|
||||
private const int PosixSigSegv = 11;
|
||||
private static readonly int PosixSigBus = OperatingSystem.IsMacOS() ? 10 : 7;
|
||||
|
||||
// struct sigaction: the handler pointer leads on both platforms; Darwin
|
||||
// packs { handler(8), mask(4), flags(4) }, Linux glibc/musl packs
|
||||
// { handler(8), mask(128), flags(4), restorer(8) }.
|
||||
private static readonly int PosixSigactionSize = OperatingSystem.IsMacOS() ? 16 : 152;
|
||||
private static readonly int PosixSigactionFlagsOffset = OperatingSystem.IsMacOS() ? 12 : 136;
|
||||
|
||||
private static readonly int PosixSaSigInfo = OperatingSystem.IsMacOS() ? 0x0040 : 0x0004;
|
||||
private static readonly int PosixSaNoDefer = OperatingSystem.IsMacOS() ? 0x0010 : 0x40000000;
|
||||
|
||||
// siginfo_t.si_addr: Darwin { signo, errno, code, pid, uid, status, addr },
|
||||
// Linux { signo, errno, code, pad32, addr }.
|
||||
private static readonly int PosixSigInfoAddressOffset = OperatingSystem.IsMacOS() ? 24 : 16;
|
||||
|
||||
// Darwin ucontext_t stores a pointer to __darwin_mcontext64 at +48; the
|
||||
// general registers live in its __ss thread state after the 16-byte
|
||||
// exception state. Linux glibc embeds mcontext_t inline at +40 with the
|
||||
// registers in gregs[23]. Rosetta 2 delivers the regular x86-64 layout
|
||||
// to translated processes.
|
||||
private const int DarwinUcontextMcontextOffset = 48;
|
||||
private const int DarwinMcontextErrOffset = 4;
|
||||
private const int DarwinMcontextFaultAddressOffset = 8;
|
||||
private const int LinuxUcontextGregsOffset = 40;
|
||||
private const int LinuxGregsErrOffset = 19 * 8;
|
||||
|
||||
// Byte offsets of the general registers relative to GetPosixRegisterBase,
|
||||
// ordered to match the contiguous Win64 CONTEXT block CTX_RAX..CTX_RIP
|
||||
// (rax, rcx, rdx, rbx, rsp, rbp, rsi, rdi, r8..r15, rip). Verified
|
||||
// against the x86-64 platform headers.
|
||||
private static readonly int[] PosixRegisterOffsets = OperatingSystem.IsMacOS()
|
||||
? new[] { 16, 32, 40, 24, 72, 64, 56, 48, 80, 88, 96, 104, 112, 120, 128, 136, 144 }
|
||||
: new[] { 104, 112, 96, 88, 120, 80, 72, 64, 0, 8, 16, 24, 32, 40, 48, 56, 128 };
|
||||
|
||||
private static DirectExecutionBackend? _posixSignalBackend;
|
||||
private static bool _posixSignalHandlersInstalled;
|
||||
private static bool _posixRawRecoveryEnabled;
|
||||
private static bool _posixSignalWarmup;
|
||||
private static readonly nint[] _posixPreviousActions = new nint[32];
|
||||
private static int _posixSignalTraceCount;
|
||||
private static long _perfSignalCount;
|
||||
private static readonly bool _perfSignalCounter =
|
||||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_PERF_MEM"), "1", StringComparison.Ordinal);
|
||||
|
||||
[ThreadStatic]
|
||||
private static int _posixSignalHandlerDepth;
|
||||
|
||||
private void SetupPosixExceptionHandler()
|
||||
{
|
||||
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_POSIX_SIGNALS"), "1", StringComparison.Ordinal))
|
||||
{
|
||||
Console.Error.WriteLine("[LOADER][WARN] POSIX signal exception bridge disabled by SHARPEMU_DISABLE_POSIX_SIGNALS=1; guest faults will not be recovered.");
|
||||
return;
|
||||
}
|
||||
|
||||
_posixSignalBackend = this;
|
||||
if (_posixSignalHandlersInstalled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_posixRawRecoveryEnabled = !string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_RAW_HANDLER"), "1", StringComparison.Ordinal);
|
||||
if (!_posixRawRecoveryEnabled)
|
||||
{
|
||||
Console.Error.WriteLine("[LOADER][INFO] Raw sentinel recovery disabled by SHARPEMU_DISABLE_RAW_HANDLER=1");
|
||||
}
|
||||
|
||||
WarmUpPosixSignalPath();
|
||||
SharpEmu.HLE.GuestImageWriteTracker.WarmUp();
|
||||
|
||||
if (!InstallPosixSignalHandler(PosixSigSegv) ||
|
||||
!InstallPosixSignalHandler(PosixSigBus) ||
|
||||
!InstallPosixSignalHandler(PosixSigIll) ||
|
||||
!InstallPosixSignalHandler(PosixSigTrap) ||
|
||||
!InstallPosixSignalHandler(PosixSigAbort))
|
||||
{
|
||||
throw new InvalidOperationException("Failed to install POSIX fault signal handlers");
|
||||
}
|
||||
|
||||
_posixSignalHandlersInstalled = true;
|
||||
Console.Error.WriteLine("[LOADER][INFO] POSIX signal exception bridge installed (SIGSEGV/SIGBUS/SIGILL)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the signal-recovery path once with fabricated inputs before the
|
||||
/// handlers are installed. The first entry into the handler must not
|
||||
/// require JIT compilation (a fault can interrupt arbitrary runtime
|
||||
/// states), and under Rosetta 2 the signal trampoline cannot enter x86
|
||||
/// code that has never been executed (and therefore never translated): a
|
||||
/// cold handler is silently never invoked and the faulting instruction
|
||||
/// retries forever.
|
||||
/// </summary>
|
||||
private void WarmUpPosixSignalPath()
|
||||
{
|
||||
byte* fakeUcontext = stackalloc byte[512];
|
||||
new Span<byte>(fakeUcontext, 512).Clear();
|
||||
byte* fakeMcontext = stackalloc byte[512];
|
||||
new Span<byte>(fakeMcontext, 512).Clear();
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
*(byte**)(fakeUcontext + DarwinUcontextMcontextOffset) = fakeMcontext;
|
||||
}
|
||||
|
||||
_posixSignalWarmup = true;
|
||||
try
|
||||
{
|
||||
((delegate* unmanaged<int, nint, nint, void>)&HandlePosixSignal)(PosixSigSegv, 0, (nint)fakeUcontext);
|
||||
|
||||
// Warm the branches the fabricated fault above skips without
|
||||
// spamming diagnostics: the benign-exception path through
|
||||
// VectoredHandler, the lazy-commit probe (fault address 0 bails
|
||||
// out immediately), and the chain helper (signal 0 has no saved
|
||||
// action and sigaction(0, ...) fails with EINVAL).
|
||||
EXCEPTION_RECORD record = default;
|
||||
record.ExceptionCode = DBG_PRINTEXCEPTION_C;
|
||||
byte* contextRecord = stackalloc byte[Win64ContextSize];
|
||||
new Span<byte>(contextRecord, Win64ContextSize).Clear();
|
||||
EXCEPTION_POINTERS pointers;
|
||||
pointers.ExceptionRecord = &record;
|
||||
pointers.ContextRecord = contextRecord;
|
||||
_ = VectoredHandler(&pointers);
|
||||
|
||||
record.ExceptionCode = 3221225477u;
|
||||
record.NumberParameters = 2;
|
||||
// 0x70000 is never guest-owned, so this walks the vmem region
|
||||
// scan and the PRT range check, then bails out silently.
|
||||
record.ExceptionInformation[1] = 0x70000;
|
||||
_ = TryHandleLazyCommittedPage(&record, 0, 0);
|
||||
ChainPreviousPosixAction(0, 0, 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_posixSignalWarmup = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool InstallPosixSignalHandler(int signal)
|
||||
{
|
||||
byte* action = stackalloc byte[PosixSigactionSize];
|
||||
new Span<byte>(action, PosixSigactionSize).Clear();
|
||||
*(nint*)action = (nint)(delegate* unmanaged<int, nint, nint, void>)&HandlePosixSignal;
|
||||
// No SA_ONSTACK: the runtime's alternate stacks are far too small for
|
||||
// the recovery/diagnostic path (JIT compilation of cold handler code
|
||||
// can run inside the signal frame). Guest faults deliver onto the 2MB
|
||||
// guest stack, host faults onto the regular thread stack — the same
|
||||
// stacks Windows dispatches exceptions on.
|
||||
*(int*)(action + PosixSigactionFlagsOffset) = PosixSaSigInfo | PosixSaNoDefer;
|
||||
|
||||
var previous = (byte*)NativeMemory.AllocZeroed((nuint)PosixSigactionSize);
|
||||
if (sigaction(signal, action, previous) != 0)
|
||||
{
|
||||
NativeMemory.Free(previous);
|
||||
Console.Error.WriteLine($"[LOADER][ERROR] sigaction({signal}) failed: errno={Marshal.GetLastPInvokeError()}");
|
||||
return false;
|
||||
}
|
||||
|
||||
_posixPreviousActions[signal] = (nint)previous;
|
||||
return true;
|
||||
}
|
||||
|
||||
[UnmanagedCallersOnly]
|
||||
private static void HandlePosixSignal(int signal, nint siginfo, nint ucontext)
|
||||
{
|
||||
if (_posixSignalHandlerDepth > 0)
|
||||
{
|
||||
// A fault inside our own fault handler (diagnostics touched an
|
||||
// unmapped address): restore the default action and return so the
|
||||
// re-executed instruction terminates the process.
|
||||
RestoreDefaultPosixAction(signal);
|
||||
return;
|
||||
}
|
||||
|
||||
_posixSignalHandlerDepth++;
|
||||
if (_perfSignalCounter)
|
||||
{
|
||||
var n = Interlocked.Increment(ref _perfSignalCount);
|
||||
if (n % 100000 == 0)
|
||||
{
|
||||
Console.Error.WriteLine($"[PERF][MEM] posix_faults={n}");
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
// Guest-image write tracking runs first: it only needs the fault
|
||||
// address (safe for host and guest threads alike) and must resume
|
||||
// the faulting write immediately after restoring write access.
|
||||
if (signal != PosixSigIll &&
|
||||
siginfo != 0 &&
|
||||
SharpEmu.HLE.GuestImageWriteTracker.TryHandleWriteFault(
|
||||
*(ulong*)((byte*)siginfo + PosixSigInfoAddressOffset)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryHandlePosixFault(signal, siginfo, ucontext))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// A managed exception must never unwind out of a signal frame.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_posixSignalHandlerDepth--;
|
||||
}
|
||||
|
||||
ChainPreviousPosixAction(signal, siginfo, ucontext);
|
||||
}
|
||||
|
||||
private static bool TryHandlePosixFault(int signal, nint siginfo, nint ucontext)
|
||||
{
|
||||
byte* registers = GetPosixRegisterBase(ucontext);
|
||||
if (registers == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte* contextRecord = stackalloc byte[Win64ContextSize];
|
||||
new Span<byte>(contextRecord, Win64ContextSize).Clear();
|
||||
int[] offsets = PosixRegisterOffsets;
|
||||
for (int i = 0; i < offsets.Length; i++)
|
||||
{
|
||||
WriteCtxU64(contextRecord, CTX_RAX + i * 8, *(ulong*)(registers + offsets[i]));
|
||||
}
|
||||
|
||||
EXCEPTION_RECORD record = default;
|
||||
record.ExceptionAddress = (void*)ReadCtxU64(contextRecord, CTX_RIP);
|
||||
if (signal == PosixSigIll)
|
||||
{
|
||||
record.ExceptionCode = 3221225501u;
|
||||
}
|
||||
else if (signal == PosixSigTrap)
|
||||
{
|
||||
record.ExceptionCode = 2147483651u;
|
||||
}
|
||||
else if (signal == PosixSigAbort)
|
||||
{
|
||||
record.ExceptionCode = 1073741845u;
|
||||
}
|
||||
else
|
||||
{
|
||||
ulong faultAddress = GetPosixFaultAddress(siginfo, registers);
|
||||
record.ExceptionCode = 3221225477u;
|
||||
record.NumberParameters = 2;
|
||||
record.ExceptionInformation[0] = GetPosixAccessType(registers, faultAddress, ReadCtxU64(contextRecord, CTX_RIP));
|
||||
record.ExceptionInformation[1] = faultAddress;
|
||||
}
|
||||
|
||||
EXCEPTION_POINTERS pointers;
|
||||
pointers.ExceptionRecord = &record;
|
||||
pointers.ContextRecord = contextRecord;
|
||||
|
||||
int traceIndex = _posixSignalWarmup ? 0 : Interlocked.Increment(ref _posixSignalTraceCount);
|
||||
bool traceSignal = traceIndex > 0 && (traceIndex <= 16 || traceIndex % 1024 == 0 ||
|
||||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_POSIX_SIGNALS"), "1", StringComparison.Ordinal));
|
||||
if (traceSignal)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] posix-signal#{traceIndex}: sig={signal} rip=0x{ReadCtxU64(contextRecord, CTX_RIP):X16} " +
|
||||
$"fault=0x{record.ExceptionInformation[1]:X16} access={record.ExceptionInformation[0]} rsp=0x{ReadCtxU64(contextRecord, CTX_RSP):X16}");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
|
||||
// Sentinel recovery runs first: on Windows both vectored handlers see
|
||||
// every fault anyway, and recovering here avoids dumping the full
|
||||
// VectoredHandler diagnostics for each recoverable trap.
|
||||
int disposition = 0;
|
||||
if (_posixRawRecoveryEnabled)
|
||||
{
|
||||
disposition = TryRecoverUnresolvedSentinel(&pointers);
|
||||
}
|
||||
if (disposition != -1 && !_posixSignalWarmup && _posixSignalBackend is { } backend)
|
||||
{
|
||||
disposition = backend.VectoredHandler(&pointers);
|
||||
}
|
||||
if (traceSignal)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] posix-signal#{traceIndex}: recovered={disposition == -1} new_rip=0x{ReadCtxU64(contextRecord, CTX_RIP):X16}");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
if (disposition != -1 && !_posixSignalWarmup)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < offsets.Length; i++)
|
||||
{
|
||||
*(ulong*)(registers + offsets[i]) = ReadCtxU64(contextRecord, CTX_RAX + i * 8);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static byte* GetPosixRegisterBase(nint ucontext)
|
||||
{
|
||||
if (ucontext == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return *(byte**)((byte*)ucontext + DarwinUcontextMcontextOffset);
|
||||
}
|
||||
|
||||
return (byte*)ucontext + LinuxUcontextGregsOffset;
|
||||
}
|
||||
|
||||
private static ulong GetPosixFaultAddress(nint siginfo, byte* registers)
|
||||
{
|
||||
ulong address = siginfo != 0 ? *(ulong*)((byte*)siginfo + PosixSigInfoAddressOffset) : 0;
|
||||
if (address == 0 && OperatingSystem.IsMacOS())
|
||||
{
|
||||
address = *(ulong*)(registers + DarwinMcontextFaultAddressOffset);
|
||||
}
|
||||
|
||||
return address;
|
||||
}
|
||||
|
||||
private static ulong GetPosixAccessType(byte* registers, ulong faultAddress, ulong rip)
|
||||
{
|
||||
// x86 page-fault error code: bit 1 = write access, bit 4 = instruction
|
||||
// fetch. Fall back to comparing the fault address against RIP when
|
||||
// the error code is not populated (e.g. under Rosetta 2 translation).
|
||||
ulong error = OperatingSystem.IsMacOS()
|
||||
? *(uint*)(registers + DarwinMcontextErrOffset)
|
||||
: *(ulong*)(registers + LinuxGregsErrOffset);
|
||||
if ((error & 0x10) != 0)
|
||||
{
|
||||
return 8;
|
||||
}
|
||||
if ((error & 0x2) != 0)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return faultAddress != 0 && faultAddress == rip ? 8u : 0u;
|
||||
}
|
||||
|
||||
private static void RestoreDefaultPosixAction(int signal)
|
||||
{
|
||||
byte* action = stackalloc byte[PosixSigactionSize];
|
||||
new Span<byte>(action, PosixSigactionSize).Clear();
|
||||
_ = sigaction(signal, action, null);
|
||||
}
|
||||
|
||||
private static void ChainPreviousPosixAction(int signal, nint siginfo, nint ucontext)
|
||||
{
|
||||
byte* previous = (uint)signal < (uint)_posixPreviousActions.Length
|
||||
? (byte*)_posixPreviousActions[signal]
|
||||
: null;
|
||||
nint handler = previous != null ? *(nint*)previous : 0;
|
||||
if (handler == 0)
|
||||
{
|
||||
// SIG_DFL (or nothing saved): reinstate the default action and
|
||||
// return, so re-executing the faulting instruction terminates the
|
||||
// process with the original fault context intact.
|
||||
RestoreDefaultPosixAction(signal);
|
||||
return;
|
||||
}
|
||||
if (handler == 1)
|
||||
{
|
||||
// SIG_IGN
|
||||
return;
|
||||
}
|
||||
|
||||
int flags = *(int*)(previous + PosixSigactionFlagsOffset);
|
||||
if ((flags & PosixSaSigInfo) != 0)
|
||||
{
|
||||
((delegate* unmanaged<int, nint, nint, void>)handler)(signal, siginfo, ucontext);
|
||||
}
|
||||
else
|
||||
{
|
||||
((delegate* unmanaged<int, void>)handler)(signal);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("libc", SetLastError = true)]
|
||||
private static extern int sigaction(int signum, void* act, void* oldact);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE.Host;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder for hosts whose fault bridge is installed directly by the
|
||||
/// execution backend. POSIX uses its sigaction bridge and never calls these
|
||||
/// Windows-shaped registration methods.
|
||||
/// </summary>
|
||||
internal sealed class NullHostFaultHandling : IHostFaultHandling
|
||||
{
|
||||
public static NullHostFaultHandling Instance { get; } = new();
|
||||
|
||||
private NullHostFaultHandling()
|
||||
{
|
||||
}
|
||||
|
||||
public nint CreateHandlerThunk(nint managedCallback, uint hostRspSwitchTlsSlot, nint tlsGetValueAddress)
|
||||
{
|
||||
_ = managedCallback;
|
||||
_ = hostRspSwitchTlsSlot;
|
||||
_ = tlsGetValueAddress;
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void FreeThunk(nint thunk)
|
||||
{
|
||||
_ = thunk;
|
||||
}
|
||||
|
||||
public nint AddFirstChanceHandler(nint thunk)
|
||||
{
|
||||
_ = thunk;
|
||||
return 0;
|
||||
}
|
||||
|
||||
public void RemoveHandler(nint handle)
|
||||
{
|
||||
_ = handle;
|
||||
}
|
||||
|
||||
public void SetUnhandledFilter(nint thunk)
|
||||
{
|
||||
_ = thunk;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native;
|
||||
|
||||
/// <summary>
|
||||
/// POSIX replacements for the kernel32 helpers the native backend embeds in
|
||||
/// emitted x86-64 code. Every stub exposed here follows the Win64 calling
|
||||
/// convention the emitted call sites were written for (first argument in
|
||||
/// ECX, result in RAX, Win64 non-volatile registers preserved), so the
|
||||
/// emission code stays identical across platforms.
|
||||
/// </summary>
|
||||
internal static unsafe class PosixHostStubs
|
||||
{
|
||||
private static readonly object Gate = new();
|
||||
private static bool _initialized;
|
||||
private static nint _tlsGetValueStub;
|
||||
private static nint _queryPerformanceCounterStub;
|
||||
private static nint _switchToThreadStub;
|
||||
private static nint _sleepStub;
|
||||
|
||||
public static nint TlsGetValueStubAddress
|
||||
{
|
||||
get { EnsureInitialized(); return _tlsGetValueStub; }
|
||||
}
|
||||
|
||||
public static nint QueryPerformanceCounterStubAddress
|
||||
{
|
||||
get { EnsureInitialized(); return _queryPerformanceCounterStub; }
|
||||
}
|
||||
|
||||
public static nint SwitchToThreadStubAddress
|
||||
{
|
||||
get { EnsureInitialized(); return _switchToThreadStub; }
|
||||
}
|
||||
|
||||
public static nint SleepStubAddress
|
||||
{
|
||||
get { EnsureInitialized(); return _sleepStub; }
|
||||
}
|
||||
|
||||
public static nint CreateWorkerEvent()
|
||||
{
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
return dispatch_semaphore_create(0);
|
||||
}
|
||||
|
||||
var semaphore = Marshal.AllocHGlobal(64);
|
||||
if (sem_init(semaphore, 0, 0) != 0)
|
||||
{
|
||||
Marshal.FreeHGlobal(semaphore);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return semaphore;
|
||||
}
|
||||
|
||||
public static bool SignalWorkerEvent(nint handle)
|
||||
{
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
_ = dispatch_semaphore_signal(handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
return sem_post(handle) == 0;
|
||||
}
|
||||
|
||||
public static bool WaitWorkerEvent(nint handle, int timeoutMilliseconds)
|
||||
{
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
if (timeoutMilliseconds < 0)
|
||||
{
|
||||
return dispatch_semaphore_wait(handle, ulong.MaxValue) == 0;
|
||||
}
|
||||
|
||||
var deadline = dispatch_time(0, timeoutMilliseconds * 1_000_000L);
|
||||
return dispatch_semaphore_wait(handle, deadline) == 0;
|
||||
}
|
||||
|
||||
if (timeoutMilliseconds < 0)
|
||||
{
|
||||
while (sem_wait(handle) != 0)
|
||||
{
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
var deadlineTicks = Environment.TickCount64 + timeoutMilliseconds;
|
||||
while (sem_trywait(handle) != 0)
|
||||
{
|
||||
if (Environment.TickCount64 >= deadlineTicks)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void DestroyWorkerEvent(nint handle)
|
||||
{
|
||||
if (handle == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
dispatch_release(handle);
|
||||
return;
|
||||
}
|
||||
|
||||
_ = sem_destroy(handle);
|
||||
Marshal.FreeHGlobal(handle);
|
||||
}
|
||||
|
||||
/// <summary>Allocates a pthread TLS key, mirroring kernel32!TlsAlloc.</summary>
|
||||
public static uint TlsAlloc()
|
||||
{
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
nuint key;
|
||||
return pthread_key_create_mac(&key, 0) == 0 ? (uint)key : uint.MaxValue;
|
||||
}
|
||||
|
||||
uint key32;
|
||||
return pthread_key_create_linux(&key32, 0) == 0 ? key32 : uint.MaxValue;
|
||||
}
|
||||
|
||||
public static bool TlsFree(uint key)
|
||||
{
|
||||
return OperatingSystem.IsMacOS()
|
||||
? pthread_key_delete_mac((nuint)key) == 0
|
||||
: pthread_key_delete_linux(key) == 0;
|
||||
}
|
||||
|
||||
public static bool TlsSetValue(uint key, nint value)
|
||||
{
|
||||
return OperatingSystem.IsMacOS()
|
||||
? pthread_setspecific_mac((nuint)key, value) == 0
|
||||
: pthread_setspecific_linux(key, value) == 0;
|
||||
}
|
||||
|
||||
public static nint TlsGetValue(uint key)
|
||||
{
|
||||
return OperatingSystem.IsMacOS()
|
||||
? pthread_getspecific_mac((nuint)key)
|
||||
: pthread_getspecific_linux(key);
|
||||
}
|
||||
|
||||
/// <summary>Stable numeric id of the calling thread (kernel32!GetCurrentThreadId).</summary>
|
||||
public static uint GetCurrentThreadId()
|
||||
{
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
ulong tid;
|
||||
return pthread_threadid_np(0, &tid) == 0 ? unchecked((uint)tid) : 0u;
|
||||
}
|
||||
|
||||
return unchecked((uint)gettid());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a managed callback (compiled for the SysV ABI on POSIX .NET) in a
|
||||
/// thunk that accepts up to four integer arguments in the Win64 ABI the
|
||||
/// emitted x86-64 call sites use. Win64 passes args in rcx/rdx/r8/r9 and
|
||||
/// treats rdi/rsi as non-volatile; SysV expects rdi/rsi/rdx/rcx and
|
||||
/// clobbers them, so the thunk saves rdi/rsi, shuffles the registers, keeps
|
||||
/// the stack 16-byte aligned for the call, and forwards the rax result.
|
||||
/// </summary>
|
||||
public static nint CreateWin64ToSysVThunk(nint sysvTarget)
|
||||
{
|
||||
var page = (byte*)HostMemory.Alloc(
|
||||
null,
|
||||
4096,
|
||||
HostMemory.MEM_COMMIT | HostMemory.MEM_RESERVE,
|
||||
HostMemory.PAGE_EXECUTE_READWRITE);
|
||||
if (page == null)
|
||||
{
|
||||
throw new OutOfMemoryException("Failed to allocate Win64->SysV thunk page");
|
||||
}
|
||||
|
||||
var offset = 0;
|
||||
Emit(page, ref offset, 0x57); // push rdi
|
||||
Emit(page, ref offset, 0x56); // push rsi
|
||||
Emit(page, ref offset, 0x48, 0x89, 0xCF); // mov rdi, rcx
|
||||
Emit(page, ref offset, 0x48, 0x89, 0xD6); // mov rsi, rdx
|
||||
Emit(page, ref offset, 0x4C, 0x89, 0xC2); // mov rdx, r8
|
||||
Emit(page, ref offset, 0x4C, 0x89, 0xC9); // mov rcx, r9
|
||||
Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8 (realign to 16)
|
||||
EmitMovRaxImm64(page, ref offset, sysvTarget); // mov rax, target
|
||||
Emit(page, ref offset, 0xFF, 0xD0); // call rax
|
||||
Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x08); // add rsp, 8
|
||||
Emit(page, ref offset, 0x5E); // pop rsi
|
||||
Emit(page, ref offset, 0x5F); // pop rdi
|
||||
Emit(page, ref offset, 0xC3); // ret
|
||||
|
||||
if (!HostMemory.Protect(page, 4096, HostMemory.PAGE_EXECUTE_READ, out _))
|
||||
{
|
||||
throw new InvalidOperationException("Failed to protect Win64->SysV thunk page");
|
||||
}
|
||||
|
||||
HostMemory.FlushInstructionCache(page, (nuint)offset);
|
||||
return (nint)page;
|
||||
}
|
||||
|
||||
private static void EnsureInitialized()
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
BuildStubs();
|
||||
_initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void BuildStubs()
|
||||
{
|
||||
var page = (byte*)HostMemory.Alloc(
|
||||
null,
|
||||
4096,
|
||||
HostMemory.MEM_COMMIT | HostMemory.MEM_RESERVE,
|
||||
HostMemory.PAGE_EXECUTE_READWRITE);
|
||||
if (page == null)
|
||||
{
|
||||
throw new OutOfMemoryException("Failed to allocate POSIX host helper stub page");
|
||||
}
|
||||
|
||||
var offset = 0;
|
||||
_tlsGetValueStub = EmitTlsGetValue(page, ref offset);
|
||||
_queryPerformanceCounterStub = EmitQueryPerformanceCounter(page, ref offset);
|
||||
_switchToThreadStub = EmitSwitchToThread(page, ref offset);
|
||||
_sleepStub = EmitSleep(page, ref offset);
|
||||
|
||||
if (!HostMemory.Protect(page, 4096, HostMemory.PAGE_EXECUTE_READ, out _))
|
||||
{
|
||||
throw new InvalidOperationException("Failed to protect POSIX host helper stub page");
|
||||
}
|
||||
|
||||
HostMemory.FlushInstructionCache(page, (nuint)offset);
|
||||
}
|
||||
|
||||
private static nint EmitTlsGetValue(byte* page, ref int offset)
|
||||
{
|
||||
var start = (nint)(page + offset);
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
// On macOS x86-64 pthread keys index the gs-based thread specific
|
||||
// data array directly, so TlsGetValue(index in ecx) collapses to a
|
||||
// single load that clobbers nothing but RAX.
|
||||
Emit(page, ref offset, 0x89, 0xC8); // mov eax, ecx
|
||||
Emit(page, ref offset, 0x65, 0x48, 0x8B, 0x04, 0xC5, 0, 0, 0, 0); // mov rax, gs:[rax*8]
|
||||
Emit(page, ref offset, 0xC3); // ret
|
||||
return start;
|
||||
}
|
||||
|
||||
// Linux: call pthread_getspecific, preserving the registers that are
|
||||
// volatile in SysV but non-volatile in Win64 (rsi, rdi).
|
||||
var pthreadGetSpecific = ResolveLibcExport("pthread_getspecific");
|
||||
Emit(page, ref offset, 0x56); // push rsi
|
||||
Emit(page, ref offset, 0x57); // push rdi
|
||||
Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8
|
||||
Emit(page, ref offset, 0x89, 0xCF); // mov edi, ecx
|
||||
EmitMovRaxImm64(page, ref offset, pthreadGetSpecific); // mov rax, imm64
|
||||
Emit(page, ref offset, 0xFF, 0xD0); // call rax
|
||||
Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x08); // add rsp, 8
|
||||
Emit(page, ref offset, 0x5F); // pop rdi
|
||||
Emit(page, ref offset, 0x5E); // pop rsi
|
||||
Emit(page, ref offset, 0xC3); // ret
|
||||
return start;
|
||||
}
|
||||
|
||||
private static nint EmitQueryPerformanceCounter(byte* page, ref int offset)
|
||||
{
|
||||
// BOOL QueryPerformanceCounter(LARGE_INTEGER* out in rcx): the emitted
|
||||
// consumers only need a monotonically increasing counter, which rdtsc
|
||||
// provides without leaving Win64-safe registers.
|
||||
var start = (nint)(page + offset);
|
||||
Emit(page, ref offset, 0x0F, 0x31); // rdtsc
|
||||
Emit(page, ref offset, 0x48, 0xC1, 0xE2, 0x20); // shl rdx, 32
|
||||
Emit(page, ref offset, 0x48, 0x09, 0xD0); // or rax, rdx
|
||||
Emit(page, ref offset, 0x48, 0x89, 0x01); // mov [rcx], rax
|
||||
Emit(page, ref offset, 0xB8, 0x01, 0x00, 0x00, 0x00); // mov eax, 1
|
||||
Emit(page, ref offset, 0xC3); // ret
|
||||
return start;
|
||||
}
|
||||
|
||||
private static nint EmitSwitchToThread(byte* page, ref int offset)
|
||||
{
|
||||
var schedYield = ResolveLibcExport("sched_yield");
|
||||
var start = (nint)(page + offset);
|
||||
Emit(page, ref offset, 0x56); // push rsi
|
||||
Emit(page, ref offset, 0x57); // push rdi
|
||||
Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8
|
||||
EmitMovRaxImm64(page, ref offset, schedYield); // mov rax, imm64
|
||||
Emit(page, ref offset, 0xFF, 0xD0); // call rax
|
||||
Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x08); // add rsp, 8
|
||||
Emit(page, ref offset, 0x5F); // pop rdi
|
||||
Emit(page, ref offset, 0x5E); // pop rsi
|
||||
Emit(page, ref offset, 0xB8, 0x01, 0x00, 0x00, 0x00); // mov eax, 1
|
||||
Emit(page, ref offset, 0xC3); // ret
|
||||
return start;
|
||||
}
|
||||
|
||||
private static nint EmitSleep(byte* page, ref int offset)
|
||||
{
|
||||
// void Sleep(DWORD milliseconds in ecx) -> usleep(microseconds in edi).
|
||||
var usleep = ResolveLibcExport("usleep");
|
||||
var start = (nint)(page + offset);
|
||||
Emit(page, ref offset, 0x56); // push rsi
|
||||
Emit(page, ref offset, 0x57); // push rdi
|
||||
Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8
|
||||
Emit(page, ref offset, 0x89, 0xCF); // mov edi, ecx
|
||||
Emit(page, ref offset, 0x81, 0xFF, 0xFF, 0x0F, 0x00, 0x00); // cmp edi, 0xFFF
|
||||
Emit(page, ref offset, 0x76, 0x05); // jbe +5
|
||||
Emit(page, ref offset, 0xBF, 0xFF, 0x0F, 0x00, 0x00); // mov edi, 0xFFF (cap at ~4s)
|
||||
Emit(page, ref offset, 0x69, 0xFF, 0xE8, 0x03, 0x00, 0x00); // imul edi, edi, 1000
|
||||
EmitMovRaxImm64(page, ref offset, usleep); // mov rax, imm64
|
||||
Emit(page, ref offset, 0xFF, 0xD0); // call rax
|
||||
Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x08); // add rsp, 8
|
||||
Emit(page, ref offset, 0x5F); // pop rdi
|
||||
Emit(page, ref offset, 0x5E); // pop rsi
|
||||
Emit(page, ref offset, 0xC3); // ret
|
||||
return start;
|
||||
}
|
||||
|
||||
private static nint ResolveLibcExport(string name)
|
||||
{
|
||||
var libc = NativeLibrary.Load(OperatingSystem.IsMacOS() ? "libSystem.dylib" : "libc.so.6");
|
||||
return NativeLibrary.GetExport(libc, name);
|
||||
}
|
||||
|
||||
private static void Emit(byte* page, ref int offset, params byte[] bytes)
|
||||
{
|
||||
foreach (var value in bytes)
|
||||
{
|
||||
page[offset++] = value;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EmitMovRaxImm64(byte* page, ref int offset, nint value)
|
||||
{
|
||||
Emit(page, ref offset, 0x48, 0xB8);
|
||||
*(long*)(page + offset) = value;
|
||||
offset += sizeof(long);
|
||||
}
|
||||
|
||||
[DllImport("libc", EntryPoint = "pthread_key_create", SetLastError = true)]
|
||||
private static extern int pthread_key_create_mac(nuint* key, nint destructor);
|
||||
|
||||
[DllImport("libc", EntryPoint = "pthread_key_create", SetLastError = true)]
|
||||
private static extern int pthread_key_create_linux(uint* key, nint destructor);
|
||||
|
||||
[DllImport("libc", EntryPoint = "pthread_key_delete")]
|
||||
private static extern int pthread_key_delete_mac(nuint key);
|
||||
|
||||
[DllImport("libc", EntryPoint = "pthread_key_delete")]
|
||||
private static extern int pthread_key_delete_linux(uint key);
|
||||
|
||||
[DllImport("libc", EntryPoint = "pthread_setspecific")]
|
||||
private static extern int pthread_setspecific_mac(nuint key, nint value);
|
||||
|
||||
[DllImport("libc", EntryPoint = "pthread_setspecific")]
|
||||
private static extern int pthread_setspecific_linux(uint key, nint value);
|
||||
|
||||
[DllImport("libc", EntryPoint = "pthread_getspecific")]
|
||||
private static extern nint pthread_getspecific_mac(nuint key);
|
||||
|
||||
[DllImport("libc", EntryPoint = "pthread_getspecific")]
|
||||
private static extern nint pthread_getspecific_linux(uint key);
|
||||
|
||||
[DllImport("libc")]
|
||||
private static extern int pthread_threadid_np(nint thread, ulong* threadId);
|
||||
|
||||
[DllImport("libc")]
|
||||
private static extern int gettid();
|
||||
|
||||
[DllImport("libc")]
|
||||
private static extern nint dispatch_semaphore_create(long value);
|
||||
|
||||
[DllImport("libc")]
|
||||
private static extern nint dispatch_semaphore_signal(nint semaphore);
|
||||
|
||||
[DllImport("libc")]
|
||||
private static extern nint dispatch_semaphore_wait(nint semaphore, ulong timeout);
|
||||
|
||||
[DllImport("libc")]
|
||||
private static extern ulong dispatch_time(ulong when, long deltaNanoseconds);
|
||||
|
||||
[DllImport("libc")]
|
||||
private static extern void dispatch_release(nint handle);
|
||||
|
||||
[DllImport("libc")]
|
||||
private static extern int sem_init(nint semaphore, int shared, uint value);
|
||||
|
||||
[DllImport("libc")]
|
||||
private static extern int sem_post(nint semaphore);
|
||||
|
||||
[DllImport("libc")]
|
||||
private static extern int sem_wait(nint semaphore);
|
||||
|
||||
[DllImport("libc")]
|
||||
private static extern int sem_trywait(nint semaphore);
|
||||
|
||||
[DllImport("libc")]
|
||||
private static extern int sem_destroy(nint semaphore);
|
||||
}
|
||||
@@ -194,11 +194,11 @@ public sealed unsafe class StubManager : IDisposable
|
||||
_stubAddresses.Clear();
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern void* VirtualAlloc(void* lpAddress, nuint dwSize, AllocationType flAllocationType, MemoryProtection flProtect);
|
||||
private static void* VirtualAlloc(void* lpAddress, nuint dwSize, AllocationType flAllocationType, MemoryProtection flProtect) =>
|
||||
HostMemory.Alloc(lpAddress, dwSize, (uint)flAllocationType, (uint)flProtect);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool VirtualFree(void* lpAddress, nuint dwSize, FreeType dwFreeType);
|
||||
private static bool VirtualFree(void* lpAddress, nuint dwSize, FreeType dwFreeType) =>
|
||||
HostMemory.Free(lpAddress, dwSize, (uint)dwFreeType);
|
||||
|
||||
[Flags]
|
||||
private enum AllocationType : uint
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Byte offsets into the Win64 CONTEXT record delivered to vectored exception
|
||||
/// handlers. The handlers read/write guest registers directly at these offsets
|
||||
/// (no managed CONTEXT struct exists); a future POSIX backend gets a sibling
|
||||
/// class for its mcontext layout.
|
||||
/// </summary>
|
||||
internal static class Win64ContextOffsets
|
||||
{
|
||||
public const int Size = 0x4D0;
|
||||
public const int Mxcsr = 52;
|
||||
public const int Rax = 120;
|
||||
public const int Rcx = 128;
|
||||
public const int Rdx = 136;
|
||||
public const int Rbx = 144;
|
||||
public const int Rsp = 152;
|
||||
public const int Rbp = 160;
|
||||
public const int Rsi = 168;
|
||||
public const int Rdi = 176;
|
||||
public const int R8 = 184;
|
||||
public const int R9 = 192;
|
||||
public const int R10 = 200;
|
||||
public const int R11 = 208;
|
||||
public const int R12 = 216;
|
||||
public const int R13 = 224;
|
||||
public const int R14 = 232;
|
||||
public const int R15 = 240;
|
||||
public const int Rip = 248;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Windows NTSTATUS exception codes and EXCEPTION_RECORD access-type values the
|
||||
/// fault handlers filter on. Values are the same numbers the handlers previously
|
||||
/// compared as bare literals; only the spelling changed.
|
||||
/// </summary>
|
||||
internal static class WindowsFaultCodes
|
||||
{
|
||||
public const uint AccessViolation = 0xC0000005u; // 3221225477
|
||||
public const uint Breakpoint = 0x80000003u; // 2147483651
|
||||
public const uint IllegalInstruction = 0xC000001Du; // 3221225501
|
||||
public const uint FastFail = 0xC0000409u; // 3221226505
|
||||
public const uint StackOverflow = 0xC00000FDu;
|
||||
public const uint ClrManagedException = 0xE0434352u;
|
||||
|
||||
// EXCEPTION_RECORD.ExceptionInformation[0] for access violations.
|
||||
public const ulong AccessRead = 0;
|
||||
public const ulong AccessWrite = 1;
|
||||
public const ulong AccessExecute = 8;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using SharpEmu.HLE.Host;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native.Windows;
|
||||
|
||||
/// <summary>
|
||||
/// Vectored-exception-handler installation and the handler pre-filter thunk.
|
||||
/// The thunk is inherently Windows-shaped (TEB stack-limit reads via gs:,
|
||||
/// NTSTATUS pre-filtering, Win64 calling convention) and moved here whole from
|
||||
/// DirectExecutionBackend; a POSIX backend supplies a sibling built around
|
||||
/// sigaction/sigaltstack instead.
|
||||
/// </summary>
|
||||
internal sealed unsafe partial class WindowsFaultHandling : IHostFaultHandling
|
||||
{
|
||||
private readonly IHostMemory _memory;
|
||||
|
||||
public WindowsFaultHandling(IHostMemory memory)
|
||||
{
|
||||
_memory = memory;
|
||||
}
|
||||
|
||||
public nint CreateHandlerThunk(nint managedCallback, uint hostRspSwitchTlsSlot, nint tlsGetValueAddress)
|
||||
{
|
||||
const uint stubSize = 256u;
|
||||
void* ptr = (void*)_memory.Allocate(0, stubSize, HostPageProtection.ReadWriteExecute);
|
||||
if (ptr == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
byte* code = (byte*)ptr;
|
||||
int offset = 0;
|
||||
// Native pre-filter: these exception codes are raised while the thread can be in
|
||||
// cooperative GC mode (a C# throw is RaiseException(0xE0434352) on the throwing
|
||||
// thread; FailFast/stack-overflow arrive mid-runtime-failure). Entering the managed
|
||||
// handler then trips the CLR's reverse-P/Invoke check and kills the process with
|
||||
// "Invalid Program: attempted to call a UnmanagedCallersOnly method from managed
|
||||
// code" — this is why no managed throw (even one with a catch handler) ever
|
||||
// survived inside the emulator. Continue the handler search without touching
|
||||
// managed code; the CLR's own VEH handles its exceptions. MSVC C++ exceptions
|
||||
// (Vulkan drivers, host CRT) are excluded too: the managed handler only ever
|
||||
// returned CONTINUE_SEARCH for them.
|
||||
ReadOnlySpan<uint> nonManagedExceptionCodes =
|
||||
[WindowsFaultCodes.ClrManagedException, 0xE06D7363u, WindowsFaultCodes.FastFail, WindowsFaultCodes.StackOverflow];
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x01); // mov rax, [rcx] (ExceptionRecord*)
|
||||
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x00); // mov eax, [rax] (ExceptionCode)
|
||||
var passJumpOffsets = stackalloc int[nonManagedExceptionCodes.Length];
|
||||
for (int i = 0; i < nonManagedExceptionCodes.Length; i++)
|
||||
{
|
||||
EmitByte(code, ref offset, 0x3D); // cmp eax, imm32
|
||||
EmitUInt32(code, ref offset, nonManagedExceptionCodes[i]);
|
||||
EmitByte(code, ref offset, 0x74); // je pass
|
||||
passJumpOffsets[i] = offset;
|
||||
EmitByte(code, ref offset, 0x00);
|
||||
}
|
||||
EmitByte(code, ref offset, 0xEB); EmitByte(code, ref offset, 0x03); // jmp over pass block
|
||||
int passOffset = offset;
|
||||
EmitByte(code, ref offset, 0x31); EmitByte(code, ref offset, 0xC0); // pass: xor eax, eax (EXCEPTION_CONTINUE_SEARCH)
|
||||
EmitByte(code, ref offset, 0xC3); // ret
|
||||
for (int i = 0; i < nonManagedExceptionCodes.Length; i++)
|
||||
{
|
||||
code[passJumpOffsets[i]] = checked((byte)(passOffset - (passJumpOffsets[i] + 1)));
|
||||
}
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x54); // push r12
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x55); // push r13
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xE4); // mov r12, rsp
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xCD); // mov r13, rcx
|
||||
EmitByte(code, ref offset, 0x65); EmitByte(code, ref offset, 0x48); // mov rax, gs:[8]
|
||||
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x04); EmitByte(code, ref offset, 0x25);
|
||||
EmitUInt32(code, ref offset, 8u);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x39); EmitByte(code, ref offset, 0xC4); // cmp r12, rax
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x83); // jae guestStack
|
||||
int aboveStackJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x65); EmitByte(code, ref offset, 0x48); // mov rax, gs:[0x10]
|
||||
EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x04); EmitByte(code, ref offset, 0x25);
|
||||
EmitUInt32(code, ref offset, 0x10u);
|
||||
EmitByte(code, ref offset, 0x49); EmitByte(code, ref offset, 0x39); EmitByte(code, ref offset, 0xC4); // cmp r12, rax
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x82); // jb guestStack
|
||||
int belowStackJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xE9); // mov rcx, r13
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = managedCallback;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0xE9);
|
||||
int hostRestoreJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
|
||||
int guestStackOffset = offset;
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0xB9);
|
||||
EmitUInt32(code, ref offset, hostRspSwitchTlsSlot);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = tlsGetValueAddress;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x85); EmitByte(code, ref offset, 0xC0); // test rax, rax
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x84);
|
||||
int missingTlsJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x8B); EmitByte(code, ref offset, 0x18); // mov r11, [rax]
|
||||
EmitByte(code, ref offset, 0x4D); EmitByte(code, ref offset, 0x85); EmitByte(code, ref offset, 0xDB); // test r11, r11
|
||||
EmitByte(code, ref offset, 0x0F); EmitByte(code, ref offset, 0x84);
|
||||
int missingHostStackJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xDC); // mov rsp, r11
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xEC); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xE9); // mov rcx, r13
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0xB8);
|
||||
*(nint*)(code + offset) = managedCallback;
|
||||
offset += sizeof(nint);
|
||||
EmitByte(code, ref offset, 0xFF); EmitByte(code, ref offset, 0xD0);
|
||||
EmitByte(code, ref offset, 0x48); EmitByte(code, ref offset, 0x83); EmitByte(code, ref offset, 0xC4); EmitByte(code, ref offset, 0x28);
|
||||
EmitByte(code, ref offset, 0xE9);
|
||||
int guestRestoreJump = offset;
|
||||
EmitUInt32(code, ref offset, 0u);
|
||||
|
||||
int passThroughOffset = offset;
|
||||
EmitByte(code, ref offset, 0x31); EmitByte(code, ref offset, 0xC0); // xor eax, eax
|
||||
int restoreOffset = offset;
|
||||
EmitByte(code, ref offset, 0x4C); EmitByte(code, ref offset, 0x89); EmitByte(code, ref offset, 0xE4); // mov rsp, r12
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5D);
|
||||
EmitByte(code, ref offset, 0x41); EmitByte(code, ref offset, 0x5C);
|
||||
EmitByte(code, ref offset, 0xC3);
|
||||
|
||||
*(int*)(code + aboveStackJump) = guestStackOffset - (aboveStackJump + sizeof(int));
|
||||
*(int*)(code + belowStackJump) = guestStackOffset - (belowStackJump + sizeof(int));
|
||||
*(int*)(code + hostRestoreJump) = restoreOffset - (hostRestoreJump + sizeof(int));
|
||||
*(int*)(code + missingTlsJump) = passThroughOffset - (missingTlsJump + sizeof(int));
|
||||
*(int*)(code + missingHostStackJump) = passThroughOffset - (missingHostStackJump + sizeof(int));
|
||||
*(int*)(code + guestRestoreJump) = restoreOffset - (guestRestoreJump + sizeof(int));
|
||||
|
||||
if (!_memory.Protect((ulong)ptr, stubSize, HostPageProtection.ReadExecute, out _))
|
||||
{
|
||||
Console.Error.WriteLine($"[LOADER][ERROR] VirtualProtect failed for exception handler trampoline at 0x{(nint)ptr:X16}");
|
||||
_ = _memory.Free((ulong)ptr);
|
||||
return 0;
|
||||
}
|
||||
_memory.FlushInstructionCache((ulong)ptr, (ulong)offset);
|
||||
return (nint)ptr;
|
||||
}
|
||||
|
||||
public void FreeThunk(nint thunk)
|
||||
{
|
||||
_ = _memory.Free((ulong)thunk);
|
||||
}
|
||||
|
||||
public nint AddFirstChanceHandler(nint thunk)
|
||||
{
|
||||
return (nint)AddVectoredExceptionHandler(1u, thunk);
|
||||
}
|
||||
|
||||
public void RemoveHandler(nint handle)
|
||||
{
|
||||
_ = RemoveVectoredExceptionHandler((void*)handle);
|
||||
}
|
||||
|
||||
public void SetUnhandledFilter(nint thunk)
|
||||
{
|
||||
_ = SetUnhandledExceptionFilter(thunk);
|
||||
}
|
||||
|
||||
private static void EmitByte(byte* code, ref int offset, byte value)
|
||||
{
|
||||
code[offset++] = value;
|
||||
}
|
||||
|
||||
private static void EmitUInt32(byte* code, ref int offset, uint value)
|
||||
{
|
||||
*(uint*)(code + offset) = value;
|
||||
offset += sizeof(uint);
|
||||
}
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
private static partial void* AddVectoredExceptionHandler(uint first, IntPtr handler);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
private static partial uint RemoveVectoredExceptionHandler(void* handle);
|
||||
|
||||
[LibraryImport("kernel32.dll")]
|
||||
private static partial IntPtr SetUnhandledExceptionFilter(IntPtr lpTopLevelExceptionFilter);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Core.Cpu;
|
||||
|
||||
public sealed class TrackedCpuMemory : ICpuMemory, ITrackedCpuMemory, IGuestMemoryAllocator
|
||||
public sealed class TrackedCpuMemory : ICpuMemory, ITrackedCpuMemory, IGuestMemoryAllocator, ICpuMemoryWrapper
|
||||
{
|
||||
private readonly ICpuMemory _inner;
|
||||
|
||||
@@ -50,4 +50,9 @@ public sealed class TrackedCpuMemory : ICpuMemory, ITrackedCpuMemory, IGuestMemo
|
||||
address = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryFreeGuestMemory(ulong address)
|
||||
{
|
||||
return _inner is IGuestMemoryAllocator allocator && allocator.TryFreeGuestMemory(address);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,13 @@ public static class Ps5ParamJsonReader
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(data);
|
||||
ReadOnlyMemory<byte> json = data;
|
||||
if (json.Span.StartsWith("\uFEFF"u8))
|
||||
{
|
||||
json = json[3..];
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return TryReadPs5Param(doc.RootElement);
|
||||
}
|
||||
catch (JsonException)
|
||||
@@ -56,12 +62,15 @@ public static class Ps5ParamJsonReader
|
||||
|
||||
private static (string? Title, string? TitleId, string? Version) TryReadPs5Param(JsonElement root)
|
||||
{
|
||||
string? titleId = root.TryGetProperty("titleId", out var eTid) ? eTid.GetString() : null;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
return (null, null, null);
|
||||
|
||||
var titleId = GetString(root, "titleId");
|
||||
|
||||
string? ver =
|
||||
(root.TryGetProperty("contentVersion", out var cv) ? cv.GetString() : null)
|
||||
?? (root.TryGetProperty("masterVersion", out var mv) ? mv.GetString() : null)
|
||||
?? (root.TryGetProperty("targetContentVersion", out var tv) ? tv.GetString() : null);
|
||||
GetString(root, "contentVersion")
|
||||
?? GetString(root, "masterVersion")
|
||||
?? GetString(root, "targetContentVersion");
|
||||
|
||||
string? title = ExtractTitleName(root);
|
||||
|
||||
@@ -70,34 +79,49 @@ public static class Ps5ParamJsonReader
|
||||
|
||||
private static string? ExtractTitleName(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("localizedParameters", out var lp))
|
||||
if ((!root.TryGetProperty("localizedParameters", out var lp) || lp.ValueKind != JsonValueKind.Object) &&
|
||||
root.TryGetProperty("disc", out var disc) && disc.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (root.TryGetProperty("disc", out var disc) && disc.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
disc.TryGetProperty("localizedParameters", out lp);
|
||||
}
|
||||
disc.TryGetProperty("localizedParameters", out lp);
|
||||
}
|
||||
|
||||
if (lp.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
|
||||
string? defLang = lp.TryGetProperty("defaultLanguage", out var dl) ? dl.GetString() : null;
|
||||
var defLang = GetString(lp, "defaultLanguage");
|
||||
|
||||
if (!string.IsNullOrEmpty(defLang))
|
||||
{
|
||||
if (lp.TryGetProperty(defLang, out var langObj) && langObj.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (langObj.TryGetProperty("titleName", out var tn))
|
||||
return tn.GetString();
|
||||
var title = GetString(langObj, "titleName");
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
if (lp.TryGetProperty("en-US", out var en) && en.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (en.TryGetProperty("titleName", out var tn2))
|
||||
return tn2.GetString();
|
||||
var title = GetString(en, "titleName");
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
return title;
|
||||
}
|
||||
|
||||
foreach (var property in lp.EnumerateObject())
|
||||
{
|
||||
if (property.Value.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var title = GetString(property.Value, "titleName");
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement parent, string propertyName) =>
|
||||
parent.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String
|
||||
? value.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ public enum ProgramHeaderType : uint
|
||||
|
||||
Tls = 7,
|
||||
|
||||
GnuEhFrame = 0x6474E550,
|
||||
|
||||
SceRela = 0x60000000,
|
||||
|
||||
SceProcParam = 0x61000001,
|
||||
|
||||
@@ -24,7 +24,10 @@ public sealed class SelfImage
|
||||
ulong procParamAddress = 0,
|
||||
string? title = null,
|
||||
string? titleId = null,
|
||||
string? version = null)
|
||||
string? version = null,
|
||||
uint tlsModuleId = 0,
|
||||
ulong tlsMemorySize = 0,
|
||||
ulong tlsStaticOffset = 0)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(programHeaders);
|
||||
ArgumentNullException.ThrowIfNull(mappedRegions);
|
||||
@@ -44,6 +47,9 @@ public sealed class SelfImage
|
||||
Title = title;
|
||||
TitleId = titleId;
|
||||
Version = version;
|
||||
TlsModuleId = tlsModuleId;
|
||||
TlsMemorySize = tlsMemorySize;
|
||||
TlsStaticOffset = tlsStaticOffset;
|
||||
}
|
||||
|
||||
public bool IsSelf { get; }
|
||||
@@ -75,4 +81,11 @@ public sealed class SelfImage
|
||||
public string? TitleId { get; }
|
||||
|
||||
public string? Version { get; }
|
||||
|
||||
public uint TlsModuleId { get; }
|
||||
|
||||
public ulong TlsMemorySize { get; }
|
||||
|
||||
/// <summary>Variant II distance from the thread pointer to this module's static TLS base.</summary>
|
||||
public ulong TlsStaticOffset { get; }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
@@ -10,13 +11,11 @@ using SharpEmu.Core;
|
||||
using SharpEmu.Core.Cpu;
|
||||
using SharpEmu.Core.Memory;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Logging;
|
||||
|
||||
namespace SharpEmu.Core.Loader;
|
||||
|
||||
public sealed class SelfLoader : ISelfLoader
|
||||
{
|
||||
private static readonly SharpEmuLogger Log = SharpEmuLog.For("Loader");
|
||||
private const uint SelfMagic = 0x4F153D1D;
|
||||
private const ulong SelfSegmentFlag = 0x800;
|
||||
private const int PageSize = 0x1000;
|
||||
@@ -56,11 +55,22 @@ public sealed class SelfLoader : ISelfLoader
|
||||
private const long DtSceSymTab = 0x61000039;
|
||||
private const long DtSceSymTabSize = 0x6100003F;
|
||||
|
||||
private const uint RelocationTypeNone = 0;
|
||||
private const uint RelocationTypeAbsolute64 = 1;
|
||||
private const uint RelocationTypePc32 = 2;
|
||||
private const uint RelocationTypePlt32 = 4;
|
||||
private const uint RelocationTypeGlobalData = 6;
|
||||
private const uint RelocationTypeJumpSlot = 7;
|
||||
private const uint RelocationTypeRelative = 8;
|
||||
private const uint RelocationTypeTlsModuleId = 16;
|
||||
private const uint RelocationTypeUnsigned32 = 10;
|
||||
private const uint RelocationTypeSigned32 = 11;
|
||||
private const uint RelocationTypeTlsModuleId = 16; // R_X86_64_DTPMOD64
|
||||
private const uint RelocationTypeTlsDtpOff64 = 17; // R_X86_64_DTPOFF64
|
||||
private const uint RelocationTypeTlsTpOff64 = 18; // R_X86_64_TPOFF64
|
||||
private const uint RelocationTypePc64 = 24;
|
||||
private const uint RelocationTypeSize32 = 32;
|
||||
private const uint RelocationTypeSize64 = 33;
|
||||
private const uint RelocationTypeRelative64 = 38;
|
||||
private const ulong Ps5MainImageBase = 0x0000000800000000UL;
|
||||
private const ulong Ps4MainImageBase = 0x0000000000400000UL;
|
||||
private const ulong Ps5ModuleSearchStart = 0x0000000804000000UL;
|
||||
@@ -86,6 +96,11 @@ public sealed class SelfLoader : ISelfLoader
|
||||
private static readonly int SelfSegmentSize = Unsafe.SizeOf<SelfSegment>();
|
||||
private static readonly int ProgramHeaderSize = Unsafe.SizeOf<ProgramHeader>();
|
||||
|
||||
static SelfLoader()
|
||||
{
|
||||
RunRelocationSelfChecks();
|
||||
}
|
||||
|
||||
public SelfImage Load(ReadOnlySpan<byte> imageData, IVirtualMemory virtualMemory)
|
||||
{
|
||||
return Load(imageData, virtualMemory, fs: null, mountRoot: null);
|
||||
@@ -154,16 +169,22 @@ public sealed class SelfLoader : ISelfLoader
|
||||
{
|
||||
virtualMemory.Clear();
|
||||
_nextTlsModuleId = 1;
|
||||
GuestTlsTemplate.Reset();
|
||||
}
|
||||
|
||||
var tlsModuleId = _nextTlsModuleId == 0 ? 1u : _nextTlsModuleId;
|
||||
Log.Debug($"TLS load_start clear={clearVirtualMemory} next={_nextTlsModuleId} assigned={tlsModuleId}");
|
||||
|
||||
var loadContext = ParseLayout(imageData);
|
||||
var elfHeader = ReadUnmanaged<ElfHeader>(imageData, loadContext.ElfOffset);
|
||||
ValidateElfHeader(elfHeader);
|
||||
|
||||
var programHeaders = ParseProgramHeaders(imageData, loadContext, elfHeader);
|
||||
var hasTlsSegment = TryGetProgramHeader(programHeaders, ProgramHeaderType.Tls, out var processTlsHeader, out _) &&
|
||||
processTlsHeader.MemorySize != 0;
|
||||
var tlsModuleId = hasTlsSegment
|
||||
? (_nextTlsModuleId == 0 ? 1u : _nextTlsModuleId)
|
||||
: 0u;
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TLS] load_start clear={clearVirtualMemory} next={_nextTlsModuleId} " +
|
||||
$"assigned={tlsModuleId} has_pt_tls={hasTlsSegment}");
|
||||
|
||||
var totalImageSize = CalculateTotalImageSize(programHeaders);
|
||||
Console.WriteLine($"Total image size needed: 0x{totalImageSize:X} ({totalImageSize} bytes)");
|
||||
@@ -176,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;
|
||||
@@ -196,6 +218,13 @@ public sealed class SelfLoader : ISelfLoader
|
||||
}
|
||||
|
||||
MapLoadSegments(imageData, loadContext, programHeaders, virtualMemory, imageBase);
|
||||
// Register every module before relocations so DTPMOD/DTPOFF/TPOFF use
|
||||
// the module's real PT_TLS identity and Variant II static offset.
|
||||
var tlsInfo = RegisterModuleTlsTemplate(
|
||||
programHeaders,
|
||||
virtualMemory,
|
||||
imageBase,
|
||||
tlsModuleId);
|
||||
var importStubs = ResolveAndPatchImportStubs(
|
||||
imageData,
|
||||
loadContext,
|
||||
@@ -253,11 +282,11 @@ public sealed class SelfLoader : ISelfLoader
|
||||
Console.WriteLine($"[LOADER] PH[{i}]: type={ph.HeaderType}, vaddr=0x{ph.VirtualAddress:X16} -> 0x{ph.VirtualAddress + imageBase:X16}, memsz=0x{ph.MemorySize:X}");
|
||||
}
|
||||
|
||||
if (_nextTlsModuleId == tlsModuleId && _nextTlsModuleId < uint.MaxValue)
|
||||
if (tlsModuleId != 0 && _nextTlsModuleId == tlsModuleId && _nextTlsModuleId < uint.MaxValue)
|
||||
{
|
||||
_nextTlsModuleId++;
|
||||
}
|
||||
Log.Debug($"TLS load_done assigned={tlsModuleId} next={_nextTlsModuleId}");
|
||||
Console.Error.WriteLine($"[LOADER][TLS] load_done assigned={tlsModuleId} next={_nextTlsModuleId}");
|
||||
|
||||
return new SelfImage(
|
||||
loadContext.IsSelf,
|
||||
@@ -274,7 +303,10 @@ public sealed class SelfLoader : ISelfLoader
|
||||
procParamAddress,
|
||||
applicationInfo.Title,
|
||||
applicationInfo.TitleId,
|
||||
applicationInfo.Version);
|
||||
applicationInfo.Version,
|
||||
tlsModuleId,
|
||||
tlsInfo.MemorySize,
|
||||
tlsInfo.StaticOffset);
|
||||
}
|
||||
|
||||
private static (string? Title, string? TitleId, string? Version) TryLoadParamJson(
|
||||
@@ -345,6 +377,21 @@ public sealed class SelfLoader : ISelfLoader
|
||||
return new LoadContext(IsSelf: true, elfOffset, selfHeader.FileSize, segments);
|
||||
}
|
||||
|
||||
// Not a recognized (fake-signed) SELF. Only a bare, decrypted ELF is
|
||||
// acceptable here; anything else — most commonly a still-encrypted
|
||||
// retail eboot — must be reported clearly rather than failing later
|
||||
// with an opaque "not a valid ELF header" message.
|
||||
const uint ElfMagicBigEndian = 0x7F454C46; // "\x7fELF"
|
||||
var leadingWord = BinaryPrimitives.ReadUInt32BigEndian(imageData[..sizeof(uint)]);
|
||||
if (leadingWord != ElfMagicBigEndian)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Image is neither a decrypted ELF nor a recognized fake-signed SELF " +
|
||||
$"(leading bytes 0x{leadingWord:X8}). This is almost certainly a still-encrypted " +
|
||||
$"retail eboot — SharpEmu has no decryption keys and requires a decrypted / " +
|
||||
$"fake-signed (fSELF) image.");
|
||||
}
|
||||
|
||||
return new LoadContext(IsSelf: false, ElfOffset: 0, SelfFileSize: 0, Array.Empty<SelfSegment>());
|
||||
}
|
||||
|
||||
@@ -405,15 +452,15 @@ public sealed class SelfLoader : ISelfLoader
|
||||
|
||||
var virtualAddress = header.VirtualAddress + imageBase;
|
||||
|
||||
Log.Debug($"Segment {index}: VAddr=0x{virtualAddress:X16}, FileSize=0x{header.FileSize:X}, MemSize=0x{header.MemorySize:X}, Align=0x{header.Alignment:X}");
|
||||
Console.Error.WriteLine($"[LOADER] Segment {index}: VAddr=0x{virtualAddress:X16}, FileSize=0x{header.FileSize:X}, MemSize=0x{header.MemorySize:X}, Align=0x{header.Alignment:X}");
|
||||
if (header.Alignment > 1)
|
||||
{
|
||||
var vaddrMod = virtualAddress % header.Alignment;
|
||||
var offsetMod = header.Offset % header.Alignment;
|
||||
if (vaddrMod != offsetMod)
|
||||
{
|
||||
Log.Warning(
|
||||
$"Segment {index} ELF alignment mismatch! " +
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER] WARNING: Segment {index} ELF alignment mismatch! " +
|
||||
$"VAddr=0x{virtualAddress:X}, Offset=0x{header.Offset:X}, Align=0x{header.Alignment:X}, " +
|
||||
$"VAddr%Align=0x{vaddrMod:X}, Offset%Align=0x{offsetMod:X}");
|
||||
}
|
||||
@@ -456,6 +503,43 @@ public sealed class SelfLoader : ISelfLoader
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static ModuleTlsInfo RegisterModuleTlsTemplate(
|
||||
IReadOnlyList<ProgramHeader> programHeaders,
|
||||
IVirtualMemory virtualMemory,
|
||||
ulong imageBase,
|
||||
uint tlsModuleId)
|
||||
{
|
||||
if (!TryGetProgramHeader(programHeaders, ProgramHeaderType.Tls, out var tlsHeader, out _) ||
|
||||
tlsHeader.MemorySize == 0)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
// tdata (initialized) bytes come from the mapped segment; tbss is the
|
||||
// implicitly-zero remainder up to MemorySize.
|
||||
var fileSize = (int)Math.Min(tlsHeader.FileSize, tlsHeader.MemorySize);
|
||||
var initImage = fileSize > 0 ? new byte[fileSize] : [];
|
||||
if (fileSize > 0 &&
|
||||
!virtualMemory.TryRead(imageBase + tlsHeader.VirtualAddress, initImage))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TLS] Failed to read TLS init image at 0x{imageBase + tlsHeader.VirtualAddress:X}; seeding zeros.");
|
||||
initImage = [];
|
||||
}
|
||||
|
||||
var staticOffset = GuestTlsTemplate.RegisterModule(
|
||||
tlsModuleId,
|
||||
initImage,
|
||||
tlsHeader.MemorySize,
|
||||
tlsHeader.Alignment,
|
||||
tlsHeader.VirtualAddress);
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TLS] Module {tlsModuleId} TLS template: memsz=0x{tlsHeader.MemorySize:X} " +
|
||||
$"filesz=0x{tlsHeader.FileSize:X} align=0x{tlsHeader.Alignment:X} " +
|
||||
$"static_offset=0x{staticOffset:X} total_static=0x{GuestTlsTemplate.StaticTlsSize:X}");
|
||||
return new ModuleTlsInfo(tlsHeader.MemorySize, staticOffset);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<ulong, string> ResolveAndPatchImportStubs(
|
||||
ReadOnlySpan<byte> imageData,
|
||||
LoadContext loadContext,
|
||||
@@ -535,12 +619,17 @@ public sealed class SelfLoader : ISelfLoader
|
||||
uint maxSymbolIndex = 0;
|
||||
foreach (var relocation in relocations)
|
||||
{
|
||||
if (relocation.Type == RelocationTypeNone)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!IsSupportedRelocationType(relocation.Type))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (relocation.Type is RelocationTypeRelative or RelocationTypeTlsModuleId)
|
||||
if (relocation.Type is RelocationTypeNone or RelocationTypeRelative or RelocationTypeRelative64 or RelocationTypeTlsModuleId)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -624,13 +713,16 @@ public sealed class SelfLoader : ISelfLoader
|
||||
|
||||
importedRelocations = BuildImportedRelocations(descriptors);
|
||||
|
||||
var stubsByAddress = CreateImportStubMapping(virtualMemory, orderedImportNids);
|
||||
var stubImportNids = orderedImportNids
|
||||
.Where(nid => ShouldCreateImportStub(nid, descriptors, moduleManager))
|
||||
.ToArray();
|
||||
var stubsByAddress = CreateImportStubMapping(virtualMemory, stubImportNids);
|
||||
Console.WriteLine($"[LOADER] Created {stubsByAddress.Count} import stubs");
|
||||
|
||||
int printCount = Math.Min(10, orderedImportNids.Count);
|
||||
int printCount = Math.Min(10, stubImportNids.Length);
|
||||
for (int i = 0; i < printCount; i++)
|
||||
{
|
||||
var nid = orderedImportNids[i];
|
||||
var nid = stubImportNids[i];
|
||||
var addr = stubsByAddress.First(x => x.Value == nid).Key;
|
||||
}
|
||||
|
||||
@@ -654,45 +746,65 @@ public sealed class SelfLoader : ISelfLoader
|
||||
|
||||
foreach (var descriptor in descriptors)
|
||||
{
|
||||
ulong targetValue;
|
||||
ulong symbolValue;
|
||||
if (descriptor.ImportNid is null)
|
||||
{
|
||||
targetValue = AddSigned(descriptor.SymbolValue, descriptor.Addend);
|
||||
symbolValue = descriptor.SymbolValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!addressesByNid.TryGetValue(descriptor.ImportNid, out var stubAddress))
|
||||
if (addressesByNid.TryGetValue(descriptor.ImportNid, out var stubAddress))
|
||||
{
|
||||
throw new InvalidOperationException($"Import stub not found for NID '{descriptor.ImportNid}'.");
|
||||
symbolValue = stubAddress;
|
||||
}
|
||||
|
||||
targetValue = AddSigned(stubAddress, descriptor.Addend);
|
||||
}
|
||||
|
||||
if (targetValue < 0x1000)
|
||||
{
|
||||
if (descriptor.ValueKind == RelocationValueKind.TlsModuleId)
|
||||
else if (descriptor.IsWeak)
|
||||
{
|
||||
Log.Debug(
|
||||
$"Patching DTPMOD64 at 0x{descriptor.TargetAddress:X} with module id 0x{targetValue:X}");
|
||||
// ELF unresolved weak definitions use S=0. They must not
|
||||
// receive a trap import stub, which would turn a permitted
|
||||
// null test into a call to an unresolved-import handler.
|
||||
symbolValue = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning($"!!! CRITICAL !!! Patching address 0x{descriptor.TargetAddress:X} with INVALID value 0x{targetValue:X} for NID {descriptor.ImportNid ?? "(null)"}");
|
||||
Log.Warning($" SymbolValue=0x{descriptor.SymbolValue:X}, Addend=0x{descriptor.Addend:X}, StubAddress=0x{(addressesByNid.TryGetValue(descriptor.ImportNid ?? "", out var sa) ? sa : 0):X}");
|
||||
throw new InvalidOperationException($"Import stub not found for NID '{descriptor.ImportNid}'.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!TryWriteUInt64(virtualMemory, descriptor.TargetAddress, targetValue))
|
||||
var targetValue = ComputeRelocationValue(descriptor, symbolValue);
|
||||
|
||||
if (targetValue < 0x1000 && descriptor.ValueKind is
|
||||
RelocationValueKind.TlsOffset or
|
||||
RelocationValueKind.PcRelative or
|
||||
RelocationValueKind.SymbolSize)
|
||||
{
|
||||
throw new InvalidDataException($"Failed to patch relocation at 0x{descriptor.TargetAddress:X16}.");
|
||||
// A TLS offset (TPOFF64/DTPOFF64) is a signed displacement, not a
|
||||
// mapped address, so a small or negative value here is expected.
|
||||
}
|
||||
else if (targetValue < 0x1000 && !descriptor.IsWeak)
|
||||
{
|
||||
if (descriptor.ValueKind == RelocationValueKind.TlsModuleId)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TLS] Patching DTPMOD64 at 0x{descriptor.TargetAddress:X} with module id 0x{targetValue:X}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.Error.WriteLine($"[LOADER] !!! CRITICAL !!! Patching address 0x{descriptor.TargetAddress:X} with INVALID value 0x{targetValue:X} for NID {descriptor.ImportNid ?? "(null)"}");
|
||||
Console.Error.WriteLine($"[LOADER] SymbolValue=0x{descriptor.SymbolValue:X}, Addend=0x{descriptor.Addend:X}, StubAddress=0x{(addressesByNid.TryGetValue(descriptor.ImportNid ?? "", out var sa) ? sa : 0):X}");
|
||||
}
|
||||
}
|
||||
|
||||
if (!TryWriteRelocationValue(virtualMemory, descriptor, targetValue, out var writeError))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Failed to patch relocation at 0x{descriptor.TargetAddress:X16}: {writeError}");
|
||||
}
|
||||
|
||||
if (descriptor.TargetAddress >= 0x00000008030FC300UL &&
|
||||
descriptor.TargetAddress <= 0x00000008030FC3F0UL)
|
||||
{
|
||||
Log.Debug(
|
||||
$"[RELOC] target=0x{descriptor.TargetAddress:X16} value=0x{targetValue:X16} addend=0x{descriptor.Addend:X} nid={(descriptor.ImportNid ?? "<sym>")}");
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][RELOC] target=0x{descriptor.TargetAddress:X16} value=0x{targetValue:X16} addend=0x{descriptor.Addend:X} nid={(descriptor.ImportNid ?? "<sym>")}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -785,29 +897,36 @@ public sealed class SelfLoader : ISelfLoader
|
||||
{
|
||||
if (IsFocusRelocationOffset(relocation.Offset, imageBase))
|
||||
{
|
||||
Log.Debug(
|
||||
$"[FOCUS][SCAN] off=0x{relocation.Offset:X16} type={relocation.Type} sym={relocation.SymbolIndex} addend=0x{relocation.Addend:X}");
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][FOCUS][SCAN] off=0x{relocation.Offset:X16} type={relocation.Type} sym={relocation.SymbolIndex} addend=0x{relocation.Addend:X}");
|
||||
}
|
||||
|
||||
if (!IsSupportedRelocationType(relocation.Type))
|
||||
{
|
||||
if (IsFocusRelocationOffset(relocation.Offset, imageBase))
|
||||
// Surface unsupported relocation types loudly instead of
|
||||
// skipping silently: TLS-relative (17/18), COPY (5), and
|
||||
// IRELATIVE/ifunc (37) leave their targets unrelocated, which
|
||||
// manifests later as reads of zero or calls into address 0.
|
||||
ReportUnsupportedRelocation(relocation.Type, relocation.Offset, imageBase);
|
||||
if (relocation.Type is 5 or 37)
|
||||
{
|
||||
Log.Debug($"[FOCUS][SKIP] unsupported type={relocation.Type}");
|
||||
throw new NotSupportedException(
|
||||
$"Relocation type {relocation.Type} requires deferred runtime-linker processing.");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryResolveMappedAddress(virtualMemory, relocation.Offset, imageBase, sizeof(ulong), out var targetAddress))
|
||||
var relocationWriteSize = GetRelocationWriteSize(relocation.Type);
|
||||
if (!TryResolveMappedAddress(virtualMemory, relocation.Offset, imageBase, relocationWriteSize, out var targetAddress))
|
||||
{
|
||||
if (IsFocusRelocationOffset(relocation.Offset, imageBase))
|
||||
{
|
||||
Log.Debug("[FOCUS][SKIP] target address not mapped");
|
||||
Console.Error.WriteLine("[LOADER][FOCUS][SKIP] target address not mapped");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (relocation.Type == RelocationTypeRelative)
|
||||
if (relocation.Type is RelocationTypeRelative or RelocationTypeRelative64)
|
||||
{
|
||||
descriptors.Add(new RelocationDescriptor(
|
||||
targetAddress,
|
||||
@@ -821,7 +940,12 @@ public sealed class SelfLoader : ISelfLoader
|
||||
|
||||
if (relocation.Type == RelocationTypeTlsModuleId)
|
||||
{
|
||||
var dtpmodValue = tlsModuleId == 0 ? 1u : tlsModuleId;
|
||||
if (tlsModuleId == 0)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"R_X86_64_DTPMOD64 at 0x{targetAddress:X16} references a module without PT_TLS.");
|
||||
}
|
||||
var dtpmodValue = tlsModuleId;
|
||||
descriptors.Add(new RelocationDescriptor(
|
||||
targetAddress,
|
||||
0,
|
||||
@@ -842,50 +966,100 @@ public sealed class SelfLoader : ISelfLoader
|
||||
{
|
||||
if (targetAddress >= FocusRelocGuestStart && targetAddress <= FocusRelocGuestEnd)
|
||||
{
|
||||
Log.Debug($"[FOCUS][SKIP] symbol read failed index={symbolIndex}");
|
||||
Console.Error.WriteLine($"[LOADER][FOCUS][SKIP] symbol read failed index={symbolIndex}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
var addend = relocation.Type is RelocationTypeGlobalData or RelocationTypeJumpSlot ? 0 : relocation.Addend;
|
||||
var symbolBind = GetSymbolBind(symbol.Info);
|
||||
if (symbolBind == SymbolBindLocal)
|
||||
if (relocation.Type is RelocationTypeTlsDtpOff64 or RelocationTypeTlsTpOff64)
|
||||
{
|
||||
var symbolAddress = ResolveMappedAddressOrFallback(virtualMemory, symbol.Value, imageBase);
|
||||
if (symbolAddress == 0)
|
||||
// Variant II static TLS: the module block sits at
|
||||
// [tp - blockSize, tp). DTPOFF64 is the module-relative offset
|
||||
// (st_value + addend); TPOFF64 is that offset expressed relative
|
||||
// to the thread pointer, i.e. minus the aligned block size.
|
||||
var tlsSymbolOffset = AddSigned(symbol.Value, relocation.Addend);
|
||||
if (!GuestTlsTemplate.TryGetStaticOffset(tlsModuleId, out var moduleStaticOffset))
|
||||
{
|
||||
Log.Warning(
|
||||
$"Skipping local relocation with invalid symbol value 0x{symbol.Value:X} " +
|
||||
$"at target 0x{targetAddress:X16}, type={relocation.Type}, sym={symbolIndex}");
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TLS] Missing PT_TLS registration for module {tlsModuleId}; " +
|
||||
$"cannot apply relocation {relocation.Type} at 0x{targetAddress:X16}.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var tlsValue = relocation.Type == RelocationTypeTlsTpOff64
|
||||
? unchecked(tlsSymbolOffset - moduleStaticOffset)
|
||||
: tlsSymbolOffset;
|
||||
descriptors.Add(new RelocationDescriptor(
|
||||
targetAddress,
|
||||
addend,
|
||||
0,
|
||||
null,
|
||||
symbolAddress,
|
||||
RelocationValueKind.Pointer,
|
||||
tlsValue,
|
||||
RelocationValueKind.TlsOffset,
|
||||
IsDataImport: false));
|
||||
continue;
|
||||
}
|
||||
|
||||
var symbolBind = GetSymbolBind(symbol.Info);
|
||||
if (symbolIndex == 0)
|
||||
{
|
||||
descriptors.Add(CreateSymbolRelocationDescriptor(
|
||||
relocation,
|
||||
targetAddress,
|
||||
symbol,
|
||||
symbolAddress: 0,
|
||||
importNid: null,
|
||||
isWeak: false));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (symbolBind == SymbolBindLocal)
|
||||
{
|
||||
var symbolAddress = relocation.Type is RelocationTypeSize32 or RelocationTypeSize64
|
||||
? 0
|
||||
: ResolveMappedAddressOrFallback(virtualMemory, symbol.Value, imageBase);
|
||||
if (symbolAddress == 0)
|
||||
{
|
||||
if (relocation.Type is not (RelocationTypeSize32 or RelocationTypeSize64))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER] Skipping local relocation with invalid symbol value 0x{symbol.Value:X} " +
|
||||
$"at target 0x{targetAddress:X16}, type={relocation.Type}, sym={symbolIndex}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
descriptors.Add(CreateSymbolRelocationDescriptor(
|
||||
relocation,
|
||||
targetAddress,
|
||||
symbol,
|
||||
symbolAddress,
|
||||
importNid: null,
|
||||
isWeak: false));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (symbol.Value != 0)
|
||||
{
|
||||
var symbolAddress = ResolveMappedAddressOrFallback(virtualMemory, symbol.Value, imageBase);
|
||||
var symbolAddress = relocation.Type is RelocationTypeSize32 or RelocationTypeSize64
|
||||
? 0
|
||||
: ResolveMappedAddressOrFallback(virtualMemory, symbol.Value, imageBase);
|
||||
if (symbolAddress == 0)
|
||||
{
|
||||
Log.Warning(
|
||||
$"Skipping relocation with invalid symbol value 0x{symbol.Value:X} " +
|
||||
$"at target 0x{targetAddress:X16}, type={relocation.Type}, sym={symbolIndex}");
|
||||
if (relocation.Type is not (RelocationTypeSize32 or RelocationTypeSize64))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER] Skipping relocation with invalid symbol value 0x{symbol.Value:X} " +
|
||||
$"at target 0x{targetAddress:X16}, type={relocation.Type}, sym={symbolIndex}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
descriptors.Add(new RelocationDescriptor(
|
||||
descriptors.Add(CreateSymbolRelocationDescriptor(
|
||||
relocation,
|
||||
targetAddress,
|
||||
addend,
|
||||
null,
|
||||
symbol,
|
||||
symbolAddress,
|
||||
RelocationValueKind.Pointer,
|
||||
IsDataImport: false));
|
||||
importNid: null,
|
||||
isWeak: false));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -893,16 +1067,26 @@ public sealed class SelfLoader : ISelfLoader
|
||||
{
|
||||
if (targetAddress >= FocusRelocGuestStart && targetAddress <= FocusRelocGuestEnd)
|
||||
{
|
||||
Log.Debug($"[FOCUS][SKIP] bind={symbolBind} not importable");
|
||||
Console.Error.WriteLine($"[LOADER][FOCUS][SKIP] bind={symbolBind} not importable");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryReadNullTerminatedAscii(stringTable, symbol.NameOffset, out var symbolName))
|
||||
{
|
||||
if (symbolBind == SymbolBindWeak)
|
||||
{
|
||||
descriptors.Add(CreateSymbolRelocationDescriptor(
|
||||
relocation,
|
||||
targetAddress,
|
||||
symbol,
|
||||
symbolAddress: 0,
|
||||
importNid: null,
|
||||
isWeak: true));
|
||||
}
|
||||
if (targetAddress >= FocusRelocGuestStart && targetAddress <= FocusRelocGuestEnd)
|
||||
{
|
||||
Log.Debug($"[FOCUS][SKIP] symbol name read failed offset={symbol.NameOffset}");
|
||||
Console.Error.WriteLine($"[LOADER][FOCUS][SKIP] symbol name read failed offset={symbol.NameOffset}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -910,6 +1094,16 @@ public sealed class SelfLoader : ISelfLoader
|
||||
var nid = ExtractNid(symbolName);
|
||||
if (string.IsNullOrWhiteSpace(nid))
|
||||
{
|
||||
if (symbolBind == SymbolBindWeak)
|
||||
{
|
||||
descriptors.Add(CreateSymbolRelocationDescriptor(
|
||||
relocation,
|
||||
targetAddress,
|
||||
symbol,
|
||||
symbolAddress: 0,
|
||||
importNid: null,
|
||||
isWeak: true));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -918,16 +1112,74 @@ public sealed class SelfLoader : ISelfLoader
|
||||
orderedImportNids.Add(nid);
|
||||
}
|
||||
|
||||
descriptors.Add(new RelocationDescriptor(
|
||||
descriptors.Add(CreateSymbolRelocationDescriptor(
|
||||
relocation,
|
||||
targetAddress,
|
||||
addend,
|
||||
nid,
|
||||
0,
|
||||
RelocationValueKind.Pointer,
|
||||
IsDataImport: GetSymbolType(symbol.Info) == SymbolTypeObject));
|
||||
symbol,
|
||||
symbolAddress: 0,
|
||||
importNid: nid,
|
||||
isWeak: symbolBind == SymbolBindWeak));
|
||||
}
|
||||
}
|
||||
|
||||
private static RelocationDescriptor CreateSymbolRelocationDescriptor(
|
||||
ElfRelocation relocation,
|
||||
ulong targetAddress,
|
||||
ElfSymbol symbol,
|
||||
ulong symbolAddress,
|
||||
string? importNid,
|
||||
bool isWeak)
|
||||
{
|
||||
var valueKind = relocation.Type switch
|
||||
{
|
||||
RelocationTypePc32 or RelocationTypePlt32 or RelocationTypePc64 => RelocationValueKind.PcRelative,
|
||||
RelocationTypeSize32 or RelocationTypeSize64 => RelocationValueKind.SymbolSize,
|
||||
_ => RelocationValueKind.Pointer,
|
||||
};
|
||||
var writeKind = relocation.Type switch
|
||||
{
|
||||
RelocationTypePc32 or RelocationTypePlt32 or RelocationTypeSigned32 => RelocationWriteKind.Int32,
|
||||
RelocationTypeUnsigned32 or RelocationTypeSize32 => RelocationWriteKind.UInt32,
|
||||
_ => RelocationWriteKind.UInt64,
|
||||
};
|
||||
var symbolValue = valueKind == RelocationValueKind.SymbolSize
|
||||
? symbol.Size
|
||||
: symbolAddress;
|
||||
var addend = relocation.Type is RelocationTypeGlobalData or RelocationTypeJumpSlot
|
||||
? 0
|
||||
: relocation.Addend;
|
||||
return new RelocationDescriptor(
|
||||
targetAddress,
|
||||
addend,
|
||||
importNid,
|
||||
symbolValue,
|
||||
valueKind,
|
||||
IsDataImport: GetSymbolType(symbol.Info) == SymbolTypeObject,
|
||||
writeKind,
|
||||
isWeak);
|
||||
}
|
||||
|
||||
private static bool ShouldCreateImportStub(
|
||||
string nid,
|
||||
IReadOnlyList<RelocationDescriptor> descriptors,
|
||||
IModuleManager? moduleManager)
|
||||
{
|
||||
for (var i = 0; i < descriptors.Count; i++)
|
||||
{
|
||||
var descriptor = descriptors[i];
|
||||
if (!string.Equals(descriptor.ImportNid, nid, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!descriptor.IsWeak || moduleManager?.TryGetExport(nid, out _) == true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void RegisterRuntimeSymbolsAndHooks(
|
||||
ReadOnlySpan<byte> imageData,
|
||||
LoadContext loadContext,
|
||||
@@ -950,8 +1202,8 @@ public sealed class SelfLoader : ISelfLoader
|
||||
|
||||
if (sectionSymbols > 0 || dynamicSymbols > 0)
|
||||
{
|
||||
Log.Info(
|
||||
$"Runtime symbol index populated: section={sectionSymbols}, dynamic={dynamicSymbols}, total={runtimeSymbols.Count}");
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER] Runtime symbol index populated: section={sectionSymbols}, dynamic={dynamicSymbols}, total={runtimeSymbols.Count}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1025,8 +1277,8 @@ public sealed class SelfLoader : ISelfLoader
|
||||
|
||||
if (preInitializers.Count != 0 || initializers.Count != 0)
|
||||
{
|
||||
Log.Info(
|
||||
$"Initializers discovered: preinit={preInitializers.Count}, init={initializers.Count}");
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER] Initializers discovered: preinit={preInitializers.Count}, init={initializers.Count}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1409,8 +1661,8 @@ public sealed class SelfLoader : ISelfLoader
|
||||
|
||||
var requiredBytes = checked((ulong)orderedImportNids.Count * ImportStubSlotSize);
|
||||
var mapSize = AlignUp(Math.Max(requiredBytes, (ulong)PageSize), PageSize);
|
||||
Log.Debug(
|
||||
$"CreateImportStubMapping: nids={orderedImportNids.Count}, required=0x{requiredBytes:X}, map_size=0x{mapSize:X}");
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER] CreateImportStubMapping: nids={orderedImportNids.Count}, required=0x{requiredBytes:X}, map_size=0x{mapSize:X}");
|
||||
if (mapSize > int.MaxValue)
|
||||
{
|
||||
throw new NotSupportedException("Import stub mapping exceeds 2 GB and is not supported.");
|
||||
@@ -1682,11 +1934,51 @@ public sealed class SelfLoader : ISelfLoader
|
||||
private static bool IsSupportedRelocationType(uint relocationType)
|
||||
{
|
||||
return relocationType is
|
||||
RelocationTypeNone or
|
||||
RelocationTypeAbsolute64 or
|
||||
RelocationTypePc32 or
|
||||
RelocationTypePlt32 or
|
||||
RelocationTypeGlobalData or
|
||||
RelocationTypeJumpSlot or
|
||||
RelocationTypeRelative or
|
||||
RelocationTypeTlsModuleId;
|
||||
RelocationTypeUnsigned32 or
|
||||
RelocationTypeSigned32 or
|
||||
RelocationTypeTlsModuleId or
|
||||
RelocationTypeTlsDtpOff64 or
|
||||
RelocationTypeTlsTpOff64 or
|
||||
RelocationTypePc64 or
|
||||
RelocationTypeSize32 or
|
||||
RelocationTypeSize64 or
|
||||
RelocationTypeRelative64;
|
||||
}
|
||||
|
||||
private static readonly HashSet<uint> _reportedUnsupportedRelocationTypes = new();
|
||||
|
||||
private static void ReportUnsupportedRelocation(uint relocationType, ulong offset, ulong imageBase)
|
||||
{
|
||||
// Report each distinct unsupported type once to keep the log useful.
|
||||
lock (_reportedUnsupportedRelocationTypes)
|
||||
{
|
||||
if (!_reportedUnsupportedRelocationTypes.Add(relocationType))
|
||||
{
|
||||
if (IsFocusRelocationOffset(offset, imageBase))
|
||||
{
|
||||
Console.Error.WriteLine($"[LOADER][FOCUS][SKIP] unsupported type={relocationType}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var name = relocationType switch
|
||||
{
|
||||
5 => "R_X86_64_COPY",
|
||||
37 => "R_X86_64_IRELATIVE (ifunc)",
|
||||
_ => "unknown",
|
||||
};
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][ERROR] Unsupported relocation type {relocationType} ({name}) rejected " +
|
||||
$"(first at off=0x{offset:X16}); COPY requires dependency symbol storage and IRELATIVE requires resolver execution.");
|
||||
}
|
||||
|
||||
private static ulong DetermineRequestedImageBase(
|
||||
@@ -1842,16 +2134,16 @@ public sealed class SelfLoader : ISelfLoader
|
||||
tableBytes = GC.AllocateUninitializedArray<byte>((int)size);
|
||||
|
||||
var guestAddr = location + imageBase;
|
||||
Log.Debug($"TryLoadTableBytes: trying guest address 0x{guestAddr:X}");
|
||||
Console.Error.WriteLine($"[LOADER] TryLoadTableBytes: trying guest address 0x{guestAddr:X}");
|
||||
if (virtualMemory.TryRead(guestAddr, tableBytes))
|
||||
{
|
||||
Log.Debug($"TryLoadTableBytes: loaded from guest memory at 0x{guestAddr:X}");
|
||||
Console.Error.WriteLine($"[LOADER] TryLoadTableBytes: loaded from guest memory at 0x{guestAddr:X}");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (virtualMemory.TryRead(location, tableBytes))
|
||||
{
|
||||
Log.Debug($"TryLoadTableBytes: loaded from absolute guest address 0x{location:X}");
|
||||
Console.Error.WriteLine($"[LOADER] TryLoadTableBytes: loaded from absolute guest address 0x{location:X}");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1859,11 +2151,11 @@ public sealed class SelfLoader : ISelfLoader
|
||||
{
|
||||
var slice = elfData.Slice((int)location, (int)size);
|
||||
tableBytes = slice.ToArray();
|
||||
Log.Debug($"TryLoadTableBytes: loaded from elfData as file offset at 0x{location:X}");
|
||||
Console.Error.WriteLine($"[LOADER] TryLoadTableBytes: loaded from elfData as file offset at 0x{location:X}");
|
||||
return true;
|
||||
}
|
||||
|
||||
Log.Warning($"TryLoadTableBytes: FAILED for location 0x{location:X}");
|
||||
Console.Error.WriteLine($"[LOADER] TryLoadTableBytes: FAILED for location 0x{location:X}");
|
||||
tableBytes = Array.Empty<byte>();
|
||||
return false;
|
||||
}
|
||||
@@ -1920,17 +2212,17 @@ public sealed class SelfLoader : ISelfLoader
|
||||
|
||||
private static ulong ResolveMappedAddressOrFallback(IVirtualMemory virtualMemory, ulong address, ulong imageBase)
|
||||
{
|
||||
Log.Debug($"ResolveMappedAddressOrFallback addr=0x{address:X} imageBase=0x{imageBase:X16}");
|
||||
Console.Error.WriteLine($"[LOADER][TEST] ResolveMappedAddressOrFallback addr=0x{address:X} imageBase=0x{imageBase:X16}");
|
||||
|
||||
if (address == 0)
|
||||
{
|
||||
Log.Debug("-> return 0 (null)");
|
||||
Console.Error.WriteLine("[LOADER][TEST] -> return 0 (null)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (TryResolveMappedAddress(virtualMemory, address, imageBase, 1, out var resolved))
|
||||
{
|
||||
Log.Debug($"-> resolved raw 0x{resolved:X16}");
|
||||
Console.Error.WriteLine($"[LOADER][TEST] -> resolved raw 0x{resolved:X16}");
|
||||
return resolved;
|
||||
}
|
||||
|
||||
@@ -1939,18 +2231,18 @@ public sealed class SelfLoader : ISelfLoader
|
||||
var rebased = address + imageBase;
|
||||
if (TryResolveMappedAddress(virtualMemory, rebased, imageBase, 1, out var resolvedRebased))
|
||||
{
|
||||
Log.Debug($"-> resolved rebased 0x{resolvedRebased:X16}");
|
||||
Console.Error.WriteLine($"[LOADER][TEST] -> resolved rebased 0x{resolvedRebased:X16}");
|
||||
return resolvedRebased;
|
||||
}
|
||||
}
|
||||
|
||||
if (address < 0x10000)
|
||||
{
|
||||
Log.Debug($"-> reject small 0x{address:X}");
|
||||
Console.Error.WriteLine($"[LOADER][TEST] -> reject small 0x{address:X}");
|
||||
return 0;
|
||||
}
|
||||
|
||||
Log.Debug($"-> fallback raw 0x{address:X}");
|
||||
Console.Error.WriteLine($"[LOADER][TEST] -> fallback raw 0x{address:X}");
|
||||
return address;
|
||||
}
|
||||
|
||||
@@ -2040,6 +2332,106 @@ public sealed class SelfLoader : ISelfLoader
|
||||
return virtualMemory.TryWrite(address, buffer);
|
||||
}
|
||||
|
||||
private static ulong ComputeRelocationValue(RelocationDescriptor descriptor, ulong resolvedSymbolValue)
|
||||
{
|
||||
var baseValue = descriptor.ValueKind == RelocationValueKind.SymbolSize
|
||||
? descriptor.SymbolValue
|
||||
: resolvedSymbolValue;
|
||||
var value = AddSigned(baseValue, descriptor.Addend);
|
||||
return descriptor.ValueKind == RelocationValueKind.PcRelative
|
||||
? unchecked(value - descriptor.TargetAddress)
|
||||
: value;
|
||||
}
|
||||
|
||||
private static bool TryWriteRelocationValue(
|
||||
IVirtualMemory virtualMemory,
|
||||
RelocationDescriptor descriptor,
|
||||
ulong value,
|
||||
out string? error)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
|
||||
var length = sizeof(ulong);
|
||||
switch (descriptor.WriteKind)
|
||||
{
|
||||
case RelocationWriteKind.UInt64:
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
||||
break;
|
||||
|
||||
case RelocationWriteKind.UInt32:
|
||||
if (value > uint.MaxValue)
|
||||
{
|
||||
error = $"value 0x{value:X16} overflows an unsigned 32-bit relocation";
|
||||
return false;
|
||||
}
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer, (uint)value);
|
||||
length = sizeof(uint);
|
||||
break;
|
||||
|
||||
case RelocationWriteKind.Int32:
|
||||
var signedValue = unchecked((long)value);
|
||||
if (signedValue is < int.MinValue or > int.MaxValue)
|
||||
{
|
||||
error = $"value {signedValue} overflows a signed 32-bit relocation";
|
||||
return false;
|
||||
}
|
||||
BinaryPrimitives.WriteInt32LittleEndian(buffer, (int)signedValue);
|
||||
length = sizeof(int);
|
||||
break;
|
||||
|
||||
default:
|
||||
error = $"unknown relocation write kind {descriptor.WriteKind}";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = virtualMemory.TryWrite(descriptor.TargetAddress, buffer[..length])
|
||||
? null
|
||||
: "target memory is not writable";
|
||||
return error is null;
|
||||
}
|
||||
|
||||
private static int GetRelocationWriteSize(uint relocationType)
|
||||
{
|
||||
return relocationType is
|
||||
RelocationTypePc32 or
|
||||
RelocationTypePlt32 or
|
||||
RelocationTypeUnsigned32 or
|
||||
RelocationTypeSigned32 or
|
||||
RelocationTypeSize32
|
||||
? sizeof(uint)
|
||||
: sizeof(ulong);
|
||||
}
|
||||
|
||||
[Conditional("DEBUG")]
|
||||
private static void RunRelocationSelfChecks()
|
||||
{
|
||||
var pc32 = new RelocationDescriptor(
|
||||
TargetAddress: 0x1000,
|
||||
Addend: -4,
|
||||
ImportNid: null,
|
||||
SymbolValue: 0x1800,
|
||||
RelocationValueKind.PcRelative,
|
||||
IsDataImport: false,
|
||||
RelocationWriteKind.Int32);
|
||||
Debug.Assert(
|
||||
unchecked((long)ComputeRelocationValue(pc32, pc32.SymbolValue)) == 0x7FC,
|
||||
"R_X86_64_PC32 did not apply S + A - P.");
|
||||
|
||||
var weak = new RelocationDescriptor(
|
||||
TargetAddress: 0x2000,
|
||||
Addend: 7,
|
||||
ImportNid: "weak",
|
||||
SymbolValue: 0,
|
||||
RelocationValueKind.Pointer,
|
||||
IsDataImport: false,
|
||||
IsWeak: true);
|
||||
Debug.Assert(
|
||||
ComputeRelocationValue(weak, resolvedSymbolValue: 0) == 7,
|
||||
"An unresolved weak relocation did not use S=0.");
|
||||
Debug.Assert(
|
||||
!ShouldCreateImportStub("weak", [weak], moduleManager: null),
|
||||
"An unresolved weak symbol incorrectly received a trap import stub.");
|
||||
}
|
||||
|
||||
private static ulong AlignUp(ulong value, ulong alignment)
|
||||
{
|
||||
var mask = alignment - 1;
|
||||
@@ -2248,6 +2640,16 @@ public sealed class SelfLoader : ISelfLoader
|
||||
{
|
||||
throw new InvalidDataException($"Unsupported ELF program header entry size: {header.ProgramHeaderEntrySize}.");
|
||||
}
|
||||
|
||||
// The CPU backend executes guest instructions natively, so a non
|
||||
// x86-64 image can only fail deep in execution with an opaque SIGILL.
|
||||
// Catch it here with an actionable message. (EM_X86_64 == 62.)
|
||||
const ushort ElfMachineX86_64 = 62;
|
||||
if (header.Machine != ElfMachineX86_64)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Unsupported ELF machine type 0x{header.Machine:X4}; SharpEmu only runs x86-64 (EM_X86_64) images.");
|
||||
}
|
||||
}
|
||||
|
||||
private static unsafe T ReadUnmanaged<T>(ReadOnlySpan<byte> source, int offset)
|
||||
@@ -2328,10 +2730,25 @@ public sealed class SelfLoader : ISelfLoader
|
||||
public uint Type => (uint)(Info & uint.MaxValue);
|
||||
}
|
||||
|
||||
private readonly record struct ModuleTlsInfo(ulong MemorySize, ulong StaticOffset);
|
||||
|
||||
private enum RelocationValueKind : byte
|
||||
{
|
||||
Pointer = 0,
|
||||
TlsModuleId = 1
|
||||
TlsModuleId = 1,
|
||||
// A pre-computed TLS offset written verbatim (TPOFF64/DTPOFF64). Unlike
|
||||
// Pointer it is a signed displacement, not a mapped address, so it is
|
||||
// patched as-is without the low-address validity warning.
|
||||
TlsOffset = 2,
|
||||
PcRelative = 3,
|
||||
SymbolSize = 4,
|
||||
}
|
||||
|
||||
private enum RelocationWriteKind : byte
|
||||
{
|
||||
UInt64 = 0,
|
||||
UInt32 = 1,
|
||||
Int32 = 2,
|
||||
}
|
||||
|
||||
private readonly record struct RelocationDescriptor(
|
||||
@@ -2340,7 +2757,9 @@ public sealed class SelfLoader : ISelfLoader
|
||||
string? ImportNid,
|
||||
ulong SymbolValue,
|
||||
RelocationValueKind ValueKind,
|
||||
bool IsDataImport);
|
||||
bool IsDataImport,
|
||||
RelocationWriteKind WriteKind = RelocationWriteKind.UInt64,
|
||||
bool IsWeak = false);
|
||||
|
||||
private enum SelfSegmentResolveStatus
|
||||
{
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using SharpEmu.Core.Loader;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.HLE.Host;
|
||||
using SharpEmu.Logging;
|
||||
|
||||
namespace SharpEmu.Core.Memory;
|
||||
|
||||
public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryAllocator, IDisposable
|
||||
public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryAllocator, IGuestAddressSpace, IDisposable
|
||||
{
|
||||
private static readonly SharpEmuLogger Log = SharpEmuLog.For("VMEM");
|
||||
|
||||
@@ -28,41 +29,127 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
private const ulong DefaultLazyReservePrimeBytes = 0x0400_0000UL; // 64 MiB
|
||||
private const ulong LazyReservePrimeChunkBytes = 0x0200_0000UL; // 32 MiB
|
||||
|
||||
private const uint MEM_COMMIT = 0x1000;
|
||||
private const uint MEM_RESERVE = 0x2000;
|
||||
private const uint MEM_RELEASE = 0x8000;
|
||||
// Raw Windows PAGE_* values retained for the internal region/protection
|
||||
// bookkeeping: regions and saved old-protection values always carry the raw
|
||||
// value of the host platform in use, and these classification helpers only
|
||||
// ever see values this class itself assigned (see IHostMemory.ProtectRaw).
|
||||
private const uint PAGE_EXECUTE_READ = 0x20;
|
||||
private const uint PAGE_EXECUTE_READWRITE = 0x40;
|
||||
private const uint PAGE_EXECUTE = 0x10;
|
||||
private const uint PAGE_EXECUTE_WRITECOPY = 0x80;
|
||||
private const uint PAGE_NOACCESS = 0x01;
|
||||
private const uint PAGE_READWRITE = 0x04;
|
||||
private const uint PAGE_READONLY = 0x02;
|
||||
|
||||
private readonly IHostMemory _hostMemory;
|
||||
private ulong _guestAllocationArenaBase;
|
||||
private ulong _guestAllocationOffset;
|
||||
private readonly SortedDictionary<ulong, ulong> _guestAllocationFreeRanges = new();
|
||||
private readonly Dictionary<ulong, (ulong Offset, ulong Size)> _guestAllocations = new();
|
||||
private static readonly ulong LazyReservePrimeBytes = ResolveLazyReservePrimeBytes();
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern void* VirtualAlloc(void* lpAddress, nuint dwSize, uint flAllocationType, uint flProtect);
|
||||
public PhysicalVirtualMemory(IHostMemory? hostMemory = null)
|
||||
{
|
||||
_hostMemory = hostMemory ?? CrossPlatformHostMemory.Instance;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool VirtualFree(void* lpAddress, nuint dwSize, uint dwFreeType);
|
||||
private sealed class CrossPlatformHostMemory : IHostMemory
|
||||
{
|
||||
public static readonly CrossPlatformHostMemory Instance = new();
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool VirtualProtect(void* lpAddress, nuint dwSize, uint flNewProtect, out uint lpflOldProtect);
|
||||
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) =>
|
||||
unchecked((ulong)HostMemory.Alloc(
|
||||
(void*)desiredAddress,
|
||||
(nuint)size,
|
||||
HostMemory.MEM_RESERVE | HostMemory.MEM_COMMIT,
|
||||
ToRawProtection(protection)));
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern nuint VirtualQuery(void* lpAddress, out MemoryBasicInformation64 lpBuffer, nuint dwLength);
|
||||
public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection) =>
|
||||
unchecked((ulong)HostMemory.Alloc(
|
||||
(void*)desiredAddress,
|
||||
(nuint)size,
|
||||
HostMemory.MEM_RESERVE,
|
||||
ToRawProtection(protection)));
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern void* GetCurrentProcess();
|
||||
public bool Commit(ulong address, ulong size, HostPageProtection protection) =>
|
||||
HostMemory.Alloc(
|
||||
(void*)address,
|
||||
(nuint)size,
|
||||
HostMemory.MEM_COMMIT,
|
||||
ToRawProtection(protection)) != null;
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool FlushInstructionCache(void* hProcess, void* lpBaseAddress, nuint dwSize);
|
||||
public bool Free(ulong address) =>
|
||||
HostMemory.Free((void*)address, 0, HostMemory.MEM_RELEASE);
|
||||
|
||||
public bool Protect(
|
||||
ulong address,
|
||||
ulong size,
|
||||
HostPageProtection protection,
|
||||
out uint rawOldProtection) =>
|
||||
HostMemory.Protect(
|
||||
(void*)address,
|
||||
(nuint)size,
|
||||
ToRawProtection(protection),
|
||||
out rawOldProtection);
|
||||
|
||||
public bool ProtectRaw(
|
||||
ulong address,
|
||||
ulong size,
|
||||
uint rawProtection,
|
||||
out uint rawOldProtection) =>
|
||||
HostMemory.Protect((void*)address, (nuint)size, rawProtection, out rawOldProtection);
|
||||
|
||||
public bool Query(ulong address, out HostRegionInfo info)
|
||||
{
|
||||
if (HostMemory.Query((void*)address, out var raw) == 0)
|
||||
{
|
||||
info = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
var state = raw.State switch
|
||||
{
|
||||
HostMemory.MEM_FREE_STATE => HostRegionState.Free,
|
||||
HostMemory.MEM_RESERVE => HostRegionState.Reserved,
|
||||
_ => HostRegionState.Committed,
|
||||
};
|
||||
|
||||
info = new HostRegionInfo(
|
||||
raw.BaseAddress,
|
||||
raw.AllocationBase,
|
||||
raw.RegionSize,
|
||||
state,
|
||||
raw.State,
|
||||
FromRawProtection(raw.Protect),
|
||||
raw.Protect,
|
||||
raw.AllocationProtect);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void FlushInstructionCache(ulong address, ulong size) =>
|
||||
HostMemory.FlushInstructionCache((void*)address, (nuint)size);
|
||||
|
||||
private static uint ToRawProtection(HostPageProtection protection) => protection switch
|
||||
{
|
||||
HostPageProtection.NoAccess => HostMemory.PAGE_NOACCESS,
|
||||
HostPageProtection.ReadOnly => HostMemory.PAGE_READONLY,
|
||||
HostPageProtection.ReadWrite => HostMemory.PAGE_READWRITE,
|
||||
HostPageProtection.Execute => HostMemory.PAGE_EXECUTE,
|
||||
HostPageProtection.ReadExecute => HostMemory.PAGE_EXECUTE_READ,
|
||||
HostPageProtection.ReadWriteExecute => HostMemory.PAGE_EXECUTE_READWRITE,
|
||||
HostPageProtection.ExecuteWriteCopy => 0x80,
|
||||
_ => HostMemory.PAGE_NOACCESS,
|
||||
};
|
||||
|
||||
private static HostPageProtection FromRawProtection(uint protection) => protection switch
|
||||
{
|
||||
HostMemory.PAGE_READONLY => HostPageProtection.ReadOnly,
|
||||
HostMemory.PAGE_READWRITE => HostPageProtection.ReadWrite,
|
||||
HostMemory.PAGE_EXECUTE => HostPageProtection.Execute,
|
||||
HostMemory.PAGE_EXECUTE_READ => HostPageProtection.ReadExecute,
|
||||
HostMemory.PAGE_EXECUTE_READWRITE => HostPageProtection.ReadWriteExecute,
|
||||
0x80 => HostPageProtection.ExecuteWriteCopy,
|
||||
_ => HostPageProtection.NoAccess,
|
||||
};
|
||||
}
|
||||
|
||||
public bool TryAllocateAtExact(ulong desiredAddress, ulong size, bool executable, out ulong actualAddress)
|
||||
{
|
||||
@@ -74,17 +161,17 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
var alignedSize = (size + 0xFFF) & ~0xFFFUL;
|
||||
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
||||
var allocationType = MEM_COMMIT | MEM_RESERVE;
|
||||
var result = VirtualAlloc((void*)desiredAddress, (nuint)alignedSize, allocationType, protection);
|
||||
if (result == null)
|
||||
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
|
||||
var result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
|
||||
if (result == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
actualAddress = (ulong)result;
|
||||
actualAddress = result;
|
||||
if (actualAddress != desiredAddress)
|
||||
{
|
||||
VirtualFree(result, 0, MEM_RELEASE);
|
||||
_hostMemory.Free(result);
|
||||
actualAddress = 0;
|
||||
return false;
|
||||
}
|
||||
@@ -111,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)
|
||||
@@ -119,33 +224,33 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
var alignedSize = (size + 0xFFF) & ~0xFFFUL;
|
||||
|
||||
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
||||
var allocationType = MEM_COMMIT | MEM_RESERVE;
|
||||
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
|
||||
var reservedOnly = false;
|
||||
var preferReserveOnly = !executable &&
|
||||
alignedSize >= LargeDataReserveThreshold &&
|
||||
alignedSize > FullCommitRegionLimit;
|
||||
|
||||
void* result = null;
|
||||
ulong result = 0;
|
||||
if (preferReserveOnly)
|
||||
{
|
||||
result = VirtualAlloc((void*)desiredAddress, (nuint)alignedSize, MEM_RESERVE, PAGE_READWRITE);
|
||||
if (result == null && allowAlternative)
|
||||
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
|
||||
if (result == 0 && allowAlternative)
|
||||
{
|
||||
result = VirtualAlloc(null, (nuint)alignedSize, MEM_RESERVE, PAGE_READWRITE);
|
||||
result = _hostMemory.Reserve(0, alignedSize, HostPageProtection.ReadWrite);
|
||||
}
|
||||
|
||||
if (result != null)
|
||||
if (result != 0)
|
||||
{
|
||||
reservedOnly = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (result == null)
|
||||
if (result == 0)
|
||||
{
|
||||
result = VirtualAlloc((void*)desiredAddress, (nuint)alignedSize, allocationType, protection);
|
||||
result = _hostMemory.Allocate(desiredAddress, alignedSize, hostProtection);
|
||||
}
|
||||
|
||||
if (result == null)
|
||||
if (result == 0)
|
||||
{
|
||||
if (!allowAlternative)
|
||||
{
|
||||
@@ -153,32 +258,32 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
|
||||
TraceVmem($"Could not allocate at 0x{desiredAddress:X16}, trying any address...");
|
||||
result = VirtualAlloc(null, (nuint)alignedSize, allocationType, protection);
|
||||
result = _hostMemory.Allocate(0, alignedSize, hostProtection);
|
||||
|
||||
if (result == null)
|
||||
if (result == 0)
|
||||
{
|
||||
if (!executable)
|
||||
{
|
||||
result = VirtualAlloc((void*)desiredAddress, (nuint)alignedSize, MEM_RESERVE, PAGE_READWRITE);
|
||||
if (result == null && allowAlternative)
|
||||
result = _hostMemory.Reserve(desiredAddress, alignedSize, HostPageProtection.ReadWrite);
|
||||
if (result == 0 && allowAlternative)
|
||||
{
|
||||
result = VirtualAlloc(null, (nuint)alignedSize, MEM_RESERVE, PAGE_READWRITE);
|
||||
result = _hostMemory.Reserve(0, alignedSize, HostPageProtection.ReadWrite);
|
||||
}
|
||||
|
||||
if (result != null)
|
||||
if (result != 0)
|
||||
{
|
||||
reservedOnly = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (result == null)
|
||||
if (result == 0)
|
||||
{
|
||||
throw new OutOfMemoryException($"Failed to allocate {alignedSize} bytes of virtual memory");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var actualAddress = (ulong)result;
|
||||
var actualAddress = result;
|
||||
|
||||
var lazyPrimeState = "n/a";
|
||||
if (reservedOnly)
|
||||
@@ -191,9 +296,8 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
{
|
||||
var remaining = primeBytes - committedBytes;
|
||||
var chunkBytes = Math.Min(remaining, LazyReservePrimeChunkBytes);
|
||||
var commitAddress = (void*)(actualAddress + committedBytes);
|
||||
var committed = VirtualAlloc(commitAddress, (nuint)chunkBytes, MEM_COMMIT, PAGE_READWRITE);
|
||||
if (committed == null)
|
||||
var commitAddress = actualAddress + committedBytes;
|
||||
if (!_hostMemory.Commit(commitAddress, chunkBytes, HostPageProtection.ReadWrite))
|
||||
{
|
||||
break;
|
||||
}
|
||||
@@ -263,6 +367,35 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
var requestedCursor = AlignUp(desiredAddress, effectiveAlignment);
|
||||
var cursor = GetAllocationSearchCursor(desiredAddress, requestedCursor, effectiveAlignment, executable);
|
||||
|
||||
// macOS needs alignment over-allocation; Linux uses exact-address search.
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
var reserveSize = effectiveAlignment > PageSize
|
||||
? alignedSize + effectiveAlignment
|
||||
: alignedSize;
|
||||
try
|
||||
{
|
||||
var posixAddress = AllocateAt(cursor, reserveSize, executable, allowAlternative: true);
|
||||
if (posixAddress != 0)
|
||||
{
|
||||
var alignedBase = AlignUp(posixAddress, effectiveAlignment);
|
||||
if (alignedBase + alignedSize <= posixAddress + reserveSize)
|
||||
{
|
||||
actualAddress = alignedBase;
|
||||
UpdateAllocationSearchCursor(desiredAddress, effectiveAlignment, executable, alignedBase + alignedSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
ReleaseUntrackedAllocation(posixAddress);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var attempt = 0; attempt < 0x10000; attempt++)
|
||||
{
|
||||
if (cursor == 0 || ulong.MaxValue - cursor < alignedSize)
|
||||
@@ -276,19 +409,10 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
actualAddress = AllocateAt(cursor, alignedSize, executable, allowAlternative: false);
|
||||
if (actualAddress == cursor)
|
||||
{
|
||||
UpdateAllocationSearchCursor(desiredAddress, effectiveAlignment, executable, actualAddress + alignedSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
actualAddress = 0;
|
||||
}
|
||||
catch
|
||||
if (TryAllocateAtExact(cursor, alignedSize, executable, out actualAddress))
|
||||
{
|
||||
UpdateAllocationSearchCursor(desiredAddress, effectiveAlignment, executable, actualAddress + alignedSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
cursor = AlignUp(cursor + effectiveAlignment, effectiveAlignment);
|
||||
@@ -297,6 +421,28 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ReleaseUntrackedAllocation(ulong address)
|
||||
{
|
||||
_gate.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < _regions.Count; i++)
|
||||
{
|
||||
if (_regions[i].VirtualAddress == address)
|
||||
{
|
||||
_regions.RemoveAt(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.ExitWriteLock();
|
||||
}
|
||||
|
||||
_hostMemory.Free(address);
|
||||
}
|
||||
|
||||
public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address)
|
||||
{
|
||||
address = 0;
|
||||
@@ -316,7 +462,9 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
GuestAllocationArenaSize,
|
||||
executable: false,
|
||||
allowAlternative: true);
|
||||
_guestAllocationOffset = GuestAllocationArenaStartOffset;
|
||||
_guestAllocationFreeRanges.Add(
|
||||
GuestAllocationArenaStartOffset,
|
||||
GuestAllocationArenaSize - GuestAllocationArenaStartOffset);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
@@ -324,18 +472,128 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
}
|
||||
|
||||
var alignedOffset = AlignUp(_guestAllocationOffset, alignment);
|
||||
if (alignedOffset > GuestAllocationArenaSize || size > GuestAllocationArenaSize - alignedOffset)
|
||||
ulong rangeOffset = 0;
|
||||
ulong rangeSize = 0;
|
||||
ulong alignedOffset = 0;
|
||||
var found = false;
|
||||
foreach (var range in _guestAllocationFreeRanges)
|
||||
{
|
||||
alignedOffset = AlignUp(range.Key, alignment);
|
||||
if (alignedOffset >= range.Key &&
|
||||
alignedOffset - range.Key <= range.Value &&
|
||||
size <= range.Value - (alignedOffset - range.Key))
|
||||
{
|
||||
rangeOffset = range.Key;
|
||||
rangeSize = range.Value;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_guestAllocationFreeRanges.Remove(rangeOffset);
|
||||
if (alignedOffset > rangeOffset)
|
||||
{
|
||||
_guestAllocationFreeRanges.Add(rangeOffset, alignedOffset - rangeOffset);
|
||||
}
|
||||
|
||||
var allocationEnd = alignedOffset + size;
|
||||
var rangeEnd = rangeOffset + rangeSize;
|
||||
if (allocationEnd < rangeEnd)
|
||||
{
|
||||
_guestAllocationFreeRanges.Add(allocationEnd, rangeEnd - allocationEnd);
|
||||
}
|
||||
|
||||
address = _guestAllocationArenaBase + alignedOffset;
|
||||
_guestAllocationOffset = alignedOffset + size;
|
||||
_guestAllocations.Add(address, (alignedOffset, size));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryFreeGuestMemory(ulong address)
|
||||
{
|
||||
lock (_guestAllocationGate)
|
||||
{
|
||||
if (!_guestAllocations.Remove(address, out var allocation))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var freeOffset = allocation.Offset;
|
||||
var freeSize = allocation.Size;
|
||||
ulong? previousOffset = null;
|
||||
ulong? nextOffset = null;
|
||||
|
||||
foreach (var range in _guestAllocationFreeRanges)
|
||||
{
|
||||
if (range.Key < freeOffset)
|
||||
{
|
||||
previousOffset = range.Key;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextOffset = range.Key;
|
||||
break;
|
||||
}
|
||||
|
||||
if (previousOffset is { } previous &&
|
||||
previous + _guestAllocationFreeRanges[previous] == freeOffset)
|
||||
{
|
||||
freeOffset = previous;
|
||||
freeSize += _guestAllocationFreeRanges[previous];
|
||||
_guestAllocationFreeRanges.Remove(previous);
|
||||
}
|
||||
|
||||
if (nextOffset is { } next && freeOffset + freeSize == next)
|
||||
{
|
||||
freeSize += _guestAllocationFreeRanges[next];
|
||||
_guestAllocationFreeRanges.Remove(next);
|
||||
}
|
||||
|
||||
_guestAllocationFreeRanges.Add(freeOffset, freeSize);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryProtect(ulong address, ulong size, GuestPageProtection protection)
|
||||
{
|
||||
if (size == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _hostMemory.Protect(address, size, ResolveProtection(protection), out _);
|
||||
}
|
||||
|
||||
// Reproduces the decomposition KernelMemoryCompatExports.ResolveHostProtection
|
||||
// performed before this seam existed; the Windows backend maps each case back
|
||||
// to the identical PAGE_* value.
|
||||
private static HostPageProtection ResolveProtection(GuestPageProtection protection)
|
||||
{
|
||||
var read = (protection & GuestPageProtection.Read) != 0;
|
||||
var write = (protection & GuestPageProtection.Write) != 0;
|
||||
var execute = (protection & GuestPageProtection.Execute) != 0;
|
||||
|
||||
if (execute)
|
||||
{
|
||||
return write
|
||||
? HostPageProtection.ReadWriteExecute
|
||||
: read
|
||||
? HostPageProtection.ReadExecute
|
||||
: HostPageProtection.Execute;
|
||||
}
|
||||
|
||||
return write
|
||||
? HostPageProtection.ReadWrite
|
||||
: read
|
||||
? HostPageProtection.ReadOnly
|
||||
: HostPageProtection.NoAccess;
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (_guestAllocationGate)
|
||||
@@ -345,7 +603,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
{
|
||||
foreach (var region in _regions)
|
||||
{
|
||||
VirtualFree((void*)region.VirtualAddress, 0, MEM_RELEASE);
|
||||
_hostMemory.Free(region.VirtualAddress);
|
||||
}
|
||||
_regions.Clear();
|
||||
_pageProtections.Clear();
|
||||
@@ -360,7 +618,8 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
|
||||
_guestAllocationArenaBase = 0;
|
||||
_guestAllocationOffset = 0;
|
||||
_guestAllocationFreeRanges.Clear();
|
||||
_guestAllocations.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,46 +678,67 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
private void ApplySegmentProtection(ulong mapStart, ulong mapEnd, ProgramHeaderFlags flags)
|
||||
{
|
||||
var runStart = mapStart;
|
||||
var runFlags = ProgramHeaderFlags.None;
|
||||
var hasRun = false;
|
||||
|
||||
for (var pageAddress = mapStart; pageAddress < mapEnd; pageAddress += PageSize)
|
||||
{
|
||||
_pageProtections.TryGetValue(pageAddress, out var existingFlags);
|
||||
var mergedFlags = existingFlags | flags;
|
||||
_pageProtections[pageAddress] = mergedFlags;
|
||||
SetProtection(pageAddress, PageSize, mergedFlags);
|
||||
|
||||
if (!hasRun)
|
||||
{
|
||||
runStart = pageAddress;
|
||||
runFlags = mergedFlags;
|
||||
hasRun = true;
|
||||
}
|
||||
else if (mergedFlags != runFlags)
|
||||
{
|
||||
SetProtection(runStart, pageAddress - runStart, runFlags);
|
||||
runStart = pageAddress;
|
||||
runFlags = mergedFlags;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasRun)
|
||||
{
|
||||
SetProtection(runStart, mapEnd - runStart, runFlags);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetProtection(ulong address, ulong size, ProgramHeaderFlags flags)
|
||||
{
|
||||
uint protection;
|
||||
HostPageProtection protection;
|
||||
|
||||
if (flags == ProgramHeaderFlags.None)
|
||||
{
|
||||
protection = PAGE_NOACCESS;
|
||||
protection = HostPageProtection.NoAccess;
|
||||
}
|
||||
else if ((flags & ProgramHeaderFlags.Execute) != 0)
|
||||
{
|
||||
protection = (flags & ProgramHeaderFlags.Write) != 0
|
||||
? PAGE_EXECUTE_READWRITE
|
||||
: PAGE_EXECUTE_READ;
|
||||
? HostPageProtection.ReadWriteExecute
|
||||
: HostPageProtection.ReadExecute;
|
||||
}
|
||||
else if ((flags & ProgramHeaderFlags.Write) != 0)
|
||||
{
|
||||
protection = PAGE_READWRITE;
|
||||
protection = HostPageProtection.ReadWrite;
|
||||
}
|
||||
else
|
||||
{
|
||||
protection = PAGE_READONLY;
|
||||
protection = HostPageProtection.ReadOnly;
|
||||
}
|
||||
|
||||
if (!VirtualProtect((void*)address, (nuint)size, protection, out _))
|
||||
if (!_hostMemory.Protect(address, size, protection, out _))
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to set memory protection at 0x{address:X16}");
|
||||
}
|
||||
|
||||
if ((flags & ProgramHeaderFlags.Execute) != 0)
|
||||
{
|
||||
FlushInstructionCache(GetCurrentProcess(), (void*)address, (nuint)size);
|
||||
_hostMemory.FlushInstructionCache(address, size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,6 +830,47 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryCompare(ulong virtualAddress, ReadOnlySpan<byte> expected)
|
||||
{
|
||||
_gate.EnterReadLock();
|
||||
try
|
||||
{
|
||||
var region = FindRegion(virtualAddress, (ulong)expected.Length);
|
||||
if (region is null ||
|
||||
!TryResolveRegionOffset(
|
||||
virtualAddress,
|
||||
(ulong)expected.Length,
|
||||
region,
|
||||
out var offset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expected.IsEmpty)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var srcPtr = (void*)(region.VirtualAddress + offset);
|
||||
if (region.IsReservedOnly &&
|
||||
!EnsureRangeCommitted((ulong)srcPtr, (ulong)expected.Length, region))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!CanReadWithoutProtectionChange((ulong)srcPtr, (ulong)expected.Length, region))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return new ReadOnlySpan<byte>(srcPtr, expected.Length).SequenceEqual(expected);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source)
|
||||
{
|
||||
var requiresExclusiveAccess = false;
|
||||
@@ -689,7 +1010,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!VirtualProtect(destPtr, (nuint)source.Length, PAGE_EXECUTE_READWRITE, out var oldProtect))
|
||||
if (!_hostMemory.Protect((ulong)destPtr, (ulong)source.Length, HostPageProtection.ReadWriteExecute, out var oldProtect))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -703,10 +1024,10 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
}
|
||||
finally
|
||||
{
|
||||
VirtualProtect(destPtr, (nuint)source.Length, oldProtect, out _);
|
||||
_hostMemory.ProtectRaw((ulong)destPtr, (ulong)source.Length, oldProtect, out _);
|
||||
if (IsExecutableProtection(oldProtect))
|
||||
{
|
||||
FlushInstructionCache(GetCurrentProcess(), destPtr, (nuint)source.Length);
|
||||
_hostMemory.FlushInstructionCache((ulong)destPtr, (ulong)source.Length);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,9 +1049,14 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
_gate.EnterReadLock();
|
||||
try
|
||||
{
|
||||
return FindRegion(virtualAddress, 1) is not null
|
||||
? (void*)virtualAddress
|
||||
: null;
|
||||
var region = FindRegion(virtualAddress, 1);
|
||||
if (region is null ||
|
||||
(region.IsReservedOnly && !EnsureRangeCommitted(virtualAddress, 1, region)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return (void*)virtualAddress;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -932,12 +1258,12 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return protection is PAGE_READWRITE or PAGE_EXECUTE_READWRITE;
|
||||
}
|
||||
|
||||
private static uint GetCommitProtection(MemoryRegion region)
|
||||
private static HostPageProtection GetCommitProtection(MemoryRegion region)
|
||||
{
|
||||
return region.IsExecutable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
||||
return region.IsExecutable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
|
||||
}
|
||||
|
||||
private static unsafe bool EnsureRangeCommitted(ulong address, ulong size, MemoryRegion region)
|
||||
private bool EnsureRangeCommitted(ulong address, ulong size, MemoryRegion region)
|
||||
{
|
||||
if (size == 0 || !region.IsReservedOnly)
|
||||
{
|
||||
@@ -951,7 +1277,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
var pageAddress = startPage;
|
||||
while (pageAddress < endPage)
|
||||
{
|
||||
if (VirtualQuery((void*)pageAddress, out var info, (nuint)sizeof(MemoryBasicInformation64)) == 0)
|
||||
if (!_hostMemory.Query(pageAddress, out var info))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -965,19 +1291,19 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return false;
|
||||
}
|
||||
|
||||
if (info.State == MEM_COMMIT)
|
||||
if (info.State == HostRegionState.Committed)
|
||||
{
|
||||
pageAddress = rangeEnd;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (info.State != MEM_RESERVE)
|
||||
if (info.State != HostRegionState.Reserved)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var commitSize = rangeEnd - pageAddress;
|
||||
if (VirtualAlloc((void*)pageAddress, (nuint)commitSize, MEM_COMMIT, commitProtection) == null)
|
||||
if (!_hostMemory.Commit(pageAddress, commitSize, commitProtection))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -998,11 +1324,11 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
|
||||
var startPage = AlignDown(address, PageSize);
|
||||
var endPage = AlignUp(address + size, PageSize);
|
||||
var temporaryProtection = region.IsExecutable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
||||
var temporaryProtection = region.IsExecutable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
|
||||
|
||||
for (var pageAddress = startPage; pageAddress < endPage; pageAddress += PageSize)
|
||||
{
|
||||
if (!VirtualProtect((void*)pageAddress, (nuint)PageSize, temporaryProtection, out var oldProtection))
|
||||
if (!_hostMemory.Protect(pageAddress, PageSize, temporaryProtection, out var oldProtection))
|
||||
{
|
||||
RestorePageProtections(touchedPages);
|
||||
touchedPages.Clear();
|
||||
@@ -1015,11 +1341,11 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void RestorePageProtections(List<(ulong Address, uint Protection)> touchedPages)
|
||||
private void RestorePageProtections(List<(ulong Address, uint Protection)> touchedPages)
|
||||
{
|
||||
foreach (var (pageAddress, protection) in touchedPages)
|
||||
{
|
||||
VirtualProtect((void*)pageAddress, (nuint)PageSize, protection, out _);
|
||||
_hostMemory.ProtectRaw(pageAddress, PageSize, protection, out _);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1076,16 +1402,4 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
public uint Protection { get; set; }
|
||||
}
|
||||
|
||||
private struct MemoryBasicInformation64
|
||||
{
|
||||
public ulong BaseAddress;
|
||||
public ulong AllocationBase;
|
||||
public uint AllocationProtect;
|
||||
public uint Alignment1;
|
||||
public ulong RegionSize;
|
||||
public uint State;
|
||||
public uint Protect;
|
||||
public uint Type;
|
||||
public uint Alignment2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,15 +41,14 @@ public sealed class VirtualMemory : IVirtualMemory
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (var existing in _regions)
|
||||
var insertionIndex = FindInsertionIndex(virtualAddress);
|
||||
if ((insertionIndex > 0 && virtualAddress < _regions[insertionIndex - 1].EndAddress) ||
|
||||
(insertionIndex < _regions.Count && endAddress > _regions[insertionIndex].Region.VirtualAddress))
|
||||
{
|
||||
if (virtualAddress < existing.EndAddress && endAddress > existing.Region.VirtualAddress)
|
||||
{
|
||||
throw new InvalidOperationException("Attempted to map an overlapping virtual memory region.");
|
||||
}
|
||||
throw new InvalidOperationException("Attempted to map an overlapping virtual memory region.");
|
||||
}
|
||||
|
||||
_regions.Add(new MappedRegion(
|
||||
_regions.Insert(insertionIndex, new MappedRegion(
|
||||
new VirtualMemoryRegion(virtualAddress, memorySize, fileOffset, (ulong)fileData.Length, protection),
|
||||
endAddress,
|
||||
backingMemory));
|
||||
@@ -74,12 +73,12 @@ public sealed class VirtualMemory : IVirtualMemory
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!TryResolveRegion(virtualAddress, destination.Length, out var region, out var offset))
|
||||
if (!TryValidateRange(virtualAddress, destination.Length, ProgramHeaderFlags.Read, out var regionIndex))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
region.BackingMemory.AsSpan(offset, destination.Length).CopyTo(destination);
|
||||
CopyFromRegions(virtualAddress, destination, regionIndex);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -88,39 +87,127 @@ public sealed class VirtualMemory : IVirtualMemory
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!TryResolveRegion(virtualAddress, source.Length, out var region, out var offset))
|
||||
if (!TryValidateRange(virtualAddress, source.Length, ProgramHeaderFlags.Write, out var regionIndex))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
source.CopyTo(region.BackingMemory.AsSpan(offset, source.Length));
|
||||
CopyToRegions(virtualAddress, source, regionIndex);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryResolveRegion(ulong virtualAddress, int length, out MappedRegion region, out int offset)
|
||||
private bool TryValidateRange(
|
||||
ulong virtualAddress,
|
||||
int length,
|
||||
ProgramHeaderFlags requiredProtection,
|
||||
out int regionIndex)
|
||||
{
|
||||
foreach (var candidate in _regions)
|
||||
regionIndex = FindContainingRegionIndex(virtualAddress);
|
||||
if (regionIndex < 0)
|
||||
{
|
||||
if (virtualAddress < candidate.Region.VirtualAddress || virtualAddress >= candidate.EndAddress)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var candidateOffset = checked((int)(virtualAddress - candidate.Region.VirtualAddress));
|
||||
if (candidateOffset + length > candidate.BackingMemory.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
region = candidate;
|
||||
offset = candidateOffset;
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
region = default;
|
||||
offset = 0;
|
||||
return false;
|
||||
var currentAddress = virtualAddress;
|
||||
var remaining = length;
|
||||
var currentIndex = regionIndex;
|
||||
while (true)
|
||||
{
|
||||
if (currentIndex >= _regions.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var region = _regions[currentIndex];
|
||||
if (currentAddress < region.Region.VirtualAddress ||
|
||||
currentAddress >= region.EndAddress ||
|
||||
(region.Region.Protection & requiredProtection) == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (remaining == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var available = region.EndAddress - currentAddress;
|
||||
var chunkLength = (int)Math.Min((ulong)remaining, available);
|
||||
remaining -= chunkLength;
|
||||
if (remaining == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
currentAddress += (ulong)chunkLength;
|
||||
currentIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
private int FindContainingRegionIndex(ulong virtualAddress)
|
||||
{
|
||||
var insertionIndex = FindInsertionIndex(virtualAddress);
|
||||
if (insertionIndex < _regions.Count &&
|
||||
_regions[insertionIndex].Region.VirtualAddress == virtualAddress)
|
||||
{
|
||||
return insertionIndex;
|
||||
}
|
||||
|
||||
var candidateIndex = insertionIndex - 1;
|
||||
return candidateIndex >= 0 && virtualAddress < _regions[candidateIndex].EndAddress
|
||||
? candidateIndex
|
||||
: -1;
|
||||
}
|
||||
|
||||
private void CopyFromRegions(ulong virtualAddress, Span<byte> destination, int regionIndex)
|
||||
{
|
||||
var copied = 0;
|
||||
var currentAddress = virtualAddress;
|
||||
while (copied < destination.Length)
|
||||
{
|
||||
var region = _regions[regionIndex++];
|
||||
var regionOffset = checked((int)(currentAddress - region.Region.VirtualAddress));
|
||||
var chunkLength = Math.Min(destination.Length - copied, region.BackingMemory.Length - regionOffset);
|
||||
region.BackingMemory.AsSpan(regionOffset, chunkLength).CopyTo(destination[copied..]);
|
||||
copied += chunkLength;
|
||||
currentAddress += (ulong)chunkLength;
|
||||
}
|
||||
}
|
||||
|
||||
private void CopyToRegions(ulong virtualAddress, ReadOnlySpan<byte> source, int regionIndex)
|
||||
{
|
||||
var copied = 0;
|
||||
var currentAddress = virtualAddress;
|
||||
while (copied < source.Length)
|
||||
{
|
||||
var region = _regions[regionIndex++];
|
||||
var regionOffset = checked((int)(currentAddress - region.Region.VirtualAddress));
|
||||
var chunkLength = Math.Min(source.Length - copied, region.BackingMemory.Length - regionOffset);
|
||||
source.Slice(copied, chunkLength).CopyTo(region.BackingMemory.AsSpan(regionOffset, chunkLength));
|
||||
copied += chunkLength;
|
||||
currentAddress += (ulong)chunkLength;
|
||||
}
|
||||
}
|
||||
|
||||
private int FindInsertionIndex(ulong virtualAddress)
|
||||
{
|
||||
var lower = 0;
|
||||
var upper = _regions.Count;
|
||||
while (lower < upper)
|
||||
{
|
||||
var middle = lower + ((upper - lower) / 2);
|
||||
if (_regions[middle].Region.VirtualAddress < virtualAddress)
|
||||
{
|
||||
lower = middle + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
upper = middle;
|
||||
}
|
||||
}
|
||||
|
||||
return lower;
|
||||
}
|
||||
|
||||
private readonly record struct MappedRegion(VirtualMemoryRegion Region, ulong EndAddress, byte[] BackingMemory);
|
||||
|
||||
@@ -11,20 +11,18 @@ using SharpEmu.Libs.VideoOut;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using SharpEmu.Libs.AppContent;
|
||||
using SharpEmu.Libs.SaveData;
|
||||
using SharpEmu.Logging;
|
||||
using SharpEmu.Libs.Fiber;
|
||||
using SharpEmu.Libs.SystemService;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.IO;
|
||||
|
||||
namespace SharpEmu.Core.Runtime;
|
||||
|
||||
public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
{
|
||||
private static readonly SharpEmuLogger Log = SharpEmuLog.For("Runtime");
|
||||
|
||||
private readonly record struct LoadedModuleImage(string Path, SelfImage Image);
|
||||
private readonly record struct LoadedModuleImage(string Path, SelfImage Image, int Handle, bool StartAtBoot);
|
||||
|
||||
private static readonly HashSet<string> PreloadSkipModules = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -83,8 +81,9 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
ImportTraceLimit = Math.Max(0, options.ImportTraceLimit),
|
||||
};
|
||||
var moduleManager = new ModuleManager();
|
||||
moduleManager.RegisterFromAssembly(typeof(VideoOutExports).Assembly, Generation.Gen4 | Generation.Gen5, Aerolib.Instance);
|
||||
moduleManager.RegisterFromAssembly(typeof(KernelExports).Assembly, Generation.Gen4 | Generation.Gen5, Aerolib.Instance);
|
||||
// The compile-time generated registry (SharpEmu.SourceGenerators) is the sole
|
||||
// registration source; content tests in SharpEmu.Libs.Tests pin its invariants.
|
||||
moduleManager.RegisterExports(SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen4 | Generation.Gen5));
|
||||
moduleManager.Freeze();
|
||||
|
||||
var virtualMemory = new PhysicalVirtualMemory();
|
||||
@@ -132,20 +131,21 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
{
|
||||
var normalizedEbootPath = Path.GetFullPath(ebootPath);
|
||||
using var app0Binding = BindApp0Root(normalizedEbootPath);
|
||||
Log.Info($"Loading: {ebootPath}");
|
||||
Console.Error.WriteLine($"[RUNTIME] Loading: {ebootPath}");
|
||||
LastExecutionDiagnostics = null;
|
||||
LastExecutionTrace = null;
|
||||
LastSessionSummary = null;
|
||||
LastBasicBlockTrace = null;
|
||||
LastMilestoneLog = null;
|
||||
FiberExports.ResetRuntimeState();
|
||||
KernelModuleRegistry.Reset();
|
||||
var image = LoadImage(normalizedEbootPath);
|
||||
VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version);
|
||||
SaveDataExports.ConfigureApplicationInfo(image.TitleId);
|
||||
LogAppBundleInfo(normalizedEbootPath, image);
|
||||
RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false);
|
||||
SystemServiceExports.ConfigureApplicationInfo(image.TitleId);
|
||||
_ = RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false);
|
||||
KernelRuntimeCompatExports.ConfigureProcessProcParamAddress(image.ProcParamAddress);
|
||||
Log.Info($"Entry: 0x{image.EntryPoint:X16}");
|
||||
Console.Error.WriteLine($"[RUNTIME] Entry: 0x{image.EntryPoint:X16}");
|
||||
var generation = image.ElfHeader.AbiVersion == 2 ? Generation.Gen5 : Generation.Gen4;
|
||||
var activeImportStubs = new Dictionary<ulong, string>(image.ImportStubs);
|
||||
var activeRuntimeSymbols = new Dictionary<string, ulong>(image.RuntimeSymbols, StringComparer.Ordinal);
|
||||
@@ -168,7 +168,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
processImageName);
|
||||
if (initializerResult is { } failedInitializerResult)
|
||||
{
|
||||
Log.Error($"Initializer dispatch failed: {failedInitializerResult}");
|
||||
Console.Error.WriteLine($"[RUNTIME] Initializer dispatch failed: {failedInitializerResult}");
|
||||
LastExecutionTrace = _cpuDispatcher.LastImportResolutionTrace;
|
||||
LastMilestoneLog = _cpuDispatcher.LastMilestoneLog;
|
||||
LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary);
|
||||
@@ -176,8 +176,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
return failedInitializerResult;
|
||||
}
|
||||
|
||||
Log.Info($"Dispatching, gen: {generation}");
|
||||
Log.Debug($"About to call DispatchEntry with entryPoint=0x{image.EntryPoint:X16}");
|
||||
Console.Error.WriteLine($"[RUNTIME] Dispatching, gen: {generation}");
|
||||
Console.Error.WriteLine($"[RUNTIME] About to call DispatchEntry with entryPoint=0x{image.EntryPoint:X16}");
|
||||
|
||||
var result = _cpuDispatcher.DispatchEntry(
|
||||
image.EntryPoint,
|
||||
@@ -187,8 +187,18 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
processImageName,
|
||||
_cpuExecutionOptions);
|
||||
|
||||
Log.Info($"DispatchEntry returned: {result}");
|
||||
Log.Info($"Dispatch result: {result}");
|
||||
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);
|
||||
@@ -359,66 +369,6 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void LogAppBundleInfo(string ebootPath, SelfImage image)
|
||||
{
|
||||
var executableName = Path.GetFileName(ebootPath);
|
||||
if (string.IsNullOrWhiteSpace(executableName))
|
||||
{
|
||||
executableName = "eboot.bin";
|
||||
}
|
||||
|
||||
var displayName = string.IsNullOrWhiteSpace(image.Title) ? "(unknown)" : image.Title!.Trim();
|
||||
var titleId = string.IsNullOrWhiteSpace(image.TitleId) ? "(unknown)" : image.TitleId!.Trim();
|
||||
var version = string.IsNullOrWhiteSpace(image.Version) ? "(unknown)" : image.Version!.Trim();
|
||||
var contentId = ResolveContentId(Path.GetDirectoryName(ebootPath)) ?? "(unknown)";
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("App bundle info:");
|
||||
builder.AppendLine($"- Display name: {displayName}");
|
||||
builder.AppendLine($"- Version: {version}");
|
||||
builder.AppendLine($"- Title ID: {titleId}");
|
||||
builder.AppendLine($"- Content ID: {contentId}");
|
||||
builder.AppendLine($"- Executable: {executableName}");
|
||||
builder.Append("- Platform: PlayStation 5");
|
||||
Log.Info(builder.ToString());
|
||||
}
|
||||
|
||||
private static string? ResolveContentId(string? bundleRoot)
|
||||
{
|
||||
if (string.IsNullOrEmpty(bundleRoot))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var candidate in new[]
|
||||
{
|
||||
Path.Combine(bundleRoot, "sce_sys", "param.json"),
|
||||
Path.Combine(bundleRoot, "param.json"),
|
||||
})
|
||||
{
|
||||
if (!File.Exists(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllBytes(candidate));
|
||||
if (doc.RootElement.TryGetProperty("contentId", out var contentId))
|
||||
{
|
||||
return contentId.GetString();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or System.Text.Json.JsonException)
|
||||
{
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static App0BindingScope? BindApp0Root(string normalizedEbootPath)
|
||||
{
|
||||
const string app0VariableName = "SHARPEMU_APP0_DIR";
|
||||
@@ -433,6 +383,20 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dump tools commonly place decrypted executables in an app0/decrypted
|
||||
// sidecar while leaving Unity data in the parent app0 directory. Keep
|
||||
// loading code/modules beside the decrypted eboot, but mount /app0 at
|
||||
// the content-bearing parent so boot.config, metadata and assets resolve.
|
||||
if (string.Equals(Path.GetFileName(app0Root), "decrypted", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var parentRoot = Path.GetDirectoryName(app0Root);
|
||||
if (!string.IsNullOrWhiteSpace(parentRoot) &&
|
||||
Directory.Exists(Path.Combine(parentRoot, "Media")))
|
||||
{
|
||||
app0Root = parentRoot;
|
||||
}
|
||||
}
|
||||
|
||||
Environment.SetEnvironmentVariable(app0VariableName, app0Root);
|
||||
return new App0BindingScope(app0VariableName);
|
||||
}
|
||||
@@ -478,6 +442,56 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool TryGetEhFrameInfo(
|
||||
SelfImage image,
|
||||
ulong imageSize,
|
||||
out ulong ehFrameHeaderAddress,
|
||||
out ulong ehFrameAddress,
|
||||
out ulong ehFrameSize)
|
||||
{
|
||||
ehFrameHeaderAddress = 0;
|
||||
ehFrameAddress = 0;
|
||||
ehFrameSize = 0;
|
||||
var imageBase = image.EntryPoint >= image.ElfHeader.EntryPoint
|
||||
? image.EntryPoint - image.ElfHeader.EntryPoint
|
||||
: 0UL;
|
||||
for (var i = 0; i < image.ProgramHeaders.Count; i++)
|
||||
{
|
||||
var header = image.ProgramHeaders[i];
|
||||
if (header.HeaderType != ProgramHeaderType.GnuEhFrame || header.MemorySize < 8)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var headerAddress = imageBase + header.VirtualAddress;
|
||||
Span<byte> ehHeader = stackalloc byte[8];
|
||||
if (!_virtualMemory.TryRead(headerAddress, ehHeader) ||
|
||||
ehHeader[0] != 1 ||
|
||||
ehHeader[1] != 0x1B)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var relativeOffset = BinaryPrimitives.ReadInt32LittleEndian(ehHeader[4..]);
|
||||
ehFrameHeaderAddress = headerAddress;
|
||||
ehFrameAddress = unchecked((ulong)((long)headerAddress + 4 + relativeOffset));
|
||||
if (ehFrameAddress < 0x10000)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var relativeEhFrameAddress = ehFrameAddress - imageBase;
|
||||
ehFrameSize = header.VirtualAddress > relativeEhFrameAddress
|
||||
? header.VirtualAddress - relativeEhFrameAddress
|
||||
: imageSize > header.VirtualAddress
|
||||
? imageSize - header.VirtualAddress
|
||||
: 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private OrbisGen2Result? RunPreloadedModuleInitializers(
|
||||
IReadOnlyList<LoadedModuleImage> loadedModuleImages,
|
||||
Generation generation,
|
||||
@@ -487,20 +501,30 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
for (var i = 0; i < loadedModuleImages.Count; i++)
|
||||
{
|
||||
var loadedModule = loadedModuleImages[i];
|
||||
if (!loadedModule.StartAtBoot)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var initEntryPoint = loadedModule.Image.InitFunctionEntryPoint;
|
||||
if (initEntryPoint < 0x10000)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!KernelModuleRegistry.TryBeginModuleStart(loadedModule.Handle, out _))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var moduleName = Path.GetFileName(loadedModule.Path);
|
||||
if (string.IsNullOrWhiteSpace(moduleName))
|
||||
{
|
||||
moduleName = $"module#{i}";
|
||||
}
|
||||
|
||||
Log.Info(
|
||||
$"Starting module {moduleName}: dt_init=0x{initEntryPoint:X16}");
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Starting module {moduleName}: dt_init=0x{initEntryPoint:X16}");
|
||||
|
||||
var result = _cpuDispatcher.DispatchModuleInitializer(
|
||||
initEntryPoint,
|
||||
@@ -509,10 +533,13 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
activeRuntimeSymbols,
|
||||
moduleName,
|
||||
_cpuExecutionOptions);
|
||||
KernelModuleRegistry.CompleteModuleStart(
|
||||
loadedModule.Handle,
|
||||
result == OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
if (result != OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
{
|
||||
Log.Error(
|
||||
$"Module start failed: {moduleName} -> {result}");
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Module start failed: {moduleName} -> {result}");
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -533,8 +560,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
return null;
|
||||
}
|
||||
|
||||
Log.Info(
|
||||
$"Running initializers for {label}: preinit={image.PreInitializerFunctions.Count}, init={image.InitializerFunctions.Count}");
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Running initializers for {label}: preinit={image.PreInitializerFunctions.Count}, init={image.InitializerFunctions.Count}");
|
||||
|
||||
var result = RunInitializerList(
|
||||
$"{label}:preinit",
|
||||
@@ -573,8 +600,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
continue;
|
||||
}
|
||||
|
||||
Log.Debug(
|
||||
$" Initializer {label}[{i}] -> 0x{initializerAddress:X16}");
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Initializer {label}[{i}] -> 0x{initializerAddress:X16}");
|
||||
|
||||
var result = _cpuDispatcher.DispatchEntry(
|
||||
initializerAddress,
|
||||
@@ -606,12 +633,17 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
|
||||
var moduleDirectories = new[]
|
||||
{
|
||||
Path.Combine(ebootDirectory, "sce_module"),
|
||||
Path.Combine(ebootDirectory, "sce_modules"),
|
||||
Path.Combine(ebootDirectory, "Media", "Modules"),
|
||||
(Path: Path.Combine(ebootDirectory, "sce_module"), StartAtBoot: true),
|
||||
(Path: Path.Combine(ebootDirectory, "sce_modules"), StartAtBoot: true),
|
||||
(Path: Path.Combine(ebootDirectory, "Media", "Modules"), StartAtBoot: true),
|
||||
// Unity native plugins are loaded later through sceKernelLoadStartModule. Map
|
||||
// them up front so the HLE loader can return a real module handle and dlsym
|
||||
// can resolve their exports, but defer DT_INIT until the guest requests them.
|
||||
(Path: Path.Combine(ebootDirectory, "Media", "Plugins"), StartAtBoot: false),
|
||||
}
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Where(Directory.Exists)
|
||||
.GroupBy(entry => entry.Path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.First())
|
||||
.Where(entry => Directory.Exists(entry.Path))
|
||||
.ToArray();
|
||||
|
||||
if (moduleDirectories.Length == 0)
|
||||
@@ -621,28 +653,30 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
|
||||
var allModulePaths = moduleDirectories
|
||||
.SelectMany(directory => Directory
|
||||
.EnumerateFiles(directory)
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase))
|
||||
.Where(path =>
|
||||
.EnumerateFiles(directory.Path)
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(path => (Path: path, directory.StartAtBoot)))
|
||||
.Where(entry =>
|
||||
{
|
||||
var extension = Path.GetExtension(path);
|
||||
var extension = Path.GetExtension(entry.Path);
|
||||
return string.Equals(extension, ".prx", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(extension, ".sprx", StringComparison.OrdinalIgnoreCase);
|
||||
})
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.GroupBy(entry => entry.Path, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.First())
|
||||
.ToArray();
|
||||
|
||||
var modulePaths = allModulePaths
|
||||
.Where(ShouldPreloadModule)
|
||||
.Where(entry => ShouldPreloadModule(entry.Path))
|
||||
.ToArray();
|
||||
var skippedModules = allModulePaths
|
||||
.Where(path => !ShouldPreloadModule(path))
|
||||
.Select(Path.GetFileName)
|
||||
.Where(entry => !ShouldPreloadModule(entry.Path))
|
||||
.Select(entry => Path.GetFileName(entry.Path))
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.ToArray();
|
||||
if (skippedModules.Length > 0)
|
||||
{
|
||||
Log.Info($"Skipping {skippedModules.Length} core module(s): {string.Join(", ", skippedModules)}");
|
||||
Console.Error.WriteLine($"[RUNTIME] Skipping {skippedModules.Length} core module(s): {string.Join(", ", skippedModules)}");
|
||||
}
|
||||
|
||||
if (modulePaths.Length == 0)
|
||||
@@ -650,14 +684,15 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
return loadedImages;
|
||||
}
|
||||
|
||||
Log.Debug($"Module search directories: {string.Join(", ", moduleDirectories)}");
|
||||
Log.Info($"Loading {modulePaths.Length} module(s)...");
|
||||
Console.Error.WriteLine($"[RUNTIME] Module search directories: {string.Join(", ", moduleDirectories.Select(entry => entry.Path))}");
|
||||
Console.Error.WriteLine($"[RUNTIME] Loading {modulePaths.Length} module(s)...");
|
||||
var loadedModules = 0;
|
||||
var failedModules = 0;
|
||||
var mergedImportCount = 0;
|
||||
var mergedSymbolCount = 0;
|
||||
foreach (var modulePath in modulePaths)
|
||||
foreach (var moduleEntry in modulePaths)
|
||||
{
|
||||
var modulePath = moduleEntry.Path;
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(modulePath);
|
||||
@@ -682,25 +717,52 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
|
||||
mergedImportCount += MergeImportStubs(importStubs, moduleImage.ImportStubs, modulePath);
|
||||
mergedSymbolCount += MergeRuntimeSymbols(runtimeSymbols, moduleImage.RuntimeSymbols);
|
||||
RegisterLoadedModule(modulePath, moduleImage, isMain: false, isSystemModule: false);
|
||||
loadedImages.Add(new LoadedModuleImage(modulePath, moduleImage));
|
||||
InstallNativePluginCompatibilityHooks(importStubs, moduleImage, modulePath);
|
||||
var moduleHandle = RegisterLoadedModule(modulePath, moduleImage, isMain: false, isSystemModule: false);
|
||||
var moduleName = Path.GetFileName(modulePath);
|
||||
var startAtBoot = moduleEntry.StartAtBoot ||
|
||||
string.Equals(moduleName, "libfmod.prx", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(moduleName, "libfmodstudio.prx", StringComparison.OrdinalIgnoreCase);
|
||||
loadedImages.Add(new LoadedModuleImage(modulePath, moduleImage, moduleHandle, startAtBoot));
|
||||
loadedModules++;
|
||||
|
||||
Log.Info(
|
||||
$"Loaded module {Path.GetFileName(modulePath)}: entry=0x{moduleImage.EntryPoint:X16}, imports={moduleImage.ImportStubs.Count}, symbols={moduleImage.RuntimeSymbols.Count}");
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Loaded module {Path.GetFileName(modulePath)}: entry=0x{moduleImage.EntryPoint:X16}, imports={moduleImage.ImportStubs.Count}, symbols={moduleImage.RuntimeSymbols.Count}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failedModules++;
|
||||
Log.Error($"Module load failed: {modulePath} ({ex.GetType().Name}: {ex.Message})", ex);
|
||||
Console.Error.WriteLine($"[RUNTIME] Module load failed: {modulePath} ({ex.GetType().Name}: {ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
Log.Info(
|
||||
$"Module preload summary: loaded={loadedModules}, failed={failedModules}, merged_imports={mergedImportCount}, merged_symbols={mergedSymbolCount}");
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Module preload summary: loaded={loadedModules}, failed={failedModules}, merged_imports={mergedImportCount}, merged_symbols={mergedSymbolCount}");
|
||||
return loadedImages;
|
||||
}
|
||||
|
||||
private static void InstallNativePluginCompatibilityHooks(
|
||||
IDictionary<ulong, string> importStubs,
|
||||
SelfImage moduleImage,
|
||||
string modulePath)
|
||||
{
|
||||
if (!string.Equals(Path.GetFileName(modulePath), "libfmod.prx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ReadOnlySpan<string> compatibilityNids = ["uPLTdl3psGk"];
|
||||
foreach (var nid in compatibilityNids)
|
||||
{
|
||||
if (moduleImage.RuntimeSymbols.TryGetValue(nid, out var address) && address >= 0x10000)
|
||||
{
|
||||
importStubs[address] = nid;
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Installed FMOD compatibility hook: {nid} -> 0x{address:X16}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RebindImportedDataSymbols(
|
||||
SelfImage mainImage,
|
||||
IReadOnlyList<LoadedModuleImage> loadedModuleImages,
|
||||
@@ -717,8 +779,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
|
||||
if (rebound != 0 || unresolved != 0)
|
||||
{
|
||||
Log.Info(
|
||||
$"Imported data rebind: rebound={rebound}, unresolved={unresolved}");
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Imported data rebind: rebound={rebound}, unresolved={unresolved}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -750,8 +812,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
{
|
||||
if (logRebind)
|
||||
{
|
||||
Log.Warning(
|
||||
$"Imported data unresolved: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} addend=0x{unchecked((ulong)relocation.Addend):X16}");
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Imported data unresolved: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} addend=0x{unchecked((ulong)relocation.Addend):X16}");
|
||||
}
|
||||
|
||||
unresolved++;
|
||||
@@ -763,8 +825,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
{
|
||||
if (logRebind)
|
||||
{
|
||||
Log.Error(
|
||||
$"Imported data write-failed: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}");
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Imported data write-failed: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}");
|
||||
}
|
||||
|
||||
unresolved++;
|
||||
@@ -773,8 +835,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
|
||||
if (logRebind)
|
||||
{
|
||||
Log.Debug(
|
||||
$"Imported data rebound: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}");
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Imported data rebound: nid={relocation.Nid} target=0x{relocation.TargetAddress:X16} value=0x{reboundValue:X16}");
|
||||
}
|
||||
|
||||
rebound++;
|
||||
@@ -810,8 +872,8 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
{
|
||||
if (!string.Equals(existingNid, nid, StringComparison.Ordinal))
|
||||
{
|
||||
Log.Warning(
|
||||
$"Import stub conflict at 0x{address:X16}: keep={existingNid}, skip={nid} ({Path.GetFileName(modulePath)})");
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Import stub conflict at 0x{address:X16}: keep={existingNid}, skip={nid} ({Path.GetFileName(modulePath)})");
|
||||
}
|
||||
|
||||
continue;
|
||||
@@ -905,7 +967,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
return !PreloadSkipModules.Contains(fileName);
|
||||
}
|
||||
|
||||
private static void RegisterLoadedModule(string modulePath, SelfImage image, bool isMain, bool isSystemModule)
|
||||
private int RegisterLoadedModule(string modulePath, SelfImage image, bool isMain, bool isSystemModule)
|
||||
{
|
||||
if (!TryComputeImageRange(image, out var baseAddress, out var size))
|
||||
{
|
||||
@@ -913,15 +975,27 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
size = 0;
|
||||
}
|
||||
|
||||
_ = TryGetEhFrameInfo(
|
||||
image,
|
||||
size,
|
||||
out var ehFrameHeaderAddress,
|
||||
out var ehFrameAddress,
|
||||
out var ehFrameSize);
|
||||
var handle = KernelModuleRegistry.RegisterModule(
|
||||
modulePath,
|
||||
baseAddress,
|
||||
size,
|
||||
image.EntryPoint,
|
||||
image.InitFunctionEntryPoint,
|
||||
ehFrameHeaderAddress,
|
||||
ehFrameAddress,
|
||||
ehFrameSize,
|
||||
isMain,
|
||||
isSystemModule);
|
||||
Log.Info(
|
||||
$"Registered module handle={handle} name={Path.GetFileName(modulePath)} base=0x{baseAddress:X16} size=0x{size:X16}");
|
||||
KernelModuleRegistry.RegisterModuleSymbols(handle, image.RuntimeSymbols);
|
||||
Console.Error.WriteLine(
|
||||
$"[RUNTIME] Registered module handle={handle} name={Path.GetFileName(modulePath)} base=0x{baseAddress:X16} size=0x{size:X16}");
|
||||
return handle;
|
||||
}
|
||||
|
||||
private static bool TryComputeImageRange(SelfImage image, out ulong baseAddress, out ulong size)
|
||||
@@ -1088,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();
|
||||
|
||||
@@ -14,6 +14,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<PackageReference Include="Iced" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="SharpEmu.Libs.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Iced": {
|
||||
"type": "Direct",
|
||||
"requested": "[1.21.0, )",
|
||||
"resolved": "1.21.0",
|
||||
"contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg=="
|
||||
},
|
||||
"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.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"
|
||||
}
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
},
|
||||
"sharpemu.hle": {
|
||||
"type": "Project"
|
||||
},
|
||||
"sharpemu.libs": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SharpEmu.HLE": "[1.0.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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
@@ -127,6 +144,34 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Top-level page switcher (Library / Options): plain transparent
|
||||
buttons, not TabItem, so there is no Fluent selected-tab underline.
|
||||
The active page is conveyed by brightness alone; LB/RB gamepad
|
||||
hints flank the pair. -->
|
||||
<Style Selector="Button.segment">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Foreground" Value="{StaticResource MutedBrush}" />
|
||||
<Setter Property="FontSize" Value="22" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="Padding" Value="6,4" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
</Style>
|
||||
<Style Selector="Button.segment:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.segment.active">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Gamepad shoulder-button hint chip (LB/RB, L1/R1). -->
|
||||
<Style Selector="Border.padHint">
|
||||
<Setter Property="Background" Value="{StaticResource ElevatedBrush}" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Padding" Value="8,3" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ContextMenu">
|
||||
<Setter Property="Background" Value="{StaticResource CardBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource CardBorderBrush}" />
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using Avalonia;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using System.Collections.Specialized;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
public sealed class ConsoleWindow : Window
|
||||
{
|
||||
private readonly AvaloniaList<LogLine> _sourceLines;
|
||||
private readonly AvaloniaList<LogLine> _visibleLines = new();
|
||||
private readonly ListBox _list;
|
||||
private readonly TextBox _searchBox;
|
||||
private readonly CheckBox _autoScrollCheck;
|
||||
|
||||
public ConsoleWindow(
|
||||
AvaloniaList<LogLine> lines,
|
||||
Action clear,
|
||||
bool autoScroll)
|
||||
{
|
||||
var loc = Localization.Instance;
|
||||
|
||||
_sourceLines = lines;
|
||||
Title = loc.Get("Console.WindowTitle");
|
||||
Width = 980;
|
||||
Height = 620;
|
||||
MinWidth = 520;
|
||||
MinHeight = 320;
|
||||
Background = new SolidColorBrush(Color.Parse("#0D1017"));
|
||||
Icon = new WindowIcon(AssetLoader.Open(new Uri("avares://SharpEmu.GUI/Assets/SharpEmu.ico")));
|
||||
|
||||
_searchBox = new TextBox
|
||||
{
|
||||
Watermark = loc.Get("Console.SearchWatermark"),
|
||||
Width = 320,
|
||||
Margin = new Thickness(0, 0, 12, 0),
|
||||
};
|
||||
_autoScrollCheck = new CheckBox
|
||||
{
|
||||
Content = loc.Get("Console.AutoScroll"),
|
||||
IsChecked = autoScroll,
|
||||
FontSize = 12,
|
||||
Margin = new Thickness(0, 0, 12, 0),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
var copyButton = new Button
|
||||
{
|
||||
Classes = { "ghost" },
|
||||
Content = loc.Get("Console.Copy"),
|
||||
Padding = new Thickness(10, 4),
|
||||
Margin = new Thickness(0, 0, 8, 0),
|
||||
};
|
||||
var clearButton = new Button
|
||||
{
|
||||
Classes = { "ghost" },
|
||||
Content = loc.Get("Console.Clear"),
|
||||
Padding = new Thickness(10, 4),
|
||||
};
|
||||
copyButton.Click += async (_, _) => await CopyAsync();
|
||||
clearButton.Click += (_, _) => clear();
|
||||
_searchBox.TextChanged += (_, _) => RefreshVisibleLines();
|
||||
|
||||
_list = new ListBox
|
||||
{
|
||||
Classes = { "console" },
|
||||
ItemsSource = _visibleLines,
|
||||
BorderThickness = new Thickness(1),
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#232B3A")),
|
||||
ItemTemplate = new FuncDataTemplate<LogLine>((_, _) =>
|
||||
{
|
||||
var text = new TextBlock { TextWrapping = TextWrapping.NoWrap };
|
||||
text.Bind(TextBlock.TextProperty, new Binding(nameof(LogLine.Text)));
|
||||
text.Bind(TextBlock.ForegroundProperty, new Binding(nameof(LogLine.Brush)));
|
||||
return text;
|
||||
}),
|
||||
};
|
||||
|
||||
Content = new Grid
|
||||
{
|
||||
Margin = new Thickness(12),
|
||||
RowDefinitions = new RowDefinitions("Auto,*"),
|
||||
Children =
|
||||
{
|
||||
new Grid
|
||||
{
|
||||
Margin = new Thickness(0, 0, 0, 8),
|
||||
ColumnDefinitions = new ColumnDefinitions("*,Auto,Auto,Auto,Auto"),
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Classes = { "sectionTitle" },
|
||||
Text = loc.Get("Console.Title"),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
},
|
||||
_searchBox.WithGridColumn(1),
|
||||
_autoScrollCheck.WithGridColumn(2),
|
||||
copyButton.WithGridColumn(3),
|
||||
clearButton.WithGridColumn(4),
|
||||
},
|
||||
},
|
||||
_list.WithGridRow(1),
|
||||
},
|
||||
};
|
||||
|
||||
lines.CollectionChanged += OnLinesChanged;
|
||||
Closed += (_, _) => lines.CollectionChanged -= OnLinesChanged;
|
||||
RefreshVisibleLines();
|
||||
}
|
||||
|
||||
private void OnLinesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
RefreshVisibleLines();
|
||||
if (_autoScrollCheck.IsChecked == true)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => (_list.Scroll as ScrollViewer)?.ScrollToEnd());
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshVisibleLines()
|
||||
{
|
||||
var query = _searchBox.Text ?? string.Empty;
|
||||
_visibleLines.Clear();
|
||||
_visibleLines.AddRange(string.IsNullOrWhiteSpace(query)
|
||||
? _sourceLines
|
||||
: _sourceLines.Where(line => line.Text.Contains(query, StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
|
||||
private async Task CopyAsync()
|
||||
{
|
||||
if (_visibleLines.Count == 0 || Clipboard is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Clipboard.SetTextAsync(string.Join(Environment.NewLine, _visibleLines.Select(line => line.Text)));
|
||||
}
|
||||
}
|
||||
|
||||
file static class GridExtensions
|
||||
{
|
||||
public static T WithGridColumn<T>(this T control, int column) where T : Control
|
||||
{
|
||||
Grid.SetColumn(control, column);
|
||||
return control;
|
||||
}
|
||||
|
||||
public static T WithGridRow<T>(this T control, int row) where T : Control
|
||||
{
|
||||
Grid.SetRow(control, row);
|
||||
return control;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal Discord Rich Presence client. Speaks Discord's local IPC
|
||||
/// protocol (framed JSON over the discord-ipc-N named pipe) directly, so no
|
||||
/// external dependency is needed. All failures are silent: no Discord, no
|
||||
/// presence, no impact on the launcher.
|
||||
/// </summary>
|
||||
public sealed class DiscordRichPresence : IDisposable
|
||||
{
|
||||
private const int OpHandshake = 0;
|
||||
private const int OpFrame = 1;
|
||||
private const int MaxFrameBytes = 64 * 1024;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly string _clientId;
|
||||
private readonly AutoResetEvent _signal = new(false);
|
||||
private readonly Thread _worker;
|
||||
|
||||
private NamedPipeClientStream? _pipe;
|
||||
private PresenceState? _desired;
|
||||
private PresenceState? _sent;
|
||||
private volatile bool _disposed;
|
||||
|
||||
private sealed record PresenceState(string Details, string? State, long? StartUnixSeconds);
|
||||
|
||||
public DiscordRichPresence(string clientId)
|
||||
{
|
||||
_clientId = clientId;
|
||||
_worker = new Thread(WorkerLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "DiscordRichPresence",
|
||||
};
|
||||
_worker.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests the given presence; the worker applies it asynchronously and
|
||||
/// retries (with reconnection) until it is delivered or replaced.
|
||||
/// </summary>
|
||||
public void SetPresence(string details, string? state = null, long? startUnixSeconds = null)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_desired = new PresenceState(details, state, startUnixSeconds);
|
||||
}
|
||||
|
||||
Trace($"queued details=\"{details}\" state=\"{state}\"");
|
||||
_signal.Set();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
_signal.Set();
|
||||
ClosePipe();
|
||||
}
|
||||
|
||||
private void WorkerLoop()
|
||||
{
|
||||
while (!_disposed)
|
||||
{
|
||||
// Woken immediately on presence changes; the periodic tick
|
||||
// retries after connection failures (e.g. Discord starts later).
|
||||
_signal.WaitOne(TimeSpan.FromSeconds(15));
|
||||
if (_disposed || _clientId.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
PresenceState? desired;
|
||||
lock (_gate)
|
||||
{
|
||||
desired = _desired;
|
||||
}
|
||||
|
||||
if (desired is null || desired == _sent)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
EnsureConnected();
|
||||
SendActivity(desired);
|
||||
_sent = desired;
|
||||
Trace($"sent details=\"{desired.Details}\" state=\"{desired.State}\"");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Connection died (or never existed); reconnect on a later tick.
|
||||
Trace($"send failed: {exception.Message}");
|
||||
ClosePipe();
|
||||
_sent = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
if (_pipe is { IsConnected: true })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ClosePipe();
|
||||
Exception? lastError = null;
|
||||
for (var index = 0; index < 10; index++)
|
||||
{
|
||||
var pipe = new NamedPipeClientStream(
|
||||
".",
|
||||
$"discord-ipc-{index}",
|
||||
PipeDirection.InOut,
|
||||
PipeOptions.Asynchronous);
|
||||
try
|
||||
{
|
||||
pipe.Connect(500);
|
||||
_pipe = pipe;
|
||||
WriteFrame(OpHandshake, JsonSerializer.Serialize(new
|
||||
{
|
||||
v = 1,
|
||||
client_id = _clientId,
|
||||
}));
|
||||
|
||||
// Discord replies with a READY (or error) frame; consume it
|
||||
// so the pipe buffer stays drained.
|
||||
_ = ReadFrame();
|
||||
return;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
lastError = exception;
|
||||
pipe.Dispose();
|
||||
_pipe = null;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new IOException("Discord IPC pipe not found.");
|
||||
}
|
||||
|
||||
private void SendActivity(PresenceState presence)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["cmd"] = "SET_ACTIVITY",
|
||||
["nonce"] = Guid.NewGuid().ToString(),
|
||||
["args"] = new Dictionary<string, object?>
|
||||
{
|
||||
["pid"] = Environment.ProcessId,
|
||||
["activity"] = new Dictionary<string, object?>
|
||||
{
|
||||
["details"] = presence.Details,
|
||||
["state"] = presence.State,
|
||||
["timestamps"] = presence.StartUnixSeconds is { } start
|
||||
? new Dictionary<string, object?> { ["start"] = start }
|
||||
: null,
|
||||
["assets"] = new Dictionary<string, object?>
|
||||
{
|
||||
["large_image"] = "logo",
|
||||
["large_text"] = "SharpEmu",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
WriteFrame(OpFrame, JsonSerializer.Serialize(payload, JsonOptions));
|
||||
|
||||
// Consume Discord's acknowledgement to keep the pipe drained.
|
||||
_ = ReadFrame();
|
||||
}
|
||||
|
||||
private void WriteFrame(int opcode, string json)
|
||||
{
|
||||
var pipe = _pipe ?? throw new IOException("Discord IPC pipe is not connected.");
|
||||
var payload = Encoding.UTF8.GetBytes(json);
|
||||
var frame = new byte[8 + payload.Length];
|
||||
BitConverter.TryWriteBytes(frame.AsSpan(0, 4), opcode);
|
||||
BitConverter.TryWriteBytes(frame.AsSpan(4, 4), payload.Length);
|
||||
payload.CopyTo(frame, 8);
|
||||
pipe.Write(frame, 0, frame.Length);
|
||||
pipe.Flush();
|
||||
}
|
||||
|
||||
private string ReadFrame()
|
||||
{
|
||||
var pipe = _pipe ?? throw new IOException("Discord IPC pipe is not connected.");
|
||||
var header = ReadExactly(pipe, 8);
|
||||
var length = BitConverter.ToInt32(header, 4);
|
||||
if (length is < 0 or > MaxFrameBytes)
|
||||
{
|
||||
throw new IOException($"Discord IPC frame length {length} is out of range.");
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(ReadExactly(pipe, length));
|
||||
}
|
||||
|
||||
private static byte[] ReadExactly(NamedPipeClientStream pipe, int count)
|
||||
{
|
||||
var buffer = new byte[count];
|
||||
var read = 0;
|
||||
while (read < count)
|
||||
{
|
||||
var chunk = pipe.Read(buffer, read, count - read);
|
||||
if (chunk <= 0)
|
||||
{
|
||||
throw new IOException("Discord IPC pipe closed.");
|
||||
}
|
||||
|
||||
read += chunk;
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
private static void Trace(string message)
|
||||
{
|
||||
if (string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_LOG_DISCORD"),
|
||||
"1",
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
Console.Error.WriteLine($"[DISCORD] {message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void ClosePipe()
|
||||
{
|
||||
var pipe = _pipe;
|
||||
_pipe = null;
|
||||
try
|
||||
{
|
||||
pipe?.Dispose();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Best effort; the pipe may already be broken.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,20 +93,35 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
if (_processHandle != 0)
|
||||
{
|
||||
_ = TerminateProcess(_processHandle, 1);
|
||||
}
|
||||
_stopRequested = true;
|
||||
processHandle = _processHandle;
|
||||
jobHandle = _jobHandle;
|
||||
fallbackProcess = _fallbackProcess;
|
||||
}
|
||||
|
||||
try
|
||||
if (jobHandle != 0)
|
||||
{
|
||||
_ = TerminateJobObject(jobHandle, unchecked((uint)HostStopExitCode));
|
||||
return;
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -116,141 +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 |
|
||||
PROCESS_CREATION_MITIGATION_POLICY2_XTENDED_CONTROL_FLOW_GUARD_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))
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to apply the mitigation policy.");
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (!created)
|
||||
{
|
||||
// Some mitigation policy bits (e.g. XFG) are not supported on
|
||||
// older Windows builds. Mirror the CLI's behavior and fall back
|
||||
// to launching without the mitigation attribute list.
|
||||
startupInfoEx.lpAttributeList = 0;
|
||||
created = CreateProcessW(
|
||||
exePath,
|
||||
new StringBuilder(BuildCommandLine(exePath, arguments)),
|
||||
attributeList,
|
||||
0,
|
||||
(nint)ProcThreadAttributeMitigationPolicy,
|
||||
mitigationPolicies,
|
||||
(nuint)(sizeof(ulong) * 2),
|
||||
0,
|
||||
true,
|
||||
CREATE_NO_WINDOW,
|
||||
0,
|
||||
currentDirectory,
|
||||
ref startupInfoEx,
|
||||
out processInfo);
|
||||
}
|
||||
|
||||
if (!created)
|
||||
0))
|
||||
{
|
||||
var error = Marshal.GetLastWin32Error();
|
||||
throw new Win32Exception(error, $"Failed to start '{exePath}' (Win32 error {error}: {new Win32Exception(error).Message}).");
|
||||
throw new InvalidOperationException($"Could not apply process mitigations (Win32 error {Marshal.GetLastWin32Error()}).");
|
||||
}
|
||||
|
||||
CloseHandle(processInfo.hThread);
|
||||
_processHandle = processInfo.hProcess;
|
||||
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;
|
||||
|
||||
_jobHandle = CreateJobObjectW(0, null);
|
||||
if (_jobHandle != 0 &&
|
||||
(!TryEnableKillOnJobClose(_jobHandle) || !AssignProcessToJobObject(_jobHandle, processInfo.hProcess)))
|
||||
var childArguments = new List<string>(arguments.Count + 1) { MitigatedChildFlag };
|
||||
childArguments.AddRange(arguments);
|
||||
var commandLine = new StringBuilder(BuildCommandLine(exePath, childArguments));
|
||||
ProcessInformation processInfo;
|
||||
lock (EnvironmentGate)
|
||||
{
|
||||
CloseHandle(_jobHandle);
|
||||
_jobHandle = 0;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
StartReaderThread(stdoutRead, isError: false);
|
||||
StartReaderThread(stderrRead, isError: true);
|
||||
StartExitWatcherThread();
|
||||
processHandle = processInfo.Process;
|
||||
threadHandle = processInfo.Thread;
|
||||
CloseHandle(stdoutWrite);
|
||||
stdoutWrite = 0;
|
||||
CloseHandle(stderrWrite);
|
||||
stderrWrite = 0;
|
||||
|
||||
var jobHandle = CreateJobObjectW(0, null);
|
||||
if (jobHandle != 0 &&
|
||||
(!TryEnableKillOnJobClose(jobHandle) || !AssignProcessToJobObject(jobHandle, processHandle)))
|
||||
{
|
||||
CloseHandle(jobHandle);
|
||||
jobHandle = 0;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -258,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)
|
||||
@@ -362,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);
|
||||
@@ -384,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
|
||||
{
|
||||
@@ -409,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;
|
||||
@@ -544,7 +576,7 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct IO_COUNTERS
|
||||
private struct IoCounters
|
||||
{
|
||||
public ulong ReadOperationCount;
|
||||
public ulong WriteOperationCount;
|
||||
@@ -555,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;
|
||||
@@ -567,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);
|
||||
@@ -639,6 +642,10 @@ internal sealed class EmulatorProcess : IDisposable
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool TerminateProcess(nint process, uint exitCode);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool TerminateJobObject(nint job, uint exitCode);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CloseHandle(nint handle);
|
||||
|
||||
@@ -29,10 +29,12 @@ public sealed class GameEntry : INotifyPropertyChanged
|
||||
private long _sizeBytes;
|
||||
|
||||
public GameEntry(
|
||||
string name, string? titleId, string path, long sizeBytes, string? coverPath, string? backgroundPath)
|
||||
string name, string? titleId, string? version, string path, long sizeBytes,
|
||||
string? coverPath, string? backgroundPath)
|
||||
{
|
||||
Name = name;
|
||||
TitleId = titleId;
|
||||
Version = version;
|
||||
Path = path;
|
||||
_sizeBytes = sizeBytes;
|
||||
CoverPath = coverPath;
|
||||
@@ -46,6 +48,9 @@ public sealed class GameEntry : INotifyPropertyChanged
|
||||
|
||||
public string? TitleId { get; }
|
||||
|
||||
/// <summary>Content version from sce_sys/param.json, e.g. "01.000.000".</summary>
|
||||
public string? Version { get; }
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
/// <summary>
|
||||
@@ -65,7 +70,7 @@ public sealed class GameEntry : INotifyPropertyChanged
|
||||
|
||||
_sizeBytes = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SizeBytes)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Detail)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SizeText)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,9 +111,15 @@ public sealed class GameEntry : INotifyPropertyChanged
|
||||
|
||||
public bool HasCover => _cover is not null;
|
||||
|
||||
public string Detail => TitleId is not null
|
||||
? $"{TitleId} • {FormatSize(SizeBytes)}"
|
||||
: FormatSize(SizeBytes);
|
||||
public bool HasTitleId => TitleId is not null;
|
||||
|
||||
/// <summary>Badge text shown in the launch bar, e.g. "v01.000.000".</summary>
|
||||
public string? VersionText => Version is null ? null : $"v{Version}";
|
||||
|
||||
public bool HasVersion => Version is not null;
|
||||
|
||||
/// <summary>Formatted install size badge shown in the launch bar.</summary>
|
||||
public string SizeText => FormatSize(SizeBytes);
|
||||
|
||||
private static string ComputeInitials(string name)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,14 +23,43 @@ public sealed class GuiSettings
|
||||
|
||||
public bool StrictDynlibResolution { get; set; }
|
||||
|
||||
/// <summary>Mirror emulator output to user/logs/<titleId>-<timestamp>.log.</summary>
|
||||
/// <summary>
|
||||
/// Mirror emulator output to user/logs/<titleId>-<timestamp>.log, if <see cref="LogFilePath"/> is null.
|
||||
/// </summary>
|
||||
public bool LogToFile { get; set; }
|
||||
|
||||
/// <summary>If <see cref="LogToFile"/> is true it logs to this file path.</summary>
|
||||
public string? LogFilePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If <see cref="OverrideLogFile"/> is false it appends <titleId>-<timestamp> to the filename specified by
|
||||
/// <see cref="LogFilePath"/>. Otherwise it uses the exact filename from <see cref="LogFilePath"/>
|
||||
/// </summary>
|
||||
public bool OverrideLogFile { get; set; }
|
||||
|
||||
/// <summary>Loop the selected game's sce_sys/snd0.at9 preview music.</summary>
|
||||
public bool PlayTitleMusic { get; set; } = true;
|
||||
|
||||
public string? EmulatorPath { get; set; }
|
||||
|
||||
/// <summary>UI language, matching a file code under Languages/ (e.g. "en", "tr").</summary>
|
||||
public string Language { get; set; } = "en";
|
||||
|
||||
/// <summary>Publish launcher/game status to Discord Rich Presence.</summary>
|
||||
public bool DiscordRichPresence { get; set; } = true;
|
||||
|
||||
public bool CheckForUpdatesOnStartup { get; set; } = true;
|
||||
|
||||
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
|
||||
public List<string> EnvironmentToggles { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Discord application ID used for Rich Presence; the default is the
|
||||
/// SharpEmu application. Override to rebrand what Discord shows as
|
||||
/// "Playing …" (register at discord.com/developers/applications).
|
||||
/// </summary>
|
||||
public string DiscordClientId { get; set; } = "1525606762248540221";
|
||||
|
||||
// The emulator is portable and keeps its data next to the executable;
|
||||
// the GUI follows the same convention.
|
||||
public static string SettingsPath => Path.Combine(AppContext.BaseDirectory, "gui-settings.json");
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"_languageName": "العربية",
|
||||
|
||||
"Page.Library": "المكتبة",
|
||||
"Page.Options": "الخيارات",
|
||||
"Page.GameCount.One": "لعبة واحدة",
|
||||
"Page.GameCount.Other": "{0} لعبة",
|
||||
|
||||
"Library.SearchWatermark": "ابحث في المكتبة...",
|
||||
"Library.AddFolder": "+ إضافة مجلد",
|
||||
"Library.Rescan": "⟳ إعادة الفحص",
|
||||
"Library.OpenFile": "فتح ملف...",
|
||||
|
||||
"Library.Context.Launch": "تشغيل",
|
||||
"Library.Context.OpenFolder": "فتح مجلد اللعبة",
|
||||
"Library.Context.CopyPath": "نسخ المسار",
|
||||
"Library.Context.CopyTitleId": "نسخ معرف العنوان",
|
||||
"Library.Context.Remove": "إزالة من المكتبة",
|
||||
|
||||
"Library.Empty.Title": "مكتبتك فارغة",
|
||||
"Library.Empty.Hint": "أضف مجلداً يحتوي على ألعابك للبدء.",
|
||||
"Library.Empty.SearchTitle": "لا توجد ألعاب تطابق بحثك",
|
||||
"Library.Empty.SearchHint": "لا شيء في المكتبة يطابق “{0}”.",
|
||||
"Library.Empty.AddFolder": "+ إضافة مجلد ألعاب",
|
||||
|
||||
"Library.Loading": "جارٍ تحميل المكتبة...",
|
||||
|
||||
"Options.General": "عام",
|
||||
"Options.Section.Emulation": "المحاكاة",
|
||||
"Options.Section.Logging": "التسجيل",
|
||||
"Options.Section.Launcher": "المُشغِّل",
|
||||
|
||||
"Options.CpuEngine.Label": "محرك المعالج",
|
||||
"Options.CpuEngine.Desc": "محرك التنفيذ المستخدم لتشغيل كود اللعبة.",
|
||||
"Options.CpuEngine.Native": "أصلي",
|
||||
|
||||
"Options.Strict.Label": "ربط صارم للمكتبات الديناميكية (dynlib)",
|
||||
"Options.Strict.Desc": "إفشال التشغيل عندما يتعذر التعرف على رمز مستورد.",
|
||||
|
||||
"Options.LogLevel.Label": "مستوى التسجيل",
|
||||
"Options.LogLevel.Desc": "مدى تفصيل مخرجات نافذة سجلات المحاكي.",
|
||||
"Options.LogLevel.Trace": "تتبع",
|
||||
"Options.LogLevel.Debug": "تصحيح الأخطاء",
|
||||
"Options.LogLevel.Info": "معلومات",
|
||||
"Options.LogLevel.Warning": "تحذير",
|
||||
"Options.LogLevel.Error": "خطأ",
|
||||
"Options.LogLevel.Critical": "حرج",
|
||||
|
||||
"Options.TraceImports.Label": "حد تتبع الاستيراد",
|
||||
"Options.TraceImports.Desc": "تتبع أول N استيراد لكل وحدة (0 = إيقاف).",
|
||||
|
||||
"Options.LogToFile.Label": "التسجيل في ملف",
|
||||
"Options.LogToFile.Desc": "نسخ مخرجات المحاكي إلى ملف سجل.",
|
||||
|
||||
"Options.LogFilePath.Label": "مسار ملف السجل",
|
||||
"Options.LogFilePath.Default": "لا يوجد مسار مخصص — تُحفظ السجلات في user/logs بجوار المحاكي.",
|
||||
"Options.LogFilePath.Select": "تحديد...",
|
||||
|
||||
"Options.OverrideLogFile.Label": "الكتابة فوق ملف السجل",
|
||||
"Options.OverrideLogFile.Desc": "استخدام مسار الملف الدقيق بدلاً من إلحاق معرف العنوان والطابع الزمني.",
|
||||
|
||||
"Options.TitleMusic.Label": "موسيقى اللعبة",
|
||||
"Options.TitleMusic.Desc": "تكرار موسيقى المعاينة للعبة المحددة في المكتبة.",
|
||||
|
||||
"Options.Discord.Label": "حالة دسكورد",
|
||||
"Options.Discord.Desc": "إظهار اللعبة قيد التشغيل في ملفك الشخصي على دسكورد.",
|
||||
|
||||
"Options.Language.Label": "لغة المحاكي",
|
||||
"Options.Language.Desc": "اللغة المستخدمة في جميع أنحاء المشغل. تُطبق فوراً.",
|
||||
|
||||
"Common.On": "تشغيل",
|
||||
"Common.Off": "إيقاف",
|
||||
|
||||
"Console.Title": "نافذة السجلات",
|
||||
"Console.SearchWatermark": "بحث...",
|
||||
"Console.AutoScroll": "تمرير تلقائي",
|
||||
"Console.Split": "تقسيم",
|
||||
"Console.Copy": "نسخ",
|
||||
"Console.Clear": "مسح",
|
||||
"Console.WindowTitle": "نافذة سجلات SharpEmu",
|
||||
|
||||
"Launch.NoGameSelected": "لم تُحدد أي لعبة",
|
||||
"Launch.NoGameHint": "اختر لعبة من المكتبة، أو افتح ملف eboot.bin مباشرة.",
|
||||
"Launch.Idle": "خامل",
|
||||
"Launch.Console": "≡ نافذة السجلات",
|
||||
"Launch.Launch": "▶ تشغيل",
|
||||
"Launch.Stop": "■ إيقاف",
|
||||
"Launch.Running": "قيد التشغيل — {0}",
|
||||
"Launch.Stopping": "جارٍ الإيقاف...",
|
||||
"Launch.Exited": "انتهى برمز {0} ({1})",
|
||||
"Launch.ExeNotFound": "لم يُعثر على الملف التنفيذي SharpEmu. ابنِ مشروع SharpEmu.CLI أولاً (dotnet build).",
|
||||
"Launch.LogFile": "ملف السجل: {0}",
|
||||
"Launch.Command": "$ SharpEmu {0}",
|
||||
"Launch.StartFailed": "فشل بدء تشغيل المحاكي: {0}",
|
||||
"Launch.ProcessExited": "انتهت العملية برمز {0} ({1}).",
|
||||
|
||||
"Exit.Ok": "موافق",
|
||||
"Exit.InvalidArguments": "معطيات غير صالحة",
|
||||
"Exit.EbootNotFound": "لم يُعثر على eboot",
|
||||
"Exit.RuntimeException": "استثناء وقت التشغيل",
|
||||
"Exit.EmulationError": "خطأ في المحاكاة",
|
||||
"Exit.Unknown": "غير معروف",
|
||||
|
||||
"Status.EmulatorLocating": "المحاكي: جارٍ تحديد الموقع...",
|
||||
"Status.EmulatorPath": "المحاكي: {0}",
|
||||
"Status.EmulatorNotFound": "المحاكي: لم يُعثر على الملف التنفيذي SharpEmu — ابنِ SharpEmu.CLI أولاً.",
|
||||
"Status.ScanningLibrary": "جارٍ فحص المكتبة...",
|
||||
"Status.AddFolderPrompt": "أضف مجلد ألعاب لملء المكتبة.",
|
||||
"Status.LibraryScanned": "فُحصت المكتبة: {0} لعبة في {1} مجلد.",
|
||||
"Status.CouldNotOpenFolder": "تعذر فتح المجلد: {0}",
|
||||
"Status.CopiedToClipboard": "نُسخ {0} إلى الحافظة.",
|
||||
"Status.RemovedFromLibrary": "أُزيل “{0}” من المكتبة. أعد إضافة مجلده لاستعادته.",
|
||||
"Status.Running": "جارٍ تشغيل {0}",
|
||||
"Status.Stopping": "جارٍ الإيقاف...",
|
||||
"Status.Idle": "خامل",
|
||||
|
||||
"Clipboard.Path": "المسار",
|
||||
"Clipboard.TitleId": "معرف العنوان",
|
||||
|
||||
"Discord.Playing": "يلعب {0}",
|
||||
"Discord.Browsing": "يتصفح المكتبة",
|
||||
|
||||
"Dialog.ChooseGameFolder": "اختر مجلداً يحتوي على ألعاب",
|
||||
"Dialog.OpenExecutable": "افتح ملفاً تنفيذياً لتشغيله",
|
||||
"Dialog.PsExecutables": "ملفات PS التنفيذية",
|
||||
"Dialog.SaveLogFile": "حدد مكان حفظ ملف السجل",
|
||||
"Dialog.PlainTextFiles": "ملفات نصية عادية",
|
||||
"Dialog.LogFiles": "ملفات السجل"
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"_languageName": "Português (Brasil)",
|
||||
|
||||
"Page.Library": "Biblioteca",
|
||||
"Page.Options": "Opções",
|
||||
"Page.GameCount.One": "1 Jogo",
|
||||
"Page.GameCount.Other": "{0} jogos",
|
||||
|
||||
"Library.SearchWatermark": "Pesquisar na biblioteca…",
|
||||
"Library.AddFolder": "+ Adicionar pasta",
|
||||
"Library.Rescan": "⟳ Atualizar biblioteca",
|
||||
"Library.OpenFile": "Abrir arquivo…",
|
||||
|
||||
"Library.Context.Launch": "Jogar",
|
||||
"Library.Context.OpenFolder": "Abrir pasta do jogo",
|
||||
"Library.Context.CopyPath": "Copiar o caminho",
|
||||
"Library.Context.CopyTitleId": "Copiar ID do título",
|
||||
"Library.Context.Remove": "Remover da biblioteca",
|
||||
|
||||
"Library.Empty.Title": "Sua biblioteca está vazia",
|
||||
"Library.Empty.Hint": "Adicione uma pasta contendo seus jogos para começar.",
|
||||
"Library.Empty.SearchTitle": "Nenhum jogo corresponde à sua busca",
|
||||
"Library.Empty.SearchHint": "Nada na biblioteca corresponde a “{0}”.",
|
||||
"Library.Empty.AddFolder": "+ Adicionar pasta do jogo",
|
||||
|
||||
"Library.Loading": "Carregando biblioteca…",
|
||||
|
||||
"Options.General": "Opções Gerais",
|
||||
"Options.Env.Tab": "Ambiente",
|
||||
"Options.Section.Environment": "VARIÁVEIS DE AMBIENTE",
|
||||
"Options.Env.Desc": "Variáveis de ambiente passadas ao emulador durante a inicialização.",
|
||||
"Options.Env.Bthid.Desc": "Informa o Bluetooth HID como indisponível para títulos cujo middleware de volante/FFB fica verificando indefinidamente.\nNormalmente, deixe desativado. Alguns títulos travam quando a inicialização falha.",
|
||||
"Options.Env.LoopGuard.Desc": "Não força o encerramento de títulos que repetem a mesma chamada por tempo excessivo.\nExperimente esta opção se um jogo fechar sozinho durante o carregamento.",
|
||||
"Options.Env.VkValidation.Desc": "Ativa as camadas de validação do Vulkan para depuração de GPU.\nLento. Requer que o Vulkan SDK esteja instalado.",
|
||||
"Options.Env.DumpSpirv.Desc": "Extrai os shaders AGC e suas traduções SPIR-V para a pasta shader-dumps.\nUse ao reportar bugs de shader ou renderização.",
|
||||
"Options.Env.LogDirectMemory.Desc": "Registra alocações de memória direta e falhas no console.\nUse quando um jogo aborta ou fecha durante a inicialização (boot).",
|
||||
"Options.Env.LogNp.Desc": "Registra chamadas da biblioteca NP (PlayStation Network) no console.",
|
||||
"Options.Section.Emulation": "EMULAÇÃO",
|
||||
"Options.Section.Logging": "LOGS",
|
||||
"Options.Section.Launcher": "INICIALIZADOR",
|
||||
|
||||
"Options.CpuEngine.Label": "Motor da CPU",
|
||||
"Options.CpuEngine.Desc": "Motor de execução usado para executar o código do jogo.",
|
||||
"Options.CpuEngine.Native": "Nativo",
|
||||
|
||||
"Options.Strict.Label": "Resolução estrita de bibliotecas dinâmicas",
|
||||
"Options.Strict.Desc": "Interrompe a inicialização caso um símbolo importado não possa ser vinculado.",
|
||||
|
||||
"Options.LogLevel.Label": "Nível de log",
|
||||
"Options.LogLevel.Desc": "Nível de detalhamento das mensagens exibidas no console do emulador.",
|
||||
"Options.LogLevel.Trace": "Trace",
|
||||
"Options.LogLevel.Debug": "Debug",
|
||||
"Options.LogLevel.Info": "Info",
|
||||
"Options.LogLevel.Warning": "Warning",
|
||||
"Options.LogLevel.Error": "Error",
|
||||
"Options.LogLevel.Critical": "Critical",
|
||||
|
||||
"Options.TraceImports.Label": "Limite de rastreamento de importações",
|
||||
"Options.TraceImports.Desc": "Rastreia as primeiras N importações de cada módulo (0 = desativado).",
|
||||
|
||||
"Options.LogToFile.Label": "Salvar log em arquivo",
|
||||
"Options.LogToFile.Desc": "Copia a saída do emulador para um arquivo de log.",
|
||||
|
||||
"Options.LogFilePath.Label": "Caminho do arquivo de log",
|
||||
"Options.LogFilePath.Default": "Nenhum caminho definido — logs vão para user/logs na pasta do emulador.",
|
||||
"Options.LogFilePath.Select": "Selecionar…",
|
||||
|
||||
"Options.OverrideLogFile.Label": "Sobrescrever arquivo de log",
|
||||
"Options.OverrideLogFile.Desc": "Use o caminho exato do arquivo em vez de adicionar o ID do título e o log de data e hora.",
|
||||
|
||||
"Options.TitleMusic.Label": "Música de prévia",
|
||||
"Options.TitleMusic.Desc": "Reproduz em loop a música de prévia do jogo selecionado na biblioteca.",
|
||||
|
||||
"Options.Discord.Label": "Status do Discord",
|
||||
"Options.Discord.Desc": "Exibir o jogo em execução no seu perfil do Discord.",
|
||||
|
||||
"Options.Language.Label": "Idioma do emulador",
|
||||
"Options.Language.Desc": "Idioma usado em toda a interface do emulador. A alteração é aplicada imediatamente.",
|
||||
|
||||
"Common.On": "Ativado",
|
||||
"Common.Off": "Desativado",
|
||||
|
||||
"Console.Title": "CONSOLE",
|
||||
"Console.SearchWatermark": "Pesquisar...",
|
||||
"Console.AutoScroll": "Rolagem automática",
|
||||
"Console.Split": "Dividir",
|
||||
"Console.Copy": "Copiar",
|
||||
"Console.Clear": "Limpar",
|
||||
"Console.WindowTitle": "Console do SharpEmu",
|
||||
|
||||
"Launch.NoGameSelected": "Nenhum jogo selecionado",
|
||||
"Launch.NoGameHint": "Selecione um jogo na biblioteca ou abra um arquivo eboot.bin diretamente.",
|
||||
"Launch.Idle": "Ocioso",
|
||||
"Launch.Console": "≡ Console",
|
||||
"Launch.Launch": "▶ Iniciar",
|
||||
"Launch.Stop": "■ Parar",
|
||||
"Launch.Running": "Em execução — {0}",
|
||||
"Launch.Stopping": "Encerrando…",
|
||||
"Launch.Exited": "Encerrado com código {0} ({1})",
|
||||
"Launch.ExeNotFound": "Executável do SharpEmu não encontrado. Compile primeiro o projeto SharpEmu.CLI (dotnet build).",
|
||||
"Launch.LogFile": "Arquivo de log: {0}",
|
||||
"Launch.Command": "$ SharpEmu {0}",
|
||||
"Launch.StartFailed": "Falha ao iniciar o emulador: {0}",
|
||||
"Launch.ProcessExited": "O processo foi encerrado com código {0} ({1}).",
|
||||
|
||||
"Exit.Ok": "OK",
|
||||
"Exit.InvalidArguments": "argumentos inválidos",
|
||||
"Exit.EbootNotFound": "eboot.bin não encontrado",
|
||||
"Exit.RuntimeException": "exceção em tempo de execução",
|
||||
"Exit.EmulationError": "erro de emulação",
|
||||
"Exit.Unknown": "desconhecido",
|
||||
|
||||
"Status.EmulatorLocating": "Emulador: localizando…",
|
||||
"Status.EmulatorPath": "Emulador: {0}",
|
||||
"Status.EmulatorNotFound": "Emulador: executável do SharpEmu não encontrado — compile o SharpEmu.CLI primeiro.",
|
||||
"Status.ScanningLibrary": "Verificando biblioteca…",
|
||||
"Status.AddFolderPrompt": "Adicione uma pasta de jogos para preencher a biblioteca.",
|
||||
"Status.LibraryScanned": "Biblioteca verificada: {0} jogo(s) em {1} pasta(s).",
|
||||
"Status.CouldNotOpenFolder": "Não foi possível abrir a pasta: {0}",
|
||||
"Status.CopiedToClipboard": "{0} copiado para a área de transferência.",
|
||||
"Status.RemovedFromLibrary": "“{0}” removido da biblioteca. Adicione novamente sua pasta para restaurá-lo.",
|
||||
"Status.Running": "Executando {0}",
|
||||
"Status.Stopping": "Encerrando…",
|
||||
"Status.Idle": "Ocioso",
|
||||
|
||||
"Clipboard.Path": "Caminho",
|
||||
"Clipboard.TitleId": "ID do título",
|
||||
|
||||
"Discord.Playing": "Jogando {0}",
|
||||
"Discord.Browsing": "Navegando pela biblioteca",
|
||||
|
||||
"Dialog.ChooseGameFolder": "Escolha uma pasta contendo jogos",
|
||||
"Dialog.OpenExecutable": "Abrir um executável para iniciar",
|
||||
"Dialog.PsExecutables": "Executáveis de PS",
|
||||
"Dialog.SaveLogFile": "Selecione onde salvar o arquivo de log",
|
||||
"Dialog.PlainTextFiles": "Arquivos de texto simples",
|
||||
"Dialog.LogFiles": "Arquivos de log",
|
||||
|
||||
"Options.About" : "Sobre",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Código-fonte, problemas e desenvolvimento do projeto.",
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Discord.Desc": "Participe da comunidade, obtenha suporte e acompanhe o desenvolvimento.",
|
||||
"About.GithubButton": "Contribua no GitHub!",
|
||||
"About.DiscordButton": "Entre no nosso Discord!"
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"_languageName": "Deutsch",
|
||||
|
||||
"Page.Library": "Bibliothek",
|
||||
"Page.Options": "Optionen",
|
||||
"Page.GameCount.One": "1 Spiel",
|
||||
"Page.GameCount.Other": "{0} Spiele",
|
||||
|
||||
"Library.SearchWatermark": "Bibliothek durchsuchen…",
|
||||
"Library.AddFolder": "+ Spielordner hinzufügen",
|
||||
"Library.Rescan": "⟳ Neu scannen",
|
||||
"Library.OpenFile": "Datei öffnen…",
|
||||
|
||||
"Library.Context.Launch": "Starten",
|
||||
"Library.Context.OpenFolder": "Spielordner öffnen",
|
||||
"Library.Context.CopyPath": "Pfad kopieren",
|
||||
"Library.Context.CopyTitleId": "Title ID kopieren",
|
||||
"Library.Context.Remove": "Aus Bibliothek entfernen",
|
||||
|
||||
"Library.Empty.Title": "Deine Bibliothek ist leer",
|
||||
"Library.Empty.Hint": "Füge einen Ordner mit deinen Spielen hinzu, um zu beginnen.",
|
||||
"Library.Empty.SearchTitle": "Keine Spiele gefunden",
|
||||
"Library.Empty.SearchHint": "Nichts in der Bibliothek entspricht “{0}”.",
|
||||
"Library.Empty.AddFolder": "+ Spielordner hinzufügen",
|
||||
|
||||
"Library.Loading": "Bibliothek wird geladen…",
|
||||
|
||||
"Options.General": "Allgemein",
|
||||
"Options.Section.Emulation": "EMULATION",
|
||||
"Options.Section.Logging": "PROTOKOLLIERUNG",
|
||||
"Options.Section.Launcher": "LAUNCHER",
|
||||
|
||||
"Options.CpuEngine.Label": "CPU-Engine",
|
||||
"Options.CpuEngine.Desc": "Ausführungs-Engine, die zum Ausführen des Spiel-Codes verwendet wird.",
|
||||
"Options.CpuEngine.Native": "Nativ",
|
||||
|
||||
"Options.Strict.Label": "Strikte dynlib-Auflösung",
|
||||
"Options.Strict.Desc": "Starten abbrechen, wenn ein importiertes Symbol nicht aufgelöst werden kann.",
|
||||
|
||||
"Options.LogLevel.Label": "Protokollstufe",
|
||||
"Options.LogLevel.Desc": "Ausführlichkeit der Emulator-Konsolenausgabe.",
|
||||
"Options.LogLevel.Trace": "Trace",
|
||||
"Options.LogLevel.Debug": "Debug",
|
||||
"Options.LogLevel.Info": "Info",
|
||||
"Options.LogLevel.Warning": "Warnung",
|
||||
"Options.LogLevel.Error": "Fehler",
|
||||
"Options.LogLevel.Critical": "Kritisch",
|
||||
|
||||
"Options.TraceImports.Label": "Import-Trace-Limit",
|
||||
"Options.TraceImports.Desc": "Die ersten N Imports pro Modul verfolgen (0 = aus).",
|
||||
|
||||
"Options.LogToFile.Label": "In Datei protokollieren",
|
||||
"Options.LogToFile.Desc": "Emulator-Ausgabe zusätzlich in eine Log-Datei schreiben.",
|
||||
|
||||
"Options.LogFilePath.Label": "Protokolldatei-Pfad",
|
||||
"Options.LogFilePath.Default": "Kein benutzerdefinierter Pfad – Logs werden im Ordner user/logs neben dem Emulator gespeichert.",
|
||||
"Options.LogFilePath.Select": "Auswählen…",
|
||||
|
||||
"Options.OverrideLogFile.Label": "Protokolldatei überschreiben",
|
||||
"Options.OverrideLogFile.Desc": "Genauen Dateipfad verwenden, statt Title-ID und Zeitstempel anzuhängen.",
|
||||
|
||||
"Options.TitleMusic.Label": "Titel-Musik",
|
||||
"Options.TitleMusic.Desc": "Die Vorschau-Musik des ausgewählten Spiels in der Bibliothek loopend abspielen.",
|
||||
|
||||
"Options.Discord.Label": "Discord-Präsenz",
|
||||
"Options.Discord.Desc": "Zeigt das aktuell gespielte Spiel in deinem Discord-Profil an.",
|
||||
|
||||
"Options.Language.Label": "Emulator-Sprache",
|
||||
"Options.Language.Desc": "Sprache der Benutzeroberfläche. Wird sofort angewendet.",
|
||||
|
||||
"Common.On": "An",
|
||||
"Common.Off": "Aus",
|
||||
|
||||
"Console.Title": "KONSOLE",
|
||||
"Console.SearchWatermark": "Suchen...",
|
||||
"Console.AutoScroll": "Auto-Scroll",
|
||||
"Console.Split": "Teilen",
|
||||
"Console.Copy": "Kopieren",
|
||||
"Console.Clear": "Leeren",
|
||||
"Console.WindowTitle": "SharpEmu Konsole",
|
||||
|
||||
"Launch.NoGameSelected": "Kein Spiel ausgewählt",
|
||||
"Launch.NoGameHint": "Wähle ein Spiel aus der Bibliothek aus oder öffne eine eboot.bin direkt.",
|
||||
"Launch.Idle": "Bereit",
|
||||
"Launch.Console": "≡ Konsole",
|
||||
"Launch.Launch": "▶ Starten",
|
||||
"Launch.Stop": "■ Stoppen",
|
||||
"Launch.Running": "Läuft -- {0}",
|
||||
"Launch.Stopping": "Wird beendet…",
|
||||
"Launch.Exited": "Beendet mit Code {0} ({1})",
|
||||
"Launch.ExeNotFound": "SharpEmu-Executable wurde nicht gefunden. Baue zuerst das SharpEmu.CLI-Projekt (dotnet build).",
|
||||
"Launch.LogFile": "Log-Datei: {0}",
|
||||
"Launch.Command": "$ SharpEmu {0}",
|
||||
"Launch.StartFailed": "Emulator konnte nicht gestartet werden: {0}",
|
||||
"Launch.ProcessExited": "Prozess wurde mit Code {0} ({1}) beendet.",
|
||||
|
||||
"Exit.Ok": "OK",
|
||||
"Exit.InvalidArguments": "Ungültige Argumente",
|
||||
"Exit.EbootNotFound": "eboot nicht gefunden",
|
||||
"Exit.RuntimeException": "Laufzeitfehler",
|
||||
"Exit.EmulationError": "Emulationsfehler",
|
||||
"Exit.Unknown": "unbekannt",
|
||||
|
||||
"Status.EmulatorLocating": "Emulator: wird gesucht…",
|
||||
"Status.EmulatorPath": "Emulator: {0}",
|
||||
"Status.EmulatorNotFound": "Emulator: SharpEmu-Executable nicht gefunden -- baue zuerst SharpEmu.CLI.",
|
||||
"Status.ScanningLibrary": "Bibliothek wird gescannt…",
|
||||
"Status.AddFolderPrompt": "Füge einen Spielordner hinzu, um die Bibliothek zu füllen.",
|
||||
"Status.LibraryScanned": "Bibliothek gescannt: {0} Spiel(e) in {1} Ordner(n).",
|
||||
"Status.CouldNotOpenFolder": "Ordner konnte nicht geöffnet werden: {0}",
|
||||
"Status.CopiedToClipboard": "{0} in die Zwischenablage kopiert.",
|
||||
"Status.RemovedFromLibrary": "“{0}” wurde aus der Bibliothek entfernt. Füge den Ordner erneut hinzu, um es wiederherzustellen.",
|
||||
"Status.Running": "Läuft {0}",
|
||||
"Status.Stopping": "Wird gestoppt…",
|
||||
"Status.Idle": "Bereit",
|
||||
|
||||
"Clipboard.Path": "Pfad",
|
||||
"Clipboard.TitleId": "Title ID",
|
||||
|
||||
"Discord.Playing": "Spielt {0}",
|
||||
"Discord.Browsing": "Durchsucht die Bibliothek",
|
||||
|
||||
"Dialog.ChooseGameFolder": "Spielordner auswählen",
|
||||
"Dialog.OpenExecutable": "Ausführbare Datei zum Starten öffnen",
|
||||
"Dialog.PsExecutables": "PS-Ausführbare Dateien",
|
||||
"Dialog.SaveLogFile": "Protokolldatei speichern unter",
|
||||
"Dialog.PlainTextFiles": "Textdateien",
|
||||
"Dialog.LogFiles": "Protokolldateien"
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"_languageName": "Dansk",
|
||||
|
||||
"Page.Library": "Bibliotek",
|
||||
"Page.Options": "Indstillinger",
|
||||
"Page.GameCount.One": "1 spil",
|
||||
"Page.GameCount.Other": "{0} spil",
|
||||
|
||||
"Library.SearchWatermark": "Søg i biblioteket…",
|
||||
"Library.AddFolder": "+ Tilføj mappe",
|
||||
"Library.Rescan": "⟳ Genindlæs",
|
||||
"Library.OpenFile": "Åbn fil…",
|
||||
|
||||
"Library.Context.Launch": "Start",
|
||||
"Library.Context.OpenFolder": "Åbn spilmappe",
|
||||
"Library.Context.CopyPath": "Kopiér sti",
|
||||
"Library.Context.CopyTitleId": "Kopiér titel-ID",
|
||||
"Library.Context.Remove": "Fjern fra bibliotek",
|
||||
|
||||
"Library.Empty.Title": "Dit bibliotek er tomt",
|
||||
"Library.Empty.Hint": "Tilføj en mappe med dine spil for at komme i gang.",
|
||||
"Library.Empty.SearchTitle": "Ingen spil matcher din søgning",
|
||||
"Library.Empty.SearchHint": "Intet i biblioteket matcher “{0}”.",
|
||||
"Library.Empty.AddFolder": "+ Tilføj spilmappe",
|
||||
|
||||
"Library.Loading": "Indlæser bibliotek…",
|
||||
|
||||
"Options.General": "Generelt",
|
||||
"Options.Section.Emulation": "EMULERING",
|
||||
"Options.Section.Logging": "LOGGING",
|
||||
"Options.Section.Launcher": "LAUNCHER",
|
||||
|
||||
"Options.CpuEngine.Label": "CPU-engine",
|
||||
"Options.CpuEngine.Desc": "Den eksekveringsengine der bruges til at køre spilkode.",
|
||||
"Options.CpuEngine.Native": "Native",
|
||||
|
||||
"Options.Strict.Label": "Streng dynlib-opløsning",
|
||||
"Options.Strict.Desc": "Afbryd opstarten, når et importeret symbol ikke kan findes.",
|
||||
|
||||
"Options.LogLevel.Label": "Logniveau",
|
||||
"Options.LogLevel.Desc": "Detaljeringsgrad for emulatorens konsoloutput.",
|
||||
"Options.LogLevel.Trace": "Trace",
|
||||
"Options.LogLevel.Debug": "Debug",
|
||||
"Options.LogLevel.Info": "Info",
|
||||
"Options.LogLevel.Warning": "Advarsel",
|
||||
"Options.LogLevel.Error": "Fejl",
|
||||
"Options.LogLevel.Critical": "Kritisk",
|
||||
|
||||
"Options.TraceImports.Label": "Grænse for import-trace",
|
||||
"Options.TraceImports.Desc": "Spor de første N imports pr. modul (0 = fra).",
|
||||
|
||||
"Options.LogToFile.Label": "Log til fil",
|
||||
"Options.LogToFile.Desc": "Spejl emulatorens output til en logfil.",
|
||||
|
||||
"Options.LogFilePath.Label": "Sti til logfil",
|
||||
"Options.LogFilePath.Default": "Ingen brugerdefineret sti — logs gemmes i user/logs ved siden af emulatoren.",
|
||||
"Options.LogFilePath.Select": "Vælg…",
|
||||
|
||||
"Options.OverrideLogFile.Label": "Tilsidesæt logfil",
|
||||
"Options.OverrideLogFile.Desc": "Brug den præcise filsti i stedet for at tilføje titel-ID og tidsstempel.",
|
||||
|
||||
"Options.TitleMusic.Label": "Titelmusik",
|
||||
"Options.TitleMusic.Desc": "Gentag det valgte spils forhåndsvisningsmusik i biblioteket.",
|
||||
|
||||
"Options.Discord.Label": "Discord-tilstedeværelse",
|
||||
"Options.Discord.Desc": "Vis det kørende spil på din Discord-profil.",
|
||||
|
||||
"Options.Language.Label": "Emulatorsprog",
|
||||
"Options.Language.Desc": "Sprog der bruges i hele launcheren. Anvendes med det samme.",
|
||||
|
||||
"Common.On": "Til",
|
||||
"Common.Off": "Fra",
|
||||
|
||||
"Console.Title": "KONSOL",
|
||||
"Console.SearchWatermark": "Søg...",
|
||||
"Console.AutoScroll": "Auto-scroll",
|
||||
"Console.Split": "Opdel",
|
||||
"Console.Copy": "Kopiér",
|
||||
"Console.Clear": "Ryd",
|
||||
"Console.WindowTitle": "SharpEmu-konsol",
|
||||
|
||||
"Launch.NoGameSelected": "Intet spil valgt",
|
||||
"Launch.NoGameHint": "Vælg et spil fra biblioteket, eller åbn en eboot.bin direkte.",
|
||||
"Launch.Idle": "Inaktiv",
|
||||
"Launch.Console": "≡ Konsol",
|
||||
"Launch.Launch": "▶ Start",
|
||||
"Launch.Stop": "■ Stop",
|
||||
"Launch.Running": "Kører — {0}",
|
||||
"Launch.Stopping": "Stopper…",
|
||||
"Launch.Exited": "Afsluttet med kode {0} ({1})",
|
||||
"Launch.ExeNotFound": "SharpEmu-programmet blev ikke fundet. Byg SharpEmu.CLI-projektet først (dotnet build).",
|
||||
"Launch.LogFile": "Logfil: {0}",
|
||||
"Launch.Command": "$ SharpEmu {0}",
|
||||
"Launch.StartFailed": "Kunne ikke starte emulatoren: {0}",
|
||||
"Launch.ProcessExited": "Processen afsluttede med kode {0} ({1}).",
|
||||
|
||||
"Exit.Ok": "OK",
|
||||
"Exit.InvalidArguments": "ugyldige argumenter",
|
||||
"Exit.EbootNotFound": "eboot ikke fundet",
|
||||
"Exit.RuntimeException": "runtime-fejl",
|
||||
"Exit.EmulationError": "emuleringsfejl",
|
||||
"Exit.Unknown": "ukendt",
|
||||
|
||||
"Status.EmulatorLocating": "Emulator: lokaliserer…",
|
||||
"Status.EmulatorPath": "Emulator: {0}",
|
||||
"Status.EmulatorNotFound": "Emulator: SharpEmu-programmet blev ikke fundet — byg SharpEmu.CLI først.",
|
||||
"Status.ScanningLibrary": "Skanner bibliotek…",
|
||||
"Status.AddFolderPrompt": "Tilføj en spilmappe for at udfylde biblioteket.",
|
||||
"Status.LibraryScanned": "Bibliotek skannet: {0} spil i {1} mappe(r).",
|
||||
"Status.CouldNotOpenFolder": "Kunne ikke åbne mappe: {0}",
|
||||
"Status.CopiedToClipboard": "{0} kopieret til udklipsholderen.",
|
||||
"Status.RemovedFromLibrary": "“{0}” fjernet fra biblioteket. Tilføj mappen igen for at gendanne det.",
|
||||
"Status.Running": "Kører {0}",
|
||||
"Status.Stopping": "Stopper…",
|
||||
"Status.Idle": "Inaktiv",
|
||||
|
||||
"Clipboard.Path": "Sti",
|
||||
"Clipboard.TitleId": "Titel-ID",
|
||||
|
||||
"Discord.Playing": "Spiller {0}",
|
||||
"Discord.Browsing": "Gennemser biblioteket",
|
||||
|
||||
"Dialog.ChooseGameFolder": "Vælg en mappe der indeholder spil",
|
||||
"Dialog.OpenExecutable": "Åbn et program der skal startes",
|
||||
"Dialog.PsExecutables": "PS-programmer",
|
||||
"Dialog.SaveLogFile": "Vælg hvor logfilen skal gemmes",
|
||||
"Dialog.PlainTextFiles": "Almindelige tekstfiler",
|
||||
"Dialog.LogFiles": "Logfiler"
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
{
|
||||
"_languageName": "English",
|
||||
|
||||
"Page.Library": "Library",
|
||||
"Page.Options": "Options",
|
||||
"Page.GameCount.One": "1 game",
|
||||
"Page.GameCount.Other": "{0} games",
|
||||
|
||||
"Library.SearchWatermark": "Search library…",
|
||||
"Library.AddFolder": "+ Add folder",
|
||||
"Library.Rescan": "⟳ Rescan",
|
||||
"Library.OpenFile": "Open file…",
|
||||
|
||||
"Library.Context.Launch": "Launch",
|
||||
"Library.Context.OpenFolder": "Open game folder",
|
||||
"Library.Context.CopyPath": "Copy path",
|
||||
"Library.Context.CopyTitleId": "Copy title ID",
|
||||
"Library.Context.Remove": "Remove from library",
|
||||
|
||||
"Library.Empty.Title": "Your library is empty",
|
||||
"Library.Empty.Hint": "Add a folder containing your games to get started.",
|
||||
"Library.Empty.SearchTitle": "No games match your search",
|
||||
"Library.Empty.SearchHint": "Nothing in the library matches “{0}”.",
|
||||
"Library.Empty.AddFolder": "+ Add game folder",
|
||||
|
||||
"Library.Loading": "Loading library…",
|
||||
|
||||
"Options.General": "General",
|
||||
"Options.Env.Tab": "Environment",
|
||||
"Options.Section.Environment": "ENVIRONMENT VARIABLES",
|
||||
"Options.Env.Desc": "Switches passed to the emulator as environment variables at launch.",
|
||||
"Options.Env.Bthid.Desc": "Report Bluetooth HID as unavailable for titles whose wheel/FFB middleware polls forever.\nLeave off normally. Some titles freeze when init fails.",
|
||||
"Options.Env.LoopGuard.Desc": "Do not force quit titles that repeat the same call for too long.\nTry this when a game exits on its own while loading.",
|
||||
"Options.Env.WritableApp0.Desc": "Allow titles to create and write files inside their install folder.\nNeeded by unpackaged dumps that write their save or config data under /app0.",
|
||||
"Options.Env.VkValidation.Desc": "Enable Vulkan validation layers for GPU debugging.\nSlow. Requires the Vulkan SDK to be installed.",
|
||||
"Options.Env.DumpSpirv.Desc": "Dump AGC shaders and their SPIR-V translations to the shader-dumps folder.\nUse when reporting shader or rendering bugs.",
|
||||
"Options.Env.LogDirectMemory.Desc": "Log direct memory allocations and failures to the console.\nUse when a game aborts or exits during boot.",
|
||||
"Options.Env.LogIo.Desc": "Log file open, read, and path-resolve activity to the console.\nUse when a game cannot find its data files during boot.",
|
||||
"Options.Env.LogNp.Desc": "Log NP (PlayStation Network) library calls to the console.",
|
||||
"Options.Section.Emulation": "EMULATION",
|
||||
"Options.Section.Logging": "LOGGING",
|
||||
"Options.Section.Launcher": "LAUNCHER",
|
||||
|
||||
"Options.CpuEngine.Label": "CPU engine",
|
||||
"Options.CpuEngine.Desc": "Execution engine used to run game code.",
|
||||
"Options.CpuEngine.Native": "Native",
|
||||
|
||||
"Options.Strict.Label": "Strict dynlib resolution",
|
||||
"Options.Strict.Desc": "Fail the launch when an imported symbol cannot be resolved.",
|
||||
|
||||
"Options.LogLevel.Label": "Log level",
|
||||
"Options.LogLevel.Desc": "Verbosity of the emulator console output.",
|
||||
"Options.LogLevel.Trace": "Trace",
|
||||
"Options.LogLevel.Debug": "Debug",
|
||||
"Options.LogLevel.Info": "Info",
|
||||
"Options.LogLevel.Warning": "Warning",
|
||||
"Options.LogLevel.Error": "Error",
|
||||
"Options.LogLevel.Critical": "Critical",
|
||||
|
||||
"Options.TraceImports.Label": "Import trace limit",
|
||||
"Options.TraceImports.Desc": "Trace the first N imports per module (0 = off).",
|
||||
|
||||
"Options.LogToFile.Label": "Log to file",
|
||||
"Options.LogToFile.Desc": "Mirror emulator output to a log file.",
|
||||
|
||||
"Options.LogFilePath.Label": "Log file path",
|
||||
"Options.LogFilePath.Default": "No custom path — logs go to user/logs next to the emulator.",
|
||||
"Options.LogFilePath.Select": "Select…",
|
||||
|
||||
"Options.OverrideLogFile.Label": "Override log file",
|
||||
"Options.OverrideLogFile.Desc": "Use the exact file path instead of appending title ID and timestamp.",
|
||||
|
||||
"Options.TitleMusic.Label": "Title music",
|
||||
"Options.TitleMusic.Desc": "Loop the selected game's preview music in the library.",
|
||||
|
||||
"Options.Discord.Label": "Discord presence",
|
||||
"Options.Discord.Desc": "Show the running game on your Discord profile.",
|
||||
|
||||
"Options.Language.Label": "Emulator language",
|
||||
"Options.Language.Desc": "Language used throughout the launcher. Applies immediately.",
|
||||
|
||||
"Common.On": "On",
|
||||
"Common.Off": "Off",
|
||||
|
||||
"Console.Title": "CONSOLE",
|
||||
"Console.SearchWatermark": "Search...",
|
||||
"Console.AutoScroll": "Auto-scroll",
|
||||
"Console.Split": "Split",
|
||||
"Console.Copy": "Copy",
|
||||
"Console.Clear": "Clear",
|
||||
"Console.WindowTitle": "SharpEmu Console",
|
||||
|
||||
"Launch.NoGameSelected": "No game selected",
|
||||
"Launch.NoGameHint": "Pick a game from the library, or open an eboot.bin directly.",
|
||||
"Launch.Idle": "Idle",
|
||||
"Launch.Console": "≡ Console",
|
||||
"Launch.Launch": "▶ Launch",
|
||||
"Launch.Stop": "■ Stop",
|
||||
"Launch.Running": "Running — {0}",
|
||||
"Launch.Stopping": "Stopping…",
|
||||
"Launch.Exited": "Exited with code {0} ({1})",
|
||||
"Launch.ExeNotFound": "SharpEmu executable not found. Build the SharpEmu.CLI project first (dotnet build).",
|
||||
"Launch.LogFile": "Log file: {0}",
|
||||
"Launch.Command": "$ SharpEmu {0}",
|
||||
"Launch.StartFailed": "Failed to start the emulator: {0}",
|
||||
"Launch.ProcessExited": "Process exited with code {0} ({1}).",
|
||||
|
||||
"Exit.Ok": "OK",
|
||||
"Exit.InvalidArguments": "invalid arguments",
|
||||
"Exit.EbootNotFound": "eboot not found",
|
||||
"Exit.RuntimeException": "runtime exception",
|
||||
"Exit.EmulationError": "emulation error",
|
||||
"Exit.Unknown": "unknown",
|
||||
|
||||
"Status.EmulatorLocating": "Emulator: locating…",
|
||||
"Status.EmulatorPath": "Emulator: {0}",
|
||||
"Status.EmulatorNotFound": "Emulator: SharpEmu executable not found — build SharpEmu.CLI first.",
|
||||
"Status.ScanningLibrary": "Scanning library…",
|
||||
"Status.AddFolderPrompt": "Add a game folder to populate the library.",
|
||||
"Status.LibraryScanned": "Library scanned: {0} game(s) in {1} folder(s).",
|
||||
"Status.CouldNotOpenFolder": "Could not open folder: {0}",
|
||||
"Status.CopiedToClipboard": "{0} copied to clipboard.",
|
||||
"Status.RemovedFromLibrary": "Removed “{0}” from the library. Re-add its folder to restore it.",
|
||||
"Status.Running": "Running {0}",
|
||||
"Status.Stopping": "Stopping…",
|
||||
"Status.Idle": "Idle",
|
||||
|
||||
"Clipboard.Path": "Path",
|
||||
"Clipboard.TitleId": "Title ID",
|
||||
|
||||
"Discord.Playing": "Playing {0}",
|
||||
"Discord.Browsing": "Browsing the library",
|
||||
|
||||
"Dialog.ChooseGameFolder": "Choose a folder containing games",
|
||||
"Dialog.OpenExecutable": "Open an executable to launch",
|
||||
"Dialog.PsExecutables": "PS executables",
|
||||
"Dialog.SaveLogFile": "Select where to save the Log file",
|
||||
"Dialog.PlainTextFiles": "Plain Text Files",
|
||||
"Dialog.LogFiles": "Log Files",
|
||||
|
||||
"Options.About" : "About",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Source code, issues and project development.",
|
||||
"About.Github.LatestCommitLabel": "Latest Commit",
|
||||
"About.Github.LatestCommitDescription": "Latest commit on the main branch",
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Discord.Desc": "Join the community, get support and follow development.",
|
||||
"About.GithubButton": "Contribute in GitHub!",
|
||||
"About.DiscordButton": "Join our Discord!",
|
||||
|
||||
"Updater.Auto.Label": "Check for updates on startup",
|
||||
"Updater.Auto.Desc": "Checks GitHub without delaying startup.",
|
||||
"Updater.Label": "Updates",
|
||||
"Updater.Check": "Check for updates",
|
||||
"Updater.DownloadRestart": "Download and restart",
|
||||
"Updater.Status.Ready": "Current build: {0}",
|
||||
"Updater.Status.Checking": "Checking for updates…",
|
||||
"Updater.Status.Current": "You are up to date ({0}).",
|
||||
"Updater.Status.Available": "A new build is available: {0}",
|
||||
"Updater.Status.Downloading": "Downloading update… {0}%",
|
||||
"Updater.Status.Installing": "Installing update…",
|
||||
"Updater.Status.Timeout": "Update check timed out after 10 seconds.",
|
||||
"Updater.Status.Failed": "Could not check for updates.",
|
||||
"Updater.Status.Unsupported": "Automatic updating requires a Windows, Linux or macOS x64 build."
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"_languageName": "Español",
|
||||
|
||||
"Page.Library": "Biblioteca",
|
||||
"Page.Options": "Opciones",
|
||||
"Page.GameCount.One": "1 juego",
|
||||
"Page.GameCount.Other": "{0} juegos",
|
||||
|
||||
"Library.SearchWatermark": "Buscar en la biblioteca…",
|
||||
"Library.AddFolder": "+ Añadir carpeta",
|
||||
"Library.Rescan": "⟳ Volver a escanear",
|
||||
"Library.OpenFile": "Abrir archivo…",
|
||||
|
||||
"Library.Context.Launch": "Iniciar",
|
||||
"Library.Context.OpenFolder": "Abrir carpeta de juegos",
|
||||
"Library.Context.CopyPath": "Copiar ruta",
|
||||
"Library.Context.CopyTitleId": "Copiar ID del título",
|
||||
"Library.Context.Remove": "Eliminar de la biblioteca",
|
||||
|
||||
"Library.Empty.Title": "Tu biblioteca está vacía",
|
||||
"Library.Empty.Hint": "Añade una carpeta que contenga tus juegos para empezar.",
|
||||
"Library.Empty.SearchTitle": "Ningún juego coincide con la búsqueda",
|
||||
"Library.Empty.SearchHint": "No se ha encontrado nada en la biblioteca que coincida con “{0}”.",
|
||||
"Library.Empty.AddFolder": "+ Añadir carpeta de juegos",
|
||||
|
||||
"Library.Loading": "Cargando biblioteca…",
|
||||
|
||||
"Options.General": "General",
|
||||
"Options.Section.Emulation": "EMULACIÓN",
|
||||
"Options.Section.Logging": "LOGS",
|
||||
"Options.Section.Launcher": "LAUNCHER",
|
||||
|
||||
"Options.CpuEngine.Label": "Motor de CPU",
|
||||
"Options.CpuEngine.Desc": "Motor utilizado para ejecutar el código del juego.",
|
||||
"Options.CpuEngine.Native": "Nativo",
|
||||
|
||||
"Options.Strict.Label": "Resolución estricta de dynlib (Bibliotecas dinámicas)",
|
||||
"Options.Strict.Desc": "Detener la ejecución cuando un símbolo importado no se pueda resolver.",
|
||||
|
||||
"Options.LogLevel.Label": "Nivel de Log",
|
||||
"Options.LogLevel.Desc": "Verbosidad de la salida en consola del emulador.",
|
||||
"Options.LogLevel.Trace": "Trazas",
|
||||
"Options.LogLevel.Debug": "Depuración",
|
||||
"Options.LogLevel.Info": "Información",
|
||||
"Options.LogLevel.Warning": "Advertencia",
|
||||
"Options.LogLevel.Error": "Error",
|
||||
"Options.LogLevel.Critical": "Crítico",
|
||||
|
||||
"Options.TraceImports.Label": "Límite de trazado de importaciones",
|
||||
"Options.TraceImports.Desc": "Trazar las primeras N importaciones por módulo (0 = off).",
|
||||
|
||||
"Options.LogToFile.Label": "Registrar log en archivo",
|
||||
"Options.LogToFile.Desc": "Duplicar la salida del emulador en un archivo de logs.",
|
||||
|
||||
"Options.LogFilePath.Label": "Ruta del archivo de Log",
|
||||
"Options.LogFilePath.Default": "Sin ruta personalizada — los logs van a user/logs al lado del emulador.",
|
||||
"Options.LogFilePath.Select": "Seleccionar…",
|
||||
|
||||
"Options.OverrideLogFile.Label": "Sobreescribir archivo de logs",
|
||||
"Options.OverrideLogFile.Desc": "Utilizar la misma ruta para el archivo de logs en vez de añadir la ID del título y marca de tiempo.",
|
||||
|
||||
"Options.TitleMusic.Label": "Música del título",
|
||||
"Options.TitleMusic.Desc": "Repetir en bucle la preview de la música del juego seleccionado en la biblioteca.",
|
||||
|
||||
"Options.Discord.Label": "Actividad de Discord",
|
||||
"Options.Discord.Desc": "Mostrar juego en ejecución en tu perfil de Discord.",
|
||||
|
||||
"Options.Language.Label": "Idioma del emulador",
|
||||
"Options.Language.Desc": "Idioma utilizado en todo el launcher. Se aplica inmediatamente.",
|
||||
|
||||
"Common.On": "Encendido",
|
||||
"Common.Off": "Apagado",
|
||||
|
||||
"Console.Title": "CONSOLA",
|
||||
"Console.SearchWatermark": "Buscar...",
|
||||
"Console.AutoScroll": "Desplazamiento automático",
|
||||
"Console.Split": "Desacoplar",
|
||||
"Console.Copy": "Copiar",
|
||||
"Console.Clear": "Limpiar",
|
||||
"Console.WindowTitle": "Consola SharpEmu",
|
||||
|
||||
"Launch.NoGameSelected": "No hay ningún juego seleccionado",
|
||||
"Launch.NoGameHint": "Selecciona un juego de la biblioteca o abre un eboot.bin directamente.",
|
||||
"Launch.Idle": "Inactivo",
|
||||
"Launch.Console": "≡ Consola",
|
||||
"Launch.Launch": "▶ Iniciar",
|
||||
"Launch.Stop": "■ Detener",
|
||||
"Launch.Running": "En ejecución — {0}",
|
||||
"Launch.Stopping": "Deteniendo…",
|
||||
"Launch.Exited": "Finalizó con el código {0} ({1})",
|
||||
"Launch.ExeNotFound": "No se ha encontrado el ejecutable de SharpEmu. Compila previamente el proyecto SharpEmu.CLI (dotnet build).",
|
||||
"Launch.LogFile": "Archivo de Log: {0}",
|
||||
"Launch.Command": "$ SharpEmu {0}",
|
||||
"Launch.StartFailed": "Error al iniciar el emulador: {0}",
|
||||
"Launch.ProcessExited": "El proceso finalizó con el código {0} ({1}).",
|
||||
|
||||
"Exit.Ok": "OK",
|
||||
"Exit.InvalidArguments": "argumentos no válidos",
|
||||
"Exit.EbootNotFound": "no se encontró eboot",
|
||||
"Exit.RuntimeException": "excepción en tiempo de ejecución",
|
||||
"Exit.EmulationError": "error de emulación",
|
||||
"Exit.Unknown": "desconocido",
|
||||
|
||||
"Status.EmulatorLocating": "Emulador: localizando…",
|
||||
"Status.EmulatorPath": "Emulador: {0}",
|
||||
"Status.EmulatorNotFound": "Emulador: No se encontró el ejecutable de SharpEmu — compila previamente SharpEmu.CLI.",
|
||||
"Status.ScanningLibrary": "Escaneando biblioteca…",
|
||||
"Status.AddFolderPrompt": "Añade una carpeta de juegos para poblar la biblioteca.",
|
||||
"Status.LibraryScanned": "Biblioteca escaneada: Se encontraron {0} juego(s) en {1} carpeta(s).",
|
||||
"Status.CouldNotOpenFolder": "No se ha podido abrir la carpeta: {0}",
|
||||
"Status.CopiedToClipboard": "{0} copiado al portapapeles.",
|
||||
"Status.RemovedFromLibrary": "Se eliminó “{0}” de la biblioteca. Vuelve a añadir su carpeta para restaurarlo.",
|
||||
"Status.Running": "Ejecutando {0}",
|
||||
"Status.Stopping": "Deteniendo…",
|
||||
"Status.Idle": "Inactivo",
|
||||
|
||||
"Clipboard.Path": "Ruta",
|
||||
"Clipboard.TitleId": "ID del título",
|
||||
|
||||
"Discord.Playing": "Jugando a {0}",
|
||||
"Discord.Browsing": "Navegando en la biblioteca, buscando un juego para divertirse.",
|
||||
|
||||
"Dialog.ChooseGameFolder": "Selecciona una carpeta que contenga juegos",
|
||||
"Dialog.OpenExecutable": "Abrir un ejecutable para iniciar",
|
||||
"Dialog.PsExecutables": "Ejecutables de PS",
|
||||
"Dialog.SaveLogFile": "Selecciona dónde guardar el archivo de Logs",
|
||||
"Dialog.PlainTextFiles": "Archivos en texto plano",
|
||||
"Dialog.LogFiles": "Archivos de Log",
|
||||
|
||||
"Options.About" : "Informacion",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Código fuente, issues y desarrollo del proyecto.",
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Github.LatestCommitLabel": "Último Commit",
|
||||
"About.Github.LatestCommitDescription": "Último commit en la rama main",
|
||||
"About.Discord.Desc": "Únete a la comunidad, recibe soporte y sigue el desarrollo.",
|
||||
"About.GithubButton": "Contribuye en GitHub!",
|
||||
"About.DiscordButton": "Únete a nuestro Discord!"
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"_languageName": "Français",
|
||||
|
||||
"Page.Library": "Bibliothèque",
|
||||
"Page.Options": "Options",
|
||||
"Page.GameCount.One": "1 jeu",
|
||||
"Page.GameCount.Other": "{0} jeux",
|
||||
|
||||
"Library.SearchWatermark": "Rechercher dans la bibliothèque…",
|
||||
"Library.AddFolder": "+ Ajouter un dossier",
|
||||
"Library.Rescan": "⟳ Analyser à nouveau",
|
||||
"Library.OpenFile": "Ouvrir un fichier…",
|
||||
|
||||
"Library.Context.Launch": "Lancer",
|
||||
"Library.Context.OpenFolder": "Ouvrir le dossier du jeu",
|
||||
"Library.Context.CopyPath": "Copier le chemin",
|
||||
"Library.Context.CopyTitleId": "Copier l’identifiant du jeu",
|
||||
"Library.Context.Remove": "Retirer de la bibliothèque",
|
||||
|
||||
"Library.Empty.Title": "Votre bibliothèque est vide",
|
||||
"Library.Empty.Hint": "Ajoutez un dossier contenant vos jeux pour commencer.",
|
||||
"Library.Empty.SearchTitle": "Aucun jeu ne correspond à votre recherche",
|
||||
"Library.Empty.SearchHint": "Aucun élément de la bibliothèque ne correspond à « {0} ».",
|
||||
"Library.Empty.AddFolder": "+ Ajouter un dossier de jeux",
|
||||
|
||||
"Library.Loading": "Chargement de la bibliothèque…",
|
||||
|
||||
"Options.General": "Général",
|
||||
"Options.Env.Tab": "Environnement",
|
||||
"Options.Section.Environment": "VARIABLES D’ENVIRONNEMENT",
|
||||
"Options.Env.Desc": "Paramètres passés à l’émulateur comme variables d’environnement au lancement.",
|
||||
"Options.Env.Bthid.Desc": "Signaler le HID Bluetooth comme indisponible pour les titres dont le middleware volant/FFB interroge indéfiniment.\nLaissez désactivé normalement. Certains titres se bloquent si l’initialisation échoue.",
|
||||
"Options.Env.LoopGuard.Desc": "Ne pas forcer la fermeture des titres qui répètent le même appel trop longtemps.\nEssayez ceci quand un jeu se ferme tout seul pendant le chargement.",
|
||||
"Options.Env.VkValidation.Desc": "Activer les couches de validation Vulkan pour le débogage GPU.\nRalentit. Nécessite l’installation du Vulkan SDK.",
|
||||
"Options.Env.DumpSpirv.Desc": "Exporter les shaders AGC et leurs traductions SPIR-V dans le dossier shader-dumps.\nÀ utiliser pour signaler des bugs de shader ou de rendu.",
|
||||
"Options.Env.LogDirectMemory.Desc": "Journaliser les allocations de mémoire directe et les échecs dans la console.\nÀ utiliser quand un jeu plante ou se ferme pendant le démarrage.",
|
||||
"Options.Env.LogNp.Desc": "Journaliser les appels de la bibliothèque NP (PlayStation Network) dans la console.",
|
||||
"Options.Section.Emulation": "ÉMULATION",
|
||||
"Options.Section.Logging": "JOURNALISATION",
|
||||
"Options.Section.Launcher": "LANCEUR",
|
||||
|
||||
"Options.CpuEngine.Label": "Moteur CPU",
|
||||
"Options.CpuEngine.Desc": "Moteur d’exécution utilisé pour exécuter le code du jeu.",
|
||||
"Options.CpuEngine.Native": "Natif",
|
||||
|
||||
"Options.Strict.Label": "Résolution stricte des bibliothèques dynamiques",
|
||||
"Options.Strict.Desc": "Interrompre le lancement lorsqu’un symbole importé ne peut pas être résolu.",
|
||||
|
||||
"Options.LogLevel.Label": "Niveau de journalisation",
|
||||
"Options.LogLevel.Desc": "Niveau de détail des messages affichés dans la console de l’émulateur.",
|
||||
"Options.LogLevel.Trace": "Traçage",
|
||||
"Options.LogLevel.Debug": "Débogage",
|
||||
"Options.LogLevel.Info": "Informations",
|
||||
"Options.LogLevel.Warning": "Avertissements",
|
||||
"Options.LogLevel.Error": "Erreurs",
|
||||
"Options.LogLevel.Critical": "Erreurs critiques",
|
||||
|
||||
"Options.TraceImports.Label": "Limite de traçage des imports",
|
||||
"Options.TraceImports.Desc": "Tracer les N premiers imports de chaque module (0 = désactivé).",
|
||||
|
||||
"Options.LogToFile.Label": "Enregistrer dans un fichier",
|
||||
"Options.LogToFile.Desc": "Copier la sortie de l’émulateur dans un fichier journal.",
|
||||
|
||||
"Options.LogFilePath.Label": "Chemin du fichier journal",
|
||||
"Options.LogFilePath.Default": "Aucun chemin personnalisé — les journaux sont enregistrés dans user/logs à côté de l’émulateur.",
|
||||
"Options.LogFilePath.Select": "Sélectionner…",
|
||||
|
||||
"Options.OverrideLogFile.Label": "Remplacer le fichier journal",
|
||||
"Options.OverrideLogFile.Desc": "Utiliser exactement ce chemin au lieu d’ajouter l’identifiant du jeu et l’horodatage.",
|
||||
|
||||
"Options.TitleMusic.Label": "Musique du jeu",
|
||||
"Options.TitleMusic.Desc": "Lire en boucle la musique d’aperçu du jeu sélectionné dans la bibliothèque.",
|
||||
|
||||
"Options.Discord.Label": "Présence Discord",
|
||||
"Options.Discord.Desc": "Afficher le jeu en cours d’exécution sur votre profil Discord.",
|
||||
|
||||
"Options.Language.Label": "Langue de l’émulateur",
|
||||
"Options.Language.Desc": "Langue utilisée dans l’ensemble du lanceur. Le changement est immédiat.",
|
||||
|
||||
"Common.On": "Activé",
|
||||
"Common.Off": "Désactivé",
|
||||
|
||||
"Console.Title": "CONSOLE",
|
||||
"Console.SearchWatermark": "Rechercher…",
|
||||
"Console.AutoScroll": "Défilement automatique",
|
||||
"Console.Split": "Détacher",
|
||||
"Console.Copy": "Copier",
|
||||
"Console.Clear": "Effacer",
|
||||
"Console.WindowTitle": "Console SharpEmu",
|
||||
|
||||
"Launch.NoGameSelected": "Aucun jeu sélectionné",
|
||||
"Launch.NoGameHint": "Choisissez un jeu dans la bibliothèque ou ouvrez directement un fichier eboot.bin.",
|
||||
"Launch.Idle": "Inactif",
|
||||
"Launch.Console": "≡ Console",
|
||||
"Launch.Launch": "▶ Lancer",
|
||||
"Launch.Stop": "■ Arrêter",
|
||||
"Launch.Running": "En cours d’exécution — {0}",
|
||||
"Launch.Stopping": "Arrêt en cours…",
|
||||
"Launch.Exited": "Processus terminé avec le code {0} ({1})",
|
||||
"Launch.ExeNotFound": "L’exécutable SharpEmu est introuvable. Compilez d’abord le projet SharpEmu.CLI (dotnet build).",
|
||||
"Launch.LogFile": "Fichier journal : {0}",
|
||||
"Launch.Command": "$ SharpEmu {0}",
|
||||
"Launch.StartFailed": "Impossible de démarrer l’émulateur : {0}",
|
||||
"Launch.ProcessExited": "Le processus s’est terminé avec le code {0} ({1}).",
|
||||
|
||||
"Exit.Ok": "OK",
|
||||
"Exit.InvalidArguments": "arguments non valides",
|
||||
"Exit.EbootNotFound": "eboot introuvable",
|
||||
"Exit.RuntimeException": "exception d’exécution",
|
||||
"Exit.EmulationError": "erreur d’émulation",
|
||||
"Exit.Unknown": "inconnu",
|
||||
|
||||
"Status.EmulatorLocating": "Émulateur : recherche en cours…",
|
||||
"Status.EmulatorPath": "Émulateur : {0}",
|
||||
"Status.EmulatorNotFound": "Émulateur : l’exécutable SharpEmu est introuvable — compilez d’abord SharpEmu.CLI.",
|
||||
"Status.ScanningLibrary": "Analyse de la bibliothèque…",
|
||||
"Status.AddFolderPrompt": "Ajoutez un dossier de jeux pour remplir la bibliothèque.",
|
||||
"Status.LibraryScanned": "Bibliothèque analysée : {0} jeu(x) dans {1} dossier(s).",
|
||||
"Status.CouldNotOpenFolder": "Impossible d’ouvrir le dossier : {0}",
|
||||
"Status.CopiedToClipboard": "{0} copié dans le presse-papiers.",
|
||||
"Status.RemovedFromLibrary": "« {0} » a été retiré de la bibliothèque. Ajoutez à nouveau son dossier pour le restaurer.",
|
||||
"Status.Running": "Exécution de {0}",
|
||||
"Status.Stopping": "Arrêt en cours…",
|
||||
"Status.Idle": "Inactif",
|
||||
|
||||
"Clipboard.Path": "Chemin",
|
||||
"Clipboard.TitleId": "Identifiant du jeu",
|
||||
|
||||
"Discord.Playing": "Joue à {0}",
|
||||
"Discord.Browsing": "Parcourt la bibliothèque",
|
||||
|
||||
"Dialog.ChooseGameFolder": "Choisir un dossier contenant des jeux",
|
||||
"Dialog.OpenExecutable": "Ouvrir un exécutable à lancer",
|
||||
"Dialog.PsExecutables": "Exécutables PlayStation",
|
||||
"Dialog.SaveLogFile": "Choisir l’emplacement du fichier journal",
|
||||
"Dialog.PlainTextFiles": "Fichiers texte brut",
|
||||
"Dialog.LogFiles": "Fichiers journaux",
|
||||
|
||||
"Options.About": "À propos",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Code source, issues et développement du projet.",
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Discord.Desc": "Rejoignez la communauté, obtenez de l’aide et suivez le développement.",
|
||||
"About.GithubButton": "Contribuer sur GitHub !",
|
||||
"About.DiscordButton": "Rejoindre notre Discord !"
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"_languageName": "Hungarian",
|
||||
|
||||
"Page.Library": "Könyvtár",
|
||||
"Page.Options": "Beállítások",
|
||||
"Page.GameCount.One": "1 játék",
|
||||
"Page.GameCount.Other": "{0} játékok",
|
||||
|
||||
"Library.SearchWatermark": "Keresés a könyvtárban",
|
||||
"Library.AddFolder": "+ Mappa hozzáadása",
|
||||
"Library.Rescan": "⟳ Újrakeresés",
|
||||
"Library.OpenFile": "Fájl megnyitása…",
|
||||
|
||||
"Library.Context.Launch": "Inditás",
|
||||
"Library.Context.OpenFolder": "Játékmappa megnyitása",
|
||||
"Library.Context.CopyPath": "Elérési út másolása",
|
||||
"Library.Context.CopyTitleId": "Cím ID másolása",
|
||||
"Library.Context.Remove": "Eltávolítás a Könyvtárból",
|
||||
|
||||
"Library.Empty.Title": "A könyvtárad üres",
|
||||
"Library.Empty.Hint": "Add meg a játékaidat tartalmazó mappát a kezdáshez.",
|
||||
"Library.Empty.SearchTitle": "Nincs találat a elemre",
|
||||
"Library.Empty.SearchHint": "A könyvtárban nincs olyan elem, amely egyezne a „{0}” kifejezéssel.",
|
||||
"Library.Empty.AddFolder": "+ Játékmappa hozzáadása",
|
||||
|
||||
"Library.Loading": "Könyvtár betöltése",
|
||||
|
||||
"Options.General": "Általános",
|
||||
"Options.Env.Tab": "Környezet",
|
||||
"Options.Section.Environment": "KÖRNYEZETI VÁLTOZÓK",
|
||||
"Options.Env.Desc": "Indításkor környezeti változóként az emulátorhoz átadott kapcsolók.",
|
||||
"Options.Env.Bthid.Desc": "Jelenti, amely címeknél a Bluetooth HID nem elérhető, amelyeknél a kormány/FFB-közbenső szoftver végtelenül lekérdezi az adatokat.\nNormál esetben hagyja ki. Egyes címek lefagyanak, ha az inicializálás sikertelen.",
|
||||
"Options.Env.LoopGuard.Desc": "Ne erőltesse a kilépést azoknál a címeknél, amelyek túl sokáig ismételnek ugyanazt a hívást.\nPróbálja ki ezt, ha egy játék betöltés közben magától kilép.",
|
||||
"Options.Env.VkValidation.Desc": "Engedélyezze a Vulkan-érvényesítési rétegeket a GPU hibakereséshez.\nLassú. A Vulkan SDK telepítését igényli.",
|
||||
"Options.Env.DumpSpirv.Desc": "Az AGC-shaderek és azok SPIR-V-fordításainak mentése a shader-dumps mappába.\nHasználd shader- vagy renderelési hibák jelentésekor.",
|
||||
"Options.Env.LogDirectMemory.Desc": "A közvetlen memóriaallokációk és hibák naplózása a konzolra.\nHasználd, ha egy játék a rendszerindítás során megszakad vagy kilép.",
|
||||
"Options.Env.LogNp.Desc": "Az NP (PlayStation Network) könyvtárhívásokat naplózza a konzolra.",
|
||||
"Options.Section.Emulation": "EMULÁCIÓ",
|
||||
"Options.Section.Logging": "LOGOLÁS",
|
||||
"Options.Section.Launcher": "INDITÓ",
|
||||
|
||||
"Options.CpuEngine.Label": "CPU motor",
|
||||
"Options.CpuEngine.Desc": "A játék kódjának futtatásához használt végrehajtó motor.",
|
||||
"Options.CpuEngine.Native": "Natív",
|
||||
|
||||
"Options.Strict.Label": "Szigorú dynlib felbontás",
|
||||
"Options.Strict.Desc": "Indítás megszakítása, ha egy importált szimbólum nem oldható fel.",
|
||||
|
||||
"Options.LogLevel.Label": "Naplózási szint",
|
||||
"Options.LogLevel.Desc": "Az emulátor konzol kimenetének részletessége.",
|
||||
"Options.LogLevel.Trace": "Trace",
|
||||
"Options.LogLevel.Debug": "Debug",
|
||||
"Options.LogLevel.Info": "Információ",
|
||||
"Options.LogLevel.Warning": "Figyelmeztetés",
|
||||
"Options.LogLevel.Error": "Hiba",
|
||||
"Options.LogLevel.Critical": "Kritikus",
|
||||
|
||||
"Options.TraceImports.Label": "Import trace limit",
|
||||
"Options.TraceImports.Desc": "Az első N darab import nyomon követése modulonként (0 = ki).",
|
||||
|
||||
"Options.LogToFile.Label": "Naplozás fájlba",
|
||||
"Options.LogToFile.Desc": "Az emulátor kimenetének tükrözése egy log fájlba.",
|
||||
|
||||
"Options.LogFilePath.Label": "Naplófájl elérési útja",
|
||||
"Options.LogFilePath.Default": "Nincs egyéni út — a logok az emulátor melletti user/logs mappába kerülnek.",
|
||||
"Options.LogFilePath.Select": "Kiválasztás…",
|
||||
|
||||
"Options.OverrideLogFile.Label": "Naplófájl felülírása",
|
||||
"Options.OverrideLogFile.Desc": "A pontos fájlútvonal használata a cím ID és időbélyeg hozzáfűzése helyett.",
|
||||
|
||||
"Options.TitleMusic.Label": "Címzene",
|
||||
"Options.TitleMusic.Desc": "A kiválasztott játék előnézeti zenéjének ismétlése a könyvtárban.",
|
||||
|
||||
"Options.Discord.Label": "Discord jelenlét",
|
||||
"Options.Discord.Desc": "A futó játék megjelenítése a Discord profilodon.",
|
||||
|
||||
"Options.Language.Label": "Emulátor nyelve",
|
||||
"Options.Language.Desc": "Az indítóban használt nyelv. Azonnal érvénybe lép.",
|
||||
|
||||
"Common.On": "Be",
|
||||
"Common.Off": "Ki",
|
||||
|
||||
"Console.Title": "KONZOL",
|
||||
"Console.SearchWatermark": "Keresés...",
|
||||
"Console.AutoScroll": "Automatikus görgetés",
|
||||
"Console.Split": "Felosztás",
|
||||
"Console.Copy": "Másolás",
|
||||
"Console.Clear": "Törlés",
|
||||
"Console.WindowTitle": "SharpEmu Konzol",
|
||||
|
||||
"Launch.NoGameSelected": "Nincs játék kiválasztva",
|
||||
"Launch.NoGameHint": "Válassz egy játékot a könyvtárból, vagy nyiss meg közvetlenül egy eboot.bin fájlt.",
|
||||
"Launch.Idle": "Tétlen",
|
||||
"Launch.Console": "≡ Konzol",
|
||||
"Launch.Launch": "▶ Inditás",
|
||||
"Launch.Stop": "■ Leállítás",
|
||||
"Launch.Running": "Fut — {0}",
|
||||
"Launch.Stopping": "Leállítás…",
|
||||
"Launch.Exited": "Kilépett a következő kóddal: {0} ({1})",
|
||||
"Launch.ExeNotFound": "A SharpEmu futtatható fájl nem található. Előbb építsd fel a SharpEmu.CLI projektet (dotnet build).",
|
||||
"Launch.LogFile": "Naplófájl: {0}",
|
||||
"Launch.Command": "$ SharpEmu {0}",
|
||||
"Launch.StartFailed": "Nem sikerült elindítani az emulátort: {0}",
|
||||
"Launch.ProcessExited": "A folyamat kilépett a következő kóddal: {0} ({1}).",
|
||||
|
||||
"Exit.Ok": "OK",
|
||||
"Exit.InvalidArguments": "nemérvényes argumentumok",
|
||||
"Exit.EbootNotFound": "eboot nem található",
|
||||
"Exit.RuntimeException": "runtime exception",
|
||||
"Exit.EmulationError": "emulációs hiba",
|
||||
"Exit.Unknown": "ismeretlen",
|
||||
|
||||
"Status.EmulatorLocating": "Emulátor: keresés…",
|
||||
"Status.EmulatorPath": "Emulátor: {0}",
|
||||
"Status.EmulatorNotFound": "Emulátor: a SharpEmu futtatható fájl nem található — előbb építsd fel a SharpEmu.CLI-t.",
|
||||
"Status.ScanningLibrary": "Könyvtár beolvasása…",
|
||||
"Status.AddFolderPrompt": "Adj hozzá egy játékmappát a könyvtár feltöltéséhez.",
|
||||
"Status.LibraryScanned": "Könyvtár beolvasva: {0} játék {1} mappában.",
|
||||
"Status.CouldNotOpenFolder": "Nem sikerült megnyitni a mappát: {0}",
|
||||
"Status.CopiedToClipboard": "{0} másolva a vágólapra.",
|
||||
"Status.RemovedFromLibrary": "„{0}” eltávolítva a könyvtárból. A visszaállításához add hozzá újra a mappáját.",
|
||||
"Status.Running": "Fut {0}",
|
||||
"Status.Stopping": "Leállítás…",
|
||||
"Status.Idle": "Nyugodt",
|
||||
|
||||
"Clipboard.Path": "Út",
|
||||
"Clipboard.TitleId": "Cím ID",
|
||||
|
||||
"Discord.Playing": "Játékban {0}",
|
||||
"Discord.Browsing": "Böngéssz a könyvtárban",
|
||||
|
||||
"Dialog.ChooseGameFolder": "Válassz egy mappát ami a játékaidat tartalmazza",
|
||||
"Dialog.OpenExecutable": "Futtatható fájl megnyitása az indításhoz",
|
||||
"Dialog.PsExecutables": "PS futtatható fájlok",
|
||||
"Dialog.SaveLogFile": "Válaszd ki, hogy hova szeretnéd menteni a napló fájlokat",
|
||||
"Dialog.PlainTextFiles": "Egyszerű szöveges fájlok",
|
||||
"Dialog.LogFiles": "Naplózási fájlok",
|
||||
|
||||
"Options.About" : "Erről",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Forrás kód, hibajelentések és a projekt fejlesztése.",
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Discord.Desc": "Csatlakozz a közösséghe, kérj segítéget és kövesd nyomon a fejlesztést.",
|
||||
"About.GithubButton": "Járulj hozzá GitHubon!",
|
||||
"About.DiscordButton": "Csatlakozz a Discordunhoz!"
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"_languageName": "Italiano",
|
||||
|
||||
"Page.Library": "Libreria",
|
||||
"Page.Options": "Opzioni",
|
||||
"Page.GameCount.One": "1 gioco",
|
||||
"Page.GameCount.Other": "{0} giochi",
|
||||
|
||||
"Library.SearchWatermark": "Cerca nella libreria…",
|
||||
"Library.AddFolder": "+ Aggiungi cartella",
|
||||
"Library.Rescan": "⟳ Riscansiona",
|
||||
"Library.OpenFile": "Apri file…",
|
||||
|
||||
"Library.Context.Launch": "Avvia",
|
||||
"Library.Context.OpenFolder": "Apri cartella gioco",
|
||||
"Library.Context.CopyPath": "Copia percorso",
|
||||
"Library.Context.CopyTitleId": "Copia ID titolo",
|
||||
"Library.Context.Remove": "Rimuovi dalla libreria",
|
||||
|
||||
"Library.Empty.Title": "La tua libreria è vuota",
|
||||
"Library.Empty.Hint": "Aggiungi la cartella che contiene i tuoi giochi per partire.",
|
||||
"Library.Empty.SearchTitle": "Nessun gioco corrisponde alla ricerca",
|
||||
"Library.Empty.SearchHint": "Nulla nella libreria corrisponde a “{0}”.",
|
||||
"Library.Empty.AddFolder": "+ Aggiungi cartella giochi",
|
||||
|
||||
"Library.Loading": "Caricamento libreria…",
|
||||
|
||||
"Options.General": "Generale",
|
||||
"Options.Section.Emulation": "EMULAZIONE",
|
||||
"Options.Section.Logging": "LOG",
|
||||
"Options.Section.Launcher": "LAUNCHER",
|
||||
|
||||
"Options.CpuEngine.Label": "CPU engine",
|
||||
"Options.CpuEngine.Desc": "Motore di esecuzione utilizzato per eseguire il codice del gioco.",
|
||||
"Options.CpuEngine.Native": "Nativo",
|
||||
|
||||
"Options.Strict.Label": "Risoluzione rigorosa dynlib",
|
||||
"Options.Strict.Desc": "Interrompi l'avvio quando un simbolo importato non può essere trovato.",
|
||||
|
||||
"Options.LogLevel.Label": "Livello Log",
|
||||
"Options.LogLevel.Desc": "Livello di dettaglio dell'output console dell'emulatore.",
|
||||
"Options.LogLevel.Trace": "Trace",
|
||||
"Options.LogLevel.Debug": "Debug",
|
||||
"Options.LogLevel.Info": "Info",
|
||||
"Options.LogLevel.Warning": "Warning",
|
||||
"Options.LogLevel.Error": "Error",
|
||||
"Options.LogLevel.Critical": "Critical",
|
||||
|
||||
"Options.TraceImports.Label": "Limite tracciamento import",
|
||||
"Options.TraceImports.Desc": "Traccia i primi N import per modulo (0 = disattivato).",
|
||||
|
||||
|
||||
"Options.LogToFile.Label": "Salva log su file",
|
||||
"Options.LogToFile.Desc": "Duplica l'output dell'emulatore in un file di log.",
|
||||
|
||||
|
||||
"Options.LogFilePath.Label": "Percorso file di log",
|
||||
"Options.LogFilePath.Default": "Nessun percorso personalizzato — i log vengono salvati in user/logs accanto all'emulatore.",
|
||||
"Options.LogFilePath.Select": "Seleziona…",
|
||||
|
||||
"Options.OverrideLogFile.Label": "Sovrascrivi file di log",
|
||||
"Options.OverrideLogFile.Desc": "Usa esattamente il percorso specificato invece di aggiungere Title ID e timestamp.",
|
||||
|
||||
|
||||
"Options.TitleMusic.Label": "Musica del titolo",
|
||||
"Options.TitleMusic.Desc": "Riproduci in loop la musica di anteprima del gioco selezionato nella libreria.",
|
||||
|
||||
"Options.Discord.Label": "Presenza Discord",
|
||||
"Options.Discord.Desc": "Mostra il gioco in esecuzione sul tuo profilo Discord.",
|
||||
|
||||
"Options.Language.Label": "Lingua dell'emulatore",
|
||||
"Options.Language.Desc": "Lingua utilizzata in tutto il launcher. Viene applicata immediatamente.",
|
||||
|
||||
"Common.On": "On",
|
||||
"Common.Off": "Off",
|
||||
|
||||
"Console.Title": "CONSOLE",
|
||||
"Console.SearchWatermark": "Cerca...",
|
||||
"Console.AutoScroll": "Scorrimento automatico",
|
||||
"Console.Split": "Dividi",
|
||||
"Console.Copy": "Copia",
|
||||
"Console.Clear": "Cancella",
|
||||
"Console.WindowTitle": "Console SharpEmu",
|
||||
|
||||
"Launch.NoGameSelected": "Nessun gioco selezionato",
|
||||
"Launch.NoGameHint": "Scegli un gioco dalla libreria, oppure apri direttamente un eboot.bin.",
|
||||
"Launch.Idle": "Inattivo",
|
||||
"Launch.Console": "≡ Console",
|
||||
"Launch.Launch": "▶ Avvia",
|
||||
"Launch.Stop": "■ Ferma",
|
||||
"Launch.Running": "In esecuzione — {0}",
|
||||
"Launch.Stopping": "Arresto in corso…",
|
||||
"Launch.Exited": "Terminato con codice {0} ({1})",
|
||||
"Launch.ExeNotFound": "Eseguibile SharpEmu non trovato. Compila prima il progetto SharpEmu.CLI (dotnet build).",
|
||||
"Launch.LogFile": "File di log: {0}",
|
||||
"Launch.Command": "$ SharpEmu {0}",
|
||||
"Launch.StartFailed": "Impossibile avviare l'emulatore: {0}",
|
||||
"Launch.ProcessExited": "Processo terminato con codice {0} ({1}).",
|
||||
|
||||
|
||||
"Exit.Ok": "OK",
|
||||
"Exit.InvalidArguments": "argomenti non validi",
|
||||
"Exit.EbootNotFound": "eboot non trovato",
|
||||
"Exit.RuntimeException": "errore di runtime",
|
||||
"Exit.EmulationError": "errore di emulazione",
|
||||
"Exit.Unknown": "sconosciuto",
|
||||
|
||||
|
||||
"Status.EmulatorLocating": "Emulatore: ricerca in corso…",
|
||||
"Status.EmulatorPath": "Emulatore: {0}",
|
||||
"Status.EmulatorNotFound": "Emulatore: eseguibile SharpEmu non trovato — compila prima SharpEmu.CLI.",
|
||||
"Status.ScanningLibrary": "Scansione della libreria…",
|
||||
"Status.AddFolderPrompt": "Aggiungi una cartella di giochi per popolare la libreria.",
|
||||
"Status.LibraryScanned": "Libreria scansionata: {0} gioco/giochi in {1} cartella/e.",
|
||||
"Status.CouldNotOpenFolder": "Impossibile aprire la cartella: {0}",
|
||||
"Status.CopiedToClipboard": "{0} copiato negli appunti.",
|
||||
"Status.RemovedFromLibrary": "“{0}” rimosso dalla libreria. Riaggiungi la sua cartella per ripristinarlo.",
|
||||
"Status.Running": "In esecuzione: {0}",
|
||||
"Status.Stopping": "Arresto in corso…",
|
||||
"Status.Idle": "Inattivo",
|
||||
|
||||
"Clipboard.Path": "Percorso",
|
||||
"Clipboard.TitleId": "ID Titolo",
|
||||
|
||||
"Discord.Playing": "Sta giocando a {0}",
|
||||
"Discord.Browsing": "Sta esplorando la libreria",
|
||||
|
||||
"Dialog.ChooseGameFolder": "Scegli una cartella contenente giochi",
|
||||
"Dialog.OpenExecutable": "Apri un eseguibile da avviare",
|
||||
"Dialog.PsExecutables": "Eseguibili PS",
|
||||
"Dialog.SaveLogFile": "Scegli dove salvare il file di log",
|
||||
"Dialog.PlainTextFiles": "File di testo semplice",
|
||||
"Dialog.LogFiles": "File di log"
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"_languageName": "日本語",
|
||||
|
||||
"Page.Library": "ライブラリ",
|
||||
"Page.Options": "オプション",
|
||||
"Page.GameCount.One": "ゲーム 1本",
|
||||
"Page.GameCount.Other": "ゲーム {0}本",
|
||||
|
||||
"Library.SearchWatermark": "ライブラリを検索…",
|
||||
"Library.AddFolder": "+ フォルダーを追加",
|
||||
"Library.Rescan": "⟳ 再スキャン",
|
||||
"Library.OpenFile": "ファイルを開く…",
|
||||
|
||||
"Library.Context.Launch": "起動",
|
||||
"Library.Context.OpenFolder": "ゲームフォルダーを開く",
|
||||
"Library.Context.CopyPath": "パスをコピー",
|
||||
"Library.Context.CopyTitleId": "ゲームIDをコピー",
|
||||
"Library.Context.Remove": "ライブラリから削除",
|
||||
|
||||
"Library.Empty.Title": "ライブラリが空です",
|
||||
"Library.Empty.Hint": "開始するには、ゲームが含まれるフォルダーを追加してください。",
|
||||
"Library.Empty.SearchTitle": "検索条件に一致するゲームが見つかりません",
|
||||
"Library.Empty.SearchHint": "ライブラリに「{0}」と一致する項目はありません。",
|
||||
"Library.Empty.AddFolder": "+ ゲームフォルダーを追加",
|
||||
|
||||
"Library.Loading": "ライブラリを読み込み中…",
|
||||
|
||||
"Options.General": "一般",
|
||||
"Options.Section.Emulation": "エミュレーション",
|
||||
"Options.Section.Logging": "ロギング",
|
||||
"Options.Section.Launcher": "ランチャー",
|
||||
|
||||
"Options.CpuEngine.Label": "CPUエンジン",
|
||||
"Options.CpuEngine.Desc": "ゲームコードを実行するために使用される実行エンジン。",
|
||||
"Options.CpuEngine.Native": "ネイティブ",
|
||||
|
||||
"Options.Strict.Label": "厳格な動的ライブラリ解決",
|
||||
"Options.Strict.Desc": "インポートされたシンボルが解決できない場合、起動を中断します。",
|
||||
|
||||
"Options.LogLevel.Label": "ログレベル",
|
||||
"Options.LogLevel.Desc": "エミュレータコンソールに表示されるメッセージの詳細度。",
|
||||
"Options.LogLevel.Trace": "トレース",
|
||||
"Options.LogLevel.Debug": "デバッグ",
|
||||
"Options.LogLevel.Info": "情報",
|
||||
"Options.LogLevel.Warning": "警告",
|
||||
"Options.LogLevel.Error": "エラー",
|
||||
"Options.LogLevel.Critical": "致命的なエラー",
|
||||
|
||||
"Options.TraceImports.Label": "インポートトレース制限",
|
||||
"Options.TraceImports.Desc": "各モジュールの最初のN個のインポートをトレースします(0 = 無効)。",
|
||||
|
||||
"Options.LogToFile.Label": "ファイルに保存",
|
||||
"Options.LogToFile.Desc": "エミュレータの出力をログファイルにコピーします。",
|
||||
|
||||
"Options.LogFilePath.Label": "ログファイルのパス",
|
||||
"Options.LogFilePath.Default": "カスタムパスなし — ログはエミュレータと同じ場所の user/logs フォルダーに保存されます。",
|
||||
"Options.LogFilePath.Select": "選択…",
|
||||
|
||||
"Options.OverrideLogFile.Label": "ログファイルを上書き",
|
||||
"Options.OverrideLogFile.Desc": "ゲームIDやタイムスタンプを追加せず、指定されたパスをそのまま使用します。",
|
||||
|
||||
"Options.TitleMusic.Label": "ゲーム内音楽",
|
||||
"Options.TitleMusic.Desc": "ライブラリで選択したゲームのプレビュー音楽をループ再生します。",
|
||||
|
||||
"Options.Discord.Label": "Discordステータス表示",
|
||||
"Options.Discord.Desc": "現在プレイ中のゲームをDiscordのプロフィールに表示します。",
|
||||
|
||||
"Options.Language.Label": "エミュレータの言語",
|
||||
"Options.Language.Desc": "ランチャー全体で使用される言語。変更はすぐに適用されます。",
|
||||
|
||||
"Common.On": "オン",
|
||||
"Common.Off": "オフ",
|
||||
|
||||
"Console.Title": "コンソール",
|
||||
"Console.SearchWatermark": "検索…",
|
||||
"Console.AutoScroll": "自動スクロール",
|
||||
"Console.Split": "ウィンドウを分離",
|
||||
"Console.Copy": "コピー",
|
||||
"Console.Clear": "消去",
|
||||
"Console.WindowTitle": "SharpEmu コンソール",
|
||||
|
||||
"Launch.NoGameSelected": "ゲームが選択されていません",
|
||||
"Launch.NoGameHint": "ライブラリからゲームを選択するか、eboot.bin ファイルを直接開いてください。",
|
||||
"Launch.Idle": "待機中",
|
||||
"Launch.Console": "≡ コンソール",
|
||||
"Launch.Launch": "▶ 起動",
|
||||
"Launch.Stop": "■ 停止",
|
||||
"Launch.Running": "実行中 — {0}",
|
||||
"Launch.Stopping": "停止中…",
|
||||
"Launch.Exited": "プロセスがコード {0} ({1}) で終了しました",
|
||||
"Launch.ExeNotFound": "SharpEmuの実行ファイルが見つかりません。先に SharpEmu.CLI プロジェクトをビルドしてください(dotnet build)。",
|
||||
"Launch.LogFile": "ログファイル: {0}",
|
||||
"Launch.Command": "$ SharpEmu {0}",
|
||||
"Launch.StartFailed": "エミュレータを起動できませんでした: {0}",
|
||||
"Launch.ProcessExited": "プロセスがコード {0} ({1}) で終了しました。",
|
||||
|
||||
"Exit.Ok": "OK",
|
||||
"Exit.InvalidArguments": "無効な引数",
|
||||
"Exit.EbootNotFound": "eboot が見つかりません",
|
||||
"Exit.RuntimeException": "ランタイム例外",
|
||||
"Exit.EmulationError": "エミュレーションエラー",
|
||||
"Exit.Unknown": "不明",
|
||||
|
||||
"Status.EmulatorLocating": "エミュレータ: 位置を検索中…",
|
||||
"Status.EmulatorPath": "エミュレータ: {0}",
|
||||
"Status.EmulatorNotFound": "エミュレータ: SharpEmuの実行ファイルが見つかりません — 先に SharpEmu.CLI をビルドしてください。",
|
||||
"Status.ScanningLibrary": "ライブラリをスキャン中…",
|
||||
"Status.AddFolderPrompt": "ライブラリに表示するゲームフォルダーを追加してください。",
|
||||
"Status.LibraryScanned": "ライブラリのスキャン完了: {1} 個のフォルダーから {0} 本のゲームを検出。",
|
||||
"Status.CouldNotOpenFolder": "フォルダーを開けませんでした: {0}",
|
||||
"Status.CopiedToClipboard": "「{0}」をクリップボードにコピーしました。",
|
||||
"Status.RemovedFromLibrary": "「{0}」がライブラリから削除されました。復元するにはフォルダーを再追加してください。",
|
||||
"Status.Running": "{0} を実行中",
|
||||
"Status.Stopping": "停止中…",
|
||||
"Status.Idle": "待機中",
|
||||
|
||||
"Clipboard.Path": "パス",
|
||||
"Clipboard.TitleId": "ゲームID",
|
||||
|
||||
"Discord.Playing": "{0} をプレイ中",
|
||||
"Discord.Browsing": "ライブラリを閲覧中",
|
||||
|
||||
"Dialog.ChooseGameFolder": "ゲームが含まれるフォルダーを選択",
|
||||
"Dialog.OpenExecutable": "起動する実行ファイルを開く",
|
||||
"Dialog.PsExecutables": "PlayStation 実行ファイル",
|
||||
"Dialog.SaveLogFile": "ログファイルの保存先を選択",
|
||||
"Dialog.PlainTextFiles": "プレーンテキストファイル",
|
||||
"Dialog.LogFiles": "ログファイル"
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"_languageName": "한국어",
|
||||
|
||||
"Page.Library": "라이브러리",
|
||||
"Page.Options": "옵션",
|
||||
"Page.GameCount.One": "게임 1개",
|
||||
"Page.GameCount.Other": "게임 {0}개",
|
||||
|
||||
"Library.SearchWatermark": "라이브러리 검색…",
|
||||
"Library.AddFolder": "+ 폴더 추가",
|
||||
"Library.Rescan": "⟳ 다시 스캔",
|
||||
"Library.OpenFile": "파일 열기…",
|
||||
|
||||
"Library.Context.Launch": "실행",
|
||||
"Library.Context.OpenFolder": "게임 폴더 열기",
|
||||
"Library.Context.CopyPath": "경로 복사",
|
||||
"Library.Context.CopyTitleId": "게임 ID 복사",
|
||||
"Library.Context.Remove": "라이브러리에서 제거",
|
||||
|
||||
"Library.Empty.Title": "라이브러리가 비어 있습니다",
|
||||
"Library.Empty.Hint": "시작하려면 게임이 포함된 폴더를 추가하세요.",
|
||||
"Library.Empty.SearchTitle": "검색 결과와 일치하는 게임이 없습니다",
|
||||
"Library.Empty.SearchHint": "라이브러리에 '{0}'와(과) 일치하는 항목이 없습니다.",
|
||||
"Library.Empty.AddFolder": "+ 게임 폴더 추가",
|
||||
|
||||
"Library.Loading": "라이브러리 불러오는 중…",
|
||||
|
||||
"Options.General": "일반",
|
||||
"Options.Section.Emulation": "에뮬레이션",
|
||||
"Options.Section.Logging": "로깅",
|
||||
"Options.Section.Launcher": "런처",
|
||||
|
||||
"Options.CpuEngine.Label": "CPU 엔진",
|
||||
"Options.CpuEngine.Desc": "게임 코드를 실행하는 데 사용되는 실행 엔진입니다.",
|
||||
"Options.CpuEngine.Native": "네이티브",
|
||||
|
||||
"Options.Strict.Label": "엄격한 동적 라이브러리 해석",
|
||||
"Options.Strict.Desc": "가져온 심볼을 해석할 수 없는 경우 실행을 중단합니다.",
|
||||
|
||||
"Options.LogLevel.Label": "로그 수준",
|
||||
"Options.LogLevel.Desc": "에뮬레이터 콘솔에 표시할 메시지의 세부 정보 수준입니다.",
|
||||
"Options.LogLevel.Trace": "트레이스",
|
||||
"Options.LogLevel.Debug": "디버그",
|
||||
"Options.LogLevel.Info": "정보",
|
||||
"Options.LogLevel.Warning": "경고",
|
||||
"Options.LogLevel.Error": "오류",
|
||||
"Options.LogLevel.Critical": "치명적 오류",
|
||||
|
||||
"Options.TraceImports.Label": "가져오기 트레이스 한도",
|
||||
"Options.TraceImports.Desc": "각 모듈의 처음 N개 가져오기를 트레이스합니다 (0 = 비활성화).",
|
||||
|
||||
"Options.LogToFile.Label": "파일로 저장",
|
||||
"Options.LogToFile.Desc": "에뮬레이터 출력을 로그 파일에 복사합니다.",
|
||||
|
||||
"Options.LogFilePath.Label": "로그 파일 경로",
|
||||
"Options.LogFilePath.Default": "사용자 지정 경로 없음 — 로그는 에뮬레이터 옆의 user/logs 폴더에 저장됩니다.",
|
||||
"Options.LogFilePath.Select": "선택…",
|
||||
|
||||
"Options.OverrideLogFile.Label": "로그 파일 덮어쓰기",
|
||||
"Options.OverrideLogFile.Desc": "게임 ID와 타임스탬프를 추가하는 대신 정확히 이 경로를 사용합니다.",
|
||||
|
||||
"Options.TitleMusic.Label": "게임 음악",
|
||||
"Options.TitleMusic.Desc": "라이브러리에서 선택한 게임의 미리보기 음악을 반복 재생합니다.",
|
||||
|
||||
"Options.Discord.Label": "디스코드 상태 표시",
|
||||
"Options.Discord.Desc": "디스코드 프로필에 현재 실행 중인 게임을 표시합니다.",
|
||||
|
||||
"Options.Language.Label": "에뮬레이터 언어",
|
||||
"Options.Language.Desc": "런처 전체에 사용되는 언어입니다. 변경 사항은 즉시 적용됩니다.",
|
||||
|
||||
"Common.On": "켬",
|
||||
"Common.Off": "끔",
|
||||
|
||||
"Console.Title": "콘솔",
|
||||
"Console.SearchWatermark": "검색…",
|
||||
"Console.AutoScroll": "자동 스크롤",
|
||||
"Console.Split": "창 분리",
|
||||
"Console.Copy": "복사",
|
||||
"Console.Clear": "지우기",
|
||||
"Console.WindowTitle": "SharpEmu 콘솔",
|
||||
|
||||
"Launch.NoGameSelected": "선택된 게임 없음",
|
||||
"Launch.NoGameHint": "라이브러리에서 게임을 선택하거나 eboot.bin 파일을 직접 여세요.",
|
||||
"Launch.Idle": "대기 중",
|
||||
"Launch.Console": "≡ 콘솔",
|
||||
"Launch.Launch": "▶ 실행",
|
||||
"Launch.Stop": "■ 중지",
|
||||
"Launch.Running": "실행 중 — {0}",
|
||||
"Launch.Stopping": "중지 중…",
|
||||
"Launch.Exited": "프로세스가 코드 {0} ({1})(으)로 종료되었습니다",
|
||||
"Launch.ExeNotFound": "SharpEmu 실행 파일을 찾을 수 없습니다. 먼저 SharpEmu.CLI 프로젝트를 컴파일하세요 (dotnet build).",
|
||||
"Launch.LogFile": "로그 파일: {0}",
|
||||
"Launch.Command": "$ SharpEmu {0}",
|
||||
"Launch.StartFailed": "에뮬레이터를 시작할 수 없습니다: {0}",
|
||||
"Launch.ProcessExited": "프로세스가 코드 {0} ({1})(으)로 종료되었습니다.",
|
||||
|
||||
"Exit.Ok": "확인",
|
||||
"Exit.InvalidArguments": "잘못된 인수",
|
||||
"Exit.EbootNotFound": "eboot을 찾을 수 없음",
|
||||
"Exit.RuntimeException": "런타임 예외",
|
||||
"Exit.EmulationError": "에뮬레이션 오류",
|
||||
"Exit.Unknown": "알 수 없음",
|
||||
|
||||
"Status.EmulatorLocating": "에뮬레이터: 위치 검색 중…",
|
||||
"Status.EmulatorPath": "에뮬레이터: {0}",
|
||||
"Status.EmulatorNotFound": "에뮬레이터: SharpEmu 실행 파일을 찾을 수 없습니다 — 먼저 SharpEmu.CLI를 컴파일하세요.",
|
||||
"Status.ScanningLibrary": "라이브러리 스캔 중…",
|
||||
"Status.AddFolderPrompt": "라이브러리를 채우려면 게임 폴더를 추가하세요.",
|
||||
"Status.LibraryScanned": "라이브러리 스캔 완료: {1}개 폴더에서 {0}개 게임 발견.",
|
||||
"Status.CouldNotOpenFolder": "폴더를 열 수 없습니다: {0}",
|
||||
"Status.CopiedToClipboard": "{0}이(가) 클립보드에 복사되었습니다.",
|
||||
"Status.RemovedFromLibrary": "'{0}'이(가) 라이브러리에서 제거되었습니다. 복구하려면 폴더를 다시 추가하세요.",
|
||||
"Status.Running": "{0} 실행 중",
|
||||
"Status.Stopping": "중지 중…",
|
||||
"Status.Idle": "대기 중",
|
||||
|
||||
"Clipboard.Path": "경로",
|
||||
"Clipboard.TitleId": "게임 ID",
|
||||
|
||||
"Discord.Playing": "{0} 플레이 중",
|
||||
"Discord.Browsing": "라이브러리 둘러보는 중",
|
||||
|
||||
"Dialog.ChooseGameFolder": "게임이 포함된 폴더 선택",
|
||||
"Dialog.OpenExecutable": "실행할 파일 열기",
|
||||
"Dialog.PsExecutables": "PlayStation 실행 파일",
|
||||
"Dialog.SaveLogFile": "로그 파일 저장 위치 선택",
|
||||
"Dialog.PlainTextFiles": "일반 텍스트 파일",
|
||||
"Dialog.LogFiles": "로그 파일"
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"_languageName": "Nederlands",
|
||||
|
||||
"Page.Library": "Bibliotheek",
|
||||
"Page.Options": "Opties",
|
||||
"Page.GameCount.One": "1 game",
|
||||
"Page.GameCount.Other": "{0} games",
|
||||
|
||||
"Library.SearchWatermark": "Zoeken in bibliotheek…",
|
||||
"Library.AddFolder": "+ Map toevoegen",
|
||||
"Library.Rescan": "⟳ Opnieuw scannen",
|
||||
"Library.OpenFile": "Bestand openen…",
|
||||
|
||||
"Library.Context.Launch": "Starten",
|
||||
"Library.Context.OpenFolder": "Gamemap openen",
|
||||
"Library.Context.CopyPath": "Pad kopiëren",
|
||||
"Library.Context.CopyTitleId": "Titel-ID kopiëren",
|
||||
"Library.Context.Remove": "Verwijderen uit bibliotheek",
|
||||
|
||||
"Library.Empty.Title": "Je bibliotheek is leeg",
|
||||
"Library.Empty.Hint": "Voeg een map met je games toe om te beginnen.",
|
||||
"Library.Empty.SearchTitle": "Geen games komen overeen met je zoekopdracht",
|
||||
"Library.Empty.SearchHint": "Niets in de bibliotheek komt overeen met “{0}”.",
|
||||
"Library.Empty.AddFolder": "+ Gamemap toevoegen",
|
||||
|
||||
"Library.Loading": "Bibliotheek laden…",
|
||||
|
||||
"Options.General": "Algemeen",
|
||||
"Options.Section.Emulation": "EMULATIE",
|
||||
"Options.Section.Logging": "LOGGING",
|
||||
"Options.Section.Launcher": "LAUNCHER",
|
||||
|
||||
"Options.CpuEngine.Label": "CPU-engine",
|
||||
"Options.CpuEngine.Desc": "Engine die wordt gebruikt om gamecode uit te voeren.",
|
||||
"Options.CpuEngine.Native": "Native",
|
||||
|
||||
"Options.Strict.Label": "Strikte dynlib-resolutie",
|
||||
"Options.Strict.Desc": "Laat het opstarten mislukken wanneer een geïmporteerd symbool niet kan worden opgelost.",
|
||||
|
||||
"Options.LogLevel.Label": "Logniveau",
|
||||
"Options.LogLevel.Desc": "Uitgebreidheid van de console-uitvoer van de emulator.",
|
||||
"Options.LogLevel.Trace": "Trace",
|
||||
"Options.LogLevel.Debug": "Debug",
|
||||
"Options.LogLevel.Info": "Info",
|
||||
"Options.LogLevel.Warning": "Waarschuwing",
|
||||
"Options.LogLevel.Error": "Fout",
|
||||
"Options.LogLevel.Critical": "Kritiek",
|
||||
|
||||
"Options.TraceImports.Label": "Tracelimiet voor imports",
|
||||
"Options.TraceImports.Desc": "Traceer de eerste N imports per module (0 = uit).",
|
||||
|
||||
"Options.LogToFile.Label": "Loggen naar bestand",
|
||||
"Options.LogToFile.Desc": "Stuur de uitvoer van de emulator ook naar een logbestand.",
|
||||
|
||||
"Options.LogFilePath.Label": "Pad naar logbestand",
|
||||
"Options.LogFilePath.Default": "Geen aangepast pad — logs komen terecht in user/logs naast de emulator.",
|
||||
"Options.LogFilePath.Select": "Selecteren…",
|
||||
|
||||
"Options.OverrideLogFile.Label": "Logbestand overschrijven",
|
||||
"Options.OverrideLogFile.Desc": "Gebruik het exacte bestandspad in plaats van de titel-ID en tijdstempel toe te voegen.",
|
||||
|
||||
"Options.TitleMusic.Label": "Titelmuziek",
|
||||
"Options.TitleMusic.Desc": "Herhaal de voorbeeldmuziek van de geselecteerde game in de bibliotheek.",
|
||||
|
||||
"Options.Discord.Label": "Discord-aanwezigheid",
|
||||
"Options.Discord.Desc": "Toon de actieve game op je Discord-profiel.",
|
||||
|
||||
"Options.Language.Label": "Taal van de emulator",
|
||||
"Options.Language.Desc": "Taal die in de hele launcher wordt gebruikt. Wordt direct toegepast.",
|
||||
|
||||
"Common.On": "Aan",
|
||||
"Common.Off": "Uit",
|
||||
|
||||
"Console.Title": "CONSOLE",
|
||||
"Console.SearchWatermark": "Zoeken...",
|
||||
"Console.AutoScroll": "Automatisch scrollen",
|
||||
"Console.Split": "Splitsen",
|
||||
"Console.Copy": "Kopiëren",
|
||||
"Console.Clear": "Wissen",
|
||||
"Console.WindowTitle": "SharpEmu-console",
|
||||
|
||||
"Launch.NoGameSelected": "Geen game geselecteerd",
|
||||
"Launch.NoGameHint": "Kies een game uit de bibliotheek, of open direct een eboot.bin-bestand.",
|
||||
"Launch.Idle": "Inactief",
|
||||
"Launch.Console": "≡ Console",
|
||||
"Launch.Launch": "▶ Starten",
|
||||
"Launch.Stop": "■ Stoppen",
|
||||
"Launch.Running": "Actief — {0}",
|
||||
"Launch.Stopping": "Stoppen…",
|
||||
"Launch.Exited": "Afgesloten met code {0} ({1})",
|
||||
"Launch.ExeNotFound": "SharpEmu-uitvoerbaar bestand niet gevonden. Bouw eerst het SharpEmu.CLI-project (dotnet build).",
|
||||
"Launch.LogFile": "Logbestand: {0}",
|
||||
"Launch.Command": "$ SharpEmu {0}",
|
||||
"Launch.StartFailed": "Starten van de emulator mislukt: {0}",
|
||||
"Launch.ProcessExited": "Proces afgesloten met code {0} ({1}).",
|
||||
|
||||
"Exit.Ok": "OK",
|
||||
"Exit.InvalidArguments": "ongeldige argumenten",
|
||||
"Exit.EbootNotFound": "eboot niet gevonden",
|
||||
"Exit.RuntimeException": "runtime-uitzondering",
|
||||
"Exit.EmulationError": "emulatiefout",
|
||||
"Exit.Unknown": "onbekend",
|
||||
|
||||
"Status.EmulatorLocating": "Emulator: zoeken…",
|
||||
"Status.EmulatorPath": "Emulator: {0}",
|
||||
"Status.EmulatorNotFound": "Emulator: SharpEmu-uitvoerbaar bestand niet gevonden — bouw eerst SharpEmu.CLI.",
|
||||
"Status.ScanningLibrary": "Bibliotheek scannen…",
|
||||
"Status.AddFolderPrompt": "Voeg een gamemap toe om de bibliotheek te vullen.",
|
||||
"Status.LibraryScanned": "Bibliotheek gescand: {0} game(s) in {1} map(pen).",
|
||||
"Status.CouldNotOpenFolder": "Kan map niet openen: {0}",
|
||||
"Status.CopiedToClipboard": "{0} gekopieerd naar klembord.",
|
||||
"Status.RemovedFromLibrary": "“{0}” verwijderd uit de bibliotheek. Voeg de map opnieuw toe om dit te herstellen.",
|
||||
"Status.Running": "Actief {0}",
|
||||
"Status.Stopping": "Stoppen…",
|
||||
"Status.Idle": "Inactief",
|
||||
|
||||
"Clipboard.Path": "Pad",
|
||||
"Clipboard.TitleId": "Titel-ID",
|
||||
|
||||
"Discord.Playing": "Speelt {0}",
|
||||
"Discord.Browsing": "Bladert door de bibliotheek",
|
||||
|
||||
"Dialog.ChooseGameFolder": "Kies een map met games",
|
||||
"Dialog.OpenExecutable": "Open een uitvoerbaar bestand om te starten",
|
||||
"Dialog.PsExecutables": "PS-uitvoerbare bestanden",
|
||||
"Dialog.SaveLogFile": "Selecteer waar het logbestand moet worden opgeslagen",
|
||||
"Dialog.PlainTextFiles": "Platte tekstbestanden",
|
||||
"Dialog.LogFiles": "Logbestanden"
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
{
|
||||
"_languageName": "Português (Portugal)",
|
||||
|
||||
"Page.Library": "Biblioteca",
|
||||
"Page.Options": "Opções",
|
||||
"Page.GameCount.One": "1 jogo",
|
||||
"Page.GameCount.Other": "{0} jogos",
|
||||
|
||||
"Library.SearchWatermark": "Pesquisar biblioteca…",
|
||||
"Library.AddFolder": "+ Adicionar pasta",
|
||||
"Library.Rescan": "⟳ Reanalisar",
|
||||
"Library.OpenFile": "Abrir ficheiro…",
|
||||
|
||||
"Library.Context.Launch": "Iniciar",
|
||||
"Library.Context.OpenFolder": "Abrir pasta do jogo",
|
||||
"Library.Context.CopyPath": "Copiar caminho",
|
||||
"Library.Context.CopyTitleId": "Copiar ID do título",
|
||||
"Library.Context.Remove": "Remover da biblioteca",
|
||||
|
||||
"Library.Empty.Title": "A sua biblioteca está vazia",
|
||||
"Library.Empty.Hint": "Adicione uma pasta com os seus jogos para começar.",
|
||||
"Library.Empty.SearchTitle": "Nenhum jogo corresponde à sua pesquisa",
|
||||
"Library.Empty.SearchHint": "Nada na biblioteca corresponde a “{0}”.",
|
||||
"Library.Empty.AddFolder": "+ Adicionar pasta de jogos",
|
||||
|
||||
"Library.Loading": "A carregar biblioteca…",
|
||||
|
||||
"Options.General": "Geral",
|
||||
"Options.Env.Tab": "Ambiente",
|
||||
"Options.Section.Environment": "VARIÁVEIS DE AMBIENTE",
|
||||
"Options.Env.Desc": "Switches passados ao emulador como variáveis de ambiente no arranque.",
|
||||
"Options.Env.Bthid.Desc": "Reporta o Bluetooth HID como indisponível para títulos cujo middleware de volante/FFB fica à espera indefinidamente.\nDeixe desativado normalmente. Alguns títulos bloqueiam quando a inicialização falha.",
|
||||
"Options.Env.LoopGuard.Desc": "Não force o encerramento de títulos que repetem a mesma chamada durante demasiado tempo.\nExperimente isto quando um jogo fecha sozinho durante o carregamento.",
|
||||
"Options.Env.VkValidation.Desc": "Ativa as camadas de validação do Vulkan para depuração da GPU.\nLento. Requer que o Vulkan SDK esteja instalado.",
|
||||
"Options.Env.DumpSpirv.Desc": "Extrai os shaders AGC e as respetivas traduções SPIR-V para a pasta shader-dumps.\nUtilize ao reportar problemas de shaders ou renderização.",
|
||||
"Options.Env.LogDirectMemory.Desc": "Regista alocações de memória direta e falhas na consola.\nUtilize quando um jogo aborta ou fecha durante o arranque.",
|
||||
"Options.Env.LogNp.Desc": "Regista chamadas da biblioteca NP (PlayStation Network) na consola.",
|
||||
"Options.Section.Emulation": "EMULAÇÃO",
|
||||
"Options.Section.Logging": "REGISTOS",
|
||||
"Options.Section.Launcher": "LANÇADOR",
|
||||
|
||||
"Options.CpuEngine.Label": "Motor de CPU",
|
||||
"Options.CpuEngine.Desc": "Motor de execução utilizado para correr o código do jogo.",
|
||||
"Options.CpuEngine.Native": "Nativo",
|
||||
|
||||
"Options.Strict.Label": "Resolução estrita de dynlib",
|
||||
"Options.Strict.Desc": "Falha o arranque quando um símbolo importado não pode ser resolvido.",
|
||||
|
||||
"Options.LogLevel.Label": "Nível de registo",
|
||||
"Options.LogLevel.Desc": "Nível de detalhe da saída da consola do emulador.",
|
||||
"Options.LogLevel.Trace": "Rastreio",
|
||||
"Options.LogLevel.Debug": "Depuração",
|
||||
"Options.LogLevel.Info": "Informação",
|
||||
"Options.LogLevel.Warning": "Aviso",
|
||||
"Options.LogLevel.Error": "Erro",
|
||||
"Options.LogLevel.Critical": "Crítico",
|
||||
|
||||
"Options.TraceImports.Label": "Limite de rastreio de importações",
|
||||
"Options.TraceImports.Desc": "Rastreia as primeiras N importações por módulo (0 = desativado).",
|
||||
|
||||
"Options.LogToFile.Label": "Registar para ficheiro",
|
||||
"Options.LogToFile.Desc": "Duplicar a saída do emulador para um ficheiro de registo.",
|
||||
|
||||
"Options.LogFilePath.Label": "Caminho do ficheiro de registo",
|
||||
"Options.LogFilePath.Default": "Sem caminho personalizado — os registos vão para user/logs junto ao emulador.",
|
||||
"Options.LogFilePath.Select": "Selecionar…",
|
||||
|
||||
"Options.OverrideLogFile.Label": "Substituir ficheiro de registo",
|
||||
"Options.OverrideLogFile.Desc": "Utilizar o caminho de ficheiro exato em vez de acrescentar o ID do título e a hora.",
|
||||
|
||||
"Options.TitleMusic.Label": "Música do título",
|
||||
"Options.TitleMusic.Desc": "Repetir em loop a música de pré-visualização do jogo selecionado na biblioteca.",
|
||||
|
||||
"Options.Discord.Label": "Presença no Discord",
|
||||
"Options.Discord.Desc": "Mostrar o jogo em execução no seu perfil do Discord.",
|
||||
|
||||
"Options.Language.Label": "Idioma do emulador",
|
||||
"Options.Language.Desc": "Idioma utilizado em todo o lançador. Aplica-se de imediato.",
|
||||
|
||||
"Common.On": "Ativado",
|
||||
"Common.Off": "Desativado",
|
||||
|
||||
"Console.Title": "CONSOLA",
|
||||
"Console.SearchWatermark": "Pesquisar...",
|
||||
"Console.AutoScroll": "Deslocamento automático",
|
||||
"Console.Split": "Dividir",
|
||||
"Console.Copy": "Copiar",
|
||||
"Console.Clear": "Limpar",
|
||||
"Console.WindowTitle": "Consola do SharpEmu",
|
||||
|
||||
"Launch.NoGameSelected": "Nenhum jogo selecionado",
|
||||
"Launch.NoGameHint": "Escolha um jogo da biblioteca ou abra um eboot.bin diretamente.",
|
||||
"Launch.Idle": "Inativo",
|
||||
"Launch.Console": "≡ Consola",
|
||||
"Launch.Launch": "▶ Iniciar",
|
||||
"Launch.Stop": "■ Parar",
|
||||
"Launch.Running": "Em execução — {0}",
|
||||
"Launch.Stopping": "A parar…",
|
||||
"Launch.Exited": "Terminou com o código {0} ({1})",
|
||||
"Launch.ExeNotFound": "Executável do SharpEmu não encontrado. Compile primeiro o projeto SharpEmu.CLI (dotnet build).",
|
||||
"Launch.LogFile": "Ficheiro de registo: {0}",
|
||||
"Launch.Command": "$ SharpEmu {0}",
|
||||
"Launch.StartFailed": "Falha ao iniciar o emulador: {0}",
|
||||
"Launch.ProcessExited": "O processo terminou com o código {0} ({1}).",
|
||||
|
||||
"Exit.Ok": "OK",
|
||||
"Exit.InvalidArguments": "argumentos inválidos",
|
||||
"Exit.EbootNotFound": "eboot não encontrado",
|
||||
"Exit.RuntimeException": "exceção em tempo de execução",
|
||||
"Exit.EmulationError": "erro de emulação",
|
||||
"Exit.Unknown": "desconhecido",
|
||||
|
||||
"Status.EmulatorLocating": "Emulador: a localizar…",
|
||||
"Status.EmulatorPath": "Emulador: {0}",
|
||||
"Status.EmulatorNotFound": "Emulador: executável do SharpEmu não encontrado — compile primeiro o SharpEmu.CLI.",
|
||||
"Status.ScanningLibrary": "A analisar biblioteca…",
|
||||
"Status.AddFolderPrompt": "Adicione uma pasta de jogos para preencher a biblioteca.",
|
||||
"Status.LibraryScanned": "Biblioteca analisada: {0} jogo(s) em {1} pasta(s).",
|
||||
"Status.CouldNotOpenFolder": "Não foi possível abrir a pasta: {0}",
|
||||
"Status.CopiedToClipboard": "{0} copiado para a área de transferência.",
|
||||
"Status.RemovedFromLibrary": "“{0}” removido da biblioteca. Adicione novamente a pasta para o restaurar.",
|
||||
"Status.Running": "A executar {0}",
|
||||
"Status.Stopping": "A parar…",
|
||||
"Status.Idle": "Inativo",
|
||||
|
||||
"Clipboard.Path": "Caminho",
|
||||
"Clipboard.TitleId": "ID do título",
|
||||
|
||||
"Discord.Playing": "A jogar {0}",
|
||||
"Discord.Browsing": "A navegar na biblioteca",
|
||||
|
||||
"Dialog.ChooseGameFolder": "Escolha uma pasta com jogos",
|
||||
"Dialog.OpenExecutable": "Abrir um executável para iniciar",
|
||||
"Dialog.PsExecutables": "Executáveis PS",
|
||||
"Dialog.SaveLogFile": "Selecione onde guardar o ficheiro de registo",
|
||||
"Dialog.PlainTextFiles": "Ficheiros de Texto Simples",
|
||||
"Dialog.LogFiles": "Ficheiros de Registo",
|
||||
|
||||
"Options.About" : "Sobre",
|
||||
"About.Github.Label": "GitHub",
|
||||
"About.Github.Desc": "Código-fonte, problemas e desenvolvimento do projeto.",
|
||||
"About.Discord.Label": "Discord",
|
||||
"About.Discord.Desc": "Junte-se à comunidade, obtenha suporte e acompanhe o desenvolvimento.",
|
||||
"About.GithubButton": "Contribua no GitHub!",
|
||||
"About.DiscordButton": "Junte-se ao nosso Discord!"
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"_languageName": "Русский",
|
||||
|
||||
"Page.Library": "Библиотека",
|
||||
"Page.Options": "Настройки",
|
||||
"Page.GameCount.One": "1 игра",
|
||||
"Page.GameCount.Other": "Игр: {0}",
|
||||
|
||||
"Library.SearchWatermark": "Поиск…",
|
||||
"Library.AddFolder": "+ Добавить папку",
|
||||
"Library.Rescan": "⟳ Сканировать",
|
||||
"Library.OpenFile": "Открыть файл…",
|
||||
|
||||
"Library.Context.Launch": "Запустить",
|
||||
"Library.Context.OpenFolder": "Открыть папку с игрой",
|
||||
"Library.Context.CopyPath": "Скопировать путь",
|
||||
"Library.Context.CopyTitleId": "Скопировать ID игры",
|
||||
"Library.Context.Remove": "Удалить из библиотеки",
|
||||
|
||||
"Library.Empty.Title": "Ваша библиотека пуста",
|
||||
"Library.Empty.Hint": "Добавьте папку с играми, чтобы начать.",
|
||||
"Library.Empty.SearchTitle": "По вашему запросу ничего не найдено",
|
||||
"Library.Empty.SearchHint": "В библиотеке нет игр, соответствующих запросу «{0}».",
|
||||
"Library.Empty.AddFolder": "+ Добавить папку с играми",
|
||||
|
||||
"Library.Loading": "Загрузка библиотеки…",
|
||||
|
||||
"Options.General": "Основные",
|
||||
"Options.Section.Emulation": "ЭМУЛЯЦИЯ",
|
||||
"Options.Section.Logging": "ЛОГГИРОВАНИЕ",
|
||||
"Options.Section.Launcher": "ЛАУНЧЕР",
|
||||
|
||||
"Options.CpuEngine.Label": "Движок ЦП",
|
||||
"Options.CpuEngine.Desc": "Движок выполнения, используемый для запуска игрового кода.",
|
||||
"Options.CpuEngine.Native": "Нативный",
|
||||
|
||||
"Options.Strict.Label": "Строгое разрешение динамических библиотек",
|
||||
"Options.Strict.Desc": "Не запускать игру, если не удается найти или связать импортируемую функцию/переменную.",
|
||||
|
||||
"Options.LogLevel.Label": "Уровень логгирования",
|
||||
"Options.LogLevel.Desc": "Подробность вывода в консоль эмулятора.",
|
||||
"Options.LogLevel.Trace": "Trace",
|
||||
"Options.LogLevel.Debug": "Debug",
|
||||
"Options.LogLevel.Info": "Info",
|
||||
"Options.LogLevel.Warning": "Warning",
|
||||
"Options.LogLevel.Error": "Error",
|
||||
"Options.LogLevel.Critical": "Critical",
|
||||
|
||||
"Options.TraceImports.Label": "Лимит трассировки импортов",
|
||||
"Options.TraceImports.Desc": "Трассировать первые N импортов в каждом модуле (0 - выключено).",
|
||||
|
||||
"Options.LogToFile.Label": "Запись лога в файл",
|
||||
"Options.LogToFile.Desc": "Дублировать вывод эмулятора в файл лога.",
|
||||
|
||||
"Options.LogFilePath.Label": "Путь к файлу лога",
|
||||
"Options.LogFilePath.Default": "Пользовательский путь не задан: логи сохраняются в user/logs рядом с эмулятором.",
|
||||
"Options.LogFilePath.Select": "Выбрать…",
|
||||
|
||||
"Options.OverrideLogFile.Label": "Переопределить файл лога",
|
||||
"Options.OverrideLogFile.Desc": "Использовать указанный путь к файлу без добавления ID игры и метки времени.",
|
||||
|
||||
"Options.TitleMusic.Label": "Музыка игры",
|
||||
"Options.TitleMusic.Desc": "Зацикленно воспроизводить музыку предпросмотра выбранной игры в библиотеке.",
|
||||
|
||||
"Options.Discord.Label": "Статус Discord",
|
||||
"Options.Discord.Desc": "Показывать запущенную игру в профиле Discord.",
|
||||
|
||||
"Options.Language.Label": "Язык эмулятора",
|
||||
"Options.Language.Desc": "Язык интерфейса лаунчера. Изменение применяется сразу.",
|
||||
|
||||
"Common.On": "Включено",
|
||||
"Common.Off": "Выключено",
|
||||
|
||||
"Console.Title": "КОНСОЛЬ",
|
||||
"Console.SearchWatermark": "Поиск...",
|
||||
"Console.AutoScroll": "Авто-прокрутка",
|
||||
"Console.Split": "Разделить",
|
||||
"Console.Copy": "Скопировать",
|
||||
"Console.Clear": "Очистить",
|
||||
"Console.WindowTitle": "Консоль SharpEmu",
|
||||
|
||||
"Launch.NoGameSelected": "Ничего не выбрано",
|
||||
"Launch.NoGameHint": "Выберите игру из библиотеки или откройте eboot.bin напрямую.",
|
||||
"Launch.Idle": "Ожидание",
|
||||
"Launch.Console": "≡ Консоль",
|
||||
"Launch.Launch": "▶ Запустить",
|
||||
"Launch.Stop": "■ Остановить",
|
||||
"Launch.Running": "Запущено - {0}",
|
||||
"Launch.Stopping": "Остановка…",
|
||||
"Launch.Exited": "Завершено с кодом {0} ({1})",
|
||||
"Launch.ExeNotFound": "Исполняемый файл SharpEmu не найден. Скомпилируйте сначала проект SharpEmu.CLI (dotnet build).",
|
||||
"Launch.LogFile": "Лог: {0}",
|
||||
"Launch.Command": "$ SharpEmu {0}",
|
||||
"Launch.StartFailed": "Не удалось запустить эмулятор: {0}",
|
||||
"Launch.ProcessExited": "Процесс завершился с кодом {0} ({1}).",
|
||||
|
||||
"Exit.Ok": "OK",
|
||||
"Exit.InvalidArguments": "некорректные аргументы",
|
||||
"Exit.EbootNotFound": "eboot не найден",
|
||||
"Exit.RuntimeException": "ошибка выполнения",
|
||||
"Exit.EmulationError": "ошибка эмуляции",
|
||||
"Exit.Unknown": "неизвестная ошибка",
|
||||
|
||||
"Status.EmulatorLocating": "Эмулятор: поиск…",
|
||||
"Status.EmulatorPath": "Эмулятор: {0}",
|
||||
"Status.EmulatorNotFound": "Эмулятор: исполняемый файл SharpEmu не был найден - скомпилируйте сначала SharpEmu.CLI.",
|
||||
"Status.ScanningLibrary": "Сканирование библиотеки…",
|
||||
"Status.AddFolderPrompt": "Добавьте папку с играми, чтобы заполнить библиотеку.",
|
||||
"Status.LibraryScanned": "Сканирование библиотеки завершено: игр: {0}, папок: {1}.",
|
||||
"Status.CouldNotOpenFolder": "Не удалось открыть папку: {0}",
|
||||
"Status.CopiedToClipboard": "{0}: скопировано в буфер обмена.",
|
||||
"Status.RemovedFromLibrary": "Игра «{0}» удалена из библиотеки. Добавьте её папку заново, чтобы вернуть.",
|
||||
"Status.Running": "Запущено: {0}",
|
||||
"Status.Stopping": "Остановка…",
|
||||
"Status.Idle": "Ожидание",
|
||||
|
||||
"Clipboard.Path": "Путь",
|
||||
"Clipboard.TitleId": "ID игры",
|
||||
|
||||
"Discord.Playing": "Играет в {0}",
|
||||
"Discord.Browsing": "Просматривает библиотеку",
|
||||
|
||||
"Dialog.ChooseGameFolder": "Выберите папку, содержащую игры",
|
||||
"Dialog.OpenExecutable": "Открыть исполняемый файл для запуска",
|
||||
"Dialog.PsExecutables": "Исполняемые файлы PS",
|
||||
"Dialog.SaveLogFile": "Выберите, куда сохранить файл с логами",
|
||||
"Dialog.PlainTextFiles": "Текстовые файлы",
|
||||
"Dialog.LogFiles": "Логи"
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"_languageName": "Türkçe",
|
||||
|
||||
"Page.Library": "Kütüphane",
|
||||
"Page.Options": "Ayarlar",
|
||||
"Page.GameCount.One": "1 oyun",
|
||||
"Page.GameCount.Other": "{0} oyun",
|
||||
|
||||
"Library.SearchWatermark": "Kütüphanede ara…",
|
||||
"Library.AddFolder": "+ Klasör ekle",
|
||||
"Library.Rescan": "⟳ Yeniden tara",
|
||||
"Library.OpenFile": "Dosya aç…",
|
||||
|
||||
"Library.Context.Launch": "Başlat",
|
||||
"Library.Context.OpenFolder": "Oyun klasörünü aç",
|
||||
"Library.Context.CopyPath": "Yolu kopyala",
|
||||
"Library.Context.CopyTitleId": "Title ID'yi kopyala",
|
||||
"Library.Context.Remove": "Kütüphaneden kaldır",
|
||||
|
||||
"Library.Empty.Title": "Kütüphaneniz boş",
|
||||
"Library.Empty.Hint": "Başlamak için oyunlarınızı içeren bir klasör ekleyin.",
|
||||
"Library.Empty.SearchTitle": "Aramanızla eşleşen oyun yok",
|
||||
"Library.Empty.SearchHint": "Kütüphanede “{0}” ile eşleşen bir şey yok.",
|
||||
"Library.Empty.AddFolder": "+ Oyun klasörü ekle",
|
||||
|
||||
"Library.Loading": "Kütüphane yükleniyor…",
|
||||
|
||||
"Options.General": "Genel",
|
||||
"Options.Section.Emulation": "EMÜLASYON",
|
||||
"Options.Section.Logging": "GÜNLÜKLEME",
|
||||
"Options.Section.Launcher": "BAŞLATICI",
|
||||
|
||||
"Options.CpuEngine.Label": "CPU motoru",
|
||||
"Options.CpuEngine.Desc": "Oyun kodunu çalıştırmak için kullanılan yürütme motoru.",
|
||||
"Options.CpuEngine.Native": "Native",
|
||||
|
||||
"Options.Strict.Label": "Katı dynlib çözümü",
|
||||
"Options.Strict.Desc": "İçe aktarılan bir sembol çözümlenemediğinde başlatmayı başarısız kılar.",
|
||||
|
||||
"Options.LogLevel.Label": "Günlük seviyesi",
|
||||
"Options.LogLevel.Desc": "Emülatör konsol çıktısının ayrıntı düzeyi.",
|
||||
"Options.LogLevel.Trace": "Trace",
|
||||
"Options.LogLevel.Debug": "Debug",
|
||||
"Options.LogLevel.Info": "Info",
|
||||
"Options.LogLevel.Warning": "Warning",
|
||||
"Options.LogLevel.Error": "Error",
|
||||
"Options.LogLevel.Critical": "Critical",
|
||||
|
||||
"Options.TraceImports.Label": "Import izleme sınırı",
|
||||
"Options.TraceImports.Desc": "Modül başına ilk N import'u izle (0 = kapalı).",
|
||||
|
||||
"Options.LogToFile.Label": "Dosyaya günlükle",
|
||||
"Options.LogToFile.Desc": "Emülatör çıktısını bir günlük dosyasına yansıt.",
|
||||
|
||||
"Options.LogFilePath.Label": "Günlük dosyası yolu",
|
||||
"Options.LogFilePath.Default": "Özel yol yok — günlükler emülatörün yanındaki user/logs klasörüne yazılır.",
|
||||
"Options.LogFilePath.Select": "Seç…",
|
||||
|
||||
"Options.OverrideLogFile.Label": "Günlük dosyasının üzerine yaz",
|
||||
"Options.OverrideLogFile.Desc": "Title ID ve zaman damgası eklemek yerine tam dosya yolunu kullan.",
|
||||
|
||||
"Options.TitleMusic.Label": "Oyun müziği",
|
||||
"Options.TitleMusic.Desc": "Kütüphanede seçili oyunun önizleme müziğini döngüye al.",
|
||||
|
||||
"Options.Discord.Label": "Discord durumu",
|
||||
"Options.Discord.Desc": "Discord profilinde çalışan oyunu göster.",
|
||||
|
||||
"Options.Language.Label": "Emülatör dili",
|
||||
"Options.Language.Desc": "Başlatıcı genelinde kullanılan dil. Hemen uygulanır.",
|
||||
|
||||
"Common.On": "Açık",
|
||||
"Common.Off": "Kapalı",
|
||||
|
||||
"Console.Title": "KONSOL",
|
||||
"Console.SearchWatermark": "Ara...",
|
||||
"Console.AutoScroll": "Otomatik kaydır",
|
||||
"Console.Split": "Ayır",
|
||||
"Console.Copy": "Kopyala",
|
||||
"Console.Clear": "Temizle",
|
||||
"Console.WindowTitle": "SharpEmu Konsol",
|
||||
|
||||
"Launch.NoGameSelected": "Oyun seçilmedi",
|
||||
"Launch.NoGameHint": "Kütüphaneden bir oyun seçin veya doğrudan bir eboot.bin açın.",
|
||||
"Launch.Idle": "Boşta",
|
||||
"Launch.Console": "≡ Konsol",
|
||||
"Launch.Launch": "▶ Başlat",
|
||||
"Launch.Stop": "■ Durdur",
|
||||
"Launch.Running": "Çalışıyor — {0}",
|
||||
"Launch.Stopping": "Durduruluyor…",
|
||||
"Launch.Exited": "Çıkış kodu {0} ({1})",
|
||||
"Launch.ExeNotFound": "SharpEmu çalıştırılabilir dosyası bulunamadı. Önce SharpEmu.CLI projesini derleyin (dotnet build).",
|
||||
"Launch.LogFile": "Günlük dosyası: {0}",
|
||||
"Launch.Command": "$ SharpEmu {0}",
|
||||
"Launch.StartFailed": "Emülatör başlatılamadı: {0}",
|
||||
"Launch.ProcessExited": "İşlem {0} kodla sonlandı ({1}).",
|
||||
|
||||
"Exit.Ok": "Tamam",
|
||||
"Exit.InvalidArguments": "geçersiz argümanlar",
|
||||
"Exit.EbootNotFound": "eboot bulunamadı",
|
||||
"Exit.RuntimeException": "çalışma zamanı hatası",
|
||||
"Exit.EmulationError": "emülasyon hatası",
|
||||
"Exit.Unknown": "bilinmiyor",
|
||||
|
||||
"Status.EmulatorLocating": "Emülatör: bulunuyor…",
|
||||
"Status.EmulatorPath": "Emülatör: {0}",
|
||||
"Status.EmulatorNotFound": "Emülatör: SharpEmu çalıştırılabilir dosyası bulunamadı — önce SharpEmu.CLI'yi derleyin.",
|
||||
"Status.ScanningLibrary": "Kütüphane taranıyor…",
|
||||
"Status.AddFolderPrompt": "Kütüphaneyi doldurmak için bir oyun klasörü ekleyin.",
|
||||
"Status.LibraryScanned": "Kütüphane tarandı: {1} klasörde {0} oyun.",
|
||||
"Status.CouldNotOpenFolder": "Klasör açılamadı: {0}",
|
||||
"Status.CopiedToClipboard": "{0} panoya kopyalandı.",
|
||||
"Status.RemovedFromLibrary": "“{0}” kütüphaneden kaldırıldı. Geri getirmek için klasörünü yeniden ekleyin.",
|
||||
"Status.Running": "{0} çalışıyor",
|
||||
"Status.Stopping": "Durduruluyor…",
|
||||
"Status.Idle": "Boşta",
|
||||
|
||||
"Clipboard.Path": "Yol",
|
||||
"Clipboard.TitleId": "Title ID",
|
||||
|
||||
"Discord.Playing": "{0} oynuyor",
|
||||
"Discord.Browsing": "Kütüphaneye göz atıyor",
|
||||
|
||||
"Dialog.ChooseGameFolder": "Oyunlarınızı içeren bir klasör seçin",
|
||||
"Dialog.OpenExecutable": "Başlatılacak bir çalıştırılabilir dosya aç",
|
||||
"Dialog.PsExecutables": "PS çalıştırılabilirleri",
|
||||
"Dialog.SaveLogFile": "Günlük dosyasının kaydedileceği yeri seçin",
|
||||
"Dialog.PlainTextFiles": "Düz Metin Dosyaları",
|
||||
"Dialog.LogFiles": "Günlük Dosyaları",
|
||||
|
||||
"Updater.Auto.Label": "Açılışta güncellemeleri denetle",
|
||||
"Updater.Auto.Desc": "Açılışı geciktirmeden GitHub'ı denetler.",
|
||||
"Updater.Label": "Güncellemeler",
|
||||
"Updater.Check": "Güncellemeleri denetle",
|
||||
"Updater.DownloadRestart": "İndir ve yeniden başlat",
|
||||
"Updater.Status.Ready": "Mevcut build: {0}",
|
||||
"Updater.Status.Checking": "Güncellemeler denetleniyor…",
|
||||
"Updater.Status.Current": "Güncelsiniz ({0}).",
|
||||
"Updater.Status.Available": "Yeni build mevcut: {0}",
|
||||
"Updater.Status.Downloading": "Güncelleme indiriliyor… %{0}",
|
||||
"Updater.Status.Installing": "Güncelleme kuruluyor…",
|
||||
"Updater.Status.Timeout": "Güncelleme denetimi 10 saniye sonra zaman aşımına uğradı.",
|
||||
"Updater.Status.Failed": "Güncellemeler denetlenemedi.",
|
||||
"Updater.Status.Unsupported": "Otomatik güncelleme Windows, Linux veya macOS x64 build'i gerektirir."
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
/// <summary>
|
||||
/// Loads UI strings for the launcher. Every language ships embedded in the
|
||||
/// assembly (see SharpEmu.GUI.csproj) so a release build is fully
|
||||
/// self-contained; an optional Languages/<code>.json file next to the
|
||||
/// executable overrides the embedded copy for that code, so a translation
|
||||
/// fix or a brand-new language never needs a rebuild.
|
||||
/// </summary>
|
||||
public sealed class Localization
|
||||
{
|
||||
public static Localization Instance { get; } = new();
|
||||
|
||||
public sealed record LanguageInfo(string Code, string NativeName);
|
||||
|
||||
private const string EmbeddedResourcePrefix = "Languages.";
|
||||
private const string EmbeddedResourceSuffix = ".json";
|
||||
|
||||
private Dictionary<string, string> _strings = new();
|
||||
private Dictionary<string, string> _fallbackStrings = new();
|
||||
|
||||
|
||||
private Localization()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Directory holding optional *.json language overrides, next to the executable.</summary>
|
||||
public static string LanguagesDirectory => Path.Combine(AppContext.BaseDirectory, "Languages");
|
||||
|
||||
public string CurrentCode { get; private set; } = "en";
|
||||
|
||||
public string Get(string key)
|
||||
{
|
||||
if (_strings.TryGetValue(key, out var value))
|
||||
return value;
|
||||
|
||||
if (_fallbackStrings.TryGetValue(key, out var fallbackValue))
|
||||
return fallbackValue;
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
public string Format(string key, params object?[] args) => string.Format(Get(key), args);
|
||||
|
||||
/// <summary>
|
||||
/// Languages available either embedded in the binary or as a loose
|
||||
/// override file, sorted by code. A loose file's declared name wins when
|
||||
/// the same code exists in both places.
|
||||
/// </summary>
|
||||
public List<LanguageInfo> DiscoverLanguages()
|
||||
{
|
||||
var languages = new Dictionary<string, LanguageInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var code in EmbeddedLanguageCodes())
|
||||
{
|
||||
using var stream = OpenEmbeddedLanguageStream(code);
|
||||
if (stream is not null)
|
||||
{
|
||||
languages[code] = new LanguageInfo(code, ReadLanguageName(stream) ?? code);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(LanguagesDirectory, "*.json"))
|
||||
{
|
||||
var code = Path.GetFileNameWithoutExtension(file);
|
||||
using var stream = File.OpenRead(file);
|
||||
languages[code] = new LanguageInfo(code, ReadLanguageName(stream) ?? code);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// No loose Languages directory: the embedded languages still stand.
|
||||
}
|
||||
|
||||
var result = languages.Values.ToList();
|
||||
result.Sort((a, b) => string.CompareOrdinal(a.Code, b.Code));
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Loads a language by code (e.g. "en"): a loose override file first, then the embedded copy.</summary>
|
||||
/// english is the fallback language
|
||||
public void Load(string code)
|
||||
{
|
||||
if (_fallbackStrings.Count == 0 && !string.Equals(code, "en", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!TryLoadLooseFile("en", out var fallback) && !TryLoadEmbedded("en", out fallback))
|
||||
{
|
||||
fallback = new Dictionary<string, string>();
|
||||
}
|
||||
_fallbackStrings = fallback;
|
||||
}
|
||||
else if (string.Equals(code, "en", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (TryLoadLooseFile("en", out var enDict) || TryLoadEmbedded("en", out enDict))
|
||||
{
|
||||
_strings = enDict;
|
||||
_fallbackStrings = enDict;
|
||||
}
|
||||
else
|
||||
{
|
||||
_strings = new Dictionary<string, string>();
|
||||
_fallbackStrings = new Dictionary<string, string>();
|
||||
}
|
||||
CurrentCode = "en";
|
||||
return;
|
||||
}
|
||||
|
||||
// Load the requested language
|
||||
if (TryLoadLooseFile(code, out var loaded) || TryLoadEmbedded(code, out loaded))
|
||||
{
|
||||
_strings = loaded;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_fallbackStrings.Count > 0)
|
||||
_strings = new Dictionary<string, string>(_fallbackStrings);
|
||||
else
|
||||
_strings = new Dictionary<string, string>();
|
||||
}
|
||||
CurrentCode = code;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EmbeddedLanguageCodes()
|
||||
{
|
||||
foreach (var name in typeof(Localization).Assembly.GetManifestResourceNames())
|
||||
{
|
||||
if (name.StartsWith(EmbeddedResourcePrefix, StringComparison.Ordinal) &&
|
||||
name.EndsWith(EmbeddedResourceSuffix, StringComparison.Ordinal))
|
||||
{
|
||||
yield return name[EmbeddedResourcePrefix.Length..^EmbeddedResourceSuffix.Length];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Stream? OpenEmbeddedLanguageStream(string code) =>
|
||||
typeof(Localization).Assembly.GetManifestResourceStream($"{EmbeddedResourcePrefix}{code}{EmbeddedResourceSuffix}");
|
||||
|
||||
private static string? ReadLanguageName(Stream stream)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
if (document.RootElement.TryGetProperty("_languageName", out var name) &&
|
||||
name.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return name.GetString();
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Malformed file: fall back to the code as its own display name.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool TryLoadLooseFile(string code)
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(LanguagesDirectory, $"{code}.json");
|
||||
return File.Exists(path) && TryLoad(code, File.ReadAllText(path));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryLoadEmbedded(string code)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = OpenEmbeddedLanguageStream(code);
|
||||
if (stream is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
return TryLoad(code, reader.ReadToEnd());
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryLoadLooseFile(string code, out Dictionary<string, string> result)
|
||||
{
|
||||
result = new Dictionary<string, string>();
|
||||
try
|
||||
{
|
||||
var path = Path.Combine(LanguagesDirectory, $"{code}.json");
|
||||
if (!File.Exists(path))
|
||||
return false;
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
return TryLoad(json, out result);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryLoadEmbedded(string code, out Dictionary<string, string> result)
|
||||
{
|
||||
result = new Dictionary<string, string>();
|
||||
try
|
||||
{
|
||||
using var stream = OpenEmbeddedLanguageStream(code);
|
||||
if (stream is null)
|
||||
return false;
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
return TryLoad(json, out result);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryLoad(string json, out Dictionary<string, string> result)
|
||||
{
|
||||
var loaded = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
|
||||
if (loaded is null)
|
||||
{
|
||||
result = new Dictionary<string, string>();
|
||||
return false;
|
||||
}
|
||||
|
||||
result = loaded;
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryLoad(string code, string json)
|
||||
{
|
||||
if (TryLoad(json, out var dict))
|
||||
{
|
||||
_strings = dict;
|
||||
CurrentCode = code;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -5,18 +5,22 @@ 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"
|
||||
MinWidth="980" MinHeight="640"
|
||||
WindowState="Maximized"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Background="{StaticResource BgBrush}"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaChromeHints="PreferSystemChrome"
|
||||
ExtendClientAreaTitleBarHeightHint="44"
|
||||
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico">
|
||||
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico"
|
||||
KeyDown="OnKeyDown">
|
||||
|
||||
<Grid RowDefinitions="44,*,32">
|
||||
<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. -->
|
||||
@@ -40,8 +44,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
<!-- Title bar -->
|
||||
<Grid x:Name="TitleBar" Grid.Row="0" Background="{StaticResource ChromeBrush}">
|
||||
<!-- Title bar; hidden in fullscreen (F11) along with the status bar, so
|
||||
the game gets the whole screen. -->
|
||||
<Grid x:Name="TitleBar" Grid.Row="0" Height="44" Background="{StaticResource ChromeBrush}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10" Margin="16,0" VerticalAlignment="Center">
|
||||
<Image Source="avares://SharpEmu.GUI/Assets/SharpEmu.ico" Width="20" Height="20"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality" />
|
||||
@@ -53,119 +58,510 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Grid>
|
||||
|
||||
<!-- Main content -->
|
||||
<Grid Grid.Row="1" Margin="18,14,18,14" RowDefinitions="Auto,*,Auto,Auto">
|
||||
<Grid x:Name="MainContent" Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto">
|
||||
|
||||
<!-- Library toolbar -->
|
||||
<Grid Grid.Row="0" ColumnDefinitions="Auto,Auto,*,Auto,Auto,Auto,Auto" Margin="6,0,6,12">
|
||||
<TextBlock Grid.Column="0" Text="Library" FontSize="22" FontWeight="Bold" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="1" Classes="pill" Margin="12,2,0,0" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="GameCountText" Text="0 games" FontSize="11" Foreground="{StaticResource MutedBrush}" />
|
||||
</Border>
|
||||
<TextBox Grid.Column="3" x:Name="SearchBox" Watermark="Search library…" Width="280"
|
||||
VerticalAlignment="Center" Margin="0,0,12,0" />
|
||||
<Button Grid.Column="4" x:Name="AddFolderButton" Classes="ghost" Content="+ Add folder"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||
<Button Grid.Column="5" x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||
<Button Grid.Column="6" x:Name="OpenFileButton" Classes="ghost" Content="Open file…"
|
||||
VerticalAlignment="Center" />
|
||||
<!-- 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 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}" />
|
||||
</Border>
|
||||
<Button x:Name="LibraryTabButton" Classes="segment active" Content="Library" />
|
||||
<Button x:Name="OptionsTabButton" Classes="segment" Content="Options" />
|
||||
<Border Classes="padHint" VerticalAlignment="Center">
|
||||
<TextBlock Text="RB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" x:Name="LibraryToolbar" Orientation="Horizontal" Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<TextBox x:Name="SearchBox" Watermark="Search library…" Width="240" VerticalAlignment="Center" />
|
||||
<Button x:Name="AddFolderButton" Classes="ghost" Content="+ Add folder" VerticalAlignment="Center" />
|
||||
<Button x:Name="RescanButton" Classes="ghost" Content="⟳ Rescan" VerticalAlignment="Center" />
|
||||
<Button x:Name="OpenFileButton" Classes="ghost" Content="Open file…" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Cover-art grid -->
|
||||
<Panel Grid.Row="1">
|
||||
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
|
||||
SelectionMode="Single" Padding="0">
|
||||
<ListBox.ContextMenu>
|
||||
<ContextMenu x:Name="GameContextMenu" Placement="Pointer">
|
||||
<MenuItem x:Name="CtxLaunch" Header="Launch" FontWeight="SemiBold">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="▶" FontSize="11" Foreground="{StaticResource AccentHoverBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="CtxOpenFolder" Header="Open game folder">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="📂" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<MenuItem x:Name="CtxCopyPath" Header="Copy path">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="CtxCopyTitleId" Header="Copy title ID">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<MenuItem x:Name="CtxRemove" Header="Remove from library"
|
||||
Foreground="{StaticResource DangerHoverBrush}">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="✕" FontSize="12" Foreground="{StaticResource DangerHoverBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
</ListBox.ContextMenu>
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Width="128" Height="186" Spacing="7">
|
||||
<Border Classes="coverShadow" Width="128" Height="128">
|
||||
<Border Classes="coverClip">
|
||||
<Panel>
|
||||
<Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}">
|
||||
<TextBlock Text="{Binding Initials}" FontSize="36" FontWeight="Bold"
|
||||
Foreground="#E8ECF4" Opacity="0.85"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Image Source="{Binding Cover}" Stretch="UniformToFill" IsVisible="{Binding HasCover}" />
|
||||
</Panel>
|
||||
</Border>
|
||||
</Border>
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"
|
||||
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Text="{Binding Detail}" FontSize="11" Foreground="{StaticResource MutedBrush}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- Empty state -->
|
||||
<StackPanel x:Name="EmptyState" Spacing="10" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
IsVisible="False">
|
||||
<TextBlock Text="🎮" FontSize="44" HorizontalAlignment="Center" Opacity="0.7" />
|
||||
<TextBlock x:Name="EmptyStateTitle" Text="Your library is empty" FontSize="18" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock x:Name="EmptyStateHint" Text="Add a folder containing your games to get started."
|
||||
FontSize="13" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
|
||||
<Button x:Name="EmptyAddFolderButton" Classes="accent" Content="+ Add game folder"
|
||||
HorizontalAlignment="Center" Margin="0,8,0,0" />
|
||||
</StackPanel>
|
||||
<!-- Library page. The tile row gets extra top margin so it sits
|
||||
closer to eye level (PS5 home-screen style) instead of hugging
|
||||
the Library/Options switcher above it. -->
|
||||
<Panel x:Name="LibraryPage" Margin="0,64,0,0">
|
||||
<ListBox x:Name="GameList" Classes="tileGrid" Background="Transparent"
|
||||
SelectionMode="Single" Padding="0">
|
||||
<ListBox.ContextMenu>
|
||||
<ContextMenu x:Name="GameContextMenu" Placement="Pointer">
|
||||
<MenuItem x:Name="CtxLaunch" Header="Launch" FontWeight="SemiBold">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="▶" FontSize="11" Foreground="{StaticResource AccentHoverBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="CtxOpenFolder" Header="Open game folder">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="📂" FontSize="12" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<MenuItem x:Name="CtxCopyPath" Header="Copy path">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<MenuItem x:Name="CtxCopyTitleId" Header="Copy title ID">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="⧉" FontSize="13" Foreground="{StaticResource MutedBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
<Separator />
|
||||
<MenuItem x:Name="CtxRemove" Header="Remove from library"
|
||||
Foreground="{StaticResource DangerHoverBrush}">
|
||||
<MenuItem.Icon>
|
||||
<TextBlock Text="✕" FontSize="12" Foreground="{StaticResource DangerHoverBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</MenuItem.Icon>
|
||||
</MenuItem>
|
||||
</ContextMenu>
|
||||
</ListBox.ContextMenu>
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Width="128" Height="172" Spacing="7">
|
||||
<Border Classes="coverShadow" Width="128" Height="128">
|
||||
<Border Classes="coverClip">
|
||||
<Panel>
|
||||
<Border Background="{Binding PlaceholderBrush}" IsVisible="{Binding !HasCover}">
|
||||
<TextBlock Text="{Binding Initials}" FontSize="36" FontWeight="Bold"
|
||||
Foreground="#E8ECF4" Opacity="0.85"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Image Source="{Binding Cover}" Stretch="UniformToFill" IsVisible="{Binding HasCover}" />
|
||||
</Panel>
|
||||
</Border>
|
||||
</Border>
|
||||
<TextBlock Text="{Binding Name}" FontSize="13" FontWeight="SemiBold"
|
||||
TextWrapping="Wrap" MaxLines="2" TextTrimming="CharacterEllipsis"
|
||||
TextAlignment="Center" HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<!-- Empty state -->
|
||||
<StackPanel x:Name="EmptyState" Spacing="10" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
IsVisible="False">
|
||||
<TextBlock Text="🎮" FontSize="44" HorizontalAlignment="Center" Opacity="0.7" />
|
||||
<TextBlock x:Name="EmptyStateTitle" Text="Your library is empty" FontSize="18" FontWeight="SemiBold"
|
||||
HorizontalAlignment="Center" />
|
||||
<TextBlock x:Name="EmptyStateHint" Text="Add a folder containing your games to get started."
|
||||
FontSize="13" Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
|
||||
<Button x:Name="EmptyAddFolderButton" Classes="accent" Content="+ Add game folder"
|
||||
HorizontalAlignment="Center" Margin="0,8,0,0" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Loading state: covers the grid while a scan (initial load,
|
||||
rescan, or a just-added folder) is in flight, so the screen
|
||||
never looks blank mid-scan. -->
|
||||
<StackPanel x:Name="LoadingState" Spacing="14" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
IsVisible="False">
|
||||
<ProgressBar IsIndeterminate="True" Width="180" Height="3" />
|
||||
<TextBlock x:Name="LoadingStateText" Text="Loading library…" FontSize="13"
|
||||
Foreground="{StaticResource MutedBrush}" HorizontalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
|
||||
<!-- Options page: sub-tabs so future categories (Graphics, …) slot
|
||||
in next to General. Card-grouped sections match the visual
|
||||
language used by the console panel and launch bar below. -->
|
||||
<Grid x:Name="OptionsPage" IsVisible="False">
|
||||
<TabControl>
|
||||
<TabItem x:Name="GeneralTabItem" Header="General" FontSize="15">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock x:Name="EmulationSectionTitle" Classes="sectionTitle" Text="EMULATION" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="CpuEngineLabel" Text="CPU engine" FontSize="13" />
|
||||
<TextBlock x:Name="CpuEngineDesc" Text="Execution engine used to run game code."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ComboBox Grid.Column="1" x:Name="CpuEngineBox" Width="160" SelectedIndex="0"
|
||||
VerticalAlignment="Center" CornerRadius="8">
|
||||
<ComboBoxItem x:Name="CpuEngineNativeItem" Content="Native" />
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="StrictLabel" Text="Strict dynlib resolution" FontSize="13" />
|
||||
<TextBlock x:Name="StrictDesc" Text="Fail the launch when an imported symbol cannot be resolved."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="StrictToggle" OnContent="On" OffContent="Off"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock x:Name="LoggingSectionTitle" Classes="sectionTitle" Text="LOGGING" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="LogLevelLabel" Text="Log level" FontSize="13" />
|
||||
<TextBlock x:Name="LogLevelDesc" Text="Verbosity of the emulator console output."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ComboBox Grid.Column="1" x:Name="LogLevelBox" Width="160" SelectedIndex="2"
|
||||
VerticalAlignment="Center" CornerRadius="8">
|
||||
<ComboBoxItem x:Name="LogLevelTraceItem" Content="Trace" />
|
||||
<ComboBoxItem x:Name="LogLevelDebugItem" Content="Debug" />
|
||||
<ComboBoxItem x:Name="LogLevelInfoItem" Content="Info" />
|
||||
<ComboBoxItem x:Name="LogLevelWarningItem" Content="Warning" />
|
||||
<ComboBoxItem x:Name="LogLevelErrorItem" Content="Error" />
|
||||
<ComboBoxItem x:Name="LogLevelCriticalItem" Content="Critical" />
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="TraceImportsLabel" Text="Import trace limit" FontSize="13" />
|
||||
<TextBlock x:Name="TraceImportsDesc" Text="Trace the first N imports per module (0 = off)."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<NumericUpDown Grid.Column="1" x:Name="TraceImportsBox" Width="160" Minimum="0"
|
||||
Maximum="4096" Increment="16" Value="0" FormatString="0"
|
||||
VerticalAlignment="Center" CornerRadius="8" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="LogToFileLabel" Text="Log to file" FontSize="13" />
|
||||
<TextBlock x:Name="LogToFileDesc" Text="Mirror emulator output to a log file."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="LogToFileToggle" OnContent="On" OffContent="Off"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="LogFilePathLabel" Text="Log file path" FontSize="13" />
|
||||
<TextBlock x:Name="LogFilePathText" Text="No custom path"
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" x:Name="SelectLogFilePathButton" Classes="ghost" Content="Select…"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="OverrideLogFileLabel" Text="Override log file" FontSize="13" />
|
||||
<TextBlock x:Name="OverrideLogFileDesc"
|
||||
Text="Use the exact file path instead of appending title ID and timestamp."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="OverrideLogFileToggle" OnContent="On" OffContent="Off"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock x:Name="LauncherSectionTitle" Classes="sectionTitle" Text="LAUNCHER" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="LanguageLabel" Text="Emulator language" FontSize="13" />
|
||||
<TextBlock x:Name="LanguageDesc"
|
||||
Text="Language used throughout the launcher. Applies immediately."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ComboBox Grid.Column="1" x:Name="LanguageBox" Width="160"
|
||||
VerticalAlignment="Center" CornerRadius="8"
|
||||
DisplayMemberBinding="{Binding NativeName}" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="TitleMusicLabel" Text="Title music" FontSize="13" />
|
||||
<TextBlock x:Name="TitleMusicDesc" Text="Loop the selected game's preview music in the library."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="TitleMusicToggle" OnContent="On" OffContent="Off"
|
||||
IsChecked="True" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="DiscordLabel" Text="Discord presence" FontSize="13" />
|
||||
<TextBlock x:Name="DiscordDesc" Text="Show the running game on your Discord profile."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="DiscordToggle" OnContent="On" OffContent="Off"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock x:Name="AutoUpdateLabel" Text="Check for updates on startup" FontSize="13" />
|
||||
<TextBlock x:Name="AutoUpdateDesc" Text="Checks GitHub without delaying startup."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="AutoUpdateToggle" OnContent="On" OffContent="Off"
|
||||
IsChecked="True" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock x:Name="AboutSectionTitle"
|
||||
Classes="sectionTitle"
|
||||
Text="ABOUT" />
|
||||
|
||||
<!--Latest commit info-->
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<Image Source="avares://SharpEmu.GUI/Assets/commit-icon.png"
|
||||
Width="24" Height="24" VerticalAlignment="Center" />
|
||||
<StackPanel Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="LatestCommitLabel" Text="Latest commit" FontSize="13"/>
|
||||
<TextBlock x:Name="LatestCommitDescription" Text="Latest commit on the main branch" FontSize="11" Foreground="{StaticResource MutedBrush}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="1" x:Name="LatestCommitHashText" Classes="ghost"
|
||||
Content="Loading…" FontSize="12" FontFamily="Consolas,monospace"
|
||||
VerticalAlignment="Center" IsEnabled="False" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<!--Update-->
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<Image Source="avares://SharpEmu.GUI/Assets/update-icon.png"
|
||||
Width="24"
|
||||
Height="24"
|
||||
VerticalAlignment="Center" />
|
||||
<StackPanel Spacing="2" VerticalAlignment="Center">
|
||||
<TextBlock x:Name="UpdateLabel" Text="Updates" FontSize="13" />
|
||||
<TextBlock x:Name="UpdateStatusText" Text="Current build: dev" FontSize="11"
|
||||
Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" x:Name="UpdateButton" Classes="ghost"
|
||||
Content="Check for updates" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<!--Github-->
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<Image Source="avares://SharpEmu.GUI/Assets/github.png"
|
||||
Width="20"
|
||||
Height="20"
|
||||
VerticalAlignment="Center" />
|
||||
<StackPanel VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="GithubLabel"
|
||||
Text="GitHub"
|
||||
FontSize="13" />
|
||||
<TextBlock x:Name="GithubDesc"
|
||||
Text="Source code, issues and project development."
|
||||
FontSize="11"
|
||||
Foreground="{StaticResource MutedBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="1"
|
||||
x:Name="GithubButton"
|
||||
Classes="ghost"
|
||||
Content="Open"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<!--Discord-->
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<Image Source="avares://SharpEmu.GUI/Assets/discord.png"
|
||||
Width="20"
|
||||
Height="20"
|
||||
VerticalAlignment="Center" />
|
||||
<StackPanel VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
<TextBlock x:Name="DiscordServerLabel"
|
||||
Text="Discord"
|
||||
FontSize="13" />
|
||||
<TextBlock x:Name="DiscordServerDesc"
|
||||
Text="Join the community, get support and follow development."
|
||||
FontSize="11"
|
||||
Foreground="{StaticResource MutedBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="1"
|
||||
x:Name="DiscordButton"
|
||||
Classes="ghost"
|
||||
Content="Join"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem x:Name="EnvTabItem" Header="Environment" FontSize="15">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="14">
|
||||
<TextBlock x:Name="EnvSectionTitle" Classes="sectionTitle" Text="ENVIRONMENT VARIABLES" />
|
||||
<TextBlock x:Name="EnvDesc"
|
||||
Text="Switches passed to the emulator as environment variables at launch."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="SHARPEMU_BTHID_UNAVAILABLE" FontSize="13" FontFamily="Consolas,monospace" />
|
||||
<TextBlock x:Name="EnvBthidDesc"
|
||||
Text="Report Bluetooth HID as unavailable for titles whose wheel/FFB middleware polls forever. Leave off normally. Some titles freeze when init fails."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="EnvBthidToggle" OnContent="On" OffContent="Off"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="SHARPEMU_DISABLE_IMPORT_LOOP_GUARD" FontSize="13" FontFamily="Consolas,monospace" />
|
||||
<TextBlock x:Name="EnvLoopGuardDesc"
|
||||
Text="Do not force quit titles that repeat the same call for too long. Try this when a game exits on its own while loading."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="EnvLoopGuardToggle" OnContent="On" OffContent="Off"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="SHARPEMU_WRITABLE_APP0" FontSize="13" FontFamily="Consolas,monospace" />
|
||||
<TextBlock x:Name="EnvWritableApp0Desc"
|
||||
Text="Allow titles to create and write files inside their install folder. Needed by unpackaged dumps that write their save or config data under /app0."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="EnvWritableApp0Toggle" OnContent="On" OffContent="Off"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="SHARPEMU_VK_VALIDATION" FontSize="13" FontFamily="Consolas,monospace" />
|
||||
<TextBlock x:Name="EnvVkValidationDesc"
|
||||
Text="Enable Vulkan validation layers for GPU debugging. Slow. Requires the Vulkan SDK to be installed."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="EnvVkValidationToggle" OnContent="On" OffContent="Off"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="SHARPEMU_DUMP_SPIRV" FontSize="13" FontFamily="Consolas,monospace" />
|
||||
<TextBlock x:Name="EnvDumpSpirvDesc"
|
||||
Text="Dump AGC shaders and their SPIR-V translations to the shader-dumps folder. Use when reporting shader or rendering bugs."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="EnvDumpSpirvToggle" OnContent="On" OffContent="Off"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="SHARPEMU_LOG_DIRECT_MEMORY" FontSize="13" FontFamily="Consolas,monospace" />
|
||||
<TextBlock x:Name="EnvLogDirectMemoryDesc"
|
||||
Text="Log direct memory allocations and failures to the console. Use when a game aborts or exits during boot."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="EnvLogDirectMemoryToggle" OnContent="On" OffContent="Off"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="SHARPEMU_LOG_IO" FontSize="13" FontFamily="Consolas,monospace" />
|
||||
<TextBlock x:Name="EnvLogIoDesc"
|
||||
Text="Log file open, read, and path-resolve activity to the console. Use when a game cannot find its data files during boot."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="EnvLogIoToggle" OnContent="On" OffContent="Off"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="SHARPEMU_LOG_NP" FontSize="13" FontFamily="Consolas,monospace" />
|
||||
<TextBlock x:Name="EnvLogNpDesc"
|
||||
Text="Log NP (PlayStation Network) library calls to the console."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1" x:Name="EnvLogNpToggle" OnContent="On" OffContent="Off"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</Grid>
|
||||
</Panel>
|
||||
|
||||
<!-- Console (collapsible) -->
|
||||
<Border Grid.Row="2" x:Name="ConsolePanel" Classes="card" Padding="0" Height="240"
|
||||
Margin="0,12,0,0" IsVisible="False">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto" Margin="16,12,16,8">
|
||||
<TextBlock Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
|
||||
<CheckBox Grid.Column="1" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
|
||||
FontSize="12" Margin="0,0,12,0" />
|
||||
<Button Grid.Column="2" x:Name="CopyLogButton" Classes="ghost" Content="Copy" FontSize="12"
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto,Auto,Auto,Auto,Auto" Margin="16,12,16,8">
|
||||
<TextBlock x:Name="ConsoleSectionTitle" Classes="sectionTitle" Text="CONSOLE" VerticalAlignment="Center" />
|
||||
<TextBox Grid.Column="1" FontSize="12" Margin="0,0,12,0" x:Name="ConsoleSearchBox"
|
||||
Watermark="Search..." Width="320" />
|
||||
<CheckBox Grid.Column="2" x:Name="AutoScrollCheck" Content="Auto-scroll" IsChecked="True"
|
||||
FontSize="12" Margin="0,0,12,0" />
|
||||
<Button Grid.Column="3" x:Name="DetachConsoleButton" Classes="ghost" Content="Split" FontSize="12"
|
||||
Padding="10,4" Margin="0,0,8,0" />
|
||||
<Button Grid.Column="3" x:Name="ClearLogButton" Classes="ghost" Content="Clear" FontSize="12"
|
||||
<Button Grid.Column="4" x:Name="CopyLogButton" Classes="ghost" Content="Copy" FontSize="12"
|
||||
Padding="10,4" Margin="0,0,8,0" />
|
||||
<Button Grid.Column="5" x:Name="ClearLogButton" Classes="ghost" Content="Clear" FontSize="12"
|
||||
Padding="10,4" />
|
||||
</Grid>
|
||||
<ListBox Grid.Row="1" x:Name="ConsoleList" Classes="console" BorderThickness="0,1,0,0"
|
||||
@@ -179,8 +575,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Launch bar -->
|
||||
<Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14">
|
||||
<!-- Launch bar; capped so it does not sprawl on a maximized window. -->
|
||||
<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">
|
||||
|
||||
@@ -199,9 +595,32 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1" Spacing="3" VerticalAlignment="Center" Margin="14,0,14,0">
|
||||
<TextBlock x:Name="SelectedGameTitle" Text="No game selected" FontSize="16" FontWeight="Bold"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<StackPanel Grid.Column="1" Spacing="4" VerticalAlignment="Center" Margin="14,0,14,0">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*">
|
||||
<TextBlock Grid.Column="0" x:Name="SelectedGameTitle" Text="No game selected" FontSize="16"
|
||||
FontWeight="Bold" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
|
||||
MaxWidth="340" Margin="0,0,10,0" />
|
||||
|
||||
<!-- Title id / version / size badges, right next to the
|
||||
title. The title's own MaxWidth (not a "*" column) is
|
||||
what keeps them from drifting to the far right. -->
|
||||
<StackPanel Grid.Column="1" x:Name="SelectedBadgesRow" Orientation="Horizontal" Spacing="6"
|
||||
IsVisible="False" VerticalAlignment="Center">
|
||||
<Border Classes="pill" IsVisible="{Binding HasTitleId, FallbackValue=False}">
|
||||
<TextBlock Text="{Binding TitleId}" FontSize="10" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource MutedBrush}" />
|
||||
</Border>
|
||||
<Border Classes="pill" IsVisible="{Binding HasVersion, FallbackValue=False}">
|
||||
<TextBlock Text="{Binding VersionText}" FontSize="10" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource MutedBrush}" />
|
||||
</Border>
|
||||
<Border Classes="pill">
|
||||
<TextBlock Text="{Binding SizeText, FallbackValue=''}" FontSize="10" FontWeight="SemiBold"
|
||||
Foreground="{StaticResource MutedBrush}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock x:Name="SelectedGamePath" Text="Pick a game from the library, or open an eboot.bin directly."
|
||||
FontSize="11" Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="7">
|
||||
@@ -213,56 +632,78 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<ToggleButton x:Name="OptionsToggle" Classes="ghost" Content="⚙ Options" />
|
||||
<ToggleButton x:Name="ConsoleToggle" Classes="ghost" Content="≡ Console" />
|
||||
<Button x:Name="LaunchButton" Classes="accent" Content="▶ Launch" IsEnabled="False" />
|
||||
<Button x:Name="StopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- Launch options (collapsible) -->
|
||||
<WrapPanel x:Name="OptionsPanel" IsVisible="False">
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="CPU engine" />
|
||||
<ComboBox x:Name="CpuEngineBox" Width="150" SelectedIndex="0" CornerRadius="8">
|
||||
<ComboBoxItem Content="Native" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="Log level" />
|
||||
<ComboBox x:Name="LogLevelBox" Width="150" SelectedIndex="2" CornerRadius="8">
|
||||
<ComboBoxItem Content="Trace" />
|
||||
<ComboBoxItem Content="Debug" />
|
||||
<ComboBoxItem Content="Info" />
|
||||
<ComboBoxItem Content="Warning" />
|
||||
<ComboBoxItem Content="Error" />
|
||||
<ComboBoxItem Content="Critical" />
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="Import trace limit (0 = off)" />
|
||||
<NumericUpDown x:Name="TraceImportsBox" Width="170" Minimum="0" Maximum="4096" Increment="16"
|
||||
Value="0" FormatString="0" CornerRadius="8" />
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="Strict dynlib resolution" />
|
||||
<ToggleSwitch x:Name="StrictToggle" OnContent="On" OffContent="Off" />
|
||||
</StackPanel>
|
||||
<StackPanel Margin="0,0,24,0">
|
||||
<TextBlock Classes="fieldLabel" Text="Log to file" />
|
||||
<ToggleSwitch x:Name="LogToFileToggle" OnContent="On" OffContent="Off" />
|
||||
</StackPanel>
|
||||
<StackPanel>
|
||||
<TextBlock Classes="fieldLabel" Text="Title music" />
|
||||
<ToggleSwitch x:Name="TitleMusicToggle" OnContent="On" OffContent="Off" IsChecked="True" />
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</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 Grid.Row="2" Background="{StaticResource ChromeBrush}" ColumnDefinitions="*,Auto">
|
||||
<Grid x:Name="StatusBar" Grid.Row="2" Height="32" Background="{StaticResource ChromeBrush}"
|
||||
ColumnDefinitions="*,Auto">
|
||||
<TextBlock x:Name="EmulatorPathText" Grid.Column="0" Text="Emulator: locating…" FontSize="11"
|
||||
Foreground="{StaticResource FaintBrush}" VerticalAlignment="Center" Margin="16,0"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
@@ -9,12 +9,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
the executable is started without arguments. -->
|
||||
<PropertyGroup>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<Version>0.0.1</Version>
|
||||
<!-- Required by the source-generated LibraryImport stubs in the linked
|
||||
controller readers below. -->
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
@@ -28,17 +34,15 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" />
|
||||
<AvaloniaResource Include="..\..\assets\images\github.png" Link="Assets/github.png" />
|
||||
<AvaloniaResource Include="..\..\assets\images\discord.png" Link="Assets/discord.png" />
|
||||
<AvaloniaResource Include="..\..\assets\images\update-icon.png" Link="Assets/update-icon.png" />
|
||||
<AvaloniaResource Include="..\..\assets\images\commit-icon.png" Link="Assets/commit-icon.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- The controller readers (DualSense raw HID + Xbox XInput) are shared
|
||||
with the emulator's pad HLE. They are dependency-free, so they are
|
||||
compiled in directly rather than pulling a reference to all of
|
||||
SharpEmu.Libs into the launcher. -->
|
||||
<ItemGroup>
|
||||
<Compile Include="..\SharpEmu.Libs\Pad\PadState.cs" Link="Input/PadState.cs" />
|
||||
<Compile Include="..\SharpEmu.Libs\Pad\HidNative.cs" Link="Input/HidNative.cs" />
|
||||
<Compile Include="..\SharpEmu.Libs\Pad\DualSenseReader.cs" Link="Input/DualSenseReader.cs" />
|
||||
<Compile Include="..\SharpEmu.Libs\Pad\XInputReader.cs" Link="Input/XInputReader.cs" />
|
||||
<EmbeddedResource Include="Languages\*.json">
|
||||
<LogicalName>Languages.%(Filename)%(Extension)</LogicalName>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -47,7 +47,7 @@ internal sealed class SndPreviewPlayer
|
||||
{
|
||||
// Debounce so skimming through the library does not decode (or
|
||||
// start) a preview per tile.
|
||||
await Task.Delay(300).ConfigureAwait(false);
|
||||
await Task.Delay(120).ConfigureAwait(false);
|
||||
|
||||
byte[]? wav;
|
||||
lock (_sync)
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Formats.Tar;
|
||||
using System.IO.Compression;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SharpEmu.GUI;
|
||||
|
||||
/// <summary>Self-contained Windows updater; the emulator layers do not depend on it.</summary>
|
||||
public static class Updater
|
||||
{
|
||||
private const string ApplyArgument = "--sharpemu-apply-update";
|
||||
private const string LatestReleaseUrl = "https://api.github.com/repos/sharpemu/sharpemu/releases/latest";
|
||||
private static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(10);
|
||||
private static readonly HttpClient Http = CreateHttpClient();
|
||||
|
||||
public sealed record UpdateInfo(string Sha, string Name, string DownloadUrl, long Size);
|
||||
|
||||
public static async Task<UpdateInfo?> CheckAsync(string? currentSha, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var platform = CurrentPlatform();
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(CheckTimeout);
|
||||
|
||||
using var response = await Http.GetAsync(LatestReleaseUrl, timeout.Token);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return ParseRelease(
|
||||
await response.Content.ReadAsStringAsync(timeout.Token),
|
||||
currentSha,
|
||||
platform.Rid,
|
||||
platform.Extension);
|
||||
}
|
||||
|
||||
public static async Task DownloadAndRestartAsync(
|
||||
UpdateInfo update,
|
||||
IProgress<int>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "SharpEmu.Update");
|
||||
var payload = Path.Combine(root, "payload");
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(root);
|
||||
var archive = Path.Combine(root, update.Name);
|
||||
using (var response = await Http.GetAsync(update.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
|
||||
{
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using var input = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
await using var output = File.Create(archive);
|
||||
var buffer = new byte[81920];
|
||||
long written = 0;
|
||||
int read;
|
||||
while ((read = await input.ReadAsync(buffer, cancellationToken)) > 0)
|
||||
{
|
||||
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
|
||||
written += read;
|
||||
progress?.Report(update.Size == 0 ? 0 : (int)(written * 100 / update.Size));
|
||||
}
|
||||
|
||||
if (written != update.Size)
|
||||
{
|
||||
throw new InvalidDataException($"Downloaded {written} bytes; expected {update.Size}.");
|
||||
}
|
||||
}
|
||||
|
||||
var platform = CurrentPlatform();
|
||||
var stagedExe = ExtractArchive(archive, payload, platform.Extension, platform.ExecutableName);
|
||||
|
||||
var start = new ProcessStartInfo(stagedExe)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = payload,
|
||||
};
|
||||
start.ArgumentList.Add(ApplyArgument);
|
||||
start.ArgumentList.Add(Environment.ProcessId.ToString());
|
||||
start.ArgumentList.Add(AppContext.BaseDirectory);
|
||||
using var helper = Process.Start(start)
|
||||
?? throw new InvalidOperationException("The update installer could not be started.");
|
||||
}
|
||||
|
||||
/// <summary>Runs from the downloaded executable after the old GUI exits.</summary>
|
||||
public static bool TryApply(string[] args, out int exitCode)
|
||||
{
|
||||
exitCode = 0;
|
||||
if (args.Length != 3 || args[0] != ApplyArgument)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (int.TryParse(args[1], out var oldPid))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Process.GetProcessById(oldPid).WaitForExit(30_000))
|
||||
{
|
||||
throw new TimeoutException("SharpEmu did not close within 30 seconds.");
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// The old process has already exited.
|
||||
}
|
||||
}
|
||||
|
||||
var source = AppContext.BaseDirectory;
|
||||
var target = Path.GetFullPath(args[2]);
|
||||
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(source, file);
|
||||
if (relative.Equals("gui-settings.json", StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.StartsWith("user" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.StartsWith("logs" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.StartsWith("Languages" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var destination = Path.Combine(target, relative);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
|
||||
File.Copy(file, destination, overwrite: true);
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
File.SetUnixFileMode(destination, File.GetUnixFileMode(file));
|
||||
}
|
||||
}
|
||||
|
||||
using var restarted = Process.Start(new ProcessStartInfo(
|
||||
Path.Combine(target, CurrentPlatform().ExecutableName))
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = target,
|
||||
}) ?? throw new InvalidOperationException("The updated SharpEmu could not be started.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
exitCode = 1;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(args[2], "update-error.log"), ex.ToString());
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort diagnostics only.
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static UpdateInfo? ParseRelease(
|
||||
string json,
|
||||
string? currentSha,
|
||||
string rid,
|
||||
string extension)
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var candidates = new List<(DateTimeOffset Created, UpdateInfo Update)>();
|
||||
foreach (var asset in document.RootElement.GetProperty("assets").EnumerateArray())
|
||||
{
|
||||
var name = asset.GetProperty("name").GetString() ?? "";
|
||||
var marker = $"-{rid}-";
|
||||
var markerIndex = name.LastIndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||
if (!name.EndsWith(extension, StringComparison.OrdinalIgnoreCase) ||
|
||||
markerIndex < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var sha = name[(markerIndex + marker.Length)..^extension.Length];
|
||||
if (sha.Length < 7 || !sha.All(Uri.IsHexDigit))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.Add((
|
||||
asset.GetProperty("created_at").GetDateTimeOffset(),
|
||||
new UpdateInfo(
|
||||
sha,
|
||||
name,
|
||||
asset.GetProperty("browser_download_url").GetString()!,
|
||||
asset.GetProperty("size").GetInt64())));
|
||||
}
|
||||
|
||||
var latest = candidates.OrderByDescending(candidate => candidate.Created).FirstOrDefault().Update;
|
||||
return latest is null || string.Equals(latest.Sha, currentSha, StringComparison.OrdinalIgnoreCase)
|
||||
? null
|
||||
: latest;
|
||||
}
|
||||
|
||||
private static string ExtractArchive(
|
||||
string archive,
|
||||
string payload,
|
||||
string extension,
|
||||
string executableName)
|
||||
{
|
||||
if (extension == ".zip")
|
||||
{
|
||||
ZipFile.ExtractToDirectory(archive, payload);
|
||||
}
|
||||
else
|
||||
{
|
||||
Directory.CreateDirectory(payload);
|
||||
using var compressed = File.OpenRead(archive);
|
||||
using var gzip = new GZipStream(compressed, CompressionMode.Decompress);
|
||||
TarFile.ExtractToDirectory(gzip, payload, overwriteFiles: false);
|
||||
}
|
||||
|
||||
var executable = Path.Combine(payload, executableName);
|
||||
if (!File.Exists(executable))
|
||||
{
|
||||
throw new InvalidDataException($"The update archive does not contain {executableName}.");
|
||||
}
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
File.SetUnixFileMode(executable, File.GetUnixFileMode(executable) | UnixFileMode.UserExecute);
|
||||
}
|
||||
|
||||
return executable;
|
||||
}
|
||||
|
||||
private static HttpClient CreateHttpClient()
|
||||
{
|
||||
var client = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
|
||||
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("SharpEmu", "0.0.1"));
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
|
||||
return client;
|
||||
}
|
||||
|
||||
private static PlatformInfo CurrentPlatform()
|
||||
{
|
||||
if (RuntimeInformation.ProcessArchitecture != Architecture.X64)
|
||||
{
|
||||
throw new PlatformNotSupportedException("SharpEmu releases require an x64 process.");
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsWindows()) return new("win-x64", ".zip", "SharpEmu.exe");
|
||||
if (OperatingSystem.IsLinux()) return new("linux-x64", ".tar.gz", "SharpEmu");
|
||||
if (OperatingSystem.IsMacOS()) return new("osx-x64", ".tar.gz", "SharpEmu");
|
||||
throw new PlatformNotSupportedException();
|
||||
}
|
||||
|
||||
private sealed record PlatformInfo(string Rid, string Extension, string ExecutableName);
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Avalonia": {
|
||||
"type": "Direct",
|
||||
"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": "Direct",
|
||||
"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": "Direct",
|
||||
"requested": "[11.3.18, )",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "27u6hB3Y2Ue586yjfeVakberY73VNQXtuKwe/P927XG1QPlhsfmOyifLHDDpSHG85Zl1x/Xv9IZ3+tk9FnjcZQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"Avalonia.Themes.Fluent": {
|
||||
"type": "Direct",
|
||||
"requested": "[11.3.18, )",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "+Q/TJoynD0zNuu5w2gD+xcTl7GNKJFxlPYAndRLs/mTDrNbbsvv/271WyIysbMPsXSjCyBDp7RCZzQkpD6x5Bg==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"Tmds.DBus.Protocol": {
|
||||
"type": "Direct",
|
||||
"requested": "[0.21.3, )",
|
||||
"resolved": "0.21.3",
|
||||
"contentHash": "hDwB8WsQoyALQKqIbwzS68UKdlnafDm4T/DkO/JrA/YIneP/rKv96SxYPVXeh3FP4i/SXfShrYftKLtciJAIlw=="
|
||||
},
|
||||
"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=="
|
||||
},
|
||||
"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=="
|
||||
},
|
||||
"sharpemu.logging": {
|
||||
"type": "Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Linq;
|
||||
using SharpEmu.Logging;
|
||||
|
||||
namespace SharpEmu.HLE;
|
||||
|
||||
public sealed class Aerolib : ISymbolCatalog
|
||||
{
|
||||
private static readonly SharpEmuLogger Log = SharpEmuLog.For("Aerolib");
|
||||
private static readonly Lazy<Aerolib> _instance = new(() => new Aerolib());
|
||||
private static readonly Aerolib EmptyCatalog = new Aerolib(empty: true);
|
||||
|
||||
@@ -108,14 +110,14 @@ public sealed class Aerolib : ISymbolCatalog
|
||||
|
||||
if (resourceName == null)
|
||||
{
|
||||
Console.Error.WriteLine("[AEROLIB] Embedded resource 'aerolib.bin' not found");
|
||||
Log.Error("Embedded resource 'aerolib.bin' not found");
|
||||
return;
|
||||
}
|
||||
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream == null)
|
||||
{
|
||||
Console.Error.WriteLine("[AEROLIB] Failed to open embedded resource stream");
|
||||
Log.Error("Failed to open embedded resource stream");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -145,11 +147,11 @@ public sealed class Aerolib : ISymbolCatalog
|
||||
_byExportName[name] = symbol;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"[AEROLIB] Loaded {_byNid.Count} NID entries from binary resource");
|
||||
Log.Info($"Loaded {_byNid.Count} NID entries from binary resource");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[AEROLIB] Failed to load embedded aerolib.bin: {ex.Message}");
|
||||
Log.Error($"Failed to load embedded aerolib.bin: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers;
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
@@ -25,6 +26,12 @@ public sealed class CpuContext(ICpuMemory memory, Generation generation)
|
||||
|
||||
public ulong GsBase { get; set; }
|
||||
|
||||
/// <summary>x87 control word observed at the current guest boundary.</summary>
|
||||
public ushort FpuControlWord { get; set; } = 0x037F;
|
||||
|
||||
/// <summary>MXCSR observed at the current guest boundary.</summary>
|
||||
public uint Mxcsr { get; set; } = 0x1F80;
|
||||
|
||||
public ulong this[CpuRegister register]
|
||||
{
|
||||
get => _registers[(int)register];
|
||||
@@ -238,23 +245,63 @@ public sealed class CpuContext(ICpuMemory memory, Generation generation)
|
||||
return false;
|
||||
}
|
||||
|
||||
var bytes = new byte[capacity];
|
||||
for (var index = 0; index < bytes.Length; index++)
|
||||
const int StackBufferLength = 512;
|
||||
const int ReadChunkLength = 128;
|
||||
var rented = capacity > StackBufferLength ? ArrayPool<byte>.Shared.Rent(capacity) : null;
|
||||
Span<byte> bytes = rented is null ? stackalloc byte[StackBufferLength] : rented;
|
||||
try
|
||||
{
|
||||
if (!Memory.TryRead(address + (ulong)index, bytes.AsSpan(index, 1)))
|
||||
var length = 0;
|
||||
while (length < capacity)
|
||||
{
|
||||
return false;
|
||||
// Bulk-read in bounded chunks rather than the full capacity: the string
|
||||
// may end just before unmapped memory, and overreading past the
|
||||
// terminator by more than a chunk could fault where the old
|
||||
// byte-by-byte loop succeeded.
|
||||
var chunk = Math.Min(ReadChunkLength, capacity - length);
|
||||
var span = bytes.Slice(length, chunk);
|
||||
if (Memory.TryRead(address + (ulong)length, span))
|
||||
{
|
||||
var terminator = span.IndexOf((byte)0);
|
||||
if (terminator >= 0)
|
||||
{
|
||||
value = Encoding.UTF8.GetString(bytes[..(length + terminator)]);
|
||||
return true;
|
||||
}
|
||||
|
||||
length += chunk;
|
||||
continue;
|
||||
}
|
||||
|
||||
// The chunk touches an unreadable range; fall back to per-byte reads so a
|
||||
// terminator sitting before the bad byte still yields the string.
|
||||
for (var i = 0; i < chunk; i++)
|
||||
{
|
||||
if (!Memory.TryRead(address + (ulong)(length + i), bytes.Slice(length + i, 1)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (bytes[length + i] == 0)
|
||||
{
|
||||
value = Encoding.UTF8.GetString(bytes[..(length + i)]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
length += chunk;
|
||||
}
|
||||
|
||||
if (bytes[index] == 0)
|
||||
value = Encoding.UTF8.GetString(bytes[..capacity]);
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (rented is not null)
|
||||
{
|
||||
value = Encoding.UTF8.GetString(bytes, 0, index);
|
||||
return true;
|
||||
ArrayPool<byte>.Shared.Return(rented);
|
||||
}
|
||||
}
|
||||
|
||||
value = Encoding.UTF8.GetString(bytes);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool PushUInt64(ulong value)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.HLE;
|
||||
|
||||
/// <summary>
|
||||
/// Marks a string parameter of a [SysAbiExport] handler as a guest null-terminated
|
||||
/// UTF-8 pointer. The generated thunk reads the string from the argument register's
|
||||
/// address (bounded by <see cref="MaxLength"/>) before invoking the handler, and
|
||||
/// returns ORBIS_GEN2_ERROR_MEMORY_FAULT to the guest when the read fails — including
|
||||
/// a null pointer, matching TryReadNullTerminatedUtf8's contract.
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Parameter, Inherited = false, AllowMultiple = false)]
|
||||
public sealed class GuestCStringAttribute : Attribute
|
||||
{
|
||||
public GuestCStringAttribute(int maxLength) => MaxLength = maxLength;
|
||||
|
||||
/// <summary>Upper bound in bytes for the guest read, terminator included.</summary>
|
||||
public int MaxLength { get; }
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace SharpEmu.HLE;
|
||||
|
||||
/// <summary>
|
||||
/// Detects guest CPU writes into memory that backs a host GPU image. On PS5
|
||||
/// render targets alias unified memory, so games freely mix CPU writes and GPU
|
||||
/// draws on the same surface (Chowdren titles memset their fog layers every
|
||||
/// frame). Host GPU images are separate storage, so the video backend needs to
|
||||
/// know when the guest CPU rewrote a surface to re-upload it. Ranges are
|
||||
/// write-protected; the first write faults, the fault handler restores write
|
||||
/// access and marks the range dirty, and the video backend consumes the dirty
|
||||
/// flag once per flip and re-arms protection after re-uploading.
|
||||
/// </summary>
|
||||
public static unsafe class GuestImageWriteTracker
|
||||
{
|
||||
private const int ProtRead = 0x1;
|
||||
private const int ProtWrite = 0x2;
|
||||
private const int ClockMonotonicRaw = 4;
|
||||
|
||||
private sealed class TrackedRange
|
||||
{
|
||||
public ulong Address;
|
||||
public ulong ByteCount;
|
||||
public ulong Start;
|
||||
public ulong End;
|
||||
public int Dirty;
|
||||
public int Armed;
|
||||
public int FirstCpuWriteSeen;
|
||||
public int PendingFirstCpuWrite;
|
||||
public bool TraceLifetime;
|
||||
public long SourceSequence;
|
||||
public long FirstCpuWriteTraceSequence;
|
||||
public long FirstCpuWriteTimestampNanoseconds;
|
||||
public ulong FirstCpuWriteAddress;
|
||||
public ulong FirstCpuWritePage;
|
||||
public string Source = "unspecified";
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct Timespec
|
||||
{
|
||||
public long Seconds;
|
||||
public long Nanoseconds;
|
||||
}
|
||||
|
||||
private static readonly object _gate = new();
|
||||
private static readonly Dictionary<ulong, TrackedRange> _rangesByAddress = new();
|
||||
|
||||
// Snapshot array read lock-free from the signal handler; rebuilt on every
|
||||
// mutation under the gate. Signal handlers must not take managed locks.
|
||||
private static TrackedRange[] _rangeSnapshot = [];
|
||||
|
||||
private static readonly bool _enabled = !OperatingSystem.IsWindows() &&
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_GUEST_IMAGE_CPU_SYNC") != "0";
|
||||
private static readonly (bool Wildcard, ulong[] Addresses) _lifetimeTraceFilter =
|
||||
ParseAddressList(Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGE_ADDRS"));
|
||||
private static readonly (bool Wildcard, string[] Sources) _lifetimeSourceTraceFilter =
|
||||
ParseSourceList(Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_MEMORY_LIFETIME"));
|
||||
private static readonly bool _lifetimeTraceEnabled =
|
||||
_lifetimeTraceFilter.Wildcard ||
|
||||
_lifetimeTraceFilter.Addresses.Length != 0 ||
|
||||
_lifetimeSourceTraceFilter.Wildcard ||
|
||||
_lifetimeSourceTraceFilter.Sources.Length != 0;
|
||||
private static readonly long _lifetimeTraceEpochNanoseconds =
|
||||
_enabled && _lifetimeTraceEnabled ? GetMonotonicNanoseconds() : 0;
|
||||
private static long _lifetimeTraceSequence;
|
||||
|
||||
[DllImport("libc", EntryPoint = "mprotect", SetLastError = true)]
|
||||
private static extern int Mprotect(nint address, nuint length, int protection);
|
||||
|
||||
[DllImport("libc", EntryPoint = "clock_gettime", SetLastError = false)]
|
||||
private static extern int ClockGetTime(int clockId, Timespec* time);
|
||||
|
||||
public static bool Enabled => _enabled;
|
||||
|
||||
/// <summary>
|
||||
/// Exercises the fault-handling path once outside signal context so every
|
||||
/// branch is JIT-compiled (and, under Rosetta 2, translated) before a real
|
||||
/// fault arrives — a cold signal path is silently never entered there.
|
||||
/// </summary>
|
||||
public static void WarmUp()
|
||||
{
|
||||
if (!_enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var scratch = NativeMemory.AllocZeroed(4096);
|
||||
try
|
||||
{
|
||||
// Warm the timestamp P/Invoke used by the signal-safe scalar
|
||||
// capture path before a real protected-page write reaches it.
|
||||
_ = GetMonotonicNanoseconds();
|
||||
var address = (ulong)scratch;
|
||||
Track(address, 4096);
|
||||
_ = TryHandleWriteFault(address);
|
||||
_ = ConsumeDirty(address);
|
||||
Untrack(address);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NativeMemory.Free(scratch);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Registers a range and arms write protection on it.</summary>
|
||||
public static void Track(
|
||||
ulong address,
|
||||
ulong byteCount,
|
||||
long sourceSequence = 0,
|
||||
string source = "unspecified")
|
||||
{
|
||||
if (!_enabled || address == 0 || byteCount == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var (start, length) = PageAlign(address, byteCount);
|
||||
lock (_gate)
|
||||
{
|
||||
_rangesByAddress.TryGetValue(address, out var range);
|
||||
if (range is not null &&
|
||||
(range.Start != start ||
|
||||
range.End != start + length ||
|
||||
range.ByteCount != byteCount))
|
||||
{
|
||||
// Never resize an object that is still reachable from the
|
||||
// signal handler's lock-free snapshot. Retire it and publish
|
||||
// a fresh immutable range.
|
||||
DisarmLocked(range, "replace-range");
|
||||
_rangesByAddress.Remove(address);
|
||||
range = null;
|
||||
}
|
||||
|
||||
if (range is null)
|
||||
{
|
||||
range = new TrackedRange
|
||||
{
|
||||
Address = address,
|
||||
ByteCount = byteCount,
|
||||
Start = start,
|
||||
End = start + length,
|
||||
TraceLifetime =
|
||||
ShouldTraceRange(start, start + length) || ShouldTraceSource(source),
|
||||
SourceSequence = sourceSequence,
|
||||
Source = source,
|
||||
};
|
||||
_rangesByAddress[address] = range;
|
||||
RebuildSnapshotLocked();
|
||||
}
|
||||
else
|
||||
{
|
||||
FlushPendingFirstCpuWrite(range);
|
||||
}
|
||||
|
||||
range.SourceSequence = sourceSequence;
|
||||
range.Source = source;
|
||||
range.TraceLifetime =
|
||||
ShouldTraceRange(range.Start, range.End) || ShouldTraceSource(source);
|
||||
ArmLocked(range, "arm");
|
||||
}
|
||||
}
|
||||
|
||||
public static void Untrack(ulong address)
|
||||
{
|
||||
if (!_enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_rangesByAddress.TryGetValue(address, out var range))
|
||||
{
|
||||
DisarmLocked(range, "untrack");
|
||||
_rangesByAddress.Remove(address);
|
||||
RebuildSnapshotLocked();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the guest CPU wrote the range since the last call,
|
||||
/// clearing the flag. The caller re-arms via <see cref="Rearm"/> after it
|
||||
/// finished reading the guest bytes.
|
||||
/// </summary>
|
||||
public static bool ConsumeDirty(ulong address)
|
||||
{
|
||||
if (!_enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_rangesByAddress.TryGetValue(address, out var range))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
FlushPendingFirstCpuWrite(range);
|
||||
return Interlocked.Exchange(ref range.Dirty, 0) != 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-consuming variant of <see cref="ConsumeDirty"/>: reports whether
|
||||
/// the range has been written since it was last re-armed, leaving the
|
||||
/// flag for the owner that evicts and re-uploads.
|
||||
/// </summary>
|
||||
public static bool PeekDirty(ulong address)
|
||||
{
|
||||
if (!_enabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_rangesByAddress.TryGetValue(address, out var range))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
FlushPendingFirstCpuWrite(range);
|
||||
return Volatile.Read(ref range.Dirty) != 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Rearm(ulong address)
|
||||
{
|
||||
if (!_enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_rangesByAddress.TryGetValue(address, out var range))
|
||||
{
|
||||
ArmLocked(range, "rearm");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prepares pages touched by a managed HLE memory write. Native guest
|
||||
/// stores fault and enter <see cref="TryHandleWriteFault"/> through the
|
||||
/// POSIX signal bridge, but a managed Buffer.MemoryCopy into a protected
|
||||
/// page is surfaced by the runtime as a fatal AccessViolation instead of
|
||||
/// a resumable guest fault. Visit every page in the write span up front so
|
||||
/// all overlapping texture owners are dirtied and made writable.
|
||||
/// </summary>
|
||||
public static void NotifyManagedWrite(ulong address, ulong byteCount)
|
||||
{
|
||||
if (!_enabled || address == 0 || byteCount == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var end = address > ulong.MaxValue - byteCount
|
||||
? ulong.MaxValue
|
||||
: address + byteCount;
|
||||
var candidate = address;
|
||||
while (candidate < end)
|
||||
{
|
||||
_ = TryHandleWriteFault(candidate);
|
||||
var nextPage = (candidate & ~0xFFFUL) + 0x1000UL;
|
||||
if (nextPage <= candidate)
|
||||
{
|
||||
break;
|
||||
}
|
||||
candidate = nextPage;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flushes scalar first-write records captured by the POSIX signal handler.
|
||||
/// Call only from ordinary managed execution, never from signal context.
|
||||
/// </summary>
|
||||
public static void FlushPendingDiagnostics()
|
||||
{
|
||||
if (!_enabled || !_lifetimeTraceEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (var range in _rangesByAddress.Values)
|
||||
{
|
||||
FlushPendingFirstCpuWrite(range);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signal-handler entry: if the fault address lies in a tracked, armed
|
||||
/// range, restore write access, mark the range dirty, and return true so
|
||||
/// the faulting write can be retried. Must not allocate or lock.
|
||||
/// </summary>
|
||||
public static bool TryHandleWriteFault(ulong faultAddress)
|
||||
{
|
||||
if (!_enabled || faultAddress == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ranges = Volatile.Read(ref _rangeSnapshot);
|
||||
var writableStart = ulong.MaxValue;
|
||||
var writableEnd = 0UL;
|
||||
for (var index = 0; index < ranges.Length; index++)
|
||||
{
|
||||
var range = ranges[index];
|
||||
if (faultAddress < range.Start || faultAddress >= range.End)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
writableStart = Math.Min(writableStart, range.Start);
|
||||
writableEnd = Math.Max(writableEnd, range.End);
|
||||
}
|
||||
|
||||
if (writableStart == ulong.MaxValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ranges are page-aligned and may overlap (font atlases and other
|
||||
// suballocations commonly share pages). Unprotecting one range also
|
||||
// makes every overlapping tracked page writable. Expand to the full
|
||||
// transitive overlap and dirty/disarm every owner, otherwise only the
|
||||
// first dictionary entry observes the write and the others retain a
|
||||
// stale cached texture indefinitely.
|
||||
var expanded = true;
|
||||
while (expanded)
|
||||
{
|
||||
expanded = false;
|
||||
for (var index = 0; index < ranges.Length; index++)
|
||||
{
|
||||
var range = ranges[index];
|
||||
if (range.Start >= writableEnd || range.End <= writableStart)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var start = Math.Min(writableStart, range.Start);
|
||||
var end = Math.Max(writableEnd, range.End);
|
||||
if (start != writableStart || end != writableEnd)
|
||||
{
|
||||
writableStart = start;
|
||||
writableEnd = end;
|
||||
expanded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var needsUnprotect = false;
|
||||
for (var index = 0; index < ranges.Length; index++)
|
||||
{
|
||||
var range = ranges[index];
|
||||
if (range.Start < writableEnd && range.End > writableStart &&
|
||||
Volatile.Read(ref range.Armed) != 0)
|
||||
{
|
||||
needsUnprotect = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUnprotect &&
|
||||
Mprotect(
|
||||
(nint)writableStart,
|
||||
(nuint)(writableEnd - writableStart),
|
||||
ProtRead | ProtWrite) != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var index = 0; index < ranges.Length; index++)
|
||||
{
|
||||
var range = ranges[index];
|
||||
if (range.Start >= writableEnd || range.End <= writableStart)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) != 0;
|
||||
if (wasArmed &&
|
||||
range.TraceLifetime &&
|
||||
Interlocked.CompareExchange(ref range.FirstCpuWriteSeen, 1, 0) == 0)
|
||||
{
|
||||
// Signal context: capture preallocated scalar fields only.
|
||||
// Formatting and I/O are deferred to a locked safe path.
|
||||
range.FirstCpuWriteTraceSequence =
|
||||
Interlocked.Increment(ref _lifetimeTraceSequence);
|
||||
range.FirstCpuWriteTimestampNanoseconds = GetMonotonicNanoseconds();
|
||||
range.FirstCpuWriteAddress = faultAddress;
|
||||
range.FirstCpuWritePage = faultAddress & ~0xFFFUL;
|
||||
Volatile.Write(ref range.PendingFirstCpuWrite, 1);
|
||||
Volatile.Write(ref range.FirstCpuWriteSeen, 2);
|
||||
}
|
||||
|
||||
Volatile.Write(ref range.Dirty, 1);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void ArmLocked(TrackedRange range, string operation)
|
||||
{
|
||||
FlushPendingFirstCpuWrite(range);
|
||||
if (Interlocked.Exchange(ref range.Armed, 1) == 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// A new publication/rearm starts a new first-write lifetime.
|
||||
Volatile.Write(ref range.FirstCpuWriteSeen, 0);
|
||||
var failed = Mprotect(
|
||||
(nint)range.Start,
|
||||
(nuint)(range.End - range.Start),
|
||||
ProtRead) != 0;
|
||||
if (failed)
|
||||
{
|
||||
Volatile.Write(ref range.Armed, 0);
|
||||
}
|
||||
|
||||
if (range.TraceLifetime)
|
||||
{
|
||||
TraceLifetime(
|
||||
range,
|
||||
failed ? $"{operation}-failed-errno-{Marshal.GetLastPInvokeError()}" : operation);
|
||||
}
|
||||
}
|
||||
|
||||
private static void DisarmLocked(TrackedRange range, string operation)
|
||||
{
|
||||
FlushPendingFirstCpuWrite(range);
|
||||
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) == 1;
|
||||
if (wasArmed)
|
||||
{
|
||||
_ = Mprotect(
|
||||
(nint)range.Start,
|
||||
(nuint)(range.End - range.Start),
|
||||
ProtRead | ProtWrite);
|
||||
}
|
||||
|
||||
if (range.TraceLifetime)
|
||||
{
|
||||
TraceLifetime(range, wasArmed ? operation : $"{operation}-already-disarmed");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RebuildSnapshotLocked()
|
||||
{
|
||||
_rangeSnapshot = _rangesByAddress.Values.ToArray();
|
||||
}
|
||||
|
||||
private static (ulong Start, ulong Length) PageAlign(ulong address, ulong byteCount)
|
||||
{
|
||||
const ulong pageMask = 0xFFFUL;
|
||||
var start = address & ~pageMask;
|
||||
var end = (address + byteCount + pageMask) & ~pageMask;
|
||||
return (start, end - start);
|
||||
}
|
||||
|
||||
private static bool ShouldTraceRange(ulong start, ulong end)
|
||||
{
|
||||
if (_lifetimeTraceFilter.Wildcard)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var addresses = _lifetimeTraceFilter.Addresses;
|
||||
for (var index = 0; index < addresses.Length; index++)
|
||||
{
|
||||
if (addresses[index] >= start && addresses[index] < end)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static (bool Wildcard, ulong[] Addresses) ParseAddressList(string? addresses)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(addresses))
|
||||
{
|
||||
return (false, []);
|
||||
}
|
||||
|
||||
var parsedAddresses = new List<ulong>();
|
||||
foreach (var token in addresses.Split(
|
||||
[',', ';', ' ', '\t'],
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (token == "*")
|
||||
{
|
||||
return (true, []);
|
||||
}
|
||||
|
||||
var span = token.AsSpan();
|
||||
if (span.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
span = span[2..];
|
||||
}
|
||||
|
||||
if (ulong.TryParse(span, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
parsedAddresses.Add(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
return (false, parsedAddresses.ToArray());
|
||||
}
|
||||
|
||||
private static bool ShouldTraceSource(string source)
|
||||
{
|
||||
if (_lifetimeSourceTraceFilter.Wildcard)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return Array.IndexOf(_lifetimeSourceTraceFilter.Sources, source) >= 0;
|
||||
}
|
||||
|
||||
private static (bool Wildcard, string[] Sources) ParseSourceList(string? sources)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sources))
|
||||
{
|
||||
return (false, []);
|
||||
}
|
||||
|
||||
var parsedSources = new List<string>();
|
||||
foreach (var token in sources.Split(
|
||||
[',', ';'],
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (token == "*")
|
||||
{
|
||||
return (true, []);
|
||||
}
|
||||
|
||||
parsedSources.Add(token);
|
||||
}
|
||||
|
||||
return (false, parsedSources.ToArray());
|
||||
}
|
||||
|
||||
private static void FlushPendingFirstCpuWrite(TrackedRange range)
|
||||
{
|
||||
var spin = new SpinWait();
|
||||
while (Volatile.Read(ref range.FirstCpuWriteSeen) == 1)
|
||||
{
|
||||
spin.SpinOnce();
|
||||
}
|
||||
|
||||
if (!range.TraceLifetime || Interlocked.Exchange(ref range.PendingFirstCpuWrite, 0) == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TraceLifetime(
|
||||
range,
|
||||
"first-cpu-write-disarm",
|
||||
range.FirstCpuWriteAddress,
|
||||
range.FirstCpuWritePage,
|
||||
range.FirstCpuWriteTraceSequence,
|
||||
range.FirstCpuWriteTimestampNanoseconds);
|
||||
}
|
||||
|
||||
private static void TraceLifetime(
|
||||
TrackedRange range,
|
||||
string operation,
|
||||
ulong faultAddress = 0,
|
||||
ulong faultPage = 0,
|
||||
long traceSequence = 0,
|
||||
long timestampNanoseconds = 0)
|
||||
{
|
||||
if (traceSequence == 0)
|
||||
{
|
||||
traceSequence = Interlocked.Increment(ref _lifetimeTraceSequence);
|
||||
}
|
||||
|
||||
if (timestampNanoseconds == 0)
|
||||
{
|
||||
timestampNanoseconds = GetMonotonicNanoseconds();
|
||||
}
|
||||
|
||||
var elapsedMilliseconds =
|
||||
(timestampNanoseconds - _lifetimeTraceEpochNanoseconds) / 1_000_000.0;
|
||||
Console.Error.WriteLine(
|
||||
$"[WT][LIFETIME] seq={traceSequence} t_ms={elapsedMilliseconds:F3} " +
|
||||
$"event={operation} source_seq={range.SourceSequence} source='{range.Source}' " +
|
||||
$"requested=0x{range.Address:X16}+0x{range.ByteCount:X} " +
|
||||
$"range=0x{range.Start:X16}..0x{range.End:X16} " +
|
||||
$"fault=0x{faultAddress:X16} page=0x{faultPage:X16}");
|
||||
}
|
||||
|
||||
private static long GetMonotonicNanoseconds()
|
||||
{
|
||||
Timespec time;
|
||||
return ClockGetTime(ClockMonotonicRaw, &time) == 0
|
||||
? unchecked((time.Seconds * 1_000_000_000L) + time.Nanoseconds)
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.HLE;
|
||||
|
||||
[Flags]
|
||||
public enum GuestPageProtection
|
||||
{
|
||||
None = 0,
|
||||
Read = 1,
|
||||
Write = 2,
|
||||
Execute = 4,
|
||||
}
|
||||
@@ -23,10 +23,32 @@ public readonly record struct GuestThreadSnapshot(
|
||||
ulong LastReturnRip,
|
||||
string? BlockReason);
|
||||
|
||||
/// <summary>
|
||||
/// Continuation state for a blocked guest thread, replacing the closure pair a blocking
|
||||
/// wait used to allocate. TryWake runs under the scheduler's guest-thread gate and
|
||||
/// returns true when the waiter has a final result and the thread should be re-readied;
|
||||
/// false leaves it parked. Resume runs later on the woken thread outside that gate, and
|
||||
/// its return value becomes the guest's RAX for the resumed call.
|
||||
/// </summary>
|
||||
public interface IGuestThreadBlockWaiter
|
||||
{
|
||||
int Resume();
|
||||
|
||||
bool TryWake();
|
||||
}
|
||||
|
||||
public interface IGuestThreadScheduler
|
||||
{
|
||||
bool SupportsGuestContextTransfer { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Associates a pthread identity created on the primary guest executor
|
||||
/// with its live CPU context. Primary execution does not pass through
|
||||
/// TryStartThread, but kernel exception delivery must still be able to
|
||||
/// target it.
|
||||
/// </summary>
|
||||
void RegisterGuestThreadContext(ulong threadHandle, CpuContext context);
|
||||
|
||||
bool TryStartThread(CpuContext creatorContext, GuestThreadStartRequest request, out string? error);
|
||||
|
||||
bool TryJoinThread(
|
||||
@@ -39,6 +61,19 @@ public interface IGuestThreadScheduler
|
||||
|
||||
int WakeBlockedThreads(string wakeKey, int maxCount = int.MaxValue);
|
||||
|
||||
/// <summary>
|
||||
/// Applies a new guest scheduling priority to a live thread, mapping it
|
||||
/// onto the host thread if one is running. Returns false when the thread
|
||||
/// handle is unknown.
|
||||
/// </summary>
|
||||
bool TrySetGuestThreadPriority(ulong guestThreadHandle, int guestPriority);
|
||||
|
||||
/// <summary>
|
||||
/// Records a new affinity mask for a guest thread and re-applies it to
|
||||
/// the host thread where the platform supports it.
|
||||
/// </summary>
|
||||
bool TrySetGuestThreadAffinity(ulong guestThreadHandle, ulong affinityMask);
|
||||
|
||||
IReadOnlyList<GuestThreadSnapshot> SnapshotThreads();
|
||||
|
||||
bool TryCallGuestFunction(
|
||||
@@ -51,11 +86,36 @@ public interface IGuestThreadScheduler
|
||||
string reason,
|
||||
out string? error);
|
||||
|
||||
bool TryCallGuestFunction(
|
||||
CpuContext callerContext,
|
||||
ulong entryPoint,
|
||||
ulong arg0,
|
||||
ulong arg1,
|
||||
ulong arg2,
|
||||
ulong stackAddress,
|
||||
ulong stackSize,
|
||||
string reason,
|
||||
out ulong returnValue,
|
||||
out string? error);
|
||||
|
||||
bool TryCallGuestContinuation(
|
||||
CpuContext callerContext,
|
||||
GuestCpuContinuation continuation,
|
||||
string reason,
|
||||
out string? error);
|
||||
|
||||
/// <summary>
|
||||
/// Asynchronously invokes an installed kernel exception handler as the
|
||||
/// target guest thread. This is used by IL2CPP's stop-the-world collector:
|
||||
/// the handler acknowledges suspension and may remain blocked until the
|
||||
/// collecting thread resumes it.
|
||||
/// </summary>
|
||||
bool TryRaiseGuestException(
|
||||
CpuContext callerContext,
|
||||
ulong threadHandle,
|
||||
ulong handler,
|
||||
int exceptionType,
|
||||
out string? error);
|
||||
}
|
||||
|
||||
public readonly record struct GuestImportCallFrame(
|
||||
@@ -80,13 +140,34 @@ public readonly record struct GuestCpuContinuation(
|
||||
ulong Rdi,
|
||||
ulong R8,
|
||||
ulong R9,
|
||||
ulong R10,
|
||||
ulong R11,
|
||||
ulong R12,
|
||||
ulong R13,
|
||||
ulong R14,
|
||||
ulong R15);
|
||||
ulong R15,
|
||||
ushort FpuControlWord,
|
||||
uint Mxcsr,
|
||||
bool RestoreFullFpuState);
|
||||
|
||||
public static class GuestThreadExecution
|
||||
{
|
||||
private sealed class DelegateGuestThreadBlockWaiter : IGuestThreadBlockWaiter
|
||||
{
|
||||
private readonly Func<int> _resume;
|
||||
private readonly Func<bool> _tryWake;
|
||||
|
||||
public DelegateGuestThreadBlockWaiter(Func<int> resume, Func<bool> tryWake)
|
||||
{
|
||||
_resume = resume;
|
||||
_tryWake = tryWake;
|
||||
}
|
||||
|
||||
public int Resume() => _resume();
|
||||
|
||||
public bool TryWake() => _tryWake();
|
||||
}
|
||||
|
||||
[ThreadStatic]
|
||||
private static ulong _currentGuestThreadHandle;
|
||||
|
||||
@@ -106,10 +187,7 @@ public static class GuestThreadExecution
|
||||
private static string? _pendingBlockWakeKey;
|
||||
|
||||
[ThreadStatic]
|
||||
private static Func<int>? _pendingBlockResumeHandler;
|
||||
|
||||
[ThreadStatic]
|
||||
private static Func<bool>? _pendingBlockWakeHandler;
|
||||
private static IGuestThreadBlockWaiter? _pendingBlockWaiter;
|
||||
|
||||
[ThreadStatic]
|
||||
private static long _pendingBlockDeadlineTimestamp;
|
||||
@@ -157,8 +235,7 @@ public static class GuestThreadExecution
|
||||
_pendingBlockContinuationValid = false;
|
||||
_pendingBlockContinuation = default;
|
||||
_pendingBlockWakeKey = null;
|
||||
_pendingBlockResumeHandler = null;
|
||||
_pendingBlockWakeHandler = null;
|
||||
_pendingBlockWaiter = null;
|
||||
_pendingBlockDeadlineTimestamp = 0;
|
||||
_pendingEntryExit = false;
|
||||
_pendingEntryExitValue = 0;
|
||||
@@ -179,8 +256,7 @@ public static class GuestThreadExecution
|
||||
_pendingBlockContinuationValid = false;
|
||||
_pendingBlockContinuation = default;
|
||||
_pendingBlockWakeKey = null;
|
||||
_pendingBlockResumeHandler = null;
|
||||
_pendingBlockWakeHandler = null;
|
||||
_pendingBlockWaiter = null;
|
||||
_pendingBlockDeadlineTimestamp = 0;
|
||||
_pendingEntryExit = false;
|
||||
_pendingEntryExitValue = 0;
|
||||
@@ -211,8 +287,7 @@ public static class GuestThreadExecution
|
||||
CpuContext? context,
|
||||
string reason,
|
||||
string? wakeKey = null,
|
||||
Func<int>? resumeHandler = null,
|
||||
Func<bool>? wakeHandler = null,
|
||||
IGuestThreadBlockWaiter? waiter = null,
|
||||
long blockDeadlineTimestamp = 0)
|
||||
{
|
||||
if (!IsGuestThread)
|
||||
@@ -222,8 +297,7 @@ public static class GuestThreadExecution
|
||||
|
||||
_pendingBlockReason = string.IsNullOrWhiteSpace(reason) ? "guest_thread_blocked" : reason;
|
||||
_pendingBlockWakeKey = string.IsNullOrWhiteSpace(wakeKey) ? _pendingBlockReason : wakeKey;
|
||||
_pendingBlockResumeHandler = resumeHandler;
|
||||
_pendingBlockWakeHandler = wakeHandler;
|
||||
_pendingBlockWaiter = waiter;
|
||||
_pendingBlockDeadlineTimestamp = blockDeadlineTimestamp;
|
||||
if (context is not null && TryCaptureCurrentBlockContinuation(context, out var continuation))
|
||||
{
|
||||
@@ -239,6 +313,23 @@ public static class GuestThreadExecution
|
||||
return true;
|
||||
}
|
||||
|
||||
// Compatibility bridge for exports that still describe blocked work as a
|
||||
// resume/wake delegate pair. New hot paths should provide an
|
||||
// IGuestThreadBlockWaiter directly to avoid allocating closures.
|
||||
public static bool RequestCurrentThreadBlock(
|
||||
CpuContext? context,
|
||||
string reason,
|
||||
string? wakeKey,
|
||||
Func<int> resumeHandler,
|
||||
Func<bool> wakeHandler,
|
||||
long blockDeadlineTimestamp = 0) =>
|
||||
RequestCurrentThreadBlock(
|
||||
context,
|
||||
reason,
|
||||
wakeKey,
|
||||
new DelegateGuestThreadBlockWaiter(resumeHandler, wakeHandler),
|
||||
blockDeadlineTimestamp);
|
||||
|
||||
public static bool TryConsumeCurrentThreadBlock(out string reason)
|
||||
{
|
||||
return TryConsumeCurrentThreadBlock(out reason, out _, out _);
|
||||
@@ -255,7 +346,6 @@ public static class GuestThreadExecution
|
||||
out hasContinuation,
|
||||
out _,
|
||||
out _,
|
||||
out _,
|
||||
out _);
|
||||
}
|
||||
|
||||
@@ -264,19 +354,50 @@ public static class GuestThreadExecution
|
||||
out GuestCpuContinuation continuation,
|
||||
out bool hasContinuation,
|
||||
out string wakeKey,
|
||||
out Func<int>? resumeHandler,
|
||||
out Func<bool>? wakeHandler)
|
||||
out IGuestThreadBlockWaiter? waiter)
|
||||
{
|
||||
return TryConsumeCurrentThreadBlock(
|
||||
out reason,
|
||||
out continuation,
|
||||
out hasContinuation,
|
||||
out wakeKey,
|
||||
out resumeHandler,
|
||||
out wakeHandler,
|
||||
out waiter,
|
||||
out _);
|
||||
}
|
||||
|
||||
public static bool TryConsumeCurrentThreadBlock(
|
||||
out string reason,
|
||||
out GuestCpuContinuation continuation,
|
||||
out bool hasContinuation,
|
||||
out string wakeKey,
|
||||
out IGuestThreadBlockWaiter? waiter,
|
||||
out long blockDeadlineTimestamp)
|
||||
{
|
||||
reason = _pendingBlockReason ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(reason))
|
||||
{
|
||||
continuation = default;
|
||||
hasContinuation = false;
|
||||
wakeKey = string.Empty;
|
||||
waiter = null;
|
||||
blockDeadlineTimestamp = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
continuation = _pendingBlockContinuation;
|
||||
hasContinuation = _pendingBlockContinuationValid;
|
||||
wakeKey = _pendingBlockWakeKey ?? reason;
|
||||
waiter = _pendingBlockWaiter;
|
||||
blockDeadlineTimestamp = _pendingBlockDeadlineTimestamp;
|
||||
_pendingBlockReason = null;
|
||||
_pendingBlockContinuation = default;
|
||||
_pendingBlockContinuationValid = false;
|
||||
_pendingBlockWakeKey = null;
|
||||
_pendingBlockWaiter = null;
|
||||
_pendingBlockDeadlineTimestamp = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryConsumeCurrentThreadBlock(
|
||||
out string reason,
|
||||
out GuestCpuContinuation continuation,
|
||||
@@ -286,32 +407,16 @@ public static class GuestThreadExecution
|
||||
out Func<bool>? wakeHandler,
|
||||
out long blockDeadlineTimestamp)
|
||||
{
|
||||
reason = _pendingBlockReason ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(reason))
|
||||
{
|
||||
continuation = default;
|
||||
hasContinuation = false;
|
||||
wakeKey = string.Empty;
|
||||
resumeHandler = null;
|
||||
wakeHandler = null;
|
||||
blockDeadlineTimestamp = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
continuation = _pendingBlockContinuation;
|
||||
hasContinuation = _pendingBlockContinuationValid;
|
||||
wakeKey = _pendingBlockWakeKey ?? reason;
|
||||
resumeHandler = _pendingBlockResumeHandler;
|
||||
wakeHandler = _pendingBlockWakeHandler;
|
||||
blockDeadlineTimestamp = _pendingBlockDeadlineTimestamp;
|
||||
_pendingBlockReason = null;
|
||||
_pendingBlockContinuation = default;
|
||||
_pendingBlockContinuationValid = false;
|
||||
_pendingBlockWakeKey = null;
|
||||
_pendingBlockResumeHandler = null;
|
||||
_pendingBlockWakeHandler = null;
|
||||
_pendingBlockDeadlineTimestamp = 0;
|
||||
return true;
|
||||
var consumed = TryConsumeCurrentThreadBlock(
|
||||
out reason,
|
||||
out continuation,
|
||||
out hasContinuation,
|
||||
out wakeKey,
|
||||
out var waiter,
|
||||
out blockDeadlineTimestamp);
|
||||
resumeHandler = waiter is null ? null : waiter.Resume;
|
||||
wakeHandler = waiter is null ? null : waiter.TryWake;
|
||||
return consumed;
|
||||
}
|
||||
|
||||
public static long ComputeDeadlineTimestamp(TimeSpan timeout)
|
||||
@@ -360,10 +465,15 @@ public static class GuestThreadExecution
|
||||
context[CpuRegister.Rdi],
|
||||
context[CpuRegister.R8],
|
||||
context[CpuRegister.R9],
|
||||
context[CpuRegister.R10],
|
||||
context[CpuRegister.R11],
|
||||
context[CpuRegister.R12],
|
||||
context[CpuRegister.R13],
|
||||
context[CpuRegister.R14],
|
||||
context[CpuRegister.R15]);
|
||||
context[CpuRegister.R15],
|
||||
context.FpuControlWord,
|
||||
context.Mxcsr,
|
||||
RestoreFullFpuState: false);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace SharpEmu.HLE;
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide registry of ELF PT_TLS templates and per-thread dynamic thread
|
||||
/// vectors. AMD64 uses TLS Variant II: startup/static module blocks precede the
|
||||
/// thread pointer, while modules introduced after a thread was initialized are
|
||||
/// represented by separately allocated DTV entries.
|
||||
/// </summary>
|
||||
public static class GuestTlsTemplate
|
||||
{
|
||||
// Must match CpuDispatcher/DirectExecutionBackend's mapped prefix. PS5
|
||||
// modules can require more than one host page of Variant II static TLS;
|
||||
// Dreaming Sarah's startup image, for example, reaches 0x1870 bytes.
|
||||
public const ulong StartupStaticTlsReservation = 0x10000UL;
|
||||
private static readonly object _gate = new();
|
||||
private static readonly SortedDictionary<ulong, ModuleTemplate> _modules = new();
|
||||
private static readonly Dictionary<ulong, ThreadDtv> _threadDtvs = new();
|
||||
private static ulong _staticTlsSize;
|
||||
private static ulong _maximumAlignment = 1;
|
||||
private static ulong _generation;
|
||||
|
||||
static GuestTlsTemplate()
|
||||
{
|
||||
RunTlsLayoutSelfChecks();
|
||||
}
|
||||
|
||||
private sealed class ModuleTemplate
|
||||
{
|
||||
public required ulong ModuleId { get; init; }
|
||||
public required byte[] InitImage { get; init; }
|
||||
public required ulong MemorySize { get; init; }
|
||||
public required ulong Alignment { get; init; }
|
||||
public required ulong AlignmentBias { get; init; }
|
||||
public required ulong StaticOffset { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ThreadDtv
|
||||
{
|
||||
public ulong Generation { get; set; }
|
||||
public Dictionary<ulong, DtvEntry> Entries { get; } = new();
|
||||
public nint DtvAllocationBase { get; set; }
|
||||
public ulong DtvAddress { get; set; }
|
||||
}
|
||||
|
||||
private sealed class DtvEntry
|
||||
{
|
||||
public required ulong Address { get; init; }
|
||||
public nint AllocationBase { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Main executable's initialized PT_TLS bytes.</summary>
|
||||
public static byte[] InitImage
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _modules.TryGetValue(1, out var module)
|
||||
? (byte[])module.InitImage.Clone()
|
||||
: [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Aligned size of the main executable's static TLS block.</summary>
|
||||
public static ulong BlockSize
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _modules.TryGetValue(1, out var module)
|
||||
? module.StaticOffset
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Main executable's PT_TLS alignment.</summary>
|
||||
public static ulong Alignment
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _modules.TryGetValue(1, out var module)
|
||||
? module.Alignment
|
||||
: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Total Variant II static TLS span required below the TCB.</summary>
|
||||
public static ulong StaticTlsSize
|
||||
{
|
||||
get { lock (_gate) { return _staticTlsSize; } }
|
||||
}
|
||||
|
||||
/// <summary>Largest PT_TLS alignment required by the registered modules.</summary>
|
||||
public static ulong MaximumAlignment
|
||||
{
|
||||
get { lock (_gate) { return _maximumAlignment; } }
|
||||
}
|
||||
|
||||
/// <summary>Current DTV generation, incremented for every registered module.</summary>
|
||||
public static ulong Generation
|
||||
{
|
||||
get { lock (_gate) { return _generation; } }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backwards-compatible main-module registration entry point.
|
||||
/// </summary>
|
||||
public static void Set(ReadOnlySpan<byte> initImage, ulong memorySize, ulong alignment)
|
||||
{
|
||||
Reset();
|
||||
RegisterModule(1, initImage, memorySize, alignment, alignmentBias: 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers one module's PT_TLS template and allocates its Variant II
|
||||
/// static offset. Modules must be registered in loader module-ID order.
|
||||
/// </summary>
|
||||
public static ulong RegisterModule(
|
||||
ulong moduleId,
|
||||
ReadOnlySpan<byte> initImage,
|
||||
ulong memorySize,
|
||||
ulong alignment,
|
||||
ulong alignmentBias = 0)
|
||||
{
|
||||
if (moduleId == 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(moduleId));
|
||||
}
|
||||
|
||||
var normalizedAlignment = alignment == 0 ? 1 : alignment;
|
||||
if ((normalizedAlignment & (normalizedAlignment - 1)) != 0)
|
||||
{
|
||||
throw new InvalidDataException($"PT_TLS alignment 0x{alignment:X} is not a power of two.");
|
||||
}
|
||||
if (memorySize > int.MaxValue || (ulong)initImage.Length > memorySize)
|
||||
{
|
||||
throw new InvalidDataException("PT_TLS template size is invalid or exceeds the supported process limit.");
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_modules.TryGetValue(moduleId, out var existing))
|
||||
{
|
||||
if (existing.MemorySize != memorySize ||
|
||||
existing.Alignment != normalizedAlignment ||
|
||||
existing.AlignmentBias != alignmentBias ||
|
||||
!existing.InitImage.AsSpan().SequenceEqual(initImage))
|
||||
{
|
||||
throw new InvalidOperationException($"TLS module {moduleId} was registered with a different template.");
|
||||
}
|
||||
|
||||
return existing.StaticOffset;
|
||||
}
|
||||
|
||||
// FreeBSD/AMD64 Variant II: choose the smallest offset satisfying
|
||||
// offset - previous >= size and (-offset) % align == p_vaddr % align.
|
||||
var staticOffset = CalculateStaticOffset(
|
||||
_staticTlsSize,
|
||||
memorySize,
|
||||
normalizedAlignment,
|
||||
alignmentBias);
|
||||
if (staticOffset > StartupStaticTlsReservation)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Static TLS requires 0x{staticOffset:X} bytes, but startup maps only " +
|
||||
$"0x{StartupStaticTlsReservation:X} bytes below the thread pointer.");
|
||||
}
|
||||
_modules.Add(moduleId, new ModuleTemplate
|
||||
{
|
||||
ModuleId = moduleId,
|
||||
InitImage = initImage.ToArray(),
|
||||
MemorySize = memorySize,
|
||||
Alignment = normalizedAlignment,
|
||||
AlignmentBias = alignmentBias,
|
||||
StaticOffset = staticOffset,
|
||||
});
|
||||
_staticTlsSize = staticOffset;
|
||||
_maximumAlignment = Math.Max(_maximumAlignment, normalizedAlignment);
|
||||
_generation++;
|
||||
return staticOffset;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the static TP-relative block offset for a module.</summary>
|
||||
public static bool TryGetStaticOffset(ulong moduleId, out ulong staticOffset)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_modules.TryGetValue(moduleId, out var module))
|
||||
{
|
||||
staticOffset = module.StaticOffset;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
staticOffset = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Clears all module templates and frees dynamic DTV blocks.</summary>
|
||||
public static void Reset()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
foreach (var dtv in _threadDtvs.Values)
|
||||
{
|
||||
FreeDynamicEntries(dtv);
|
||||
}
|
||||
|
||||
_threadDtvs.Clear();
|
||||
_modules.Clear();
|
||||
_staticTlsSize = 0;
|
||||
_maximumAlignment = 1;
|
||||
_generation++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes every currently registered startup TLS block for a thread
|
||||
/// and records their addresses in that thread's DTV.
|
||||
/// </summary>
|
||||
public static void SeedThreadBlock(CpuContext context, ulong threadPointer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
if (threadPointer == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_threadDtvs.Remove(threadPointer, out var oldDtv))
|
||||
{
|
||||
FreeDynamicEntries(oldDtv);
|
||||
}
|
||||
|
||||
var dtv = new ThreadDtv();
|
||||
_threadDtvs.Add(threadPointer, dtv);
|
||||
foreach (var module in _modules.Values)
|
||||
{
|
||||
dtv.Entries[module.ModuleId] = CreateStaticOrDynamicEntry(context, threadPointer, module);
|
||||
}
|
||||
RebuildGuestDtv(context, threadPointer, dtv);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implements the DTV lookup used by <c>__tls_get_addr</c>. Unknown module
|
||||
/// IDs and offsets outside the module's TLS image are rejected with zero.
|
||||
/// </summary>
|
||||
public static ulong ResolveAddress(CpuContext context, ulong moduleId, ulong offset)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
if (context.FsBase == 0 || moduleId == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_modules.TryGetValue(moduleId, out var module) || offset >= module.MemorySize)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!_threadDtvs.TryGetValue(context.FsBase, out var dtv))
|
||||
{
|
||||
dtv = new ThreadDtv();
|
||||
_threadDtvs.Add(context.FsBase, dtv);
|
||||
foreach (var startupModule in _modules.Values)
|
||||
{
|
||||
dtv.Entries[startupModule.ModuleId] = CreateStaticOrDynamicEntry(context, context.FsBase, startupModule);
|
||||
}
|
||||
RebuildGuestDtv(context, context.FsBase, dtv);
|
||||
}
|
||||
|
||||
var entryAdded = false;
|
||||
if (!dtv.Entries.TryGetValue(moduleId, out var entry))
|
||||
{
|
||||
// This module appeared after the thread's startup TLS layout was
|
||||
// seeded. Model rtld's lazy DTV allocation instead of assuming
|
||||
// unused space exists below the already-live thread pointer.
|
||||
entry = CreateDynamicEntry(module);
|
||||
dtv.Entries.Add(moduleId, entry);
|
||||
entryAdded = true;
|
||||
}
|
||||
|
||||
if (entryAdded || dtv.Generation != _generation)
|
||||
{
|
||||
RebuildGuestDtv(context, context.FsBase, dtv);
|
||||
}
|
||||
|
||||
return checked(entry.Address + offset);
|
||||
}
|
||||
}
|
||||
|
||||
private static DtvEntry CreateStaticOrDynamicEntry(
|
||||
CpuContext context,
|
||||
ulong threadPointer,
|
||||
ModuleTemplate module)
|
||||
{
|
||||
if (threadPointer >= module.StaticOffset)
|
||||
{
|
||||
var address = threadPointer - module.StaticOffset;
|
||||
var zeroImage = module.MemorySize == 0 ? [] : new byte[(int)module.MemorySize];
|
||||
if ((zeroImage.Length == 0 || context.Memory.TryWrite(address, zeroImage)) &&
|
||||
(module.InitImage.Length == 0 || context.Memory.TryWrite(address, module.InitImage)))
|
||||
{
|
||||
return new DtvEntry { Address = address };
|
||||
}
|
||||
}
|
||||
|
||||
return CreateDynamicEntry(module);
|
||||
}
|
||||
|
||||
private static DtvEntry CreateDynamicEntry(ModuleTemplate module)
|
||||
{
|
||||
var size = Math.Max(1UL, module.MemorySize);
|
||||
var allocationSize = checked(size + module.Alignment - 1);
|
||||
if (allocationSize > int.MaxValue)
|
||||
{
|
||||
throw new OutOfMemoryException("TLS module allocation exceeds the supported host allocation size.");
|
||||
}
|
||||
|
||||
var allocationBase = Marshal.AllocHGlobal((int)allocationSize);
|
||||
var alignedAddress = AlignUp(unchecked((ulong)allocationBase), module.Alignment);
|
||||
Marshal.Copy(new byte[(int)size], 0, unchecked((nint)alignedAddress), (int)size);
|
||||
if (module.InitImage.Length != 0)
|
||||
{
|
||||
Marshal.Copy(module.InitImage, 0, unchecked((nint)alignedAddress), module.InitImage.Length);
|
||||
}
|
||||
|
||||
return new DtvEntry
|
||||
{
|
||||
Address = alignedAddress,
|
||||
AllocationBase = allocationBase,
|
||||
};
|
||||
}
|
||||
|
||||
private static void FreeDynamicEntries(ThreadDtv dtv)
|
||||
{
|
||||
foreach (var entry in dtv.Entries.Values)
|
||||
{
|
||||
if (entry.AllocationBase != 0)
|
||||
{
|
||||
Marshal.FreeHGlobal(entry.AllocationBase);
|
||||
}
|
||||
}
|
||||
if (dtv.DtvAllocationBase != 0)
|
||||
{
|
||||
Marshal.FreeHGlobal(dtv.DtvAllocationBase);
|
||||
dtv.DtvAllocationBase = 0;
|
||||
dtv.DtvAddress = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static void RebuildGuestDtv(CpuContext context, ulong threadPointer, ThreadDtv dtv)
|
||||
{
|
||||
var maximumModuleId = _modules.Count == 0 ? 0UL : _modules.Keys.Max();
|
||||
var byteSize = checked(2UL * sizeof(ulong) + maximumModuleId * sizeof(ulong));
|
||||
if (byteSize > int.MaxValue)
|
||||
{
|
||||
throw new OutOfMemoryException("Guest DTV exceeds the supported host allocation size.");
|
||||
}
|
||||
|
||||
var newAllocation = Marshal.AllocHGlobal((int)Math.Max((ulong)sizeof(ulong) * 2, byteSize));
|
||||
var newAddress = unchecked((ulong)newAllocation);
|
||||
Marshal.Copy(new byte[(int)Math.Max((ulong)sizeof(ulong) * 2, byteSize)], 0, newAllocation, (int)Math.Max((ulong)sizeof(ulong) * 2, byteSize));
|
||||
Marshal.WriteInt64(newAllocation, 0, unchecked((long)_generation));
|
||||
Marshal.WriteInt64(newAllocation, sizeof(ulong), unchecked((long)maximumModuleId));
|
||||
foreach (var (moduleId, entry) in dtv.Entries)
|
||||
{
|
||||
var slotOffset = checked((int)(2UL * sizeof(ulong) + (moduleId - 1) * sizeof(ulong)));
|
||||
Marshal.WriteInt64(newAllocation, slotOffset, unchecked((long)entry.Address));
|
||||
}
|
||||
|
||||
if (!context.TryWriteUInt64(threadPointer + sizeof(ulong), newAddress))
|
||||
{
|
||||
Marshal.FreeHGlobal(newAllocation);
|
||||
throw new InvalidOperationException("Failed to install the guest DTV pointer in the thread control block.");
|
||||
}
|
||||
|
||||
if (dtv.DtvAllocationBase != 0)
|
||||
{
|
||||
Marshal.FreeHGlobal(dtv.DtvAllocationBase);
|
||||
}
|
||||
dtv.DtvAllocationBase = newAllocation;
|
||||
dtv.DtvAddress = newAddress;
|
||||
dtv.Generation = _generation;
|
||||
}
|
||||
|
||||
private static ulong AlignUp(ulong value, ulong alignment)
|
||||
{
|
||||
var mask = alignment - 1;
|
||||
return checked((value + mask) & ~mask);
|
||||
}
|
||||
|
||||
private static ulong CalculateStaticOffset(
|
||||
ulong previousOffset,
|
||||
ulong size,
|
||||
ulong alignment,
|
||||
ulong alignmentBias)
|
||||
{
|
||||
var result = checked(previousOffset + size + alignment - 1);
|
||||
return result - ((result + alignmentBias) & (alignment - 1));
|
||||
}
|
||||
|
||||
[Conditional("DEBUG")]
|
||||
private static void RunTlsLayoutSelfChecks()
|
||||
{
|
||||
var firstOffset = RegisterModule(1, [0x11, 0x22], 0x20, 0x10, 0);
|
||||
var secondOffset = RegisterModule(2, [0x7A], 0x18, 0x20, 8);
|
||||
Debug.Assert(firstOffset == 0x20, "First Variant II TLS offset is incorrect.");
|
||||
Debug.Assert(secondOffset >= firstOffset + 0x18, "TLS modules overlap in the static layout.");
|
||||
Debug.Assert((unchecked(0UL - secondOffset) & 0x1F) == 8, "PT_TLS virtual-address congruence was not preserved.");
|
||||
|
||||
var lateEntry = CreateDynamicEntry(_modules[2]);
|
||||
try
|
||||
{
|
||||
Debug.Assert(lateEntry.AllocationBase != 0, "A late TLS module did not receive a dynamic DTV block.");
|
||||
Debug.Assert(Marshal.ReadByte(unchecked((nint)lateEntry.Address)) == 0x7A, "Late-module tdata was not initialized.");
|
||||
Debug.Assert(Marshal.ReadByte(unchecked((nint)lateEntry.Address + 1)) == 0, "Late-module tbss was not zero initialized.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(lateEntry.AllocationBase);
|
||||
Reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ public static class HleDataSymbols
|
||||
private const string LibcNeedFlagNid = "P330P3dFF68";
|
||||
private const string LibcInternalNeedFlagNid = "ZT4ODD2Ts9o";
|
||||
private const int ProgNameMaxBytes = 511;
|
||||
private const ulong StackChkGuardValue = 0xC0DEC0DECAFEBABEUL;
|
||||
// Terminator canaries reserve the low byte as NUL. Keep the process data
|
||||
// symbol and every per-thread TLS copy byte-for-byte identical.
|
||||
private const ulong StackChkGuardValue = 0xC0DEC0DECAFEBA00UL;
|
||||
|
||||
private static readonly object _gate = new();
|
||||
private static readonly nint _stackChkGuardAddress = Allocate(sizeof(ulong) * 2);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.HLE.Host;
|
||||
|
||||
/// <summary>
|
||||
/// General-purpose register snapshot of a suspended thread, produced by
|
||||
/// <see cref="IHostThreading.TryCaptureThreadRegisters"/>. Registers are named
|
||||
/// after the guest ISA (x86-64), which every supported host executes natively.
|
||||
/// </summary>
|
||||
public readonly record struct HostCapturedRegisters(
|
||||
ulong Rip,
|
||||
ulong Rsp,
|
||||
ulong Rbp,
|
||||
ulong Rax,
|
||||
ulong Rbx,
|
||||
ulong Rcx,
|
||||
ulong Rdx);
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.HLE.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Host-neutral gamepad button flags. Named after the PlayStation layout the guest API
|
||||
/// exposes, but the numeric values are the seam's own — the HLE pad exports translate
|
||||
/// them to SCE_PAD_BUTTON bits, so guest ABI values never leak into host backends.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum HostGamepadButtons : uint
|
||||
{
|
||||
None = 0,
|
||||
Up = 1 << 0,
|
||||
Down = 1 << 1,
|
||||
Left = 1 << 2,
|
||||
Right = 1 << 3,
|
||||
Cross = 1 << 4,
|
||||
Circle = 1 << 5,
|
||||
Square = 1 << 6,
|
||||
Triangle = 1 << 7,
|
||||
L1 = 1 << 8,
|
||||
R1 = 1 << 9,
|
||||
L2 = 1 << 10,
|
||||
R2 = 1 << 11,
|
||||
L3 = 1 << 12,
|
||||
R3 = 1 << 13,
|
||||
Options = 1 << 14,
|
||||
TouchPad = 1 << 15,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of one host gamepad: sticks are 0..255 with 128 centered and Y growing
|
||||
/// downward; triggers 0..255. Unmanaged on purpose so per-frame polls can stackalloc
|
||||
/// snapshot buffers.
|
||||
/// </summary>
|
||||
public readonly record struct HostGamepadState(
|
||||
bool Connected,
|
||||
HostGamepadButtons Buttons,
|
||||
byte LeftX,
|
||||
byte LeftY,
|
||||
byte RightX,
|
||||
byte RightY,
|
||||
byte LeftTrigger,
|
||||
byte RightTrigger);
|
||||
@@ -0,0 +1,20 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.HLE.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Platform-neutral page protection. Values intentionally enumerate the exact
|
||||
/// combinations the emulator uses today so each maps 1:1 onto a single native
|
||||
/// protection constant (PAGE_* on Windows, PROT_* elsewhere).
|
||||
/// </summary>
|
||||
public enum HostPageProtection
|
||||
{
|
||||
NoAccess,
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
Execute,
|
||||
ReadExecute,
|
||||
ReadWriteExecute,
|
||||
ExecuteWriteCopy,
|
||||
}
|
||||