Compare commits

..

23 Commits

Author SHA1 Message Date
ParantezTech f63204df75 [shader_recompiler] Fix guest image byte count calculation for Vulkan video presenter 2026-07-18 15:56:21 +03:00
kadu04t 5309f384cf Reject undefined numeric LogLevel values (#390) 2026-07-18 15:47:19 +03:00
Mehmed Sinan Kömek e6be48a390 GUI: harden cross-platform updater integrity and rollback (#389)
* GUI: verify updater releases by commit and SHA-256

* GUI: add updater rollback and version safeguards
2026-07-18 14:41:59 +03:00
Raiyan b3e3fe5ea8 docs: note Windows on ARM runs the x64 build via emulation (#386)
Mirror the existing Rosetta 2 note for Apple Silicon: Windows on ARM
devices (e.g. Snapdragon) can run the Windows x64 build through Windows'
built-in x64 emulation.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 14:06:19 +03:00
Dmitriy c9e2a1a390 Update Russian translations (#387) 2026-07-18 13:52:08 +03:00
Granthik 01b80fe381 UPDATE: Harden bink2 bridge (#385)
fix stride overflow, check decode return values, null movie on open failure
2026-07-18 13:49:59 +03:00
999sian f84d869795 [Kernel] Match path cache comparisons to host filesystem case sensitivity (#381)
The negative-stat cache and the apr file-size cache memoize host
filesystem probe outcomes, but both were keyed with an ignore-case
comparer while the probes themselves (File.Exists/Directory.Exists/
FileInfo) are case-sensitive on Linux. That aliases distinct paths:

- stat("/app0/DATA.BIN") fails, the miss is cached, and a later
  stat("/app0/Data.bin") is answered NOT_FOUND from the cache without
  ever probing the disk - even though the file exists and the probe
  would succeed.
- sceKernelAprResolveFilepathsToIdsAndFileSizes serves the cached size
  of a case-distinct sibling file instead of the file's own size.

The registered-mount containment guard had the inverse problem: the
ignore-case StartsWith accepted a ".." path that resolves into a
sibling directory differing from the mount root only by case
("…/Save" vs "…/save"), letting guest I/O escape the mount.

All three sites now compare with the host filesystem's semantics:
ordinal-ignore-case on Windows, ordinal elsewhere. Windows behavior is
unchanged. Tests probe actual host filesystem behavior with real temp
files and skip their case-specific sections on case-insensitive hosts.
2026-07-18 13:36:01 +03:00
Spooks 13269797bf Add live debugger frontend and mutex stall recovery (#383) 2026-07-17 22:41:07 -06:00
jimmyjumbo 1c8cdd6537 [VideoOut] Initialize output options storage (#315)
* [VideoOut] Initialize output options storage

* [VideoOut] Keep output options size local
2026-07-18 04:14:27 +03:00
Peter Bonanni b566444df3 Add elapsed time to performance overlay (#250) 2026-07-18 03:35:56 +03:00
Raiyan 8a6f4f7826 [GUI] Per-game launch settings + shared SettingRow (#2) (#378)
Add per-game launch overrides (log level, import-trace limit, strict dynlib
resolution, log-to-file, and SHARPEMU_* environment toggles) with three-tier
resolution (per-game override -> global preference -> built-in default), stored
one file per game at user/custom_configs/<titleId>.json. Editable from a new
"Game settings..." context-menu dialog.

Introduce a shared SettingRow control and adopt it across the Options page and
the per-game dialog so the two read as one app. Fully localized (reusing the
existing Options.* keys), with the actions pinned in the dialog footer.
2026-07-18 03:19:37 +03:00
Peter Bonanni 41c9b44a8a [AJM] Track registered codec instance lifecycle (#352) 2026-07-18 03:02:19 +03:00
Mees van den Kieboom 743fe5cc26 [ShaderCompiler/Vulkan] Match vertex input numeric types (#351)
Declare UINT and SINT vertex attributes with integer SPIR-V component types so shader interfaces match the Vulkan pipeline formats. Keep normalized, scaled, and floating-point formats on float inputs.

Signed-off-by: missatjuhvdk1 <177474143+missatjuhvdk1@users.noreply.github.com>
Co-authored-by: missatjuhvdk1 <177474143+missatjuhvdk1@users.noreply.github.com>
2026-07-18 03:01:01 +03:00
Peter Bonanni 0b1dea43e8 [CPU] Preserve blocked leaf import waiters (#350) 2026-07-18 02:59:13 +03:00
lilp c9d018db8e Vulkan: fix guest storage image and render-state handling (#332) 2026-07-18 02:54:52 +03:00
Chris b479dc0466 videoout: implement output support query (#269)
Co-authored-by: Chris Cheng <chris@appxtream.com>
2026-07-18 02:44:23 +03:00
Peter Bonanni 22bbb4e909 [AvPlayer] Resolve guest media within app0 (#347)
Handle project-relative file URIs through the guest app0 mount, including unambiguous case-insensitive lookup for case-sensitive hosts.

Reject host paths, traversal underflow, malformed or remote URIs, and symlink/reparse escapes; cover accepted app0 forms and sandbox boundaries with nonparallel tests.
2026-07-18 02:42:30 +03:00
Peter Bonanni 3c500d2cf0 [SystemService] Write notice skip flag as byte (#346)
The Gen5 caller supplies a one-byte flag. Preserve pointer and memory-fault behavior while writing only that byte, and cover a seeded guest-memory boundary that rejects the former four-byte write.
2026-07-18 02:41:38 +03:00
Peter Bonanni bcb0ebd991 [ShaderCompiler] Fix VReadlane scalar destination field (#344)
V_READLANE uses the gfx10 VOP3A vdst byte even though its result is scalar. Decode bits 0-7 and cover the public LLVM s5 and s101 encodings so the VOP3B sdst field cannot be confused with this opcode again.
2026-07-18 02:41:07 +03:00
Mees van den Kieboom ecbb0db9be [Kernel] Preserve socket descriptors after failed connect (#343)
Keep ownership of a socket descriptor with the guest when connect fails, and route generic close calls through the socket table. Add a deterministic regression test for the failure and close sequence.

Signed-off-by: missatjuhvdk1 <177474143+missatjuhvdk1@users.noreply.github.com>
Co-authored-by: missatjuhvdk1 <177474143+missatjuhvdk1@users.noreply.github.com>
2026-07-18 02:40:11 +03:00
Jose Olguin Lagos 81633f6d5a Validate synthetic SPIR-V in CI (#335) 2026-07-18 02:39:07 +03:00
Zaid Yousef cc290f860b [Kernel] Return largest available direct-memory span (#334) 2026-07-18 02:38:39 +03:00
Berk ff3ac0bba1 chore: bump version to 0.0.2-beta.3 (#345) 2026-07-17 19:01:35 +03:00
102 changed files with 10434 additions and 625 deletions
+33
View File
@@ -159,6 +159,11 @@ jobs:
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
PUBLISH_DIR: ${{ github.workspace }}/artifacts/publish/${{ matrix.rid }}
RELEASE_DIR: ${{ github.workspace }}/artifacts/release
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
@@ -183,6 +188,34 @@ jobs:
- 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"
+2
View File
@@ -32,6 +32,8 @@ packages/
.nuget/
.dotnet-home/
.cache/
__pycache__/
*.py[cod]
.DS_Store
Thumbs.db
+3 -1
View File
@@ -27,7 +27,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
> [!NOTE]
> SharpEmu supports Windows x64, Linux x64, and macOS x64. Apple Silicon Macs
> can run the macOS x64 build through Rosetta 2.
> 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.
+2
View File
@@ -7,6 +7,8 @@ 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" />
+177
View File
@@ -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();
```
+41 -26
View File
@@ -5,47 +5,62 @@
* Build this small adapter with a licensed RAD Bink 2 SDK. The SDK and its
* headers are not distributed by SharpEmu. See docs/bink2-bridge.md.
*/
#include <stdint.h>
#include "bink.h"
typedef struct sharpemu_bink2_info {
uint32_t width;
uint32_t height;
uint32_t frames_per_second_numerator;
uint32_t frames_per_second_denominator;
uint32_t width;
uint32_t height;
uint32_t frames_per_second_numerator;
uint32_t frames_per_second_denominator;
} sharpemu_bink2_info;
int sharpemu_bink2_open_utf8(const char *path, HBINK *movie, sharpemu_bink2_info *info) {
HBINK bink;
if (!path || !movie || !info) return 0;
HBINK bink;
if (!path || !movie || !info) return 0;
bink = BinkOpen(path, 0);
if (!bink) return 0;
*movie = NULL;
*movie = bink;
info->width = bink->Width;
info->height = bink->Height;
info->frames_per_second_numerator = bink->FrameRate;
info->frames_per_second_denominator = bink->FrameRateDiv;
return 1;
bink = BinkOpen(path, 0);
if (!bink) return 0;
if (bink->Width == 0 || bink->Height == 0) {
BinkClose(bink);
return 0;
}
*movie = bink;
info->width = bink->Width;
info->height = bink->Height;
info->frames_per_second_numerator = bink->FrameRate;
info->frames_per_second_denominator = bink->FrameRateDiv;
return 1;
}
int sharpemu_bink2_decode_next_bgra(HBINK movie, uint8_t *destination,
uint32_t stride, uint32_t destination_bytes) {
uint64_t needed;
if (!movie || !destination || stride < movie->Width * 4) return 0;
needed = (uint64_t)stride * movie->Height;
if (needed > destination_bytes) return 0;
uint64_t needed;
uint64_t min_stride;
/* Async Bink I/O has not filled the next frame yet; retry on the next host present. */
if (BinkWait(movie)) return 0;
BinkDoFrame(movie);
BinkCopyToBuffer(movie, destination, stride, movie->Height, 0, 0, BINKSURFACE32RA);
BinkNextFrame(movie);
return 1;
if (!movie || !destination) return 0;
min_stride = (uint64_t)movie->Width * 4;
if ((uint64_t)stride < min_stride) return 0;
needed = (uint64_t)stride * movie->Height;
if (needed > destination_bytes) return 0;
/* Async Bink I/O has not filled the next frame yet; retry on the next host present. */
if (BinkWait(movie)) return 0;
if (!BinkDoFrame(movie)) return 0;
if (!BinkCopyToBuffer(movie, destination, stride, movie->Height, 0, 0, BINKSURFACE32RA)) return 0;
BinkNextFrame(movie);
return 1;
}
void sharpemu_bink2_close(HBINK movie) {
if (movie) BinkClose(movie);
if (movie) BinkClose(movie);
}
+55
View 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."
+79 -1
View File
@@ -262,6 +262,32 @@ 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...");
try
@@ -335,6 +361,12 @@ internal static partial class Program
}
finally
{
if (debugHost is not null)
{
debugHost.NotifyRunCompleted();
debugHost.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
HostSessionControl.SetEmbeddedHostSurface(0);
if (hostSurface is not null)
{
@@ -998,8 +1030,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(
@@ -1033,6 +1102,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;
+1
View File
@@ -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>
+20 -12
View File
@@ -216,9 +216,17 @@
"type": "Project",
"dependencies": {
"Iced": "[1.21.0, )",
"SharpEmu.HLE": "[0.0.2-beta.2, )",
"SharpEmu.Libs": "[0.0.2-beta.2, )",
"SharpEmu.Logging": "[0.0.2-beta.2, )"
"SharpEmu.HLE": "[0.0.2-beta.3, )",
"SharpEmu.Libs": "[0.0.2-beta.3, )",
"SharpEmu.Logging": "[0.0.2-beta.3, )"
}
},
"sharpemu.debugger": {
"type": "Project",
"dependencies": {
"SharpEmu.Core": "[0.0.2-beta.3, )",
"SharpEmu.HLE": "[0.0.2-beta.3, )",
"SharpEmu.Logging": "[0.0.2-beta.3, )"
}
},
"sharpemu.gui": {
@@ -228,24 +236,24 @@
"Avalonia.Desktop": "[11.3.18, )",
"Avalonia.Fonts.Inter": "[11.3.18, )",
"Avalonia.Themes.Fluent": "[11.3.18, )",
"SharpEmu.Core": "[0.0.2-beta.2, )",
"SharpEmu.Libs": "[0.0.2-beta.2, )",
"SharpEmu.Logging": "[0.0.2-beta.2, )",
"SharpEmu.Core": "[0.0.2-beta.3, )",
"SharpEmu.Libs": "[0.0.2-beta.3, )",
"SharpEmu.Logging": "[0.0.2-beta.3, )",
"Tmds.DBus.Protocol": "[0.21.3, )"
}
},
"sharpemu.hle": {
"type": "Project",
"dependencies": {
"SharpEmu.Logging": "[0.0.2-beta.2, )"
"SharpEmu.Logging": "[0.0.2-beta.3, )"
}
},
"sharpemu.libs": {
"type": "Project",
"dependencies": {
"SharpEmu.HLE": "[0.0.2-beta.2, )",
"SharpEmu.ShaderCompiler": "[0.0.2-beta.2, )",
"SharpEmu.ShaderCompiler.Vulkan": "[0.0.2-beta.2, )",
"SharpEmu.HLE": "[0.0.2-beta.3, )",
"SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )",
"SharpEmu.ShaderCompiler.Vulkan": "[0.0.2-beta.3, )",
"Silk.NET.Input": "[2.23.0, )",
"Silk.NET.Vulkan": "[2.23.0, )",
"Silk.NET.Vulkan.Extensions.EXT": "[2.23.0, )",
@@ -259,13 +267,13 @@
"sharpemu.shadercompiler": {
"type": "Project",
"dependencies": {
"SharpEmu.HLE": "[0.0.2-beta.2, )"
"SharpEmu.HLE": "[0.0.2-beta.3, )"
}
},
"sharpemu.shadercompiler.vulkan": {
"type": "Project",
"dependencies": {
"SharpEmu.ShaderCompiler": "[0.0.2-beta.2, )"
"SharpEmu.ShaderCompiler": "[0.0.2-beta.3, )"
}
},
"Avalonia": {
+20
View File
@@ -3,6 +3,7 @@
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;
@@ -272,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,
@@ -282,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
@@ -296,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;
+12 -1
View File
@@ -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);
}
@@ -10,6 +10,7 @@ using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using SharpEmu.Core.Cpu.Debugging;
using SharpEmu.Core.Cpu;
using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
@@ -316,11 +317,15 @@ public sealed partial class DirectExecutionBackend
}
if (!isGuestWorker &&
!ActiveForcedGuestExit &&
ShouldForceGuestExitOnImportLoop(in importStubEntry, num7, num, value, value2) &&
TryForceGuestExitToHostStub(argPackPtr, num, num7, importStubEntry.Nid))
ShouldForceGuestExitOnImportLoop(in importStubEntry, num7, num, value, value2))
{
cpuContext[CpuRegister.Rax] = 1uL;
return 1uL;
// Break before the forced exit so the loop state is still live.
NotifyDebuggerStall(CpuStallKind.ImportLoop, in importStubEntry, num7, num, value, value2);
if (TryForceGuestExitToHostStub(argPackPtr, num, num7, importStubEntry.Nid))
{
cpuContext[CpuRegister.Rax] = 1uL;
return 1uL;
}
}
bool flag0 = importStubEntry.SuppressStrlenTrace;
bool flag = num7 >= 2156221920u && num7 <= 2156225024u;
@@ -1345,8 +1350,7 @@ public sealed partial class DirectExecutionBackend
out var blockContinuation,
out var hasBlockContinuation,
out var blockWakeKey,
out var blockResumeHandler,
out var blockWakeHandler,
out var blockWaiter,
out var blockDeadlineTimestamp);
if (consumedThreadBlock &&
TryYieldGuestThreadToHostStub(argPackPtr, dispatchIndex, returnRip, importStubEntry.Nid, blockReason))
@@ -1357,8 +1361,7 @@ public sealed partial class DirectExecutionBackend
GuestThreadExecution.CurrentGuestThreadHandle,
blockContinuation,
blockWakeKey,
blockResumeHandler,
blockWakeHandler,
blockWaiter,
blockDeadlineTimestamp);
}
@@ -9,6 +9,7 @@ using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using SharpEmu.Core.Cpu;
using SharpEmu.Core.Cpu.Debugging;
using SharpEmu.Core.Loader;
using SharpEmu.Core.Memory;
using SharpEmu.HLE;
@@ -235,6 +236,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private CpuContext? _cpuContext;
// Debugger seam; both null when no debugger is attached.
private ICpuDebugHook? _debugHook;
private ICpuDebugFrame? _activeDebugFrame;
[ThreadStatic]
private static DirectExecutionBackend? _activeExecutionBackend;
@@ -443,10 +449,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
// Stays set through the wake transition; Resume() consumes it when the thread pumps.
public IGuestThreadBlockWaiter? BlockWaiter { get; set; }
public Func<int>? BlockResumeHandler { get; set; }
public Func<bool>? BlockWakeHandler { get; set; }
public long BlockDeadlineTimestamp { get; set; }
public long ImportCount;
@@ -894,6 +896,49 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private bool HasActiveExecutionThread => ReferenceEquals(_activeExecutionBackend, this);
/// <summary>
/// Binds the debug frame view the dispatcher created for the frame about to
/// run, so stall notifications reference the same frame the debugger saw at
/// entry. Set to null when no debugger is attached.
/// </summary>
internal void SetActiveDebugFrame(ICpuDebugFrame? frame) => _activeDebugFrame = frame;
/// <summary>
/// Notifies an attached debugger of a detected execution stall. No-op when no
/// debugger is attached or no frame is bound. The debugger may block here to
/// present a break before the backend forces the guest out of the loop.
/// </summary>
private void NotifyDebuggerStall(
CpuStallKind kind,
in ImportStubEntry import,
ulong instructionPointer,
long dispatchIndex,
ulong argument0,
ulong argument1)
{
var hook = _debugHook;
var frame = _activeDebugFrame;
if (hook is null || frame is null)
{
return;
}
var export = import.Export;
var exportDescription = export is null ? "unresolved" : $"{export.LibraryName}:{export.Name}";
var detail = $"kind={kind}, nid={import.Nid}, export={exportDescription}, dispatch#{dispatchIndex}, " +
$"rip=0x{instructionPointer:X16}, arg0=0x{argument0:X16}, arg1=0x{argument1:X16}";
hook.OnStall(frame, new CpuStallInfo(
kind,
import.Nid,
instructionPointer,
dispatchIndex,
argument0,
argument1,
detail,
export?.LibraryName,
export?.Name));
}
private CpuContext? ActiveCpuContext => HasActiveExecutionThread ? _activeCpuContext : _cpuContext;
private ulong ActiveEntryReturnSentinelRip
@@ -1055,6 +1100,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
Console.Error.WriteLine(_moduleManager.TryGetExport("L-Q3LEjIbgA", out ExportedFunction export2) ? ("[LOADER][INFO] ExportCheck map_direct: " + export2.LibraryName + ":" + export2.Name) : "[LOADER][INFO] ExportCheck map_direct: MISSING");
_entryPoint = entryPoint;
_cpuContext = context;
_debugHook = executionOptions.DebugHook;
_returnFallbackTarget = context[CpuRegister.Rsi];
Volatile.Write(ref _globalFallbackTarget, _returnFallbackTarget);
Volatile.Write(ref _globalUnresolvedReturnStub, (ulong)_unresolvedReturnStub);
@@ -3215,43 +3261,6 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
thread.HasBlockedContinuation = true;
thread.BlockWakeKey = wakeKey;
thread.BlockWaiter = waiter;
thread.BlockResumeHandler = null;
thread.BlockWakeHandler = null;
thread.BlockDeadlineTimestamp = blockDeadlineTimestamp;
TraceFocusedContinuation(
"register",
guestThreadHandle,
continuation,
wakeKey);
}
}
private void RegisterBlockedGuestThreadContinuation(
ulong guestThreadHandle,
GuestCpuContinuation continuation,
string wakeKey,
Func<int>? resumeHandler,
Func<bool>? wakeHandler,
long blockDeadlineTimestamp)
{
if (guestThreadHandle == 0 || continuation.Rip < 65536 || continuation.Rsp == 0)
{
return;
}
using (LockGate("RegisterBlockedContinuation"))
{
if (!_guestThreads.TryGetValue(guestThreadHandle, out var thread))
{
return;
}
thread.BlockedContinuation = continuation;
thread.HasBlockedContinuation = true;
thread.BlockWakeKey = wakeKey;
thread.BlockWaiter = null;
thread.BlockResumeHandler = resumeHandler;
thread.BlockWakeHandler = wakeHandler;
thread.BlockDeadlineTimestamp = blockDeadlineTimestamp;
TraceFocusedContinuation(
"register",
@@ -3567,11 +3576,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
owner.State = GuestThreadRunState.Blocked;
owner.BlockReason = callbackReason ?? reason;
if (owner.BlockWakeHandler is not null && owner.BlockWakeHandler())
if (owner.BlockWaiter is not null && owner.BlockWaiter.TryWake())
{
owner.State = GuestThreadRunState.Ready;
owner.BlockReason = null;
owner.BlockWakeHandler = null;
owner.BlockDeadlineTimestamp = 0;
}
}
@@ -3583,7 +3591,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
}
GuestCpuContinuation continuation = default;
Func<int>? resumeHandler = null;
IGuestThreadBlockWaiter? blockWaiter = null;
while (!ActiveForcedGuestExit)
{
WakeExpiredBlockedGuestThreads();
@@ -3604,9 +3612,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
owner.BlockedContinuation = default;
owner.HasBlockedContinuation = false;
owner.BlockWakeKey = null;
resumeHandler = owner.BlockResumeHandler;
owner.BlockResumeHandler = null;
owner.BlockWakeHandler = null;
blockWaiter = owner.BlockWaiter;
owner.BlockWaiter = null;
owner.BlockDeadlineTimestamp = 0;
owner.BlockReason = null;
owner.State = GuestThreadRunState.Running;
@@ -3629,9 +3636,9 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return false;
}
if (resumeHandler is not null)
if (blockWaiter is not null)
{
continuation = continuation with { Rax = unchecked((ulong)(long)resumeHandler()) };
continuation = continuation with { Rax = unchecked((ulong)(long)blockWaiter.Resume()) };
}
if (_logGuestThreads)
{
@@ -3844,8 +3851,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
bool savedHasBlockedContinuation;
GuestCpuContinuation savedBlockedContinuation;
string? savedBlockWakeKey;
Func<int>? savedBlockResumeHandler;
Func<bool>? savedBlockWakeHandler;
IGuestThreadBlockWaiter? savedBlockWaiter;
long savedBlockDeadlineTimestamp;
ulong exceptionStackBase;
lock (_guestThreadGate)
@@ -3981,8 +3987,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
savedHasBlockedContinuation = target.HasBlockedContinuation;
savedBlockedContinuation = target.BlockedContinuation;
savedBlockWakeKey = target.BlockWakeKey;
savedBlockResumeHandler = target.BlockResumeHandler;
savedBlockWakeHandler = target.BlockWakeHandler;
savedBlockWaiter = target.BlockWaiter;
savedBlockDeadlineTimestamp = target.BlockDeadlineTimestamp;
target.State = GuestThreadRunState.Running;
@@ -3992,8 +3997,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
target.HasBlockedContinuation = false;
target.BlockedContinuation = default;
target.BlockWakeKey = null;
target.BlockResumeHandler = null;
target.BlockWakeHandler = null;
target.BlockWaiter = null;
target.BlockDeadlineTimestamp = 0;
}
@@ -4041,8 +4045,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
target.HasBlockedContinuation = savedHasBlockedContinuation;
target.BlockedContinuation = savedBlockedContinuation;
target.BlockWakeKey = savedBlockWakeKey;
target.BlockResumeHandler = savedBlockResumeHandler;
target.BlockWakeHandler = savedBlockWakeHandler;
target.BlockWaiter = savedBlockWaiter;
target.BlockDeadlineTimestamp = savedBlockDeadlineTimestamp;
// A condition/event wake can arrive while the parked thread is
@@ -4052,12 +4055,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
// pthread wait remains parked forever after a GC suspension races it.
if (target.State == GuestThreadRunState.Blocked &&
target.HasBlockedContinuation &&
target.BlockWakeHandler is not null &&
target.BlockWakeHandler())
target.BlockWaiter is not null &&
target.BlockWaiter.TryWake())
{
target.State = GuestThreadRunState.Ready;
target.BlockReason = null;
target.BlockWakeHandler = null;
target.BlockDeadlineTimestamp = 0;
_readyGuestThreads.Enqueue(target);
Interlocked.Increment(ref _readyGuestThreadCount);
@@ -68,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();
}
@@ -79,6 +80,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
CpuEngine = options.CpuEngine,
StrictDynlibResolution = options.StrictDynlibResolution,
ImportTraceLimit = Math.Max(0, options.ImportTraceLimit),
DebugHook = options.DebugHook,
};
var moduleManager = new ModuleManager();
// The compile-time generated registry (SharpEmu.SourceGenerators) is the sole
@@ -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; }
}
@@ -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.
""";
}
+153
View File
@@ -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();
}
}
+193
View File
@@ -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);
}
}
+62
View File
@@ -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();
}
@@ -0,0 +1,43 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Cpu.Debugging;
using SharpEmu.Debugger.Breakpoints;
namespace SharpEmu.Debugger.Session;
/// <summary>
/// The debugger's coordination point. It bridges the CPU dispatcher seam
/// (<see cref="Hook"/>) to the inspection surface (<see cref="IDebugTarget"/>),
/// owns breakpoint state, and raises lifecycle events that a server relays to
/// connected clients.
/// </summary>
public interface IDebuggerSession : IDebugTarget
{
/// <summary>The breakpoints armed for this session.</summary>
BreakpointStore Breakpoints { get; }
/// <summary>
/// The dispatcher-facing hook. Assign this to
/// <c>SharpEmuRuntimeOptions.DebugHook</c> so guest frames are routed through
/// the session.
/// </summary>
ICpuDebugHook Hook { get; }
/// <summary>Raised on the emulation thread each time the target stops.</summary>
event EventHandler<DebugStopEvent>? Stopped;
/// <summary>Raised when a paused target resumes.</summary>
event EventHandler? Resumed;
/// <summary>Raised once the target has terminated.</summary>
event EventHandler? Terminated;
/// <summary>
/// Signals that the guest run has finished. Releases any parked emulation
/// thread and transitions the session to
/// <see cref="DebuggerRunState.Terminated"/>. Hosts call this after the
/// runtime returns.
/// </summary>
void NotifyTerminated();
}
@@ -0,0 +1,16 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\SharpEmu.Core\SharpEmu.Core.csproj" />
<ProjectReference Include="..\SharpEmu.HLE\SharpEmu.HLE.csproj" />
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
</ItemGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
</Project>
+24
View File
@@ -5,6 +5,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SharpEmu.GUI"
x:Class="SharpEmu.GUI.App"
RequestedThemeVariant="Dark">
@@ -32,6 +33,29 @@ SPDX-License-Identifier: GPL-2.0-or-later
<SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" />
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
<ControlTheme x:Key="{x:Type local:SettingRow}" TargetType="local:SettingRow">
<Setter Property="Template">
<ControlTemplate>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="PART_Label" Text="{TemplateBinding Label}" FontSize="13" />
<TextBlock Text="{TemplateBinding Description}" FontSize="11"
Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap"
IsVisible="{Binding Description, RelativeSource={RelativeSource TemplatedParent},
Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
<ToggleSwitch OnContent="Override" OffContent="Override" MinWidth="0"
VerticalAlignment="Center"
IsVisible="{TemplateBinding ShowOverride}"
IsChecked="{Binding IsOverridden, RelativeSource={RelativeSource TemplatedParent}, Mode=TwoWay}" />
<ContentPresenter x:Name="PART_Slot" Content="{TemplateBinding Content}" VerticalAlignment="Center" />
</StackPanel>
</Grid>
</ControlTemplate>
</Setter>
</ControlTheme>
</Application.Resources>
<Application.Styles>
+9
View File
@@ -15,6 +15,7 @@
"Library.Context.OpenFolder": "Open game folder",
"Library.Context.CopyPath": "Copy path",
"Library.Context.CopyTitleId": "Copy title ID",
"Library.Context.GameSettings": "Game settings…",
"Library.Context.Remove": "Remove from library",
"Library.Empty.Title": "Your library is empty",
@@ -81,6 +82,13 @@
"Common.On": "On",
"Common.Off": "Off",
"Common.Save": "Save",
"Common.Cancel": "Cancel",
"PerGame.Title": "Per-game settings — {0} ({1})",
"PerGame.InheritNote": "Unchecked rows inherit the global defaults.",
"PerGame.EnvToggles.Label": "Environment toggles",
"PerGame.EnvToggles.Desc": "Override the global set of SHARPEMU_* switches for this game.",
"Console.Title": "CONSOLE",
"Console.SearchWatermark": "Search...",
@@ -161,5 +169,6 @@
"Updater.Status.Installing": "Installing update…",
"Updater.Status.Timeout": "Update check timed out after 10 seconds.",
"Updater.Status.Failed": "Could not check for updates.",
"Updater.Status.ChecksumFailed": "Downloaded update failed SHA-256 verification.",
"Updater.Status.Unsupported": "Automatic updating requires a Windows, Linux or macOS x64 build."
}
+45 -1
View File
@@ -15,6 +15,7 @@
"Library.Context.OpenFolder": "Открыть папку с игрой",
"Library.Context.CopyPath": "Скопировать путь",
"Library.Context.CopyTitleId": "Скопировать ID игры",
"Library.Context.GameSettings": "Настройки игры…",
"Library.Context.Remove": "Удалить из библиотеки",
"Library.Empty.Title": "Ваша библиотека пуста",
@@ -26,6 +27,17 @@
"Library.Loading": "Загрузка библиотеки…",
"Options.General": "Основные",
"Options.Env.Tab": "Окружение",
"Options.Section.Environment": "ПЕРЕМЕННЫЕ ОКРУЖЕНИЯ",
"Options.Env.Desc": "Параметры, передаваемые эмулятору как переменные окружения при запуске.",
"Options.Env.Bthid.Desc": "Сообщать об отсутствии Bluetooth HID для игр, чьи библиотеки руля и обратной связи опрашивают устройство бесконечно.\nОбычно оставляйте выключенным. Некоторые игры зависают при сбое инициализации.",
"Options.Env.LoopGuard.Desc": "Не завершать принудительно игры, которые слишком долго повторяют один и тот же вызов.\nПопробуйте этот параметр, если игра сама закрывается во время загрузки.",
"Options.Env.WritableApp0.Desc": "Разрешить играм создавать и записывать файлы в папке установки.\nТребуется для неупакованных дампов, которые сохраняют данные или настройки в /app0.",
"Options.Env.VkValidation.Desc": "Включить слои валидации Vulkan для отладки GPU.\nЗамедляет работу. Требуется установленный Vulkan SDK.",
"Options.Env.DumpSpirv.Desc": "Сохранять шейдеры AGC и их переводы в SPIR-V в папку shader-dumps.\nИспользуйте при сообщении об ошибках шейдеров или рендеринга.",
"Options.Env.LogDirectMemory.Desc": "Выводить в консоль выделения прямой памяти и ошибки выделения.\nИспользуйте, если игра аварийно завершает работу или закрывается при запуске.",
"Options.Env.LogIo.Desc": "Выводить в консоль операции открытия и чтения файлов, а также разрешение путей.\nИспользуйте, если игра не может найти файлы данных при запуске.",
"Options.Env.LogNp.Desc": "Выводить в консоль вызовы библиотеки NP (PlayStation Network).",
"Options.Section.Emulation": "ЭМУЛЯЦИЯ",
"Options.Section.Logging": "ЛОГГИРОВАНИЕ",
"Options.Section.Launcher": "ЛАУНЧЕР",
@@ -70,6 +82,13 @@
"Common.On": "Включено",
"Common.Off": "Выключено",
"Common.Save": "Сохранить",
"Common.Cancel": "Отмена",
"PerGame.Title": "Настройки игры — {0} ({1})",
"PerGame.InheritNote": "Неотмеченные строки наследуют глобальные настройки.",
"PerGame.EnvToggles.Label": "Переключатели окружения",
"PerGame.EnvToggles.Desc": "Переопределить глобальный набор переключателей SHARPEMU_* для этой игры.",
"Console.Title": "КОНСОЛЬ",
"Console.SearchWatermark": "Поиск...",
@@ -125,5 +144,30 @@
"Dialog.PsExecutables": "Исполняемые файлы PS",
"Dialog.SaveLogFile": "Выберите, куда сохранить файл с логами",
"Dialog.PlainTextFiles": "Текстовые файлы",
"Dialog.LogFiles": "Логи"
"Dialog.LogFiles": "Логи",
"Options.About": "О программе",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Исходный код, отчёты об ошибках и разработка проекта.",
"About.Github.LatestCommitLabel": "Последний коммит",
"About.Github.LatestCommitDescription": "Последний коммит в основной ветке",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Присоединяйтесь к сообществу, получайте поддержку и следите за разработкой.",
"About.GithubButton": "Участвовать в разработке на GitHub!",
"About.DiscordButton": "Присоединиться к нашему Discord!",
"Updater.Auto.Label": "Проверять обновления при запуске",
"Updater.Auto.Desc": "Проверяет GitHub без задержки запуска.",
"Updater.Label": "Обновления",
"Updater.Check": "Проверить обновления",
"Updater.DownloadRestart": "Скачать и перезапустить",
"Updater.Status.Ready": "Текущая сборка: {0}",
"Updater.Status.Checking": "Проверка обновлений…",
"Updater.Status.Current": "Установлена актуальная версия ({0}).",
"Updater.Status.Available": "Доступна новая сборка: {0}",
"Updater.Status.Downloading": "Скачивание обновления… {0}%",
"Updater.Status.Installing": "Установка обновления…",
"Updater.Status.Timeout": "Проверка обновлений превысила лимит времени в 10 секунд.",
"Updater.Status.Failed": "Не удалось проверить наличие обновлений.",
"Updater.Status.Unsupported": "Автоматическое обновление требует сборку Windows, Linux или macOS x64."
}
+1
View File
@@ -140,5 +140,6 @@
"Updater.Status.Installing": "Güncelleme kuruluyor…",
"Updater.Status.Timeout": "Güncelleme denetimi 10 saniye sonra zaman aşımına uğradı.",
"Updater.Status.Failed": "Güncellemeler denetlenemedi.",
"Updater.Status.ChecksumFailed": "İndirilen güncelleme SHA-256 doğrulamasını geçemedi.",
"Updater.Status.Unsupported": "Otomatik güncelleme Windows, Linux veya macOS x64 build'i gerektirir."
}
+83 -162
View File
@@ -126,6 +126,13 @@ SPDX-License-Identifier: GPL-2.0-or-later
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem x:Name="CtxGameSettings" Header="Game settings…">
<MenuItem.Icon>
<TextBlock Text="⚙" FontSize="13" Foreground="{StaticResource MutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center" />
</MenuItem.Icon>
</MenuItem>
<Separator />
<MenuItem x:Name="CtxRemove" Header="Remove from library"
Foreground="{StaticResource DangerHoverBrush}">
<MenuItem.Icon>
@@ -199,27 +206,19 @@ SPDX-License-Identifier: GPL-2.0-or-later
<StackPanel Spacing="14">
<TextBlock x:Name="EmulationSectionTitle" Classes="sectionTitle" Text="EMULATION" />
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="CpuEngineLabel" Text="CPU engine" FontSize="13" />
<TextBlock x:Name="CpuEngineDesc" Text="Execution engine used to run game code."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ComboBox Grid.Column="1" x:Name="CpuEngineBox" Width="160" SelectedIndex="0"
<local:SettingRow x:Name="CpuEngineRow" Label="CPU engine"
Description="Execution engine used to run game code.">
<ComboBox x:Name="CpuEngineBox" Width="160" SelectedIndex="0"
VerticalAlignment="Center" CornerRadius="8">
<ComboBoxItem x:Name="CpuEngineNativeItem" Content="Native" />
</ComboBox>
</Grid>
</local:SettingRow>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="StrictLabel" Text="Strict dynlib resolution" FontSize="13" />
<TextBlock x:Name="StrictDesc" Text="Fail the launch when an imported symbol cannot be resolved."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="StrictToggle" OnContent="On" OffContent="Off"
<local:SettingRow x:Name="StrictRow" Label="Strict dynlib resolution"
Description="Fail the launch when an imported symbol cannot be resolved.">
<ToggleSwitch x:Name="StrictToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
</local:SettingRow>
</StackPanel>
</Border>
@@ -227,13 +226,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
<StackPanel Spacing="14">
<TextBlock x:Name="LoggingSectionTitle" Classes="sectionTitle" Text="LOGGING" />
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="LogLevelLabel" Text="Log level" FontSize="13" />
<TextBlock x:Name="LogLevelDesc" Text="Verbosity of the emulator console output."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ComboBox Grid.Column="1" x:Name="LogLevelBox" Width="160" SelectedIndex="2"
<local:SettingRow x:Name="LogLevelRow" Label="Log level"
Description="Verbosity of the emulator console output.">
<ComboBox x:Name="LogLevelBox" Width="160" SelectedIndex="2"
VerticalAlignment="Center" CornerRadius="8">
<ComboBoxItem x:Name="LogLevelTraceItem" Content="Trace" />
<ComboBoxItem x:Name="LogLevelDebugItem" Content="Debug" />
@@ -242,49 +237,32 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ComboBoxItem x:Name="LogLevelErrorItem" Content="Error" />
<ComboBoxItem x:Name="LogLevelCriticalItem" Content="Critical" />
</ComboBox>
</Grid>
</local:SettingRow>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="TraceImportsLabel" Text="Import trace limit" FontSize="13" />
<TextBlock x:Name="TraceImportsDesc" Text="Trace the first N imports per module (0 = off)."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<NumericUpDown Grid.Column="1" x:Name="TraceImportsBox" Width="160" Minimum="0"
<local:SettingRow x:Name="TraceImportsRow" Label="Import trace limit"
Description="Trace the first N imports per module (0 = off).">
<NumericUpDown x:Name="TraceImportsBox" Width="160" Minimum="0"
Maximum="4096" Increment="16" Value="0" FormatString="0"
VerticalAlignment="Center" CornerRadius="8" />
</Grid>
</local:SettingRow>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="LogToFileLabel" Text="Log to file" FontSize="13" />
<TextBlock x:Name="LogToFileDesc" Text="Mirror emulator output to a log file."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="LogToFileToggle" OnContent="On" OffContent="Off"
<local:SettingRow x:Name="LogToFileRow" Label="Log to file"
Description="Mirror emulator output to a log file.">
<ToggleSwitch x:Name="LogToFileToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
</local:SettingRow>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="LogFilePathLabel" Text="Log file path" FontSize="13" />
<TextBlock x:Name="LogFilePathText" Text="No custom path"
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<Button Grid.Column="1" x:Name="SelectLogFilePathButton" Classes="ghost" Content="Select…"
<local:SettingRow x:Name="LogFilePathRow" Label="Log file path"
Description="No custom path">
<Button x:Name="SelectLogFilePathButton" Classes="ghost" Content="Select…"
VerticalAlignment="Center" />
</Grid>
</local:SettingRow>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="OverrideLogFileLabel" Text="Override log file" FontSize="13" />
<TextBlock x:Name="OverrideLogFileDesc"
Text="Use the exact file path instead of appending title ID and timestamp."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="OverrideLogFileToggle" OnContent="On" OffContent="Off"
<local:SettingRow x:Name="OverrideLogFileRow" Label="Override log file"
Description="Use the exact file path instead of appending title ID and timestamp.">
<ToggleSwitch x:Name="OverrideLogFileToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
</local:SettingRow>
</StackPanel>
</Border>
@@ -292,47 +270,30 @@ SPDX-License-Identifier: GPL-2.0-or-later
<StackPanel Spacing="14">
<TextBlock x:Name="LauncherSectionTitle" Classes="sectionTitle" Text="LAUNCHER" />
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="LanguageLabel" Text="Emulator language" FontSize="13" />
<TextBlock x:Name="LanguageDesc"
Text="Language used throughout the launcher. Applies immediately."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ComboBox Grid.Column="1" x:Name="LanguageBox" Width="160"
<local:SettingRow x:Name="LanguageRow" Label="Emulator language"
Description="Language used throughout the launcher. Applies immediately.">
<ComboBox x:Name="LanguageBox" Width="160"
VerticalAlignment="Center" CornerRadius="8"
DisplayMemberBinding="{Binding NativeName}" />
</Grid>
</local:SettingRow>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="TitleMusicLabel" Text="Title music" FontSize="13" />
<TextBlock x:Name="TitleMusicDesc" Text="Loop the selected game's preview music in the library."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="TitleMusicToggle" OnContent="On" OffContent="Off"
<local:SettingRow x:Name="TitleMusicRow" Label="Title music"
Description="Loop the selected game's preview music in the library.">
<ToggleSwitch x:Name="TitleMusicToggle" OnContent="On" OffContent="Off"
IsChecked="True" VerticalAlignment="Center" />
</Grid>
</local:SettingRow>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="DiscordLabel" Text="Discord presence" FontSize="13" />
<TextBlock x:Name="DiscordDesc" Text="Show the running game on your Discord profile."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="DiscordToggle" OnContent="On" OffContent="Off"
<local:SettingRow x:Name="DiscordRow" Label="Discord presence"
Description="Show the running game on your Discord profile.">
<ToggleSwitch x:Name="DiscordToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
</local:SettingRow>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="AutoUpdateLabel" Text="Check for updates on startup" FontSize="13" />
<TextBlock x:Name="AutoUpdateDesc" Text="Checks GitHub without delaying startup."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="AutoUpdateToggle" OnContent="On" OffContent="Off"
<local:SettingRow x:Name="AutoUpdateRow" Label="Check for updates on startup"
Description="Checks GitHub without delaying startup.">
<ToggleSwitch x:Name="AutoUpdateToggle" OnContent="On" OffContent="Off"
IsChecked="True" VerticalAlignment="Center" />
</Grid>
</local:SettingRow>
</StackPanel>
</Border>
<Border Classes="card">
@@ -450,93 +411,53 @@ SPDX-License-Identifier: GPL-2.0-or-later
Text="Switches passed to the emulator as environment variables at launch."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="SHARPEMU_BTHID_UNAVAILABLE" FontSize="13" FontFamily="Consolas,monospace" />
<TextBlock x:Name="EnvBthidDesc"
Text="Report Bluetooth HID as unavailable for titles whose wheel/FFB middleware polls forever.&#10;Leave off normally. Some titles freeze when init fails."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="EnvBthidToggle" OnContent="On" OffContent="Off"
<local:SettingRow x:Name="EnvBthidRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_BTHID_UNAVAILABLE"
Description="Report Bluetooth HID as unavailable for titles whose wheel/FFB middleware polls forever.&#10;Leave off normally. Some titles freeze when init fails.">
<ToggleSwitch x:Name="EnvBthidToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
</local:SettingRow>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="SHARPEMU_DISABLE_IMPORT_LOOP_GUARD" FontSize="13" FontFamily="Consolas,monospace" />
<TextBlock x:Name="EnvLoopGuardDesc"
Text="Do not force quit titles that repeat the same call for too long.&#10;Try this when a game exits on its own while loading."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="EnvLoopGuardToggle" OnContent="On" OffContent="Off"
<local:SettingRow x:Name="EnvLoopGuardRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_DISABLE_IMPORT_LOOP_GUARD"
Description="Do not force quit titles that repeat the same call for too long.&#10;Try this when a game exits on its own while loading.">
<ToggleSwitch x:Name="EnvLoopGuardToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
</local:SettingRow>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="SHARPEMU_WRITABLE_APP0" FontSize="13" FontFamily="Consolas,monospace" />
<TextBlock x:Name="EnvWritableApp0Desc"
Text="Allow titles to create and write files inside their install folder.&#10;Needed by unpackaged dumps that write their save or config data under /app0."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="EnvWritableApp0Toggle" OnContent="On" OffContent="Off"
<local:SettingRow x:Name="EnvWritableApp0Row" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_WRITABLE_APP0"
Description="Allow titles to create and write files inside their install folder.&#10;Needed by unpackaged dumps that write their save or config data under /app0.">
<ToggleSwitch x:Name="EnvWritableApp0Toggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
</local:SettingRow>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="SHARPEMU_VK_VALIDATION" FontSize="13" FontFamily="Consolas,monospace" />
<TextBlock x:Name="EnvVkValidationDesc"
Text="Enable Vulkan validation layers for GPU debugging.&#10;Slow. Requires the Vulkan SDK to be installed."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="EnvVkValidationToggle" OnContent="On" OffContent="Off"
<local:SettingRow x:Name="EnvVkValidationRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_VK_VALIDATION"
Description="Enable Vulkan validation layers for GPU debugging.&#10;Slow. Requires the Vulkan SDK to be installed.">
<ToggleSwitch x:Name="EnvVkValidationToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
</local:SettingRow>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="SHARPEMU_DUMP_SPIRV" FontSize="13" FontFamily="Consolas,monospace" />
<TextBlock x:Name="EnvDumpSpirvDesc"
Text="Dump AGC shaders and their SPIR-V translations to the shader-dumps folder.&#10;Use when reporting shader or rendering bugs."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="EnvDumpSpirvToggle" OnContent="On" OffContent="Off"
<local:SettingRow x:Name="EnvDumpSpirvRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_DUMP_SPIRV"
Description="Dump AGC shaders and their SPIR-V translations to the shader-dumps folder.&#10;Use when reporting shader or rendering bugs.">
<ToggleSwitch x:Name="EnvDumpSpirvToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
</local:SettingRow>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="SHARPEMU_LOG_DIRECT_MEMORY" FontSize="13" FontFamily="Consolas,monospace" />
<TextBlock x:Name="EnvLogDirectMemoryDesc"
Text="Log direct memory allocations and failures to the console.&#10;Use when a game aborts or exits during boot."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="EnvLogDirectMemoryToggle" OnContent="On" OffContent="Off"
<local:SettingRow x:Name="EnvLogDirectMemoryRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_LOG_DIRECT_MEMORY"
Description="Log direct memory allocations and failures to the console.&#10;Use when a game aborts or exits during boot.">
<ToggleSwitch x:Name="EnvLogDirectMemoryToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
</local:SettingRow>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="SHARPEMU_LOG_IO" FontSize="13" FontFamily="Consolas,monospace" />
<TextBlock x:Name="EnvLogIoDesc"
Text="Log file open, read, and path-resolve activity to the console.&#10;Use when a game cannot find its data files during boot."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="EnvLogIoToggle" OnContent="On" OffContent="Off"
<local:SettingRow x:Name="EnvLogIoRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_LOG_IO"
Description="Log file open, read, and path-resolve activity to the console.&#10;Use when a game cannot find its data files during boot.">
<ToggleSwitch x:Name="EnvLogIoToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
</local:SettingRow>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="SHARPEMU_LOG_NP" FontSize="13" FontFamily="Consolas,monospace" />
<TextBlock x:Name="EnvLogNpDesc"
Text="Log NP (PlayStation Network) library calls to the console."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="EnvLogNpToggle" OnContent="On" OffContent="Off"
<local:SettingRow x:Name="EnvLogNpRow" LabelFontFamily="Consolas,monospace" Label="SHARPEMU_LOG_NP"
Description="Log NP (PlayStation Network) library calls to the console.">
<ToggleSwitch x:Name="EnvLogNpToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
</local:SettingRow>
</StackPanel>
</Border>
+72 -40
View File
@@ -104,6 +104,7 @@ public partial class MainWindow : Window
string EbootPath,
string DisplayName,
string? TitleId,
string LogLevel,
SharpEmuRuntimeOptions RuntimeOptions);
public MainWindow()
@@ -204,6 +205,7 @@ public partial class MainWindow : Window
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.Path, "Clipboard.Path");
CtxCopyTitleId.Click += async (_, _) =>
await CopyToClipboardAsync((GameList.SelectedItem as GameEntry)?.TitleId, "Clipboard.TitleId");
CtxGameSettings.Click += (_, _) => OpenSelectedGameSettings();
CtxRemove.Click += (_, _) => RemoveSelectedFromLibrary();
Opened += async (_, _) => await OnOpenedAsync();
@@ -573,6 +575,7 @@ public partial class MainWindow : Window
CtxOpenFolder.Header = loc.Get("Library.Context.OpenFolder");
CtxCopyPath.Header = loc.Get("Library.Context.CopyPath");
CtxCopyTitleId.Header = loc.Get("Library.Context.CopyTitleId");
CtxGameSettings.Header = loc.Get("Library.Context.GameSettings");
CtxRemove.Header = loc.Get("Library.Context.Remove");
EmptyAddFolderButton.Content = loc.Get("Library.Empty.AddFolder");
@@ -582,27 +585,27 @@ public partial class MainWindow : Window
EnvTabItem.Header = loc.Get("Options.Env.Tab");
EnvSectionTitle.Text = loc.Get("Options.Section.Environment");
EnvDesc.Text = loc.Get("Options.Env.Desc");
EnvBthidDesc.Text = loc.Get("Options.Env.Bthid.Desc");
EnvLoopGuardDesc.Text = loc.Get("Options.Env.LoopGuard.Desc");
EnvWritableApp0Desc.Text = loc.Get("Options.Env.WritableApp0.Desc");
EnvVkValidationDesc.Text = loc.Get("Options.Env.VkValidation.Desc");
EnvDumpSpirvDesc.Text = loc.Get("Options.Env.DumpSpirv.Desc");
EnvLogDirectMemoryDesc.Text = loc.Get("Options.Env.LogDirectMemory.Desc");
EnvLogIoDesc.Text = loc.Get("Options.Env.LogIo.Desc");
EnvLogNpDesc.Text = loc.Get("Options.Env.LogNp.Desc");
EnvBthidRow.Description = loc.Get("Options.Env.Bthid.Desc");
EnvLoopGuardRow.Description = loc.Get("Options.Env.LoopGuard.Desc");
EnvWritableApp0Row.Description = loc.Get("Options.Env.WritableApp0.Desc");
EnvVkValidationRow.Description = loc.Get("Options.Env.VkValidation.Desc");
EnvDumpSpirvRow.Description = loc.Get("Options.Env.DumpSpirv.Desc");
EnvLogDirectMemoryRow.Description = loc.Get("Options.Env.LogDirectMemory.Desc");
EnvLogIoRow.Description = loc.Get("Options.Env.LogIo.Desc");
EnvLogNpRow.Description = loc.Get("Options.Env.LogNp.Desc");
EmulationSectionTitle.Text = loc.Get("Options.Section.Emulation");
LoggingSectionTitle.Text = loc.Get("Options.Section.Logging");
LauncherSectionTitle.Text = loc.Get("Options.Section.Launcher");
CpuEngineLabel.Text = loc.Get("Options.CpuEngine.Label");
CpuEngineDesc.Text = loc.Get("Options.CpuEngine.Desc");
CpuEngineRow.Label = loc.Get("Options.CpuEngine.Label");
CpuEngineRow.Description = loc.Get("Options.CpuEngine.Desc");
CpuEngineNativeItem.Content = loc.Get("Options.CpuEngine.Native");
StrictLabel.Text = loc.Get("Options.Strict.Label");
StrictDesc.Text = loc.Get("Options.Strict.Desc");
StrictRow.Label = loc.Get("Options.Strict.Label");
StrictRow.Description = loc.Get("Options.Strict.Desc");
LogLevelLabel.Text = loc.Get("Options.LogLevel.Label");
LogLevelDesc.Text = loc.Get("Options.LogLevel.Desc");
LogLevelRow.Label = loc.Get("Options.LogLevel.Label");
LogLevelRow.Description = loc.Get("Options.LogLevel.Desc");
LogLevelTraceItem.Content = loc.Get("Options.LogLevel.Trace");
LogLevelDebugItem.Content = loc.Get("Options.LogLevel.Debug");
LogLevelInfoItem.Content = loc.Get("Options.LogLevel.Info");
@@ -610,29 +613,29 @@ public partial class MainWindow : Window
LogLevelErrorItem.Content = loc.Get("Options.LogLevel.Error");
LogLevelCriticalItem.Content = loc.Get("Options.LogLevel.Critical");
TraceImportsLabel.Text = loc.Get("Options.TraceImports.Label");
TraceImportsDesc.Text = loc.Get("Options.TraceImports.Desc");
TraceImportsRow.Label = loc.Get("Options.TraceImports.Label");
TraceImportsRow.Description = loc.Get("Options.TraceImports.Desc");
LogToFileLabel.Text = loc.Get("Options.LogToFile.Label");
LogToFileDesc.Text = loc.Get("Options.LogToFile.Desc");
LogToFileRow.Label = loc.Get("Options.LogToFile.Label");
LogToFileRow.Description = loc.Get("Options.LogToFile.Desc");
LogFilePathLabel.Text = loc.Get("Options.LogFilePath.Label");
LogFilePathRow.Label = loc.Get("Options.LogFilePath.Label");
SelectLogFilePathButton.Content = loc.Get("Options.LogFilePath.Select");
UpdateLogFilePathText();
OverrideLogFileLabel.Text = loc.Get("Options.OverrideLogFile.Label");
OverrideLogFileDesc.Text = loc.Get("Options.OverrideLogFile.Desc");
OverrideLogFileRow.Label = loc.Get("Options.OverrideLogFile.Label");
OverrideLogFileRow.Description = loc.Get("Options.OverrideLogFile.Desc");
LanguageLabel.Text = loc.Get("Options.Language.Label");
LanguageDesc.Text = loc.Get("Options.Language.Desc");
LanguageRow.Label = loc.Get("Options.Language.Label");
LanguageRow.Description = loc.Get("Options.Language.Desc");
TitleMusicLabel.Text = loc.Get("Options.TitleMusic.Label");
TitleMusicDesc.Text = loc.Get("Options.TitleMusic.Desc");
TitleMusicRow.Label = loc.Get("Options.TitleMusic.Label");
TitleMusicRow.Description = loc.Get("Options.TitleMusic.Desc");
DiscordLabel.Text = loc.Get("Options.Discord.Label");
DiscordDesc.Text = loc.Get("Options.Discord.Desc");
AutoUpdateLabel.Text = loc.Get("Updater.Auto.Label");
AutoUpdateDesc.Text = loc.Get("Updater.Auto.Desc");
DiscordRow.Label = loc.Get("Options.Discord.Label");
DiscordRow.Description = loc.Get("Options.Discord.Desc");
AutoUpdateRow.Label = loc.Get("Updater.Auto.Label");
AutoUpdateRow.Description = loc.Get("Updater.Auto.Desc");
foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle, AutoUpdateToggle })
{
@@ -865,6 +868,11 @@ public partial class MainWindow : Window
SetUpdateStatus("Updater.Status.Installing");
Close();
}
catch (InvalidDataException)
{
SetUpdateStatus("Updater.Status.ChecksumFailed");
UpdateButton.IsEnabled = true;
}
catch
{
SetUpdateStatus("Updater.Status.Failed");
@@ -952,7 +960,7 @@ public partial class MainWindow : Window
private void UpdateLogFilePathText()
{
LogFilePathText.Text = string.IsNullOrWhiteSpace(_settings.LogFilePath)
LogFilePathRow.Description = string.IsNullOrWhiteSpace(_settings.LogFilePath)
? Localization.Instance.Get("Options.LogFilePath.Default")
: _settings.LogFilePath;
}
@@ -1379,6 +1387,25 @@ public partial class MainWindow : Window
GameList.SelectedItem = game;
CtxLaunch.IsEnabled = !_isRunning;
CtxCopyTitleId.IsEnabled = game.TitleId is not null;
CtxGameSettings.IsEnabled = !string.IsNullOrWhiteSpace(game.TitleId);
}
private void OpenSelectedGameSettings()
{
if (GameList.SelectedItem is not GameEntry game)
{
return;
}
if (string.IsNullOrWhiteSpace(game.TitleId))
{
AppendConsoleLine(
"[GUI][WARN] Per-game settings require a title ID, which this game does not have.",
WarningLineBrush);
return;
}
_ = new PerGameSettingsDialog(game.TitleId, game.Name, _settings).ShowDialog(this);
}
private void OpenSelectedGameFolder()
@@ -1671,34 +1698,39 @@ public partial class MainWindow : Window
return;
}
var resolvedTitleId = string.IsNullOrWhiteSpace(titleId)
? _allGames.FirstOrDefault(game => game.Path.Equals(ebootPath, FilePathComparison))?.TitleId
: titleId;
var effective = EffectiveLaunchSettings.Resolve(_settings, PerGameSettings.Load(resolvedTitleId));
_sndPreview.Stop();
_consoleLines.Clear();
_allConsoleLines.Clear();
DropFileLog();
if (_settings.LogToFile)
if (effective.LogToFile)
{
OpenFileLog(titleId);
OpenFileLog(resolvedTitleId);
}
// The isolated game child inherits these diagnostics. Keep them on the
// launcher process so every platform receives the same launch options.
foreach (var staleName in _appliedEnvironmentVariables)
{
if (!_settings.EnvironmentToggles.Contains(staleName))
if (!effective.EnvironmentToggles.Contains(staleName))
{
Environment.SetEnvironmentVariable(staleName, null);
}
}
_appliedEnvironmentVariables.Clear();
foreach (var name in _settings.EnvironmentToggles)
foreach (var name in effective.EnvironmentToggles)
{
Environment.SetEnvironmentVariable(name, "1");
_appliedEnvironmentVariables.Add(name);
}
if (SharpEmuLog.TryParseLevel(_settings.LogLevel, out var logLevel))
if (SharpEmuLog.TryParseLevel(effective.LogLevel, out var logLevel))
{
SharpEmuLog.MinimumLevel = logLevel;
}
@@ -1706,15 +1738,14 @@ public partial class MainWindow : Window
var runtimeOptions = new SharpEmuRuntimeOptions
{
CpuEngine = CpuExecutionEngine.NativeOnly,
StrictDynlibResolution = _settings.StrictDynlibResolution,
ImportTraceLimit = Math.Max(0, _settings.ImportTraceLimit),
StrictDynlibResolution = effective.StrictDynlibResolution,
ImportTraceLimit = Math.Max(0, effective.ImportTraceLimit),
};
_isRunning = true;
_runningGameName = displayName;
SessionGameTitle.Text = displayName;
_runningGameTitleId = titleId ?? _allGames
.FirstOrDefault(game => game.Path.Equals(ebootPath, FilePathComparison))?.TitleId;
_runningGameTitleId = resolvedTitleId;
_runningSinceUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
StatusDot.Fill = SuccessLineBrush;
StatusText.Text = Localization.Instance.Format("Launch.Running", displayName);
@@ -1727,6 +1758,7 @@ public partial class MainWindow : Window
Path.GetFullPath(ebootPath),
displayName,
_runningGameTitleId,
effective.LogLevel,
runtimeOptions);
if (_gameSurfaceHost?.Surface is { } surface)
@@ -1895,7 +1927,7 @@ public partial class MainWindow : Window
var arguments = new List<string>
{
"--cpu-engine=native",
$"--log-level={_settings.LogLevel}",
$"--log-level={launch.LogLevel}",
};
if (launch.RuntimeOptions.StrictDynlibResolution)
{
+115
View File
@@ -0,0 +1,115 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SharpEmu.GUI;
public sealed class PerGameSettings
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true,
};
public string? LogLevel { get; set; }
public int? ImportTraceLimit { get; set; }
public bool? StrictDynlibResolution { get; set; }
public bool? LogToFile { get; set; }
public List<string>? EnvironmentToggles { get; set; }
[JsonIgnore]
public bool IsEmpty =>
LogLevel is null &&
ImportTraceLimit is null &&
StrictDynlibResolution is null &&
LogToFile is null &&
EnvironmentToggles is null;
public static string DirectoryPath =>
Path.Combine(AppContext.BaseDirectory, "user", "custom_configs");
public static string PathFor(string titleId) =>
Path.Combine(DirectoryPath, SanitizeTitleId(titleId) + ".json");
public static PerGameSettings? Load(string? titleId)
{
if (string.IsNullOrWhiteSpace(titleId))
{
return null;
}
try
{
var path = PathFor(titleId);
if (File.Exists(path))
{
return JsonSerializer.Deserialize<PerGameSettings>(File.ReadAllText(path), SerializerOptions);
}
}
catch (Exception)
{
}
return null;
}
public void Save(string titleId)
{
if (string.IsNullOrWhiteSpace(titleId))
{
return;
}
try
{
var path = PathFor(titleId);
if (IsEmpty)
{
if (File.Exists(path))
{
File.Delete(path);
}
return;
}
Directory.CreateDirectory(DirectoryPath);
File.WriteAllText(path, JsonSerializer.Serialize(this, SerializerOptions));
}
catch (Exception)
{
}
}
private static string SanitizeTitleId(string titleId)
{
var trimmed = titleId.Trim();
foreach (var invalid in Path.GetInvalidFileNameChars())
{
trimmed = trimmed.Replace(invalid, '_');
}
return trimmed.Length == 0 ? "UNKNOWN" : trimmed;
}
}
public sealed record EffectiveLaunchSettings(
string LogLevel,
int ImportTraceLimit,
bool StrictDynlibResolution,
bool LogToFile,
IReadOnlyList<string> EnvironmentToggles)
{
public static EffectiveLaunchSettings Resolve(GuiSettings global, PerGameSettings? perGame) => new(
perGame?.LogLevel ?? global.LogLevel,
perGame?.ImportTraceLimit ?? global.ImportTraceLimit,
perGame?.StrictDynlibResolution ?? global.StrictDynlibResolution,
perGame?.LogToFile ?? global.LogToFile,
perGame?.EnvironmentToggles ?? global.EnvironmentToggles);
}
+205
View File
@@ -0,0 +1,205 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
namespace SharpEmu.GUI;
public sealed class PerGameSettingsDialog : Window
{
private static readonly string[] LogLevels =
{ "Trace", "Debug", "Info", "Warning", "Error", "Critical" };
private static readonly string[] EnvToggles =
{
"SHARPEMU_BTHID_UNAVAILABLE",
"SHARPEMU_DISABLE_IMPORT_LOOP_GUARD",
"SHARPEMU_WRITABLE_APP0",
"SHARPEMU_VK_VALIDATION",
"SHARPEMU_DUMP_SPIRV",
"SHARPEMU_LOG_DIRECT_MEMORY",
"SHARPEMU_LOG_IO",
"SHARPEMU_LOG_NP",
};
private readonly string _titleId;
private readonly SettingRow _logLevelRow;
private readonly ComboBox _logLevel = new() { ItemsSource = LogLevels, Width = 160 };
private readonly SettingRow _traceRow;
private readonly NumericUpDown _trace = new()
{
Minimum = 0, Maximum = 4096, Increment = 16, Width = 160, FormatString = "0",
};
private readonly SettingRow _strictRow;
private readonly ToggleSwitch _strict = new();
private readonly SettingRow _logToFileRow;
private readonly ToggleSwitch _logToFile = new();
private readonly SettingRow _envRow;
private readonly StackPanel _envList = new() { Orientation = Orientation.Vertical, Spacing = 8, Margin = new(0, 4, 0, 0) };
private readonly List<(string Name, ToggleSwitch Box)> _envBoxes = new();
public PerGameSettingsDialog(string titleId, string displayName, GuiSettings global)
{
_titleId = titleId;
var loc = Localization.Instance;
Title = loc.Format("PerGame.Title", displayName, titleId);
Width = 520;
MaxHeight = 720;
SizeToContent = SizeToContent.Height;
WindowStartupLocation = WindowStartupLocation.CenterOwner;
CanResize = false;
Background = new SolidColorBrush(Color.Parse("#0D1017"));
_strict.OnContent = _logToFile.OnContent = loc.Get("Common.On");
_strict.OffContent = _logToFile.OffContent = loc.Get("Common.Off");
_logLevelRow = Row(loc.Get("Options.LogLevel.Label"), loc.Get("Options.LogLevel.Desc"), _logLevel);
_traceRow = Row(loc.Get("Options.TraceImports.Label"), loc.Get("Options.TraceImports.Desc"), _trace);
_strictRow = Row(loc.Get("Options.Strict.Label"), loc.Get("Options.Strict.Desc"), _strict);
_logToFileRow = Row(loc.Get("Options.LogToFile.Label"), loc.Get("Options.LogToFile.Desc"), _logToFile);
_envRow = new SettingRow
{
Label = loc.Get("PerGame.EnvToggles.Label"),
Description = loc.Get("PerGame.EnvToggles.Desc"),
ShowOverride = true,
};
foreach (var name in EnvToggles)
{
var box = new ToggleSwitch { OnContent = name, OffContent = name };
_envBoxes.Add((name, box));
_envList.Children.Add(box);
}
var content = new StackPanel { Orientation = Orientation.Vertical, Spacing = 12, Margin = new(16) };
content.Children.Add(new TextBlock
{
Text = loc.Get("PerGame.InheritNote"),
Foreground = new SolidColorBrush(Color.Parse("#8B94A7")),
FontSize = 12,
});
content.Children.Add(Card(loc.Get("Options.Section.Emulation"), _strictRow));
content.Children.Add(Card(loc.Get("Options.Section.Logging"), _logLevelRow, _traceRow, _logToFileRow));
content.Children.Add(Card(loc.Get("Options.Section.Environment"), _envRow, _envList));
var save = new Button { Content = loc.Get("Common.Save"), Classes = { "accent" } };
var cancel = new Button { Content = loc.Get("Common.Cancel"), Classes = { "ghost" } };
save.Click += (_, _) => { Persist(); Close(); };
cancel.Click += (_, _) => Close();
var buttonBar = new Border
{
BorderBrush = new SolidColorBrush(Color.Parse("#8B94A7")) { Opacity = 0.25 },
BorderThickness = new Thickness(0, 1, 0, 0),
Padding = new(16),
Child = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Right,
Children = { cancel, save },
},
};
var root = new Grid { RowDefinitions = new RowDefinitions("*,Auto") };
var scroller = new ScrollViewer { Content = content };
Grid.SetRow(scroller, 0);
Grid.SetRow(buttonBar, 1);
root.Children.Add(scroller);
root.Children.Add(buttonBar);
Content = root;
LoadValues(global);
_envRow.PropertyChanged += (_, e) =>
{
if (e.Property == SettingRow.IsOverriddenProperty)
{
_envList.IsEnabled = _envRow.IsOverridden;
}
};
_envList.IsEnabled = _envRow.IsOverridden;
}
private static SettingRow Row(string label, string description, Control value) => new()
{
Label = label,
Description = description,
ShowOverride = true,
Content = value,
};
private static Border Card(string title, params Control[] rows)
{
var stack = new StackPanel { Orientation = Orientation.Vertical, Spacing = 14 };
stack.Children.Add(new TextBlock { Text = title, Classes = { "sectionTitle" } });
foreach (var row in rows)
{
stack.Children.Add(row);
}
var card = new Border { Child = stack };
card.Classes.Add("card");
return card;
}
private void LoadValues(GuiSettings global)
{
_logLevel.SelectedItem = Array.IndexOf(LogLevels, global.LogLevel) >= 0 ? global.LogLevel : "Info";
_trace.Value = global.ImportTraceLimit;
_strict.IsChecked = global.StrictDynlibResolution;
_logToFile.IsChecked = global.LogToFile;
foreach (var (name, box) in _envBoxes)
{
box.IsChecked = global.EnvironmentToggles.Contains(name);
}
var existing = PerGameSettings.Load(_titleId);
if (existing is null)
{
return;
}
if (existing.LogLevel is { } level && Array.IndexOf(LogLevels, level) >= 0)
{
_logLevelRow.IsOverridden = true;
_logLevel.SelectedItem = level;
}
if (existing.ImportTraceLimit is { } t) { _traceRow.IsOverridden = true; _trace.Value = t; }
if (existing.StrictDynlibResolution is { } s) { _strictRow.IsOverridden = true; _strict.IsChecked = s; }
if (existing.LogToFile is { } l) { _logToFileRow.IsOverridden = true; _logToFile.IsChecked = l; }
if (existing.EnvironmentToggles is { } env)
{
_envRow.IsOverridden = true;
foreach (var (name, box) in _envBoxes)
{
box.IsChecked = env.Contains(name);
}
}
}
private void Persist()
{
var settings = new PerGameSettings
{
LogLevel = _logLevelRow.IsOverridden ? _logLevel.SelectedItem as string : null,
ImportTraceLimit = _traceRow.IsOverridden ? (int)(_trace.Value ?? 0) : null,
StrictDynlibResolution = _strictRow.IsOverridden ? _strict.IsChecked == true : null,
LogToFile = _logToFileRow.IsOverridden ? _logToFile.IsChecked == true : null,
EnvironmentToggles = _envRow.IsOverridden
? _envBoxes.Where(e => e.Box.IsChecked == true).Select(e => e.Name).ToList()
: null,
};
settings.Save(_titleId);
}
}
+101
View File
@@ -0,0 +1,101 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Data;
using Avalonia.Media;
namespace SharpEmu.GUI;
public sealed class SettingRow : ContentControl
{
public static readonly StyledProperty<string?> LabelProperty =
AvaloniaProperty.Register<SettingRow, string?>(nameof(Label));
public static readonly StyledProperty<string?> DescriptionProperty =
AvaloniaProperty.Register<SettingRow, string?>(nameof(Description));
public static readonly StyledProperty<bool> ShowOverrideProperty =
AvaloniaProperty.Register<SettingRow, bool>(nameof(ShowOverride));
public static readonly StyledProperty<bool> IsOverriddenProperty =
AvaloniaProperty.Register<SettingRow, bool>(
nameof(IsOverridden), defaultBindingMode: BindingMode.TwoWay);
public static readonly StyledProperty<FontFamily?> LabelFontFamilyProperty =
AvaloniaProperty.Register<SettingRow, FontFamily?>(nameof(LabelFontFamily));
private ContentPresenter? _slot;
private TextBlock? _label;
public string? Label
{
get => GetValue(LabelProperty);
set => SetValue(LabelProperty, value);
}
public string? Description
{
get => GetValue(DescriptionProperty);
set => SetValue(DescriptionProperty, value);
}
public bool ShowOverride
{
get => GetValue(ShowOverrideProperty);
set => SetValue(ShowOverrideProperty, value);
}
public bool IsOverridden
{
get => GetValue(IsOverriddenProperty);
set => SetValue(IsOverriddenProperty, value);
}
public FontFamily? LabelFontFamily
{
get => GetValue(LabelFontFamilyProperty);
set => SetValue(LabelFontFamilyProperty, value);
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
_slot = e.NameScope.Find<ContentPresenter>("PART_Slot");
_label = e.NameScope.Find<TextBlock>("PART_Label");
UpdateSlotEnabled();
UpdateLabelFont();
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == ShowOverrideProperty || change.Property == IsOverriddenProperty)
{
UpdateSlotEnabled();
}
else if (change.Property == LabelFontFamilyProperty)
{
UpdateLabelFont();
}
}
private void UpdateLabelFont()
{
if (_label is not null && LabelFontFamily is { } family)
{
_label.FontFamily = family;
}
}
private void UpdateSlotEnabled()
{
if (_slot is not null)
{
_slot.IsEnabled = !ShowOverride || IsOverridden;
}
}
}
+206 -37
View File
@@ -6,7 +6,10 @@ using System.Formats.Tar;
using System.IO.Compression;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Reflection;
namespace SharpEmu.GUI;
@@ -18,7 +21,7 @@ public static class Updater
private static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(10);
private static readonly HttpClient Http = CreateHttpClient();
public sealed record UpdateInfo(string Sha, string Name, string DownloadUrl, long Size);
public sealed record UpdateInfo(string Sha, string Name, string DownloadUrl, long Size, string Sha256, string TagName);
public static async Task<UpdateInfo?> CheckAsync(string? currentSha, CancellationToken cancellationToken = default)
{
@@ -28,11 +31,31 @@ public static class Updater
using var response = await Http.GetAsync(LatestReleaseUrl, timeout.Token);
response.EnsureSuccessStatusCode();
return ParseRelease(
var update = ParseRelease(
await response.Content.ReadAsStringAsync(timeout.Token),
currentSha,
null,
platform.Rid,
platform.Extension);
var currentVersion = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
if (update is null || currentSha is null ||
string.Equals(update.Sha, currentSha, StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (currentVersion is not null &&
TryParseVersion(currentVersion, out var installed) &&
TryParseVersion(update.TagName, out var available) &&
available.CompareTo(installed) <= 0)
{
return null;
}
var comparison = await CompareCommitsAsync(currentSha, update.Sha, timeout.Token);
return comparison.Status == "ahead" && comparison.ReleaseDate > comparison.CurrentDate
? update
: null;
}
public static async Task DownloadAndRestartAsync(
@@ -47,42 +70,63 @@ public static class Updater
Directory.Delete(root, recursive: true);
}
Directory.CreateDirectory(root);
var archive = Path.Combine(root, update.Name);
using (var response = await Http.GetAsync(update.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
var launched = false;
try
{
response.EnsureSuccessStatusCode();
await using var input = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var output = File.Create(archive);
var buffer = new byte[81920];
long written = 0;
int read;
while ((read = await input.ReadAsync(buffer, cancellationToken)) > 0)
Directory.CreateDirectory(root);
var archive = Path.Combine(root, update.Name);
using (var response = await Http.GetAsync(update.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
{
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
written += read;
progress?.Report(update.Size == 0 ? 0 : (int)(written * 100 / update.Size));
response.EnsureSuccessStatusCode();
await using var input = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var output = File.Create(archive);
var buffer = new byte[81920];
long written = 0;
int read;
while ((read = await input.ReadAsync(buffer, cancellationToken)) > 0)
{
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
written += read;
progress?.Report(update.Size == 0 ? 0 : (int)(written * 100 / update.Size));
}
if (written != update.Size)
{
throw new InvalidDataException($"Downloaded {written} bytes; expected {update.Size}.");
}
}
if (written != update.Size)
await using (var archiveStream = File.OpenRead(archive))
{
throw new InvalidDataException($"Downloaded {written} bytes; expected {update.Size}.");
var actualSha256 = Convert.ToHexString(await SHA256.HashDataAsync(archiveStream, cancellationToken));
if (!string.Equals(actualSha256, update.Sha256, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException($"SHA-256 mismatch; expected {update.Sha256}, got {actualSha256}.");
}
}
var platform = CurrentPlatform();
var stagedExe = ExtractArchive(archive, payload, platform.Extension, platform.ExecutableName);
var start = new ProcessStartInfo(stagedExe)
{
UseShellExecute = false,
WorkingDirectory = payload,
};
start.ArgumentList.Add(ApplyArgument);
start.ArgumentList.Add(Environment.ProcessId.ToString());
start.ArgumentList.Add(AppContext.BaseDirectory);
using var helper = Process.Start(start)
?? throw new InvalidOperationException("The update installer could not be started.");
launched = true;
}
finally
{
if (!launched)
{
TryDeleteDirectory(root);
}
}
var platform = CurrentPlatform();
var stagedExe = ExtractArchive(archive, payload, platform.Extension, platform.ExecutableName);
var start = new ProcessStartInfo(stagedExe)
{
UseShellExecute = false,
WorkingDirectory = payload,
};
start.ArgumentList.Add(ApplyArgument);
start.ArgumentList.Add(Environment.ProcessId.ToString());
start.ArgumentList.Add(AppContext.BaseDirectory);
using var helper = Process.Start(start)
?? throw new InvalidOperationException("The update installer could not be started.");
}
/// <summary>Runs from the downloaded executable after the old GUI exits.</summary>
@@ -94,6 +138,8 @@ public static class Updater
return false;
}
var backup = Path.Combine(Path.GetTempPath(), $"SharpEmu.UpdateBackup-{Environment.ProcessId}");
var changed = new List<(string Destination, string? Backup)>();
try
{
if (int.TryParse(args[1], out var oldPid))
@@ -113,6 +159,7 @@ public static class Updater
var source = AppContext.BaseDirectory;
var target = Path.GetFullPath(args[2]);
Directory.CreateDirectory(backup);
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(source, file);
@@ -126,6 +173,14 @@ public static class Updater
var destination = Path.Combine(target, relative);
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
string? backupFile = null;
if (File.Exists(destination))
{
backupFile = Path.Combine(backup, relative);
Directory.CreateDirectory(Path.GetDirectoryName(backupFile)!);
File.Copy(destination, backupFile, overwrite: true);
}
changed.Add((destination, backupFile));
File.Copy(file, destination, overwrite: true);
if (!OperatingSystem.IsWindows())
{
@@ -139,10 +194,31 @@ public static class Updater
UseShellExecute = false,
WorkingDirectory = target,
}) ?? throw new InvalidOperationException("The updated SharpEmu could not be started.");
TryDeleteDirectory(backup);
}
catch (Exception ex)
{
exitCode = 1;
foreach (var (destination, backupFile) in changed.AsEnumerable().Reverse())
{
try
{
if (backupFile is null)
{
File.Delete(destination);
}
else if (File.Exists(backupFile))
{
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
File.Copy(backupFile, destination, overwrite: true);
}
}
catch
{
// Best-effort rollback; the original error is more useful to the user.
}
}
TryDeleteDirectory(backup);
try
{
File.WriteAllText(Path.Combine(args[2], "update-error.log"), ex.ToString());
@@ -163,11 +239,12 @@ public static class Updater
string extension)
{
using var document = JsonDocument.Parse(json);
var releaseSha = ExtractReleaseSha(document.RootElement);
var candidates = new List<(DateTimeOffset Created, UpdateInfo Update)>();
foreach (var asset in document.RootElement.GetProperty("assets").EnumerateArray())
{
var name = asset.GetProperty("name").GetString() ?? "";
var marker = $"-{rid}-";
var marker = $"-{rid}";
var markerIndex = name.LastIndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (!name.EndsWith(extension, StringComparison.OrdinalIgnoreCase) ||
markerIndex < 0)
@@ -175,8 +252,21 @@ public static class Updater
continue;
}
var sha = name[(markerIndex + marker.Length)..^extension.Length];
if (sha.Length < 7 || !sha.All(Uri.IsHexDigit))
var suffix = name[(markerIndex + marker.Length)..^extension.Length].TrimStart('-');
var assetSha = suffix.Length >= 7 && suffix.All(Uri.IsHexDigit)
? suffix
: releaseSha;
if (assetSha is null ||
!asset.TryGetProperty("digest", out var digestProperty) ||
digestProperty.ValueKind != JsonValueKind.String)
{
continue;
}
var digest = digestProperty.GetString() ?? "";
if (!digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ||
digest.Length != "sha256:".Length + 64 ||
!digest["sha256:".Length..].All(Uri.IsHexDigit))
{
continue;
}
@@ -184,10 +274,12 @@ public static class Updater
candidates.Add((
asset.GetProperty("created_at").GetDateTimeOffset(),
new UpdateInfo(
sha,
assetSha,
name,
asset.GetProperty("browser_download_url").GetString()!,
asset.GetProperty("size").GetInt64())));
asset.GetProperty("size").GetInt64(),
digest["sha256:".Length..],
document.RootElement.GetProperty("tag_name").GetString() ?? "")));
}
var latest = candidates.OrderByDescending(candidate => candidate.Created).FirstOrDefault().Update;
@@ -196,6 +288,73 @@ public static class Updater
: latest;
}
private static async Task<CommitComparison> CompareCommitsAsync(
string currentSha,
string releaseSha,
CancellationToken cancellationToken)
{
var url = $"https://api.github.com/repos/sharpemu/sharpemu/compare/{currentSha}...{releaseSha}";
using var response = await Http.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
var root = document.RootElement;
var currentDate = root.GetProperty("base_commit").GetProperty("commit").GetProperty("committer").GetProperty("date").GetDateTimeOffset();
var releaseDate = currentDate;
if (root.TryGetProperty("commits", out var commits) && commits.GetArrayLength() > 0)
{
releaseDate = commits[commits.GetArrayLength() - 1]
.GetProperty("commit").GetProperty("committer").GetProperty("date").GetDateTimeOffset();
}
return new CommitComparison(root.GetProperty("status").GetString() ?? "", currentDate, releaseDate);
}
private static string? ExtractReleaseSha(JsonElement release)
{
if (!release.TryGetProperty("body", out var bodyProperty) ||
bodyProperty.ValueKind != JsonValueKind.String)
{
return null;
}
var body = bodyProperty.GetString();
var match = Regex.Match(
body ?? "",
@"\bcommit\s+([0-9a-f]{7,40})\b",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
if (!match.Success)
{
return null;
}
var sha = match.Groups[1].Value;
return sha.Length > 7 ? sha[..7] : sha;
}
private static bool TryParseVersion(string value, out ReleaseVersion version)
{
var match = Regex.Match(value.TrimStart('v'), @"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?");
if (!match.Success || !int.TryParse(match.Groups[1].Value, out var major) ||
!int.TryParse(match.Groups[2].Value, out var minor) ||
!int.TryParse(match.Groups[3].Value, out var patch))
{
version = default;
return false;
}
version = new ReleaseVersion(major, minor, patch, match.Groups[4].Value);
return true;
}
private static void TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path)) Directory.Delete(path, recursive: true);
}
catch { }
}
private static string ExtractArchive(
string archive,
string payload,
@@ -250,4 +409,14 @@ public static class Updater
}
private sealed record PlatformInfo(string Rid, string Extension, string ExecutableName);
private sealed record CommitComparison(string Status, DateTimeOffset CurrentDate, DateTimeOffset ReleaseDate);
private readonly record struct ReleaseVersion(int Major, int Minor, int Patch, string PreRelease) : IComparable<ReleaseVersion>
{
public int CompareTo(ReleaseVersion other) =>
(Major, Minor, Patch) != (other.Major, other.Minor, other.Patch)
? (Major, Minor, Patch).CompareTo((other.Major, other.Minor, other.Patch))
: string.IsNullOrEmpty(PreRelease) == string.IsNullOrEmpty(other.PreRelease)
? string.CompareOrdinal(PreRelease, other.PreRelease)
: string.IsNullOrEmpty(PreRelease) ? 1 : -1;
}
}
-21
View File
@@ -398,27 +398,6 @@ public static class GuestThreadExecution
return true;
}
public static bool TryConsumeCurrentThreadBlock(
out string reason,
out GuestCpuContinuation continuation,
out bool hasContinuation,
out string wakeKey,
out Func<int>? resumeHandler,
out Func<bool>? wakeHandler,
out long blockDeadlineTimestamp)
{
var consumed = TryConsumeCurrentThreadBlock(
out reason,
out continuation,
out hasContinuation,
out wakeKey,
out var waiter,
out blockDeadlineTimestamp);
resumeHandler = waiter is null ? null : waiter.Resume;
wakeHandler = waiter is null ? null : waiter.TryWake;
return consumed;
}
public static long ComputeDeadlineTimestamp(TimeSpan timeout)
{
if (timeout <= TimeSpan.Zero)
+29 -11
View File
@@ -5779,7 +5779,9 @@ public static partial class AgcExports
textures.Add(new TranslatedImageBinding(
texture,
Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode),
Gen5ShaderTranslator.RequiresStorageImage(
binding,
exportEvaluation.ImageBindings),
binding.MipLevel ?? 0,
binding.SamplerDescriptor));
}
@@ -6102,17 +6104,22 @@ public static partial class AgcExports
_graphicsShaderCache.TryAdd(shaderKey, compiled);
}
var imageBindings = pixelEvaluation.ImageBindings
.Concat(exportEvaluation.ImageBindings)
.ToArray();
var textures = new List<TranslatedImageBinding>(
pixelEvaluation.ImageBindings.Count +
exportEvaluation.ImageBindings.Count);
if (!TryAppendTranslatedImageBindings(
pixelEvaluation.ImageBindings,
imageBindings,
textures,
pixelShaderAddress,
exportShaderAddress,
out error) ||
!TryAppendTranslatedImageBindings(
exportEvaluation.ImageBindings,
imageBindings,
textures,
pixelShaderAddress,
exportShaderAddress,
@@ -6191,6 +6198,7 @@ public static partial class AgcExports
private static bool TryAppendTranslatedImageBindings(
IReadOnlyList<Gen5ImageBinding> bindings,
IReadOnlyList<Gen5ImageBinding> stageBindings,
List<TranslatedImageBinding> textures,
ulong pixelShaderAddress,
ulong exportShaderAddress,
@@ -6215,8 +6223,9 @@ public static partial class AgcExports
0, 1, 1, Gen5TextureFormatR8G8B8A8Unorm, 0, 0, 0, 0, 0, 1, 0xFAC);
}
var isStorage =
Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode);
var isStorage = Gen5ShaderTranslator.RequiresStorageImage(
binding,
stageBindings);
if (_traceAgcShader || _tracePixelShaderAddress == pixelShaderAddress)
{
Console.Error.WriteLine(
@@ -7374,9 +7383,11 @@ public static partial class AgcExports
/// </summary>
private static void ReturnPooledEvaluationArrays(Gen5ShaderEvaluation evaluation)
{
var returned = new HashSet<byte[]>(
System.Collections.Generic.ReferenceEqualityComparer.Instance);
foreach (var binding in evaluation.GlobalMemoryBindings)
{
if (binding.DataPooled)
if (binding.DataPooled && returned.Add(binding.Data))
{
VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
}
@@ -7386,7 +7397,7 @@ public static partial class AgcExports
{
foreach (var binding in vertexInputs)
{
if (binding.DataPooled)
if (binding.DataPooled && returned.Add(binding.Data))
{
VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
}
@@ -7406,11 +7417,13 @@ public static partial class AgcExports
bool vertex,
bool index)
{
var returned = new HashSet<byte[]>(
System.Collections.Generic.ReferenceEqualityComparer.Instance);
if (globals)
{
foreach (var binding in draw.GlobalMemoryBindings)
{
if (binding.DataPooled)
if (binding.DataPooled && returned.Add(binding.Data))
{
VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
}
@@ -7421,14 +7434,15 @@ public static partial class AgcExports
{
foreach (var binding in draw.VertexInputs)
{
if (binding.DataPooled)
if (binding.DataPooled && returned.Add(binding.Data))
{
VulkanVideoPresenter.GuestDataPool.Return(binding.Data);
}
}
}
if (index && draw.IndexBuffer is { Pooled: true } indexBuffer)
if (index && draw.IndexBuffer is { Pooled: true } indexBuffer &&
returned.Add(indexBuffer.Data))
{
VulkanVideoPresenter.GuestDataPool.Return(indexBuffer.Data);
}
@@ -7906,7 +7920,10 @@ public static partial class AgcExports
return;
}
var byteCount = (ulong)target.Width * target.Height * 4;
var byteCount = VulkanVideoPresenter.GetGuestImageByteCount(
target.Format,
target.Width,
target.Height);
if (byteCount == 0 || byteCount > MaxPresentedTextureBytes)
{
return;
@@ -8522,7 +8539,8 @@ public static partial class AgcExports
var hasStorageBinding = false;
foreach (var binding in bindings)
{
var isStorage = Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode);
var isStorage = Gen5ShaderTranslator.RequiresStorageImage(binding, bindings);
var writesStorage = Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode);
var descriptorValid = TryDecodeTextureDescriptor(binding.ResourceDescriptor, out var texture);
if (!descriptorValid)
{
@@ -8543,7 +8561,7 @@ public static partial class AgcExports
$"0x{texture.Address:X16}:{texture.Width}x{texture.Height}:" +
$"fmt{texture.Format}/num{texture.NumberType}/tile{texture.TileMode}" +
$"{descriptorState}/{ProbeTexture(ctx, texture)}");
if (isStorage && descriptorValid && texture.Address != 0)
if (writesStorage && descriptorValid && texture.Address != 0)
{
gpuState.ComputeImageWriters[texture.Address] = new ComputeImageWriter(
sequence,
+143 -4
View File
@@ -10,8 +10,29 @@ namespace SharpEmu.Libs.Audio;
public static class AjmExports
{
private static readonly ConcurrentDictionary<uint, byte> Contexts = new();
private const int OrbisAjmErrorInvalidContext = unchecked((int)0x80930002);
private const int OrbisAjmErrorInvalidInstance = unchecked((int)0x80930003);
private const int OrbisAjmErrorInvalidParameter = unchecked((int)0x80930005);
private const int OrbisAjmErrorOutOfResources = unchecked((int)0x80930007);
private const int OrbisAjmErrorCodecAlreadyRegistered = unchecked((int)0x80930009);
private const int OrbisAjmErrorCodecNotRegistered = unchecked((int)0x8093000A);
private const int OrbisAjmErrorWrongRevisionFlag = unchecked((int)0x8093000B);
private const uint MaxCodecType = 23;
private const int MaxInstanceIndex = 0x2FFF;
private static readonly ConcurrentDictionary<uint, AjmContextState> Contexts = new();
private static int _nextContextId;
private sealed class AjmContextState
{
public object Gate { get; } = new();
public HashSet<uint> RegisteredCodecs { get; } = new();
public Dictionary<uint, uint> InstancesBySlot { get; } = new();
public int NextInstanceIndex { get; set; }
}
public static int AjmInitialize(CpuContext ctx)
{
var reserved = ctx[CpuRegister.Rdi];
@@ -29,7 +50,7 @@ public static class AjmExports
return unchecked((int)0x806A0001);
}
Contexts[contextId] = 0;
Contexts[contextId] = new AjmContextState();
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AJM"), "1", StringComparison.Ordinal))
{
Console.Error.WriteLine(
@@ -62,9 +83,22 @@ public static class AjmExports
var contextId = unchecked((uint)ctx[CpuRegister.Rdi]);
var codecType = unchecked((uint)ctx[CpuRegister.Rsi]);
var reserved = ctx[CpuRegister.Rdx];
if (reserved != 0 || !Contexts.ContainsKey(contextId))
if (codecType >= MaxCodecType || reserved != 0)
{
return unchecked((int)0x806A0001);
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
}
if (!Contexts.TryGetValue(contextId, out var state))
{
return ctx.SetReturn(OrbisAjmErrorInvalidContext);
}
lock (state.Gate)
{
if (!state.RegisteredCodecs.Add(codecType))
{
return ctx.SetReturn(OrbisAjmErrorCodecAlreadyRegistered);
}
}
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AJM"), "1", StringComparison.Ordinal))
@@ -77,6 +111,97 @@ public static class AjmExports
return 0;
}
[SysAbiExport(
Nid = "AxoDrINp4J8",
ExportName = "sceAjmInstanceCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAjm")]
public static int AjmInstanceCreate(CpuContext ctx)
{
var contextId = unchecked((uint)ctx[CpuRegister.Rdi]);
var codecType = unchecked((uint)ctx[CpuRegister.Rsi]);
var flags = ctx[CpuRegister.Rdx];
var outputAddress = ctx[CpuRegister.Rcx];
if (!Contexts.TryGetValue(contextId, out var state))
{
return ctx.SetReturn(OrbisAjmErrorInvalidContext);
}
if (codecType >= MaxCodecType || outputAddress == 0)
{
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
}
if ((flags & 0x7) == 0)
{
return ctx.SetReturn(OrbisAjmErrorWrongRevisionFlag);
}
uint instanceId;
lock (state.Gate)
{
if (!state.RegisteredCodecs.Contains(codecType))
{
return ctx.SetReturn(OrbisAjmErrorCodecNotRegistered);
}
if (state.InstancesBySlot.Count >= MaxInstanceIndex)
{
return ctx.SetReturn(OrbisAjmErrorOutOfResources);
}
var nextInstanceIndex = state.NextInstanceIndex;
uint instanceSlot;
do
{
nextInstanceIndex = nextInstanceIndex % MaxInstanceIndex + 1;
instanceSlot = unchecked((uint)nextInstanceIndex);
}
while (state.InstancesBySlot.ContainsKey(instanceSlot));
instanceId = (codecType << 14) | instanceSlot;
Span<byte> value = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(value, instanceId);
if (!ctx.Memory.TryWrite(outputAddress, value))
{
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
}
state.NextInstanceIndex = nextInstanceIndex;
state.InstancesBySlot.Add(instanceSlot, instanceId);
}
Trace($"instance_create context={contextId} codec={codecType} flags=0x{flags:X} instance=0x{instanceId:X8}");
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "RbLbuKv8zho",
ExportName = "sceAjmInstanceDestroy",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAjm")]
public static int AjmInstanceDestroy(CpuContext ctx)
{
var contextId = unchecked((uint)ctx[CpuRegister.Rdi]);
var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]);
if (!Contexts.TryGetValue(contextId, out var state))
{
return ctx.SetReturn(OrbisAjmErrorInvalidContext);
}
var instanceSlot = instanceId & 0x3FFF;
lock (state.Gate)
{
if (instanceSlot == 0 || !state.InstancesBySlot.Remove(instanceSlot))
{
return ctx.SetReturn(OrbisAjmErrorInvalidInstance);
}
}
Trace($"instance_destroy context={contextId} instance=0x{instanceId:X8}");
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "Wi7DtlLV+KI",
ExportName = "sceAjmModuleUnregister",
@@ -101,4 +226,18 @@ public static class AjmExports
ctx[CpuRegister.Rax] = 0;
return 0;
}
internal static void ResetForTests()
{
Contexts.Clear();
Interlocked.Exchange(ref _nextContextId, 0);
}
private static void Trace(string message)
{
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AJM"), "1", StringComparison.Ordinal))
{
Console.Error.WriteLine($"[LOADER][TRACE] ajm.{message}");
}
}
}
+250 -11
View File
@@ -1109,7 +1109,7 @@ public static class AvPlayerExports
return null;
}
private static string? ResolveGuestPath(string guestPath)
internal static string? ResolveGuestPath(string guestPath)
{
if (string.IsNullOrWhiteSpace(guestPath))
{
@@ -1117,13 +1117,39 @@ public static class AvPlayerExports
}
var normalized = guestPath.Replace('\\', '/');
if (Uri.TryCreate(normalized, UriKind.Absolute, out var uri) && uri.IsFile)
var fileReference = normalized.StartsWith("file:", StringComparison.OrdinalIgnoreCase);
var unrealProjectRelative = false;
if (normalized.StartsWith("file://", StringComparison.OrdinalIgnoreCase) &&
Uri.TryCreate(normalized, UriKind.Absolute, out var uri) &&
uri.IsFile)
{
normalized = uri.LocalPath;
if (!string.IsNullOrEmpty(uri.Host) &&
!string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase))
{
return null;
}
normalized = uri.LocalPath.Replace('\\', '/');
}
if (File.Exists(normalized))
else if (normalized.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
{
return Path.GetFullPath(normalized);
// Some console middleware emits Unreal-style project-relative
// media references such as file://../../../Project/Content/....
// System.Uri rejects these because the first ".." is parsed as
// an invalid authority. Treat the scheme as a guest-path marker;
// the app0 sandbox below resolves the relative path.
normalized = normalized["file://".Length..];
unrealProjectRelative = true;
}
else if (normalized.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized["file:".Length..];
unrealProjectRelative = true;
}
if (unrealProjectRelative)
{
normalized = RemoveUnrealLeadingDotSegments(normalized);
}
var app0 = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
@@ -1131,19 +1157,232 @@ public static class AvPlayerExports
{
return null;
}
foreach (var prefix in new[] { "app0:/", "/app0/", "app0:", "/app0" })
var app0MountedPath = false;
foreach (var prefix in new[] { "app0:/", "/app0/", "app0/", "app0:" })
{
if (normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[prefix.Length..];
app0MountedPath = true;
break;
}
}
var candidate = Path.GetFullPath(Path.Combine(app0, normalized.TrimStart('/')));
var root = Path.GetFullPath(app0).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
return candidate.StartsWith(root, StringComparison.OrdinalIgnoreCase) && File.Exists(candidate)
? candidate
: null;
if (!app0MountedPath &&
(string.Equals(normalized, "app0:", StringComparison.OrdinalIgnoreCase) ||
string.Equals(normalized, "/app0", StringComparison.OrdinalIgnoreCase) ||
string.Equals(normalized, "app0", StringComparison.OrdinalIgnoreCase)))
{
normalized = string.Empty;
app0MountedPath = true;
}
try
{
if (fileReference)
{
if (!TryDecodeFileReference(normalized, out normalized))
{
return null;
}
}
else if (ContainsInvalidMediaPathCharacters(normalized))
{
return null;
}
if ((!fileReference &&
!app0MountedPath &&
Uri.TryCreate(normalized, UriKind.Absolute, out _)) ||
Path.IsPathFullyQualified(normalized) ||
normalized.StartsWith("/", StringComparison.Ordinal))
{
return null;
}
if (!TryNormalizeApp0RelativePath(normalized, out var relativePath) ||
relativePath.Length == 0)
{
return null;
}
var root = Path.GetFullPath(app0);
var candidate = Path.GetFullPath(Path.Combine(root, relativePath));
var relativeToRoot = Path.GetRelativePath(root, candidate);
if (Path.IsPathFullyQualified(relativeToRoot) ||
string.Equals(relativeToRoot, "..", StringComparison.Ordinal) ||
relativeToRoot.StartsWith(
".." + Path.DirectorySeparatorChar,
StringComparison.Ordinal))
{
return null;
}
return TryResolveSandboxedFile(root, relativePath, out var resolved)
? resolved
: null;
}
catch (Exception exception) when (exception is ArgumentException or
IOException or
NotSupportedException or
UnauthorizedAccessException or
UriFormatException)
{
return null;
}
}
private static string RemoveUnrealLeadingDotSegments(string guestPath)
{
while (guestPath.StartsWith("../", StringComparison.Ordinal) ||
guestPath.StartsWith("./", StringComparison.Ordinal))
{
guestPath = guestPath[(guestPath.IndexOf('/') + 1)..];
}
return guestPath;
}
private static bool TryDecodeFileReference(string encoded, out string decoded)
{
decoded = string.Empty;
for (var index = 0; index < encoded.Length; index++)
{
if (encoded[index] != '%')
{
continue;
}
if (index + 2 >= encoded.Length ||
!Uri.IsHexDigit(encoded[index + 1]) ||
!Uri.IsHexDigit(encoded[index + 2]))
{
return false;
}
var escapedByte = Convert.ToByte(encoded.Substring(index + 1, 2), 16);
if (escapedByte is (byte)'/' or (byte)'\\')
{
return false;
}
index += 2;
}
decoded = Uri.UnescapeDataString(encoded);
return !ContainsInvalidMediaPathCharacters(decoded);
}
private static bool ContainsInvalidMediaPathCharacters(string path) =>
path.IndexOfAny(['?', '#']) >= 0 || path.Any(char.IsControl);
private static bool TryNormalizeApp0RelativePath(
string guestPath,
out string relativePath)
{
var segments = new List<string>();
foreach (var segment in guestPath.TrimStart('/').Split(
'/',
StringSplitOptions.RemoveEmptyEntries))
{
if (segment == ".")
{
continue;
}
if (segment == "..")
{
if (segments.Count == 0)
{
relativePath = string.Empty;
return false;
}
segments.RemoveAt(segments.Count - 1);
continue;
}
segments.Add(segment);
}
relativePath = string.Join(Path.DirectorySeparatorChar, segments);
return true;
}
private static bool TryResolveSandboxedFile(
string root,
string relativePath,
out string resolved)
{
resolved = string.Empty;
var current = root;
var segments = relativePath.Split(
Path.DirectorySeparatorChar,
StringSplitOptions.RemoveEmptyEntries);
for (var index = 0; index < segments.Length; index++)
{
var exact = Path.Combine(current, segments[index]);
var finalSegment = index == segments.Length - 1;
string? match;
if (finalSegment ? File.Exists(exact) : Directory.Exists(exact))
{
match = exact;
}
else
{
if (!Directory.Exists(current))
{
return false;
}
match = null;
foreach (var entry in Directory.EnumerateFileSystemEntries(current))
{
if (!string.Equals(
Path.GetFileName(entry),
segments[index],
StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (match is not null)
{
// A case-sensitive host can contain two names that are
// indistinguishable to the guest. Refuse an ambiguous
// media path instead of selecting one nondeterministically.
return false;
}
match = entry;
}
}
if (match is null ||
(finalSegment ? !File.Exists(match) : !Directory.Exists(match)))
{
return false;
}
if ((File.GetAttributes(match) & FileAttributes.ReparsePoint) != 0)
{
// App packages do not need host filesystem links. Refusing
// them keeps media resolution inside the configured app0
// tree even when a dump contains a symlink or junction.
return false;
}
current = match;
}
if (!File.Exists(current))
{
return false;
}
resolved = Path.GetFullPath(current);
return true;
}
private static bool TryReadNullTerminatedUtf8(CpuContext ctx, ulong address, int maxLength, out string value)
@@ -110,8 +110,18 @@ public static partial class KernelMemoryCompatExports
private static readonly Dictionary<ulong, string> _mappedRegionNames = new();
private static readonly Dictionary<string, string> _guestMounts = new(StringComparer.OrdinalIgnoreCase);
private static readonly HashSet<string> _tracedStatResults = new(StringComparer.Ordinal);
private static readonly HashSet<string> _negativeStatCache = new(StringComparer.OrdinalIgnoreCase);
private static readonly ConcurrentDictionary<string, ulong> _aprFileSizeCache = new(StringComparer.OrdinalIgnoreCase);
// Both caches memoize host filesystem probe outcomes, so their key
// equivalence must match the host filesystem's: Windows resolves names
// case-insensitively, but Linux hosts are case-sensitive, and an
// ignore-case cache there aliases distinct paths — a cached miss for
// "/app0/DATA.BIN" keeps answering NOT_FOUND for "/app0/Data.bin" even
// though that file exists and a fresh probe would find it.
private static readonly StringComparer HostFsPathComparer =
OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal;
private static readonly StringComparison HostFsPathComparison =
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
private static readonly HashSet<string> _negativeStatCache = new(HostFsPathComparer);
private static readonly ConcurrentDictionary<string, ulong> _aprFileSizeCache = new(HostFsPathComparer);
private static long _nextFileDescriptor = 2;
internal static int AllocateGuestFileDescriptor()
@@ -1966,6 +1976,12 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (KernelSocketCompatExports.TryCloseSocketFd(fd))
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
FileStream? stream;
lock (_fdGate)
{
@@ -2459,7 +2475,20 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
if (!TryFindAvailableDirectMemorySpanLocked(searchStart, searchEnd, alignment, out var candidate, out var rangeAvailable))
bool foundSpan;
ulong candidate;
ulong rangeAvailable;
lock (_memoryGate)
{
foundSpan = TryFindAvailableDirectMemorySpanLocked(
searchStart,
searchEnd,
alignment,
out candidate,
out rangeAvailable);
}
if (!foundSpan)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
@@ -4687,8 +4716,12 @@ public static partial class KernelMemoryCompatExports
matchedHostRoot,
NormalizeMountRelativePath(relativePath)));
var rootWithSeparator = Path.TrimEndingDirectorySeparator(matchedHostRoot) + Path.DirectorySeparatorChar;
if (!string.Equals(candidate, matchedHostRoot, StringComparison.OrdinalIgnoreCase) &&
!candidate.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase))
// Host-semantics comparison: an ignore-case check on a case-sensitive
// host would let a relative path escape into a sibling directory that
// differs from the mount root only by case (root ".../Save" vs
// sibling ".../save").
if (!string.Equals(candidate, matchedHostRoot, HostFsPathComparison) &&
!candidate.StartsWith(rootWithSeparator, HostFsPathComparison))
{
return false;
}
@@ -5909,9 +5942,12 @@ public static partial class KernelMemoryCompatExports
var gapEnd = Math.Min(allocation.Start, effectiveEnd);
if (candidate < gapEnd)
{
spanStart = candidate;
spanLength = gapEnd - candidate;
return true;
var candidateLength = gapEnd - candidate;
if (candidateLength > spanLength)
{
spanStart = candidate;
spanLength = candidateLength;
}
}
if (allocation.Start >= effectiveEnd)
@@ -5922,12 +5958,20 @@ public static partial class KernelMemoryCompatExports
candidate = AlignUp(Math.Max(candidate, allocationEnd), alignment);
if (candidate >= effectiveEnd)
{
return false;
break;
}
}
if (candidate < effectiveEnd)
{
var candidateLength = effectiveEnd - candidate;
if (candidateLength > spanLength)
{
spanStart = candidate;
spanLength = candidateLength;
}
}
spanStart = candidate;
spanLength = effectiveEnd - candidate;
return spanLength != 0;
}
@@ -676,10 +676,15 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
// FreeBSD maps NORMAL to checked non-recursive behavior:
// self-lock is an error, never implicit recursion.
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
// Several Gen5 runtimes layer their own owner/count bookkeeping
// over a NORMAL or ADAPTIVE kernel mutex. Returning EDEADLK here
// leaves that guest bookkeeping out of sync with the HLE owner and
// turns the wrapper into a permanent lock/unlock retry loop. Keep
// the compatibility recursion used by the original implementation;
// ERRORCHECK mutexes still take the strict EDEADLK path below.
state.RecursionCount++;
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
else
{
@@ -160,7 +160,6 @@ internal static class KernelSocketCompatExports
if (!TryParseGuestSockaddrIn(sockaddrAddress, addrlen, ctx, out var ipAddress, out var port))
{
LogNet($"connect sockaddr parse failed: fd={fd} addr=0x{sockaddrAddress:X} len={addrlen}");
RemoveEmulatedSocketFd(fd);
ctx[CpuRegister.Rax] = unchecked((ulong)0xFFFFFFFFFFFFFFFF);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -174,7 +173,6 @@ internal static class KernelSocketCompatExports
if (!IsGuestTcpOutboundAllowed(ipAddress, redirectApplied))
{
LogNet($"connect denied by outbound policy: fd={fd} ip={ipAddress} port={port}");
RemoveEmulatedSocketFd(fd);
ctx[CpuRegister.Rax] = unchecked((ulong)0xFFFFFFFFFFFFFFFF);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -182,7 +180,6 @@ internal static class KernelSocketCompatExports
if (!TryEstablishHostTcpConnection(ipAddress, port, out var client, out var stream))
{
LogNet($"connect failed: fd={fd} ip={ipAddress} port={port}");
RemoveEmulatedSocketFd(fd);
ctx[CpuRegister.Rax] = unchecked((ulong)0xFFFFFFFFFFFFFFFF);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -418,17 +415,6 @@ internal static class KernelSocketCompatExports
state.Connected = false;
}
private static void RemoveEmulatedSocketFd(int fd)
{
lock (Gate)
{
if (Sockets.Remove(fd, out var socketState))
{
DisposeEmulatedSocket(socketState);
}
}
}
private static void LogNet(string message)
{
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NET"), "1", StringComparison.Ordinal))
+102 -16
View File
@@ -29,6 +29,22 @@ public static class Ngs2Exports
private sealed record RackState(ulong SystemHandle, uint RackId);
private sealed record VoiceState(ulong RackHandle, uint VoiceIndex);
[SysAbiExport(
Nid = "koBbCMvOKWw",
ExportName = "sceNgs2SystemCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2SystemCreate(CpuContext ctx)
{
var bufferInfoAddress = ctx[CpuRegister.Rsi];
if (!TryReadContextBuffer(ctx, bufferInfoAddress, out var hostBuffer))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
return CreateSystem(ctx, ctx[CpuRegister.Rdx], hostBuffer);
}
[SysAbiExport(
Nid = "mPYgU4oYpuY",
ExportName = "sceNgs2SystemCreateWithAllocator",
@@ -42,18 +58,12 @@ public static class Ngs2Exports
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
}
if (!TryCreateHandle(ctx, type: 1, ownerHandle: 0, out var handle) ||
!ctx.TryWriteUInt64(outHandleAddress, handle))
if (!TryCreateHandle(ctx, type: 1, ownerHandle: 0, out var handle))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
lock (StateGate)
{
Systems[handle] = new SystemState(unchecked((uint)Interlocked.Increment(ref _nextUid)));
}
return SetReturn(ctx, 0);
return CreateSystem(ctx, outHandleAddress, handle);
}
[SysAbiExport(
@@ -84,6 +94,27 @@ public static class Ngs2Exports
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "cLV4aiT9JpA",
ExportName = "sceNgs2RackCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")]
public static int Ngs2RackCreate(CpuContext ctx)
{
var bufferInfoAddress = ctx[CpuRegister.Rcx];
if (!TryReadContextBuffer(ctx, bufferInfoAddress, out var hostBuffer))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
return CreateRack(
ctx,
ctx[CpuRegister.Rdi],
unchecked((uint)ctx[CpuRegister.Rsi]),
ctx[CpuRegister.R8],
hostBuffer);
}
[SysAbiExport(
Nid = "U546k6orxQo",
ExportName = "sceNgs2RackCreateWithAllocator",
@@ -107,18 +138,12 @@ public static class Ngs2Exports
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
}
if (!TryCreateHandle(ctx, type: 2, systemHandle, out var handle) ||
!ctx.TryWriteUInt64(outHandleAddress, handle))
if (!TryCreateHandle(ctx, type: 2, systemHandle, out var handle))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
lock (StateGate)
{
Racks[handle] = new RackState(systemHandle, rackId);
}
return SetReturn(ctx, 0);
return CreateRack(ctx, systemHandle, rackId, outHandleAddress, handle);
}
[SysAbiExport(
@@ -387,6 +412,67 @@ public static class Ngs2Exports
}
}
private static int CreateSystem(CpuContext ctx, ulong outHandleAddress, ulong handle)
{
if (outHandleAddress == 0)
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
}
if (handle == 0 || !ctx.TryWriteUInt64(outHandleAddress, handle))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
lock (StateGate)
{
Systems[handle] = new SystemState(unchecked((uint)Interlocked.Increment(ref _nextUid)));
}
return SetReturn(ctx, 0);
}
private static int CreateRack(
CpuContext ctx,
ulong systemHandle,
uint rackId,
ulong outHandleAddress,
ulong handle)
{
lock (StateGate)
{
if (!Systems.ContainsKey(systemHandle))
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidSystemHandle);
}
}
if (outHandleAddress == 0)
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidOutAddress);
}
if (handle == 0 || !ctx.TryWriteUInt64(outHandleAddress, handle))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
lock (StateGate)
{
Racks[handle] = new RackState(systemHandle, rackId);
}
return SetReturn(ctx, 0);
}
private static bool TryReadContextBuffer(CpuContext ctx, ulong address, out ulong hostBuffer)
{
hostBuffer = 0;
return address != 0 &&
ctx.TryReadUInt64(address, out hostBuffer) &&
hostBuffer != 0;
}
private static bool TryCreateHandle(CpuContext ctx, uint type, ulong ownerHandle, out ulong handle)
{
handle = 0;
@@ -38,8 +38,8 @@ public static class SystemServiceExports
}
// No system notice screen to skip in the emulator; report "do not skip".
Span<byte> flagBytes = stackalloc byte[sizeof(int)];
BinaryPrimitives.WriteInt32LittleEndian(flagBytes, 0);
Span<byte> flagBytes = stackalloc byte[1];
flagBytes[0] = 0;
return ctx.Memory.TryWrite(flagAddress, flagBytes)
? ctx.SetReturn(0)
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
@@ -0,0 +1,122 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers;
using System.Numerics;
namespace SharpEmu.Libs.VideoOut;
internal sealed class BoundedByteArrayPool : ArrayPool<byte>
{
private readonly object _gate = new();
private readonly int _maxArrayLength;
private readonly ulong _maxCachedBytes;
private readonly int _maxArraysPerBucket;
private readonly Dictionary<int, Stack<byte[]>> _cachedByBucket = [];
private readonly HashSet<byte[]> _leases =
new(System.Collections.Generic.ReferenceEqualityComparer.Instance);
private ulong _cachedBytes;
public BoundedByteArrayPool(
int maxArrayLength,
ulong maxCachedBytes,
int maxArraysPerBucket)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArrayLength);
ArgumentOutOfRangeException.ThrowIfZero(maxCachedBytes);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxArraysPerBucket);
_maxArrayLength = maxArrayLength;
_maxCachedBytes = maxCachedBytes;
_maxArraysPerBucket = maxArraysPerBucket;
}
public override byte[] Rent(int minimumLength)
{
ArgumentOutOfRangeException.ThrowIfNegative(minimumLength);
var length = GetAllocationLength(minimumLength);
byte[]? array = null;
lock (_gate)
{
if (length <= _maxArrayLength &&
_cachedByBucket.TryGetValue(length, out var bucket) &&
bucket.TryPop(out array))
{
_cachedBytes -= (ulong)array.LongLength;
}
array ??= new byte[length];
_leases.Add(array);
}
return array;
}
public override void Return(byte[] array, bool clearArray = false)
{
ArgumentNullException.ThrowIfNull(array);
lock (_gate)
{
if (!_leases.Remove(array))
{
return;
}
}
if (clearArray)
{
Array.Clear(array);
}
lock (_gate)
{
if (array.Length > _maxArrayLength ||
!IsBucketLength(array.Length) ||
(ulong)array.LongLength > _maxCachedBytes -
Math.Min(_cachedBytes, _maxCachedBytes))
{
return;
}
if (!_cachedByBucket.TryGetValue(array.Length, out var bucket))
{
bucket = new Stack<byte[]>();
_cachedByBucket.Add(array.Length, bucket);
}
if (bucket.Count >= _maxArraysPerBucket)
{
return;
}
bucket.Push(array);
_cachedBytes += (ulong)array.LongLength;
}
}
public void Trim()
{
lock (_gate)
{
_cachedByBucket.Clear();
_cachedBytes = 0;
}
}
private int GetAllocationLength(int minimumLength)
{
if (minimumLength <= 16)
{
return 16;
}
if (minimumLength > _maxArrayLength)
{
return minimumLength;
}
return checked((int)BitOperations.RoundUpToPowerOf2((uint)minimumLength));
}
private static bool IsBucketLength(int length) =>
length >= 16 && (length & (length - 1)) == 0;
}
@@ -0,0 +1,38 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Gpu;
namespace SharpEmu.Libs.VideoOut;
internal static class GuestBlendStateNormalizer
{
public static GuestBlendState[] NormalizeIntegerAttachments(
IReadOnlyList<GuestBlendState> blends,
IReadOnlyList<bool> integerAttachments,
out int normalizedCount)
{
if (blends.Count != integerAttachments.Count)
{
throw new ArgumentException(
"color attachment and blend-state counts must match",
nameof(integerAttachments));
}
var normalized = new GuestBlendState[blends.Count];
normalizedCount = 0;
for (var index = 0; index < blends.Count; index++)
{
var blend = blends[index];
if (integerAttachments[index] && blend.Enable)
{
blend = blend with { Enable = false };
normalizedCount++;
}
normalized[index] = blend;
}
return normalized;
}
}
@@ -0,0 +1,67 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Gpu;
namespace SharpEmu.Libs.VideoOut;
internal enum GuestDepthExtentResolutionKind
{
Exact,
TextureAlias,
StaleOneByOne,
Mismatch,
}
internal readonly record struct GuestDepthExtentResolution(
GuestDepthExtentResolutionKind Kind,
uint Width,
uint Height)
{
public bool IsUsable => Kind != GuestDepthExtentResolutionKind.Mismatch;
}
internal static class GuestDepthExtentResolver
{
public static GuestDepthExtentResolution Resolve(
GuestDepthTarget depth,
uint colorWidth,
uint colorHeight,
IReadOnlyList<GuestDrawTexture> textures)
{
if (depth.Width >= colorWidth && depth.Height >= colorHeight)
{
return new GuestDepthExtentResolution(
GuestDepthExtentResolutionKind.Exact,
depth.Width,
depth.Height);
}
var matchingTexture = textures.FirstOrDefault(texture =>
(texture.Address == depth.Address ||
texture.Address == depth.ReadAddress ||
texture.Address == depth.WriteAddress) &&
texture.Width >= colorWidth &&
texture.Height >= colorHeight);
if (matchingTexture is not null)
{
return new GuestDepthExtentResolution(
GuestDepthExtentResolutionKind.TextureAlias,
matchingTexture.Width,
matchingTexture.Height);
}
if (depth.Width == 1 && depth.Height == 1)
{
return new GuestDepthExtentResolution(
GuestDepthExtentResolutionKind.StaleOneByOne,
colorWidth,
colorHeight);
}
return new GuestDepthExtentResolution(
GuestDepthExtentResolutionKind.Mismatch,
depth.Width,
depth.Height);
}
}
+13
View File
@@ -30,6 +30,7 @@ public static class PerfOverlay
StringComparison.Ordinal);
private static long _lastPresentTimestamp;
private static long _sessionStartTimestamp;
private static readonly double[] _frameMilliseconds = new double[FrameHistorySize];
private static int _frameHistoryIndex;
private static long _presentedInWindow;
@@ -56,6 +57,7 @@ public static class PerfOverlay
private static string _line2 = string.Empty;
private static string _line3 = string.Empty;
private static string _line4 = string.Empty;
private static string _line5 = string.Empty;
public static bool Enabled => _enabled;
@@ -65,6 +67,7 @@ public static class PerfOverlay
public static void RecordPresent()
{
var now = Stopwatch.GetTimestamp();
Interlocked.CompareExchange(ref _sessionStartTimestamp, now, 0);
var last = _lastPresentTimestamp;
_lastPresentTimestamp = now;
Interlocked.Increment(ref _presentedInWindow);
@@ -110,6 +113,8 @@ public static class PerfOverlay
DrawString(bgra, 8, y, _line3, 0xB0, 0xD0, 0xFF);
y += LineHeight;
DrawString(bgra, 8, y, _line4, 0xB0, 0xB0, 0xB0);
y += LineHeight;
DrawString(bgra, 8, y, _line5, 0xFF, 0xD0, 0x80);
y += LineHeight + 4;
DrawFrameGraph(bgra, 8, y, PanelWidth - 16, PanelHeight - y - 6);
}
@@ -161,10 +166,18 @@ public static class PerfOverlay
_lastCpuTime = cpuTime;
var drawsPerFrame = _fps > 0.5 ? _drawsPerSecond / _fps : 0;
var sessionStart = Interlocked.Read(ref _sessionStartTimestamp);
var elapsedSeconds = sessionStart == 0
? 0L
: (long)((now - sessionStart) / (double)Stopwatch.Frequency);
var elapsedHours = elapsedSeconds / 3600;
var elapsedMinutes = elapsedSeconds / 60 % 60;
var elapsedRemainingSeconds = elapsedSeconds % 60;
_line1 = $"FPS {_fps:0.0} FLIP {_submittedFps:0.0} {_averageFrameMs:0.0} MS";
_line2 = $"DRAWS {_drawsPerSecond:0}/S {drawsPerFrame:0}/F Q {pendingWork}+{inFlightSubmissions}";
_line3 = $"ALLOC {_allocatedMbPerSecond:0.0} MB/S GC {_gen0PerWindow}/{_gen1PerWindow}/{_gen2PerWindow}";
_line4 = $"CPU {_cpuPercent:0}% HEAP {GC.GetTotalMemory(false) / (1024 * 1024)} MB F1 HIDE";
_line5 = $"TIME {elapsedHours:00}:{elapsedMinutes:00}:{elapsedRemainingSeconds:00}";
}
}
+52 -8
View File
@@ -23,6 +23,7 @@ public static class VideoOutExports
private const int OrbisVideoOutErrorInvalidHandle = unchecked((int)0x8029000B);
private const int OrbisVideoOutErrorInvalidEventQueue = unchecked((int)0x8029000C);
private const int OrbisVideoOutErrorInvalidEvent = unchecked((int)0x8029000D);
private const int OrbisVideoOutErrorUnsupportedOutputMode = unchecked((int)0x80290016);
private const int OrbisVideoOutErrorInvalidOption = unchecked((int)0x8029001A);
private const int SceVideoOutBusTypeMain = 0;
private const int SceVideoOutBufferAttributeOptionNone = 0;
@@ -34,8 +35,11 @@ public static class VideoOutExports
private const int VideoOutBufferAttributeSize = 0x28;
private const int VideoOutBufferAttribute2Size = 0x50;
private const int VideoOutBuffersEntrySize = 0x20;
private const int VideoOutOutputOptionsSize = 0x40;
private const int VideoOutOutputStatusSize = 0x30;
private const int VideoOutVblankStatusSize = 0x28;
private const ulong SceVideoOutOutputModeDefault = 1;
private const ulong SceVideoOutOutputMode119_88Hz = 0xF;
private const ulong SceVideoOutPixelFormatA8R8G8B8Srgb = 0x80000000;
private const ulong SceVideoOutPixelFormatA8B8G8R8Srgb = 0x80002200;
private const ulong SceVideoOutPixelFormatA2R10G10B10 = 0x88060000;
@@ -276,17 +280,46 @@ public static class VideoOutExports
[SysAbiExport(
Nid = "Nv8c-Kb+DUM",
ExportName = "sceVideoOutIsOutputSupported",
Target = Generation.Gen4 | Generation.Gen5,
Target = Generation.Gen5,
LibraryName = "libSceVideoOut")]
public static int VideoOutIsOutputSupported(CpuContext ctx)
{
var busType = unchecked((int)ctx[CpuRegister.Rdi]);
_ = ctx[CpuRegister.Rsi]; // pixelFormat
_ = ctx[CpuRegister.Rdx]; // aspectRatio
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var mode = ctx[CpuRegister.Rsi];
var optionsAddress = ctx[CpuRegister.Rdx];
var reservedPointer = ctx[CpuRegister.Rcx];
var reserved = ctx[CpuRegister.R8];
// The emulator supports any output configuration on the main bus.
// Return 1 (supported) for SceVideoOutBusTypeMain, 0 otherwise.
return busType == SceVideoOutBusTypeMain ? 1 : 0;
if (!TryGetPort(handle, out var port))
{
return OrbisVideoOutErrorInvalidHandle;
}
if (reservedPointer != 0 || reserved != 0)
{
return OrbisVideoOutErrorInvalidValue;
}
if (optionsAddress != 0)
{
Span<byte> options = stackalloc byte[VideoOutOutputOptionsSize];
if (!ctx.Memory.TryRead(optionsAddress, options))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
if (options.ContainsAnyExcept((byte)0))
{
return OrbisVideoOutErrorInvalidOption;
}
}
if (mode != SceVideoOutOutputModeDefault && mode != SceVideoOutOutputMode119_88Hz)
{
return OrbisVideoOutErrorUnsupportedOutputMode;
}
return mode == SceVideoOutOutputModeDefault || port.RefreshRate >= 119 ? 1 : 0;
}
[SysAbiExport(
@@ -348,7 +381,18 @@ public static class VideoOutExports
LibraryName = "libSceVideoOut")]
public static int VideoOutInitializeOutputOptions(CpuContext ctx)
{
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
const int outputOptionsSize = 0x40;
var optionsAddress = ctx[CpuRegister.Rdi];
if (optionsAddress == 0)
{
return OrbisVideoOutErrorInvalidAddress;
}
Span<byte> options = stackalloc byte[outputOptionsSize];
options.Clear();
return ctx.Memory.TryWrite(optionsAddress, options)
? (int)OrbisGen2Result.ORBIS_GEN2_OK
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
[SysAbiExport(
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -110,7 +110,8 @@ public static class SharpEmuLog
}
var normalized = text.Trim();
if (Enum.TryParse<LogLevel>(normalized, ignoreCase: true, out level))
if (Enum.TryParse<LogLevel>(normalized, ignoreCase: true, out level) &&
Enum.IsDefined(level))
{
return true;
}
@@ -127,6 +128,7 @@ public static class SharpEmuLog
return true;
}
level = default;
return false;
}
@@ -162,6 +162,11 @@ public static partial class Gen5SpirvTranslator
return context.TryCompile(out shader, out error);
}
internal static SpirvImageFormat DecodeStorageImageFormat(
uint dataFormat,
uint numberType) =>
CompilationContext.DecodeStorageImageFormat(dataFormat, numberType);
private sealed partial class CompilationContext
{
private const uint ImageDescriptorDwords = 8;
@@ -279,6 +284,7 @@ public static partial class Gen5SpirvTranslator
private uint _workGroupIdInput;
private uint _computeDispatchLimit;
private uint _pushConstantUintPointer;
private uint _subgroupSizeInput;
private uint _subgroupInvocationIdInput;
private uint _waveMaskScratch;
private uint _waveMaskScratchElementPointer;
@@ -293,6 +299,13 @@ public static partial class Gen5SpirvTranslator
Uint,
}
private enum VertexInputComponentKind
{
Float,
Sint,
Uint,
}
private readonly record struct SpirvImageResource(
uint Variable,
uint ImageType,
@@ -305,7 +318,9 @@ public static partial class Gen5SpirvTranslator
private readonly record struct SpirvVertexInput(
uint Variable,
uint Type,
uint ComponentCount);
uint ComponentType,
uint ComponentCount,
VertexInputComponentKind ComponentKind);
private readonly record struct SpirvPixelOutput(
uint Variable,
@@ -961,8 +976,9 @@ public static partial class Gen5SpirvTranslator
{
var binding = _evaluation.ImageBindings[index];
_imageBindingByPc.TryAdd(binding.Pc, index);
var isStorage =
Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode);
var isStorage = Gen5ShaderTranslator.RequiresStorageImage(
binding,
_evaluation.ImageBindings);
var (format, componentKind) =
DecodeImageFormat(binding.ResourceDescriptor);
var componentType = componentKind switch
@@ -1044,58 +1060,62 @@ public static partial class Gen5SpirvTranslator
return (SpirvImageFormat.Unknown, ImageComponentKind.Float);
}
return (dataFormat, numberType) switch
var kind = numberType switch
{
(1, _) => (SpirvImageFormat.R8, ImageComponentKind.Float),
(2, _) => (SpirvImageFormat.R16f, ImageComponentKind.Float),
(3, _) => (SpirvImageFormat.Rg8, ImageComponentKind.Float),
(4, 4) => (SpirvImageFormat.R32ui, ImageComponentKind.Uint),
(4, 5) => (SpirvImageFormat.R32i, ImageComponentKind.Sint),
(4, _) => (SpirvImageFormat.R32f, ImageComponentKind.Float),
(5, 4) => (SpirvImageFormat.Rg16ui, ImageComponentKind.Uint),
(5, 5) => (SpirvImageFormat.Rg16i, ImageComponentKind.Sint),
(5, 0) => (SpirvImageFormat.Rg16, ImageComponentKind.Float),
(5, _) => (SpirvImageFormat.Rg16f, ImageComponentKind.Float),
(6 or 7, _) => (
SpirvImageFormat.R11fG11fB10f,
ImageComponentKind.Float),
(9, 4) => (SpirvImageFormat.Rgb10A2ui, ImageComponentKind.Uint),
(9, _) => (SpirvImageFormat.Rgb10A2, ImageComponentKind.Float),
(10, 4) => (SpirvImageFormat.Rgba8ui, ImageComponentKind.Uint),
(10, 5) => (SpirvImageFormat.Rgba8i, ImageComponentKind.Sint),
(10, _) => (SpirvImageFormat.Rgba8, ImageComponentKind.Float),
(11, 4) => (SpirvImageFormat.Rg32ui, ImageComponentKind.Uint),
(11, 5) => (SpirvImageFormat.Rg32i, ImageComponentKind.Sint),
(11, _) => (SpirvImageFormat.Rg32f, ImageComponentKind.Float),
(12, 4) => (SpirvImageFormat.Rgba16ui, ImageComponentKind.Uint),
(12, 5) => (SpirvImageFormat.Rgba16i, ImageComponentKind.Sint),
(12, 0) => (SpirvImageFormat.Rgba16, ImageComponentKind.Float),
(12, _) => (SpirvImageFormat.Rgba16f, ImageComponentKind.Float),
(13 or 14, 4) => (
SpirvImageFormat.Rgba32ui,
ImageComponentKind.Uint),
(13 or 14, 5) => (
SpirvImageFormat.Rgba32i,
ImageComponentKind.Sint),
(13 or 14, _) => (
SpirvImageFormat.Rgba32f,
ImageComponentKind.Float),
(20, _) => (SpirvImageFormat.R32ui, ImageComponentKind.Uint),
(22, _) => (SpirvImageFormat.Rgba16f, ImageComponentKind.Float),
(29, _) => (SpirvImageFormat.R32f, ImageComponentKind.Float),
(36, _) => (SpirvImageFormat.R8, ImageComponentKind.Float),
(49, _) => (SpirvImageFormat.R8ui, ImageComponentKind.Uint),
(56 or 62 or 64, _) => (
SpirvImageFormat.Rgba8,
ImageComponentKind.Float),
(71, _) => (SpirvImageFormat.Rgba16f, ImageComponentKind.Float),
(75, _) => (SpirvImageFormat.Rg32f, ImageComponentKind.Float),
(_, 4) => (SpirvImageFormat.Unknown, ImageComponentKind.Uint),
(_, 5) => (SpirvImageFormat.Unknown, ImageComponentKind.Sint),
_ => (SpirvImageFormat.Unknown, ImageComponentKind.Float),
4 => ImageComponentKind.Uint,
5 => ImageComponentKind.Sint,
_ => ImageComponentKind.Float,
};
return (DecodeStorageImageFormat(dataFormat, numberType), kind);
}
internal static SpirvImageFormat DecodeStorageImageFormat(
uint dataFormat,
uint numberType) =>
(dataFormat, numberType) switch
{
(1, 0 or 9) => SpirvImageFormat.R8,
(1, 1) => SpirvImageFormat.R8Snorm,
(1, 4) => SpirvImageFormat.R8ui,
(1, 5) => SpirvImageFormat.R8i,
(2, 0) => SpirvImageFormat.R16,
(2, 1) => SpirvImageFormat.R16Snorm,
(2, 4) => SpirvImageFormat.R16ui,
(2, 5) => SpirvImageFormat.R16i,
(2, 7) => SpirvImageFormat.R16f,
(3, 0 or 9) => SpirvImageFormat.Rg8,
(3, 1) => SpirvImageFormat.Rg8Snorm,
(3, 4) => SpirvImageFormat.Rg8ui,
(3, 5) => SpirvImageFormat.Rg8i,
(4, 4) => SpirvImageFormat.R32ui,
(4, 5) => SpirvImageFormat.R32i,
(4, 7) => SpirvImageFormat.R32f,
(5, 0) => SpirvImageFormat.Rg16,
(5, 1) => SpirvImageFormat.Rg16Snorm,
(5, 4) => SpirvImageFormat.Rg16ui,
(5, 5) => SpirvImageFormat.Rg16i,
(5, 7) => SpirvImageFormat.Rg16f,
(6 or 7, 7) => SpirvImageFormat.R11fG11fB10f,
(8 or 9, 0) => SpirvImageFormat.Rgb10A2,
(8 or 9, 4) => SpirvImageFormat.Rgb10A2ui,
(10, 0 or 9) => SpirvImageFormat.Rgba8,
(10, 1) => SpirvImageFormat.Rgba8Snorm,
(10, 4) => SpirvImageFormat.Rgba8ui,
(10, 5) => SpirvImageFormat.Rgba8i,
(11, 4) => SpirvImageFormat.Rg32ui,
(11, 5) => SpirvImageFormat.Rg32i,
(11, 7) => SpirvImageFormat.Rg32f,
(12, 0) => SpirvImageFormat.Rgba16,
(12, 1) => SpirvImageFormat.Rgba16Snorm,
(12, 4) => SpirvImageFormat.Rgba16ui,
(12, 5) => SpirvImageFormat.Rgba16i,
(12, 7) => SpirvImageFormat.Rgba16f,
(13 or 14, 4) => SpirvImageFormat.Rgba32ui,
(13 or 14, 5) => SpirvImageFormat.Rgba32i,
(13 or 14, 7) => SpirvImageFormat.Rgba32f,
_ => SpirvImageFormat.Unknown,
};
private void DeclareStageInterface()
{
if (UsesSubgroupOperations())
@@ -1111,6 +1131,18 @@ public static partial class Gen5SpirvTranslator
(uint)SpirvBuiltIn.SubgroupLocalInvocationId);
_interfaces.Add(_subgroupInvocationIdInput);
if (_emulateWave64)
{
_subgroupSizeInput = _module.AddGlobalVariable(
subgroupPointer,
SpirvStorageClass.Input);
_module.AddDecoration(
_subgroupSizeInput,
SpirvDecoration.BuiltIn,
(uint)SpirvBuiltIn.SubgroupSize);
_interfaces.Add(_subgroupSizeInput);
}
if (_waveLaneCount == 64)
{
_localInvocationIndexInput = _module.AddGlobalVariable(
@@ -1266,12 +1298,23 @@ public static partial class Gen5SpirvTranslator
{
foreach (var input in _evaluation.VertexInputs ?? [])
{
var componentKind = input.NumberFormat switch
{
4 => VertexInputComponentKind.Uint,
5 => VertexInputComponentKind.Sint,
_ => VertexInputComponentKind.Float,
};
var componentType = componentKind switch
{
VertexInputComponentKind.Uint => _uintType,
VertexInputComponentKind.Sint => _intType,
_ => _floatType,
};
var type = input.ComponentCount switch
{
1u => _floatType,
2u => _vec2Type,
3u => _vec3Type,
4u => _vec4Type,
1u => componentType,
>= 2u and <= 4u =>
_module.TypeVector(componentType, input.ComponentCount),
_ => 0u,
};
if (type == 0)
@@ -1293,7 +1336,9 @@ public static partial class Gen5SpirvTranslator
new SpirvVertexInput(
variable,
type,
input.ComponentCount));
componentType,
input.ComponentCount,
componentKind));
_interfaces.Add(variable);
}
}
@@ -3091,12 +3136,14 @@ public static partial class Gen5SpirvTranslator
return true;
}
var imageLoad = Gen5ShaderTranslator.IsImageLoadOperation(instruction.Opcode);
var storage = Gen5ShaderTranslator.IsStorageImageOperation(instruction.Opcode);
for (var index = 0; index < _evaluation.ImageBindings.Count; index++)
{
var candidate = _evaluation.ImageBindings[index];
if (candidate.Control.ScalarResource != control.ScalarResource ||
candidate.Control.ScalarSampler != control.ScalarSampler ||
Gen5ShaderTranslator.IsImageLoadOperation(candidate.Opcode) != imageLoad ||
Gen5ShaderTranslator.IsStorageImageOperation(candidate.Opcode) != storage ||
!HasSameScalarDefinitions(
candidate.Pc,
@@ -3173,10 +3220,13 @@ public static partial class Gen5SpirvTranslator
? loaded
: _module.AddInstruction(
SpirvOp.CompositeExtract,
_floatType,
input.ComponentType,
loaded,
component);
StoreV(control.VectorData + component, Bitcast(_uintType, value));
var raw = input.ComponentKind == VertexInputComponentKind.Uint
? value
: Bitcast(_uintType, value);
StoreV(control.VectorData + component, raw);
}
return true;
@@ -5097,12 +5147,14 @@ public static partial class Gen5SpirvTranslator
SpirvOp.UConvert,
_ulongType,
maskedLane));
return _module.AddInstruction(
SpirvOp.Select,
_ulongType,
IsCurrentLaneInRdnaWave(),
shifted,
_module.Constant64(_ulongType, 0));
return _emulateWave64
? shifted
: _module.AddInstruction(
SpirvOp.Select,
_ulongType,
IsCurrentLaneInRdnaWave(),
shifted,
_module.Constant64(_ulongType, 0));
}
private uint IsCurrentLaneInRdnaWave() =>
@@ -5139,6 +5191,11 @@ public static partial class Gen5SpirvTranslator
0);
if (_emulateWave64)
{
var high = _module.AddInstruction(
SpirvOp.CompositeExtract,
_uintType,
ballot,
1);
var subgroupLane =
Load(_uintType, _subgroupInvocationIdInput);
var firstLane = _module.AddInstruction(
@@ -5150,6 +5207,16 @@ public static partial class Gen5SpirvTranslator
EmitConditional(firstLane, () =>
{
Store(WaveMaskScratchPointer(half), low);
var nativeWave64 = _module.AddInstruction(
SpirvOp.UGreaterThanEqual,
_boolType,
Load(_uintType, _subgroupSizeInput),
UInt(64));
EmitConditional(nativeWave64, () =>
{
Store(WaveMaskScratchPointer(UInt(1)), high);
});
});
EmitWave64Barrier();
var lowMask = Load(
@@ -251,6 +251,7 @@ public enum SpirvBuiltIn : uint
LocalInvocationId = 27,
GlobalInvocationId = 28,
LocalInvocationIndex = 29,
SubgroupSize = 36,
SubgroupLocalInvocationId = 41,
}
@@ -1576,11 +1576,37 @@ public static class Gen5ShaderTranslator
private static bool IsMimgInstruction(string name) =>
name.StartsWith("Image", StringComparison.Ordinal);
public static bool IsImageLoadOperation(string name) =>
name.StartsWith("ImageLoad", StringComparison.Ordinal);
public static bool IsStorageImageOperation(string name) =>
name.StartsWith("ImageLoad", StringComparison.Ordinal) ||
name.StartsWith("ImageStore", StringComparison.Ordinal) ||
name.StartsWith("ImageAtomic", StringComparison.Ordinal);
public static bool RequiresStorageImage(
Gen5ImageBinding binding,
IReadOnlyList<Gen5ImageBinding> stageBindings)
{
if (IsStorageImageOperation(binding.Opcode))
{
return true;
}
if (!IsImageLoadOperation(binding.Opcode))
{
return false;
}
// IMAGE_LOAD itself is read-only and maps naturally to OpImageFetch,
// including for block-compressed textures which Vulkan cannot expose
// as storage images. Keep it as storage only when the same resolved
// descriptor is also written in this shader stage, preserving coherent
// read/write access through one storage-image representation.
return stageBindings.Any(candidate =>
IsStorageImageOperation(candidate.Opcode) &&
binding.ResourceDescriptor.SequenceEqual(candidate.ResourceDescriptor));
}
public static bool IsDataShareAtomic(string name) => name switch
{
"DsAddU32" or "DsSubU32" or "DsIncU32" or "DsDecU32" or
@@ -1872,8 +1898,9 @@ public static class Gen5ShaderTranslator
destinations = [Gen5Operand.Vector(word & 0xFF)];
if (opcode == "VReadlaneB32")
{
// The scalar destination lives in the low vdst byte (bits 0-7);
// bits 8-14 are the VOP3B carry-out sdst, which readlane lacks.
// V_READLANE uses the VOP3A vdst byte even though the
// destination register is scalar. Bits 8-14 are the
// distinct sdst field used by VOP3B encodings.
destinations = [Gen5Operand.Scalar(word & 0xFF)];
}
var isVop3B = IsVop3BOpcode((word >> 16) & 0x3FF);
@@ -0,0 +1,142 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Vulkan;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
public sealed class Gen5VertexInputSpirvTests
{
[Theory]
[InlineData(0u, null)]
[InlineData(4u, 0u)]
[InlineData(5u, 1u)]
public void VertexInputTypeMatchesGuestNumberFormat(
uint numberFormat,
uint? expectedIntegerSignedness)
{
var instruction = new Gen5ShaderInstruction(
0,
Gen5ShaderEncoding.Mubuf,
"BufferLoadFormatXyzw",
[],
[],
[],
new Gen5BufferMemoryControl(
4,
5,
0,
0,
0,
IndexEnabled: true,
OffsetEnabled: false,
Glc: false,
Slc: false));
var end = new Gen5ShaderInstruction(
4,
Gen5ShaderEncoding.Sopp,
"SEndpgm",
[],
[],
[],
null);
var state = new Gen5ShaderState(
new Gen5ShaderProgram(0, [instruction, end]),
[],
null);
var registers = new uint[256];
var data = new byte[16];
var evaluation = new Gen5ShaderEvaluation(
registers,
registers,
[],
[],
VertexInputs:
[
new Gen5VertexInputBinding(
0,
0,
4,
10,
numberFormat,
0x1000,
4,
0,
data,
data.Length,
DataPooled: false),
]);
Assert.True(
Gen5SpirvTranslator.TryCompileVertexShader(
state,
evaluation,
out var shader,
out var error),
error);
var module = ParseModule(shader.Spirv);
var inputVariable = module.Single(candidate =>
candidate.Opcode == SpirvOp.Decorate &&
candidate.Operands.Length >= 3 &&
candidate.Operands[1] == (uint)SpirvDecoration.Location &&
candidate.Operands[2] == 0).Operands[0];
var pointerType = module.Single(candidate =>
candidate.Opcode == SpirvOp.Variable &&
candidate.Operands[1] == inputVariable).Operands[0];
var vectorType = module.Single(candidate =>
candidate.Opcode == SpirvOp.TypePointer &&
candidate.Operands[0] == pointerType).Operands[2];
var componentType = module.Single(candidate =>
candidate.Opcode == SpirvOp.TypeVector &&
candidate.Operands[0] == vectorType).Operands[1];
if (expectedIntegerSignedness is { } signedness)
{
Assert.Contains(
module,
candidate =>
candidate.Opcode == SpirvOp.TypeInt &&
candidate.Operands[0] == componentType &&
candidate.Operands[1] == 32 &&
candidate.Operands[2] == signedness);
}
else
{
Assert.Contains(
module,
candidate =>
candidate.Opcode == SpirvOp.TypeFloat &&
candidate.Operands[0] == componentType &&
candidate.Operands[1] == 32);
}
}
private static IReadOnlyList<ParsedInstruction> ParseModule(byte[] spirv)
{
var instructions = new List<ParsedInstruction>();
for (var offset = 5; offset < spirv.Length / sizeof(uint);)
{
var header = BitConverter.ToUInt32(spirv, offset * sizeof(uint));
var wordCount = (int)(header >> 16);
Assert.True(wordCount > 0);
var operands = new uint[wordCount - 1];
for (var index = 0; index < operands.Length; index++)
{
operands[index] = BitConverter.ToUInt32(
spirv,
(offset + index + 1) * sizeof(uint));
}
instructions.Add(
new ParsedInstruction((SpirvOp)(ushort)header, operands));
offset += wordCount;
}
return instructions;
}
private sealed record ParsedInstruction(SpirvOp Opcode, uint[] Operands);
}
@@ -0,0 +1,198 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Audio;
using Xunit;
namespace SharpEmu.Libs.Tests.Audio;
[CollectionDefinition("AjmState", DisableParallelization = true)]
public sealed class AjmStateCollection
{
public const string Name = "AjmState";
}
[Collection(AjmStateCollection.Name)]
public sealed class AjmExportsTests : IDisposable
{
private const int InvalidContext = unchecked((int)0x80930002);
private const int InvalidInstance = unchecked((int)0x80930003);
private const int InvalidParameter = unchecked((int)0x80930005);
private const int CodecAlreadyRegistered = unchecked((int)0x80930009);
private const int CodecNotRegistered = unchecked((int)0x8093000A);
private const ulong MemoryBase = 0x1_0000_0000;
private const ulong ContextAddress = MemoryBase + 0x100;
private const ulong InstanceAddress = MemoryBase + 0x200;
private readonly FakeCpuMemory _memory = new(MemoryBase, 0x1000);
private readonly CpuContext _ctx;
public AjmExportsTests()
{
AjmExports.ResetForTests();
_ctx = new CpuContext(_memory, Generation.Gen5);
}
[Fact]
public void InstanceLifecycle_RegisteredCodecCreatesAndDestroysInstance()
{
var contextId = Initialize();
Assert.Equal(0, RegisterCodec(contextId, 1));
Assert.Equal(0, CreateInstance(contextId, 1, 0x401, InstanceAddress));
Assert.Equal(0x4001u, ReadUInt32(InstanceAddress));
Assert.Equal(0, DestroyInstance(contextId, 0x4001));
Assert.Equal(InvalidInstance, DestroyInstance(contextId, 0x4001));
}
[Fact]
public void InstanceCreate_UnregisteredCodecDoesNotWriteOutput()
{
var contextId = Initialize();
WriteUInt32(InstanceAddress, 0xCCCCCCCC);
Assert.Equal(CodecNotRegistered, CreateInstance(contextId, 1, 0x401, InstanceAddress));
Assert.Equal(0xCCCCCCCCu, ReadUInt32(InstanceAddress));
}
[Fact]
public void InstanceCreate_FaultingOutputDoesNotAdvanceInstanceId()
{
var contextId = Initialize();
Assert.Equal(0, RegisterCodec(contextId, 1));
Assert.Equal(InvalidParameter, CreateInstance(contextId, 1, 0x401, MemoryBase + 0x1000));
Assert.Equal(0, CreateInstance(contextId, 1, 0x401, InstanceAddress));
Assert.Equal(0x4001u, ReadUInt32(InstanceAddress));
Assert.Equal(0, DestroyInstance(contextId, 0x4001));
}
[Fact]
public void ModuleRegister_RejectsDuplicateAndUnknownContext()
{
var contextId = Initialize();
Assert.Equal(0, RegisterCodec(contextId, 1));
Assert.Equal(CodecAlreadyRegistered, RegisterCodec(contextId, 1));
Assert.Equal(InvalidContext, RegisterCodec(contextId + 1, 1));
}
[Fact]
public void InstanceDestroy_RejectsUnknownContextAndSlot()
{
var contextId = Initialize();
Assert.Equal(InvalidContext, DestroyInstance(contextId + 1, 1));
Assert.Equal(InvalidInstance, DestroyInstance(contextId, 0));
Assert.Equal(InvalidInstance, DestroyInstance(contextId, 1));
}
[Fact]
public void InstanceDestroy_ResolvesInstanceByMaskedSlot()
{
var contextId = Initialize();
Assert.Equal(0, RegisterCodec(contextId, 1));
Assert.Equal(0, CreateInstance(contextId, 1, 0x401, InstanceAddress));
Assert.Equal(0, DestroyInstance(contextId, 0x8001));
Assert.Equal(InvalidInstance, DestroyInstance(contextId, 0x4001));
}
[Fact]
public void ConcurrentInstanceCreates_ProduceUniqueLiveIds()
{
const int count = 32;
var contextId = Initialize();
Assert.Equal(0, RegisterCodec(contextId, 1));
var results = Enumerable.Range(0, count)
.AsParallel()
.Select(index =>
{
var outputAddress = MemoryBase + 0x300 + unchecked((ulong)(index * sizeof(uint)));
var context = new CpuContext(_memory, Generation.Gen5)
{
[CpuRegister.Rdi] = contextId,
[CpuRegister.Rsi] = 1,
[CpuRegister.Rdx] = 0x401,
[CpuRegister.Rcx] = outputAddress,
};
var result = AjmExports.AjmInstanceCreate(context);
return (result, instanceId: ReadUInt32(outputAddress));
})
.ToArray();
Assert.All(results, result => Assert.Equal(0, result.result));
Assert.Equal(count, results.Select(result => result.instanceId).Distinct().Count());
Assert.All(results, result => Assert.Equal(0, DestroyInstance(contextId, result.instanceId)));
}
[Fact]
public void InstanceLifecycleExports_RegisterForBothGenerations()
{
foreach (var generation in new[] { Generation.Gen4, Generation.Gen5 })
{
var manager = new ModuleManager();
manager.RegisterExports(SharpEmu.Generated.SysAbiExportRegistry.CreateExports(generation));
Assert.True(manager.TryGetExport("AxoDrINp4J8", out var create));
Assert.Equal("sceAjmInstanceCreate", create.Name);
Assert.True(manager.TryGetExport("RbLbuKv8zho", out var destroy));
Assert.Equal("sceAjmInstanceDestroy", destroy.Name);
}
}
public void Dispose()
{
AjmExports.ResetForTests();
}
private uint Initialize()
{
_ctx[CpuRegister.Rdi] = 0;
_ctx[CpuRegister.Rsi] = ContextAddress;
Assert.Equal(0, AjmExports.AjmInitialize(_ctx));
return ReadUInt32(ContextAddress);
}
private int RegisterCodec(uint contextId, uint codecType)
{
_ctx[CpuRegister.Rdi] = contextId;
_ctx[CpuRegister.Rsi] = codecType;
_ctx[CpuRegister.Rdx] = 0;
return AjmExports.AjmModuleRegister(_ctx);
}
private int CreateInstance(uint contextId, uint codecType, ulong flags, ulong outputAddress)
{
_ctx[CpuRegister.Rdi] = contextId;
_ctx[CpuRegister.Rsi] = codecType;
_ctx[CpuRegister.Rdx] = flags;
_ctx[CpuRegister.Rcx] = outputAddress;
return AjmExports.AjmInstanceCreate(_ctx);
}
private int DestroyInstance(uint contextId, uint instanceId)
{
_ctx[CpuRegister.Rdi] = contextId;
_ctx[CpuRegister.Rsi] = instanceId;
return AjmExports.AjmInstanceDestroy(_ctx);
}
private uint ReadUInt32(ulong address)
{
Span<byte> value = stackalloc byte[sizeof(uint)];
Assert.True(_memory.TryRead(address, value));
return BinaryPrimitives.ReadUInt32LittleEndian(value);
}
private void WriteUInt32(ulong address, uint value)
{
Span<byte> bytes = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(bytes, value);
Assert.True(_memory.TryWrite(address, bytes));
}
}
@@ -0,0 +1,154 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.AvPlayer;
using Xunit;
namespace SharpEmu.Libs.Tests.AvPlayer;
[CollectionDefinition("AvPlayerPathState", DisableParallelization = true)]
public sealed class AvPlayerPathStateCollection;
[Collection("AvPlayerPathState")]
public sealed class AvPlayerPathTests : IDisposable
{
private readonly string? _originalApp0;
private readonly string _tempRoot;
private readonly string _app0Root;
public AvPlayerPathTests()
{
_originalApp0 = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
_tempRoot = Path.Combine(
Path.GetTempPath(),
$"sharpemu-avplayer-{Guid.NewGuid():N}");
_app0Root = Path.Combine(_tempRoot, "app0");
Directory.CreateDirectory(_app0Root);
Environment.SetEnvironmentVariable("SHARPEMU_APP0_DIR", _app0Root);
}
[Fact]
public void UnrealRelativeFileUriAnchorsAtApp0AndResolvesMedia()
{
var mediaPath = CreateFile("Project/Content/Movies/Intro.mp4");
var resolved = AvPlayerExports.ResolveGuestPath(
"file://../../../Project/Content/Movies/Intro.mp4");
Assert.NotNull(resolved);
Assert.Equal(File.ReadAllBytes(mediaPath), File.ReadAllBytes(resolved));
AssertPathIsInsideApp0(resolved);
}
[Fact]
public void RelativeFileUriCannotEscapeApp0()
{
var outsidePath = Path.Combine(_tempRoot, "outside.mp4");
File.WriteAllBytes(outsidePath, [0x7F]);
var resolved = AvPlayerExports.ResolveGuestPath("file://../outside.mp4");
Assert.Null(resolved);
Assert.Null(AvPlayerExports.ResolveGuestPath("file://%2e%2e/outside.mp4"));
Assert.Null(AvPlayerExports.ResolveGuestPath("app0:/../../outside.mp4"));
Assert.Null(AvPlayerExports.ResolveGuestPath("file://..%2foutside.mp4"));
Assert.Null(AvPlayerExports.ResolveGuestPath("file://../outside.mp4?query"));
Assert.Null(AvPlayerExports.ResolveGuestPath("file://../outside%ZZ.mp4"));
Assert.Null(AvPlayerExports.ResolveGuestPath("file://../outside%00.mp4"));
}
[Fact]
public void AbsoluteHostPathsCannotBypassApp0()
{
var outsidePath = Path.Combine(_tempRoot, "outside.mp4");
File.WriteAllBytes(outsidePath, [0x7F]);
Assert.Null(AvPlayerExports.ResolveGuestPath(outsidePath));
Assert.Null(AvPlayerExports.ResolveGuestPath(new Uri(outsidePath).AbsoluteUri));
}
[Fact]
public void NonFileUrisAndApp0LookalikesAreRejected()
{
CreateFile("evil/intro.mp4");
Assert.Null(AvPlayerExports.ResolveGuestPath("https://example.test/intro.mp4"));
Assert.Null(AvPlayerExports.ResolveGuestPath("file://server/share/intro.mp4"));
Assert.Null(AvPlayerExports.ResolveGuestPath("/app0evil/intro.mp4"));
}
[Fact]
public void SymlinkCannotEscapeApp0()
{
if (OperatingSystem.IsWindows())
{
return;
}
var outsideDirectory = Path.Combine(_tempRoot, "outside");
Directory.CreateDirectory(outsideDirectory);
File.WriteAllBytes(Path.Combine(outsideDirectory, "secret.mp4"), [0x7F]);
Directory.CreateSymbolicLink(
Path.Combine(_app0Root, "linked"),
outsideDirectory);
Assert.Null(AvPlayerExports.ResolveGuestPath("app0:/linked/secret.mp4"));
}
[Fact]
public void App0UriStillResolvesMedia()
{
var mediaPath = CreateFile("movies/intro.mp4");
var resolved = AvPlayerExports.ResolveGuestPath("app0:/movies/intro.mp4");
Assert.Equal(Path.GetFullPath(mediaPath), resolved);
Assert.Equal(
Path.GetFullPath(mediaPath),
AvPlayerExports.ResolveGuestPath("movies/intro.mp4"));
Assert.Equal(
Path.GetFullPath(mediaPath),
AvPlayerExports.ResolveGuestPath("file:///app0/movies/intro.mp4"));
Assert.Equal(
Path.GetFullPath(mediaPath),
AvPlayerExports.ResolveGuestPath("app0:movies/intro.mp4"));
}
[Fact]
public void GuestMediaLookupIsCaseInsensitiveOnCaseSensitiveHosts()
{
var mediaPath = CreateFile("project/content/movies/intro.mp4");
var resolved = AvPlayerExports.ResolveGuestPath(
"file://../../../PROJECT/CONTENT/MOVIES/INTRO.MP4");
Assert.NotNull(resolved);
Assert.Equal(File.ReadAllBytes(mediaPath), File.ReadAllBytes(resolved));
AssertPathIsInsideApp0(resolved);
}
public void Dispose()
{
Environment.SetEnvironmentVariable("SHARPEMU_APP0_DIR", _originalApp0);
Directory.Delete(_tempRoot, recursive: true);
}
private string CreateFile(string relativePath)
{
var path = Path.Combine(_app0Root, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllBytes(path, [0x01, 0x02, 0x03]);
return path;
}
private void AssertPathIsInsideApp0(string resolved)
{
var rootWithSeparator =
Path.TrimEndingDirectorySeparator(Path.GetFullPath(_app0Root)) +
Path.DirectorySeparatorChar;
Assert.StartsWith(
rootWithSeparator,
Path.GetFullPath(resolved),
StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,112 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Reflection;
using SharpEmu.Core.Cpu.Native;
using SharpEmu.HLE;
using Xunit;
namespace SharpEmu.Libs.Tests.Cpu;
public sealed class GuestThreadBlockWaiterRepresentationTests
{
[Fact]
public void SchedulerStoresOnlyTheWaiterObjectRepresentation()
{
var stateType = typeof(DirectExecutionBackend).GetNestedType(
"GuestThreadState",
BindingFlags.NonPublic);
Assert.NotNull(stateType);
var waiterProperty = stateType.GetProperty(
"BlockWaiter",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
Assert.NotNull(waiterProperty);
Assert.Equal(typeof(IGuestThreadBlockWaiter), waiterProperty.PropertyType);
Assert.Null(stateType.GetProperty(
"BlockResumeHandler",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic));
Assert.Null(stateType.GetProperty(
"BlockWakeHandler",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic));
var registerMethods = typeof(DirectExecutionBackend)
.GetMethods(BindingFlags.Instance | BindingFlags.NonPublic)
.Where(method => method.Name == "RegisterBlockedGuestThreadContinuation")
.ToArray();
var registerMethod = Assert.Single(registerMethods);
Assert.Contains(
registerMethod.GetParameters(),
parameter => parameter.ParameterType == typeof(IGuestThreadBlockWaiter));
Assert.DoesNotContain(
registerMethod.GetParameters(),
parameter => IsFuncParameter(parameter.ParameterType));
var consumeMethods = typeof(GuestThreadExecution)
.GetMethods(BindingFlags.Static | BindingFlags.Public)
.Where(method => method.Name == nameof(GuestThreadExecution.TryConsumeCurrentThreadBlock));
Assert.DoesNotContain(
consumeMethods.SelectMany(method => method.GetParameters()),
parameter => IsFuncParameter(parameter.ParameterType));
}
[Fact]
public void DelegateCompatibilityBridgeIsConsumedAsOneWaiterObject()
{
var previousThread = GuestThreadExecution.EnterGuestThread(0x1234);
try
{
var canWake = false;
var wakeCalls = 0;
var resumeCalls = 0;
Assert.True(GuestThreadExecution.RequestCurrentThreadBlock(
context: null,
reason: "test_wait",
wakeKey: "test_waiter:1",
resumeHandler: () =>
{
resumeCalls++;
return 42;
},
wakeHandler: () =>
{
wakeCalls++;
return canWake;
}));
Assert.True(GuestThreadExecution.TryConsumeCurrentThreadBlock(
out var reason,
out _,
out var hasContinuation,
out var wakeKey,
out IGuestThreadBlockWaiter? waiter,
out var deadline));
Assert.Equal("test_wait", reason);
Assert.Equal("test_waiter:1", wakeKey);
Assert.False(hasContinuation);
Assert.Equal(0, deadline);
Assert.NotNull(waiter);
Assert.False(waiter.TryWake());
canWake = true;
Assert.True(waiter.TryWake());
Assert.Equal(42, waiter.Resume());
Assert.Equal(2, wakeCalls);
Assert.Equal(1, resumeCalls);
}
finally
{
GuestThreadExecution.RestoreGuestThread(previousThread);
}
}
private static bool IsFuncParameter(Type parameterType)
{
var type = parameterType.IsByRef
? parameterType.GetElementType()
: parameterType;
return type is not null &&
type.IsGenericType &&
type.GetGenericTypeDefinition() == typeof(Func<>);
}
}
@@ -9,8 +9,20 @@ using Xunit;
namespace SharpEmu.Libs.Tests.Kernel;
[CollectionDefinition(KernelMemoryCompatStateCollection.Name, DisableParallelization = true)]
public sealed class KernelMemoryCompatStateCollection
{
public const string Name = "KernelMemoryCompatState";
}
[Collection(KernelMemoryCompatStateCollection.Name)]
public sealed class KernelMemoryCompatExportsTests
{
private const ulong GuestMemoryBase = 0x1_0000_0000;
private const ulong AllocationOutAddress = GuestMemoryBase + 0x100;
private const ulong SpanStartOutAddress = GuestMemoryBase + 0x108;
private const ulong SpanSizeOutAddress = GuestMemoryBase + 0x110;
[Fact]
public void PosixStat_MissingFileReturnsMinusOne()
{
@@ -63,4 +75,93 @@ public sealed class KernelMemoryCompatExportsTests
CultureInfo.CurrentCulture = previousCulture;
}
}
[Fact]
public void AvailableDirectMemorySize_FragmentedRangeReturnsLargestAlignedSpan()
{
const ulong firstAllocationStart = 0x0020_0000;
const ulong firstAllocationLength = 0x0020_0000;
const ulong secondAllocationStart = 0x00C0_0000;
const ulong secondAllocationLength = 0x0040_0000;
var context = new CpuContext(new FakeCpuMemory(GuestMemoryBase, 0x1000), Generation.Gen5);
try
{
AllocateDirectMemory(context, firstAllocationStart, firstAllocationLength);
AllocateDirectMemory(context, secondAllocationStart, secondAllocationLength);
QueryAvailableDirectMemory(context, 0, 0x0100_0000, 0x4000);
Assert.True(context.TryReadUInt64(SpanStartOutAddress, out var spanStart));
Assert.True(context.TryReadUInt64(SpanSizeOutAddress, out var spanSize));
Assert.Equal(0x0040_0000UL, spanStart);
Assert.Equal(0x0080_0000UL, spanSize);
}
finally
{
ReleaseDirectMemory(context, firstAllocationStart, firstAllocationLength);
ReleaseDirectMemory(context, secondAllocationStart, secondAllocationLength);
}
}
[Fact]
public void AvailableDirectMemorySize_AppliesAlignmentBeforeComparingSpans()
{
const ulong allocationStart = 0x0070_0000;
const ulong allocationLength = 0x0010_0000;
var context = new CpuContext(new FakeCpuMemory(GuestMemoryBase, 0x1000), Generation.Gen5);
try
{
AllocateDirectMemory(context, allocationStart, allocationLength);
QueryAvailableDirectMemory(context, 0x0010_0000, 0x00C0_0000, 0x0040_0000);
Assert.True(context.TryReadUInt64(SpanStartOutAddress, out var spanStart));
Assert.True(context.TryReadUInt64(SpanSizeOutAddress, out var spanSize));
Assert.Equal(0x0080_0000UL, spanStart);
Assert.Equal(0x0040_0000UL, spanSize);
}
finally
{
ReleaseDirectMemory(context, allocationStart, allocationLength);
}
}
private static void AllocateDirectMemory(CpuContext context, ulong start, ulong length)
{
context[CpuRegister.Rdi] = start;
context[CpuRegister.Rsi] = start + length;
context[CpuRegister.Rdx] = length;
context[CpuRegister.Rcx] = 0x4000;
context[CpuRegister.R8] = 0;
context[CpuRegister.R9] = AllocationOutAddress;
Assert.Equal(0, KernelMemoryCompatExports.KernelAllocateDirectMemory(context));
Assert.True(context.TryReadUInt64(AllocationOutAddress, out var allocatedAddress));
Assert.Equal(start, allocatedAddress);
}
private static void QueryAvailableDirectMemory(
CpuContext context,
ulong searchStart,
ulong searchEnd,
ulong alignment)
{
context[CpuRegister.Rdi] = searchStart;
context[CpuRegister.Rsi] = searchEnd;
context[CpuRegister.Rdx] = alignment;
context[CpuRegister.Rcx] = SpanStartOutAddress;
context[CpuRegister.R8] = SpanSizeOutAddress;
Assert.Equal(0, KernelMemoryCompatExports.KernelAvailableDirectMemorySize(context));
}
private static void ReleaseDirectMemory(CpuContext context, ulong start, ulong length)
{
context[CpuRegister.Rdi] = start;
context[CpuRegister.Rsi] = length;
Assert.Equal(0, KernelMemoryCompatExports.KernelReleaseDirectMemory(context));
}
}
@@ -0,0 +1,149 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
using Xunit;
namespace SharpEmu.Libs.Tests.Kernel;
// The negative-stat and apr-file-size caches memoize host filesystem probe
// results, so their key equivalence must match the host filesystem's. These
// tests pin the case-sensitive-host behavior: a cached miss for one casing
// must never shadow a differently-cased path whose probe would succeed, and
// mount containment must not accept a case-sibling escape. On case-insensitive
// hosts (Windows, default macOS volumes) the aliased paths genuinely are the
// same file, so the case-specific sections skip themselves.
[Collection(KernelMemoryCompatStateCollection.Name)]
public sealed class KernelPathCaseSensitivityTests : IDisposable
{
private const ulong MemoryBase = 0x1_0000_0000;
private const ulong PathAddress = MemoryBase + 0x100;
private const ulong StatAddress = MemoryBase + 0x400;
private const ulong PathListAddress = MemoryBase + 0x900;
private const ulong PathBytesAddress = MemoryBase + 0xA00;
private const ulong IdsAddress = MemoryBase + 0xB00;
private const ulong SizesAddress = MemoryBase + 0xB40;
private readonly string _tempRoot;
public KernelPathCaseSensitivityTests()
{
_tempRoot = Path.Combine(
Path.GetTempPath(),
$"sharpemu-kernel-case-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempRoot);
}
public void Dispose()
{
Directory.Delete(_tempRoot, recursive: true);
}
[Fact]
public void Stat_CachedMissForOneCasingDoesNotShadowExistingFile()
{
var app0Root = Path.Combine(_tempRoot, "app0");
var unique = $"case_{Guid.NewGuid():N}";
Directory.CreateDirectory(Path.Combine(app0Root, unique));
File.WriteAllBytes(Path.Combine(app0Root, unique, "Data.bin"), [1, 2, 3]);
KernelMemoryCompatExports.RegisterGuestPathMount("/app0", app0Root);
// Prime the negative-stat cache with the wrongly-cased sibling. On a
// case-sensitive host the probe fails and the miss is cached; on a
// case-insensitive host the probe finds Data.bin and nothing is cached.
var missResult = PosixStat($"/app0/{unique}/DATA.BIN");
if (HostFsIsCaseSensitive())
{
Assert.Equal(-1, missResult);
}
// The correctly-cased path exists; the cached miss above must not be
// served for it.
Assert.Equal(0, PosixStat($"/app0/{unique}/Data.bin"));
}
[Fact]
public void AprResolve_DistinctlyCasedHostFilesReportTheirOwnSizes()
{
if (!HostFsIsCaseSensitive())
{
return;
}
var app0Root = Path.Combine(_tempRoot, "app0");
var unique = $"apr_{Guid.NewGuid():N}";
var assetDir = Path.Combine(app0Root, unique);
Directory.CreateDirectory(assetDir);
File.WriteAllBytes(Path.Combine(assetDir, "asset.bin"), new byte[3]);
File.WriteAllBytes(Path.Combine(assetDir, "ASSET.BIN"), new byte[7]);
KernelMemoryCompatExports.RegisterGuestPathMount("/app0", app0Root);
// Whichever casing resolves first lands in the size cache; the other
// casing is a different host file and must not inherit its size.
Assert.Equal(3UL, AprResolveSize($"/app0/{unique}/asset.bin"));
Assert.Equal(7UL, AprResolveSize($"/app0/{unique}/ASSET.BIN"));
}
[Fact]
public void MountResolution_RejectsCaseSiblingEscape()
{
if (!HostFsIsCaseSensitive())
{
return;
}
var mountRoot = Path.Combine(_tempRoot, "Save");
var sibling = Path.Combine(_tempRoot, "save");
Directory.CreateDirectory(mountRoot);
Directory.CreateDirectory(sibling);
File.WriteAllBytes(Path.Combine(sibling, "secret.bin"), [1]);
KernelMemoryCompatExports.RegisterGuestPathMount("/sharpemu_case_mnt", mountRoot);
// "../save/..." leaves the mount root; only a case-insensitive
// containment check lets it pass by matching the "Save" prefix.
var resolved = KernelMemoryCompatExports.ResolveGuestPath(
"/sharpemu_case_mnt/../save/secret.bin");
Assert.False(File.Exists(resolved));
}
private bool HostFsIsCaseSensitive()
{
var name = $"probe_{Guid.NewGuid():N}";
var probe = Path.Combine(_tempRoot, name + ".tmp");
File.WriteAllText(probe, string.Empty);
return !File.Exists(Path.Combine(_tempRoot, name.ToUpperInvariant() + ".TMP"));
}
private static int PosixStat(string guestPath)
{
var memory = new FakeCpuMemory(MemoryBase, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
memory.WriteCString(PathAddress, guestPath);
context[CpuRegister.Rdi] = PathAddress;
context[CpuRegister.Rsi] = StatAddress;
return KernelMemoryCompatExports.PosixStat(context);
}
private static ulong AprResolveSize(string guestPath)
{
var memory = new FakeCpuMemory(MemoryBase, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
memory.WriteCString(PathBytesAddress, guestPath);
Span<byte> pointerBytes = stackalloc byte[sizeof(ulong)];
BitConverter.TryWriteBytes(pointerBytes, PathBytesAddress);
Assert.True(memory.TryWrite(PathListAddress, pointerBytes));
context[CpuRegister.Rdi] = PathListAddress;
context[CpuRegister.Rsi] = 1;
context[CpuRegister.Rdx] = IdsAddress;
context[CpuRegister.Rcx] = SizesAddress;
var result = KernelMemoryCompatExports.KernelAprResolveFilepathsToIdsAndFileSizes(context);
Assert.Equal(0, result);
Span<byte> sizeBytes = stackalloc byte[sizeof(ulong)];
Assert.True(memory.TryRead(SizesAddress, sizeBytes));
return BitConverter.ToUInt64(sizeBytes);
}
}
@@ -0,0 +1,50 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
using Xunit;
namespace SharpEmu.Libs.Tests.Kernel;
public sealed class KernelSocketCompatExportsTests
{
[Fact]
public void Connect_InvalidSockaddrLeavesFdOpenForGuestClose()
{
const ulong memoryBase = 0x0000_7FFF_3000_0000;
var context = new CpuContext(new FakeCpuMemory(memoryBase, 0x1000), Generation.Gen5);
context[CpuRegister.Rdi] = 2;
context[CpuRegister.Rsi] = 1;
context[CpuRegister.Rdx] = 6;
Assert.Equal(0, KernelSocketCompatExports.Socket(context));
Assert.NotEqual(ulong.MaxValue, context[CpuRegister.Rax]);
var guestFd = checked((int)context[CpuRegister.Rax]);
try
{
context[CpuRegister.Rdi] = unchecked((ulong)guestFd);
context[CpuRegister.Rsi] = memoryBase;
context[CpuRegister.Rdx] = 0;
Assert.Equal(0, KernelSocketCompatExports.Connect(context));
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
context[CpuRegister.Rdi] = unchecked((ulong)guestFd);
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
KernelMemoryCompatExports.PosixClose(context));
Assert.Equal(0UL, context[CpuRegister.Rax]);
context[CpuRegister.Rdi] = unchecked((ulong)guestFd);
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND,
KernelMemoryCompatExports.PosixClose(context));
}
finally
{
KernelSocketCompatExports.TryCloseSocketFd(guestFd);
}
}
}
@@ -0,0 +1,39 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Logging;
using Xunit;
namespace SharpEmu.Libs.Tests.Logging;
public sealed class SharpEmuLogTests
{
[Theory]
[InlineData("Trace", LogLevel.Trace)]
[InlineData("debug", LogLevel.Debug)]
[InlineData(" Info ", LogLevel.Info)]
[InlineData("WARNING", LogLevel.Warning)]
[InlineData("Error", LogLevel.Error)]
[InlineData("critical", LogLevel.Critical)]
[InlineData("None", LogLevel.None)]
[InlineData("warn", LogLevel.Warning)]
[InlineData("fatal", LogLevel.Critical)]
public void TryParseLevelAcceptsDefinedNamesAndAliases(string text, LogLevel expected)
{
Assert.True(SharpEmuLog.TryParseLevel(text, out var actual));
Assert.Equal(expected, actual);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("unknown")]
[InlineData("999")]
[InlineData("-1")]
public void TryParseLevelRejectsInvalidValues(string? text)
{
Assert.False(SharpEmuLog.TryParseLevel(text, out var level));
Assert.Equal(default, level);
}
}
@@ -0,0 +1,99 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
using Xunit;
namespace SharpEmu.Libs.Tests.Pthread;
public sealed class PthreadMutexSemanticsTests
{
[Fact]
public void AdaptiveMutex_SelfLockUsesCompatibilityRecursion()
{
const ulong memoryBase = 0x1_0000_0000;
const ulong mutexAddress = memoryBase + 0x100;
var memory = new AllocatingCpuMemory(memoryBase, 0x4000);
var context = new CpuContext(memory, Generation.Gen5);
Assert.True(context.TryWriteUInt64(mutexAddress, 1)); // Static adaptive initializer.
context[CpuRegister.Rdi] = mutexAddress;
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
}
private sealed class AllocatingCpuMemory : ICpuMemory, IGuestMemoryAllocator
{
private readonly ulong _baseAddress;
private readonly byte[] _storage;
private ulong _nextAllocation;
public AllocatingCpuMemory(ulong baseAddress, int size)
{
_baseAddress = baseAddress;
_storage = new byte[size];
_nextAllocation = baseAddress + 0x1000;
}
public bool TryRead(ulong virtualAddress, Span<byte> destination)
{
if (!TryResolve(virtualAddress, destination.Length, out var offset))
{
return false;
}
_storage.AsSpan(offset, destination.Length).CopyTo(destination);
return true;
}
public bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source)
{
if (!TryResolve(virtualAddress, source.Length, out var offset))
{
return false;
}
source.CopyTo(_storage.AsSpan(offset, source.Length));
return true;
}
public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address)
{
var mask = alignment - 1;
var aligned = (_nextAllocation + mask) & ~mask;
if (!TryResolve(aligned, checked((int)size), out _))
{
address = 0;
return false;
}
address = aligned;
_nextAllocation = aligned + size;
return true;
}
public bool TryFreeGuestMemory(ulong address) =>
address >= _baseAddress && address < _baseAddress + (ulong)_storage.Length;
private bool TryResolve(ulong virtualAddress, int length, out int offset)
{
offset = 0;
if (virtualAddress < _baseAddress)
{
return false;
}
var relative = virtualAddress - _baseAddress;
if (relative + (ulong)length > (ulong)_storage.Length)
{
return false;
}
offset = (int)relative;
return true;
}
}
}
@@ -0,0 +1,52 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.ShaderCompiler;
using Xunit;
namespace SharpEmu.Libs.Tests.ShaderCompiler;
public sealed class Gen5ShaderTranslatorTests
{
private const ulong ProgramAddress = 0x1_0000_0000;
[Theory]
[InlineData(0xD7600005u, 5u)]
[InlineData(0xD7600065u, 101u)]
public void VReadlaneB32DecodesScalarDestinationFromVdstByte(
uint instructionWord,
uint expectedDestination)
{
var memory = new FakeCpuMemory(ProgramAddress, 0x100);
Span<byte> code = stackalloc byte[3 * sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(code, instructionWord);
BinaryPrimitives.WriteUInt32LittleEndian(code[sizeof(uint)..], 0x02000501u);
BinaryPrimitives.WriteUInt32LittleEndian(code[(2 * sizeof(uint))..], 0xBF810000u);
Assert.True(memory.TryWrite(ProgramAddress, code));
var context = new CpuContext(memory, Generation.Gen5);
Assert.True(
Gen5ShaderTranslator.TryDecodeProgram(
context,
ProgramAddress,
out var program,
out var error),
error);
var instruction = Assert.Single(
program.Instructions,
static item => item.Opcode == "VReadlaneB32");
Assert.Equal(Gen5ShaderEncoding.Vop3, instruction.Encoding);
var destination = Assert.Single(instruction.Destinations);
Assert.Equal(Gen5OperandKind.ScalarRegister, destination.Kind);
Assert.Equal(expectedDestination, destination.Value);
Assert.Equal(Gen5OperandKind.VectorRegister, instruction.Sources[0].Kind);
Assert.Equal(1u, instruction.Sources[0].Value);
Assert.Equal(Gen5OperandKind.ScalarRegister, instruction.Sources[1].Kind);
Assert.Equal(2u, instruction.Sources[1].Value);
}
}
@@ -0,0 +1,29 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.Libs.SystemService;
using Xunit;
namespace SharpEmu.Libs.Tests.SystemService;
public sealed class SystemServiceExportsTests
{
private const ulong MemoryBase = 0x1_0000_0000;
[Fact]
public void GetNoticeScreenSkipFlagWritesOneByteAtMemoryBoundary()
{
var memory = new FakeCpuMemory(MemoryBase, 1);
var context = new CpuContext(memory, Generation.Gen5);
Assert.True(memory.TryWrite(MemoryBase, new byte[] { 0xA5 }));
context[CpuRegister.Rdi] = MemoryBase;
Assert.Equal(0, SystemServiceExports.SystemServiceGetNoticeScreenSkipFlag(context));
Assert.Equal(0UL, context[CpuRegister.Rax]);
Span<byte> flag = stackalloc byte[1];
Assert.True(memory.TryRead(MemoryBase, flag));
Assert.Equal(0, flag[0]);
}
}
@@ -0,0 +1,56 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.Libs.VideoOut;
using Xunit;
namespace SharpEmu.Libs.Tests.VideoOut;
public sealed class VideoOutOutputOptionsTests
{
private const ulong MemoryBase = 0x1_0000_0000;
private const ulong OptionsAddress = MemoryBase + 0x40;
private const int OptionsSize = 0x40;
private const int InvalidAddress = unchecked((int)0x80290002);
[Theory]
[InlineData(Generation.Gen4)]
[InlineData(Generation.Gen5)]
public void InitializeOutputOptions_ClearsExactlyOneStructure(Generation generation)
{
var memory = new FakeCpuMemory(MemoryBase, 0x1000);
var context = new CpuContext(memory, generation);
var sentinel = new byte[OptionsSize + 1];
Array.Fill(sentinel, (byte)0xCC);
Assert.True(memory.TryWrite(OptionsAddress, sentinel));
context[CpuRegister.Rdi] = OptionsAddress;
Assert.Equal(0, VideoOutExports.VideoOutInitializeOutputOptions(context));
var result = new byte[OptionsSize + 1];
Assert.True(memory.TryRead(OptionsAddress, result));
Assert.All(result.AsSpan(0, OptionsSize).ToArray(), value => Assert.Equal(0, value));
Assert.Equal(0xCC, result[OptionsSize]);
}
[Fact]
public void InitializeOutputOptions_NullAddressReturnsInvalidAddress()
{
var context = new CpuContext(new FakeCpuMemory(MemoryBase, 0x100), Generation.Gen5);
context[CpuRegister.Rdi] = 0;
Assert.Equal(InvalidAddress, VideoOutExports.VideoOutInitializeOutputOptions(context));
}
[Fact]
public void InitializeOutputOptions_UnwritableStructureReturnsMemoryFault()
{
var context = new CpuContext(new FakeCpuMemory(MemoryBase, 0x100), Generation.Gen5);
context[CpuRegister.Rdi] = MemoryBase + 0xC1;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT,
VideoOutExports.VideoOutInitializeOutputOptions(context));
}
}
@@ -0,0 +1,103 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using Xunit;
namespace SharpEmu.Libs.Tests.VideoOut;
public sealed class VideoOutOutputSupportTests
{
private const string OpenNid = "Up36PTk687E";
private const string CloseNid = "uquVH4-Du78";
private const string OutputSupportNid = "Nv8c-Kb+DUM";
private const ulong MemoryBase = 0x1_0000_0000;
private const ulong OptionsAddress = MemoryBase + 0x100;
private static readonly ulong InvalidValue = unchecked((ulong)(int)0x80290001);
private static readonly ulong InvalidHandle = unchecked((ulong)(int)0x8029000B);
private static readonly ulong UnsupportedOutputMode = unchecked((ulong)(int)0x80290016);
private static readonly ulong InvalidOption = unchecked((ulong)(int)0x8029001A);
private static readonly ulong MemoryFault =
unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
[Fact]
public void Gen5QueryReportsCapabilitiesAndValidatesArguments()
{
var gen4Manager = new ModuleManager();
gen4Manager.RegisterExports(
SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen4));
Assert.False(gen4Manager.TryGetExport(OutputSupportNid, out _));
var manager = new ModuleManager();
manager.RegisterExports(
SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen5));
Assert.True(manager.TryGetExport(OutputSupportNid, out var export));
Assert.Equal("sceVideoOutIsOutputSupported", export.Name);
Assert.Equal("libSceVideoOut", export.LibraryName);
Assert.Equal(Generation.Gen5, export.Target);
var memory = new FakeCpuMemory(MemoryBase, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = 0;
context[CpuRegister.Rsi] = 0;
context[CpuRegister.Rdx] = 0;
context[CpuRegister.Rcx] = 0;
Assert.True(manager.TryDispatch(OpenNid, context, out _));
var handle = context[CpuRegister.Rax];
Assert.NotEqual(0UL, handle);
try
{
Assert.Equal(1UL, DispatchOutputSupport(manager, context, handle, 1));
Assert.Equal(0UL, DispatchOutputSupport(manager, context, handle, 15));
Assert.Equal(
InvalidHandle,
DispatchOutputSupport(manager, context, ulong.MaxValue, 1));
Assert.Equal(
InvalidValue,
DispatchOutputSupport(manager, context, handle, 1, reservedPointer: 1));
Assert.Equal(
InvalidValue,
DispatchOutputSupport(manager, context, handle, 1, reserved: 1));
Assert.Equal(
1UL,
DispatchOutputSupport(manager, context, handle, 1, OptionsAddress));
Assert.Equal(
MemoryFault,
DispatchOutputSupport(manager, context, handle, 1, MemoryBase + 0x1000));
Assert.True(memory.TryWrite(OptionsAddress, new byte[] { 1 }));
Assert.Equal(
InvalidOption,
DispatchOutputSupport(manager, context, handle, 1, OptionsAddress));
Assert.Equal(
UnsupportedOutputMode,
DispatchOutputSupport(manager, context, handle, 2));
}
finally
{
context[CpuRegister.Rdi] = handle;
_ = manager.TryDispatch(CloseNid, context, out _);
}
}
private static ulong DispatchOutputSupport(
ModuleManager manager,
CpuContext context,
ulong handle,
ulong mode,
ulong optionsAddress = 0,
ulong reservedPointer = 0,
ulong reserved = 0)
{
context[CpuRegister.Rdi] = handle;
context[CpuRegister.Rsi] = mode;
context[CpuRegister.Rdx] = optionsAddress;
context[CpuRegister.Rcx] = reservedPointer;
context[CpuRegister.R8] = reserved;
context[CpuRegister.R9] = 0x1FC;
Assert.True(manager.TryDispatch(OutputSupportNid, context, out _));
return context[CpuRegister.Rax];
}
}
@@ -0,0 +1,39 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.VideoOut;
using Xunit;
namespace SharpEmu.Libs.Tests.VideoOut;
public sealed class VulkanGuestImageByteCountTests
{
[Theory]
[InlineData(10u, 642u, 362u, 929616UL)]
[InlineData(12u, 642u, 362u, 1859232UL)]
[InlineData(13u, 2u, 2u, 48UL)]
public void UsesGuestSurfaceTexelSize(
uint format,
uint width,
uint height,
ulong expected)
{
Assert.Equal(
expected,
VulkanVideoPresenter.GetGuestImageByteCount(format, width, height));
}
[Theory]
[InlineData(169u, 4u, 4u, 8UL)]
[InlineData(173u, 5u, 5u, 64UL)]
public void UsesCompressedBlockExtent(
uint format,
uint width,
uint height,
ulong expected)
{
Assert.Equal(
expected,
VulkanVideoPresenter.GetGuestImageByteCount(format, width, height));
}
}
+89
View File
@@ -0,0 +1,89 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
# SharpEmu Debugger Frontend
A polished browser UI for SharpEmu's live debugger. A small Python bridge talks
to the emulator over its JSON-lines TCP protocol and serves the frontend on
loopback. It uses only the Python standard library; no package installation or
JavaScript build step is required.
The favicon and header mark use the official SharpEmu logo served by
`sharpemu.app`.
## Start
Start the frontend from the repository root:
```bash
./tools/SharpEmu.DebuggerFrontend/run.sh
```
Use **Browse…** to choose the game's `eboot.bin`, then select **Launch &
attach**. The frontend starts the local Release build with its debug server,
waits for it to become ready, and connects automatically. Emulator output is
shown in the Activity panel. The Stop button only controls the process launched
by this frontend.
You can still start SharpEmu manually and use the connection bar to attach:
```bash
./artifacts/bin/Release/net10.0/linux-x64/SharpEmu \
--debug-server=127.0.0.1:5714 "/path/to/game/eboot.bin"
```
The frontend opens `http://127.0.0.1:8765/`. If SharpEmu is already running, it
connects to the default debug endpoint automatically. If it is not running yet,
the UI remains available for launching or attaching later.
Running the launcher again on the same UI port gracefully closes and replaces
the previous verified SharpEmu frontend instance. It will not stop an unrelated
application that happens to own the requested port; in that case, select a
different port with `--ui-port`.
Useful options:
```text
--debug-host HOST Debug server host (default 127.0.0.1)
--debug-port PORT Debug server port (default 5714)
--listen ADDRESS Web UI bind address (default 127.0.0.1)
--ui-port PORT Web UI port; 0 chooses a free port (default 8765)
--no-connect Do not connect to SharpEmu automatically
--no-browser Do not open a browser automatically
--verbose Print HTTP request logs
```
## Features
- Live connection and target-state display
- Native file picker, local emulator launch, automatic debugger attach, and process stop
- Live output from the frontend-launched SharpEmu process
- Continue, pause, and frame-step controls with keyboard shortcuts
- Register inspection and editing
- Hex/ASCII memory reads and validated memory writes
- Breakpoint and watchpoint creation, toggling, and deletion
- Stop reason, frame, result, opcode, and fault details
- Evidence-based stall diagnosis with likely causes, ranked fixes, and targeted checks
- Raw JSON command console for new protocol operations
- Searchable activity stream containing requests, replies, and async events
The debugger currently stops and steps at guest frame boundaries. Data
watchpoints and per-instruction stepping are exposed in the protocol but depend
on future CPU backend hooks, as documented in `docs/debugger-server.md`.
## Test
```bash
python3 -m unittest discover -s tools/SharpEmu.DebuggerFrontend/tests -v
node --check tools/SharpEmu.DebuggerFrontend/web/app.js
```
The HTTP service binds to loopback by default and has no authentication. Only
use a non-loopback `--listen` address on a trusted network.
On Linux, the Browse button uses `zenity` or `kdialog`. A full path can always
be entered manually. Closing the frontend also stops the emulator process it
launched so its captured output pipe cannot be orphaned; manually launched
emulators are never stopped by the frontend.
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env sh
# Copyright (C) 2026 SharpEmu Emulator Project
# SPDX-License-Identifier: GPL-2.0-or-later
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
exec python3 "$script_dir/debugger_frontend.py" "$@"
@@ -0,0 +1,309 @@
# Copyright (C) 2026 SharpEmu Emulator Project
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
import json
from pathlib import Path
import socket
import sys
import tempfile
import textwrap
import threading
import unittest
from urllib.request import Request, urlopen
FRONTEND_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(FRONTEND_ROOT))
from debugger_frontend import ( # noqa: E402
BridgeError,
DebuggerBridge,
EmulatorProcessManager,
FrontendHttpServer,
FrontendRequestHandler,
analyze_debug_stop,
create_frontend_server,
)
REGISTERS = {
"rax": "0x0000000000000001",
"rip": "0x00000008801234A0",
"rflags": "0x0000000000000202",
}
class FakeDebuggerServer:
def __init__(self) -> None:
self.listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.listener.bind(("127.0.0.1", 0))
self.listener.listen(1)
self.port = self.listener.getsockname()[1]
self.client: socket.socket | None = None
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
def _run(self) -> None:
try:
client, _ = self.listener.accept()
self.client = client
reader = client.makefile("r", encoding="utf-8", newline="\n")
writer = client.makefile("w", encoding="utf-8", newline="\n")
self._write(writer, {"event": "hello", "protocol": "json-lines/1", "state": "Paused"})
for line in reader:
request = json.loads(line)
command = request["command"]
if command == "status":
self._write(writer, {
"ok": True,
"command": command,
"data": {"state": "Paused", "breakpoints": 1},
})
elif command == "registers":
self._write(writer, {
"event": "stopped",
"reason": "Breakpoint",
"address": REGISTERS["rip"],
"frameKind": "ProcessEntry",
"frameLabel": "eboot.bin",
"registers": REGISTERS,
})
self._write(writer, {
"ok": True,
"command": command,
"data": {"registers": REGISTERS},
})
elif command == "list-breakpoints":
self._write(writer, {
"ok": True,
"command": command,
"data": {
"breakpoints": [{
"id": 1,
"kind": "Execute",
"address": REGISTERS["rip"],
"length": 1,
"enabled": True,
}],
},
})
else:
self._write(writer, {"ok": True, "command": command})
except OSError:
pass
@staticmethod
def _write(writer: object, payload: dict[str, object]) -> None:
writer.write(json.dumps(payload, separators=(",", ":")) + "\n")
writer.flush()
def close(self) -> None:
if self.client is not None:
try:
self.client.shutdown(socket.SHUT_RDWR)
except OSError:
pass
self.client.close()
self.listener.close()
self.thread.join(timeout=1)
class DebuggerBridgeTests(unittest.TestCase):
def setUp(self) -> None:
self.server = FakeDebuggerServer()
self.bridge = DebuggerBridge()
self.bridge.connect("127.0.0.1", self.server.port)
def tearDown(self) -> None:
self.bridge.disconnect(log=False)
self.server.close()
def test_connect_receives_hello(self) -> None:
snapshot = self.bridge.snapshot()
self.assertTrue(snapshot["connected"])
self.assertEqual("json-lines/1", snapshot["protocol"])
self.assertEqual("Paused", snapshot["state"])
def test_request_pairs_reply_while_processing_event(self) -> None:
reply = self.bridge.request({"command": "registers"})
self.assertTrue(reply["ok"])
snapshot = self.bridge.snapshot()
self.assertEqual(REGISTERS["rip"], snapshot["registers"]["rip"])
self.assertEqual("Breakpoint", snapshot["lastStop"]["reason"])
self.assertTrue(any(item["summary"] == "stopped" for item in snapshot["messages"]))
def test_breakpoint_snapshot_is_updated(self) -> None:
self.bridge.request({"command": "list-breakpoints"})
snapshot = self.bridge.snapshot()
self.assertEqual(1, len(snapshot["breakpoints"]))
self.assertEqual("Execute", snapshot["breakpoints"][0]["kind"])
def test_invalid_request_is_rejected_locally(self) -> None:
with self.assertRaises(BridgeError):
self.bridge.request({})
def test_journal_cursor_returns_only_new_messages(self) -> None:
cursor = self.bridge.snapshot()["cursor"]
self.bridge.request({"command": "status"})
snapshot = self.bridge.snapshot(cursor)
self.assertGreaterEqual(len(snapshot["messages"]), 2)
self.assertTrue(all(message["id"] > cursor for message in snapshot["messages"]))
class StallAnalysisTests(unittest.TestCase):
def test_legacy_mutex_stall_identifies_likely_scheduler_fix(self) -> None:
analysis = analyze_debug_stop({
"reason": "Stall",
"detail": "kind=ImportLoop, nid=9UK1vLZQft4, dispatch#40667904, rip=0x0000000801CE2418",
})
self.assertIsNotNone(analysis)
self.assertEqual("Mutex lock is livelocking", analysis["title"])
self.assertEqual("High", analysis["confidence"])
self.assertIn("PthreadMutexLockCore", analysis["fix"])
self.assertTrue(any("9UK1vLZQft4" in item for item in analysis["evidence"]))
def test_unresolved_import_stall_recommends_export_implementation(self) -> None:
analysis = analyze_debug_stop({
"reason": "Stall",
"stall": {
"kind": "ImportLoop",
"nid": "missing-nid",
"resolved": False,
"dispatchIndex": 8192,
"instructionPointer": "0x0000000000001234",
},
})
self.assertIsNotNone(analysis)
self.assertEqual("Unresolved import is being retried", analysis["title"])
self.assertIn("Implement or correctly register", analysis["fix"])
class FrontendHttpTests(unittest.TestCase):
def setUp(self) -> None:
self.debugger = FakeDebuggerServer()
self.bridge = DebuggerBridge()
self.process_manager = EmulatorProcessManager(self.bridge.journal)
handler = type(
"TestFrontendRequestHandler",
(FrontendRequestHandler,),
{"bridge": self.bridge, "process_manager": self.process_manager},
)
self.http_server = FrontendHttpServer(("127.0.0.1", 0), handler)
self.http_port = self.http_server.server_address[1]
self.http_thread = threading.Thread(target=self.http_server.serve_forever, daemon=True)
self.http_thread.start()
def tearDown(self) -> None:
self.bridge.disconnect(log=False)
self.process_manager.stop(log=False)
self.http_server.shutdown()
self.http_server.server_close()
self.http_thread.join(timeout=1)
self.debugger.close()
def post(self, path: str, payload: dict[str, object]) -> dict[str, object]:
request = Request(
f"http://127.0.0.1:{self.http_port}{path}",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urlopen(request, timeout=2) as response:
return json.load(response)
def test_api_connect_and_command_round_trip(self) -> None:
connected = self.post("/api/connect", {"host": "127.0.0.1", "port": self.debugger.port})
self.assertTrue(connected["connected"])
self.assertEqual("json-lines/1", connected["protocol"])
result = self.post("/api/command", {"request": {"command": "registers"}})
self.assertTrue(result["response"]["ok"])
def test_static_frontend_is_served(self) -> None:
with urlopen(f"http://127.0.0.1:{self.http_port}/", timeout=2) as response:
body = response.read().decode("utf-8")
self.assertEqual("text/html; charset=utf-8", response.headers["Content-Type"])
self.assertIn("SharpEmu <span>Debugger</span>", body)
self.assertIn('rel="icon" type="image/webp"', body)
with urlopen(f"http://127.0.0.1:{self.http_port}/sharpemu-logo.webp", timeout=2) as response:
logo = response.read()
self.assertEqual("image/webp", response.headers["Content-Type"])
self.assertTrue(logo.startswith(b"RIFF"))
def test_api_launches_and_stops_emulator_with_auto_attach(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
root = Path(temporary_directory)
eboot = root / "eboot.bin"
eboot.write_bytes(b"test")
emulator = root / "fake-sharpemu"
emulator.write_text(textwrap.dedent("""\
#!/usr/bin/env python3
import json
import socket
import sys
endpoint = next(arg.split("=", 1)[1] for arg in sys.argv if arg.startswith("--debug-server="))
port = int(endpoint.rsplit(":", 1)[1])
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", port))
listener.listen(4)
print("Fake SharpEmu debug server ready", flush=True)
while True:
client, _ = listener.accept()
with client:
reader = client.makefile("r", encoding="utf-8")
writer = client.makefile("w", encoding="utf-8")
writer.write(json.dumps({"event": "hello", "protocol": "json-lines/1", "state": "Paused"}) + "\\n")
writer.flush()
for line in reader:
request = json.loads(line)
command = request["command"]
data = {"state": "Paused", "breakpoints": 0} if command == "status" else {"breakpoints": []}
writer.write(json.dumps({"ok": True, "command": command, "data": data}) + "\\n")
writer.flush()
"""), encoding="utf-8")
emulator.chmod(0o755)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as reservation:
reservation.bind(("127.0.0.1", 0))
debug_port = reservation.getsockname()[1]
launched = self.post("/api/launch", {
"ebootPath": str(eboot),
"debugPort": debug_port,
"emulatorPath": str(emulator),
})
self.assertTrue(launched["connected"])
self.assertTrue(launched["emulator"]["running"])
self.assertEqual(str(eboot), launched["emulator"]["eboot"])
stopped = self.post("/api/stop-emulator", {})
self.assertFalse(stopped["connected"])
self.assertFalse(stopped["emulator"]["running"])
def test_rerun_replaces_existing_frontend_on_same_port(self) -> None:
replacement_handler = type(
"ReplacementFrontendRequestHandler",
(FrontendRequestHandler,),
{"bridge": self.bridge, "process_manager": self.process_manager},
)
old_thread = self.http_thread
replacement = create_frontend_server("127.0.0.1", self.http_port, replacement_handler)
old_thread.join(timeout=2)
self.assertFalse(old_thread.is_alive())
self.http_server = replacement
self.http_thread = threading.Thread(target=replacement.serve_forever, daemon=True)
self.http_thread.start()
with urlopen(f"http://127.0.0.1:{self.http_port}/api/health", timeout=2) as response:
health = json.load(response)
self.assertEqual("sharpemu-debugger-frontend", health["application"])
if __name__ == "__main__":
unittest.main()
+626
View File
@@ -0,0 +1,626 @@
/*
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
*/
"use strict";
const REGISTER_ORDER = [
"rax", "rcx", "rdx", "rbx", "rsp", "rbp", "rsi", "rdi",
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15",
"rip", "rflags", "fs_base", "gs_base",
];
const ui = {
cursor: 0,
snapshot: null,
activity: [],
polling: false,
refreshing: false,
};
const byId = (id) => document.getElementById(id);
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { "Content-Type": "application/json" },
cache: "no-store",
...options,
});
let payload;
try {
payload = await response.json();
} catch {
throw new Error(`Frontend returned HTTP ${response.status}.`);
}
if (!response.ok) {
throw new Error(payload.error || `Request failed with HTTP ${response.status}.`);
}
return payload;
}
async function pollSnapshot() {
if (ui.polling) return;
ui.polling = true;
try {
const snapshot = await api(`/api/snapshot?since=${ui.cursor}`);
applySnapshot(snapshot);
} catch (error) {
showToast("Frontend unavailable", error.message, "error");
} finally {
ui.polling = false;
}
}
function applySnapshot(snapshot) {
const previous = ui.snapshot;
const previousCursor = ui.cursor;
const newMessages = (snapshot.messages || []).filter((message) => message.id > previousCursor);
ui.snapshot = snapshot;
ui.cursor = snapshot.cursor;
if (newMessages.length) {
ui.activity.push(...newMessages);
ui.activity = ui.activity.slice(-350);
renderActivity();
for (const message of newMessages) {
handleActivityNotification(message);
}
}
if (!previous && snapshot.defaultEndpoint) {
byId("debug-host").value = snapshot.defaultEndpoint.host;
byId("debug-port").value = snapshot.defaultEndpoint.port;
}
renderConnection(snapshot);
renderEmulator(snapshot.emulator || {});
renderTarget(snapshot);
renderRegisters(snapshot.registers || {});
renderBreakpoints(snapshot.breakpoints || []);
updateControls(snapshot);
}
function renderConnection(snapshot) {
const status = byId("connection-status");
status.classList.toggle("connected", snapshot.connected);
status.classList.toggle("disconnected", !snapshot.connected);
byId("connection-state").textContent = snapshot.connected ? "Connected" : "Offline";
byId("connection-endpoint").textContent = snapshot.endpoint || "Not connected";
byId("connection-button-label").textContent = snapshot.connected ? "Disconnect" : "Connect";
byId("connection-button").classList.toggle("button-danger", snapshot.connected);
byId("connection-button").classList.toggle("button-primary", !snapshot.connected);
byId("protocol-version").textContent = snapshot.protocol || "—";
}
function renderEmulator(emulator) {
const statusWrap = byId("emulator-status").closest(".launch-status");
statusWrap.classList.toggle("running", Boolean(emulator.running));
statusWrap.classList.toggle("exited", !emulator.running && emulator.exitCode !== null && emulator.exitCode !== undefined);
if (emulator.running) {
byId("emulator-status").textContent = `SharpEmu running · PID ${emulator.pid}`;
byId("emulator-detail").textContent = emulator.eboot || "Launching game executable";
} else if (emulator.exitCode !== null && emulator.exitCode !== undefined) {
byId("emulator-status").textContent = `SharpEmu exited · code ${emulator.exitCode}`;
byId("emulator-detail").textContent = emulator.eboot || "The launched process has ended.";
} else {
byId("emulator-status").textContent = "No frontend-launched session";
byId("emulator-detail").textContent = "You can still attach to an emulator that is already running.";
}
if (emulator.eboot && document.activeElement !== byId("eboot-path")) {
byId("eboot-path").value = emulator.eboot;
}
byId("eboot-path").disabled = Boolean(emulator.running);
byId("browse-eboot-button").disabled = Boolean(emulator.running);
byId("launch-button").disabled = Boolean(emulator.running);
byId("stop-emulator-button").disabled = !emulator.running;
}
function renderTarget(snapshot) {
const state = snapshot.state || "Disconnected";
const stop = snapshot.lastStop || {};
const registers = snapshot.registers || {};
byId("toolbar-target-state").textContent = state;
const badge = byId("target-state-badge");
badge.textContent = state;
badge.className = `state-badge ${state.toLowerCase()}`;
byId("target-address").textContent = stop.address || registers.rip || "—";
byId("stop-reason").textContent = stop.reason || "—";
byId("frame-kind").textContent = stop.frameKind || "—";
byId("frame-label").textContent = stop.frameLabel || "—";
byId("stop-result").textContent = stop.result || "—";
byId("opcode-bytes").textContent = stop.opcodeBytes || "—";
const detailWrap = byId("stop-detail-wrap");
detailWrap.classList.toggle("hidden", !stop.detail);
byId("stop-detail").textContent = stop.detail || "";
renderStallAnalysis(stop.analysis);
}
function renderStallAnalysis(analysis) {
const panel = byId("stall-analysis");
if (!analysis) {
panel.classList.add("hidden");
return;
}
panel.classList.remove("hidden");
byId("stall-analysis-title").textContent = analysis.title || "Execution stall detected";
byId("stall-confidence").textContent = `${analysis.confidence || "Medium"} confidence`;
byId("stall-summary").textContent = analysis.summary || "The guest is not making forward progress.";
byId("stall-cause").textContent = analysis.cause || "The repeated path is not changing the state checked by the guest.";
byId("stall-fix").textContent = analysis.fix || "Trace the repeated import and implement its missing state transition.";
const actions = (analysis.actions || []).map((text) => createTextElement("li", text));
byId("stall-actions").replaceChildren(...actions);
const evidence = (analysis.evidence || []).map((text) => createTextElement("li", text));
byId("stall-evidence").replaceChildren(...evidence);
}
function renderRegisters(registers) {
const grid = byId("register-grid");
if (!Object.keys(registers).length) {
grid.className = "register-grid empty-state";
grid.replaceChildren(createTextElement("p", "Pause the target to inspect registers."));
return;
}
grid.className = "register-grid";
const fragment = document.createDocumentFragment();
for (const name of REGISTER_ORDER) {
const value = registers[name] ?? "—";
const item = document.createElement("div");
item.className = `register-item ${["rip", "rflags", "fs_base", "gs_base"].includes(name) ? "special" : ""}`;
const label = createTextElement("span", name);
label.className = "register-name";
const button = createTextElement("button", value);
button.type = "button";
button.className = "register-value";
button.dataset.register = name;
button.title = `Edit ${name}`;
button.addEventListener("click", () => openRegisterDialog(name, value));
item.append(label, button);
fragment.append(item);
}
grid.replaceChildren(fragment);
}
function renderBreakpoints(breakpoints) {
byId("breakpoint-count").textContent = breakpoints.length;
const body = byId("breakpoint-table");
if (!breakpoints.length) {
const row = document.createElement("tr");
row.className = "empty-row";
const cell = createTextElement("td", "No breakpoints configured.");
cell.colSpan = 6;
row.append(cell);
body.replaceChildren(row);
return;
}
const fragment = document.createDocumentFragment();
for (const breakpoint of breakpoints) {
const row = document.createElement("tr");
const enabledCell = document.createElement("td");
const toggle = document.createElement("input");
toggle.type = "checkbox";
toggle.className = "switch";
toggle.checked = Boolean(breakpoint.enabled);
toggle.title = toggle.checked ? "Disable breakpoint" : "Enable breakpoint";
toggle.addEventListener("change", async () => {
toggle.disabled = true;
try {
await sendCommand({ command: "enable-breakpoint", id: breakpoint.id, enabled: toggle.checked });
await refreshBreakpoints();
} catch (error) {
toggle.checked = !toggle.checked;
showToast("Breakpoint update failed", error.message, "error");
} finally {
toggle.disabled = false;
}
});
enabledCell.append(toggle);
const idCell = createTextElement("td", String(breakpoint.id));
const kindCell = document.createElement("td");
const kind = createTextElement("span", breakpoint.kind);
kind.className = "kind-pill";
kindCell.append(kind);
const addressCell = createTextElement("td", breakpoint.address);
const lengthCell = createTextElement("td", String(breakpoint.length));
const actionCell = document.createElement("td");
const remove = createTextElement("button", "Remove");
remove.type = "button";
remove.className = "row-action";
remove.addEventListener("click", async () => {
remove.disabled = true;
try {
await sendCommand({ command: "remove-breakpoint", id: breakpoint.id });
await refreshBreakpoints();
showToast("Breakpoint removed", `Breakpoint ${breakpoint.id} was removed.`, "success");
} catch (error) {
showToast("Remove failed", error.message, "error");
} finally {
remove.disabled = false;
}
});
actionCell.append(remove);
row.append(enabledCell, idCell, kindCell, addressCell, lengthCell, actionCell);
fragment.append(row);
}
body.replaceChildren(fragment);
}
function updateControls(snapshot) {
const connected = Boolean(snapshot.connected);
const state = String(snapshot.state || "").toLowerCase();
const paused = connected && state === "paused";
const running = connected && state === "running";
byId("continue-button").disabled = !paused;
byId("step-button").disabled = !paused;
byId("pause-button").disabled = !running;
byId("refresh-button").disabled = !connected;
for (const id of [
"breakpoint-address", "breakpoint-kind", "breakpoint-length",
"memory-address", "memory-length", "memory-write-address", "memory-write-bytes",
"raw-command-input",
]) {
byId(id).disabled = !connected || (["memory-address", "memory-length", "memory-write-address", "memory-write-bytes"].includes(id) && !paused);
}
byId("breakpoint-form").querySelector("button").disabled = !connected;
byId("memory-read-form").querySelector("button").disabled = !paused;
byId("memory-write-form").querySelector("button").disabled = !paused;
byId("raw-command-form").querySelector("button").disabled = !connected;
}
async function sendCommand(request) {
const payload = await api("/api/command", {
method: "POST",
body: JSON.stringify({ request }),
});
const response = payload.response;
if (!response?.ok) {
throw new Error(response?.error || `${request.command} failed.`);
}
await pollSnapshot();
return response;
}
async function refreshTarget() {
if (!ui.snapshot?.connected || ui.refreshing) return;
ui.refreshing = true;
byId("refresh-button").disabled = true;
try {
const status = await sendCommand({ command: "status" });
if (String(status.data?.state).toLowerCase() === "paused") {
await sendCommand({ command: "registers" });
}
await sendCommand({ command: "list-breakpoints" });
} catch (error) {
showToast("Refresh failed", error.message, "error");
} finally {
ui.refreshing = false;
await pollSnapshot();
}
}
async function refreshBreakpoints() {
await sendCommand({ command: "list-breakpoints" });
}
async function connectOrDisconnect(event) {
event.preventDefault();
const button = byId("connection-button");
button.disabled = true;
try {
if (ui.snapshot?.connected) {
const snapshot = await api("/api/disconnect", { method: "POST", body: "{}" });
applySnapshot(snapshot);
showToast("Disconnected", "The debugger connection was closed.");
return;
}
const host = byId("debug-host").value.trim();
const port = Number.parseInt(byId("debug-port").value, 10);
const snapshot = await api("/api/connect", {
method: "POST",
body: JSON.stringify({ host, port }),
});
applySnapshot(snapshot);
showToast("Debugger connected", `Attached to ${host}:${port}.`, "success");
await refreshTarget();
} catch (error) {
showToast("Connection failed", error.message, "error");
await pollSnapshot();
} finally {
button.disabled = false;
}
}
async function browseForEboot() {
const button = byId("browse-eboot-button");
button.disabled = true;
const previousLabel = button.textContent;
button.textContent = "Choosing…";
try {
const result = await api("/api/select-eboot", {
method: "POST",
body: JSON.stringify({ initialPath: byId("eboot-path").value.trim() || null }),
});
if (result.path) {
byId("eboot-path").value = result.path;
byId("eboot-path").focus();
byId("eboot-path").setSelectionRange(result.path.length, result.path.length);
}
} catch (error) {
showToast("File picker unavailable", error.message, "error");
} finally {
button.textContent = previousLabel;
button.disabled = Boolean(ui.snapshot?.emulator?.running);
}
}
async function launchEboot(event) {
event.preventDefault();
const ebootPath = byId("eboot-path").value.trim();
const debugPort = Number.parseInt(byId("debug-port").value, 10);
const button = byId("launch-button");
const previousHtml = button.innerHTML;
button.disabled = true;
button.textContent = "Starting SharpEmu…";
showToast("Launching SharpEmu", "Waiting for the local debug server to become ready.");
try {
const snapshot = await api("/api/launch", {
method: "POST",
body: JSON.stringify({ ebootPath, debugPort }),
});
byId("debug-host").value = "127.0.0.1";
applySnapshot(snapshot);
showToast("Launch complete", "SharpEmu is running and the debugger attached automatically.", "success");
await refreshTarget();
} catch (error) {
showToast("Launch failed", error.message, "error");
await pollSnapshot();
} finally {
button.innerHTML = previousHtml;
button.disabled = Boolean(ui.snapshot?.emulator?.running);
}
}
async function stopEmulator() {
if (!window.confirm("Stop the SharpEmu process launched by this frontend?")) return;
const button = byId("stop-emulator-button");
button.disabled = true;
button.textContent = "Stopping…";
try {
const snapshot = await api("/api/stop-emulator", { method: "POST", body: "{}" });
applySnapshot(snapshot);
showToast("SharpEmu stopped", "The frontend-launched emulator process was stopped.");
} catch (error) {
showToast("Stop failed", error.message, "error");
} finally {
button.textContent = "Stop";
button.disabled = !ui.snapshot?.emulator?.running;
}
}
async function executionCommand(command, label) {
try {
await sendCommand({ command });
showToast(label, command === "pause" ? "Pause requested at the next frame boundary." : `${label} command accepted.`, "success");
} catch (error) {
showToast(`${label} failed`, error.message, "error");
}
}
function openRegisterDialog(name, value) {
if (String(ui.snapshot?.state).toLowerCase() !== "paused") return;
byId("register-name").value = name;
byId("register-value").value = value;
byId("register-dialog").showModal();
byId("register-value").focus();
byId("register-value").select();
}
async function applyRegister(event) {
event.preventDefault();
const name = byId("register-name").value;
const value = byId("register-value").value.trim();
try {
await sendCommand({ command: "set-register", register: name, value });
await sendCommand({ command: "registers" });
byId("register-dialog").close();
showToast("Register updated", `${name.toUpperCase()} is now ${value}.`, "success");
} catch (error) {
showToast("Register update failed", error.message, "error");
}
}
async function addBreakpoint(event) {
event.preventDefault();
const address = byId("breakpoint-address").value.trim();
const kind = byId("breakpoint-kind").value;
const length = Number.parseInt(byId("breakpoint-length").value, 10);
try {
await sendCommand({ command: "add-breakpoint", address, kind, length });
await refreshBreakpoints();
byId("breakpoint-address").value = "";
showToast("Breakpoint added", `${kind} breakpoint created at ${address}.`, "success");
} catch (error) {
showToast("Breakpoint failed", error.message, "error");
}
}
async function readMemory(event) {
event.preventDefault();
const address = byId("memory-address").value.trim();
const length = Number.parseInt(byId("memory-length").value, 10);
try {
const response = await sendCommand({ command: "read-memory", address, length });
const data = response.data || {};
byId("memory-view").textContent = formatHexDump(data.address || address, data.bytes || "");
byId("memory-meta").textContent = `${data.length || 0} bytes from ${data.address || address}`;
if (!byId("memory-write-address").value) byId("memory-write-address").value = data.address || address;
} catch (error) {
showToast("Memory read failed", error.message, "error");
}
}
async function writeMemory(event) {
event.preventDefault();
const address = byId("memory-write-address").value.trim();
const bytes = byId("memory-write-bytes").value.replace(/\s+/g, "");
if (!bytes || bytes.length % 2 || !/^[0-9a-f]+$/i.test(bytes)) {
showToast("Invalid bytes", "Enter an even number of hexadecimal digits.", "error");
return;
}
try {
const response = await sendCommand({ command: "write-memory", address, bytes });
showToast("Memory written", `${response.data?.written || bytes.length / 2} bytes written at ${address}.`, "success");
if (byId("memory-address").value.trim() === address) {
byId("memory-read-form").requestSubmit();
}
} catch (error) {
showToast("Memory write failed", error.message, "error");
}
}
function formatHexDump(startAddress, hex) {
const clean = String(hex).replace(/\s+/g, "");
if (!clean) return "No bytes returned.";
const bytes = clean.match(/.{1,2}/g) || [];
let base = 0n;
try { base = BigInt(startAddress); } catch { /* display from zero */ }
const lines = [];
for (let offset = 0; offset < bytes.length; offset += 16) {
const chunk = bytes.slice(offset, offset + 16);
const address = `0x${(base + BigInt(offset)).toString(16).toUpperCase().padStart(16, "0")}`;
const left = chunk.slice(0, 8).join(" ").padEnd(23, " ");
const right = chunk.slice(8).join(" ").padEnd(23, " ");
const ascii = chunk.map((value) => {
const code = Number.parseInt(value, 16);
return code >= 32 && code <= 126 ? String.fromCharCode(code) : ".";
}).join("");
lines.push(`${address} ${left} ${right} |${ascii.padEnd(16, " ")}|`);
}
return lines.join("\n");
}
async function sendRawCommand(event) {
event.preventDefault();
try {
const request = JSON.parse(byId("raw-command-input").value);
if (!request || Array.isArray(request) || typeof request !== "object") throw new Error("Request must be a JSON object.");
await sendCommand(request);
showToast("Raw request sent", request.command || "Request completed.", "success");
} catch (error) {
showToast("Raw request failed", error.message, "error");
}
}
function renderActivity() {
const stream = byId("activity-stream");
const query = byId("activity-search").value.trim().toLowerCase();
const messages = query
? ui.activity.filter((item) => `${item.kind} ${item.summary} ${JSON.stringify(item.payload || "")}`.toLowerCase().includes(query))
: ui.activity;
if (!messages.length) {
const empty = createTextElement("div", query ? "No activity matches this filter." : "Protocol events and commands will appear here.");
empty.className = "activity-empty";
stream.replaceChildren(empty);
return;
}
const nearBottom = stream.scrollHeight - stream.scrollTop - stream.clientHeight < 70;
const fragment = document.createDocumentFragment();
for (const item of messages.slice(-250)) {
const row = document.createElement("div");
row.className = `activity-row ${item.kind}`;
const timestamp = createTextElement("span", item.time);
timestamp.className = "activity-time";
const kind = createTextElement("span", item.kind);
kind.className = "activity-kind";
const summary = document.createElement("div");
summary.className = "activity-summary";
summary.append(createTextElement("span", item.summary));
if (item.payload !== undefined) {
const details = document.createElement("details");
details.append(createTextElement("summary", "View payload"));
const pre = createTextElement("pre", JSON.stringify(item.payload, null, 2));
details.append(pre);
summary.append(details);
}
row.append(timestamp, kind, summary);
fragment.append(row);
}
stream.replaceChildren(fragment);
if (nearBottom || !query) stream.scrollTop = stream.scrollHeight;
}
function handleActivityNotification(message) {
if (message.kind === "event" && message.summary === "stopped") {
const reason = message.payload?.reason || "Target stopped";
showToast("Target paused", `${reason} at ${message.payload?.address || "unknown address"}.`);
} else if (message.kind === "event" && message.summary === "terminated") {
showToast("Target terminated", "The emulation run has completed.");
} else if (message.kind === "emulator" && message.summary.startsWith("Emulator exited")) {
showToast("Emulator exited", message.summary);
}
}
function showToast(title, message, tone = "") {
const toast = document.createElement("div");
toast.className = `toast ${tone}`;
toast.append(createTextElement("strong", title), createTextElement("span", message));
byId("toast-stack").append(toast);
window.setTimeout(() => toast.remove(), 4300);
}
function createTextElement(tag, text) {
const element = document.createElement(tag);
element.textContent = text == null ? "" : String(text);
return element;
}
function bindEvents() {
byId("connection-form").addEventListener("submit", connectOrDisconnect);
byId("launch-form").addEventListener("submit", launchEboot);
byId("browse-eboot-button").addEventListener("click", browseForEboot);
byId("stop-emulator-button").addEventListener("click", stopEmulator);
byId("continue-button").addEventListener("click", () => executionCommand("continue", "Continue"));
byId("pause-button").addEventListener("click", () => executionCommand("pause", "Pause"));
byId("step-button").addEventListener("click", () => executionCommand("step", "Step"));
byId("refresh-button").addEventListener("click", refreshTarget);
byId("breakpoint-form").addEventListener("submit", addBreakpoint);
byId("memory-read-form").addEventListener("submit", readMemory);
byId("memory-write-form").addEventListener("submit", writeMemory);
byId("raw-command-form").addEventListener("submit", sendRawCommand);
byId("register-form").addEventListener("submit", applyRegister);
byId("register-dialog-close").addEventListener("click", () => byId("register-dialog").close());
byId("register-cancel").addEventListener("click", () => byId("register-dialog").close());
byId("activity-search").addEventListener("input", renderActivity);
byId("clear-activity").addEventListener("click", () => {
ui.activity = [];
renderActivity();
});
document.addEventListener("keydown", (event) => {
if (["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName)) return;
if (event.key === "F5") {
event.preventDefault();
if (!byId("continue-button").disabled) executionCommand("continue", "Continue");
} else if (event.key === "F6") {
event.preventDefault();
if (!byId("pause-button").disabled) executionCommand("pause", "Pause");
} else if (event.key === "F10") {
event.preventDefault();
if (!byId("step-button").disabled) executionCommand("step", "Step");
}
});
}
async function initialize() {
bindEvents();
await pollSnapshot();
window.setInterval(pollSnapshot, 700);
window.setInterval(() => {
if (ui.snapshot?.connected) refreshTarget();
}, 7000);
}
initialize();
@@ -0,0 +1,302 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>SharpEmu Debugger</title>
<link rel="icon" type="image/webp" href="/sharpemu-logo.webp">
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="ambient ambient-one"></div>
<div class="ambient ambient-two"></div>
<header class="app-header">
<div class="brand">
<div class="brand-mark" aria-hidden="true"><img src="/sharpemu-logo.webp" alt=""></div>
<div>
<div class="eyebrow">LIVE DEVELOPMENT TOOLS</div>
<h1>SharpEmu <span>Debugger</span></h1>
</div>
</div>
<form id="connection-form" class="connection-bar">
<label class="connection-field">
<span>Host</span>
<input id="debug-host" name="host" autocomplete="off" value="127.0.0.1" aria-label="Debugger host">
</label>
<div class="connection-divider" aria-hidden="true">:</div>
<label class="connection-field port-field">
<span>Port</span>
<input id="debug-port" name="port" type="number" min="1" max="65535" value="5714" aria-label="Debugger port">
</label>
<button id="connection-button" class="button button-primary" type="submit">
<span class="button-icon" aria-hidden="true"></span>
<span id="connection-button-label">Connect</span>
</button>
</form>
<div id="connection-status" class="connection-status disconnected" aria-live="polite">
<span class="status-dot"></span>
<div>
<strong id="connection-state">Offline</strong>
<small id="connection-endpoint">Not connected</small>
</div>
</div>
</header>
<nav class="execution-toolbar" aria-label="Execution controls">
<div class="toolbar-group">
<button id="continue-button" class="tool-button accent" type="button" title="Continue (F5)">
<span class="tool-icon"></span><span>Continue</span><kbd>F5</kbd>
</button>
<button id="pause-button" class="tool-button" type="button" title="Pause (F6)">
<span class="tool-icon"></span><span>Pause</span><kbd>F6</kbd>
</button>
<button id="step-button" class="tool-button" type="button" title="Step frame (F10)">
<span class="tool-icon"></span><span>Step frame</span><kbd>F10</kbd>
</button>
<div class="toolbar-separator"></div>
<button id="refresh-button" class="tool-button compact" type="button" title="Refresh target state">
<span class="tool-icon"></span><span>Refresh</span>
</button>
</div>
<div class="target-pill">
<span>Target</span>
<strong id="toolbar-target-state">Disconnected</strong>
</div>
</nav>
<main class="workspace">
<section class="launch-panel" aria-labelledby="launch-title">
<div class="launch-heading">
<div class="launch-icon" aria-hidden="true"></div>
<div>
<div class="eyebrow">LOCAL SESSION</div>
<h2 id="launch-title">Launch and attach</h2>
<p>Choose a game executable and the frontend will start SharpEmu with its debugger enabled.</p>
</div>
</div>
<form id="launch-form" class="launch-form">
<label class="launch-path-field">
<span>Game executable</span>
<input id="eboot-path" placeholder="/path/to/game/eboot.bin" autocomplete="off" required>
</label>
<button id="browse-eboot-button" class="button button-secondary align-end" type="button">Browse…</button>
<button id="launch-button" class="button button-primary align-end" type="submit">
<span class="button-icon" aria-hidden="true"></span>Launch &amp; attach
</button>
<button id="stop-emulator-button" class="button button-danger align-end" type="button" disabled>Stop</button>
</form>
<div class="launch-status">
<span id="emulator-status-dot" class="status-dot"></span>
<div>
<strong id="emulator-status">No frontend-launched session</strong>
<small id="emulator-detail">You can still attach to an emulator that is already running.</small>
</div>
</div>
</section>
<section class="dashboard-grid">
<article class="panel target-panel">
<div class="panel-header">
<div>
<div class="eyebrow">SESSION</div>
<h2>Target overview</h2>
</div>
<span id="target-state-badge" class="state-badge disconnected">Disconnected</span>
</div>
<div class="target-address">
<span>Instruction pointer</span>
<strong id="target-address"></strong>
</div>
<dl class="fact-grid">
<div><dt>Stop reason</dt><dd id="stop-reason"></dd></div>
<div><dt>Frame</dt><dd id="frame-kind"></dd></div>
<div class="wide"><dt>Image / module</dt><dd id="frame-label"></dd></div>
<div><dt>Result</dt><dd id="stop-result"></dd></div>
<div><dt>Opcode bytes</dt><dd id="opcode-bytes"></dd></div>
</dl>
<div id="stop-detail-wrap" class="stop-detail hidden">
<span>Detail</span>
<p id="stop-detail"></p>
</div>
<div id="stall-analysis" class="stall-analysis hidden">
<div class="stall-analysis-header">
<div>
<div class="eyebrow">STALL DIAGNOSIS</div>
<h3 id="stall-analysis-title">Execution is not making progress</h3>
</div>
<span id="stall-confidence" class="confidence-badge">Medium confidence</span>
</div>
<p id="stall-summary" class="stall-summary"></p>
<div class="diagnosis-block cause-block">
<span class="diagnosis-label">Why this happens</span>
<p id="stall-cause"></p>
</div>
<div class="diagnosis-block fix-block">
<span class="diagnosis-label">Most likely fix</span>
<p id="stall-fix"></p>
</div>
<div class="diagnosis-columns">
<div>
<span class="diagnosis-label">Recommended checks</span>
<ol id="stall-actions"></ol>
</div>
<div>
<span class="diagnosis-label">Evidence</span>
<ul id="stall-evidence"></ul>
</div>
</div>
<p class="diagnosis-disclaimer">Heuristic diagnosis based on the detected loop and resolved HLE import. Confirm with tracing before changing synchronization behavior.</p>
</div>
</article>
<article class="panel registers-panel">
<div class="panel-header">
<div>
<div class="eyebrow">CPU STATE</div>
<h2>Registers</h2>
</div>
<span class="panel-hint">Select a value to edit</span>
</div>
<div id="register-grid" class="register-grid empty-state">
<p>Pause the target to inspect registers.</p>
</div>
</article>
<article class="panel breakpoints-panel">
<div class="panel-header">
<div>
<div class="eyebrow">EXECUTION</div>
<h2>Breakpoints</h2>
</div>
<span id="breakpoint-count" class="count-badge">0</span>
</div>
<form id="breakpoint-form" class="inline-form breakpoint-form">
<label class="grow">
<span>Address</span>
<input id="breakpoint-address" placeholder="0x00000008801234A0" autocomplete="off" required>
</label>
<label>
<span>Kind</span>
<select id="breakpoint-kind">
<option value="execute">Execute</option>
<option value="readwatch">Read watch</option>
<option value="writewatch">Write watch</option>
<option value="accesswatch">Access watch</option>
</select>
</label>
<label class="length-field">
<span>Length</span>
<input id="breakpoint-length" type="number" min="1" value="1">
</label>
<button class="button button-secondary align-end" type="submit">Add</button>
</form>
<div class="table-wrap">
<table class="data-table">
<thead><tr><th>On</th><th>ID</th><th>Kind</th><th>Address</th><th>Length</th><th></th></tr></thead>
<tbody id="breakpoint-table">
<tr class="empty-row"><td colspan="6">No breakpoints configured.</td></tr>
</tbody>
</table>
</div>
<p class="panel-note">Execute breakpoints are active at frame boundaries. Data watchpoints are protocol-ready and await CPU backend hooks.</p>
</article>
<article class="panel memory-panel">
<div class="panel-header">
<div>
<div class="eyebrow">GUEST MEMORY</div>
<h2>Memory inspector</h2>
</div>
<span id="memory-meta" class="panel-hint">Up to 64 KiB</span>
</div>
<form id="memory-read-form" class="inline-form memory-controls">
<label class="grow">
<span>Address</span>
<input id="memory-address" placeholder="0x0000000880200000" autocomplete="off" required>
</label>
<label class="memory-length">
<span>Bytes</span>
<input id="memory-length" type="number" min="1" max="65536" value="256" required>
</label>
<button class="button button-secondary align-end" type="submit">Read memory</button>
</form>
<pre id="memory-view" class="memory-view" tabindex="0">No memory loaded.</pre>
<form id="memory-write-form" class="write-form">
<label>
<span>Write address</span>
<input id="memory-write-address" placeholder="0x0000000880200000" autocomplete="off" required>
</label>
<label class="grow">
<span>Hex bytes</span>
<input id="memory-write-bytes" placeholder="90 90 CC" autocomplete="off" required>
</label>
<button class="button button-danger align-end" type="submit">Write</button>
</form>
</article>
<article class="panel activity-panel">
<div class="panel-header activity-header">
<div>
<div class="eyebrow">PROTOCOL</div>
<h2>Activity</h2>
</div>
<div class="activity-actions">
<label class="search-box">
<span aria-hidden="true"></span>
<input id="activity-search" placeholder="Filter activity" aria-label="Filter activity">
</label>
<button id="clear-activity" class="icon-button" type="button" title="Clear activity">Clear</button>
</div>
</div>
<div id="activity-stream" class="activity-stream" role="log" aria-live="polite">
<div class="activity-empty">Protocol events and commands will appear here.</div>
</div>
<details class="raw-command">
<summary>Advanced: send raw JSON request</summary>
<form id="raw-command-form">
<textarea id="raw-command-input" spellcheck="false">{"command":"status"}</textarea>
<button class="button button-secondary" type="submit">Send request</button>
</form>
</details>
</article>
</section>
</main>
<footer class="app-footer">
<span>SharpEmu JSON-lines protocol <strong id="protocol-version"></strong></span>
<span class="footer-shortcuts"><kbd>F5</kbd> Continue <kbd>F6</kbd> Pause <kbd>F10</kbd> Step</span>
</footer>
<dialog id="register-dialog" class="modal">
<form id="register-form" method="dialog">
<div class="modal-header">
<div><div class="eyebrow">CPU STATE</div><h2>Edit register</h2></div>
<button id="register-dialog-close" class="icon-button close-button" type="button" aria-label="Close">×</button>
</div>
<label>
<span>Register</span>
<input id="register-name" readonly>
</label>
<label>
<span>Value</span>
<input id="register-value" autocomplete="off" required>
</label>
<div class="modal-actions">
<button id="register-cancel" class="button button-ghost" type="button">Cancel</button>
<button class="button button-primary" type="submit">Apply value</button>
</div>
</form>
</dialog>
<div id="toast-stack" class="toast-stack" aria-live="assertive"></div>
<script src="/app.js"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Some files were not shown because too many files have changed in this diff Show More