Compare commits
196 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93829e3242 | |||
| 4191a9e12b | |||
| 559b7f0a84 | |||
| 2272b9b576 | |||
| 8dd3172c0f | |||
| e7ea186ea8 | |||
| 74a519875b | |||
| 4682e64e81 | |||
| 912883de05 | |||
| f704586a8d | |||
| d3600c9255 | |||
| 5f97031df5 | |||
| 2a4da8c0a9 | |||
| 4c37e64c66 | |||
| fc9e3ff393 | |||
| eb47d753f6 | |||
| 6aa78bb55b | |||
| 9be6f85ef0 | |||
| 4c8c67a3dd | |||
| ada67a1924 | |||
| 2379e8988c | |||
| 105c58b380 | |||
| da35f0db47 | |||
| 1f3963c543 | |||
| 4bb1af93d7 | |||
| 0ae785c617 | |||
| e01092aa38 | |||
| 224a36eba7 | |||
| ac883e44fa | |||
| 25d741b35b | |||
| dce7c87c4d | |||
| 6ee445f0c2 | |||
| 9d187dec55 | |||
| 3574a3b145 | |||
| a1cbff8a9c | |||
| db9b20481c | |||
| 8cd46243ab | |||
| 3334707f7c | |||
| 20eda4443c | |||
| bb3318a503 | |||
| d151e151c2 | |||
| 472fc96a37 | |||
| 33be88bdf9 | |||
| 184e24fbb6 | |||
| 327018e80a | |||
| 04557fd250 | |||
| 90c72ebecf | |||
| 8ef5a54ee4 | |||
| 0c467e8c57 | |||
| 9ff60abb9b | |||
| 3ebfc56d4c | |||
| 73e8821d5b | |||
| bc51cc2c4d | |||
| d7f6e3f578 | |||
| e56e74f960 | |||
| 0f224ec036 | |||
| 85dc98dedc | |||
| 5d7d8e0edd | |||
| a60bfc9c83 | |||
| 0b83b34cda | |||
| 09812600a0 | |||
| 3005babab8 | |||
| 09bd4f028b | |||
| 2bda253927 | |||
| 71e5912c75 | |||
| a030cb5a5d | |||
| 336286e588 | |||
| cab001f265 | |||
| bab965e394 | |||
| daaeb6213e | |||
| 94153955b0 | |||
| 6dda6589d0 | |||
| 2ced3af114 | |||
| 18708aa2d3 | |||
| a709ccca17 | |||
| 5309f384cf | |||
| e6be48a390 | |||
| b3e3fe5ea8 | |||
| c9e2a1a390 | |||
| 01b80fe381 | |||
| f84d869795 | |||
| 13269797bf | |||
| 1c8cdd6537 | |||
| b566444df3 | |||
| 8a6f4f7826 | |||
| 41c9b44a8a | |||
| 743fe5cc26 | |||
| 0b1dea43e8 | |||
| c9d018db8e | |||
| b479dc0466 | |||
| 22bbb4e909 | |||
| 3c500d2cf0 | |||
| bcb0ebd991 | |||
| ecbb0db9be | |||
| 81633f6d5a | |||
| cc290f860b | |||
| ff3ac0bba1 | |||
| 847371d2de | |||
| fbafd3f429 | |||
| aa25f6e978 | |||
| b9eee497aa | |||
| a9a4366ef4 | |||
| 76e8f789c4 | |||
| 6fce0e3055 | |||
| 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 |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 86 KiB |
@@ -0,0 +1,27 @@
|
||||
## Before submitting
|
||||
|
||||
Please read our contribution guidelines before opening a pull request:
|
||||
|
||||
➡️ [**CONTRIBUTING.md**](https://github.com/sharpemu/sharpemu/blob/main/CONTRIBUTING.md)
|
||||
|
||||
By opening this pull request, you confirm that you have read and agree to follow the contribution guidelines.
|
||||
|
||||
## Testing
|
||||
|
||||
If applicable, list the game(s) you tested and briefly describe the results.
|
||||
|
||||
Example:
|
||||
|
||||
- Demon's Souls (PPSA01341) – Boots to splash screen.
|
||||
- Dreaming Sarah – Save/load works correctly.
|
||||
|
||||
If your changes do not affect runtime behavior (e.g. documentation, tooling, CI), write `N/A`.
|
||||
|
||||
## Checklist
|
||||
|
||||
By submitting this pull request, you confirm that:
|
||||
|
||||
- [ ] I have read and followed `CONTRIBUTING.md`.
|
||||
- [ ] I tested my changes or marked the testing section as `N/A`.
|
||||
- [ ] I wrote this pull request description myself and did not paste AI-generated explanations.
|
||||
- [ ] I listed the game(s) I tested (or marked the testing section as `N/A`).
|
||||
@@ -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:
|
||||
@@ -80,7 +89,6 @@ jobs:
|
||||
DOTNET_NOLOGO: true
|
||||
NUGET_PACKAGES: ${{ github.workspace }}\.nuget\packages
|
||||
PUBLISH_DIR: ${{ github.workspace }}\artifacts\publish\win-x64
|
||||
RELEASE_DIR: ${{ github.workspace }}\artifacts\release
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
@@ -92,69 +100,187 @@ 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}"
|
||||
|
||||
- name: Create release archive
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path $env:RELEASE_DIR -Force | Out-Null
|
||||
|
||||
$archivePath = Join-Path $env:RELEASE_DIR "${{ needs.init.outputs.archive-name }}"
|
||||
if (Test-Path $archivePath) {
|
||||
Remove-Item $archivePath -Force
|
||||
}
|
||||
|
||||
Compress-Archive -Path (Join-Path $env:PUBLISH_DIR '*') -DestinationPath $archivePath -CompressionLevel Optimal
|
||||
|
||||
- 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.PUBLISH_DIR }}
|
||||
if-no-files-found: error
|
||||
include-hidden-files: true
|
||||
|
||||
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 }}
|
||||
SPIRV_HEADERS_COMMIT: ad9184e76a66b1001c29db9b0a3e87f646c64de0
|
||||
# SpirvModuleBuilder emits SPIR-V 1.5 and VulkanVideoPresenter requests Vulkan 1.2.
|
||||
SPIRV_TARGET_ENV: vulkan1.2
|
||||
SPIRV_TOOLS_COMMIT: 0539c81f69a3daeb706fd3477dca61435b475156
|
||||
SPIRV_TOOLS_VERSION: v2026.2
|
||||
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: Build pinned SPIRV-Tools
|
||||
if: matrix.rid == 'linux-x64'
|
||||
run: |
|
||||
git clone --no-checkout --filter=blob:none https://github.com/KhronosGroup/SPIRV-Tools.git "$RUNNER_TEMP/spirv-tools"
|
||||
git -C "$RUNNER_TEMP/spirv-tools" checkout --detach "$SPIRV_TOOLS_COMMIT"
|
||||
test "$(git -C "$RUNNER_TEMP/spirv-tools" rev-parse HEAD)" = "$SPIRV_TOOLS_COMMIT"
|
||||
|
||||
git clone --no-checkout --filter=blob:none https://github.com/KhronosGroup/SPIRV-Headers.git "$RUNNER_TEMP/spirv-tools/external/spirv-headers"
|
||||
git -C "$RUNNER_TEMP/spirv-tools/external/spirv-headers" checkout --detach "$SPIRV_HEADERS_COMMIT"
|
||||
test "$(git -C "$RUNNER_TEMP/spirv-tools/external/spirv-headers" rev-parse HEAD)" = "$SPIRV_HEADERS_COMMIT"
|
||||
|
||||
cmake -S "$RUNNER_TEMP/spirv-tools" -B "$RUNNER_TEMP/spirv-tools-build" \
|
||||
-G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DSPIRV_SKIP_TESTS=ON \
|
||||
-DSPIRV_WERROR=OFF
|
||||
cmake --build "$RUNNER_TEMP/spirv-tools-build" --target spirv-val
|
||||
|
||||
- name: Generate and validate synthetic SPIR-V
|
||||
if: matrix.rid == 'linux-x64'
|
||||
run: |
|
||||
dotnet run --project tools/SharpEmu.Tools.ShaderDump/SharpEmu.Tools.ShaderDump.csproj -c Release -- artifacts/shader-dump
|
||||
scripts/validate-synthetic-spirv.sh \
|
||||
"$RUNNER_TEMP/spirv-tools-build/tools/spirv-val" \
|
||||
"$SPIRV_TOOLS_VERSION" \
|
||||
"$SPIRV_TARGET_ENV" \
|
||||
artifacts/shader-dump
|
||||
|
||||
- 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: Upload build artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: sharpemu-${{ matrix.rid }}-${{ needs.init.outputs.short-sha }}
|
||||
path: ${{ env.PUBLISH_DIR }}
|
||||
if-no-files-found: error
|
||||
include-hidden-files: true
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
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: Package release assets
|
||||
shell: bash
|
||||
env:
|
||||
SHORT_SHA: ${{ needs.init.outputs.short-sha }}
|
||||
VERSION: ${{ needs.init.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
win_dir="release/sharpemu-win-x64-${SHORT_SHA}"
|
||||
linux_dir="release/sharpemu-linux-x64-${SHORT_SHA}"
|
||||
macos_dir="release/sharpemu-osx-x64-${SHORT_SHA}"
|
||||
for package_dir in "${win_dir}" "${linux_dir}" "${macos_dir}"; do
|
||||
test -d "${package_dir}"
|
||||
done
|
||||
|
||||
mkdir -p release-assets
|
||||
(cd "${win_dir}" && zip -q -r "../../release-assets/sharpemu-${VERSION}-win-x64.zip" .)
|
||||
|
||||
chmod +x "${linux_dir}/SharpEmu" "${macos_dir}/SharpEmu"
|
||||
tar -czf "release-assets/sharpemu-${VERSION}-linux-x64.tar.gz" -C "${linux_dir}" .
|
||||
tar -czf "release-assets/sharpemu-${VERSION}-osx-x64.tar.gz" -C "${macos_dir}" .
|
||||
|
||||
- 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-assets -maxdepth 1 -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
|
||||
if [ "${#assets[@]}" -ne 3 ]; then
|
||||
echo "Expected 3 release assets, found ${#assets[@]}." >&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}"
|
||||
|
||||
@@ -32,6 +32,8 @@ packages/
|
||||
.nuget/
|
||||
.dotnet-home/
|
||||
.cache/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -40,3 +42,4 @@ ehthumbs.db
|
||||
|
||||
.vs/
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -5,6 +5,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
# Contributing
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The pull request template is mandatory.
|
||||
>
|
||||
> Pull requests that do not follow the template or leave the required checklist incomplete will be closed without review, even if the proposed code is technically correct or beneficial. Please review these contribution guidelines before submitting a pull request.
|
||||
|
||||
Contributions are always welcome!
|
||||
|
||||
Before opening a pull request, please keep the following in mind:
|
||||
@@ -21,6 +26,25 @@ Before opening a pull request, please keep the following in mind:
|
||||
|
||||
If you're unsure about a design decision, feel free to open a discussion or draft PR first.
|
||||
|
||||
## Pull Request Expectations
|
||||
|
||||
Pull requests should provide real, observable emulator behavior rather than only suppressing errors or unresolved imports.
|
||||
|
||||
Changes that only return success, zero, or fabricated handles without implementing the expected state, output, or side effects will generally not be accepted. Functions that create resources, write output structures, register callbacks, or expose runtime state should model the behavior required by the guest.
|
||||
|
||||
When applicable, PRs should include:
|
||||
|
||||
- The affected game or application.
|
||||
- Relevant logs or failing imports.
|
||||
- Behavior before and after the change.
|
||||
- Real game testing and known limitations.
|
||||
|
||||
Avoid submitting large collections of speculative NIDs or unrelated exports. Keep each PR focused on one problem or a closely related set of changes.
|
||||
|
||||
Large architectural changes should be discussed with the maintainers before implementation. Contributors are encouraged to ask first when they are uncertain whether a proposed direction fits the project.
|
||||
|
||||
Opening a PR does not guarantee that it will be merged. Maintainers evaluate changes based on correctness, evidence, testing, scope, maintenance cost, and the long-term direction of the project.
|
||||
|
||||
## AI-Assisted Contributions
|
||||
|
||||
AI-assisted development is welcome and may be used for research, reverse engineering, code generation, or documentation.
|
||||
|
||||
@@ -9,10 +9,18 @@ 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.5</SharpEmuVersion>
|
||||
<Version>$(SharpEmuVersion)</Version>
|
||||
|
||||
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
|
||||
|
||||
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And $([MSBuild]::IsOSPlatform('Windows'))">win</_HostRidOSPrefix>
|
||||
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And '$(_HostRidOSPrefix)' == '' And $([MSBuild]::IsOSPlatform('Linux'))">linux</_HostRidOSPrefix>
|
||||
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And '$(_HostRidOSPrefix)' == '' And $([MSBuild]::IsOSPlatform('OSX'))">osx</_HostRidOSPrefix>
|
||||
<_HostRidArch Condition="'$(_HostRidOSPrefix)' != '' And '$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)' == 'Arm64'">arm64</_HostRidArch>
|
||||
<_HostRidArch Condition="'$(_HostRidOSPrefix)' != '' And '$(_HostRidArch)' == ''">x64</_HostRidArch>
|
||||
<RuntimeIdentifier Condition="'$(_HostRidOSPrefix)' != ''">$(_HostRidOSPrefix)-$(_HostRidArch)</RuntimeIdentifier>
|
||||
|
||||
<BaseIntermediateOutputPath>$(RepoRoot)artifacts/obj/$(MSBuildProjectName)/</BaseIntermediateOutputPath>
|
||||
<BaseOutputPath>$(RepoRoot)artifacts/bin/</BaseOutputPath>
|
||||
|
||||
|
||||
@@ -11,12 +11,20 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="11.3.18" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.18" />
|
||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.18" />
|
||||
<PackageVersion Include="FFmpeg.AutoGen" Version="7.1.1" />
|
||||
<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,11 @@ 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, and Windows on ARM devices
|
||||
> (e.g. Snapdragon) can run the Windows x64 build through Windows' built-in
|
||||
> x64 emulation.
|
||||
|
||||
> [!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 +43,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 +72,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 +107,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,7 +134,7 @@ 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
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ path = [
|
||||
"global.json",
|
||||
"**/packages.lock.json",
|
||||
"scripts/ps5_names.txt",
|
||||
"src/SharpEmu.HLE/Aerolib/aerolib.bin",
|
||||
"src/SharpEmu.GUI/Languages/**",
|
||||
"src/SharpEmu.ShaderCompiler.Metal/Templates/**",
|
||||
"tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/**",
|
||||
"_logs/**",
|
||||
".github/images/**",
|
||||
".github/pull_request_template.md",
|
||||
"assets/images/**"
|
||||
]
|
||||
precedence = "aggregate"
|
||||
|
||||
@@ -7,9 +7,20 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/SharpEmu.CLI/SharpEmu.CLI.csproj" />
|
||||
<Project Path="src/SharpEmu.Core/SharpEmu.Core.csproj" />
|
||||
<Project Path="src/SharpEmu.DebugClient/SharpEmu.DebugClient.csproj" />
|
||||
<Project Path="src/SharpEmu.Debugger/SharpEmu.Debugger.csproj" />
|
||||
<Project Path="src/SharpEmu.GUI/SharpEmu.GUI.csproj" />
|
||||
<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.Metal/SharpEmu.ShaderCompiler.Metal.csproj" />
|
||||
<Project Path="src/SharpEmu.ShaderCompiler.Vulkan/SharpEmu.ShaderCompiler.Vulkan.csproj" />
|
||||
<Project Path="src/SharpEmu.SourceGenerators/SharpEmu.SourceGenerators.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj" />
|
||||
<Project Path="tests/SharpEmu.ShaderCompiler.Metal.Tests/SharpEmu.ShaderCompiler.Metal.Tests.csproj" />
|
||||
<Project Path="tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
|
||||
|
After Width: | Height: | Size: 653 B |
|
After Width: | Height: | Size: 698 B |
|
After Width: | Height: | Size: 802 B |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,20 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
# Aerolib Catalog
|
||||
|
||||
```bash
|
||||
# NID to export name
|
||||
python scripts/aerolib_catalog.py lookup Zxa0VhQVTsk
|
||||
|
||||
# Export name to NID
|
||||
python scripts/aerolib_catalog.py lookup sceKernelWaitSema
|
||||
|
||||
# Search export names
|
||||
python scripts/aerolib_catalog.py search VideoOut --limit 20
|
||||
|
||||
# Export all NID/name pairs to artifacts/aerolib.txt
|
||||
python scripts/aerolib_catalog.py export
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
<!--
|
||||
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 decoder 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.
|
||||
|
||||
The default path decodes by calling FFmpeg's own C API directly from managed
|
||||
code (`src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs`, via the
|
||||
[FFmpeg.AutoGen](https://github.com/Ruslan-B/FFmpeg.AutoGen) P/Invoke
|
||||
bindings) against a custom FFmpeg build
|
||||
(`github.com/sharpemu/ffmpeg-core`, LGPL-2.1) that adds a Bink 2 decoder to
|
||||
FFmpeg 7.1.2; see "Supplying the FFmpeg libraries" below for where those
|
||||
libraries come from. No proprietary RAD SDK is needed to build or run
|
||||
SharpEmu, and there is no C/C++ code of SharpEmu's own involved in decoding
|
||||
-- SharpEmu.CLI.csproj only downloads a prebuilt release archive.
|
||||
|
||||
Set `SHARPEMU_BINK_MODE=guest` to leave decoding to the Bink implementation
|
||||
statically linked into the game instead. Set `skip` only when explicitly
|
||||
testing a title whose cinematics are optional.
|
||||
|
||||
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.
|
||||
SHARPEMU_BINK_MODE=native is equivalent to the default and mainly useful for
|
||||
being explicit about it.
|
||||
|
||||
The experimental `SHARPEMU_BINK_MODE=ffmpeg` override is unrelated to the
|
||||
default path above: instead of calling into FFmpeg in-process, it spawns a
|
||||
standalone `ffmpeg` executable and reads raw frames from its stdout
|
||||
(`src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs`). SharpEmu searches
|
||||
`SHARPEMU_FFMPEG_PATH`, the executable directory, its `ffmpeg` subdirectory,
|
||||
and then `PATH` (plus a couple of common Homebrew paths on macOS). That
|
||||
`ffmpeg` build must contain a Bink 2 decoder itself; a stock FFmpeg build that
|
||||
only recognizes the Bink container is not sufficient. Most users want the
|
||||
default `native` mode instead, which always has Bink 2 support since it's
|
||||
built against `ffmpeg-core` specifically.
|
||||
|
||||
## Supplying the FFmpeg libraries
|
||||
|
||||
`dotnet publish` fetches a prebuilt release of `github.com/sharpemu/ffmpeg-core`
|
||||
(the tag is pinned in `SharpEmu.CLI.csproj`'s `FfmpegRuntimeTag`, matched to
|
||||
the `FFmpeg.AutoGen` package version in `Directory.Packages.props` -- both
|
||||
need to agree on the same FFmpeg ABI) and copies its dynamically linked
|
||||
libraries into a `plugins` folder next to the published executable. No C
|
||||
toolchain is required to build SharpEmu; publishing just downloads a zip.
|
||||
`plugins` is a loose, unpacked folder rather than something embedded in the
|
||||
single-file bundle, so the OS loader can resolve the libraries' own
|
||||
inter-dependencies (`avcodec` depends on `avutil`, etc.) itself.
|
||||
|
||||
A plain `dotnet publish` with no `-r` still works: it defaults to the host
|
||||
machine's own RID (see `Directory.Build.props`), so it fetches the matching
|
||||
`ffmpeg-core` archive and populates `plugins` without any extra flags.
|
||||
Passing an explicit `-r <rid>` (e.g. to cross-publish `linux-x64` from
|
||||
Windows) still overrides that default normally.
|
||||
|
||||
To use a different set of FFmpeg libraries, drop them into the published
|
||||
`plugins` folder yourself (matching FFmpeg's own file-naming and versioning
|
||||
conventions, e.g. `avformat-61.dll` / `libavformat.so.61` / matching
|
||||
`.dylib`) -- `FfmpegNativeBinkFrameSource` points `ffmpeg.RootPath` at that
|
||||
folder and does not otherwise care where the files came from.
|
||||
|
||||
If the libraries are absent or fail to load, `FfmpegNativeBinkFrameSource.TryOpen`
|
||||
degrades gracefully: SharpEmu logs one informational line ("Bink2 bridge
|
||||
could not open movie ...") and leaves the guest's own rendering path
|
||||
untouched, rather than crashing.
|
||||
@@ -0,0 +1,177 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
# Live debug server
|
||||
|
||||
SharpEmu can expose a **live debug server** so an external process can inspect
|
||||
and control a running guest over TCP. The server lives in the emulator; the
|
||||
companion `SharpEmu.DebugClient` executable is one client, and the wire protocol
|
||||
is simple enough to script against directly.
|
||||
|
||||
This document describes the moving parts and the wire protocol. For day-to-day
|
||||
client usage, see
|
||||
[`src/SharpEmu.DebugClient/DEVELOPER_READ.md`](../src/SharpEmu.DebugClient/DEVELOPER_READ.md).
|
||||
|
||||
## Layering
|
||||
|
||||
| Assembly | Role |
|
||||
| -------- | ---- |
|
||||
| `SharpEmu.Core` | Defines the dispatcher seam `ICpuDebugHook` / `ICpuDebugFrame` (namespace `SharpEmu.Core.Cpu.Debug`) and the `CpuExecutionOptions.DebugHook` slot. Core has **no** reference to the debugger. |
|
||||
| `SharpEmu.Debugger` | The debugger: `DebuggerSession` (implements the hook), `BreakpointStore`, the TCP `DebuggerServer`, the pluggable `IDebugProtocol` with a JSON-lines implementation, and the `DebuggerServerHost` one-call wiring. |
|
||||
| `SharpEmu.CLI` | Parses `--debug-server`, builds a `DebuggerServerHost`, hands its `Hook` to `SharpEmuRuntimeOptions.DebugHook`, and manages its lifetime. |
|
||||
| `SharpEmu.DebugClient` | A standalone client executable. Depends only on the BCL. |
|
||||
|
||||
The dependency direction is important: Core stays debugger-agnostic and only
|
||||
publishes the seam. Anything that observes execution implements
|
||||
`ICpuDebugHook` and is injected through the options, so the debugger can evolve
|
||||
without touching the CPU core.
|
||||
|
||||
## Execution model
|
||||
|
||||
`CpuDispatcher` enters a fresh frame for the process entry point and for each
|
||||
module initializer. When a `DebugHook` is attached it is notified at those
|
||||
boundaries:
|
||||
|
||||
- `OnFrameEnter(frame)` — before the native backend runs the frame. The
|
||||
`DebuggerSession` decides whether to stop (pause request, breakpoint on the
|
||||
entry address, single-step, or stop-at-entry). To stop, it **parks the
|
||||
emulation thread** inside this call on a gate; the frame stays live, so a
|
||||
client can read and write registers and memory while parked. `continue` /
|
||||
`step` release the gate.
|
||||
- `OnFrameExit(frame, result)` — after the frame completes.
|
||||
|
||||
Because pausing parks the one thread that owns the guest context, register and
|
||||
memory accessors are only served while the session reports `Paused`; otherwise
|
||||
they return "not paused" so a client never observes torn state.
|
||||
|
||||
### What is and isn't live yet
|
||||
|
||||
- **Live:** attach/handshake, run-state tracking, register read/write, memory
|
||||
read/write, breakpoint management, execution breakpoints at frame entry,
|
||||
pause, frame-level step, continue, and stop/resume/terminate events.
|
||||
- **Surface only (armed as the backend grows hooks):** per-instruction
|
||||
stepping and data watchpoints (`readwatch` / `writewatch` / `accesswatch`).
|
||||
The verbs and types exist so clients and tooling can be written now.
|
||||
|
||||
## Enabling the server
|
||||
|
||||
```bash
|
||||
SharpEmu --debug-server "/path/to/eboot.bin" # 127.0.0.1:5714
|
||||
SharpEmu --debug-server=0.0.0.0:5714 "/path/to/eboot.bin"
|
||||
```
|
||||
|
||||
The bind address defaults to loopback; a routable address must be given
|
||||
explicitly. With stop-at-entry (the default `DebuggerSessionOptions.StopAtEntry`),
|
||||
the guest parks at its first frame until a client connects and issues
|
||||
`continue`, giving you a window to set breakpoints before any guest code runs.
|
||||
|
||||
## Browser frontend
|
||||
|
||||
The dependency-free Python frontend can choose and launch an `eboot.bin`, attach
|
||||
to its debugger automatically, and provides execution controls, registers,
|
||||
memory inspection, breakpoint management, process output, and a live protocol
|
||||
activity stream:
|
||||
|
||||
```bash
|
||||
./tools/SharpEmu.DebuggerFrontend/run.sh
|
||||
```
|
||||
|
||||
It connects to `127.0.0.1:5714` and opens `http://127.0.0.1:8765/` by default.
|
||||
See [`tools/SharpEmu.DebuggerFrontend/README.md`](../tools/SharpEmu.DebuggerFrontend/README.md)
|
||||
for configuration and testing options.
|
||||
|
||||
## Wire protocol (json-lines/1)
|
||||
|
||||
One JSON object per line, UTF-8, `\n`-terminated, in both directions.
|
||||
|
||||
### Requests
|
||||
|
||||
A `command` string plus command-specific fields. Numeric fields accept a JSON
|
||||
number or a `0x`-prefixed hex string.
|
||||
|
||||
| `command` | Fields | Reply `data` |
|
||||
| --------- | ------ | ------------ |
|
||||
| `ping` | — | — |
|
||||
| `status` (`info`) | — | `state`, `breakpoints`, `lastStop?` |
|
||||
| `state` | — | `state` |
|
||||
| `registers` (`regs`) | — | `registers` (rax..r15, rip, rflags, fs_base, gs_base) |
|
||||
| `set-register` | `register`, `value` | — |
|
||||
| `read-memory` | `address`, `length` (≤ 65536) | `address`, `length`, `bytes` (hex) |
|
||||
| `write-memory` | `address`, `bytes` (hex) | `written` |
|
||||
| `list-breakpoints` (`breakpoints`) | — | `breakpoints[]` |
|
||||
| `add-breakpoint` (`break`) | `address`, `kind?`, `length?` | `breakpoint` |
|
||||
| `remove-breakpoint` (`delete-breakpoint`) | `id` | — |
|
||||
| `enable-breakpoint` | `id`, `enabled?` (default true) | — |
|
||||
| `continue` (`cont`, `c`) | — | — |
|
||||
| `step` (`s`) | — | — |
|
||||
| `pause` | — | — |
|
||||
|
||||
### Replies
|
||||
|
||||
```json
|
||||
{"ok":true,"command":"registers","data":{ "registers": { "rax":"0x…", … } }}
|
||||
{"ok":false,"command":"read-memory","error":"Target is not paused."}
|
||||
```
|
||||
|
||||
### Events (unsolicited)
|
||||
|
||||
```json
|
||||
{"event":"hello","protocol":"json-lines/1","state":"Paused"}
|
||||
{"event":"stopped","reason":"Breakpoint","address":"0x…","frameKind":"ProcessEntry","frameLabel":"eboot.bin","registers":{…},"breakpoint":{…}}
|
||||
{"event":"resumed"}
|
||||
{"event":"terminated"}
|
||||
```
|
||||
|
||||
`reason` is one of `EntryPoint`, `Breakpoint`, `Watchpoint`, `Step`, `Pause`,
|
||||
`Fault`, or `Stall`.
|
||||
|
||||
Stall stops include structured evidence in addition to the human-readable
|
||||
detail. Import-loop evidence identifies the NID, resolved HLE export, repeating
|
||||
guest return site, dispatch count, and first two ABI arguments:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "stopped",
|
||||
"reason": "Stall",
|
||||
"stall": {
|
||||
"kind": "ImportLoop",
|
||||
"nid": "9UK1vLZQft4",
|
||||
"instructionPointer": "0x0000000801CE2418",
|
||||
"dispatchIndex": 40667904,
|
||||
"argument0": "0x0000000812345000",
|
||||
"argument1": "0x0000000000000000",
|
||||
"resolved": true,
|
||||
"library": "libKernel",
|
||||
"function": "scePthreadMutexLock"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The Python frontend uses this evidence to explain the likely failure class and
|
||||
rank concrete checks/fixes. Its diagnosis is intentionally labelled heuristic:
|
||||
it helps locate the responsible HLE/scheduler path but does not replace tracing.
|
||||
|
||||
## Swapping the protocol
|
||||
|
||||
`DebuggerServer` takes an `IDebugProtocol` factory. The default is
|
||||
`JsonLineDebugProtocol`; a GDB remote serial stub (or any other framing) can be
|
||||
dropped in without changing the session or command semantics, which live in
|
||||
`DebugCommandDispatcher`.
|
||||
|
||||
## Embedding the server
|
||||
|
||||
```csharp
|
||||
using SharpEmu.Debugger;
|
||||
using SharpEmu.Core.Runtime;
|
||||
|
||||
await using var host = new DebuggerServerHost();
|
||||
host.Start();
|
||||
|
||||
var options = new SharpEmuRuntimeOptions { DebugHook = host.Hook };
|
||||
using var runtime = SharpEmuRuntime.CreateDefault(options);
|
||||
var result = runtime.Run(ebootPath);
|
||||
|
||||
host.NotifyRunCompleted();
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
# Guest write watch
|
||||
|
||||
`GuestWriteWatch` is an optional diagnostic tool. It helps you find managed
|
||||
code and HLE code that damage guest memory. The tool starts only if you set one
|
||||
or more `SHARPEMU_WATCH_*` environment variables.
|
||||
|
||||
The tool monitors writes through the SharpEmu managed virtual-memory APIs. It
|
||||
does not monitor stores that native guest code makes directly. Use a platform
|
||||
debugger or a hardware watchpoint to monitor these stores.
|
||||
|
||||
## Watch modes
|
||||
|
||||
- `SHARPEMU_WATCH_WRITE=0x<address>` logs a write that overlaps the eight-byte
|
||||
block at the specified guest address.
|
||||
- `SHARPEMU_WATCH_POOL_HEADER=1` monitors the pointer at offset `0x40`. It
|
||||
monitors the first 64 direct mappings that have a size of 64 KiB and
|
||||
protection value `0xF2`.
|
||||
- `SHARPEMU_WATCH_VALUE_PATTERN=1` logs an eight-byte write if its lower 32 bits
|
||||
are `1`. The upper 32 bits must look like a small guest-pointer prefix.
|
||||
- `SHARPEMU_WATCH_VALUE1=1` logs short writes of value `1` in the high guest
|
||||
memory range. The tool logs a maximum of 128 entries for each process.
|
||||
- `SHARPEMU_WATCH_BULK_TORN=1` scans aligned 64-bit words in bulk writes. It
|
||||
finds damaged pointer patterns and byte-shifted pointer patterns. The tool
|
||||
logs a maximum of 64 entries for each process.
|
||||
- `SHARPEMU_WATCH_BULK_DEST_HI=0x<high-dword>` scans only writes that have the
|
||||
specified upper 32 bits in the destination address.
|
||||
|
||||
For each match, the tool logs the destination address, the data pattern, and the
|
||||
managed call stack. The log uses the `watch_write` or `watch_bulk_torn` warning
|
||||
tag.
|
||||
|
||||
Use these variables together to scan bulk writes in the
|
||||
`0x00000080xxxxxxxx` region.
|
||||
|
||||
macOS and Linux:
|
||||
|
||||
```sh
|
||||
SHARPEMU_WATCH_BULK_TORN=1 \
|
||||
SHARPEMU_WATCH_BULK_DEST_HI=0x80 \
|
||||
SharpEmu /path/to/eboot.bin
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
$env:SHARPEMU_WATCH_BULK_TORN = "1"
|
||||
$env:SHARPEMU_WATCH_BULK_DEST_HI = "0x80"
|
||||
& .\SharpEmu.exe C:\path\to\game\eboot.bin
|
||||
```
|
||||
|
||||
To reduce unnecessary log entries, use an exact `SHARPEMU_WATCH_WRITE`
|
||||
address from a crash dump.
|
||||
@@ -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.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.103",
|
||||
"rollForward": "disable"
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Copyright (C) 2026 SharpEmu Emulator Project
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
NID_SUFFIX = bytes.fromhex("518d64a635ded8c1e6b039b1c3e55230")
|
||||
NID_PATTERN = re.compile(r"^[A-Za-z0-9+-]{11}$")
|
||||
DEFAULT_NAMES_FILE = Path(__file__).resolve().with_name("ps5_names.txt")
|
||||
DEFAULT_EXPORT_FILE = Path(__file__).resolve().parents[1] / "artifacts" / "aerolib.txt"
|
||||
|
||||
|
||||
def compute_nid(export_name: str) -> str:
|
||||
digest = hashlib.sha1(export_name.encode("utf-8") + NID_SUFFIX).digest()
|
||||
encoded = base64.b64encode(digest[:8][::-1]).decode("ascii")
|
||||
return encoded.rstrip("=").replace("/", "-")
|
||||
|
||||
|
||||
def read_names(path: Path) -> list[str]:
|
||||
try:
|
||||
return [
|
||||
line.strip()
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
except OSError as error:
|
||||
raise SystemExit(f"Unable to read catalog '{path}': {error}") from error
|
||||
|
||||
|
||||
def write_pair(nid: str, export_name: str) -> None:
|
||||
print(f"{nid}\t{export_name}")
|
||||
|
||||
|
||||
def lookup(args: argparse.Namespace) -> int:
|
||||
value = args.value.strip()
|
||||
if NID_PATTERN.fullmatch(value):
|
||||
for export_name in read_names(args.names):
|
||||
if compute_nid(export_name) == value:
|
||||
write_pair(value, export_name)
|
||||
return 0
|
||||
|
||||
print(f"NID not found in catalog: {value}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
names = set(read_names(args.names))
|
||||
write_pair(compute_nid(value), value)
|
||||
if value not in names:
|
||||
print("Warning: export name is not present in the catalog.", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
def search(args: argparse.Namespace) -> int:
|
||||
names = read_names(args.names)
|
||||
if args.regex:
|
||||
try:
|
||||
pattern = re.compile(args.query, 0 if args.case_sensitive else re.IGNORECASE)
|
||||
except re.error as error:
|
||||
print(f"Invalid regular expression: {error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
matches = (name for name in names if pattern.search(name))
|
||||
elif args.case_sensitive:
|
||||
matches = (name for name in names if args.query in name)
|
||||
else:
|
||||
query = args.query.casefold()
|
||||
matches = (name for name in names if query in name.casefold())
|
||||
|
||||
count = 0
|
||||
for export_name in matches:
|
||||
write_pair(compute_nid(export_name), export_name)
|
||||
count += 1
|
||||
if args.limit and count >= args.limit:
|
||||
break
|
||||
|
||||
if count == 0:
|
||||
print(f"No catalog names matched: {args.query}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def export_catalog(args: argparse.Namespace) -> int:
|
||||
pairs = [(compute_nid(name), name) for name in read_names(args.names)]
|
||||
if args.sort == "nid":
|
||||
pairs.sort(key=lambda pair: (pair[0], pair[1]))
|
||||
elif args.sort == "name":
|
||||
pairs.sort(key=lambda pair: pair[1])
|
||||
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
with args.output.open("w", encoding="utf-8", newline="\n") as output:
|
||||
output.write("# NID\tExportName\n")
|
||||
for nid, export_name in pairs:
|
||||
output.write(f"{nid}\t{export_name}\n")
|
||||
except OSError as error:
|
||||
print(f"Unable to write catalog '{args.output}': {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Wrote {len(pairs)} entries to {args.output}")
|
||||
return 0
|
||||
|
||||
|
||||
def create_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Inspect the SharpEmu PS5 export-name/NID catalog.",
|
||||
epilog=(
|
||||
"Examples:\n"
|
||||
" python scripts/aerolib_catalog.py lookup Zxa0VhQVTsk\n"
|
||||
" python scripts/aerolib_catalog.py lookup sceKernelWaitSema\n"
|
||||
" python scripts/aerolib_catalog.py search VideoOut --limit 20\n"
|
||||
" python scripts/aerolib_catalog.py export"
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--names",
|
||||
type=Path,
|
||||
default=DEFAULT_NAMES_FILE,
|
||||
help=f"source name list (default: {DEFAULT_NAMES_FILE})",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
lookup_parser = subparsers.add_parser(
|
||||
"lookup", help="resolve a NID or calculate the NID for an export name"
|
||||
)
|
||||
lookup_parser.add_argument("value", help="11-character NID or exact export name")
|
||||
lookup_parser.set_defaults(handler=lookup)
|
||||
|
||||
search_parser = subparsers.add_parser(
|
||||
"search", help="find export names and print matching NID/name pairs"
|
||||
)
|
||||
search_parser.add_argument("query", help="name substring or regular expression")
|
||||
search_parser.add_argument(
|
||||
"--limit", type=int, default=50, help="maximum matches; 0 means unlimited"
|
||||
)
|
||||
search_parser.add_argument(
|
||||
"--case-sensitive", action="store_true", help="match case exactly"
|
||||
)
|
||||
search_parser.add_argument(
|
||||
"--regex", action="store_true", help="treat the query as a regular expression"
|
||||
)
|
||||
search_parser.set_defaults(handler=search)
|
||||
|
||||
export_parser = subparsers.add_parser(
|
||||
"export", help="write every NID/name pair to a tab-separated text file"
|
||||
)
|
||||
export_parser.add_argument(
|
||||
"output",
|
||||
type=Path,
|
||||
nargs="?",
|
||||
default=DEFAULT_EXPORT_FILE,
|
||||
help=f"output file (default: {DEFAULT_EXPORT_FILE})",
|
||||
)
|
||||
export_parser.add_argument(
|
||||
"--sort",
|
||||
choices=("source", "nid", "name"),
|
||||
default="nid",
|
||||
help="output ordering (default: nid)",
|
||||
)
|
||||
export_parser.set_defaults(handler=export_catalog)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = create_parser()
|
||||
args = parser.parse_args()
|
||||
return args.handler(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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,54 +0,0 @@
|
||||
# Copyright (C) 2026 SharpEmu Emulator Project
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import hashlib
|
||||
import struct
|
||||
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)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
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()
|
||||
@@ -154449,3 +154449,9 @@ vector_str_substr
|
||||
WTFAnnotateBenignRaceSized
|
||||
WTFAnnotateHappensAfter
|
||||
WTFAnnotateHappensBefore
|
||||
_sceUlobjmgrRegisterObject
|
||||
_sceUlobjmgrUnregisterObject
|
||||
sceNpEAAccessInitialize
|
||||
sceNpEAAccessTerminate
|
||||
sceNpHasEAAccessSubscription
|
||||
sceNpHasEAAccessSubscriptionAbortRequest
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright (C) 2026 SharpEmu Emulator Project
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$#" -ne 4 ]; then
|
||||
echo "usage: $0 <spirv-val> <expected-version> <target-env> <module-directory>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
validator=$1
|
||||
expected_version=$2
|
||||
target_env=$3
|
||||
module_directory=$4
|
||||
|
||||
if [ ! -x "$validator" ]; then
|
||||
echo "SPIR-V validator is not executable: $validator" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ ! -d "$module_directory" ]; then
|
||||
echo "SPIR-V module directory does not exist: $module_directory" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
validator_version="$("$validator" --version | head -n 1)"
|
||||
if [[ "$validator_version" != *"SPIRV-Tools $expected_version"* ]]; then
|
||||
echo "unexpected SPIRV-Tools version: $validator_version (expected $expected_version)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "Validator: $validator_version"
|
||||
echo "Target environment: $target_env"
|
||||
|
||||
mapfile -d '' modules < <(find "$module_directory" -type f -name '*.spv' -print0 | sort -z)
|
||||
if [ "${#modules[@]}" -eq 0 ]; then
|
||||
echo "no SPIR-V modules found in $module_directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
failures=0
|
||||
for module in "${modules[@]}"; do
|
||||
echo "Validating module: $module"
|
||||
if ! "$validator" --target-env "$target_env" "$module"; then
|
||||
echo "SPIR-V validation failed: $module" >&2
|
||||
failures=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$failures" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Validated ${#modules[@]} synthetic SPIR-V modules."
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -57,11 +58,16 @@ internal static partial class Program
|
||||
|
||||
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);
|
||||
PreloadGlfw();
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
// No arguments: open the desktop frontend. Any argument selects
|
||||
// the classic CLI behavior below.
|
||||
return GuiLauncher.Run();
|
||||
}
|
||||
|
||||
@@ -74,6 +80,163 @@ internal static partial class Program
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SharpEmu.CLI.csproj publishes glfw into a "plugins" subfolder rather
|
||||
/// than flat next to the executable, which falls outside the default OS
|
||||
/// DLL/dlopen search path. Preloading it here by full path first means
|
||||
/// any later bare-name lookup (however Silk.NET/GLFW itself resolves the
|
||||
/// library) finds it already loaded in the process and reuses it -- the
|
||||
/// same technique <see cref="PreloadMacVulkanLoader"/> already relies on
|
||||
/// for the Vulkan loader.
|
||||
/// </summary>
|
||||
private static void PreloadGlfw()
|
||||
{
|
||||
var fileName = OperatingSystem.IsWindows() ? "glfw3.dll"
|
||||
: OperatingSystem.IsMacOS() ? "libglfw.3.dylib"
|
||||
: "libglfw.so.3";
|
||||
var candidate = Path.Combine(AppContext.BaseDirectory, "plugins", fileName);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
NativeLibrary.TryLoad(candidate, out _);
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -81,7 +244,17 @@ internal static partial class Program
|
||||
return childExitCode;
|
||||
}
|
||||
|
||||
if (!TryParseArguments(args, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath))
|
||||
if (!TryExtractHostSurfaceArgument(args, out var emulatorArgs, out var hostSurface, out var hostSurfaceError))
|
||||
{
|
||||
Console.Error.WriteLine($"[LOADER][ERROR] {hostSurfaceError}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
HostSessionControl.SetEmbeddedHostSurface(
|
||||
hostSurface?.WindowHandle ?? 0,
|
||||
hostSurface?.DisplayHandle ?? 0);
|
||||
|
||||
if (!TryParseArguments(emulatorArgs, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath))
|
||||
{
|
||||
PrintUsage();
|
||||
return 1;
|
||||
@@ -106,53 +279,156 @@ internal static partial class Program
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (!TryGetDebugServerOptions(args, out var debugServerEnabled, out var debugServerOptions, out var debugServerError))
|
||||
{
|
||||
Log.Error($"Invalid --debug-server endpoint: {debugServerError}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
SharpEmu.Debugger.DebuggerServerHost? debugHost = null;
|
||||
if (debugServerEnabled)
|
||||
{
|
||||
debugHost = new SharpEmu.Debugger.DebuggerServerHost(debugServerOptions);
|
||||
try
|
||||
{
|
||||
debugHost.Start();
|
||||
Log.Info($"Live debug server listening on {debugHost.Endpoint}. Attach with SharpEmu.DebugClient.");
|
||||
// With StopAtEntry, the guest parks at its first frame until a
|
||||
// client connects and continues.
|
||||
runtimeOptions = runtimeOptions with { DebugHook = debugHost.Hook };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error("Failed to start the debug server.", ex);
|
||||
debugHost.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
return 6;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
if (debugHost is not null)
|
||||
{
|
||||
debugHost.NotifyRunCompleted();
|
||||
debugHost.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -249,7 +525,9 @@ internal static partial class Program
|
||||
return handle != 0 && handle != -1;
|
||||
}
|
||||
|
||||
private static string[] NormalizeInternalArguments(string[] args, out bool isMitigatedChild)
|
||||
private static string[] NormalizeInternalArguments(
|
||||
string[] args,
|
||||
out bool isMitigatedChild)
|
||||
{
|
||||
isMitigatedChild = false;
|
||||
var trustedMitigatedChild = string.Equals(
|
||||
@@ -295,12 +573,7 @@ internal static partial class Program
|
||||
return false;
|
||||
}
|
||||
|
||||
var childArgs = new string[args.Length + 1];
|
||||
childArgs[0] = MitigatedChildFlag;
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
childArgs[i + 1] = args[i];
|
||||
}
|
||||
string[] childArgs = [MitigatedChildFlag, .. args];
|
||||
|
||||
var commandLine = BuildCommandLine(processPath, childArgs);
|
||||
var startupInfoEx = new STARTUPINFOEX();
|
||||
@@ -351,7 +624,7 @@ internal static partial class Program
|
||||
nint jobHandle = 0;
|
||||
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
|
||||
var created = CreateProcessW(
|
||||
processPath,
|
||||
null,
|
||||
cmdLineBuilder,
|
||||
0,
|
||||
0,
|
||||
@@ -769,8 +1042,45 @@ internal static partial class Program
|
||||
|
||||
private static void PrintUsage()
|
||||
{
|
||||
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--log-level=<level>] [--log-file[=<path>]] <path-to-eboot.bin>");
|
||||
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--log-level=<level>] [--log-file[=<path>]] [--debug-server[=host:port]] <path-to-eboot.bin>");
|
||||
Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin""");
|
||||
Log.Info("Debug server: --debug-server starts a live debug listener (default 127.0.0.1:5714); connect with SharpEmu.DebugClient.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects the <c>--debug-server</c> flag and parses its optional
|
||||
/// <c>host:port</c> endpoint. Returns false only when the flag is present but
|
||||
/// its endpoint is malformed, so the caller can abort with a clear error.
|
||||
/// </summary>
|
||||
private static bool TryGetDebugServerOptions(
|
||||
string[] args,
|
||||
out bool enabled,
|
||||
out SharpEmu.Debugger.Server.DebuggerServerOptions options,
|
||||
out string error)
|
||||
{
|
||||
enabled = false;
|
||||
options = new SharpEmu.Debugger.Server.DebuggerServerOptions();
|
||||
error = string.Empty;
|
||||
foreach (var argument in args)
|
||||
{
|
||||
if (string.Equals(argument, "--debug-server", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
enabled = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const string prefix = "--debug-server=";
|
||||
if (argument.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
enabled = true;
|
||||
if (!SharpEmu.Debugger.Server.DebuggerServerOptions.TryParseEndpoint(argument[prefix.Length..], out options, out error))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseArguments(
|
||||
@@ -804,6 +1114,15 @@ internal static partial class Program
|
||||
continue;
|
||||
}
|
||||
|
||||
// The debug-server endpoint is parsed separately (see
|
||||
// TryGetDebugServerOptions); accept the flag here so it is not
|
||||
// rejected as an unknown option or mistaken for the eboot path.
|
||||
if (string.Equals(argument, "--debug-server", StringComparison.OrdinalIgnoreCase) ||
|
||||
argument.StartsWith("--debug-server=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(argument, "--trace-imports", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
importTraceLimit = DefaultImportTraceLimit;
|
||||
@@ -1131,7 +1450,7 @@ internal static partial class Program
|
||||
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CreateProcessW(
|
||||
string applicationName,
|
||||
string? applicationName,
|
||||
StringBuilder commandLine,
|
||||
nint processAttributes,
|
||||
nint threadAttributes,
|
||||
@@ -1188,4 +1507,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);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SharpEmu.Core\SharpEmu.Core.csproj" />
|
||||
<ProjectReference Include="..\SharpEmu.Debugger\SharpEmu.Debugger.csproj" />
|
||||
<ProjectReference Include="..\SharpEmu.GUI\SharpEmu.GUI.csproj" />
|
||||
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
|
||||
</ItemGroup>
|
||||
@@ -16,35 +17,55 @@ 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>
|
||||
<!-- A plain "dotnet publish" with no -r defaults $(RuntimeIdentifier) to
|
||||
the host's own RID; see Directory.Build.props, which is where that
|
||||
default actually has to live (PublishDir's RID suffix is decided
|
||||
there, evaluated before this file, so a default set only here would
|
||||
be too late for it). -->
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<Version>0.0.1</Version>
|
||||
<ServerGarbageCollection>true</ServerGarbageCollection>
|
||||
<!-- 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'">
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
<DebugType>none</DebugType>
|
||||
<DebugSymbols>false</DebugSymbols>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<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>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\LICENSE.txt">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
@@ -58,17 +79,75 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Keep glfw as a loose file next to the executable; every other native
|
||||
<!-- Native libraries (glfw, FFmpeg) publish into a subfolder next to the
|
||||
executable instead of sitting loose beside it, so the publish
|
||||
directory stays uncluttered as more native deps get added. The folder
|
||||
name is a fixed constant, not derived from the RID/architecture: each
|
||||
publish output only ever holds one architecture's binaries anyway, so
|
||||
varying the name added a class of bugs (RID resolution timing, host-OS
|
||||
vs. target-RID mixups) for no benefit. Runtime code (Program.cs's
|
||||
PreloadGlfw, FfmpegNativeBinkFrameSource's RootPath) uses the same
|
||||
literal "plugins" folder name. -->
|
||||
<PropertyGroup>
|
||||
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Keep glfw as a loose file in the native subfolder; every other native
|
||||
library is embedded into the single-file bundle. -->
|
||||
<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>
|
||||
<RelativePath>$(NativeLibraryFolderName)/%(Filename)%(Extension)</RelativePath>
|
||||
</ResolvedFileToPublish>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<PropertyGroup>
|
||||
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
|
||||
<FfmpegRuntimeDir>
|
||||
$(BaseIntermediateOutputPath)ffmpeg-runtime/$(FfmpegRuntimeTag)/$(RuntimeIdentifier)</FfmpegRuntimeDir>
|
||||
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'win-x64'">ffmpeg-windows-x64.zip</FfmpegRuntimePackage>
|
||||
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'linux-x64'">ffmpeg-linux-x64.zip</FfmpegRuntimePackage>
|
||||
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'osx-x64'">ffmpeg-macos-x64.zip</FfmpegRuntimePackage>
|
||||
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'osx-arm64'">ffmpeg-macos-arm64.zip</FfmpegRuntimePackage>
|
||||
<FfmpegRuntimeArchive>$(FfmpegRuntimeDir)/$(FfmpegRuntimePackage)</FfmpegRuntimeArchive>
|
||||
<FfmpegRuntimeExtractDir>$(FfmpegRuntimeDir)/extracted</FfmpegRuntimeExtractDir>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="FetchFfmpegRuntime"
|
||||
BeforeTargets="Publish"
|
||||
Condition="'$(RuntimeIdentifier)' != '' And '$(FfmpegRuntimePackage)' != ''">
|
||||
<DownloadFile
|
||||
SourceUrl="https://github.com/sharpemu/ffmpeg-core/releases/download/$(FfmpegRuntimeTag)/$(FfmpegRuntimePackage)"
|
||||
DestinationFolder="$(FfmpegRuntimeDir)"
|
||||
Condition="!Exists('$(FfmpegRuntimeArchive)')" />
|
||||
<Unzip
|
||||
SourceFiles="$(FfmpegRuntimeArchive)"
|
||||
DestinationFolder="$(FfmpegRuntimeExtractDir)"
|
||||
Condition="!Exists('$(FfmpegRuntimeExtractDir)')" />
|
||||
</Target>
|
||||
|
||||
<Target Name="PublishFfmpegRuntime"
|
||||
AfterTargets="Publish"
|
||||
DependsOnTargets="FetchFfmpegRuntime"
|
||||
Condition="'$(RuntimeIdentifier)' != '' And '$(FfmpegRuntimePackage)' != ''">
|
||||
<!-- Keyed off the target $(RuntimeIdentifier), not the host OS: publishing
|
||||
e.g. linux-x64 from a Windows machine is a supported cross-publish,
|
||||
and the extracted archive's own layout (bin/*.dll vs lib/*.so*) only
|
||||
depends on which platform's ffmpeg-core package was fetched. -->
|
||||
<ItemGroup>
|
||||
<_FfmpegRuntimeFiles Condition="$(RuntimeIdentifier.StartsWith('win'))"
|
||||
Include="$(FfmpegRuntimeExtractDir)/bin/*.dll" />
|
||||
<_FfmpegRuntimeFiles Condition="!$(RuntimeIdentifier.StartsWith('win'))"
|
||||
Include="$(FfmpegRuntimeExtractDir)/lib/*.so;$(FfmpegRuntimeExtractDir)/lib/*.so.*;$(FfmpegRuntimeExtractDir)/lib/*.dylib" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(_FfmpegRuntimeFiles)"
|
||||
DestinationFolder="$(PublishDir)$(NativeLibraryFolderName)"
|
||||
SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?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>
|
||||
<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
|
||||
<asmv3:windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
</asmv3:windowsSettings>
|
||||
</asmv3:application>
|
||||
</assembly>
|
||||
@@ -1,491 +0,0 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.NET.ILLink.Tasks": {
|
||||
"type": "Direct",
|
||||
"requested": "[10.0.3, )",
|
||||
"resolved": "10.0.3",
|
||||
"contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA=="
|
||||
},
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.25547.20250602",
|
||||
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||
},
|
||||
"Avalonia.BuildServices": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.2",
|
||||
"contentHash": "qHDToxto1e3hci5YqbG9n0Ty8mlp3zBUN5wT66wKqaDVzXyQ0do3EnRILd4Ke9jpvsktaPpgE0YjEk7hornryQ=="
|
||||
},
|
||||
"Avalonia.FreeDesktop": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "aUwv8BNruRUOaUfMu4U3uibIUS60/rSHgGOhd8zBkLkpxY3JFJvgRbeq5ZzHIyKXCuKi18PO00YHAgCarp3wdw==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18",
|
||||
"Tmds.DBus.Protocol": "0.21.3"
|
||||
}
|
||||
},
|
||||
"Avalonia.Native": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"Avalonia.Remote.Protocol": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "vw+6ZfgTuu72dA9aVWn6u56t2nrBd5MoMU0wo/qI9XJAl/c0oYYphIvwLvJP1JorubQY4UE3d0ac8ULBhrGBiA=="
|
||||
},
|
||||
"Avalonia.Skia": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "/B4aXmNRNjG8I5U/a1xJI+bIi0XO6DDzS3mBrIKlVnJRY2CyZiUeESRQXLnIU77Z9TvqkUROs+D47s085YjFtA==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18",
|
||||
"HarfBuzzSharp": "8.3.1.1",
|
||||
"HarfBuzzSharp.NativeAssets.Linux": "8.3.1.1",
|
||||
"HarfBuzzSharp.NativeAssets.WebAssembly": "8.3.1.1",
|
||||
"SkiaSharp": "2.88.9",
|
||||
"SkiaSharp.NativeAssets.Linux": "2.88.9",
|
||||
"SkiaSharp.NativeAssets.WebAssembly": "2.88.9"
|
||||
}
|
||||
},
|
||||
"Avalonia.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "eioUHkM2PeLPETd1aEks3rvb9plbba6buIrNdrqCpwE/qgHKUjvRNBd5mUQfAbGgTLiAes524gB8uUMDhrsJVQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18",
|
||||
"Avalonia.Angle.Windows.Natives": "2.1.25547.20250602"
|
||||
}
|
||||
},
|
||||
"Avalonia.X11": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "m4Ki/G5Dovnq+6QzfS0iGbK8V77Q6oTjToMLOB0CxPCCrl3Oxywh6kIjuGJDPaN6kopMmjxlNShyQf+vPYL+JA==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18",
|
||||
"Avalonia.FreeDesktop": "11.3.18",
|
||||
"Avalonia.Skia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "tLZN66oe/uiRPTZfrCU4i8ScVGwqHNh5MHrXj0yVf4l7Mz0FhTGnQ71RGySROTmdognAs0JtluHkL41pIabWuQ==",
|
||||
"dependencies": {
|
||||
"HarfBuzzSharp.NativeAssets.Win32": "8.3.1.1",
|
||||
"HarfBuzzSharp.NativeAssets.macOS": "8.3.1.1"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.WebAssembly": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "loJweK2u/mH/3C2zBa0ggJlITIszOkK64HLAZB7FUT670dTg965whLFYHDQo69NmC4+d9UN0icLC9VHidXaVCA=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||
},
|
||||
"MicroCom.Runtime": {
|
||||
"type": "Transitive",
|
||||
"resolved": "0.11.0",
|
||||
"contentHash": "MEnrZ3UIiH40hjzMDsxrTyi8dtqB5ziv3iBeeU4bXsL/7NLSal9F1lZKpK+tfBRnUoDSdtcW3KufE4yhATOMCA=="
|
||||
},
|
||||
"Microsoft.DotNet.PlatformAbstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.1.6",
|
||||
"contentHash": "jek4XYaQ/PGUwDKKhwR8K47Uh1189PFzMeLqO83mXrXQVIpARZCcfuDedH50YDTepBkfijCZN5U/vZi++erxtg=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyModel": {
|
||||
"type": "Transitive",
|
||||
"resolved": "9.0.9",
|
||||
"contentHash": "fNGvKct2De8ghm0Bpfq0iWthtzIWabgOTi+gJhNOPhNJIowXNEUE2eZNW/zNCzrHVA3PXg2yZ+3cWZndC2IqYA=="
|
||||
},
|
||||
"Silk.NET.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "D7AT/nnwlB+4RZ84XY8QNGBZMJI5z9l4CSSETIJ1wCfRJzRt/341y3MRZ4HbnFz4r/IGaWOEZr86iE+0/65yyQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.DotNet.PlatformAbstractions": "3.1.6",
|
||||
"Microsoft.Extensions.DependencyModel": "9.0.9"
|
||||
}
|
||||
},
|
||||
"Silk.NET.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "UIs4sH57xlPUNHQ/1bt9rymPWlGy8IMDCNv86h0iM4TOA1CkIx0XM/n/tA4AReh1zQkNrvkxPEdZ3Blvy1dyXg==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Core": "2.23.0",
|
||||
"Ultz.Native.GLFW": "3.4.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Maths": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "r8PdIVzME8EH0qAgbmRPO87I4GfgR2j8TofT7EMuRJDf1QluoQwnVypDoFJjQ2ZBSRsGYk5unYxxogI05Ogsmw=="
|
||||
},
|
||||
"Silk.NET.Windowing.Common": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "ThStSinmY9KQI8DGiF5XEhkLJVnBcgRTBTzL9ijg1wMZAYuckz7ykrNw04fjRm2Gryh6tCNGbvz2XaY0efeFzg==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Core": "2.23.0",
|
||||
"Silk.NET.Maths": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Windowing.Glfw": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "aYBudKmENmvLRn9p15HbdvlQTnnXskcDfTfbYwSb/4fr263rGLwYuDw/txUEc2jihHJiWCp5+75Y7z5wTJWl7g==",
|
||||
"dependencies": {
|
||||
"Silk.NET.GLFW": "2.23.0",
|
||||
"Silk.NET.Windowing.Common": "2.23.0"
|
||||
}
|
||||
},
|
||||
"SkiaSharp": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "3MD5VHjXXieSHCleRLuaTXmL2pD0mB7CcOB1x2kA1I4bhptf4e3R27iM93264ZYuAq6mkUyX5XbcxnZvMJYc1Q==",
|
||||
"dependencies": {
|
||||
"SkiaSharp.NativeAssets.Win32": "2.88.9",
|
||||
"SkiaSharp.NativeAssets.macOS": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.WebAssembly": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "kt06RccBHSnAs2wDYdBSfsjIDbY3EpsOVqnlDgKdgvyuRA8ZFDaHRdWNx1VHjGgYzmnFCGiTJBnXFl5BqGwGnA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
},
|
||||
"sharpemu.core": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Iced": "[1.21.0, )",
|
||||
"SharpEmu.HLE": "[1.0.0, )",
|
||||
"SharpEmu.Libs": "[1.0.0, )",
|
||||
"SharpEmu.Logging": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"sharpemu.gui": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"Avalonia": "[11.3.18, )",
|
||||
"Avalonia.Desktop": "[11.3.18, )",
|
||||
"Avalonia.Fonts.Inter": "[11.3.18, )",
|
||||
"Avalonia.Themes.Fluent": "[11.3.18, )",
|
||||
"SharpEmu.Logging": "[1.0.0, )",
|
||||
"Tmds.DBus.Protocol": "[0.21.3, )"
|
||||
}
|
||||
},
|
||||
"sharpemu.hle": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SharpEmu.Logging": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"Avalonia": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[11.3.18, )",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "2C4UxhWUObWGgYKWic1x5BMMWGJP6SElb91WeOxs+X/iR26rtkqpxFFwwo50FXS9AyYnHfk8QKXDEfe7oT/kZA==",
|
||||
"dependencies": {
|
||||
"Avalonia.BuildServices": "11.3.2",
|
||||
"Avalonia.Remote.Protocol": "11.3.18",
|
||||
"MicroCom.Runtime": "0.11.0"
|
||||
}
|
||||
},
|
||||
"Avalonia.Desktop": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[11.3.18, )",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "bilMPa5vYiis6fbNovb6esKytBnOCEGojBa1XFegLCRHCP6g6PvZwS0XF/YOAGkENRlHG8dI7lohOpQ9bIkq1g==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18",
|
||||
"Avalonia.Native": "11.3.18",
|
||||
"Avalonia.Skia": "11.3.18",
|
||||
"Avalonia.Win32": "11.3.18",
|
||||
"Avalonia.X11": "11.3.18"
|
||||
}
|
||||
},
|
||||
"Avalonia.Fonts.Inter": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[11.3.18, )",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "27u6hB3Y2Ue586yjfeVakberY73VNQXtuKwe/P927XG1QPlhsfmOyifLHDDpSHG85Zl1x/Xv9IZ3+tk9FnjcZQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"Avalonia.Themes.Fluent": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[11.3.18, )",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "+Q/TJoynD0zNuu5w2gD+xcTl7GNKJFxlPYAndRLs/mTDrNbbsvv/271WyIysbMPsXSjCyBDp7RCZzQkpD6x5Bg==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"Iced": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.21.0, )",
|
||||
"resolved": "1.21.0",
|
||||
"contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg=="
|
||||
},
|
||||
"Silk.NET.Vulkan": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "3/irtlSWXZ3eTi8N6nelI6L34NTB8ZJHpqVMNzZx2aX7Ek9YEQ34NoQW8/Tljrtmkg8KRhHW8hKTEzZaKV8PgA==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Core": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Vulkan.Extensions.EXT": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "+Oth189ksRiL6HvGCwIdnsYHawqrbO8y49u1H61z3wsfcHhQZeVDYe/wF5LD7fk3NcdgDvwFD3mLm1QWhdZySw==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Core": "2.23.0",
|
||||
"Silk.NET.Vulkan": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Vulkan.Extensions.KHR": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "uRaf4j+SmH3DumjSSSUbFg33BnsGZUyXGj93O9NgGKZSJN3OTmNmQDxRew+/KiVLcgH6qzbto8aNGZ++j9GFWg==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Core": "2.23.0",
|
||||
"Silk.NET.Vulkan": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Silk.NET.Windowing": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.23.0, )",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "OPNPmt/lRyUKVYrFLQXVxyATqD3MKLc1iY1oKx1/2GppgmZxVZPwN12tekrQ4C7408kgB1L5JD1Wnirqqeb2kg==",
|
||||
"dependencies": {
|
||||
"Silk.NET.Windowing.Common": "2.23.0",
|
||||
"Silk.NET.Windowing.Glfw": "2.23.0"
|
||||
}
|
||||
},
|
||||
"Tmds.DBus.Protocol": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[0.21.3, )",
|
||||
"resolved": "0.21.3",
|
||||
"contentHash": "hDwB8WsQoyALQKqIbwzS68UKdlnafDm4T/DkO/JrA/YIneP/rKv96SxYPVXeh3FP4i/SXfShrYftKLtciJAIlw=="
|
||||
}
|
||||
},
|
||||
"net10.0/linux-x64": {
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.25547.20250602",
|
||||
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||
},
|
||||
"Avalonia.Native": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
}
|
||||
},
|
||||
"net10.0/osx-arm64": {
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.25547.20250602",
|
||||
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||
},
|
||||
"Avalonia.Native": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
}
|
||||
},
|
||||
"net10.0/win-x64": {
|
||||
"Avalonia.Angle.Windows.Natives": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.25547.20250602",
|
||||
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
|
||||
},
|
||||
"Avalonia.Native": {
|
||||
"type": "Transitive",
|
||||
"resolved": "11.3.18",
|
||||
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
|
||||
"dependencies": {
|
||||
"Avalonia": "11.3.18"
|
||||
}
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
|
||||
},
|
||||
"HarfBuzzSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.3.1.1",
|
||||
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
|
||||
"dependencies": {
|
||||
"SkiaSharp": "2.88.9"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.88.9",
|
||||
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
|
||||
},
|
||||
"Ultz.Native.GLFW": {
|
||||
"type": "Transitive",
|
||||
"resolved": "3.4.0",
|
||||
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,33 +3,38 @@
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
using SharpEmu.Core.Cpu.Debugging;
|
||||
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 +88,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 +97,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 +111,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 +127,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 +142,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;
|
||||
@@ -266,7 +273,23 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
entryFrameDiagnostic,
|
||||
Environment.NewLine,
|
||||
"CpuEngine: native-only");
|
||||
// Frame boundaries an attached debugger observes; null hook = a branch.
|
||||
var debugHook = executionOptions.DebugHook;
|
||||
var debugFrame = debugHook is null
|
||||
? null
|
||||
: new CpuContextDebugFrame(
|
||||
frameKind == EntryFrameKind.ProcessEntry
|
||||
? CpuDebugFrameKind.ProcessEntry
|
||||
: CpuDebugFrameKind.ModuleInitializer,
|
||||
entryPoint,
|
||||
processImageName,
|
||||
context,
|
||||
effectiveImportStubs);
|
||||
debugHook?.OnFrameEnter(debugFrame!);
|
||||
|
||||
_nativeCpuBackend ??= new DirectExecutionBackend(_moduleManager);
|
||||
// Let backend stall reports reference the same frame as entry.
|
||||
(_nativeCpuBackend as DirectExecutionBackend)?.SetActiveDebugFrame(debugFrame);
|
||||
if (_nativeCpuBackend.TryExecute(
|
||||
context,
|
||||
entryPoint,
|
||||
@@ -276,6 +299,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
executionOptions,
|
||||
out var nativeResult))
|
||||
{
|
||||
debugHook?.OnFrameExit(debugFrame!, nativeResult);
|
||||
LastSessionSummary = new CpuSessionSummary(
|
||||
nativeResult,
|
||||
nativeResult == OrbisGen2Result.ORBIS_GEN2_OK
|
||||
@@ -290,6 +314,8 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
return nativeResult;
|
||||
}
|
||||
|
||||
debugHook?.OnFrameExit(debugFrame!, OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_IMPLEMENTED);
|
||||
|
||||
var backendName = string.IsNullOrWhiteSpace(_nativeCpuBackend.BackendName)
|
||||
? "native-backend"
|
||||
: _nativeCpuBackend.BackendName;
|
||||
@@ -307,7 +333,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 +392,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 +430,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 +510,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 +713,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 +727,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
|
||||
disposableBackend.Dispose();
|
||||
}
|
||||
|
||||
NativeSessionLeaked = _nativeCpuBackend is DirectExecutionBackend { GuestSessionLeaked: true };
|
||||
_nativeCpuBackend = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Core.Cpu.Debugging;
|
||||
|
||||
namespace SharpEmu.Core.Cpu;
|
||||
|
||||
public readonly struct CpuExecutionOptions
|
||||
{
|
||||
public bool EnableDisasmDiagnostics { get; init; }
|
||||
|
||||
|
||||
public CpuExecutionEngine CpuEngine { get; init; }
|
||||
|
||||
public bool StrictDynlibResolution { get; init; }
|
||||
|
||||
public int ImportTraceLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// An optional debugger attached to this execution session. When set, the
|
||||
/// dispatcher notifies it at each frame boundary via
|
||||
/// <see cref="ICpuDebugHook.OnFrameEnter"/> / <see cref="ICpuDebugHook.OnFrameExit"/>.
|
||||
/// Null when no debugger is attached, which is the default and imposes no
|
||||
/// runtime cost.
|
||||
/// </summary>
|
||||
public ICpuDebugHook? DebugHook { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Debugging;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts a live <see cref="CpuContext"/> to <see cref="ICpuDebugFrame"/>. The
|
||||
/// dispatcher creates one of these around the guest context it is about to run
|
||||
/// and passes it to the attached <see cref="ICpuDebugHook"/>; every accessor
|
||||
/// forwards directly to the underlying context.
|
||||
/// </summary>
|
||||
internal sealed class CpuContextDebugFrame : ICpuDebugFrame
|
||||
{
|
||||
private readonly CpuContext _context;
|
||||
|
||||
internal CpuContextDebugFrame(
|
||||
CpuDebugFrameKind kind,
|
||||
ulong entryPoint,
|
||||
string label,
|
||||
CpuContext context,
|
||||
IReadOnlyDictionary<ulong, string> importStubs)
|
||||
{
|
||||
Kind = kind;
|
||||
EntryPoint = entryPoint;
|
||||
Label = label ?? string.Empty;
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
ImportStubs = importStubs ?? new Dictionary<ulong, string>();
|
||||
}
|
||||
|
||||
public CpuDebugFrameKind Kind { get; }
|
||||
|
||||
public Generation Generation => _context.TargetGeneration;
|
||||
|
||||
public ulong EntryPoint { get; }
|
||||
|
||||
public string Label { get; }
|
||||
|
||||
public ICpuMemory Memory => _context.Memory;
|
||||
|
||||
public ulong GetRegister(CpuRegister register) => _context[register];
|
||||
|
||||
public void SetRegister(CpuRegister register, ulong value) => _context[register] = value;
|
||||
|
||||
public ulong Rip
|
||||
{
|
||||
get => _context.Rip;
|
||||
set => _context.Rip = value;
|
||||
}
|
||||
|
||||
public ulong Rflags
|
||||
{
|
||||
get => _context.Rflags;
|
||||
set => _context.Rflags = value;
|
||||
}
|
||||
|
||||
public ulong FsBase => _context.FsBase;
|
||||
|
||||
public ulong GsBase => _context.GsBase;
|
||||
|
||||
public void GetXmm(int registerIndex, out ulong low, out ulong high)
|
||||
=> _context.GetXmmRegister(registerIndex, out low, out high);
|
||||
|
||||
public IReadOnlyDictionary<ulong, string> ImportStubs { get; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Debugging;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the kind of guest entry frame a debugger is observing. The
|
||||
/// dispatcher enters a fresh frame for the process entry point and for every
|
||||
/// module initializer, so the debug layer can label stops accordingly.
|
||||
/// </summary>
|
||||
public enum CpuDebugFrameKind
|
||||
{
|
||||
/// <summary>The guest process entry point (<c>eboot.bin</c> start).</summary>
|
||||
ProcessEntry,
|
||||
|
||||
/// <summary>A module DT_INIT / initializer routine.</summary>
|
||||
ModuleInitializer,
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Debugging;
|
||||
|
||||
/// <summary>The kind of execution stall the backend detected.</summary>
|
||||
public enum CpuStallKind
|
||||
{
|
||||
/// <summary>
|
||||
/// The guest is repeatedly re-dispatching the same import with no forward
|
||||
/// progress — most commonly a spin on a mutex lock/unlock pair.
|
||||
/// </summary>
|
||||
ImportLoop,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details of a detected stall handed to <see cref="ICpuDebugHook.OnStall"/>.
|
||||
/// Reported from the emulation thread at the point the backend recognises the
|
||||
/// livelock, before it forces the guest out of the loop.
|
||||
/// </summary>
|
||||
public readonly struct CpuStallInfo
|
||||
{
|
||||
public CpuStallInfo(
|
||||
CpuStallKind kind,
|
||||
string? nid,
|
||||
ulong instructionPointer,
|
||||
long dispatchIndex,
|
||||
ulong argument0,
|
||||
ulong argument1,
|
||||
string detail,
|
||||
string? libraryName = null,
|
||||
string? functionName = null)
|
||||
{
|
||||
Kind = kind;
|
||||
Nid = nid;
|
||||
InstructionPointer = instructionPointer;
|
||||
DispatchIndex = dispatchIndex;
|
||||
Argument0 = argument0;
|
||||
Argument1 = argument1;
|
||||
Detail = detail ?? string.Empty;
|
||||
LibraryName = libraryName;
|
||||
FunctionName = functionName;
|
||||
}
|
||||
|
||||
public CpuStallKind Kind { get; }
|
||||
|
||||
/// <summary>The NID of the import being spun on, when known.</summary>
|
||||
public string? Nid { get; }
|
||||
|
||||
/// <summary>The guest return address of the looping import dispatch.</summary>
|
||||
public ulong InstructionPointer { get; }
|
||||
|
||||
/// <summary>The import dispatch counter at detection time.</summary>
|
||||
public long DispatchIndex { get; }
|
||||
|
||||
/// <summary>The first two guest ABI arguments at stall detection.</summary>
|
||||
public ulong Argument0 { get; }
|
||||
|
||||
public ulong Argument1 { get; }
|
||||
|
||||
/// <summary>The resolved HLE export, when the NID is registered.</summary>
|
||||
public string? LibraryName { get; }
|
||||
|
||||
public string? FunctionName { get; }
|
||||
|
||||
public bool IsResolved => !string.IsNullOrWhiteSpace(FunctionName);
|
||||
|
||||
/// <summary>A human-readable one-line summary of the stall.</summary>
|
||||
public string Detail { get; }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Debugging;
|
||||
|
||||
/// <summary>
|
||||
/// A live view of the guest CPU state at a dispatch boundary, handed to an
|
||||
/// <see cref="ICpuDebugHook"/> so a debugger can read and mutate registers and
|
||||
/// guest memory without taking a dependency on the concrete
|
||||
/// <c>CpuContext</c>/<c>CpuDispatcher</c> types.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The frame instance is only valid for the duration of the hook call that
|
||||
/// receives it (between <see cref="ICpuDebugHook.OnFrameEnter"/> and the
|
||||
/// matching <see cref="ICpuDebugHook.OnFrameExit"/>). Reads and writes are
|
||||
/// forwarded straight to the underlying guest context, so mutations made from
|
||||
/// a hook are observed by the CPU backend when it resumes the frame.
|
||||
/// </remarks>
|
||||
public interface ICpuDebugFrame
|
||||
{
|
||||
/// <summary>The kind of frame being executed.</summary>
|
||||
CpuDebugFrameKind Kind { get; }
|
||||
|
||||
/// <summary>The guest ABI generation this frame targets.</summary>
|
||||
Generation Generation { get; }
|
||||
|
||||
/// <summary>The guest virtual address the frame begins executing at.</summary>
|
||||
ulong EntryPoint { get; }
|
||||
|
||||
/// <summary>
|
||||
/// A human-readable label for the frame (process image name or module name).
|
||||
/// </summary>
|
||||
string Label { get; }
|
||||
|
||||
/// <summary>Guest-addressable memory for this frame.</summary>
|
||||
ICpuMemory Memory { get; }
|
||||
|
||||
/// <summary>Reads a general-purpose register.</summary>
|
||||
ulong GetRegister(CpuRegister register);
|
||||
|
||||
/// <summary>Overwrites a general-purpose register.</summary>
|
||||
void SetRegister(CpuRegister register, ulong value);
|
||||
|
||||
/// <summary>The instruction pointer.</summary>
|
||||
ulong Rip { get; set; }
|
||||
|
||||
/// <summary>The flags register.</summary>
|
||||
ulong Rflags { get; set; }
|
||||
|
||||
/// <summary>The FS segment base (guest TLS pointer).</summary>
|
||||
ulong FsBase { get; }
|
||||
|
||||
/// <summary>The GS segment base.</summary>
|
||||
ulong GsBase { get; }
|
||||
|
||||
/// <summary>Reads the 128-bit value of an XMM register.</summary>
|
||||
void GetXmm(int registerIndex, out ulong low, out ulong high);
|
||||
|
||||
/// <summary>
|
||||
/// The import stubs (guest address to NID) resolved for this frame, so a
|
||||
/// debugger can annotate calls into HLE exports.
|
||||
/// </summary>
|
||||
IReadOnlyDictionary<ulong, string> ImportStubs { get; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Debugging;
|
||||
|
||||
/// <summary>
|
||||
/// The seam the CPU dispatcher uses to notify an attached debugger when guest
|
||||
/// execution crosses a frame boundary. Implemented outside of Core (for
|
||||
/// example by <c>SharpEmu.Debugger</c>) and supplied through
|
||||
/// <see cref="CpuExecutionOptions.DebugHook"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is intentionally coarse-grained: it exposes the entry and exit of each
|
||||
/// dispatched frame rather than per-instruction stepping. Per-instruction
|
||||
/// control requires cooperation from the native execution backend and is layered
|
||||
/// on top of this seam as the backend gains support; keeping the dispatcher-level
|
||||
/// contract stable lets the debugger infrastructure exist independently of that
|
||||
/// work. Implementations must be thread-safe: frames may be dispatched from the
|
||||
/// dedicated emulation thread while a debug server services clients on its own
|
||||
/// threads.
|
||||
/// </remarks>
|
||||
public interface ICpuDebugHook
|
||||
{
|
||||
/// <summary>
|
||||
/// Invoked immediately before the native backend begins executing a frame.
|
||||
/// The debugger may inspect or mutate <paramref name="frame"/> and may block
|
||||
/// the calling thread (for example, to honour a pause request) before
|
||||
/// returning to allow execution to proceed.
|
||||
/// </summary>
|
||||
void OnFrameEnter(ICpuDebugFrame frame);
|
||||
|
||||
/// <summary>
|
||||
/// Invoked after a frame completes, whether it returned to the host or
|
||||
/// terminated with an error. <paramref name="frame"/> reflects the final
|
||||
/// guest state.
|
||||
/// </summary>
|
||||
void OnFrameExit(ICpuDebugFrame frame, OrbisGen2Result result);
|
||||
|
||||
/// <summary>
|
||||
/// Invoked from the emulation thread when the backend detects an execution
|
||||
/// stall (for example a mutex spin loop) in the running frame, before it
|
||||
/// forces the guest out of the loop. As with <see cref="OnFrameEnter"/>, the
|
||||
/// implementation may inspect <paramref name="frame"/> and block to honour a
|
||||
/// break before returning to let the backend proceed.
|
||||
/// </summary>
|
||||
void OnStall(ICpuDebugFrame frame, CpuStallInfo info);
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Emulation;
|
||||
|
||||
/// <summary>
|
||||
/// Pure software implementation of the bit-field math behind AMD's SSE4a EXTRQ/INSERTQ
|
||||
/// (immediate-form) instructions.
|
||||
///
|
||||
/// The direct-execution backend runs guest PS5 code natively on the host CPU. The PS5's Zen 2
|
||||
/// cores implement AMD-only SSE4a (EXTRQ/INSERTQ), but Intel hosts - and Rosetta 2 on Apple
|
||||
/// Silicon - do not, so they raise #UD (STATUS_ILLEGAL_INSTRUCTION) instead of executing the
|
||||
/// opcode. SharpEmu already rewrites one specific compiled EXTRQ+VPBLENDD idiom at load time
|
||||
/// (see <see cref="Native.Sse4aExtrqBlendPatch"/>), but any other occurrence of EXTRQ/INSERTQ -
|
||||
/// a different register allocation, a title built with a different compiler version, and so on
|
||||
/// - still aborts the title. This class ported from Kyty's
|
||||
/// <c>Loader::X64InstructionEmulator::TryEmulateSse4a</c> provides the general bit-field
|
||||
/// extract/insert so the illegal-instruction handler can finish *any* immediate-form
|
||||
/// EXTRQ/INSERTQ in software and resume, instead of relying on a single hard-coded byte pattern.
|
||||
///
|
||||
/// The methods operate on plain 64-bit integers rather than the OS CONTEXT record so the bit
|
||||
/// math can be unit-tested in isolation; the unsafe CONTEXT/XMM plumbing lives in the backend
|
||||
/// adapter (<see cref="Native.DirectExecutionBackend"/>).
|
||||
/// </summary>
|
||||
public static class Sse4aBitFieldEmulator
|
||||
{
|
||||
public static bool IsValidBitField(int length, int index)
|
||||
{
|
||||
var len = length & 0x3F;
|
||||
var idx = index & 0x3F;
|
||||
return (len != 0 || idx == 0) && (len == 0 ? idx == 0 : idx + len <= 64);
|
||||
}
|
||||
|
||||
public static ulong ExtractBitField(ulong value, int length, int index)
|
||||
{
|
||||
var len = length & 0x3F;
|
||||
var idx = index & 0x3F;
|
||||
if (!IsValidBitField(length, index))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (len == 0)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var mask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
|
||||
return (value >> idx) & mask;
|
||||
}
|
||||
|
||||
public static ulong InsertBitField(ulong destination, ulong source, int length, int index)
|
||||
{
|
||||
var len = length & 0x3F;
|
||||
var idx = index & 0x3F;
|
||||
if (!IsValidBitField(length, index))
|
||||
{
|
||||
return destination;
|
||||
}
|
||||
|
||||
if (len == 0)
|
||||
{
|
||||
return source;
|
||||
}
|
||||
|
||||
var fieldMask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
|
||||
var destinationClearMask = fieldMask << idx;
|
||||
var sourceField = (source & fieldMask) << idx;
|
||||
return (destination & ~destinationClearMask) | sourceField;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Threading;
|
||||
using Iced.Intel;
|
||||
using SharpEmu.Core.Cpu.Emulation;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native;
|
||||
|
||||
// General software fallback for the AMD-only instructions PS5 titles occasionally emit that a
|
||||
// Zen 2-only host implements but Intel hosts (and Rosetta 2 on Apple Silicon) do not:
|
||||
// - SSE4a EXTRQ/INSERTQ, immediate form
|
||||
// - MONITORX/MWAITX
|
||||
//
|
||||
// This is a direct port of Kyty's Loader::X64InstructionEmulator (TryEmulateSse4a /
|
||||
// TryEmulateMonitorxMwaitx). SharpEmu already special-cases exactly one compiled EXTRQ+VPBLENDD
|
||||
// byte sequence at load time (Sse4aExtrqBlendPatch), which only helps the one idiom it was
|
||||
// reverse-engineered from. This file is a general, fault-time fallback that engages for any
|
||||
// immediate-form EXTRQ/INSERTQ or MONITORX/MWAITX the narrower patch (or a title using a
|
||||
// different compiler/register allocation) does not cover, complementing rather than replacing
|
||||
// it: the load-time patch still avoids paying the fault-and-recover cost on the hot path it was
|
||||
// built for, while this method is the safety net for everything else.
|
||||
//
|
||||
// This is deliberately additive: DirectExecutionBackend.IllegalInstruction.cs (the BMI1/BMI2/ABM
|
||||
// fallback) is untouched, and this method is only reached from VectoredHandler after that one
|
||||
// has already declined to handle the fault.
|
||||
public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
// Byte offset of Xmm0 within the Win64 CONTEXT record: FltSave (the XMM_SAVE_AREA32/FXSAVE
|
||||
// image) starts right after Rip at offset 256, and XmmRegisters[0] sits 160 bytes into that
|
||||
// area (32-byte header + 8 legacy x87/MMX slots x 16 bytes). 256 + 160 = 416 (0x1A0). Cross-
|
||||
// checked against this file's own Win64ContextSize (0x4D0): rebuilding the whole CONTEXT
|
||||
// layout field-by-field from offset 0 lands on the same 0x4D0 total, which would not happen
|
||||
// if this offset (or anything before it) were wrong.
|
||||
private const int Win64ContextXmm0Offset = 0x1A0;
|
||||
|
||||
private static int _sse4aSoftwareFallbackAnnounced;
|
||||
private static long _sse4aInstructionsEmulated;
|
||||
private static int _monitorxSoftwareFallbackAnnounced;
|
||||
private static long _monitorxInstructionsEmulated;
|
||||
|
||||
private unsafe bool TryRecoverAmdCompatInstruction(void* contextRecord, ulong rip)
|
||||
{
|
||||
if (TryRecoverMonitorxMwaitx(contextRecord, rip))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// MONITORX/MWAITX above only ever reads guest code memory and rewrites RIP, both of
|
||||
// which the POSIX signal bridge (DirectExecutionBackend.PosixSignals.cs) faithfully
|
||||
// round-trips through the real ucontext, so it works on every supported OS. EXTRQ/
|
||||
// INSERTQ additionally read and write an XMM register: on Windows contextRecord is the
|
||||
// live CONTEXT the OS resumes the thread from, so touching the Xmm0.. slots is visible
|
||||
// to the guest, and on Linux the bridge copies the mcontext's FXSAVE image into the
|
||||
// Xmm0.. slots and writes them back through sigreturn (_posixXmmContextBridged). On
|
||||
// Darwin the XMM area is still a zeroed scratch buffer - running this there would
|
||||
// silently compute a result from stale bytes and then discard whatever it "wrote", so
|
||||
// the recovery declines until that bridge exists.
|
||||
return (OperatingSystem.IsWindows() || _posixXmmContextBridged) &&
|
||||
TryRecoverSse4aExtractInsert(contextRecord, rip);
|
||||
}
|
||||
|
||||
private unsafe bool TryRecoverMonitorxMwaitx(void* contextRecord, ulong rip)
|
||||
{
|
||||
// MONITORX (0F 01 FA) and MWAITX (0F 01 FB) are fixed 3-byte encodings with no
|
||||
// ModRM/SIB/displacement/immediate, so a raw byte compare is sufficient and unambiguous.
|
||||
var opcode = new byte[3];
|
||||
if (!TryReadHostBytes(rip, opcode) ||
|
||||
opcode[0] != 0x0F || opcode[1] != 0x01 || (opcode[2] != 0xFA && opcode[2] != 0xFB))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// PS5 titles use this pair in idle/wait loops: MONITORX arms a monitor on a cache line
|
||||
// and MWAITX blocks until that line is written (or a timeout elapses). Hosts without
|
||||
// the extension raise #UD on either one. We do not model the monitor itself, only its
|
||||
// observable effect on guest forward progress: MONITORX becomes a no-op (arming a
|
||||
// watch we never honour has no side effect of its own) and MWAITX becomes a plain
|
||||
// thread yield, i.e. treat the awaited condition as already satisfied so the guest
|
||||
// loop keeps making progress instead of executing an illegal opcode forever.
|
||||
if (opcode[2] == 0xFB)
|
||||
{
|
||||
Thread.Yield();
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, CTX_RIP, rip + 3);
|
||||
|
||||
Interlocked.Increment(ref _monitorxInstructionsEmulated);
|
||||
if (Interlocked.Exchange(ref _monitorxSoftwareFallbackAnnounced, 1) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Host lacks AMD MONITORX/MWAITX used by the guest; " +
|
||||
"emulating those instructions in software.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private unsafe bool TryRecoverSse4aExtractInsert(void* contextRecord, ulong rip)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() && !_posixXmmContextBridged ||
|
||||
!TryReadFaultingInstruction(rip, out var instruction))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var isExtrq = instruction.Mnemonic == Mnemonic.Extrq;
|
||||
var isInsertq = instruction.Mnemonic == Mnemonic.Insertq;
|
||||
if (!isExtrq && !isInsertq)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isExtrq && instruction.OpCount != 3 || isInsertq && instruction.OpCount != 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (instruction.GetOpKind(0) != OpKind.Register ||
|
||||
!TryGetXmmOffset(instruction.GetOpRegister(0), out var destOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var destLow = ReadCtxU64(contextRecord, destOffset);
|
||||
if (isExtrq)
|
||||
{
|
||||
var length = (int)instruction.GetImmediate(1);
|
||||
var index = (int)instruction.GetImmediate(2);
|
||||
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.ExtractBitField(destLow, length, index));
|
||||
WriteCtxU64(contextRecord, destOffset + 8, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (instruction.GetOpKind(1) != OpKind.Register ||
|
||||
!TryGetXmmOffset(instruction.GetOpRegister(1), out var srcOffset))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var length = (int)instruction.GetImmediate(2);
|
||||
var index = (int)instruction.GetImmediate(3);
|
||||
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.InsertBitField(
|
||||
destLow, ReadCtxU64(contextRecord, srcOffset), length, index));
|
||||
WriteCtxU64(contextRecord, destOffset + 8, 0);
|
||||
}
|
||||
|
||||
WriteCtxU64(contextRecord, CTX_RIP, rip + (ulong)instruction.Length);
|
||||
|
||||
Interlocked.Increment(ref _sse4aInstructionsEmulated);
|
||||
if (Interlocked.Exchange(ref _sse4aSoftwareFallbackAnnounced, 1) == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Host lacks SSE4a EXTRQ/INSERTQ used by the guest; " +
|
||||
"emulating those instructions in software.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Maps an Iced XMM register to its byte offset in the Win64 CONTEXT record. Written as an
|
||||
// explicit switch (rather than arithmetic on the Register enum) to match the style already
|
||||
// used by TryGetGprSlot/TryGetGpr64Offset in DirectExecutionBackend.IllegalInstruction.cs.
|
||||
private static bool TryGetXmmOffset(Register register, out int offset)
|
||||
{
|
||||
switch (register)
|
||||
{
|
||||
case Register.XMM0: offset = Win64ContextXmm0Offset + 16 * 0; return true;
|
||||
case Register.XMM1: offset = Win64ContextXmm0Offset + 16 * 1; return true;
|
||||
case Register.XMM2: offset = Win64ContextXmm0Offset + 16 * 2; return true;
|
||||
case Register.XMM3: offset = Win64ContextXmm0Offset + 16 * 3; return true;
|
||||
case Register.XMM4: offset = Win64ContextXmm0Offset + 16 * 4; return true;
|
||||
case Register.XMM5: offset = Win64ContextXmm0Offset + 16 * 5; return true;
|
||||
case Register.XMM6: offset = Win64ContextXmm0Offset + 16 * 6; return true;
|
||||
case Register.XMM7: offset = Win64ContextXmm0Offset + 16 * 7; return true;
|
||||
case Register.XMM8: offset = Win64ContextXmm0Offset + 16 * 8; return true;
|
||||
case Register.XMM9: offset = Win64ContextXmm0Offset + 16 * 9; return true;
|
||||
case Register.XMM10: offset = Win64ContextXmm0Offset + 16 * 10; return true;
|
||||
case Register.XMM11: offset = Win64ContextXmm0Offset + 16 * 11; return true;
|
||||
case Register.XMM12: offset = Win64ContextXmm0Offset + 16 * 12; return true;
|
||||
case Register.XMM13: offset = Win64ContextXmm0Offset + 16 * 13; return true;
|
||||
case Register.XMM14: offset = Win64ContextXmm0Offset + 16 * 14; return true;
|
||||
case Register.XMM15: offset = Win64ContextXmm0Offset + 16 * 15; return true;
|
||||
default:
|
||||
offset = 0;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,101 +74,40 @@ 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++;
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordDeferredBootstrapTrace(
|
||||
long dispatchIndex,
|
||||
ulong op,
|
||||
ulong symbolPointer,
|
||||
ulong outputPointer,
|
||||
ulong returnRip)
|
||||
{
|
||||
lock (_deferredBootstrapTraceGate)
|
||||
{
|
||||
_deferredBootstrapTrace[_deferredBootstrapTraceWriteIndex] = new DeferredBootstrapTraceEntry(
|
||||
dispatchIndex,
|
||||
op,
|
||||
symbolPointer,
|
||||
outputPointer,
|
||||
returnRip);
|
||||
_deferredBootstrapTraceWriteIndex =
|
||||
(_deferredBootstrapTraceWriteIndex + 1) % _deferredBootstrapTrace.Length;
|
||||
if (_deferredBootstrapTraceCount < _deferredBootstrapTrace.Length)
|
||||
{
|
||||
_deferredBootstrapTraceCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrainDeferredBootstrapTraces()
|
||||
{
|
||||
if (!_logBootstrap)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DeferredBootstrapTraceEntry[] pending;
|
||||
lock (_deferredBootstrapTraceGate)
|
||||
{
|
||||
if (_deferredBootstrapTraceCount == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pending = new DeferredBootstrapTraceEntry[_deferredBootstrapTraceCount];
|
||||
var readIndex = (_deferredBootstrapTraceWriteIndex - _deferredBootstrapTraceCount +
|
||||
_deferredBootstrapTrace.Length) % _deferredBootstrapTrace.Length;
|
||||
for (var i = 0; i < _deferredBootstrapTraceCount; i++)
|
||||
{
|
||||
pending[i] = _deferredBootstrapTrace[(readIndex + i) % _deferredBootstrapTrace.Length];
|
||||
}
|
||||
|
||||
_deferredBootstrapTraceCount = 0;
|
||||
}
|
||||
|
||||
foreach (var entry in pending)
|
||||
{
|
||||
var symbolText = "<unreadable>";
|
||||
if (TryReadAsciiZ(entry.SymbolPointer, 256, out var sym))
|
||||
{
|
||||
symbolText = sym;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][TRACE] bootstrap_call#{entry.DispatchIndex}: op=0x{entry.Op:X16} " +
|
||||
$"sym_ptr=0x{entry.SymbolPointer:X16} sym='{symbolText}' " +
|
||||
$"out_ptr=0x{entry.OutputPointer:X16} ret=0x{entry.ReturnRip:X16}");
|
||||
}
|
||||
}
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -184,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,15 +268,14 @@ public sealed partial class DirectExecutionBackend
|
||||
ulong callRip = returnRip + (ulong)i;
|
||||
ulong target = unchecked((ulong)((long)(callRip + 5) + rel32));
|
||||
Log.Debug($"Import#{dispatchIndex} near-call @{callRip:X16}: target=0x{target:X16}");
|
||||
var importEntries = _importEntries;
|
||||
for (int importIndex = 0; importIndex < importEntries.Length; importIndex++)
|
||||
for (int importIndex = 0; importIndex < _importEntries.Length; importIndex++)
|
||||
{
|
||||
if (importEntries[importIndex].Address != target)
|
||||
if (_importEntries[importIndex].Address != target)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string nid = importEntries[importIndex].Nid;
|
||||
string nid = _importEntries[importIndex].Nid;
|
||||
if (_moduleManager.TryGetExport(nid, out var export))
|
||||
{
|
||||
Log.Debug(
|
||||
@@ -270,14 +302,14 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
Log.Debug(
|
||||
$"Import#{dispatchIndex} near-call PLT slot: [0x{slot:X16}] = 0x{slotTarget:X16}");
|
||||
for (int importIndex = 0; importIndex < importEntries.Length; importIndex++)
|
||||
for (int importIndex = 0; importIndex < _importEntries.Length; importIndex++)
|
||||
{
|
||||
if (importEntries[importIndex].Address != slotTarget)
|
||||
if (_importEntries[importIndex].Address != slotTarget)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string nid = importEntries[importIndex].Nid;
|
||||
string nid = _importEntries[importIndex].Nid;
|
||||
if (_moduleManager.TryGetExport(nid, out var export))
|
||||
{
|
||||
Log.Debug(
|
||||
@@ -301,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,20 +110,34 @@ public sealed partial class DirectExecutionBackend
|
||||
|
||||
ulong rip = ReadCtxU64(contextRecord, 248);
|
||||
ulong rsp = ReadCtxU64(contextRecord, 152);
|
||||
|
||||
// Thread-mode probe: a hardware exception raised while this thread is inside
|
||||
// the managed import gateway means the VEH->managed reentry happened from
|
||||
// cooperative GC mode — a ReversePInvokeBadTransition candidate.
|
||||
if (LogThreadMode && _threadModeGatewayDepth > 0)
|
||||
if (TryRecoverGuestInt41(exceptionCode, contextRecord, rip))
|
||||
{
|
||||
TraceThreadMode(
|
||||
$"veh_in_gateway code=0x{exceptionCode:X8} rip=0x{rip:X16} gateway_depth={_threadModeGatewayDepth}");
|
||||
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 (exceptionCode == StatusIllegalInstruction &&
|
||||
TryRecoverAmdCompatInstruction(contextRecord, rip))
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
if (IsBenignHostDebugException(exceptionCode))
|
||||
{
|
||||
return -1;
|
||||
@@ -161,6 +183,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);
|
||||
@@ -205,20 +256,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
|
||||
{
|
||||
@@ -226,12 +307,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)
|
||||
@@ -254,10 +338,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)
|
||||
{
|
||||
@@ -273,25 +356,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;
|
||||
@@ -299,8 +403,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;
|
||||
}
|
||||
|
||||
@@ -314,6 +430,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} (default-on; set SHARPEMU_IGNORE_INT41=0 to disable)");
|
||||
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;
|
||||
@@ -393,7 +611,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))
|
||||
{
|
||||
@@ -405,12 +623,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;
|
||||
}
|
||||
@@ -554,6 +780,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,
|
||||
@@ -821,6 +1096,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,10 @@ public sealed partial class DirectExecutionBackend
|
||||
|
||||
private NativeGuestExecutor? RentNativeGuestExecutor()
|
||||
{
|
||||
if (NativeGuestWorkersDisabled)
|
||||
// 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;
|
||||
}
|
||||
@@ -185,8 +188,20 @@ public sealed partial class DirectExecutionBackend
|
||||
private static nint _exitThreadAddress;
|
||||
|
||||
private readonly DirectExecutionBackend _backend;
|
||||
private readonly AutoResetEvent _workAvailable = new(false);
|
||||
private readonly AutoResetEvent _workCompleted = new(false);
|
||||
// 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;
|
||||
@@ -225,6 +240,11 @@ public sealed partial class DirectExecutionBackend
|
||||
private NativeGuestExecutor(DirectExecutionBackend backend)
|
||||
{
|
||||
_backend = backend;
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
_workAvailable = new AutoResetEvent(false);
|
||||
_workCompleted = new AutoResetEvent(false);
|
||||
}
|
||||
}
|
||||
|
||||
public static NativeGuestExecutor? TryCreate(DirectExecutionBackend backend)
|
||||
@@ -276,8 +296,34 @@ public sealed partial class DirectExecutionBackend
|
||||
var prologuePtr = (nint)(delegate* unmanaged<nint, nint>)&RunPrologue;
|
||||
var epiloguePtr = (nint)(delegate* unmanaged<nint, int, void>)&RunEpilogue;
|
||||
var executorHandle = GCHandle.ToIntPtr(_selfHandle);
|
||||
var workHandle = _workAvailable.SafeWaitHandle.DangerousGetHandle();
|
||||
var doneHandle = _workCompleted.SafeWaitHandle.DangerousGetHandle();
|
||||
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;
|
||||
@@ -397,8 +443,8 @@ public sealed partial class DirectExecutionBackend
|
||||
_runYieldRequested = false;
|
||||
_runYieldReason = null;
|
||||
_runForcedExit = false;
|
||||
_workAvailable.Set();
|
||||
_workCompleted.WaitOne();
|
||||
SignalWorkAvailable();
|
||||
WaitWorkCompleted();
|
||||
_runContext = null;
|
||||
_runState = null;
|
||||
yieldRequested = _runYieldRequested;
|
||||
@@ -411,6 +457,28 @@ public sealed partial class DirectExecutionBackend
|
||||
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)
|
||||
{
|
||||
@@ -540,7 +608,7 @@ public sealed partial class DirectExecutionBackend
|
||||
}
|
||||
try
|
||||
{
|
||||
_workAvailable.Set();
|
||||
SignalWorkAvailable();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
@@ -575,8 +643,18 @@ public sealed partial class DirectExecutionBackend
|
||||
{
|
||||
_selfHandle.Free();
|
||||
}
|
||||
_workAvailable.Dispose();
|
||||
_workCompleted.Dispose();
|
||||
_workAvailable?.Dispose();
|
||||
_workCompleted?.Dispose();
|
||||
if (_workSemaphore != 0)
|
||||
{
|
||||
PosixHostStubs.DestroyWorkerEvent(_workSemaphore);
|
||||
_workSemaphore = 0;
|
||||
}
|
||||
if (_doneSemaphore != 0)
|
||||
{
|
||||
PosixHostStubs.DestroyWorkerEvent(_doneSemaphore);
|
||||
_doneSemaphore = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
// 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;
|
||||
|
||||
// The kernel's x86-64 sigcontext places the FXSAVE-image pointer right
|
||||
// after the general registers it hands to the handler: err(152)
|
||||
// trapno(160) oldmask(168) cr2(176) fpstate(184), all relative to
|
||||
// GetPosixRegisterBase. glibc and musl both overlay this kernel layout
|
||||
// verbatim (glibc's mcontext_t.fpregs is the same slot), so the offset
|
||||
// is libc-independent. Inside the FXSAVE image the XMM registers start
|
||||
// at +160 (32-byte header + 8 legacy x87/MMX slots x 16 bytes) - the
|
||||
// same relative position they occupy in the Win64 CONTEXT's FltSave
|
||||
// area (Win64ContextXmm0Offset = 256 + 160).
|
||||
private const int LinuxGregsFpstateOffset = 184;
|
||||
private const int FxsaveXmmOffset = 160;
|
||||
private const int XmmBlockSize = 16 * 16;
|
||||
|
||||
// Byte offsets of the general registers relative to GetPosixRegisterBase,
|
||||
// ordered to match the contiguous Win64 CONTEXT block CTX_RAX..CTX_RIP
|
||||
// (rax, rcx, rdx, rbx, rsp, rbp, rsi, rdi, r8..r15, rip). Verified
|
||||
// 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;
|
||||
|
||||
// True while the current thread's in-flight POSIX fault carries the real
|
||||
// XMM registers in the CONTEXT scratch buffer and writes to them will
|
||||
// reach the mcontext on resume. Gates recovery paths (SSE4a EXTRQ/
|
||||
// INSERTQ) that would otherwise compute results from a zeroed XMM area
|
||||
// and silently discard what they "wrote". Darwin is not bridged yet, so
|
||||
// the flag stays false there.
|
||||
[ThreadStatic]
|
||||
private static bool _posixXmmContextBridged;
|
||||
|
||||
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]));
|
||||
}
|
||||
|
||||
// Bridge the XMM registers alongside the GPRs where the layout is
|
||||
// known: on Linux the fpstate pointer and FXSAVE image are kernel
|
||||
// ABI, so recovery paths that read or write XMM state (SSE4a
|
||||
// EXTRQ/INSERTQ) see the live registers and their writes reach the
|
||||
// guest through sigreturn.
|
||||
byte* fpstate = null;
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
fpstate = *(byte**)(registers + LinuxGregsFpstateOffset);
|
||||
if (fpstate != null)
|
||||
{
|
||||
Buffer.MemoryCopy(
|
||||
fpstate + FxsaveXmmOffset,
|
||||
contextRecord + Win64ContextXmm0Offset,
|
||||
XmmBlockSize,
|
||||
XmmBlockSize);
|
||||
}
|
||||
}
|
||||
_posixXmmContextBridged = fpstate != null;
|
||||
|
||||
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);
|
||||
}
|
||||
if (fpstate != null)
|
||||
{
|
||||
Buffer.MemoryCopy(
|
||||
contextRecord + Win64ContextXmm0Offset,
|
||||
fpstate + FxsaveXmmOffset,
|
||||
XmmBlockSize,
|
||||
XmmBlockSize);
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -252,7 +252,7 @@ public static unsafe class JitStubs
|
||||
var pattern = TlsAccessPattern;
|
||||
var end = start + length - pattern.Length;
|
||||
|
||||
for (var ptr = start; ptr < end; ptr++)
|
||||
for (var ptr = start; ptr <= end; ptr++)
|
||||
{
|
||||
if (MatchesPattern(ptr, pattern))
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System;
|
||||
|
||||
namespace SharpEmu.Core.Cpu.Native;
|
||||
|
||||
/// <summary>
|
||||
/// Recognizes Sony's AMD-only SSE4a EXTRQ+blend idiom and rewrites it into an
|
||||
/// equivalent SSE4.1 sequence. SharpEmu executes guest x86-64 natively, but
|
||||
/// Rosetta 2 and Intel hosts do not implement SSE4a, so the original opcode
|
||||
/// raises #UD -> SIGILL. The compiler emits the idiom against whichever XMM
|
||||
/// register it happens to allocate (Dead Cells uses xmm1, others xmm2), so the
|
||||
/// source register is read from the ModRM r/m field rather than hard-coded.
|
||||
///
|
||||
/// The match/encode logic is deliberately free of native page-patching so it
|
||||
/// can be unit-tested against handcrafted byte sequences.
|
||||
/// </summary>
|
||||
public static class Sse4aExtrqBlendPatch
|
||||
{
|
||||
/// <summary>Length in bytes of both the matched idiom and its replacement.</summary>
|
||||
public const int SequenceLength = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Matches the 12-byte idiom, extracting the destination register D and the
|
||||
/// source (scratch) register N:
|
||||
/// <code>
|
||||
/// EXTRQ xmmN, 0x28, 0x00 ; 66 0F 78 /0 28 00 mask xmmN to low 40 bits
|
||||
/// VPBLENDD xmmD, xmmD, xmmN, 2 ; C4 E3 vvvv 02 /r 02 copy dword 1 into xmmD
|
||||
/// </code>
|
||||
/// N lives in the ModRM r/m field of both instructions; D (the blend
|
||||
/// destination and src1) lives in the VPBLENDD ModRM reg field and VEX.vvvv.
|
||||
/// Both are xmm0-xmm7 (the VEX byte1 0xE3 pins R/X/B, so no xmm8-15 extension).
|
||||
/// The compiler allocates whichever registers it likes — Dead Cells builds use
|
||||
/// D=xmm0 and D=xmm3, others differ — so both are read from the encoding.
|
||||
/// </summary>
|
||||
public static bool TryMatch(ReadOnlySpan<byte> source, out int destRegister, out int srcRegister)
|
||||
{
|
||||
destRegister = -1;
|
||||
srcRegister = -1;
|
||||
if (source.Length < SequenceLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// EXTRQ xmmN, 0x28, 0x00 : 66 0F 78, ModRM (mod=11 reg=000 rm=N), 28, 00.
|
||||
if (source[0] != 0x66 || source[1] != 0x0F || source[2] != 0x78 ||
|
||||
(source[3] & 0xF8) != 0xC0 || source[4] != 0x28 || source[5] != 0x00)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var n = source[3] & 0x07;
|
||||
|
||||
// VPBLENDD xmmD, xmmD, xmmN, 2 : C4 E3 <W=0 vvvv=~D L=0 pp=01> 02 ModRM 02.
|
||||
// VEX.byte2 fixed bits (W, L, pp) must read 0b*0000*01; vvvv encodes ~D.
|
||||
if (source[6] != 0xC4 || source[7] != 0xE3 || (source[8] & 0x87) != 0x01 ||
|
||||
source[9] != 0x02 || source[11] != 0x02)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var d = (~(source[8] >> 3)) & 0x0F;
|
||||
if (d > 7)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// ModRM: mod=11, reg=D (dest = src1), rm=N (src2 = the masked register).
|
||||
if (source[10] != (0xC0 | (d << 3) | n))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
destRegister = d;
|
||||
srcRegister = n;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the SSE4.1 equivalent into <paramref name="destination"/>:
|
||||
/// <code>
|
||||
/// PEXTRB eax, xmmN, 4 ; 66 0F 3A 14 /r 04 extract byte 4 (zero-extended)
|
||||
/// PINSRD xmmD, eax, 1 ; 66 0F 3A 22 /r 01 insert into xmmD dword lane 1
|
||||
/// </code>
|
||||
/// After EXTRQ masks xmmN to its low 40 bits, dword 1 is just byte 4
|
||||
/// zero-extended, so the two-instruction extract/insert reproduces the exact
|
||||
/// observable result the AMD idiom left in xmmD. eax is a caller-dead scratch
|
||||
/// at every site the compiler emits this idiom.
|
||||
/// </summary>
|
||||
public static bool TryEncode(int destRegister, int srcRegister, Span<byte> destination)
|
||||
{
|
||||
if ((uint)destRegister > 7 || (uint)srcRegister > 7 || destination.Length < SequenceLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// PEXTRB eax, xmmN, 4 : ModRM (mod=11 reg=N rm=000 -> eax), imm8 = byte index 4.
|
||||
destination[0] = 0x66;
|
||||
destination[1] = 0x0F;
|
||||
destination[2] = 0x3A;
|
||||
destination[3] = 0x14;
|
||||
destination[4] = (byte)(0xC0 | (srcRegister << 3));
|
||||
destination[5] = 0x04;
|
||||
|
||||
// PINSRD xmmD, eax, 1 : ModRM (mod=11 reg=D -> xmmD, rm=000 -> eax), lane 1.
|
||||
destination[6] = 0x66;
|
||||
destination[7] = 0x0F;
|
||||
destination[8] = 0x3A;
|
||||
destination[9] = 0x22;
|
||||
destination[10] = (byte)(0xC0 | (destRegister << 3));
|
||||
destination[11] = 0x01;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -40,6 +40,9 @@ public sealed class TrackedCpuMemory : ICpuMemory, ITrackedCpuMemory, IGuestMemo
|
||||
return result;
|
||||
}
|
||||
|
||||
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length) =>
|
||||
_inner.TryCopy(destinationAddress, sourceAddress, length);
|
||||
|
||||
public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address)
|
||||
{
|
||||
if (_inner is IGuestMemoryAllocator allocator)
|
||||
@@ -50,4 +53,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 SharpEmu.Core.Loader;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Core.Memory;
|
||||
|
||||
@@ -41,15 +42,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 +74,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 +88,133 @@ 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));
|
||||
return true;
|
||||
CopyToRegions(virtualAddress, source, regionIndex);
|
||||
}
|
||||
|
||||
if (GuestWriteWatch.Armed)
|
||||
{
|
||||
GuestWriteWatch.Check(virtualAddress, source);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryValidateRange(
|
||||
ulong virtualAddress,
|
||||
int length,
|
||||
ProgramHeaderFlags requiredProtection,
|
||||
out int regionIndex)
|
||||
{
|
||||
regionIndex = FindContainingRegionIndex(virtualAddress);
|
||||
if (regionIndex < 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 bool TryResolveRegion(ulong virtualAddress, int length, out MappedRegion region, out int offset)
|
||||
private int FindContainingRegionIndex(ulong virtualAddress)
|
||||
{
|
||||
foreach (var candidate in _regions)
|
||||
var insertionIndex = FindInsertionIndex(virtualAddress);
|
||||
if (insertionIndex < _regions.Count &&
|
||||
_regions[insertionIndex].Region.VirtualAddress == virtualAddress)
|
||||
{
|
||||
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 insertionIndex;
|
||||
}
|
||||
|
||||
region = default;
|
||||
offset = 0;
|
||||
return false;
|
||||
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)
|
||||
{
|
||||
@@ -70,6 +68,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
CpuEngine = cpuExecutionOptions.CpuEngine,
|
||||
StrictDynlibResolution = cpuExecutionOptions.StrictDynlibResolution,
|
||||
ImportTraceLimit = Math.Max(0, cpuExecutionOptions.ImportTraceLimit),
|
||||
DebugHook = cpuExecutionOptions.DebugHook,
|
||||
};
|
||||
_fileSystem = fileSystem ?? new PhysicalFileSystem();
|
||||
}
|
||||
@@ -81,9 +80,12 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
|
||||
CpuEngine = options.CpuEngine,
|
||||
StrictDynlibResolution = options.StrictDynlibResolution,
|
||||
ImportTraceLimit = Math.Max(0, options.ImportTraceLimit),
|
||||
DebugHook = options.DebugHook,
|
||||
};
|
||||
var moduleManager = new ModuleManager();
|
||||
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();
|
||||
@@ -131,20 +133,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, BuildInfo.CommitSha);
|
||||
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);
|
||||
@@ -167,7 +170,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);
|
||||
@@ -175,8 +178,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,
|
||||
@@ -186,8 +189,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);
|
||||
@@ -358,66 +371,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";
|
||||
@@ -432,6 +385,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);
|
||||
}
|
||||
@@ -477,6 +444,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,
|
||||
@@ -486,20 +503,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,
|
||||
@@ -508,10 +535,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;
|
||||
}
|
||||
}
|
||||
@@ -532,8 +562,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",
|
||||
@@ -572,8 +602,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,
|
||||
@@ -605,12 +635,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)
|
||||
@@ -620,28 +655,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)
|
||||
@@ -649,14 +686,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);
|
||||
@@ -681,25 +719,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,
|
||||
@@ -716,8 +781,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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -749,8 +814,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++;
|
||||
@@ -762,8 +827,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++;
|
||||
@@ -772,8 +837,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++;
|
||||
@@ -809,8 +874,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;
|
||||
@@ -904,7 +969,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))
|
||||
{
|
||||
@@ -912,15 +977,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)
|
||||
@@ -1087,6 +1164,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();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
namespace SharpEmu.Core.Runtime;
|
||||
|
||||
using SharpEmu.Core.Cpu;
|
||||
using SharpEmu.Core.Cpu.Debugging;
|
||||
|
||||
public readonly struct SharpEmuRuntimeOptions
|
||||
{
|
||||
@@ -12,4 +13,11 @@ public readonly struct SharpEmuRuntimeOptions
|
||||
public bool StrictDynlibResolution { get; init; }
|
||||
|
||||
public int ImportTraceLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// An optional debugger to attach to guest execution. Flows through to
|
||||
/// <see cref="CpuExecutionOptions.DebugHook"/>. Null (the default) runs with
|
||||
/// no debugger attached.
|
||||
/// </summary>
|
||||
public ICpuDebugHook? DebugHook { get; init; }
|
||||
}
|
||||
|
||||
@@ -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,127 +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",
|
||||
"dependencies": {
|
||||
"SharpEmu.Logging": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Net;
|
||||
|
||||
namespace SharpEmu.DebugClient;
|
||||
|
||||
/// <summary>
|
||||
/// Parses the <c>host:port</c> the client connects to. Mirrors the server's
|
||||
/// defaults (loopback, port 5714) so a bare invocation attaches to a local
|
||||
/// emulator with no arguments.
|
||||
/// </summary>
|
||||
internal static class ClientEndpoint
|
||||
{
|
||||
public const int DefaultPort = 5714;
|
||||
|
||||
public static bool TryParse(string? text, out string host, out int port, out string error)
|
||||
{
|
||||
host = "127.0.0.1";
|
||||
port = DefaultPort;
|
||||
error = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var value = text.Trim();
|
||||
var separator = value.LastIndexOf(':');
|
||||
if (separator >= 0)
|
||||
{
|
||||
var portText = value[(separator + 1)..];
|
||||
if (portText.Length > 0 && (!int.TryParse(portText, out port) || port is <= 0 or > 65535))
|
||||
{
|
||||
error = $"Invalid port '{portText}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
value = value[..separator];
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
host = string.Equals(value, "localhost", StringComparison.OrdinalIgnoreCase) ? "127.0.0.1" : value;
|
||||
}
|
||||
|
||||
if (!IPAddress.TryParse(host, out _) && !Uri.CheckHostName(host).Equals(UriHostNameType.Dns))
|
||||
{
|
||||
error = $"Invalid host '{host}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SharpEmu.DebugClient;
|
||||
|
||||
/// <summary>
|
||||
/// Turns a friendly REPL line (<c>mem 0x1000 64</c>) into the JSON request the
|
||||
/// server understands. Local-only verbs (help, quit) are reported back to the
|
||||
/// caller instead of producing a request.
|
||||
/// </summary>
|
||||
internal static class CommandTranslator
|
||||
{
|
||||
public enum ActionKind
|
||||
{
|
||||
SendRequest,
|
||||
ShowHelp,
|
||||
Quit,
|
||||
Ignore,
|
||||
Error,
|
||||
}
|
||||
|
||||
public readonly record struct Result(ActionKind Kind, string? Payload = null, string? Error = null);
|
||||
|
||||
public static Result Translate(string line)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return new Result(ActionKind.Ignore);
|
||||
}
|
||||
|
||||
var parts = trimmed.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
var verb = parts[0].ToLowerInvariant();
|
||||
switch (verb)
|
||||
{
|
||||
case "help" or "?":
|
||||
return new Result(ActionKind.ShowHelp);
|
||||
case "quit" or "exit" or "q":
|
||||
return new Result(ActionKind.Quit);
|
||||
case "raw":
|
||||
var json = trimmed[verb.Length..].Trim();
|
||||
return json.Length == 0
|
||||
? Error("raw requires a JSON object argument.")
|
||||
: Send(json);
|
||||
|
||||
case "ping":
|
||||
return Request("ping");
|
||||
case "status" or "info":
|
||||
return Request("status");
|
||||
case "state":
|
||||
return Request("state");
|
||||
case "regs" or "registers":
|
||||
return Request("registers");
|
||||
case "continue" or "cont" or "c":
|
||||
return Request("continue");
|
||||
case "step" or "s":
|
||||
return Request("step");
|
||||
case "pause" or "p":
|
||||
return Request("pause");
|
||||
case "bp" or "breakpoints" or "bl":
|
||||
return Request("list-breakpoints");
|
||||
|
||||
case "setreg":
|
||||
return parts.Length >= 3
|
||||
? Request("set-register", ("register", parts[1]), ("value", parts[2]))
|
||||
: Error("Usage: setreg <register> <value>");
|
||||
case "mem" or "read":
|
||||
return parts.Length >= 3
|
||||
? Request("read-memory", ("address", parts[1]), ("length", parts[2]))
|
||||
: Error("Usage: mem <address> <length>");
|
||||
case "write":
|
||||
return parts.Length >= 3
|
||||
? Request("write-memory", ("address", parts[1]), ("bytes", parts[2]))
|
||||
: Error("Usage: write <address> <hex-bytes>");
|
||||
case "break" or "b":
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
return Error("Usage: break <address> [kind] [length]");
|
||||
}
|
||||
|
||||
var breakArgs = new List<(string, string)> { ("address", parts[1]) };
|
||||
if (parts.Length >= 3)
|
||||
{
|
||||
breakArgs.Add(("kind", parts[2]));
|
||||
}
|
||||
|
||||
if (parts.Length >= 4)
|
||||
{
|
||||
breakArgs.Add(("length", parts[3]));
|
||||
}
|
||||
|
||||
return Request("add-breakpoint", breakArgs.ToArray());
|
||||
case "del" or "rm" or "delete":
|
||||
return parts.Length >= 2
|
||||
? Request("remove-breakpoint", ("id", parts[1]))
|
||||
: Error("Usage: del <id>");
|
||||
case "enable":
|
||||
return parts.Length >= 2
|
||||
? Request("enable-breakpoint", ("id", parts[1]), ("enabled", "true"))
|
||||
: Error("Usage: enable <id>");
|
||||
case "disable":
|
||||
return parts.Length >= 2
|
||||
? RequestWithBool("enable-breakpoint", ("id", parts[1]), enabledName: "enabled", enabled: false)
|
||||
: Error("Usage: disable <id>");
|
||||
|
||||
default:
|
||||
return Error($"Unknown command '{verb}'. Type 'help' for the command list.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Result Request(string command, params (string Name, string Value)[] args)
|
||||
{
|
||||
var payload = new Dictionary<string, object?> { ["command"] = command };
|
||||
foreach (var (name, value) in args)
|
||||
{
|
||||
payload[name] = value;
|
||||
}
|
||||
|
||||
return Send(JsonSerializer.Serialize(payload));
|
||||
}
|
||||
|
||||
private static Result RequestWithBool(string command, (string Name, string Value) idArg, string enabledName, bool enabled)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["command"] = command,
|
||||
[idArg.Name] = idArg.Value,
|
||||
[enabledName] = enabled,
|
||||
};
|
||||
return Send(JsonSerializer.Serialize(payload));
|
||||
}
|
||||
|
||||
private static Result Send(string json) => new(ActionKind.SendRequest, json);
|
||||
|
||||
private static Result Error(string message) => new(ActionKind.Error, Error: message);
|
||||
|
||||
public const string HelpText = """
|
||||
SharpEmu debug client commands:
|
||||
status | info Show target state and last stop
|
||||
state Show run state only
|
||||
regs | registers Dump integer registers (paused only)
|
||||
setreg <reg> <value> Set a register (rip/rflags/gp, paused only)
|
||||
mem <addr> <len> Read guest memory as hex (paused only)
|
||||
write <addr> <hex> Write guest memory from hex (paused only)
|
||||
break <addr> [kind] [len] Add a breakpoint (kind: execute/readwatch/writewatch/accesswatch)
|
||||
bp | breakpoints List breakpoints
|
||||
del <id> Remove a breakpoint
|
||||
enable <id> / disable <id> Toggle a breakpoint
|
||||
continue | c Resume the target
|
||||
step | s Resume and stop at the next frame
|
||||
pause Ask a running target to stop
|
||||
ping Round-trip check
|
||||
raw <json> Send a literal JSON request
|
||||
help | ? Show this help
|
||||
quit | exit Disconnect and exit
|
||||
Addresses and values accept decimal or 0x-prefixed hex.
|
||||
""";
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
# SharpEmu.DebugClient
|
||||
|
||||
A small, standalone command-line client that connects to the SharpEmu
|
||||
emulator's **live debug server** and drives it interactively. It ships as its
|
||||
own executable (`SharpEmu.DebugClient`) and takes no dependency on the emulator
|
||||
assemblies — it speaks the server's line-delimited JSON protocol directly over
|
||||
TCP, so you can also drive the server from `nc`, a script, or your own tool.
|
||||
|
||||
> **Status:** infrastructure. The transport, protocol, session model, and
|
||||
> breakpoint store are in place. Stops are delivered at **frame boundaries**
|
||||
> (process entry and each module initializer); per-instruction stepping and data
|
||||
> watchpoints are part of the surface and become live as the CPU backend grows
|
||||
> the corresponding hooks. See [`docs/debugger-server.md`](../../docs/debugger-server.md)
|
||||
> for the architecture and protocol reference.
|
||||
|
||||
## How it fits together
|
||||
|
||||
```
|
||||
+-------------------------+ TCP (JSON lines) +----------------------+
|
||||
| SharpEmu (emulator) | <------------------------------> | SharpEmu.DebugClient |
|
||||
| --debug-server | | (this executable) |
|
||||
| | | |
|
||||
| DebuggerServerHost | | REPL / --exec |
|
||||
| +- DebuggerServer | frame boundaries via ICpuDebugHook| |
|
||||
| +- DebuggerSession <-+------ CPU dispatcher ------------ | |
|
||||
+-------------------------+ +----------------------+
|
||||
```
|
||||
|
||||
The emulator is the **server**; this client is a separate process that connects
|
||||
to it and issues commands. The two never share memory — everything crosses the
|
||||
socket as JSON.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
dotnet build src/SharpEmu.DebugClient/SharpEmu.DebugClient.csproj
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Launch the emulator with the debug server enabled. It listens on
|
||||
`127.0.0.1:5714` by default and, with stop-at-entry on, parks the guest at
|
||||
its first frame until you continue:
|
||||
|
||||
```bash
|
||||
SharpEmu --debug-server "/path/to/game/eboot.bin"
|
||||
# or choose an endpoint:
|
||||
SharpEmu --debug-server=127.0.0.1:5714 "/path/to/game/eboot.bin"
|
||||
```
|
||||
|
||||
2. In another terminal, attach the client:
|
||||
|
||||
```bash
|
||||
SharpEmu.DebugClient # defaults to 127.0.0.1:5714
|
||||
SharpEmu.DebugClient 127.0.0.1:5714 # explicit endpoint
|
||||
```
|
||||
|
||||
3. Drive the target:
|
||||
|
||||
```
|
||||
status
|
||||
regs
|
||||
break 0x00000008801234a0
|
||||
continue
|
||||
mem 0x00000008802000000 64
|
||||
```
|
||||
|
||||
## Invocation
|
||||
|
||||
```
|
||||
SharpEmu.DebugClient [host:port] [--exec "<command>"]... [--quiet]
|
||||
```
|
||||
|
||||
| Option | Meaning |
|
||||
| ------------- | ------------------------------------------------------------- |
|
||||
| `host:port` | Server endpoint. Default `127.0.0.1:5714`. `localhost` is fine. |
|
||||
| `--exec, -e` | Run one command non-interactively, then exit. Repeatable. |
|
||||
| `--quiet` | Suppress the connection banner. |
|
||||
| `--help, -h` | Show usage and the command list. |
|
||||
|
||||
Non-interactive example (scriptable):
|
||||
|
||||
```bash
|
||||
SharpEmu.DebugClient --exec "break 0x8801234a0" --exec "continue"
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
Addresses and values accept decimal or `0x`-prefixed hex. Register and memory
|
||||
commands only succeed while the target is **paused**.
|
||||
|
||||
| Command | Server verb | Description |
|
||||
| ------- | ----------- | ----------- |
|
||||
| `status` \| `info` | `status` | Target state plus the last stop. |
|
||||
| `state` | `state` | Run state only (`Running`/`Paused`/…). |
|
||||
| `regs` \| `registers` | `registers` | Dump the integer registers. |
|
||||
| `setreg <reg> <value>` | `set-register` | Set `rip`, `rflags`, or a GP register. |
|
||||
| `mem <addr> <len>` \| `read <addr> <len>` | `read-memory` | Read guest memory as hex. |
|
||||
| `write <addr> <hex>` | `write-memory` | Write guest memory from a hex string. |
|
||||
| `break <addr> [kind] [len]` \| `b …` | `add-breakpoint` | Add a breakpoint. `kind`: `execute` (default), `readwatch`, `writewatch`, `accesswatch`. |
|
||||
| `bp` \| `breakpoints` | `list-breakpoints` | List breakpoints. |
|
||||
| `del <id>` \| `rm <id>` | `remove-breakpoint` | Remove a breakpoint. |
|
||||
| `enable <id>` / `disable <id>` | `enable-breakpoint` | Toggle a breakpoint. |
|
||||
| `continue` \| `c` | `continue` | Resume a paused target. |
|
||||
| `step` \| `s` | `step` | Resume and stop at the next frame boundary. |
|
||||
| `pause` | `pause` | Ask a running target to stop at the next boundary. |
|
||||
| `ping` | `ping` | Round-trip liveness check. |
|
||||
| `raw <json>` | *(passthrough)* | Send a literal JSON request. |
|
||||
| `help` \| `?` | — | Show the command list (local). |
|
||||
| `quit` \| `exit` | — | Disconnect and exit (local). |
|
||||
|
||||
## Output
|
||||
|
||||
The client prints two kinds of lines as they arrive:
|
||||
|
||||
- `reply>` — the response to a command you sent (`ok`, plus `data` or `error`).
|
||||
- `event>` — an unsolicited notification: `hello` on connect, `stopped` when the
|
||||
target hits a breakpoint / entry / step / pause, `resumed` on continue, and
|
||||
`terminated` when the run ends.
|
||||
|
||||
Because replies and events share one stream, the client prints everything it
|
||||
receives rather than pairing replies to requests — a `stopped` event may arrive
|
||||
between your command and its reply.
|
||||
|
||||
## Protocol (for building your own client)
|
||||
|
||||
One JSON object per line, UTF-8, `\n`-terminated, in both directions.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{"command":"read-memory","address":"0x8802000000","length":64}
|
||||
```
|
||||
|
||||
Reply:
|
||||
|
||||
```json
|
||||
{"ok":true,"command":"read-memory","data":{"address":"0x0000000880200000","length":64,"bytes":"48894C24.."}}
|
||||
```
|
||||
|
||||
Event:
|
||||
|
||||
```json
|
||||
{"event":"stopped","reason":"Breakpoint","address":"0x00000008801234A0","frameKind":"ProcessEntry","frameLabel":"eboot.bin","registers":{ ... }}
|
||||
```
|
||||
|
||||
The full verb list and payload fields live in
|
||||
[`docs/debugger-server.md`](../../docs/debugger-server.md).
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SharpEmu.DebugClient;
|
||||
|
||||
/// <summary>
|
||||
/// A thin TCP wrapper around the server's line-delimited JSON protocol: it
|
||||
/// writes request lines and runs a background loop that prints incoming
|
||||
/// responses and events as they arrive. Because the stream interleaves replies
|
||||
/// with asynchronous stop/resume events, a single reader printing everything is
|
||||
/// simpler and more robust than correlating request/response pairs.
|
||||
/// </summary>
|
||||
internal sealed class DebugClientConnection : IAsyncDisposable
|
||||
{
|
||||
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
|
||||
private static readonly JsonSerializerOptions PrettyOptions = new() { WriteIndented = true };
|
||||
|
||||
private readonly TcpClient _client;
|
||||
private readonly StreamReader _reader;
|
||||
private readonly StreamWriter _writer;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
|
||||
private DebugClientConnection(TcpClient client, NetworkStream stream)
|
||||
{
|
||||
_client = client;
|
||||
_reader = new StreamReader(stream, Utf8NoBom);
|
||||
_writer = new StreamWriter(stream, Utf8NoBom) { AutoFlush = false };
|
||||
}
|
||||
|
||||
public static async Task<DebugClientConnection> ConnectAsync(string host, int port, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = new TcpClient();
|
||||
await client.ConnectAsync(host, port, cancellationToken).ConfigureAwait(false);
|
||||
return new DebugClientConnection(client, client.GetStream());
|
||||
}
|
||||
|
||||
/// <summary>Continuously prints incoming lines until the stream closes.</summary>
|
||||
public async Task ReceiveLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var line = await _reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (line is null)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("[connection closed by server]");
|
||||
return;
|
||||
}
|
||||
|
||||
Print(line);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("[connection lost]");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendAsync(string json, CancellationToken cancellationToken)
|
||||
{
|
||||
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
await _writer.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static void Print(string line)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(line);
|
||||
var root = document.RootElement;
|
||||
var isEvent = root.TryGetProperty("event", out _);
|
||||
var prefix = isEvent ? "event>" : "reply>";
|
||||
var pretty = JsonSerializer.Serialize(root, PrettyOptions);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"{prefix}\n{pretty}");
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _writer.FlushAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
|
||||
_writeLock.Dispose();
|
||||
_reader.Dispose();
|
||||
await _writer.DisposeAsync().ConfigureAwait(false);
|
||||
_client.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Net.Sockets;
|
||||
using SharpEmu.DebugClient;
|
||||
|
||||
return await ClientProgram.RunAsync(args).ConfigureAwait(false);
|
||||
|
||||
internal static class ClientProgram
|
||||
{
|
||||
public static async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
if (args.Any(a => a is "--help" or "-h"))
|
||||
{
|
||||
PrintUsage();
|
||||
return 0;
|
||||
}
|
||||
|
||||
string? endpointArg = null;
|
||||
var execCommands = new List<string>();
|
||||
var quiet = false;
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var arg = args[i];
|
||||
if (string.Equals(arg, "--exec", StringComparison.OrdinalIgnoreCase) || string.Equals(arg, "-e", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (i + 1 >= args.Length)
|
||||
{
|
||||
Console.Error.WriteLine("--exec requires a command argument.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
execCommands.Add(args[++i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(arg, "--quiet", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
quiet = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.StartsWith('-'))
|
||||
{
|
||||
Console.Error.WriteLine($"Unknown option '{arg}'.");
|
||||
PrintUsage();
|
||||
return 2;
|
||||
}
|
||||
|
||||
endpointArg ??= arg;
|
||||
}
|
||||
|
||||
if (!ClientEndpoint.TryParse(endpointArg, out var host, out var port, out var endpointError))
|
||||
{
|
||||
Console.Error.WriteLine(endpointError);
|
||||
return 2;
|
||||
}
|
||||
|
||||
using var shutdown = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, eventArgs) =>
|
||||
{
|
||||
eventArgs.Cancel = true;
|
||||
shutdown.Cancel();
|
||||
};
|
||||
|
||||
DebugClientConnection connection;
|
||||
try
|
||||
{
|
||||
connection = await DebugClientConnection.ConnectAsync(host, port, shutdown.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is SocketException or OperationCanceledException)
|
||||
{
|
||||
Console.Error.WriteLine($"Could not connect to {host}:{port}: {ex.Message}");
|
||||
Console.Error.WriteLine("Start the emulator with --debug-server first.");
|
||||
return 3;
|
||||
}
|
||||
|
||||
await using (connection)
|
||||
{
|
||||
var receiveTask = connection.ReceiveLoopAsync(shutdown.Token);
|
||||
|
||||
if (execCommands.Count > 0)
|
||||
{
|
||||
await RunOneShotAsync(connection, execCommands, shutdown.Token).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!quiet)
|
||||
{
|
||||
Console.WriteLine($"Connected to SharpEmu debug server at {host}:{port}.");
|
||||
Console.WriteLine("Type 'help' for commands, 'quit' to exit.");
|
||||
}
|
||||
|
||||
await RunReplAsync(connection, shutdown).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
shutdown.Cancel();
|
||||
try
|
||||
{
|
||||
await receiveTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static async Task RunOneShotAsync(
|
||||
DebugClientConnection connection,
|
||||
IReadOnlyList<string> commands,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var command in commands)
|
||||
{
|
||||
var result = CommandTranslator.Translate(command);
|
||||
switch (result.Kind)
|
||||
{
|
||||
case CommandTranslator.ActionKind.SendRequest:
|
||||
await connection.SendAsync(result.Payload!, cancellationToken).ConfigureAwait(false);
|
||||
break;
|
||||
case CommandTranslator.ActionKind.Error:
|
||||
Console.Error.WriteLine(result.Error);
|
||||
break;
|
||||
case CommandTranslator.ActionKind.ShowHelp:
|
||||
Console.WriteLine(CommandTranslator.HelpText);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Give the server a moment to answer before the client exits.
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task RunReplAsync(DebugClientConnection connection, CancellationTokenSource shutdown)
|
||||
{
|
||||
while (!shutdown.IsCancellationRequested)
|
||||
{
|
||||
var line = await Console.In.ReadLineAsync(shutdown.Token).ConfigureAwait(false);
|
||||
if (line is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var result = CommandTranslator.Translate(line);
|
||||
switch (result.Kind)
|
||||
{
|
||||
case CommandTranslator.ActionKind.Quit:
|
||||
return;
|
||||
case CommandTranslator.ActionKind.ShowHelp:
|
||||
Console.WriteLine(CommandTranslator.HelpText);
|
||||
break;
|
||||
case CommandTranslator.ActionKind.Error:
|
||||
Console.Error.WriteLine(result.Error);
|
||||
break;
|
||||
case CommandTranslator.ActionKind.Ignore:
|
||||
break;
|
||||
case CommandTranslator.ActionKind.SendRequest:
|
||||
try
|
||||
{
|
||||
await connection.SendAsync(result.Payload!, shutdown.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
|
||||
{
|
||||
Console.Error.WriteLine("Send failed; the connection is closed.");
|
||||
return;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void PrintUsage()
|
||||
{
|
||||
Console.WriteLine("SharpEmu.DebugClient — live debugger client for the SharpEmu debug server.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Usage: SharpEmu.DebugClient [host:port] [--exec \"<command>\"]... [--quiet]");
|
||||
Console.WriteLine(" host:port Server endpoint (default 127.0.0.1:5714).");
|
||||
Console.WriteLine(" --exec, -e Run a command non-interactively (repeatable), then exit.");
|
||||
Console.WriteLine(" --quiet Suppress the connection banner.");
|
||||
Console.WriteLine(" --help, -h Show this help.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(CommandTranslator.HelpText);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!--
|
||||
Copyright (C) 2026 SharpEmu Emulator Project
|
||||
SPDX-License-Identifier: GPL-2.0-or-later
|
||||
-->
|
||||
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<!-- A small, standalone console tool: it speaks the debug server's
|
||||
line-delimited JSON protocol directly over TCP and takes no dependency
|
||||
on the emulator assemblies, so it builds and ships independently. -->
|
||||
<OutputType>Exe</OutputType>
|
||||
<AssemblyName>SharpEmu.DebugClient</AssemblyName>
|
||||
<RootNamespace>SharpEmu.DebugClient</RootNamespace>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Debugger.Breakpoints;
|
||||
|
||||
/// <summary>
|
||||
/// A single breakpoint or watchpoint. Instances are immutable; the owning
|
||||
/// <see cref="BreakpointStore"/> replaces an entry to change its enabled state.
|
||||
/// </summary>
|
||||
public sealed class Breakpoint
|
||||
{
|
||||
public Breakpoint(int id, BreakpointKind kind, ulong address, ulong length = 1, bool enabled = true)
|
||||
{
|
||||
if (length == 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(length), "Breakpoint length must be at least one byte.");
|
||||
}
|
||||
|
||||
Id = id;
|
||||
Kind = kind;
|
||||
Address = address;
|
||||
Length = length;
|
||||
Enabled = enabled;
|
||||
}
|
||||
|
||||
/// <summary>The store-assigned identifier used by clients to reference it.</summary>
|
||||
public int Id { get; }
|
||||
|
||||
public BreakpointKind Kind { get; }
|
||||
|
||||
/// <summary>The first guest address the breakpoint covers.</summary>
|
||||
public ulong Address { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The number of bytes the breakpoint covers. Always one for
|
||||
/// <see cref="BreakpointKind.Execute"/>; the watch kinds may span a range.
|
||||
/// </summary>
|
||||
public ulong Length { get; }
|
||||
|
||||
public bool Enabled { get; }
|
||||
|
||||
/// <summary>True when <paramref name="address"/> falls within this breakpoint.</summary>
|
||||
public bool Covers(ulong address) => address >= Address && address < Address + Length;
|
||||
|
||||
/// <summary>Returns a copy with a different enabled state.</summary>
|
||||
public Breakpoint WithEnabled(bool enabled)
|
||||
=> enabled == Enabled ? this : new Breakpoint(Id, Kind, Address, Length, enabled);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Debugger.Breakpoints;
|
||||
|
||||
/// <summary>
|
||||
/// The kind of stop a breakpoint requests. Execution breakpoints are honoured
|
||||
/// at the frame-boundary seam that exists today; the data-watch kinds are part
|
||||
/// of the surface so client protocols and tooling can be built against them,
|
||||
/// and are armed once the execution backend can report the corresponding
|
||||
/// accesses.
|
||||
/// </summary>
|
||||
public enum BreakpointKind
|
||||
{
|
||||
/// <summary>Stop when the instruction pointer reaches the address.</summary>
|
||||
Execute,
|
||||
|
||||
/// <summary>Stop when the guest reads from the address range.</summary>
|
||||
ReadWatch,
|
||||
|
||||
/// <summary>Stop when the guest writes to the address range.</summary>
|
||||
WriteWatch,
|
||||
|
||||
/// <summary>Stop when the guest reads from or writes to the address range.</summary>
|
||||
AccessWatch,
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Debugger.Breakpoints;
|
||||
|
||||
/// <summary>
|
||||
/// A thread-safe registry of breakpoints. The debug server mutates it from
|
||||
/// client-servicing threads while the emulation thread queries it at frame
|
||||
/// boundaries, so every operation takes the same lock.
|
||||
/// </summary>
|
||||
public sealed class BreakpointStore
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private readonly Dictionary<int, Breakpoint> _breakpoints = new();
|
||||
private int _nextId = 1;
|
||||
|
||||
/// <summary>Adds a breakpoint and returns the created entry with its id.</summary>
|
||||
public Breakpoint Add(BreakpointKind kind, ulong address, ulong length = 1)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
var effectiveLength = kind == BreakpointKind.Execute ? 1UL : Math.Max(1UL, length);
|
||||
var breakpoint = new Breakpoint(_nextId++, kind, address, effectiveLength);
|
||||
_breakpoints[breakpoint.Id] = breakpoint;
|
||||
return breakpoint;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes a breakpoint by id. Returns false when it did not exist.</summary>
|
||||
public bool Remove(int id)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _breakpoints.Remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Enables or disables a breakpoint by id.</summary>
|
||||
public bool SetEnabled(int id, bool enabled)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (!_breakpoints.TryGetValue(id, out var breakpoint))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_breakpoints[id] = breakpoint.WithEnabled(enabled);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes every breakpoint.</summary>
|
||||
public void Clear()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_breakpoints.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns a point-in-time copy of all breakpoints.</summary>
|
||||
public IReadOnlyList<Breakpoint> Snapshot()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _breakpoints.Values.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the first enabled execution breakpoint covering <paramref name="address"/>,
|
||||
/// or null when none applies.
|
||||
/// </summary>
|
||||
public Breakpoint? FindExecuteHit(ulong address)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
foreach (var breakpoint in _breakpoints.Values)
|
||||
{
|
||||
if (breakpoint.Enabled && breakpoint.Kind == BreakpointKind.Execute && breakpoint.Covers(address))
|
||||
{
|
||||
return breakpoint;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Core.Cpu.Debugging;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Debugger;
|
||||
|
||||
/// <summary>
|
||||
/// An immutable snapshot of the guest integer register state at a stop. XMM/YMM
|
||||
/// state is intentionally omitted here and read on demand through the target to
|
||||
/// keep the common register-dump path cheap.
|
||||
/// </summary>
|
||||
public readonly struct DebugRegisterFile
|
||||
{
|
||||
private readonly ulong[] _generalPurpose;
|
||||
|
||||
public DebugRegisterFile(
|
||||
ulong[] generalPurpose,
|
||||
ulong rip,
|
||||
ulong rflags,
|
||||
ulong fsBase,
|
||||
ulong gsBase)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(generalPurpose);
|
||||
if (generalPurpose.Length != 16)
|
||||
{
|
||||
throw new ArgumentException("Expected 16 general-purpose registers.", nameof(generalPurpose));
|
||||
}
|
||||
|
||||
_generalPurpose = generalPurpose;
|
||||
Rip = rip;
|
||||
Rflags = rflags;
|
||||
FsBase = fsBase;
|
||||
GsBase = gsBase;
|
||||
}
|
||||
|
||||
public ulong Rip { get; }
|
||||
|
||||
public ulong Rflags { get; }
|
||||
|
||||
public ulong FsBase { get; }
|
||||
|
||||
public ulong GsBase { get; }
|
||||
|
||||
/// <summary>Reads a register by identifier.</summary>
|
||||
public ulong this[DebugRegisterId id] => id switch
|
||||
{
|
||||
DebugRegisterId.Rip => Rip,
|
||||
DebugRegisterId.Rflags => Rflags,
|
||||
DebugRegisterId.FsBase => FsBase,
|
||||
DebugRegisterId.GsBase => GsBase,
|
||||
_ when id.IsGeneralPurpose() => _generalPurpose[(int)id],
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(id), id, null),
|
||||
};
|
||||
|
||||
/// <summary>Reads a general-purpose register.</summary>
|
||||
public ulong this[CpuRegister register] => _generalPurpose[(int)register];
|
||||
|
||||
/// <summary>Captures the integer register state of a live debug frame.</summary>
|
||||
public static DebugRegisterFile Capture(ICpuDebugFrame frame)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(frame);
|
||||
|
||||
var gpr = new ulong[16];
|
||||
for (var i = 0; i < gpr.Length; i++)
|
||||
{
|
||||
gpr[i] = frame.GetRegister((CpuRegister)i);
|
||||
}
|
||||
|
||||
return new DebugRegisterFile(gpr, frame.Rip, frame.Rflags, frame.FsBase, frame.GsBase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Debugger;
|
||||
|
||||
/// <summary>
|
||||
/// The registers a debugger can name. The first sixteen values line up with
|
||||
/// <see cref="CpuRegister"/> so a general-purpose register can be converted
|
||||
/// between the two enums by casting; the remaining values cover the special
|
||||
/// registers a debug frame exposes.
|
||||
/// </summary>
|
||||
public enum DebugRegisterId
|
||||
{
|
||||
Rax = 0,
|
||||
Rcx = 1,
|
||||
Rdx = 2,
|
||||
Rbx = 3,
|
||||
Rsp = 4,
|
||||
Rbp = 5,
|
||||
Rsi = 6,
|
||||
Rdi = 7,
|
||||
R8 = 8,
|
||||
R9 = 9,
|
||||
R10 = 10,
|
||||
R11 = 11,
|
||||
R12 = 12,
|
||||
R13 = 13,
|
||||
R14 = 14,
|
||||
R15 = 15,
|
||||
|
||||
Rip = 16,
|
||||
Rflags = 17,
|
||||
FsBase = 18,
|
||||
GsBase = 19,
|
||||
}
|
||||
|
||||
/// <summary>Helpers for mapping between debug and CPU register identifiers.</summary>
|
||||
public static class DebugRegisterIdExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// True when the identifier names one of the sixteen general-purpose
|
||||
/// registers and can be cast to <see cref="CpuRegister"/>.
|
||||
/// </summary>
|
||||
public static bool IsGeneralPurpose(this DebugRegisterId id)
|
||||
=> id is >= DebugRegisterId.Rax and <= DebugRegisterId.R15;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a general-purpose identifier to its <see cref="CpuRegister"/>.
|
||||
/// Throws when <paramref name="id"/> is a special register.
|
||||
/// </summary>
|
||||
public static CpuRegister ToCpuRegister(this DebugRegisterId id)
|
||||
{
|
||||
if (!id.IsGeneralPurpose())
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(id), id, "Not a general-purpose register.");
|
||||
}
|
||||
|
||||
return (CpuRegister)(int)id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Net;
|
||||
using SharpEmu.Core.Cpu.Debugging;
|
||||
using SharpEmu.Debugger.Server;
|
||||
using SharpEmu.Debugger.Session;
|
||||
|
||||
namespace SharpEmu.Debugger;
|
||||
|
||||
/// <summary>
|
||||
/// One-call wiring of the live debugger: it owns a <see cref="DebuggerSession"/>
|
||||
/// and a <see cref="DebuggerServer"/>, exposes the <see cref="Hook"/> to attach
|
||||
/// to <c>SharpEmuRuntimeOptions.DebugHook</c>, and starts/stops the network
|
||||
/// front-end. A host constructs one, hands <see cref="Hook"/> to the runtime,
|
||||
/// calls <see cref="Start"/>, and calls <see cref="NotifyRunCompleted"/> once the
|
||||
/// runtime returns.
|
||||
/// </summary>
|
||||
public sealed class DebuggerServerHost : IAsyncDisposable
|
||||
{
|
||||
private readonly DebuggerSession _session;
|
||||
private readonly DebuggerServer _server;
|
||||
|
||||
public DebuggerServerHost(
|
||||
DebuggerServerOptions? serverOptions = null,
|
||||
DebuggerSessionOptions? sessionOptions = null)
|
||||
{
|
||||
_session = new DebuggerSession(sessionOptions);
|
||||
_server = new DebuggerServer(_session, serverOptions);
|
||||
}
|
||||
|
||||
/// <summary>The session driving the target.</summary>
|
||||
public IDebuggerSession Session => _session;
|
||||
|
||||
/// <summary>
|
||||
/// The dispatcher hook to hand to the runtime so guest frames route through
|
||||
/// the debugger.
|
||||
/// </summary>
|
||||
public ICpuDebugHook Hook => _session.Hook;
|
||||
|
||||
/// <summary>The endpoint the server bound to, or null before <see cref="Start"/>.</summary>
|
||||
public IPEndPoint? Endpoint => _server.Endpoint;
|
||||
|
||||
/// <summary>Begins accepting debugger clients.</summary>
|
||||
public void Start() => _server.Start();
|
||||
|
||||
/// <summary>
|
||||
/// Releases a parked emulation thread and marks the target terminated. Call
|
||||
/// after the runtime's run returns so any attached client is notified and the
|
||||
/// guest thread is never left blocked in the debugger.
|
||||
/// </summary>
|
||||
public void NotifyRunCompleted() => _session.NotifyTerminated();
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_session.NotifyTerminated();
|
||||
await _server.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Debugger.Breakpoints;
|
||||
using SharpEmu.Debugger.Session;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Debugger.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Translates parsed <see cref="DebugRequest"/> verbs into operations on an
|
||||
/// <see cref="IDebuggerSession"/> and packages the outcome as a
|
||||
/// <see cref="DebugResponse"/>. This is the single place command semantics live,
|
||||
/// so it is shared by every connection and independent of the wire format.
|
||||
/// </summary>
|
||||
public sealed class DebugCommandDispatcher
|
||||
{
|
||||
private readonly IDebuggerSession _session;
|
||||
|
||||
public DebugCommandDispatcher(IDebuggerSession session)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
}
|
||||
|
||||
public DebugResponse Dispatch(DebugRequest request)
|
||||
{
|
||||
return request.Command switch
|
||||
{
|
||||
JsonLineDebugProtocol.ParseErrorCommand => ParseError(request),
|
||||
"ping" => DebugResponse.Success(request.Command),
|
||||
"status" or "info" => Status(request),
|
||||
"state" => DebugResponse.Success(request.Command, new Dictionary<string, object?>
|
||||
{
|
||||
["state"] = _session.State.ToString(),
|
||||
}),
|
||||
"registers" or "regs" => Registers(request),
|
||||
"set-register" or "set-reg" => SetRegister(request),
|
||||
"read-memory" or "read-mem" => ReadMemory(request),
|
||||
"write-memory" or "write-mem" => WriteMemory(request),
|
||||
"list-breakpoints" or "breakpoints" => ListBreakpoints(request),
|
||||
"add-breakpoint" or "break" => AddBreakpoint(request),
|
||||
"remove-breakpoint" or "delete-breakpoint" => RemoveBreakpoint(request),
|
||||
"enable-breakpoint" => EnableBreakpoint(request),
|
||||
"continue" or "cont" or "c" => Simple(request, _session.Continue(), "Target is not paused."),
|
||||
"step" or "s" => Simple(request, _session.StepFrame(), "Target is not paused."),
|
||||
"pause" => Pause(request),
|
||||
_ => DebugResponse.Failure(request.Command, $"Unknown command '{request.Command}'."),
|
||||
};
|
||||
}
|
||||
|
||||
private static DebugResponse ParseError(DebugRequest request)
|
||||
{
|
||||
var message = request.TryGetString("message", out var text) ? text : "Malformed request.";
|
||||
return DebugResponse.Failure(request.Command, message);
|
||||
}
|
||||
|
||||
private DebugResponse Status(DebugRequest request)
|
||||
{
|
||||
var data = new Dictionary<string, object?>
|
||||
{
|
||||
["state"] = _session.State.ToString(),
|
||||
["breakpoints"] = _session.Breakpoints.Snapshot().Count,
|
||||
};
|
||||
|
||||
if (_session.LastStop is { } stop)
|
||||
{
|
||||
data["lastStop"] = DescribeStop(stop);
|
||||
}
|
||||
|
||||
return DebugResponse.Success(request.Command, data);
|
||||
}
|
||||
|
||||
private DebugResponse Registers(DebugRequest request)
|
||||
{
|
||||
if (!_session.TryGetRegisters(out var registers))
|
||||
{
|
||||
return NotPaused(request);
|
||||
}
|
||||
|
||||
return DebugResponse.Success(request.Command, new Dictionary<string, object?>
|
||||
{
|
||||
["registers"] = DescribeRegisters(registers),
|
||||
});
|
||||
}
|
||||
|
||||
private DebugResponse SetRegister(DebugRequest request)
|
||||
{
|
||||
if (!request.TryGetString("register", out var name) || !TryParseRegister(name, out var id))
|
||||
{
|
||||
return DebugResponse.Failure(request.Command, "Expected a valid 'register' name.");
|
||||
}
|
||||
|
||||
if (!request.TryGetUInt64("value", out var value))
|
||||
{
|
||||
return DebugResponse.Failure(request.Command, "Expected a 'value'.");
|
||||
}
|
||||
|
||||
return _session.TrySetRegister(id, value)
|
||||
? DebugResponse.Success(request.Command)
|
||||
: DebugResponse.Failure(request.Command, "Register is not writable or target is not paused.");
|
||||
}
|
||||
|
||||
private DebugResponse ReadMemory(DebugRequest request)
|
||||
{
|
||||
if (!request.TryGetUInt64("address", out var address))
|
||||
{
|
||||
return DebugResponse.Failure(request.Command, "Expected an 'address'.");
|
||||
}
|
||||
|
||||
if (!request.TryGetInt32("length", out var length) || length <= 0 || length > MaxMemoryChunk)
|
||||
{
|
||||
return DebugResponse.Failure(request.Command, $"Expected a 'length' between 1 and {MaxMemoryChunk}.");
|
||||
}
|
||||
|
||||
var buffer = new byte[length];
|
||||
if (!_session.TryReadMemory(address, buffer))
|
||||
{
|
||||
return DebugResponse.Failure(request.Command, "Memory is unreadable or target is not paused.");
|
||||
}
|
||||
|
||||
return DebugResponse.Success(request.Command, new Dictionary<string, object?>
|
||||
{
|
||||
["address"] = FormatAddress(address),
|
||||
["length"] = length,
|
||||
["bytes"] = Convert.ToHexString(buffer),
|
||||
});
|
||||
}
|
||||
|
||||
private DebugResponse WriteMemory(DebugRequest request)
|
||||
{
|
||||
if (!request.TryGetUInt64("address", out var address))
|
||||
{
|
||||
return DebugResponse.Failure(request.Command, "Expected an 'address'.");
|
||||
}
|
||||
|
||||
if (!request.TryGetString("bytes", out var hex) || hex.Length == 0 || (hex.Length & 1) != 0)
|
||||
{
|
||||
return DebugResponse.Failure(request.Command, "Expected 'bytes' as an even-length hex string.");
|
||||
}
|
||||
|
||||
byte[] data;
|
||||
try
|
||||
{
|
||||
data = Convert.FromHexString(hex);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return DebugResponse.Failure(request.Command, "'bytes' is not valid hex.");
|
||||
}
|
||||
|
||||
if (data.Length > MaxMemoryChunk)
|
||||
{
|
||||
return DebugResponse.Failure(request.Command, $"Cannot write more than {MaxMemoryChunk} bytes at once.");
|
||||
}
|
||||
|
||||
return _session.TryWriteMemory(address, data)
|
||||
? DebugResponse.Success(request.Command, new Dictionary<string, object?> { ["written"] = data.Length })
|
||||
: DebugResponse.Failure(request.Command, "Memory is unwritable or target is not paused.");
|
||||
}
|
||||
|
||||
private DebugResponse ListBreakpoints(DebugRequest request)
|
||||
{
|
||||
var breakpoints = _session.Breakpoints.Snapshot()
|
||||
.OrderBy(breakpoint => breakpoint.Id)
|
||||
.Select(DescribeBreakpoint)
|
||||
.ToArray();
|
||||
|
||||
return DebugResponse.Success(request.Command, new Dictionary<string, object?>
|
||||
{
|
||||
["breakpoints"] = breakpoints,
|
||||
});
|
||||
}
|
||||
|
||||
private DebugResponse AddBreakpoint(DebugRequest request)
|
||||
{
|
||||
if (!request.TryGetUInt64("address", out var address))
|
||||
{
|
||||
return DebugResponse.Failure(request.Command, "Expected an 'address'.");
|
||||
}
|
||||
|
||||
var kind = BreakpointKind.Execute;
|
||||
if (request.TryGetString("kind", out var kindText) && !TryParseBreakpointKind(kindText, out kind))
|
||||
{
|
||||
return DebugResponse.Failure(request.Command, $"Unknown breakpoint kind '{kindText}'.");
|
||||
}
|
||||
|
||||
var length = 1UL;
|
||||
if (request.TryGetUInt64("length", out var requestedLength) && requestedLength > 0)
|
||||
{
|
||||
length = requestedLength;
|
||||
}
|
||||
|
||||
var breakpoint = _session.Breakpoints.Add(kind, address, length);
|
||||
return DebugResponse.Success(request.Command, new Dictionary<string, object?>
|
||||
{
|
||||
["breakpoint"] = DescribeBreakpoint(breakpoint),
|
||||
});
|
||||
}
|
||||
|
||||
private DebugResponse RemoveBreakpoint(DebugRequest request)
|
||||
{
|
||||
if (!request.TryGetInt32("id", out var id))
|
||||
{
|
||||
return DebugResponse.Failure(request.Command, "Expected an 'id'.");
|
||||
}
|
||||
|
||||
return _session.Breakpoints.Remove(id)
|
||||
? DebugResponse.Success(request.Command)
|
||||
: DebugResponse.Failure(request.Command, $"No breakpoint with id {id}.");
|
||||
}
|
||||
|
||||
private DebugResponse EnableBreakpoint(DebugRequest request)
|
||||
{
|
||||
if (!request.TryGetInt32("id", out var id))
|
||||
{
|
||||
return DebugResponse.Failure(request.Command, "Expected an 'id'.");
|
||||
}
|
||||
|
||||
var enabled = !request.TryGetBool("enabled", out var requested) || requested;
|
||||
return _session.Breakpoints.SetEnabled(id, enabled)
|
||||
? DebugResponse.Success(request.Command)
|
||||
: DebugResponse.Failure(request.Command, $"No breakpoint with id {id}.");
|
||||
}
|
||||
|
||||
private DebugResponse Pause(DebugRequest request)
|
||||
{
|
||||
_session.RequestPause();
|
||||
return DebugResponse.Success(request.Command);
|
||||
}
|
||||
|
||||
private static DebugResponse Simple(DebugRequest request, bool succeeded, string failureMessage)
|
||||
=> succeeded ? DebugResponse.Success(request.Command) : DebugResponse.Failure(request.Command, failureMessage);
|
||||
|
||||
private static DebugResponse NotPaused(DebugRequest request)
|
||||
=> DebugResponse.Failure(request.Command, "Target is not paused.");
|
||||
|
||||
internal static IReadOnlyDictionary<string, object?> DescribeStop(DebugStopEvent stop)
|
||||
{
|
||||
var data = new Dictionary<string, object?>
|
||||
{
|
||||
["reason"] = stop.Reason.ToString(),
|
||||
["address"] = FormatAddress(stop.Address),
|
||||
["frameKind"] = stop.FrameKind.ToString(),
|
||||
["frameLabel"] = stop.FrameLabel,
|
||||
["registers"] = DescribeRegisters(stop.Registers),
|
||||
};
|
||||
|
||||
if (stop.Breakpoint is { } breakpoint)
|
||||
{
|
||||
data["breakpoint"] = DescribeBreakpoint(breakpoint);
|
||||
}
|
||||
|
||||
if (stop.Result is { } result)
|
||||
{
|
||||
data["result"] = result.ToString();
|
||||
}
|
||||
|
||||
if (stop.Detail is { } detail)
|
||||
{
|
||||
data["detail"] = detail;
|
||||
}
|
||||
|
||||
if (stop.OpcodeBytes is { } opcodeBytes)
|
||||
{
|
||||
data["opcodeBytes"] = opcodeBytes;
|
||||
}
|
||||
|
||||
if (stop.StallInfo is { } stall)
|
||||
{
|
||||
data["stall"] = new Dictionary<string, object?>
|
||||
{
|
||||
["kind"] = stall.Kind.ToString(),
|
||||
["nid"] = stall.Nid,
|
||||
["instructionPointer"] = FormatAddress(stall.InstructionPointer),
|
||||
["dispatchIndex"] = stall.DispatchIndex,
|
||||
["argument0"] = FormatAddress(stall.Argument0),
|
||||
["argument1"] = FormatAddress(stall.Argument1),
|
||||
["resolved"] = stall.IsResolved,
|
||||
["library"] = stall.LibraryName,
|
||||
["function"] = stall.FunctionName,
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object?> DescribeRegisters(DebugRegisterFile registers)
|
||||
{
|
||||
var result = new Dictionary<string, object?>(20);
|
||||
for (var i = 0; i < 16; i++)
|
||||
{
|
||||
result[((CpuRegister)i).ToString().ToLowerInvariant()] = FormatAddress(registers[(CpuRegister)i]);
|
||||
}
|
||||
|
||||
result["rip"] = FormatAddress(registers.Rip);
|
||||
result["rflags"] = FormatAddress(registers.Rflags);
|
||||
result["fs_base"] = FormatAddress(registers.FsBase);
|
||||
result["gs_base"] = FormatAddress(registers.GsBase);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, object?> DescribeBreakpoint(Breakpoint breakpoint)
|
||||
=> new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = breakpoint.Id,
|
||||
["kind"] = breakpoint.Kind.ToString(),
|
||||
["address"] = FormatAddress(breakpoint.Address),
|
||||
["length"] = breakpoint.Length,
|
||||
["enabled"] = breakpoint.Enabled,
|
||||
};
|
||||
|
||||
private static string FormatAddress(ulong value) => $"0x{value:X16}";
|
||||
|
||||
private static bool TryParseRegister(string name, out DebugRegisterId id)
|
||||
{
|
||||
var normalized = name.Trim().ToLowerInvariant();
|
||||
switch (normalized)
|
||||
{
|
||||
case "rip":
|
||||
id = DebugRegisterId.Rip;
|
||||
return true;
|
||||
case "rflags":
|
||||
id = DebugRegisterId.Rflags;
|
||||
return true;
|
||||
case "fs_base" or "fsbase":
|
||||
id = DebugRegisterId.FsBase;
|
||||
return true;
|
||||
case "gs_base" or "gsbase":
|
||||
id = DebugRegisterId.GsBase;
|
||||
return true;
|
||||
}
|
||||
|
||||
return Enum.TryParse(normalized, ignoreCase: true, out id) && Enum.IsDefined(id);
|
||||
}
|
||||
|
||||
private static bool TryParseBreakpointKind(string text, out BreakpointKind kind)
|
||||
=> Enum.TryParse(text.Trim(), ignoreCase: true, out kind) && Enum.IsDefined(kind);
|
||||
|
||||
private const int MaxMemoryChunk = 64 * 1024;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SharpEmu.Debugger.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// A parsed client request: a <see cref="Command"/> verb plus a bag of named
|
||||
/// arguments backed by the original JSON. Numeric arguments accept either JSON
|
||||
/// numbers or <c>"0x"</c>-prefixed hex strings so addresses read naturally on
|
||||
/// the wire.
|
||||
/// </summary>
|
||||
public sealed class DebugRequest
|
||||
{
|
||||
private readonly JsonElement _root;
|
||||
|
||||
private DebugRequest(string command, JsonElement root)
|
||||
{
|
||||
Command = command;
|
||||
_root = root;
|
||||
}
|
||||
|
||||
/// <summary>The lower-cased command verb.</summary>
|
||||
public string Command { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Parses a single JSON object into a request. Returns false when the text is
|
||||
/// not a JSON object or is missing a string <c>command</c> field.
|
||||
/// </summary>
|
||||
public static bool TryParse(string json, out DebugRequest request, out string error)
|
||||
{
|
||||
request = null!;
|
||||
error = string.Empty;
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement.Clone();
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
error = "Request must be a JSON object.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("command", out var commandElement) ||
|
||||
commandElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
error = "Request is missing a string 'command'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var command = commandElement.GetString() ?? string.Empty;
|
||||
request = new DebugRequest(command.Trim().ToLowerInvariant(), root);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
error = $"Malformed JSON: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetString(string name, out string value)
|
||||
{
|
||||
if (_root.TryGetProperty(name, out var element) && element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
value = element.GetString() ?? string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryGetUInt64(string name, out ulong value)
|
||||
{
|
||||
value = 0;
|
||||
if (!_root.TryGetProperty(name, out var element))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Number:
|
||||
return element.TryGetUInt64(out value);
|
||||
case JsonValueKind.String:
|
||||
return TryParseNumber(element.GetString(), out value);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetInt32(string name, out int value)
|
||||
{
|
||||
value = 0;
|
||||
if (!_root.TryGetProperty(name, out var element))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Number:
|
||||
return element.TryGetInt32(out value);
|
||||
case JsonValueKind.String when TryParseNumber(element.GetString(), out var parsed) && parsed <= int.MaxValue:
|
||||
value = (int)parsed;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetBool(string name, out bool value)
|
||||
{
|
||||
value = false;
|
||||
if (!_root.TryGetProperty(name, out var element))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.True:
|
||||
value = true;
|
||||
return true;
|
||||
case JsonValueKind.False:
|
||||
value = false;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseNumber(string? text, out ulong value)
|
||||
{
|
||||
value = 0;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
text = text.Trim();
|
||||
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ulong.TryParse(text.AsSpan(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out value);
|
||||
}
|
||||
|
||||
return ulong.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Debugger.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// The reply to a <see cref="DebugRequest"/>: either success with an optional
|
||||
/// data payload, or a failure with a human-readable message.
|
||||
/// </summary>
|
||||
public sealed class DebugResponse
|
||||
{
|
||||
private DebugResponse(bool ok, string? command, IReadOnlyDictionary<string, object?>? data, string? error)
|
||||
{
|
||||
Ok = ok;
|
||||
Command = command;
|
||||
Data = data;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
public bool Ok { get; }
|
||||
|
||||
/// <summary>Echoes the command the reply answers, when known.</summary>
|
||||
public string? Command { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, object?>? Data { get; }
|
||||
|
||||
public string? Error { get; }
|
||||
|
||||
public static DebugResponse Success(string command, IReadOnlyDictionary<string, object?>? data = null)
|
||||
=> new(ok: true, command, data, error: null);
|
||||
|
||||
public static DebugResponse Failure(string command, string error)
|
||||
=> new(ok: false, command, data: null, error);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Debugger.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Frames debugger traffic on a connection. A protocol turns bytes into
|
||||
/// <see cref="DebugRequest"/> objects and serialises <see cref="DebugResponse"/>
|
||||
/// replies plus asynchronous events (stops, resumes, termination) back to the
|
||||
/// client. Swapping the implementation (line-delimited JSON today, a GDB remote
|
||||
/// serial stub later) leaves the session and server untouched.
|
||||
/// </summary>
|
||||
public interface IDebugProtocol
|
||||
{
|
||||
/// <summary>A short protocol name reported in the handshake.</summary>
|
||||
string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Reads the next request, or null at end of stream. Parse failures are
|
||||
/// surfaced as a request with a reserved error command rather than throwing.
|
||||
/// </summary>
|
||||
Task<DebugRequest?> ReadRequestAsync(TextReader reader, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Writes a reply to a request.</summary>
|
||||
Task WriteResponseAsync(TextWriter writer, DebugResponse response, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Writes an unsolicited event (for example a stop notification).</summary>
|
||||
Task WriteEventAsync(
|
||||
TextWriter writer,
|
||||
string eventName,
|
||||
IReadOnlyDictionary<string, object?> data,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SharpEmu.Debugger.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// A newline-delimited JSON protocol: one JSON object per line in each
|
||||
/// direction. Requests carry a <c>command</c>; replies carry <c>ok</c> plus
|
||||
/// <c>data</c>/<c>error</c>; events carry an <c>event</c> name. It is trivial to
|
||||
/// drive from a socket, <c>nc</c>, or a small script, which suits bring-up and
|
||||
/// tooling while a richer protocol is layered on later.
|
||||
/// </summary>
|
||||
public sealed class JsonLineDebugProtocol : IDebugProtocol
|
||||
{
|
||||
/// <summary>The command assigned to a request that failed to parse.</summary>
|
||||
public const string ParseErrorCommand = "$parse-error";
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
public string Name => "json-lines/1";
|
||||
|
||||
public async Task<DebugRequest?> ReadRequestAsync(TextReader reader, CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (line is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (DebugRequest.TryParse(line, out var request, out var error))
|
||||
{
|
||||
return request;
|
||||
}
|
||||
|
||||
// Surface the parse failure as a synthetic request so the connection
|
||||
// loop can reply with an error rather than dropping the client.
|
||||
var envelope = $"{{\"command\":\"{ParseErrorCommand}\",\"message\":{JsonSerializer.Serialize(error)}}}";
|
||||
if (DebugRequest.TryParse(envelope, out var errorRequest, out _))
|
||||
{
|
||||
return errorRequest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task WriteResponseAsync(TextWriter writer, DebugResponse response, CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>
|
||||
{
|
||||
["ok"] = response.Ok,
|
||||
};
|
||||
|
||||
if (response.Command is not null)
|
||||
{
|
||||
payload["command"] = response.Command;
|
||||
}
|
||||
|
||||
if (response.Data is not null)
|
||||
{
|
||||
payload["data"] = response.Data;
|
||||
}
|
||||
|
||||
if (response.Error is not null)
|
||||
{
|
||||
payload["error"] = response.Error;
|
||||
}
|
||||
|
||||
await WriteLineAsync(writer, payload, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task WriteEventAsync(
|
||||
TextWriter writer,
|
||||
string eventName,
|
||||
IReadOnlyDictionary<string, object?> data,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = new Dictionary<string, object?>(data.Count + 1)
|
||||
{
|
||||
["event"] = eventName,
|
||||
};
|
||||
|
||||
foreach (var (key, value) in data)
|
||||
{
|
||||
payload[key] = value;
|
||||
}
|
||||
|
||||
await WriteLineAsync(writer, payload, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task WriteLineAsync(
|
||||
TextWriter writer,
|
||||
IReadOnlyDictionary<string, object?> payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var json = JsonSerializer.Serialize(payload, SerializerOptions);
|
||||
await writer.WriteLineAsync(json.AsMemory(), cancellationToken).ConfigureAwait(false);
|
||||
await writer.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using SharpEmu.Debugger.Protocol;
|
||||
using SharpEmu.Debugger.Session;
|
||||
using SharpEmu.Logging;
|
||||
|
||||
namespace SharpEmu.Debugger.Server;
|
||||
|
||||
/// <summary>
|
||||
/// Services a single connected client: reads requests, dispatches them against
|
||||
/// the shared session, and pushes session lifecycle events. Writes from the
|
||||
/// request loop and from event callbacks are serialised through one lock so the
|
||||
/// two never interleave a half-written line.
|
||||
/// </summary>
|
||||
internal sealed class DebuggerClientConnection : IAsyncDisposable
|
||||
{
|
||||
private static readonly SharpEmuLogger Log = SharpEmuLog.For("SharpEmu.Debugger");
|
||||
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
|
||||
|
||||
private readonly TcpClient _client;
|
||||
private readonly IDebuggerSession _session;
|
||||
private readonly IDebugProtocol _protocol;
|
||||
private readonly DebugCommandDispatcher _dispatcher;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
|
||||
private TextWriter? _writer;
|
||||
private CancellationToken _cancellationToken;
|
||||
|
||||
public DebuggerClientConnection(TcpClient client, IDebuggerSession session, IDebugProtocol protocol)
|
||||
{
|
||||
_client = client;
|
||||
_session = session;
|
||||
_protocol = protocol;
|
||||
_dispatcher = new DebugCommandDispatcher(session);
|
||||
}
|
||||
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_cancellationToken = cancellationToken;
|
||||
var endpoint = _client.Client.RemoteEndPoint?.ToString() ?? "unknown";
|
||||
Log.Info($"Debugger client connected: {endpoint}");
|
||||
|
||||
using var stream = _client.GetStream();
|
||||
using var reader = new StreamReader(stream, Utf8NoBom);
|
||||
await using var writer = new StreamWriter(stream, Utf8NoBom) { AutoFlush = false };
|
||||
_writer = writer;
|
||||
|
||||
_session.Stopped += OnStopped;
|
||||
_session.Resumed += OnResumed;
|
||||
_session.Terminated += OnTerminated;
|
||||
try
|
||||
{
|
||||
await SendEventAsync("hello", new Dictionary<string, object?>
|
||||
{
|
||||
["protocol"] = _protocol.Name,
|
||||
["state"] = _session.State.ToString(),
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var request = await _protocol.ReadRequestAsync(reader, cancellationToken).ConfigureAwait(false);
|
||||
if (request is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var response = _dispatcher.Dispatch(request);
|
||||
await WriteResponseAsync(response).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Server shutting down.
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Client dropped the connection.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"Debugger client error ({endpoint}): {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_session.Stopped -= OnStopped;
|
||||
_session.Resumed -= OnResumed;
|
||||
_session.Terminated -= OnTerminated;
|
||||
_writer = null;
|
||||
Log.Info($"Debugger client disconnected: {endpoint}");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnStopped(object? sender, DebugStopEvent stop)
|
||||
=> _ = SendEventAsync("stopped", DebugCommandDispatcher.DescribeStop(stop));
|
||||
|
||||
private void OnResumed(object? sender, EventArgs e)
|
||||
=> _ = SendEventAsync("resumed", EmptyData);
|
||||
|
||||
private void OnTerminated(object? sender, EventArgs e)
|
||||
=> _ = SendEventAsync("terminated", EmptyData);
|
||||
|
||||
private async Task WriteResponseAsync(DebugResponse response)
|
||||
{
|
||||
var writer = _writer;
|
||||
if (writer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _writeLock.WaitAsync(_cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _protocol.WriteResponseAsync(writer, response, _cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendEventAsync(string name, IReadOnlyDictionary<string, object?> data)
|
||||
{
|
||||
var writer = _writer;
|
||||
if (writer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _writeLock.WaitAsync(_cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _protocol.WriteEventAsync(writer, name, data, _cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or OperationCanceledException or ObjectDisposedException)
|
||||
{
|
||||
// The client went away between the event firing and the write.
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
_writeLock.Dispose();
|
||||
_client.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, object?> EmptyData = new Dictionary<string, object?>();
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using SharpEmu.Debugger.Protocol;
|
||||
using SharpEmu.Debugger.Session;
|
||||
using SharpEmu.Logging;
|
||||
|
||||
namespace SharpEmu.Debugger.Server;
|
||||
|
||||
/// <summary>
|
||||
/// A TCP server that exposes an <see cref="IDebuggerSession"/> to remote
|
||||
/// clients over a pluggable <see cref="IDebugProtocol"/>. Every connection sees
|
||||
/// the same session, so multiple clients (for example a UI and a scripted
|
||||
/// probe) observe a consistent view of the target.
|
||||
/// </summary>
|
||||
public sealed class DebuggerServer : IDebuggerServer
|
||||
{
|
||||
private static readonly SharpEmuLogger Log = SharpEmuLog.For("SharpEmu.Debugger");
|
||||
|
||||
private readonly IDebuggerSession _session;
|
||||
private readonly DebuggerServerOptions _options;
|
||||
private readonly Func<IDebugProtocol> _protocolFactory;
|
||||
private readonly ConcurrentDictionary<DebuggerClientConnection, Task> _connections = new();
|
||||
private readonly CancellationTokenSource _shutdown = new();
|
||||
|
||||
private TcpListener? _listener;
|
||||
private Task? _acceptLoop;
|
||||
|
||||
public DebuggerServer(
|
||||
IDebuggerSession session,
|
||||
DebuggerServerOptions? options = null,
|
||||
Func<IDebugProtocol>? protocolFactory = null)
|
||||
{
|
||||
_session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
_options = options ?? new DebuggerServerOptions();
|
||||
_protocolFactory = protocolFactory ?? (static () => new JsonLineDebugProtocol());
|
||||
}
|
||||
|
||||
public bool IsListening => _listener is not null;
|
||||
|
||||
public IPEndPoint? Endpoint { get; private set; }
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (_listener is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var listener = new TcpListener(_options.BindAddress, _options.Port);
|
||||
listener.Start(_options.MaxClients);
|
||||
_listener = listener;
|
||||
Endpoint = (IPEndPoint?)listener.LocalEndpoint;
|
||||
Log.Info($"Debug server listening on {Endpoint} (protocol {_protocolFactory().Name})");
|
||||
_acceptLoop = Task.Run(() => AcceptLoopAsync(listener, _shutdown.Token));
|
||||
}
|
||||
|
||||
private async Task AcceptLoopAsync(TcpListener listener, CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
TcpClient client;
|
||||
try
|
||||
{
|
||||
client = await listener.AcceptTcpClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (SocketException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var connection = new DebuggerClientConnection(client, _session, _protocolFactory());
|
||||
var task = Task.Run(() => ServeAsync(connection, cancellationToken), cancellationToken);
|
||||
_connections[connection] = task;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ServeAsync(DebuggerClientConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await connection.RunAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connections.TryRemove(connection, out _);
|
||||
await connection.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_listener is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _shutdown.CancelAsync().ConfigureAwait(false);
|
||||
_listener.Stop();
|
||||
_listener = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (_acceptLoop is not null)
|
||||
{
|
||||
await _acceptLoop.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await Task.WhenAll(_connections.Values).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or SocketException or ObjectDisposedException)
|
||||
{
|
||||
// Expected while tearing connections down.
|
||||
}
|
||||
|
||||
_connections.Clear();
|
||||
Log.Info("Debug server stopped.");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAsync().ConfigureAwait(false);
|
||||
_shutdown.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Net;
|
||||
|
||||
namespace SharpEmu.Debugger.Server;
|
||||
|
||||
/// <summary>Network configuration for a <see cref="DebuggerServer"/>.</summary>
|
||||
public sealed class DebuggerServerOptions
|
||||
{
|
||||
/// <summary>The default TCP port the debug server listens on.</summary>
|
||||
public const int DefaultPort = 5714;
|
||||
|
||||
/// <summary>
|
||||
/// The address to bind. Defaults to loopback so the debug surface is not
|
||||
/// exposed off-box; a caller must opt in to a routable address explicitly.
|
||||
/// </summary>
|
||||
public IPAddress BindAddress { get; init; } = IPAddress.Loopback;
|
||||
|
||||
/// <summary>The TCP port to listen on.</summary>
|
||||
public int Port { get; init; } = DefaultPort;
|
||||
|
||||
/// <summary>
|
||||
/// The maximum number of simultaneous client connections. Additional
|
||||
/// connections wait in the accept backlog.
|
||||
/// </summary>
|
||||
public int MaxClients { get; init; } = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Parses a <c>host:port</c>, bare <c>port</c>, or bare host into options.
|
||||
/// Returns false when the text cannot be interpreted.
|
||||
/// </summary>
|
||||
public static bool TryParseEndpoint(string? text, out DebuggerServerOptions options, out string error)
|
||||
{
|
||||
options = new DebuggerServerOptions();
|
||||
error = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var value = text.Trim();
|
||||
var host = value;
|
||||
var port = DefaultPort;
|
||||
|
||||
var separator = value.LastIndexOf(':');
|
||||
if (separator >= 0)
|
||||
{
|
||||
var portText = value[(separator + 1)..];
|
||||
if (portText.Length > 0)
|
||||
{
|
||||
if (!int.TryParse(portText, out port) || port is <= 0 or > 65535)
|
||||
{
|
||||
error = $"Invalid port '{portText}'.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
host = value[..separator];
|
||||
}
|
||||
|
||||
var address = IPAddress.Loopback;
|
||||
if (!string.IsNullOrWhiteSpace(host) &&
|
||||
!string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) &&
|
||||
!IPAddress.TryParse(host, out address!))
|
||||
{
|
||||
error = $"Invalid bind address '{host}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
options = new DebuggerServerOptions
|
||||
{
|
||||
BindAddress = address ?? IPAddress.Loopback,
|
||||
Port = port,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Net;
|
||||
|
||||
namespace SharpEmu.Debugger.Server;
|
||||
|
||||
/// <summary>
|
||||
/// A network front-end that exposes a debugger session to remote clients.
|
||||
/// </summary>
|
||||
public interface IDebuggerServer : IAsyncDisposable
|
||||
{
|
||||
/// <summary>True once the listener is accepting connections.</summary>
|
||||
bool IsListening { get; }
|
||||
|
||||
/// <summary>The endpoint the server is bound to, or null before start.</summary>
|
||||
IPEndPoint? Endpoint { get; }
|
||||
|
||||
/// <summary>Binds and begins accepting client connections.</summary>
|
||||
void Start();
|
||||
|
||||
/// <summary>Stops accepting connections and closes active clients.</summary>
|
||||
Task StopAsync();
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Core.Cpu.Debugging;
|
||||
using SharpEmu.Debugger.Breakpoints;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Debugger.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Describes a stop delivered to debugger clients: why the target stopped,
|
||||
/// where, and the register snapshot at that point.
|
||||
/// </summary>
|
||||
public sealed class DebugStopEvent
|
||||
{
|
||||
public DebugStopEvent(
|
||||
DebugStopReason reason,
|
||||
DebugRegisterFile registers,
|
||||
CpuDebugFrameKind frameKind,
|
||||
string frameLabel,
|
||||
Breakpoint? breakpoint = null,
|
||||
OrbisGen2Result? result = null,
|
||||
string? detail = null,
|
||||
string? opcodeBytes = null,
|
||||
CpuStallInfo? stallInfo = null)
|
||||
{
|
||||
Reason = reason;
|
||||
Registers = registers;
|
||||
FrameKind = frameKind;
|
||||
FrameLabel = frameLabel ?? string.Empty;
|
||||
Breakpoint = breakpoint;
|
||||
Result = result;
|
||||
Detail = detail;
|
||||
OpcodeBytes = opcodeBytes;
|
||||
StallInfo = stallInfo;
|
||||
}
|
||||
|
||||
public DebugStopReason Reason { get; }
|
||||
|
||||
/// <summary>The instruction pointer where the target stopped.</summary>
|
||||
public ulong Address => Registers.Rip;
|
||||
|
||||
public DebugRegisterFile Registers { get; }
|
||||
|
||||
public CpuDebugFrameKind FrameKind { get; }
|
||||
|
||||
public string FrameLabel { get; }
|
||||
|
||||
/// <summary>The breakpoint responsible for the stop, when applicable.</summary>
|
||||
public Breakpoint? Breakpoint { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The frame result for a <see cref="DebugStopReason.Fault"/> stop; null for
|
||||
/// non-fault stops.
|
||||
/// </summary>
|
||||
public OrbisGen2Result? Result { get; }
|
||||
|
||||
/// <summary>A human-readable summary of a fault, when applicable.</summary>
|
||||
public string? Detail { get; }
|
||||
|
||||
/// <summary>
|
||||
/// A hex preview of the bytes at <see cref="Address"/> (the faulting
|
||||
/// instruction), when the stop is a fault and the bytes were readable.
|
||||
/// </summary>
|
||||
public string? OpcodeBytes { get; }
|
||||
|
||||
/// <summary>Structured backend evidence for a stall stop.</summary>
|
||||
public CpuStallInfo? StallInfo { get; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Debugger.Session;
|
||||
|
||||
/// <summary>Why the target stopped and handed control to the debugger.</summary>
|
||||
public enum DebugStopReason
|
||||
{
|
||||
/// <summary>Stopped at the configured entry point before running any frame.</summary>
|
||||
EntryPoint,
|
||||
|
||||
/// <summary>An execution breakpoint was hit.</summary>
|
||||
Breakpoint,
|
||||
|
||||
/// <summary>A data watchpoint was hit.</summary>
|
||||
Watchpoint,
|
||||
|
||||
/// <summary>A single-step (frame step) request completed.</summary>
|
||||
Step,
|
||||
|
||||
/// <summary>A client-requested pause took effect.</summary>
|
||||
Pause,
|
||||
|
||||
/// <summary>The guest raised a fault or trap.</summary>
|
||||
Fault,
|
||||
|
||||
/// <summary>
|
||||
/// The backend detected an execution stall (for example a mutex spin loop /
|
||||
/// livelock) with no forward progress.
|
||||
/// </summary>
|
||||
Stall,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Debugger.Session;
|
||||
|
||||
/// <summary>The execution state of a debugged target as seen by the debugger.</summary>
|
||||
public enum DebuggerRunState
|
||||
{
|
||||
/// <summary>No guest frame has entered the debugger yet.</summary>
|
||||
Detached,
|
||||
|
||||
/// <summary>The guest is executing and cannot be inspected safely.</summary>
|
||||
Running,
|
||||
|
||||
/// <summary>
|
||||
/// The guest is parked at a frame boundary. Registers and memory can be
|
||||
/// read and written, and breakpoints can be edited.
|
||||
/// </summary>
|
||||
Paused,
|
||||
|
||||
/// <summary>The guest has finished; no further frames will run.</summary>
|
||||
Terminated,
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Core.Cpu.Debugging;
|
||||
using SharpEmu.Debugger.Breakpoints;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Logging;
|
||||
|
||||
namespace SharpEmu.Debugger.Session;
|
||||
|
||||
/// <summary>
|
||||
/// The default <see cref="IDebuggerSession"/>. It plugs into the CPU dispatcher
|
||||
/// as an <see cref="ICpuDebugHook"/>: when a frame boundary warrants a stop it
|
||||
/// parks the emulation thread inside <see cref="ICpuDebugHook.OnFrameEnter"/>
|
||||
/// while a debug client inspects and edits state, then releases it on
|
||||
/// continue/step.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Pausing works by blocking the emulation thread on <see cref="_resumeGate"/>
|
||||
/// from within the hook call. Because that thread is the one that owns the guest
|
||||
/// context, register and memory accessors are safe to serve from other threads
|
||||
/// only while it is parked — which is exactly the <see cref="DebuggerRunState.Paused"/>
|
||||
/// window the accessors gate on.
|
||||
/// </remarks>
|
||||
public sealed class DebuggerSession : IDebuggerSession, ICpuDebugHook
|
||||
{
|
||||
private static readonly SharpEmuLogger Log = SharpEmuLog.For("SharpEmu.Debugger");
|
||||
|
||||
private readonly object _sync = new();
|
||||
private readonly ManualResetEventSlim _resumeGate = new(initialState: false);
|
||||
private readonly DebuggerSessionOptions _options;
|
||||
|
||||
private ICpuDebugFrame? _currentFrame;
|
||||
private DebuggerRunState _state = DebuggerRunState.Detached;
|
||||
private DebugStopEvent? _lastStop;
|
||||
private bool _seenFirstFrame;
|
||||
private bool _pausePending;
|
||||
private bool _stepPending;
|
||||
|
||||
public DebuggerSession(DebuggerSessionOptions? options = null)
|
||||
{
|
||||
_options = options ?? new DebuggerSessionOptions();
|
||||
Breakpoints = new BreakpointStore();
|
||||
}
|
||||
|
||||
public BreakpointStore Breakpoints { get; }
|
||||
|
||||
public ICpuDebugHook Hook => this;
|
||||
|
||||
public event EventHandler<DebugStopEvent>? Stopped;
|
||||
|
||||
public event EventHandler? Resumed;
|
||||
|
||||
public event EventHandler? Terminated;
|
||||
|
||||
public DebuggerRunState State
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _state;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DebugStopEvent? LastStop
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _lastStop;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ICpuDebugHook.OnFrameEnter(ICpuDebugFrame frame)
|
||||
{
|
||||
DebugStopEvent? stop;
|
||||
lock (_sync)
|
||||
{
|
||||
_currentFrame = frame;
|
||||
var firstFrame = !_seenFirstFrame;
|
||||
_seenFirstFrame = true;
|
||||
|
||||
var reason = ResolveStopReason(frame, firstFrame, out var breakpoint);
|
||||
if (reason is null)
|
||||
{
|
||||
_state = DebuggerRunState.Running;
|
||||
return;
|
||||
}
|
||||
|
||||
_state = DebuggerRunState.Paused;
|
||||
_lastStop = new DebugStopEvent(
|
||||
reason.Value,
|
||||
DebugRegisterFile.Capture(frame),
|
||||
frame.Kind,
|
||||
frame.Label,
|
||||
breakpoint);
|
||||
stop = _lastStop;
|
||||
_resumeGate.Reset();
|
||||
}
|
||||
|
||||
Log.Debug($"Debugger stop: {stop!.Reason} at 0x{stop.Address:X16} ({stop.FrameLabel})");
|
||||
Stopped?.Invoke(this, stop);
|
||||
|
||||
// Park the emulation thread until a client resumes the target. The frame
|
||||
// stays live and inspectable for the whole wait.
|
||||
_resumeGate.Wait();
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (_state != DebuggerRunState.Terminated)
|
||||
{
|
||||
_state = DebuggerRunState.Running;
|
||||
}
|
||||
}
|
||||
|
||||
Resumed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
void ICpuDebugHook.OnFrameExit(ICpuDebugFrame frame, OrbisGen2Result result)
|
||||
{
|
||||
DebugStopEvent? stop = null;
|
||||
lock (_sync)
|
||||
{
|
||||
if (_options.BreakOnFault &&
|
||||
result != OrbisGen2Result.ORBIS_GEN2_OK &&
|
||||
_state != DebuggerRunState.Terminated)
|
||||
{
|
||||
// Parking here keeps the post-fault frame inspectable.
|
||||
_currentFrame = frame;
|
||||
_state = DebuggerRunState.Paused;
|
||||
_lastStop = BuildFaultStop(frame, result);
|
||||
stop = _lastStop;
|
||||
_resumeGate.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
if (stop is not null)
|
||||
{
|
||||
Log.Debug($"Debugger fault stop: {stop.Result} at 0x{stop.Address:X16} ({stop.FrameLabel})");
|
||||
Stopped?.Invoke(this, stop);
|
||||
_resumeGate.Wait();
|
||||
Resumed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (ReferenceEquals(_currentFrame, frame))
|
||||
{
|
||||
_currentFrame = null;
|
||||
}
|
||||
|
||||
if (_state != DebuggerRunState.Terminated)
|
||||
{
|
||||
_state = DebuggerRunState.Running;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ICpuDebugHook.OnStall(ICpuDebugFrame frame, CpuStallInfo info)
|
||||
{
|
||||
if (!_options.BreakOnStall)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DebugStopEvent? stop = null;
|
||||
lock (_sync)
|
||||
{
|
||||
if (_state == DebuggerRunState.Terminated)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_currentFrame = frame;
|
||||
_state = DebuggerRunState.Paused;
|
||||
_lastStop = new DebugStopEvent(
|
||||
DebugStopReason.Stall,
|
||||
DebugRegisterFile.Capture(frame),
|
||||
frame.Kind,
|
||||
frame.Label,
|
||||
breakpoint: null,
|
||||
result: null,
|
||||
detail: info.Detail,
|
||||
opcodeBytes: ReadOpcodePreview(frame, info.InstructionPointer, 16),
|
||||
stallInfo: info);
|
||||
stop = _lastStop;
|
||||
_resumeGate.Reset();
|
||||
}
|
||||
|
||||
Log.Debug($"Debugger stall stop: {info.Kind} nid={info.Nid} at 0x{info.InstructionPointer:X16}");
|
||||
Stopped?.Invoke(this, stop);
|
||||
_resumeGate.Wait();
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (ReferenceEquals(_currentFrame, frame))
|
||||
{
|
||||
_currentFrame = null;
|
||||
}
|
||||
|
||||
if (_state != DebuggerRunState.Terminated)
|
||||
{
|
||||
_state = DebuggerRunState.Running;
|
||||
}
|
||||
}
|
||||
|
||||
Resumed?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private static DebugStopEvent BuildFaultStop(ICpuDebugFrame frame, OrbisGen2Result result)
|
||||
{
|
||||
var opcodeBytes = ReadOpcodePreview(frame, frame.Rip, 16);
|
||||
var detail = $"result={result}";
|
||||
if (opcodeBytes is not null)
|
||||
{
|
||||
detail += $", bytes={opcodeBytes}";
|
||||
}
|
||||
|
||||
return new DebugStopEvent(
|
||||
DebugStopReason.Fault,
|
||||
DebugRegisterFile.Capture(frame),
|
||||
frame.Kind,
|
||||
frame.Label,
|
||||
breakpoint: null,
|
||||
result: result,
|
||||
detail: detail,
|
||||
opcodeBytes: opcodeBytes);
|
||||
}
|
||||
|
||||
private static string? ReadOpcodePreview(ICpuDebugFrame frame, ulong address, int maxBytes)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[maxBytes];
|
||||
var count = 0;
|
||||
for (; count < maxBytes; count++)
|
||||
{
|
||||
if (!frame.Memory.TryRead(address + (ulong)count, buffer.Slice(count, 1)))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return count == 0 ? null : Convert.ToHexString(buffer[..count]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signals that the whole guest run has finished. Releases any parked
|
||||
/// emulation thread and moves the session to
|
||||
/// <see cref="DebuggerRunState.Terminated"/>.
|
||||
/// </summary>
|
||||
public void NotifyTerminated()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_state = DebuggerRunState.Terminated;
|
||||
_currentFrame = null;
|
||||
}
|
||||
|
||||
_resumeGate.Set();
|
||||
Terminated?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private DebugStopReason? ResolveStopReason(ICpuDebugFrame frame, bool firstFrame, out Breakpoint? breakpoint)
|
||||
{
|
||||
breakpoint = null;
|
||||
if (_pausePending)
|
||||
{
|
||||
_pausePending = false;
|
||||
return DebugStopReason.Pause;
|
||||
}
|
||||
|
||||
if (_stepPending)
|
||||
{
|
||||
_stepPending = false;
|
||||
return DebugStopReason.Step;
|
||||
}
|
||||
|
||||
var hit = Breakpoints.FindExecuteHit(frame.EntryPoint);
|
||||
if (hit is not null)
|
||||
{
|
||||
breakpoint = hit;
|
||||
return DebugStopReason.Breakpoint;
|
||||
}
|
||||
|
||||
if (_options.StopAtEntry && firstFrame)
|
||||
{
|
||||
return DebugStopReason.EntryPoint;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public bool TryGetRegisters(out DebugRegisterFile registers)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (!IsPausedWithFrame(out var frame))
|
||||
{
|
||||
registers = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
registers = DebugRegisterFile.Capture(frame);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TrySetRegister(DebugRegisterId id, ulong value)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (!IsPausedWithFrame(out var frame))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (id.IsGeneralPurpose())
|
||||
{
|
||||
frame.SetRegister(id.ToCpuRegister(), value);
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (id)
|
||||
{
|
||||
case DebugRegisterId.Rip:
|
||||
frame.Rip = value;
|
||||
return true;
|
||||
case DebugRegisterId.Rflags:
|
||||
frame.Rflags = value;
|
||||
return true;
|
||||
default:
|
||||
// FS/GS bases are owned by the TLS setup and are read-only here.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryReadMemory(ulong address, Span<byte> destination)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return IsPausedWithFrame(out var frame) && frame.Memory.TryRead(address, destination);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryWriteMemory(ulong address, ReadOnlySpan<byte> source)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return IsPausedWithFrame(out var frame) && frame.Memory.TryWrite(address, source);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryReadXmm(int registerIndex, out ulong low, out ulong high)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (!IsPausedWithFrame(out var frame) || (uint)registerIndex >= 16)
|
||||
{
|
||||
low = 0;
|
||||
high = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
frame.GetXmm(registerIndex, out low, out high);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Continue()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_state != DebuggerRunState.Paused)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_resumeGate.Set();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool StepFrame()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_state != DebuggerRunState.Paused)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_stepPending = true;
|
||||
_resumeGate.Set();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void RequestPause()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
if (_state == DebuggerRunState.Running)
|
||||
{
|
||||
_pausePending = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsPausedWithFrame(out ICpuDebugFrame frame)
|
||||
{
|
||||
// Callers must hold _sync.
|
||||
if (_state == DebuggerRunState.Paused && _currentFrame is not null)
|
||||
{
|
||||
frame = _currentFrame;
|
||||
return true;
|
||||
}
|
||||
|
||||
frame = null!;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Debugger.Session;
|
||||
|
||||
/// <summary>Configuration for a <see cref="DebuggerSession"/>.</summary>
|
||||
public sealed class DebuggerSessionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// When true, the session pauses at the first frame it observes so a client
|
||||
/// can attach breakpoints before the guest runs. Defaults to true, matching
|
||||
/// the "stop at entry" behaviour most debuggers expose.
|
||||
/// </summary>
|
||||
public bool StopAtEntry { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the session pauses when a frame ends with a non-OK result (a
|
||||
/// CPU trap, memory fault, or unimplemented path) so a client can inspect the
|
||||
/// post-fault register/memory state before the frame is torn down. Defaults
|
||||
/// to true. The stop reports <see cref="DebugStopReason.Fault"/>.
|
||||
/// </summary>
|
||||
public bool BreakOnFault { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the session pauses when the backend detects an execution stall
|
||||
/// (a mutex spin loop / livelock) before the guest is forced out of the loop,
|
||||
/// so a client can inspect the stalled state. Defaults to true. The stop
|
||||
/// reports <see cref="DebugStopReason.Stall"/>.
|
||||
/// </summary>
|
||||
public bool BreakOnStall { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
namespace SharpEmu.Debugger.Session;
|
||||
|
||||
/// <summary>
|
||||
/// The inspection and control surface a debugger front-end (for example a
|
||||
/// network server) drives. Register and memory accessors succeed only while the
|
||||
/// target is <see cref="DebuggerRunState.Paused"/>; they return <c>false</c>
|
||||
/// otherwise so callers never read torn state from a running guest.
|
||||
/// </summary>
|
||||
public interface IDebugTarget
|
||||
{
|
||||
/// <summary>The current execution state.</summary>
|
||||
DebuggerRunState State { get; }
|
||||
|
||||
/// <summary>The most recent stop, or null if the target has not stopped yet.</summary>
|
||||
DebugStopEvent? LastStop { get; }
|
||||
|
||||
/// <summary>Reads the integer register file. Fails unless paused.</summary>
|
||||
bool TryGetRegisters(out DebugRegisterFile registers);
|
||||
|
||||
/// <summary>Writes a single register. Fails unless paused.</summary>
|
||||
bool TrySetRegister(DebugRegisterId id, ulong value);
|
||||
|
||||
/// <summary>Reads guest memory into <paramref name="destination"/>. Fails unless paused.</summary>
|
||||
bool TryReadMemory(ulong address, Span<byte> destination);
|
||||
|
||||
/// <summary>Writes guest memory from <paramref name="source"/>. Fails unless paused.</summary>
|
||||
bool TryWriteMemory(ulong address, ReadOnlySpan<byte> source);
|
||||
|
||||
/// <summary>Reads a 128-bit XMM register. Fails unless paused.</summary>
|
||||
bool TryReadXmm(int registerIndex, out ulong low, out ulong high);
|
||||
|
||||
/// <summary>
|
||||
/// Resumes a paused target. Returns false when the target was not paused.
|
||||
/// </summary>
|
||||
bool Continue();
|
||||
|
||||
/// <summary>
|
||||
/// Resumes a paused target and stops again at the next frame boundary.
|
||||
/// Returns false when the target was not paused.
|
||||
/// </summary>
|
||||
bool StepFrame();
|
||||
|
||||
/// <summary>
|
||||
/// Requests that a running target stop at the next frame boundary. Has no
|
||||
/// effect if the target is already paused or terminated.
|
||||
/// </summary>
|
||||
void RequestPause();
|
||||
}
|
||||