Compare commits

...

123 Commits

Author SHA1 Message Date
ParantezTech 7fe80278f1 chore: bump version to 0.0.2-beta.5 2026-07-23 03:06:53 +03:00
Berk 4191a9e12b [Bink2] rework bridge to use FFmpeg's native Bink2 decoder instead of a C bridge (#554)
* [Bink2] rework bridge to use FFmpeg's native Bink2 decoder instead of a C bridge

* [readme] update DeS screenshot
2026-07-23 03:06:14 +03:00
Mariano Zambelli 559b7f0a84 feat(voice): add QoS stubs (GetStatus, Terminate, SetMode) (#541)
* feat(voice): add QoS stubs (GetStatus, Terminate, SetMode)

Titles call these functions during voice/multiplayer setup to check
network availability and configure modes. Unresolved imports caused
WARN floods in the loader logs. Reporting initialized + disconnected
lets callers take their normal offline path.

* fix(voice): return success (0) from sceVoiceQoSGetStatus instead of disconnected state
2026-07-23 01:48:55 +03:00
MarcelMediaDev 2272b9b576 fix(ajm): silence BatchJobDecode/Start/Wait/Cancel hot-path stubs (#547)
Unresolved batch NIDs flooded Import WARNs on Bink/AJM. Claim input
consumed with silence produced; this is not a real codec.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 01:44:07 +03:00
MarcelMediaDev 8dd3172c0f fix(systemservice): stub notice-screen skip flag setters (#549)
Settings probes Set/DisableNoticeScreenSkipFlagAutoSet; unresolved
NOT_FOUND can stall the SaveModTime/Load path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 01:41:08 +03:00
Berk e7ea186ea8 Enhance contribution guidelines with PR expectations
Added expectations for pull requests regarding observable behavior and testing requirements. Clarified guidelines for AI-assisted contributions.
2026-07-23 01:40:00 +03:00
MarcelMediaDev 74a519875b fix(agc): add missing Cb/Dcb GetSize stubs for packet sizing probes (#535)
Unresolved GetSize NIDs returned NOT_FOUND during RenderThread startup,
leaving null packet pointers and an immediate write AV. Return fixed
packet byte sizes in rax only — no guest memory writes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 00:18:47 +03:00
Andrey Modnov 4682e64e81 [CLI] Simplify mitigated child arguments (#529) 2026-07-23 00:18:11 +03:00
Kurt Himebauch 912883de05 fix(cmake): invalidate stale FFmpeg library cache (#543) 2026-07-22 23:56:37 +03:00
Berk f704586a8d [VideoOut] Add Bink2 support via FFMPEG bridge (#527)
* [VideoOut] Add Bink2 support via FFMPEG bridge

* [CMake] update commit

* [CMake] update commit
2026-07-22 21:41:41 +03:00
jute-ado d3600c9255 fix(ajm): accept Gen5 codec types (#526) 2026-07-22 18:24:06 +03:00
jute-ado 5f97031df5 shader: allow larger bounded Gen5 programs (#514) 2026-07-22 14:46:15 +03:00
h4sht 2a4da8c0a9 [Kernel/Semaphore] Close race between sceKernelWaitSema and sceKernelSignalSema (#504)
When sceKernelWaitSema finds the count insufficient it increments
WaitingThreads, releases the semaphore gate, and calls
RequestCurrentThreadBlock to set the thread-static block flags. A
signal arriving before the scheduler registers the block metadata
is missed by WakeBlockedThreads — the waiter has not been
registered yet and the signal's wake iteration skips it.

The scheduler's exit handler already re-checks TryWake() after
setting the thread to Blocked, but that requires the thread to
fully exit to the scheduler and back. Instead, re-check the
semaphore count under the gate immediately after the block request:
if the count is now sufficient, consume the tokens, cancel the
pending block via TryConsumeCurrentThreadBlock, and return without
ever yielding to the scheduler.

Co-authored-by: tru3 <tru3@tru3.com>
2026-07-22 14:34:19 +03:00
h4sht 4c37e64c66 [NpWebApi2] Add sceNpWebApi2PushEventCreateFilter stub (#503)
Add sceNpWebApi2PushEventCreateFilter (NID: MsaFhR+lPE4) to the
libSceNpWebApi2 module. This function is called by Unity games
during initialization and was unresolved, causing an import warning
and returning ORBIS_GEN2_ERROR_NOT_FOUND.

The stub validates the library context and returns an incrementing
filter handle, following the same pattern as the existing
sceNpWebApi2PushEventCreateHandle.

NID sourced via:
  python scripts/aerolib_catalog.py lookup MsaFhR+lPE4

Co-authored-by: tru3 <tru3@tru3.com>
2026-07-22 14:33:43 +03:00
kostyaff fc9e3ff393 fix: roll back earlier host allocations on later gap failure in TryBackFixedRange (#472) (#474)
When a fixed mapping spans multiple free runs and a later gap cannot be
backed, any earlier host allocations were leaked. Stage all allocations
during the walk and insert MemoryRegions only after every gap has been
backed successfully. On any failure, free all staged allocations.

Fixes #472

🤖 Generated with Hermes Agent
2026-07-22 14:28:25 +03:00
samto6 eb47d753f6 [Ampr] Implement the FW 4.00 write-address command exports (#510) 2026-07-22 03:00:30 +03:00
h4sht 6aa78bb55b [Loader] Fall back to fixed-range backfill when main image base is occupied (#493)
When TryAllocateAtExact fails for the main image base (0x800000000
for PS5, 0x400000 for PS4), the loader previously threw a fatal
InvalidOperationException with no recovery path. This happens when
the host OS has already claimed part of that address range — common
under Rosetta 2, with aggressive ASLR, or when another process maps
into the guest address space.

Instead of failing immediately, attempt TryBackFixedRange which
backs the range page by page, claiming any free gaps. If the
backfill also fails, Clear() rolls back partial allocations and
the exception now includes platform-specific recovery advice.

This prevents the most common emulator startup crash on affected
hosts.

Co-authored-by: tru3 <tru3@tru3.com>
2026-07-21 18:18:58 +03:00
h4sht 9be6f85ef0 [Font] Implement sceFontGetVerticalLayout (#492)
Add sceFontGetVerticalLayout (NID: 3BrWWFU+4ts) to the Font module,
completing the vertical-text counterpart to the existing
GetHorizontalLayout. The SceFontVerticalLayout structure is three
floats (baseline, lineAdvance, decorationExtent) interpreted for
vertical writing such as CJK text rendered top-to-bottom.

- Write baseline=8.0f, lineAdvance=16.0f, decorationExtent=0.0f
- Validate output pointer and return INVALID_ARGUMENT on null
- Return MEMORY_FAULT when guest writes fail

Tests:
- GetVerticalLayout_WritesExactlyThreeFloats with sentinel guard
- GetVerticalLayout_NullBuffer_ReturnsInvalidArgument

NID sourced via: python scripts/aerolib_catalog.py lookup sceFontGetVerticalLayout

Co-authored-by: tru3 <tru3@tru3.com>
2026-07-21 18:18:16 +03:00
Kurt Himebauch 4c8c67a3dd fix: Add ASTRO BOT compatibility stubs (#481)
* Add ASTRO BOT compatibility stubs

* Fix ASTRO BOT compatibility stubs
2026-07-21 18:17:40 +03:00
999sian ada67a1924 cpu: recover SSE4a EXTRQ/INSERTQ faults on Linux (#482)
The fault-time SSE4a fallback was Windows-only because the POSIX signal
bridge never carried XMM state: the CONTEXT scratch buffer only held the
17 general-purpose registers, so emulating EXTRQ/INSERTQ there would
have computed results from zeroed bytes and discarded the write. Bridge
the XMM registers on Linux by copying them between the mcontext's
FXSAVE image (kernel sigcontext ABI, libc-independent) and the CONTEXT
FltSave slots on capture and write-back, and gate the recovery on that
bridge instead of on Windows. Darwin still declines: its XMM area
remains unbridged.

With this, guest EXTRQ/INSERTQ on Linux hosts without SSE4a (any Intel
CPU) resumes with correct register state instead of dying on an
unrecovered SIGILL (#328).
2026-07-21 14:18:21 +03:00
Slick Daddy 2379e8988c [Loader] Collect stub-eligible NIDs in one pass over descriptors (#489)
BuildImportStubs filtered orderedImportNids by calling ShouldCreateImportStub
for each unique NID, and every call scanned the entire descriptor list
looking for a match. On a real module both the NID count and the descriptor
count run into the thousands, so the filter degraded to O(nids * descriptors)
ordinal string comparisons on the one-time load path.

Replace the per-NID rescan with a single pass over the descriptors that
builds a HashSet of eligible NIDs, then filter orderedImportNids with O(1)
membership. Eligibility is unchanged: a NID qualifies when any of its
descriptors is non-weak, or is weak but resolvable via the module manager.

ShouldCreateImportStub is retained (still used by the DEBUG self-checks), and
a self-check now asserts the set-based collector agrees with the per-NID rule.

Co-authored-by: slick-daddy <slick-daddy@users.noreply.github.com>
2026-07-21 12:59:30 +03:00
Slick Daddy 105c58b380 [Tests] Isolate Gen5 scalar fallback test from parallel static mutation (#488)
ScalarLoadReadsTrackedFallbackMemory swaps the process-global static
Gen5ShaderScalarEvaluator.FallbackMemoryReader under a lock private to the
test class. The SharpEmu.Libs [ModuleInitializer] (AgcShaderCompilerHooks)
assigns the same static to TryReadShaderGuestMemory the first time any Libs
type is touched, and it does not take that lock. Under xUnit's default
cross-class parallelism a concurrent Libs test could fire the initializer
mid-test, clobbering the swapped-in reader — observed on CI (linux-x64) as
the fallback returning all zeros: Expected [1181044592, 4, 1319632096, 4],
Actual [0, 0, 0, 0].

Put the test in a DisableParallelization collection, matching the existing
convention for shared-mutable-static tests (KernelMemoryCompatState,
AjmState, AvPlayerPathState). The collection runs alone in the non-parallel
phase, so no other test can mutate the static while this one holds it.

Co-authored-by: slick-daddy <slick-daddy@users.noreply.github.com>
2026-07-21 12:59:03 +03:00
Slick Daddy da35f0db47 [Audio] Hoist volume clamp out of the per-sample PCM loop (#487)
Co-authored-by: slick-daddy <slick-daddy@users.noreply.github.com>
2026-07-21 12:58:35 +03:00
Slick Daddy 1f3963c543 [Gpu] Factor the exact-XOR swizzle equation in the texture detiler (#483)
TryDetile's exact-XOR fast path (PS5 swizzle modes 5/9/24/27) ran the
full AddrLib address equation per element: a 16-bit interleave with 32
PopCount calls for every pixel of textures that are millions of elements.

Each output bit is parity(x & XMask) XOR parity(y & YMask), and parity
distributes over XOR, so the offset factors into independent xTerm(x) ^
yTerm(y) fields. Precompute the per-column X term once and hoist the Y
term per row, collapsing the inner loop to one array load and one XOR.

Add GnmTilingDetileTests, which lays out a tiled buffer from an
independent re-derivation of the mode-27 equation and asserts TryDetile
reconstructs it byte-for-byte.

Co-authored-by: slick-daddy <slick-daddy@users.noreply.github.com>
2026-07-21 12:57:39 +03:00
iExplosiveRage 4bb1af93d7 SaveData: avoid invalid DeS transaction resource pointer (#480)
Demon's Souls treats the small transaction-resource handle as a guest pointer during the fresh-save path. Return a null resource for the observed call shape to prevent the repeatable access violation at address 0x9.

Co-authored-by: RedDv <RedDv@DESKTOP-EVNB4S8>
2026-07-21 02:22:51 +03:00
Nicola Pomarico 0ae785c617 [VideoPresenter] Accept padded row pitch in guest image uploads (#475)
The guest can hand initial texture data whose rows are padded out to a
hardware alignment wider than the image width, so the total byte count
exceeds the tightly packed width*height*bpp we compute. The upload path
rejected any byte count that did not match exactly, silently dropping
these uploads and leaving the texture blank.

Recover the real source row length when the byte count is consistent
with a common padding alignment (8/16/32/64/128/256 texels) and pass it
through as BufferRowLength on the copy, instead of always hardcoding 0.
Uploads that do not match a recognised padded layout are still rejected
as before.

Verified against Dead Cells (PPSA15552): a loading-transition texture
upload that previously wedged the title now uploads correctly and the
game proceeds past the load screen, running stably past 1M draw calls
with no stalls. Dreaming Sarah (tightly packed path) still renders
normally, confirming no regression to the non-padded case.
2026-07-21 01:01:28 +03:00
Slick Daddy e01092aa38 Kernel FS: close guest→host sandbox escapes in the path resolver (#478)
* Kernel FS: default-deny unmapped guest paths (fixes absolute-path host escape)

ResolveGuestPath returned any unrecognized guest path verbatim as the host
path. Because absolute paths ("/etc/passwd", "C:\Windows\...") are already
fully qualified, they skipped the relative-path app0 fallback and were handed
straight to FileStream/File.Delete/etc., giving a malicious game arbitrary
host-file read/write/delete outside the sandbox.

Return string.Empty (deny) on fallthrough instead. Most callers already treat
a nonexistent host path as NOT_FOUND; open/truncate/rename get an explicit
empty-path guard so a denied path can't reach FileStream and throw an
ArgumentException their catch blocks don't cover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Kernel FS: contain built-in mounts (fixes Windows drive-letter injection)

The built-in mount branches (app0/temp0/download0/hostapp/devlog) combined
the mount-relative guest path onto the host root without re-checking
containment. NormalizeMountRelativePath clamps ./.. but splits only on
separators, so a drive-qualified token like "C:" survives as a segment and
Path.Combine then discards the mount root, yielding a raw host path such as
"C:\Windows\..." (arbitrary host read/write).

Route every built-in branch through a new CombineWithinMount helper that
re-resolves with Path.GetFullPath and verifies the result stays under the
mount root -- the same guard TryResolveRegisteredGuestMount already applied.
Denied paths return string.Empty, which callers treat as unresolved.

AprStreamingContractTests passed a raw Path.GetTempFileName() as the guest
path, relying on the now-removed absolute-path passthrough; updated it to
address the file through a registered mount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Kernel FS: reject reparse points inside mounts (fixes symlink escape)

Lexical containment (Path.GetFullPath + StartsWith) proves the textual
path stays under the mount root but does not follow symlinks/junctions.
A malicious game dump could plant a reparse point inside app0/temp0/etc.
pointing outside it, so a contained-looking path resolved onto the host
filesystem. Walk each existing component from the mount root to the
candidate and refuse any reparse point, in both the built-in and
registered-mount resolution paths. Mirrors AvPlayer's existing defense.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Kernel FS: fail closed when path containment cannot be verified

The reparse-point and drive-letter containment guards call Path.GetFullPath
and File.GetAttributes on untrusted guest paths. Both throw on crafted
over-long or invalid-char input, and ResolveGuestPath runs outside the file
syscalls' try blocks, so such a path was a guest-triggerable crash rather
than a denial.

Wrap the GetFullPath calls in CombineWithinMount and the registered-mount
path, and widen the GetAttributes catch, to treat any access/format failure
as an escape (deny) instead of propagating. Also tighten the ".." fallback
check so a legitimate file named "..foo" is not falsely rejected, and hoist
the repeated Path.GetFullPath(mountRoot) into a local.

Adds a regression test asserting the resolver returns without throwing for
an over-long and a NUL-embedded path under a mount.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Kernel FS: assert malformed paths resolve to empty, not just no-throw

The fail-closed regression test asserted only Assert.NotNull, which a
non-nullable string return can never violate via its value (only a throw,
which aborts the test earlier anyway). Tighten to Assert.Equal(string.Empty)
so it also locks in fail-CLOSED: a regression where a malformed path resolved
to a non-empty host path would now be caught.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Kernel FS: route new AMPR batch tests through a registered mount

Merging main brought in three AprStreamingContractTests that pass raw
Path.GetTempFileName()/temp host paths as guest paths. The default-deny
resolver from this branch rejects absolute host paths, so MissingMidBatch
failed at index 0 instead of the intended index 1. Address the present
file through a registered mount (as ResolveStatAndReadFile already does)
so entries 0 and 2 resolve and the batch fails at the genuinely-missing
entry. The two all-missing tests were unaffected but share the fix's intent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Kernel FS: make a matched-mount denial terminal; fix Unix-only test asserts

A registered mount that claims a path by prefix but denies it (failed
containment or a reparse point inside the mount) now short-circuits in
ResolveGuestPath instead of falling through to the built-in mount branches.
The fall-through let an overlapping prefix (a registered "/app0" vs the
built-in SHARPEMU_APP0_DIR branch, which resolves against a cached root)
re-resolve a denied path and turn the denial back into a resolution -- the
reparse-point escape reappeared on Linux CI through exactly this path.

Also fix two tests that asserted Windows-specific behavior unconditionally:
a "C:\..." path is not absolute on Unix (it resolves contained under the
mount there), and that case is now pinned to Windows only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: slick-daddy <slick-daddy@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 00:58:34 +03:00
TarkusTK 224a36eba7 [Gpu] Stop retrying array uploads that overrun their allocation (#476)
A 2D-array texture whose Depth times the per-slice stride runs past its
real allocation fails a slice read partway through the upload loop, and
falls through to the single-slice path after already detiling the layers
it did read. That fall-through builds the texture with ArrayLayers
defaulting to 1, so the presenter caches it under a one-layer key while
the next draw looks it up with ArrayLayers = Depth. The two never match,
so the texture misses the cache and repeats the whole read-and-detile on
every draw, throwing the result away each time.

Detiling is per-texel swizzle math, so one such texture retried a few
times per frame is expensive: it measured 568-879 ms of every second in
Demon's Souls, against a 1.4 second frame.

An allocation that is too short stays too short, so remembering the
address and not retrying it costs nothing and repairs the cache key as a
side effect: with the array upload skipped, arrayUploadLayers is 1, which
is exactly what the fall-through texture reports.

Tested on Demon's Souls (PPSA01342): 0.7 fps to 3.6-4.1 fps, CPU detile
time per second from ~700 ms to 0, and arrayed textures go from missing
the cache on every draw to hitting it every time. 28 of the 29 array
uploads in that run already succeeded and are unaffected; only the one
overrunning texture now falls back to its base slice. 495 tests pass.
2026-07-20 19:22:19 +03:00
Berk ac883e44fa [VideoPresenter] Fix logical width/height calculation (#473) 2026-07-20 16:57:38 +03:00
TarkusTK 25d741b35b [Gpu] Sample 2D array textures with real layers (#471)
Texture arrays were uploaded and viewed as plain 2D images, so every
layer index in a shader resolved to slice 0. The guest-texture gate
also rejected array, 3D and cube descriptor types outright, sending
those resources to the 1x1 black fallback.

UI atlases hit this constantly, since they pack several sheets as array
slices and pick one per vertex. In Demon's Souls the settings menu
bottom bar stretched a mid-atlas crop across itself, and slider thumbs
and button prompts drew the wrong sheet.

The MIMG decoder already had Dimension and IsArray, so
IsArrayedImageBinding makes one rule out of them for the SPIR-V
translator and the Vulkan backend to share. Both have to agree or the
declared image type and the bound view type mismatch. Sample and gather
bindings with an array address now declare an arrayed image and pass
(u, v, slice). AgcExports reads every slice at the per-slice mip-chain
stride and passes the layers packed in one buffer, which uploads as a
2D array image in a single copy region.

Load and store bindings are unchanged. Arrayed bindings that resolve to
a fallback or to a single-layer guest image get a one-layer 2D array
view so the descriptor still matches the shader.

Tested on Demon's Souls (PPSA01342): the bottom bar, slider thumbs and
button prompts draw their correct sheets. 470 tests pass.
2026-07-20 15:44:06 +03:00
TarkusTK dce7c87c4d [AGC] Implement the owner-scoped resource unregister exports (#469)
sceAgcDriverUnregisterOwnerAndResources (ZLJk9r2+2Aw) and
sceAgcDriverUnregisterAllResourcesForOwner (SCoAN5fYlUM) were
unresolved. We already register owners and resources, and the guest
registers a resource owner per streaming batch, so with no way to
release one the fixed owner pool filled up: sceAgcDriverRegisterOwner
started failing and the guest logged its own "Agc registerOwner error:
0x80020003", after which it kept half-registering records. Demon's Souls
then crashed scanning that registry.

Owner-scoped teardown is straightforward because RegisteredAgcResource
already carries its owner, so both entry points share one sweep over the
resource table. UnregisterOwnerAndResources additionally drops the owner
itself and its compute queue, and reports INVALID_ARGUMENT for an owner
that was never registered. The existing single-resource
sceAgcDriverUnregisterResource (pWLG7WOpVcw) is unchanged.

Both NIDs are checked against their export names by the SHEM004
analyzer, which fails the build on a mismatch.

Tested on Demon's Souls (PPSA01342): the registerOwner error no longer
appears and the registry stays consistent across streaming batches. 470
tests pass.
2026-07-20 15:43:47 +03:00
TarkusTK 6ee445f0c2 [AGC] Read mip 0 from its GFX10 mip-chain offset (#470)
GFX10 stores a mip chain smallest-first: the mip tail packs into the
first swizzle block, the remaining mips follow in decreasing size, and
mip 0 ends up at the end of the allocation. We read the base level
straight from the descriptor address, so every mipped sampled texture
decoded as a collage of its own smaller mips - in Demon's Souls that
showed up as scrambled menu text and repeated controller icons.

GnmTiling.TryGetBaseMipPlacement ports the AddrLib chain-offset math
from Gfx10Lib::ComputeSurfaceInfoMacroTiled/MicroTiled. It returns a
byte offset to mip 0, or, when the whole chain fits inside the tail
block, the element coordinates of mip 0 within that block.
TryCreateGuestDrawTexture applies the offset to the sampled and storage
guest reads, and TryDetileTextureSource lifts a tail-resident mip 0 out
of the detiled block as a sub-rectangle.

MAX_MIP is only decoded from extended descriptors, so resources without
one, and single-level resources, keep the current behaviour.

Tested on Demon's Souls (PPSA01342): menu text and icons decode
correctly instead of showing shrunken copies of themselves. Verified
offline by dumping the raw tiled bytes and the detiled output for a
4096x4096 UI atlas and checking the art lands at the sampled
coordinates.
2026-07-20 15:27:46 +03:00
StealUrKill 9d187dec55 Prevent AvPlayer movie startup failures across supported hosts (#456)
* Prevent AvPlayer movie startup failures across supported hosts

* Prevent GR2 startup stalls during APR file checks and adaptive mutex self-locks.
2026-07-20 15:27:37 +03:00
kuba 3574a3b145 Shader: lower VOP3P V_FMA_MIX_F32/LO/HI (was dropping Unity HDR shaders) (#466)
The decoder recognises the VOP3P mix ops (0x20 V_FMA_MIX_F32, 0x21
V_FMA_MIXLO_F16, 0x22 V_FMA_MIXHI_F16) but left them opaque
(Vop3pRaw20/21/22), so at SPIR-V emission they fell through the
vector-ALU switch to the default and failed with "unsupported vector
opcode". A single unhandled instruction fails the whole compile, so any
shader using fma_mix was dropped entirely. Unity's built-in-RP /
PostProcessing v2 HDR, tone-mapping and auto-exposure shaders emit
V_FMA_MIX_F32, so those passes never translated (this is what kept
Superliminal's auto-exposure luminance chain from running).

Name the three opcodes in DecodeVop3p (like the packed v_pk_* ops) and
lower them in the SPIR-V translator. Each mix op computes a single f32
fma(a, b, c) where every source is read *independently* as either a full
f32 register/constant or one f16 half widened to f32. Per operand,
op_sel_hi selects f16-vs-f32 and op_sel picks which f16 half; the neg_hi
field is repurposed as an absolute-value modifier and neg negates,
applied abs-then-neg. This reuses the VOP3P op_sel/op_sel_hi/neg/neg_hi
bit layout with the mix-specific meaning, not the packed-math meaning.
The result is a scalar f32 for V_FMA_MIX_F32; _MIXLO/_MIXHI narrow it
back to f16 (exact round-to-nearest-even, via the existing
EmitFloatToHalf) and write it into the low/high 16 bits of vdst,
preserving the other half. The clamp modifier saturates to [0, 1]
consistently with the other VOP3P ops. Per-operand F16/F32 select and
the abs/neg modifiers follow shadPS4's GetSrcMix, the authoritative
reference for the mix semantics.

Adds Gen5FmaMixSpirvTests: assembles V_FMA_MIX_F32 (with a representative
op_sel/op_sel_hi/neg/abs) and V_FMA_MIXLO_F16 compute shaders and asserts
they translate to GPU SPIR-V without hitting the drop path and emit a
GLSL.std.450 Fma (and an FAbs for the neg_hi modifier). Both fail against
the pre-fix tree with "unsupported vector opcode Vop3pRaw20/21".
2026-07-20 14:37:40 +03:00
Job Meijer a1cbff8a9c Fix NID BHouLQzh0X0, doubled StartupStaticTlsReservation memory. Both needed to launch GTA V. (#454)
* Increased StartupStaticTlsReservation (doubled) and fixed mistake in NID BHouLQzh0X0. Now GTA V RAGE engine seems to start loading.

* fixed NID BHouLQzh0X0, this had an issue causing GTA V not to load. Also doubled StartupStaticTlsReservation.

* Removed .vscode folder and reverted global.json
2026-07-20 14:37:30 +03:00
Spooks db9b20481c Add internal render resolution scale and fix DPI Issue (#468)
* Add internal render resolution scale and fix embedded surface DPI scaling

Adds a GUI-configurable internal resolution scale (Graphics tab) that
renders offscreen color/depth targets below native guest resolution
and upscales on present, trading image quality for GPU headroom.
Storage/UAV images and sampled asset textures are left untouched, and
texture-alias/feedback-loop lookups compare against each target's
logical (unscaled) size so scaled render targets are still found
correctly when sampled back.

Also fixes the embedded game surface not filling the window: the
isolated emulator child process had no declared DPI awareness, so
Windows silently downscaled every window-geometry query it made
against the GUI-owned surface HWND by the display's DPI factor,
leaving an unfilled black margin on scaled displays.

* Remove flaky Gen5ScalarMemoryFallbackTests

* Restore Gen5ScalarMemoryFallbackTests

---------

Co-authored-by: Spooks4576 <Spooks4576@users.noreply.github.com>
2026-07-20 14:37:20 +03:00
ParantezTech 8cd46243ab Merge branch 'main' of https://github.com/sharpemu/sharpemu 2026-07-20 14:23:24 +03:00
ParantezTech 3334707f7c [CI] fix rule name 2026-07-20 14:23:04 +03:00
kuba 20eda4443c Shader: test a wave mask consumed as a per-lane predicate at the lane bit (#465)
* Shader: read a wave mask consumed as a per-lane predicate at the lane bit

A VCC/EXEC wave mask consumed as a per-lane predicate (the VCndmask
condition, a VCC/EXEC branch, or the derived _vcc/_exec bool) was tested in
single-lane emulation with a whole-word non-zero test (IsNotZero64) instead
of the current lane's bit. That is correct for comparison results (only the
lane's own bit is ever set) but wrong for bitwise-complement wave-mask idioms
(S_NOT / S_ORN2 / S_ANDN2 / S_NAND / S_NOR), which set the unused upper 63
bits: a whole-word test then reports the lane active even when its bit is
clear.

Unity's PostProcessing NaN killer does exactly this: per channel it computes
isNaN = NLT AND NGT AND NEQ (against 0), then combines the channels as
anyNaN OR NOT(v3-is-finite) via S_ORN2_B64. The complement set the upper mask
bits, so every valid pixel read as NaN and was replaced with 0, zeroing the
whole HDR scene before Bloom/Uber/tonemap. The 3D scene therefore rendered
black behind the menu while the UI survived. Extract the current lane's bit in
both single-lane and subgroup modes so IsWaveMaskActive matches the hardware.

Fixes Superliminal (PPSA06084) black 3D scene: the storage room now renders
behind the menu with natural exposure and no forced values.

(cherry picked from commit 7af6f4b6f314fe302619c0d44f4db00971c5bf24)

* test: wave-mask predicate is tested at the current lane bit

Regression test for the wave-mask lane-bit fix. Compiles a shader that
writes VCC at run time (V_CMP_EQ_F32) and asserts the emitted SPIR-V tests
the wave mask at the current lane's bit (mask & lane_bit) rather than with a
whole-word non-zero test. Fails against the previous IsNotZero64(mask) path,
which zeroed complement wave-mask idioms (S_ORN2/S_NOT, e.g. Unity's NaN
killer) across every lane.
2026-07-20 14:18:20 +03:00
Slick Daddy bb3318a503 kernel: return -1/errno from POSIX file syscalls on failure (#461)
* kernel: return -1/errno from POSIX open and fstat on failure

The POSIX-named open (wuCroIGjt2g) and fstat (mqQMh1zPPT8) exports routed
straight to the raw sceKernel* implementations, which report failure via
the 0x8002xxxx OrbisGen2Result sentinel in the return value. libc callers
follow the POSIX ABI and expect -1 with errno set, so they stored the
sentinel as a valid fd. Unity's IL2CPP file layer did exactly this while
probing the absent /app0/Media/il2cpp.usym: open returned NOT_FOUND
(0x80020002), the guest kept the sentinel as an fd, passed it back into
fstat, and eventually dereferenced a null pointer (vmovups xmm0,[rdi],
rdi=0) deep in a native .prx, crashing with 0xC0000005.

Wrap both entry points to translate a failed raw result into -1/errno,
mirroring the existing PosixStat/PosixLseek convention. Add a shared
PosixFailure helper (fstat maps a bad handle to EBADF; path calls default
to ENOENT) and route it through PosixStat too. Covered by two regression
tests reproducing the missing-file and misused-sentinel-fd cases.

* kernel: return -1/errno from POSIX close, read and write on failure

Same defect class as open/fstat: the POSIX-named close (bY-PO6JhzhQ),
read (AqBioC2vF3I) and write (FN4gaPmuFV8) exports forwarded the raw
sceKernel* core result, leaking the 0x8002xxxx sentinel to libc callers
that expect -1/errno on a bad fd. close in particular is on the crashing
Unity path, invoked on the sentinel the guest mistook for an fd.

Wrap all three through PosixFailure with EBADF as the fd-not-found errno.
Add regression tests for each, and correct the socket test that had
locked in the old raw-sentinel contract for a double close.

---------

Co-authored-by: slick-daddy <slick-daddy@users.noreply.github.com>
2026-07-20 14:17:08 +03:00
ParantezTech d151e151c2 [CI] fix zip inside zip 2026-07-20 10:14:55 +03:00
João Victor Amorim 472fc96a37 [AGC] Support the clamp modifier on packed f16 VOP3P ops (#460)
The VOP3P emitter rejected any packed op with the clamp bit set. Clamp
saturates each f16 output half to [0, 1] (and flushes NaN to 0, matching
RDNA), so games that emit clamped packed arithmetic fell back to a loud
emit failure.

Apply the saturation to the f32 result of each lane, before it is
narrowed back to f16. Because 0.0 and 1.0 are exact in both f32 and f16
and the clamp is monotonic, clamping in f32 and then rounding to f16
yields the same value as clamping the f16 result directly; for the fused
multiply-add the pre-narrowing value is the round-to-odd f32, which
preserves that equivalence through the final round-to-nearest-even. The
saturation uses ordered compares so a NaN result collapses to 0 without a
separate IsNan test.

Verification:
- The local exact-reference harness now also clamps: add, mul, and fma
  each compared against an f16-domain clamp reference (NaN -> 0, else
  [0, 1]) over directed boundary inputs and 24M random cases. 0
  mismatches, alongside the existing 34M unclamped fma cases.
- ShaderDump pk-f16 gains a clamped add and a clamped fma; all decode and
  emit.
- The exec program computes the pinned fma with clamp (both lanes exceed
  1.0, so each saturates to 0x3C00) and stores it at offset 28;
  GpuConformance checks it on device. All values match on an AMD Radeon
  RX 7700 XT.
2026-07-20 09:09:07 +03:00
Slick Daddy 33be88bdf9 memory: back the free pages of a partially-overlapping fixed mapping (#458)
A SCE_KERNEL_MAP_FIXED request whose window partially overlaps an
existing allocation was failing outright: AllocateAt reserves the whole
range in one all-or-nothing VirtualAlloc, which returns 0 on partial
overlap. The mapping call then returned NOT_FOUND while leaving the free
tail unmapped, so the guest faulted (0xC0000005) writing into it.

Add IGuestAddressSpace.TryBackFixedRange, which walks the range via the
host Query (VirtualQuery reports contiguous same-state runs) and fills
only the free sub-ranges, leaving already-backed pages untouched. This
matches the fixed-mapping contract on hardware. Route the fixed
reservation path through it via a new backPartialOverlap flag.

Co-authored-by: slick-daddy <slick-daddy@users.noreply.github.com>
2026-07-20 09:08:29 +03:00
kadu04t 184e24fbb6 PerGameSettings Null toggles (#453) 2026-07-20 01:30:13 +03:00
kuba 327018e80a Encode linear-float flips to sRGB at present (#448)
PS5 float VideoOut buffers (A16B16G16R16F flips) hold linear scRGB
light where 1.0 is SDR white; hardware scan-out applies the display
transfer function. vkCmdBlitImage converts numerically only, so
raw-blitting a linear-float guest frame into a UNORM swapchain crushes
dim scenes to near-black.

Blit float flip sources through a cached swapchain-sized sRGB
intermediate (the sRGB store performs the linear->sRGB encode), then
raw vkCmdCopyImage the encoded bytes into the same-compatibility-class
UNORM swapchain image. Swapchains that are already sRGB keep the
direct blit (their store encodes), and swapchain formats without an
sRGB counterpart keep today's raw blit unchanged.
2026-07-20 01:29:38 +03:00
kuba 04557fd250 Refresh CPU-rewritten guest textures by write generation (#447)
* Track guest CPU write generations

* Refresh CPU-rewritten guest textures by write generation
2026-07-20 01:29:30 +03:00
Spooks 90c72ebecf Fixes a Mutex Issue Preventing Some UE Titles From Booting (#451)
* Optimize guest import, memory, and pthread hot paths

* Fix UE adaptive mutex self-lock handling
2026-07-19 13:20:05 -06:00
Nekono 8ef5a54ee4 cpu: emulate AMD-only Zen 2 instructions in software (#449)
Handle immediate EXTRQ and INSERTQ as well as MONITORX and MWAITX when the host raises illegal-instruction faults. Add unit coverage for SSE4a bit-field semantics and preserve existing load-time patching.

Co-authored-by: zocomputer <help@zocomputer.com>
2026-07-19 21:57:42 +03:00
shadowbeat070 0c467e8c57 Add missing nids (#450)
* [Kernel] Implement clock_getres and the POSIX pthread_once alias

clock_getres (smIj7eqzZE8) was missing entirely. It reports 100ns, which
is the resolution clock_gettime here actually delivers via
DateTimeOffset.UtcNow, rather than claiming the 1ns a caller might
otherwise rely on. A null res pointer is accepted per POSIX.

pthread_once (Z4QosVuAsA0) needed no new logic: libKernel exports the
same routine under two NIDs and only scePthreadOnce (14bOACANTBo) was
registered. Shipped middleware links the plain name.

Both are imported by DOOM + DOOM II (PPSA21444): clock_getres blocked
party.prx from initialising, and pthread_once is used by libcohtml,
libPlayFabMultiplayer, party.prx and the eboot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 848f035827)

* [Libs] Implement sceAgcGetIsTrinityMode, NpReachability and Trophy2 info

Three exports DOOM + DOOM II (PPSA21444) imports and currently receives
unresolved-stub errors for.

sceAgcGetIsTrinityMode reports the base console this backend emulates. It
returns the flag in rax and writes no guest memory: the observed rdi at
the call site sits inside the AGC state block, immediately below the
shader handles the guest stores, so writing through it would corrupt live
state if that register is stale rather than an out-pointer.

sceNpRegisterNpReachabilityStateCallback accepts the callback and never
fires it, matching the existing sceNpRegisterStateCallback handling.
Reachability transitions only occur on a live PSN connection.

sceNpTrophy2GetTrophyInfo reports NOT_FOUND rather than success.
Succeeding requires filling SceNpTrophy2Details and SceNpTrophy2Data,
whose layouts are not confirmed here, and a title trusting zeroed details
would read an empty name and grade 0 as real data. NOT_FOUND is a
documented outcome callers already handle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 181286f621)

* [Kernel] Implement the POSIX libKernel exports titles link directly

libKernel exports many routines under both sce-prefixed and plain POSIX
NIDs, and shipped middleware links the latter. These nine are imported by
DOOM + DOOM II (PPSA21444) and had no registration at all.

Aliases onto existing implementations, identical argument order:
  mprotect (YQOfxL4QfeU), munmap (UqDGjXA5yUM), setsockopt (fFxGkxF2bVo)

New:
  getpagesize reports OrbisPageSize (16 KiB), not the host 4 KiB. An
  allocator rounding to the host value produces sub-page offsets that
  every mapping call here rejects for misalignment.

  pthread_rwlock_tryrdlock/trywrlock get a dedicated non-blocking core.
  They deliberately do not reuse TryAcquireBlockedRwlock, which
  decrements WaitingWriters -- correct only for a thread that previously
  incremented it. A fresh try never did, so reusing it would consume
  another thread's waiter count and let a queued writer be skipped.

  getsockopt reads back the three options this backend tracks (SO_NBIO,
  SO_REUSEADDR, SO_ERROR) and rejects the rest rather than returning
  success with an untouched buffer the caller would treat as real.

  send maps WouldBlock onto the existing net error path.

  inet_ntop converts AF_INET/AF_INET6 and returns the destination
  pointer per POSIX, failing rather than truncating when it will not fit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 98c6851840)

* [Kernel] Implement the POSIX mprotect, munmap and getpagesize aliases

mprotect and munmap forward to the existing sceKernelMprotect and
sceKernelMunmap; the argument order is identical, so they are plain
aliases rather than separate implementations.

getpagesize reports OrbisPageSize (16 KiB), the granularity this backend
maps and aligns against, not the host's 4 KiB. An allocator that rounded
to the host value would produce sub-page offsets that every mapping call
here then rejects for misalignment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* [Kernel] Implement the POSIX _nanosleep symbol

libKernel exports nanosleep under two NIDs: yS8U2TGCe1A for the plain
name and NhpspxdjEKU for the underscore-prefixed _nanosleep that libc
conventionally provides alongside it. Only the former was registered.

Both are POSIX-side symbols, so this shares NanosleepCore with posix:
true - reporting failure as -1 plus errno rather than returning an
OrbisGen2Result the way sceKernelNanosleep does.

Not exercised at runtime: no title currently on this branch imports
_nanosleep, so the choice of error convention rests on it being the
same libc routine as nanosleep, not on observed behaviour.

* [Kernel] Implement the POSIX-named pthread aliases

libKernel exports each of these routines under two NIDs: a scePthread*
name and the plain POSIX name. Only the scePthread* half was registered,
so middleware compiled against POSIX headers linked an unresolved stub.

Adds the POSIX-named export for fourteen routines, each delegating to
the existing implementation:

  pthread_setprio               pthread_attr_setschedpolicy
  pthread_getschedparam         pthread_attr_setdetachstate
  pthread_attr_getschedparam    pthread_attr_setschedparam
  pthread_attr_getstack         pthread_attr_setinheritsched
  pthread_attr_get_np           pthread_attr_setguardsize
  pthread_attr_getstacksize     pthread_attr_getguardsize
  pthread_attr_getdetachstate   pthread_rename_np

Arguments are identical in both forms, and per the convention set by
scePthreadOnce's alias the POSIX name returns the same OrbisGen2Result
rather than translating to errno.

The equivalent POSIX names for mkdir, listen, accept and recv are
deliberately not included here. Those pair with sceKernelMkdir and the
libSceNet entry points, whose error convention differs from the POSIX
one, so they need a decision about error translation rather than a
straight delegation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 21:57:05 +03:00
Youss 9ff60abb9b [Kernel] Clamp guest path traversal at the mount root (#437)
NormalizeMountRelativePath only stripped leading separators and swapped
slashes; it never resolved "." or ".." segments. The bare-relative fallback
in ResolveGuestPath did not even call it -- it combined the guest path
against the app0 root verbatim.

A guest path containing ".." therefore escaped its mount into the host
filesystem. Unreal Engine titles hit this constantly: their base directory
is <app>/binaries/<platform>, so they address content with "../../../"
prefixes that resolve back inside /app0 on real hardware. Here those walked
out of the game folder entirely -- The Invincible (PPSA06426) opened
"/app0/.." and enumerated the host's Downloads directory, listing unrelated
user files, and never located its own content tree.

Resolve "." and ".." while walking the segments and clamp the result at the
mount root, then route the bare-relative fallback through the same
normalizer. This both closes the sandbox escape and makes the engine's
relative content paths land where the title expects.

Verified on The Invincible: no resolved host path contains ".." any more,
and the title now enumerates its own content directories (content/paks,
content/movies, content/locale/*) instead of an unrelated host folder. No
regression on Dead Cells (PPSA15552): still reaches AGC rendering and
presents frames with zero mutex errors.
2026-07-19 20:34:00 +03:00
Youss 3ebfc56d4c [PlayGo] Derive the installed chunk set from the pak files on disk (#438)
A title that ships no PlayGo sidecar was reported as a single-chunk
package. That is wrong for any package whose content is split across
chunks: the title is told everything past chunk 0 is not installed, even
though a locally dumped title has all of its data present.

The Invincible (PPSA06426) ships pakchunk0..8 and asks PlayGo which of
those are available. Receiving BAD_CHUNK_ID for chunks 1..8, it re-queried
scePlayGoGetLocus for the same chunk in a tight loop that never terminated
(observed ~1000 consecutive dispatches with identical arguments).

Discover the chunk ids from the pakchunk<N>-<platform>.pak files present
under the app0 root instead. Those N are exactly the chunks the package
has, so the answer is derived from the install rather than assumed. Chunk 0
is always included, so a title with no pak files at all keeps the previous
single-chunk behaviour, and ids outside the discovered set still return
BAD_CHUNK_ID so title-side chunk enumeration still terminates.

Verified on The Invincible: the discovered set is [0..8], matching the nine
pak files, and the GetLocus retry loop no longer occurs. No regression on
Dead Cells (PPSA15552): still reaches AGC rendering and presents frames.
The existing metadata-free contract test still passes -- its app0 fixture
has no pak files, so the discovered set stays [0].
2026-07-19 20:33:30 +03:00
Youss 73e8821d5b [Kernel] Hand off mutex ownership directly to the head waiter on unlock (#439)
pthread_mutex_unlock cleared ownership (OwnerThreadId = 0) and only woke
the head waiter, relying on that woken thread to re-acquire the lock
itself. If the wake raced or was lost, the mutex was left "free but with
a queued waiter" — a state the fast-acquire path in PthreadMutexLockCore
explicitly refuses (OwnerThreadId == 0 && Waiters.Count == 0), so every
later locker, including the game's main thread, queued behind a head that
never advanced and the whole process wedged.

Grant the mutex to the head waiter directly inside unlock (the same
TryGrantMutexWaiterLocked hand-off the thread-exit cleanup already uses),
then wake it. The mutex is therefore never observable as free-with-waiter.

Verified against The Invincible (PPSA06426): forward progress jumps from
~3.5M to ~40M dispatched imports and the repeated unlock INVALID_ARGUMENT
errors disappear. No regression on Dead Cells (PPSA15552), which still
reaches AGC rendering with zero mutex errors.
2026-07-19 20:33:21 +03:00
StealUrKill bc51cc2c4d Prevent invalid SaveData writes from damaging guest memory (#444)
Add an optional write monitor so the team can find future memory damage on each supported desktop system.
2026-07-19 20:27:17 +03:00
Nicola Pomarico d7f6e3f578 [Kernel] Implement sceKernelMapDirectMemory2 (#433)
The "2" variant of sceKernelMapDirectMemory was unimplemented, so titles
that call it (seen in Gex Trilogy) got an unresolved import that returned
an error the guest then used as a mapped address.

v2 inserts a memoryType argument ahead of v1's protection, shifting the
remaining arguments down one register and pushing alignment onto the
stack. Extract v1's body into a shared MapDirectMemoryCore and route both
exports through it; v2 reads its shifted arguments and the stack alignment
and accepts the memoryType (which only selects cache/GPU attributes this
HLE does not model per mapping, so it does not affect placement).
2026-07-19 14:35:40 +03:00
cse.aadi e56e74f960 Fix space-in-path game launching on Windows (#432) 2026-07-19 14:25:43 +03:00
kadu04t 0f224ec036 Gui Settings Null list Entries (#430) 2026-07-19 13:53:56 +03:00
kostyaff 85dc98dedc test: add Fiber exports contract tests (13 tests) (#428) 2026-07-19 13:53:33 +03:00
wearr 5d7d8e0edd [Kernel] add NID B5GmVDKwpn0 (pthread_yield) (#426) 2026-07-19 13:49:51 +03:00
wearr a60bfc9c83 [Kernel] Implement pthread semaphore exports (#424) 2026-07-19 04:18:16 +03:00
Berk 0b83b34cda chore: bump version to 0.0.2-beta.4 (#423) 2026-07-19 03:42:52 +03:00
Adam salem 09812600a0 Add libc heap trace contract tests (#409) 2026-07-19 03:25:42 +03:00
João Victor Amorim 3005babab8 [AGC] Emit v_pk_fma_f16 with exact single rounding (#420)
Completes the fused-FMA slice deferred by the VOP3P first slice (#145).
v_pk_fma_f16 previously failed emission loudly because an f32
multiply-add followed by an f16 pack rounds twice; the pinned miss is
fma(0x4100, 0x7522, 0x04EA) = 0x7A6B fused vs 0x7A6A via f32.

The f32 product of two f16 values is exact, so only the addition needs
correcting: compute sum = RN(product + addend), recover the exact
residual with Knuth 2Sum, and if the sum is inexact with an even
significand, step one ulp towards the true value. That is round-to-odd,
and rounding the f32 result to f16 with round-to-nearest-even then
matches a true fused f16 FMA exactly (24 significand bits >= 11 + 2).
Inf/NaN inputs turn the residual into NaN, the ordered compare skips the
parity fix, and IEEE special behaviour passes through unchanged. The
op_sel/op_sel_hi/neg_lo/neg_hi source modifiers apply to src2 through
the existing operand path; clamp stays rejected like the other packed
ops.

Every op in the 2Sum chain is decorated NoContraction: without it the
AMD RDNA3 Windows driver folds the sequence, collapses the residual to
zero, and the midpoint case decays to the double-rounded result. This
was caught by running the emitted shader on a real device (see below).

Verification:
- A mirror of the emitted sequence was checked against an exact
  integer reference (every finite f16 is m * 2^-24, so a*b + c is an
  exact Int128 multiple of 2^-48, rounded once to f16 RNE) across 34M
  cases: directed midpoint pins, random sweeps over all operand
  classes, tiny-addend midpoint stress, subnormal products, and
  Inf/NaN propagation. 0 mismatches.
- ShaderDump gains a pk-f16 program covering all five packed opcodes,
  both fma modifier paths, and the pinned constants; all programs
  decode and emit.
- The executable exec program now computes the pinned fma and its
  negated-addend twin (0x7A6B7A6B / 0x7A6A7A6A, straddling an f16
  midpoint) and stores them at offsets 20/24; GpuConformance checks
  both on device. All values match on an AMD Radeon RX 7700 XT.
2026-07-19 03:24:42 +03:00
Nicola Pomarico 09bd4f028b [Kernel] Implement sceKernelSyncOnAddressWait/Wake (#422)
libKernel's address-wait primitives were unimplemented, so every wait
returned immediately and guest runtimes that build spinlocks/queues on
top busy-spun forever. Implement them over the existing cooperative
block scheduler, keyed on the address, with a per-address wake
generation so a wait stays parked until a matching wake bumps it, and a
bounded self-heal deadline so a genuinely missed wake re-polls instead
of hanging.
2026-07-19 03:12:29 +03:00
ParantezTech 2bda253927 [script] added aerolib_catalog.py and docs/aerolib-catalog.md, renamed scripts/RELEASE-USE.md to docs/release-use.md 2026-07-19 01:33:15 +03:00
Berk 71e5912c75 [dotnet] remove lock files (#419) 2026-07-19 01:26:01 +03:00
Berk a030cb5a5d Gpu runtime stalls (#410)
* [runtime] restore default GC mode

* [cpu] add string leaf stubs

* [ampr] allow concurrent reads

* [bink] keep guest decode path

* [kernel] streamline host memory access

* [shader] add scalar memory fallback

* [gpu] bound guest data pool

* [gpu] reduce queue stalls

* [video] stabilize guest resources

* revert lock file
2026-07-19 00:31:50 +03:00
Dafenx 336286e588 CPU: scan final TLS access pattern offset (#414)
Co-authored-by: Dafenx <196083014+Dafenxz0@users.noreply.github.com>
2026-07-19 00:07:32 +03:00
Berk cab001f265 [GUI] Fixes click the controller B/O close button to close the game (#415) 2026-07-18 23:56:56 +03:00
Berk bab965e394 [HLE] Add RandomExports HLE (#413) 2026-07-18 23:44:57 +03:00
Spooks daaeb6213e Fix Massive Bug Preventing UE5 Titles From Booting (#406)
* Fix cross platform memcpy bug
2026-07-18 12:50:59 -06:00
Gutemberg Ribeiro 94153955b0 [Gpu] Metal backend: complete IGuestGpuBackend implementation on AppKit + Metal (#283)
* [ShaderCompiler.Metal] MSL translator core: dispatcher, EXEC model, compute stage

The Metal codegen backend, rebuilt on the merged backend-neutral
abstractions (replacing the pre-abstraction spike): consumes
(Gen5ShaderState, Gen5ShaderEvaluation) and emits MSL text; the renderer
owns MTLLibrary compilation, mirroring the emitters-produce-bytes rule
the Vulkan sibling documents.

The execution model mirrors Gen5SpirvTranslator: one invocation per GCN
lane (wave32 — natively the Apple simdgroup width), a typeless uint
register file with as_type<float> bitcasts, EXEC/VCC as per-lane bools
whose guest-visible mask registers materialize via simd_ballot, and the
same PC-dispatcher loop over basic blocks with the
SHARPEMU_SHADER_MAX_STEPS iteration guard and the dominating-scalar-
definition dataflow for buffer binding resolution. Unlike SPIR-V, MSL
permits shared prelude functions, so unaligned/subdword buffer access
is a range-checked device-uchar* helper instead of per-site inlining;
buffer byte lengths and the compute dispatch limit travel in one
reserved SharpEmuUniforms constant buffer (Metal has no OpArrayLength).

This first slice covers the compute entry point end to end: scalar/
vector ALU core (moves, int/float arithmetic, FMA family, shifts,
bitfield ops, min/max/med3, conversions, transcendentals with the Tau
scale on sin/cos), the full VCmp/VCmpx compare matrix writing VCC/EXEC,
the saveexec family, scalar compares and SCC-updating SOP2 forms, lane
ops (readfirstlane, mbcnt), VOP3 abs/neg/clamp/omod modifiers, scalar
memory, and raw global/buffer loads, stores, and atomics with EXEC
guards. Unsupported opcodes fail loudly with pc + mnemonic. SDWA/DPP,
typed format loads, LDS, images, and the pixel/vertex stages follow in
the next phases.

Tests live in their own self-contained project (the per-backend model:
depends only on the codegen under test): hand-assembled synthetic
fixtures drive the real decoder end to end, structural assertions and
golden-MSL comparisons run on every platform since translation is pure
text generation, and goldens regenerate via SHARPEMU_UPDATE_GOLDENS=1.

* [ShaderCompiler.Metal] Real-device runtime tests: compile + execute on the GPU

Lifts the spike's objc_msgSend LibraryImport harness (MTLDevice /
MTLCompileOptions with fast-math off, as a real Metal backend must
compile) onto the new translator contract: the guest data buffer binds
at index 0 and the SharpEmuUniforms constant buffer (dispatch limit +
buffer byte lengths) at index 1.

Three runtime tiers on hosts with a Metal device (no-op elsewhere so
Windows/Linux CI stays green): every fixture's emitted MSL must be
accepted by the OS runtime Metal compiler; the exec-store program must
produce bit-exact GPU results including the EXEC-masked store that must
not land; and a scalar countdown loop must iterate through the PC
dispatcher (s_cmp_lg_u32 + s_cbranch_scc1 across five round trips).
The loop fixture also fixes its own hand-assembly: s_sub_i32 sets SCC
to signed overflow, not result-nonzero, so the loop condition uses an
explicit compare.

* [ShaderCompiler.Metal] Phase 2: scalar/vector ALU parity with the SPIR-V translator

Ports the remaining ALU semantics from Gen5SpirvTranslator.Alu so the two
codegens cannot disagree on instruction behavior:

- Carry/borrow family (v_add_co/_ci, v_sub_co/_rev, v_subb/_rev) with the
  carry mask written to the VOP3 scalar destination or VCC, ANDed with
  EXEC; v_mad_u64_u32 with the 64-bit pair result and carry-out.
- Full SDWA support: byte/word source selects with sign-extension,
  integer abs/neg modifiers, and destination-select merge (zero-fill,
  sign-extend, preserve) into the previous register value.
- DPP16/DPP8: quad permute, row shl/shr/ror, mirror/half-mirror,
  broadcast and xor controls via simd_shuffle, bound-control and
  row/bank write-enable masks, fetch-inactive handling; DPP-predicated
  compares merge into VCC.
- Lane ops: readfirstlane from the first EXEC-active lane via
  ballot+ctz, readlane/writelane, permlane16/permlanex16.
- 64-bit scalar ops over SGPR pairs in real ulong arithmetic (logic
  family, shifts, bfe/bfm with width clamping, wqm quad expansion,
  cselect, mov, s_getpc) plus the B32/B64 saveexec families.
- Sopk forms decode the signed 16-bit immediate and s_cmpk compares the
  destination register; SOPC scalar compares including s_bitcmp0/1.
- Conversions: f16<->f32 via as_type<half>, pkrtz with round-to-zero
  mantissa truncation, pknorm via pack_float_to_{s,u}norm2x16, pk_u8
  byte insert, off_f32_i4 table, rpi/flr rounding; cube id/sc/tc/ma
  decision trees; v_cmp_class_f32; VCCZ/EXECZ/SCC readable as data.

Also fixes a real phase-1 bug the reference surfaced: fmamk/fmaak
sources arrive in natural order from the decoder, so all MAD/FMA forms
are fma(src0, src1, src2) — the previous operand swap computed
v1*v2+K for v_fmamk (should be v1*K+v2). The regenerated fmac golden
shows the corrected expansion, and mirroring the SPIR-V translator,
v_mul_u32_u24 is a full 32-bit multiply (only the hi/mad forms mask).

All 13 Metal tests pass including the real-GPU execution tier.

* [ShaderCompiler.Metal] Phase 3: typed format loads, LDS, and D16 subdword memory

Typed MUBUF/MTBUF loads convert through the descriptor's GFX10 unified
format at execution time, mirroring the SPIR-V translator: the prelude
bakes a 128-entry format table from the shared Gfx10UnifiedFormat
decoder (compiled shaders may be reused with new SRDs, so decoding must
stay dynamic), per-component layouts for the legacy DATA_FORMAT values
drive range-checked unaligned loads, NUM_FORMAT conversion handles
unorm/snorm (clamped at -1)/uscaled/sscaled/uint/sint/float including
f16 and the 10/11-bit unsigned mini-floats of 10_11_11 / 11_11_10, the
missing-component default is one in the format's domain, and dst_sel
swizzling comes from descriptor word 3. Format stores stay raw dword
stores like the reference.

LDS lands as 32 KB of threadgroup memory (gated on the program actually
using DS ops so occupancy is not taxed): ds_read/write b32/b64/b96/b128,
the write2/read2 pairs including st64 scaling, and ds_add_u32 as a
relaxed threadgroup atomic, with EXEC-guarded writes and the address
masked into bounds. Subdword loads/stores gain the D16/D16Hi variants
that merge into one half of the destination register (and shift the
source for high stores), classified the same way as the reference.

New GPU-executed fixture: an LDS round trip (write literal, s_barrier,
read back, store to the buffer) passes bit-exact on a real Metal device
alongside the existing tiers.

* [ShaderCompiler.Metal] Phase 4: pixel stage, images, and interpolation

The pixel entry points land with the same contract as the SPIR-V
translator (single-target and MRT forms, validated for unique guest
slots and dense host locations): the emitted fragment function takes a
stage_in struct carrying [[position]] plus the interpolated attributes
discovered from the program's V_INTERP controls, writes an output
struct with one [[color(hostLocation)]] attachment per binding typed by
its Float/Sint/Uint kind, seeds pixel-input VGPRs in SPI_PS_INPUT_ADDR
compact order from the fragment coordinate, keeps EXEC masking through
translation, and discards lanes that exit with EXEC off. Exports write
MRT targets per component under EXEC (disabled components keep their
previous value) including compressed half-pair exports; vertex-target
exports no-op until the vertex stage.

Images arrive as texture2d<float|int|uint> arguments (storage bindings
as access::read_write) with samplers alongside, classified from the
descriptor's unified format via the shared Gfx10UnifiedFormat decoder
and resolved per instruction with the same dominating-scalar-definition
scheme as buffers. The sample matrix covers implicit LOD, SampleL/Lz,
SampleB, SampleD gradients, PCF compare (manual reference<=texel,
broadcast r,r,r,1), per-lane texel offsets folded into normalized
coordinates by the selected mip extent (Metal sample offsets must be
constants), gather4 including compare and offset forms, clamped
ImageLoad/Mip, bounds-checked EXEC-guarded ImageStore, GetResinfo, and
A16 packed addresses / D16 packed data in both directions.

Graphics stages model LDS as per-invocation scratch (the SPIR-V
Private-array trick) instead of threadgroup memory. Wave ops keep the
invocation's real simdgroup in every stage — Apple fragment simdgroups
make that the same model as compute, where the SPIR-V translator
instead emulates a single logical lane; both round-trip EXEC masks
consistently.

The pixel fixture (interpolated attr0.xy plus inline constants exported
to MRT0) is golden-pinned, structurally asserted, and accepted by the
OS Metal compiler on a real device.

* [ShaderCompiler.Metal] Phase 5: vertex stage and fixed presenter shaders

The vertex entry point completes the four-entry-point contract: the
emitted vertex function takes fetched attributes as a stage_in struct
([[attribute(location)]], bound by the backend via MTLVertexDescriptor
from the reflected vertex inputs), returns [[position]] plus one
[[user(locnN)]] param output per export target 32..63 — unioned with
requiredVertexOutputCount so Metal's exact vertex-out/fragment-in
interface match succeeds, with unexported locations zero-filled —
seeds v5/v8 from [[vertex_id]]/[[instance_id]], intercepts buffer loads
the evaluator captured as fixed-function vertex inputs, and applies the
same EXEC-selected component rules to position/param exports (disabled
components default to 0,0,0,1) including compressed half pairs.

MSL vertex functions have no simdgroup attributes, so the vertex stage
models a single logical wave lane exactly like the SPIR-V translator's
graphics path: lane 0, ballot degrades to 0/1, and lane-shuffle ops
would fail Metal compilation loudly (no real guest vertex shader uses
them).

MslFixedShaders mirrors SpirvFixedShaders for the presenter surface:
the fullscreen-triangle vertex stage (position from the vertex index,
screen-space UV broadcast to every requested attribute location), the
copy/solid/attribute diagnostic fragments, and the output-free
depth-only fragment. Metal forbids "main", so each carries a stable
entry name.

The vertex fixture (constant position + one param export) is
golden-pinned and structurally asserted; it and all five fixed shaders
are accepted by the OS Metal compiler on a real device.

* [ShaderCompiler.Metal] Author static MSL blocks as template files

The prelude helpers (buffer access, ballot, tables), the format-load
conversion functions, and all five fixed presenter shaders move out of
AppendLine walls into Templates/*.msl embedded resources — real Metal
source with syntax highlighting and reviewable diffs — rendered by a
small {{placeholder}} substituter that fails loudly on any
unsubstituted token. Substitution points are deliberately few: the
stage-dependent ballot expression (vertex has no simdgroup attributes),
the baked GFX10 format table and layout cases, and the fixed shaders'
parameters. Per-instruction body emission stays programmatic, where a
template cannot express it.

Behavior-identical by construction: the golden files are untouched and
the whole suite — including the real-device execution and compile
tiers over the templated output — passes against them unchanged. The
.msl files carry no license headers (they would leak into every emitted
shader), so REUSE.toml annotates the Templates directory instead.

* [ShaderCompiler.Metal] Cover the MSL goldens in REUSE.toml

The golden files are verbatim emitter output regenerated by the test
suite; license headers inside them would either break the byte-exact
comparison or force the emitter to write SPDX text into every shader.
Annotate the Goldens directory like the Templates one.

* [ShaderCompiler.Metal] Address review: dominating-binding parity and harness binding indices

The buffer-binding fallback now mirrors the SPIR-V translator: a candidate
binding is accepted only when the descriptor registers hold the exact same
scalar definitions at the target PC as at one of the binding's own access
points (HasSameScalarDefinitions), instead of merely being non-conflicting
at the target. Resolutions are cached per PC like the reference.

The runtime test harness no longer hardcodes buffer indices 0/1 and a
20-byte uniforms blob: TryExecuteSingleThread takes the data/uniforms bind
indices, and ExecuteOrThrow derives them plus the uniforms size from the
compiled shader's GlobalMemoryBindings per the translator contract.

* [Gpu] Add the Metal guest-GPU backend: shader compilation and formats

First phase of the Metal backend behind the IGuestGpuBackend seam. The
backend compiles all three shader stages through Gen5MslTranslator and
exposes the guest render-target format table (mirroring the Vulkan table
case for case; guest format 9 maps to BGR10A2, the Metal layout matching
Vulkan's A2R10G10B10 pack). Wave64 compute is rejected with a clear error
until the two-pass emulation exists.

SHARPEMU_GPU_BACKEND=metal opts in on macOS; Vulkan stays the default on
every platform until the Metal presenter reaches parity. Presenter-side
methods fail loudly instead of dropping guest frames silently.

* [Gpu] Add the Metal presenter core: AppKit window, CAMetalLayer, CPU-frame path

The presenter opens an NSWindow hosting a CAMetalLayer and drives a manually
pumped NSApplication event loop, structured like the Vulkan presenter's
poll-and-render loop and posted onto HostMainThread the same way (AppKit
traps off the process main thread). All OS access goes through objc_msgSend
LibraryImport bindings declared locally — no windowing or binding packages on
this path, which is what keeps it NativeAOT-clean. Struct-returning ObjC
calls are avoided entirely so one calling convention works under Rosetta.

Presents CPU-produced BGRA frames and the splash through a fullscreen
triangle with a dedicated present fragment stage that flips V: with Metal's
y-up NDC the shared fullscreen triangle puts UV (0,0) at the bottom of the
screen while textures keep v=0 at the top. Frames letterbox via the viewport,
and nextDrawable paces the loop at presentation rate.

Guest-image submission now returns false (callers use their CPU-readback
fallback, which the presenter can show); draw and compute submission still
fail loudly pending later phases.

* [Gpu] Complete the guest-GPU seam: lift the AGC bypass surface onto the backend

The abstraction left AGC and VideoOut calling VulkanVideoPresenter statics
directly for guest work ordering (EnterGuestQueue, SubmitOrderedGuestAction,
SubmitOrderedGuestFlipWait, WaitForGuestWork), guest-image lifecycle (initial
data seeding, writes, fills, extents, upload tracking), the texture-content
cache probe, guest memory attachment, storage-offset alignment, perf
counters, and presenter close. With a non-Vulkan backend selected those
calls silently hit a never-started Vulkan presenter.

All of it now crosses IGuestGpuBackend: the Vulkan backend delegates to the
existing presenter statics (no behavior change), and the Metal backend
answers exactly like a presenter that is not running (sequence 0, image
unknown), which keeps callers on the same inline/CPU fallbacks they take
today. TextureContentIdentity moves to the seam types, and the bounded
AGC-to-presenter transfer pool becomes the backend-neutral GuestDataPool
(one pool by necessity: the AGC layer rents, the presenter returns).

AgcExports snapshots the backend's offset alignment once — it was a const
before and is read in per-draw loops (shader-key hashing, offset rounding).

* [Gpu] Metal guest work queue and guest images: ordered flips, writes, fills, blits

Mirrors the Vulkan presenter's execution model. AGC submissions become work
items consumed by the render loop in logical-guest-queue order: FIFO within
each guest queue, ready queues scheduled round-robin, completion tracked as
a contiguous sequence plus an out-of-order set, and producer backpressure
(count and payload caps) that consumer-enqueued follow-ups bypass to avoid
self-deadlock. The drain is budgeted (12ms, 256 items) so a backlog cannot
starve the Cocoa event pump or the present.

Guest images are Metal textures keyed by guest address, created on first
use from the registered display-buffer format tag (byte-identical encoding
to the Vulkan backend) and seeded once from pending initial data or guest
memory, since PS5 render targets alias guest memory. Coherence mirrors the
Vulkan design: DMA-style writes swap in a freshly written texture (never
mutating one an in-flight present may sample), fills clear through a
hazard-tracked render pass, and same-extent blits copy on the GPU.

Ordered flips capture the named image into an immutable version at their
exact queue position, so later work cannot change the frame a flip
selected; flip waits complete by queue position alone. Presentation picks
the newest ready queued guest frame (retiring superseded captures),
re-resolving mutable address-keyed textures at encode time so a write swap
never leaves a stale handle.

* [Gpu] Metal translated draws: pipelines, render state, bindings, write-back

Executes the seam's translated-draw surface on Metal. Offscreen, depth-only,
and storage draws are ordered guest work rendering into guest-addressed
images (published targets register as flip sources exactly like the Vulkan
backend); onscreen draws and recognized fixed-function draws ride the
presentation and render at present time into a pooled target.

Pipelines are built from GuestRenderState and cached by shader identity plus
a state hash: guest CB blend factor/op codes, write masks (bit-reversed for
MTLColorWriteMask), depth ZFUNC (bit-identical to MTLCompareFunction),
vertex attribute formats decoded from the same guest (dataFormat,
numberFormat) table the Vulkan backend uses, and RDNA 2:10:10:10 mapped to
Metal's R-low-bits 1010102 layout. Guest viewports pass through unchanged —
Metal accepts the negative heights PS5 games program, which is also how the
Vulkan backend inherits its orientation. Rect lists draw as 4-vertex strips;
Metal has no triangle fans, so those degrade to lists with a one-time warn.

Bindings follow the Gen5MslTranslator contract: global buffers at their flat
slot on both stages, SharpEmuUniforms (dispatch limit + buffer byte lengths)
after them, textures/samplers at the image slots with samplers decoded from
the raw guest descriptor words, and vertex streams at slot 26+ so they never
collide. Writable global buffers write back to guest memory before the work
item completes, preserving the CPU-visible GPU-write ordering point that
WaitForGuestWork promises. Feedback reads of a live render target sample a
blit snapshot; pooled guest data returns to GuestDataPool after upload.

Known simplifications for follow-up: textures upload a single mip level, and
the texture-content cache stays unclaimed (IsTextureContentCached=false)
until write-tracker-driven eviction exists, trading upload bandwidth for
correctness.

* [Gpu] Metal compute dispatch: the last seam gap

Guest compute dispatches are ordered guest work like draws. The uniforms
contract carries the per-axis dispatch limit (explicit thread counts when
the guest supplied them, groups x threadgroup size otherwise) so the
kernel's bounds guard clamps the overshoot threads of the last threadgroup;
threadgroup dimensions come from the translated shader, which bakes them at
compile time. Compute pipeline states cache per shader handle.

Storage images are shared live through the guest-image registry: a
dispatch's writes are visible to later draws, blits, and flips of the same
address, the address registers as a flip source at submit, and writer
sequences keep presentation waiting on exactly the work that produced the
frame. Writable buffers write back to guest memory before the work item
completes — the CPU-visible ordering point the returned sequence promises
through WaitForGuestWork.

Metal has no dispatch-base; nonzero base groups execute without the offset
behind a one-time warning until the emitted kernel grows base support.
SHARPEMU_SKIP_ALL_COMPUTE=1 skips all dispatches for hang isolation, same
as the Vulkan backend. With this the Metal backend implements the entire
IGuestGpuBackend surface — nothing throws.

* [Gpu] Address review: real bytes-per-pixel in guest-image uploads

Guest-image uploads hard-coded 4 bytes per texel, which mis-strided
Rgba16*/Rg32Float/Rgba32Float images and, in the guest-memory seed and
storage-snapshot paths, could make replaceRegion read past the managed
buffer. Texel width now comes from the pixel format, and
ReplaceTextureContents clamps the row count to what the source buffer
actually holds, so no caller can overread regardless of pitch and format.
RGBA8 initial data seeds only 4-byte-texel images; wider formats seed from
guest memory, whose layout is the image's native one. Extent byte counts
use the real texel width too.

Also restores the reference's comment on the deliberate single-item
backpressure admit: with no payload outstanding, refusing an oversized item
would wait forever since nothing is left to drain.

* [ShaderCompiler] First real-game fixes: SSendmsg no-op, scalar-state buffer declaration

Bring-up against a real title (2D engine, NGG shaders) found every draw
rejected at translation: RDNA2 NGG shaders bracket their exports with
s_sendmsg (GS_ALLOC_REQ/DEALLOC) to reserve hardware export space, and
neither translator handled the opcode — it fell through to the scalar-ALU
guard and failed with 'missing scalar destination'. Both translators now
treat SSendmsg as a no-op alongside SNop/SWaitcnt: exports are translated
directly, so the hardware message is moot. This was a shared gap, not a
backend one; the Vulkan path would reject the same shaders.

With translation unblocked, the OS Metal compiler rejected the emitted MSL:
the body reads the per-dispatch scalar-state buffer (initial SGPRs plus
per-binding byte biases) as b{initialScalarBufferIndex}, but the kernel
signature only declared the stage's own global bindings, so the name never
existed. The signature now declares it (const device — it is only read) at
its flat slot.

The presenter also logs one line when it first presents real content,
making 'window up but nothing shown' diagnosable from the log alone.
Verified: the title goes from 100% draw misses and a black screen to
~58k translated draws per minute and 4K frames presenting.

* [Gpu] Metal presenter: NSTimer-driven render loop under [NSApp run]

Replaces the hand-pumped event loop with a real running main loop. The
presenter now creates the NSApplication, orders the CAMetalLayer-backed
window on screen, and calls [NSApp run] so Core Animation's run-loop observer
actually commits presented drawables to the window server — without a running
loop the layer never composites and the window stays black regardless of what
is rendered into the drawable.

The per-frame work moves into RenderFrame, driven by a repeating NSTimer on
the main run loop (a tiny NSObject subclass whose onFrame: is an
UnmanagedCallersOnly callback, registered via the ObjC runtime — no binding
package). CADisplayLink is the natural choice and was tried first, but its
callback never fires in this process; proven in isolation against a bare
AppKit harness where a timer fires and composites and the display link does
not — the emulator runs as x86-64 under Rosetta and the display-server-backed
link is not serviced there. nextDrawable still blocks to the display, so the
timer only needs to keep up, not pace precisely.

Also fixes window sizing (the fixed 1280x720 window was being sized from the
guest 4K display mode, which macOS clamps while the layer keeps 4K geometry —
nothing visible), makes the metal layer the view's backing layer (wantsLayer
before setLayer) with an explicit frame, and stops both the AppKit loop and
the CFRunLoop on window close.

* [Gpu] Metal draws: normalize inverted viewports, resolve flips to drawn content

Two correctness fixes surfaced bringing a real title up. Guests program
Vulkan-style negative-height viewports for y-up rendering; Metal's NDC is
already y-up and rasterizes nothing for a negative height, so the viewport is
converted to the equivalent non-inverted rect (origin shifted, height
negated) with the same on-screen mapping.

Ordered flips now prefer produced content. A flip names the display buffer's
start address, but games render into the pixel surface past the buffer's
metadata block; the resolver takes the exact-address image when GPU work
wrote it, else the nearest same-extent GPU-written image within the buffer's
plausible metadata window, else the exact-address image even if only
seeded — so a flip presents the drawn frame rather than an empty seed. A
GpuWritten flag on guest images (set by draws, writes, blits, and dispatch
storage) distinguishes produced content from a speculative guest-memory
seed.

* [ShaderCompiler] Metal translator: per-stage uniforms slot, VCC/EXEC as data, exit branches

Three correctness fixes found by running a real game against the Vulkan
backend's behavior:

- Gen5MslShader carries UniformsBufferIndex: each stage emits its
  SharpEmuUniforms argument at globalBufferBase + totalGlobalBufferCount,
  and stages sharing a draw can disagree, so the presenter must bind the
  buffer per stage (Metal API validation: "missing Buffer binding at
  index 7 for sharpemu_uniforms").
- VCC (s106:s107) and EXEC (s126:s127) live in the scalar register file
  as raw 32-bit values with the per-lane bools as synced views. Programs
  legally park plain data in VCC (s_buffer_load into s[106] and then
  v_rcp_f32 of it); the bool-only model returned ballot masks instead.
- A branch to (or past) the program's end is an exit, matching the
  SPIR-V translator: sprite alpha-kill shaders use this to skip their
  tail and were rejected ("branch target outside program"), silently
  dropping every draw that used them.

Also: pixel-stage ballots use the per-lane form (this thread's own bit),
since simd_ballot is undefined inside the divergent dispatcher loop, and
v_readfirstlane returns the lane's own value under that model.

* [Gpu] Metal presenter: per-stage uniforms bind, keyboard input, perf overlay, title parity

- Bind SharpEmuUniforms at each stage's declared slot (see the paired
  translator change); one shared index left the vertex stage's slot
  unbound, zeroing its bounds-checked loads and killing interpolants.
- Vertex attribute byte offsets move onto the vertex descriptor (buffers
  bind at zero) and join the pipeline cache key, which they were silently
  missing from once baked into the pipeline.
- Unresolvable draw textures log a throttled warning instead of silently
  binding nothing.
- Keyboard input: an NSView subclass records keyDown/keyUp and feeds the
  POSIX host-input seam with a Windows-VK to macOS-keycode map covering
  the keys pad emulation polls; SHARPEMU_METAL_AUTOKEY scripts key
  presses for headless runs.
- Perf overlay (F1) drawn like the Vulkan presenter: CPU-rasterized
  panel uploaded to a small texture and composited with the present
  pipeline, with RecordPresent/RecordDraw feeding real numbers.
- Window title gains the selected GPU suffix and refreshes when the
  guest registers its application name; the layer is marked opaque so
  guest alpha never reaches the compositor.

* [Audio] Quiet sceAudioOutOutput on ports disposed by host shutdown

Closing the window disposes audio ports while guest audio threads are
still draining their last buffers; every remaining output then failed
the port lookup and logged a WARN per buffer (~190/s) until process
exit. Report success for missing ports once shutdown has begun; a bad
handle during normal operation still returns INVALID_ARGUMENT.

* [ShaderCompiler] Metal graphics stages model a single logical wave lane

The pixel stage used the real thread_index_in_simdgroup with all-ones
ballots while the vertex stage modeled lane 0 with 1-bit ballots, and
VReadlaneB32 still emitted a real simd_shuffle — reading another
fragment's register. Metal leaves simdgroup ops undefined inside the
divergent while(active){switch(pc)} dispatcher (empirically they
corrupted EXEC reconstruction), so graphics stages cannot use them.

Unify vertex and pixel on the SPIR-V translator's no-subgroup fallback:
one logical wave lane (lane 0), ballots degrade to bit 0, and the
shuffle-select family (readlane, readfirstlane, DPP16/DPP8 selects,
permlane16) resolves to the lane's own value. Writelane keeps the
lane-compare against the constant lane, matching the reference
fallback. Compute is untouched: its threads map one-to-one onto real
simdgroup lanes and still shuffle for real.

Entry parameter lists now always emit trailing commas and are closed by
one helper, so stage-specific trailing parameters no longer dictate
ordering.

* [ShaderCompiler] Metal compute mirrors the SPIR-V translator's wave semantics

Compute threads map one-to-one onto real simdgroup lanes, so restore
real simd_ballot for the compute prelude (the per-lane form was a
graphics fix that swept compute along) — masks parked in VCC/EXEC now
hold each lane's actual bit, and mbcnt/cndmask/saveexec read real
masks. VReadfirstlaneB32 broadcasts from the first guest-active lane
(ballot of EXEC, then ctz), matching the SPIR-V translator's explicit
first-active-lane broadcast rather than SPIR-V BroadcastFirst's
first-host-active semantics.

The wave64 gate moves into the translator and only rejects programs
that contain wave-sensitive operations (the SPIR-V translator's
subgroup-usage predicates: shuffle family, readfirstlane, wave control,
mbcnt, or VCC/EXEC operands). A wave64 kernel without them executes
identically per-thread on 32-wide Apple simdgroups, so it now
translates instead of being dropped.

* [Gpu] Metal draw textures resolve like the Vulkan presenter

Sampling a live guest target previously required the exact current
image at the descriptor's address; anything else silently bound
nothing. Mirror the Vulkan presenter's resolution chain:

- A descriptor naming a guest depth target's write or read address
  samples the depth image (identity channel select). Depth32Float
  cannot blit to a color format, so the ordered snapshot round-trips
  through a private staging buffer into an R32Float texture.
- Replacing a render target at the same guest address (new extent or
  format) retires the old image into a bounded variant cache instead of
  releasing it, and resolution scores the current image plus variants
  by descriptor match — exact extent over view format over
  initialization, active image breaking ties. Larger images qualify
  only for tiled descriptors, matching IsCompatibleGuestImageAlias.
- The throttled unresolved-texture warning remains the detector for
  anything the chain still cannot resolve.

* [ShaderCompiler] Document the Metal translator's wave-size model

Audit outcome for wave64 fidelity, no behavior change: every B64 mask
op, saveexec, and VCCZ/EXECZ test already reads and writes the full
register pair, lane indices never exceed 31 by construction, and the
GPU-executing runtime tests cover 64-bit exec save/restore. Record the
model in the class header.

* [Gpu] Plumb CB_BLEND constant color through both backends

The CONSTANT_COLOR / CONSTANT_ALPHA blend factors were mapped by both
backends but nothing ever supplied the constant, so any draw using them
blended against transparent black. Decode CB_BLEND_RED..ALPHA (the
constants existed unused) into GuestRenderState.BlendConstant, set it
dynamically per draw on both sides: Vulkan declares the blend-constants
dynamic state and calls CmdSetBlendConstants beside the viewport,
Metal calls setBlendColorRed:green:blue:alpha: in EncodeRenderState.

* [Gpu] Metal per-draw uploads bump-allocate from shared arena pages

Every draw created one MTLBuffer and one managed copy per binding
(padded guest globals, uniforms, vertex and index bytes), which
dominated allocation churn at hundreds of MB/s of garbage and held the
guest flip rate well under the display rate. Uploads now bump-allocate
256-aligned slices from 8 MiB shared-storage arena pages and bind by
offset; pages recycle once the last command buffer that referenced
them reports completion, polled at each drain so the ObjC interop
stays block-free.

Write-backs carry the slice's data pointer directly (the page outlives
the command buffer the caller waits on), the alignment-bias contract is
preserved by placing data at the bias inside its slice, and the padded
copies, per-draw buffer releases, and per-draw byte arrays are gone.

In-game on the test title: allocation rate ~795 to ~564 MB/s (the
remainder is the AGC-side per-draw guest snapshots), GC per stats
window ~30/30/17 to 16/16/15, CPU ~150 to ~131%. Metal validation
stays clean.

* [Gpu] Metal draw-texture cache: skip per-draw guest texel copies

Mirror the Vulkan presenter's identity-keyed texture cache: once the
render thread decodes a draw texture, the AGC submit thread skips the
guest-memory read/detile/copy for that identity entirely (the generic
IsTextureContentCached hook, which the Metal backend previously
hardcoded to false) and the render thread serves the cached MTLTexture
without re-uploading. GuestImageWriteTracker write-protects the source
pages; a guest CPU write evicts the entry at the next drain, and the
skip/eviction race self-heals by reading the texels directly.

Eviction differs from Vulkan in one deliberate way: dirty entries are
collected by address rather than identity, since ConsumeDirty clears
the flag on first read and several identities (same texels, different
samplers) can share one address.

Dreaming Sarah in-game on an M5 Max: guest flips 47 -> 60 (display
rate, matching Vulkan), ALLOC 564 -> 41 MB/s, gen0 GC 16 -> 5 per
second, CPU 131% -> 72%. Metal API validation clean; all 25 shader
compiler tests pass.

* [Gpu] Metal snapshot pool: recycle feedback-read textures and staging

Feedback reads created and destroyed an MTLTexture per draw (and for
depth sampling a private staging MTLBuffer too). Pool both with the
upload-arena lifecycle: acquisitions are tagged with the command buffer
that samples them at commit, and return to a bounded free list once it
reports completion. The command queue is serial, so the earlier
snapshot-blit command buffer is necessarily complete by then as well.

Dreaming Sarah renders correctly in-game; Metal API validation clean;
all 25 shader compiler tests pass. (The depth-sample path is exercised
only by inspection — no testable title samples depth yet.)

* [Gpu] Metal batched guest commands: one command buffer per drain

Draws and compute dispatches encode into a shared batch command buffer
committed once per drain instead of one commit per work item, mirroring
the Vulkan presenter's batched guest commands. Ordering inside the
batch is by encoder sequence: draw textures are now pre-resolved before
the consuming render or compute encoder opens, so feedback-read
snapshot blits encode into the batch (after the passes that rendered
the source) rather than committing ahead of them in separate command
buffers. Flips, image writes/blits, ordered actions, CPU-visible
write-backs, and every drain exit flush the batch first, preserving
the serial-queue ordering and WaitForGuestWork contracts.

Dreaming Sarah in-game on an M5 Max: CPU 72% -> 59% at a steady 60
guest flips; Metal API validation clean; all 25 shader compiler tests
pass.

* [Gpu] Metal vertex streams: share buffer slots, reject overflow gracefully

void Terrarium aborts with '-[MTLVertexAttributeDescriptorInternal
setBufferIndex:]: buffer index (31) must be < 31': every vertex
attribute got its own buffer slot from base 26, so six streams walk
past Metal's last vertex-stage buffer index (30) and the framework
assertion kills the process (reported by vladdenisov on PR #283).

Attributes of an interleaved vertex arrive from AGC as one stream
each, all reading the same guest buffer — assign slots by unique
(base address, stride, length) so those share one slot and one
upload. A draw whose unique streams still overflow the range is
skipped with a throttled warning instead of aborting. The assigned
slot keys the pipeline cache alongside the attribute offset, since
aliasing changes the baked vertex descriptor.

Dreaming Sarah renders correctly in-game at 60 flips with Metal API
validation clean; all 25 shader compiler tests pass. (void Terrarium
itself is not testable here — no decrypted copy.)

* [Gpu] Metal: drain guest work on enqueue, not only at render ticks

The Vulkan presenter's render loop is pulsed when guest work arrives
and waits at most a few milliseconds; the Metal render loop drained
guest work only inside its NSTimer tick, so every guest submit-then-
wait round-trip (release-mem labels, event writes, CPU-visible write-
backs) cost up to a full frame interval. Games that chain several such
waits per frame crawl: void Terrarium ran at 14 guest flips against
Vulkan's display rate, and input-to-effect latency suffered everywhere.

Enqueueing guest work now schedules a coalesced onGuestWork: message
onto the main run loop via performSelectorOnMainThread (block-free,
matching the NSTimer trampoline pattern), which drains the queue
immediately. A producer blocked on a full queue schedules the same
wake before waiting. void Terrarium's title menu: 14 -> 59 flips/s;
Dreaming Sarah unchanged at 60 with validation clean.

* [ShaderCompiler] Metal samplers: per-stage compact slots, not texture slots

Sampler argument indices copied the global texture slot (image binding
base + index), but Metal exposes only 16 sampler slots per stage
against 31 texture slots — a draw whose stages sample more than 16
images total emitted [[sampler(16+)]] and the MSL failed to compile
('sampler attribute parameter is out of bounds'), dropping the draw
(void Terrarium's in-game scenes).

Samplers now count sampled (non-storage) images from zero within each
stage, and Gen5MslShader carries the image-index -> sampler-slot map
plus the stage's image binding base so the presenter binds each
stage's samplers exactly where its shader declared them. A stage that
samples more than 16 images fails translation loudly. All 25 shader
compiler tests pass; goldens unchanged (single-texture fixtures keep
sampler 0).

* [Gpu] Metal draw textures: native guest formats, BC blocks, channel select

The draw-texture path assumed every texture was RGBA8: created
Rgba8Unorm, uploaded 4 bytes per pixel, and rejected anything whose
texel copy was smaller than W*H*4 as undersized. Games shipping
BC-compressed atlases (void Terrarium's entire in-game art) rendered
black, and because the rejected textures were never created they were
never content-cached — the AGC layer re-read and re-detiled megabytes
per draw (1.6 GB/s allocation, gen2 collections every second, 8 guest
flips).

Map guest texture formats to Metal case for case with the Vulkan
table (BC1-BC7 upload raw blocks — Mac-family GPUs sample them
natively — plus the 8/16/32-bit linear formats), size expectations
with the same block-aware byte math AGC uses, and honor the
descriptor's DST_SEL channel select through the texture swizzle,
mirroring Vulkan's component mapping. Unmapped codes keep the RGBA8
fallback.

void Terrarium now reaches gameplay past New Game: 49-54 guest
flips (from 8), no undersized-texture warnings, validation clean.
Dreaming Sarah unchanged at 60. All 25 shader compiler tests pass.

* [Gpu] Metal feedback reads: one snapshot per content version

Every draw sampling a live guest image blitted a fresh full-texture
snapshot, so compositing games that sample their render target on
most draws (void Terrarium: ~100 of ~105 draws per frame) pushed
gigabytes per second of blit traffic through the driver.

Guest images now carry a content version, bumped by every draw that
targets them, image write, blit destination, storage dispatch, and
guest-memory seed. The feedback-read path reuses one cached snapshot
until the version moves, so the blit happens per content change
instead of per draw. The image holds the snapshot's retain; consuming
command buffers keep replaced snapshots alive until they complete,
and retire/replace/write paths release the cache with the image.

void Terrarium in-game: 49 -> 58 guest flips at higher draw
throughput (Vulkan reference runs the same scene at 17-20 fps).
Dreaming Sarah unchanged at 60; validation clean; 25/25 tests pass.

* [Core] Pre-visit tracked texture pages before managed guest writes

A managed write into a page the guest-image write tracker has
protected dies with a fatal AccessViolation: the runtime surfaces
SIGSEGV in managed code as an exception before the resumable signal
bridge can restore access, unlike native guest stores which recover
through TryHandleWriteFault. Dead Cells crashed exactly there — an
AGC release-mem label write (CpuContext.TryWriteUInt64 on the render
thread) landing on a page the texture cache tracks.

TryWrite now calls GuestImageWriteTracker.NotifyManagedWrite up
front, unprotecting and dirtying any tracked pages in the span before
the copy — the hook existed for precisely this but had no callers.
Since this puts the tracker on every managed guest-write path, the
range snapshot now carries its overall bounds (one immutable object,
so the intersection test is always consistent with the array), letting
the common no-texture-pages case reject in a few instructions.

Dead Cells no longer crashes; Dreaming Sarah and void Terrarium
unaffected; all 25 shader compiler tests pass.

* [VideoOut] Name the active GPU backend in the macOS window title

macOS can run either backend — Vulkan through MoltenVK or native Metal
via SHARPEMU_GPU_BACKEND — so the window title now ends with the one in
use, e.g. "... · Apple M5 Max (Metal)" or "(Vulkan)". The suffix is
appended in SetSelectedGpuName (the single point both presenters call
to fold in the GPU name) and gated to macOS, so Windows and Linux
titles are unchanged. The name comes from a new BackendName on the
guest-GPU seam.

* [Gpu] Metal window: resizable, native full-screen, live drawable sizing

Add NSWindowStyleMaskResizable so the window can be dragged to any size
and set NSWindowCollectionBehaviorFullScreenPrimary so the green button
enters native full-screen instead of zooming. CAMetalLayer does not
track its drawable size to bounds on its own (even as a view's backing
layer), so the render loop matches drawableSize to the layer's current
bounds x contentsScale before each nextDrawable — a no-op on the common
unchanged tick. The present pass already aspect-fit letterboxes into the
drawable, so any window aspect ratio scales the frame without distortion.

Reading -bounds needs the x86-64 stret ABI for its 32-byte CGRect
return, added as SendStretRect. Verified live: drag-resize and
full-screen both scale correctly with Metal API validation clean.

* [ShaderCompiler] Metal wave64 compute: emulate cross-lane ops via scratch bridge

Replace the wave64 loud rejection with emulation, mirroring the SPIR-V
translator. A 64-lane guest wave is two 32-wide Apple simdgroups
co-resident in one threadgroup (Metal packs thread_index_in_threadgroup
0-31 into simdgroup 0, 32-63 into simdgroup 1), so sharpemu_lane becomes
thread_index_in_threadgroup & 63 and cross-lane ops that span the full
wave rendezvous the two halves through threadgroup scratch:

- ballot into EXEC/VCC/SGPR pairs: each half's simd_ballot is written to
  its scratch slot, a threadgroup_barrier syncs, and all lanes recombine
  the 64-bit mask into the low/high register pair (centralized in
  EmitBallotStore, which the wave32 path shares).
- read-first-lane: broadcasts the lowest active lane's value across both
  halves through a scratch slot (EmitWave64ReadFirstLane).
- mbcnt lo/hi: 64-lane thread-mask math (no cross-lane op, just correct
  per-lane masks; lanes >= 32 would overflow a 32-bit shift, so split).

The barriers are safe because the guest's scalar PC keeps all 64 lanes
lockstep through the dispatcher. Scope matches the SPIR-V reference: the
scratch is indexed by half, so correct for a one-wave (64-thread)
workgroup, and readlane across halves stays a 32-wide shuffle. Wave-
agnostic wave64 kernels still translate per-thread unchanged.

Verified on the real GPU (MetalRuntimeTests): the emitted wave64 MSL
compiles, and a 64-lane dispatch runs through the bridge barriers
without deadlocking, returning the broadcast value. All 27 tests pass;
Dreaming Sarah (60/60) and void Terrarium (in-game, 58 flips) show the
shared wave32 ballot path is unaffected.

* [Gpu] Metal samplers: bind through an argument buffer, lifting the 16-slot cap

Metal exposes only 16 direct [[sampler(N)]] slots per stage, but real
shaders sample more (void Terrarium's scene shader: 17 images) and were
dropped at translation. Route samplers through a per-stage argument
buffer instead: the MSL declares a Gen5Samplers struct (one sampler per
sampled image, [[id(N)]]) taken as constant& at a buffer slot past the
stage's globals/uniforms/scalar-state, and the runtime writes each
sampler's Tier 2 gpuResourceID into an arena slice bound there. Textures
stay on direct [[texture(N)]] slots (31 is enough). One sampler per
image keeps them distinct, matching the SPIR-V/Vulkan path — no dedup,
so no wrong-sampler artifacts.

Verified argument buffers lift the limit on Apple Silicon (20-sampler
pipeline probe). Dreaming Sarah renders correctly at 60/60 with Metal
API validation clean; the void Terrarium scene shader that exceeded the
limit now compiles and runs (draws 74 -> 102/frame); all 27 shader
compiler tests pass, goldens unchanged (fixtures sample nothing).

* [Gpu] Metal: Shared storage for CPU-populated, GPU-sampled textures

The MTLTextureDescriptor default is Managed, which on unified memory needs
an explicit host->device sync we never issue after replaceRegion, so the
GPU can sample stale texels. These textures are CPU-uploaded and GPU-read,
so Shared (coherent, no sync on Apple Silicon) is the correct mode.

* [HLE] Add missing AGC/AudioOut/Pad exports blocking Unity+FMOD titles

Four exports were unresolved and hard-stalled GPU/audio/input init in
Unity titles (Lunar Lander Beyond froze there before opening VideoOut):

- sceAgcDriverSetTFRing / sceAgcDriverSetHsOffchipParam: tessellation-ring
  and hull-shader off-chip config. We translate shaders directly, so these
  only need to report success for init to proceed.
- sceAudioOutGetPortState: report a connected primary output at full volume.
- scePadDeviceClassGetExtendedInformation: report a standard pad (no special
  peripheral) so device-class probes resolve.

Generic HLE, backend-agnostic (helps the Vulkan path equally).

* [VideoOut] RegisterBuffers2: mask the 32-bit category, accept COMPRESSED

sceVideoOutRegisterBuffers2's category is a 32-bit SceVideoOutBufferCategory
passed on the stack, but we read the full 64-bit slot — whose upper word
carries stale GNM magic (0xC0DEC0DE...) the caller never cleared. The old
check then rejected every call as INVALID_VALUE, so buffer registration
failed and no frame ever presented. Mask to 32 bits and accept both
UNCOMPRESSED (0) and COMPRESSED (1); we present either identically.

Fixes Lunar Lander Beyond reaching its window (now presents 3840x2160).

* [HLE] Stub sceAudioPropagation (3D-audio) so Astro Bot boots past its assert

Astro Bot hard-crashed right after the splash: it calls
sceAudioPropagationSystemQueryMemory during audio init, and because the
whole libSceAudioPropagation module was unimplemented the call failed, so
the game asserted (AudioPropagationContext.cpp:43) and executed int 0x41 to
abort — an unrecoverable trap that kills the process.

We don't model acoustic propagation (geometry-driven reverb/occlusion is a
quality feature, not a correctness gate). The API is placement-style, so
QueryMemory reports a buffer size and the rest succeed as no-ops: the system
lives in the caller's own buffer. All 39 entry points stubbed; the game now
boots past the assert to the presenter. Backend-agnostic HLE.

* [Kernel] pthread_cond_wait: don't spuriously EPERM an untracked mutex

pthread_cond_wait/timedwait required our host-side mutex tracking to show
the calling thread as the owner, else it returned EPERM. But libkernel's
uncontended mutex fast-path locks the mutex word in guest memory directly,
without an HLE call, so we often never observe the lock and see owner==0.

Real pthread_cond_wait requires the caller to hold the mutex but does not
verify it for normal mutexes, so EPERM here is doubly wrong: it spins the
guest (Hades hammered this millions of times/sec) and, worse, skips the
unlock — leaving the mutex held and wedging every thread that later blocks
on pthread_mutex_lock. When the mutex reads as untracked (owner==0), adopt
ownership so the unlock/wait/re-lock cycle is balanced and actually releases
it. Genuine ownership violations (owned by another thread) still error.

Eliminates the EPERM storm and converts the resulting livelock into correct
blocking; no effect on games that lock through the HLE (owner already set).

* [Core] SSE4a EXTRQ patch: read the xmm register from ModRM, not xmm2

The loader rewrites Sony's AMD-only SSE4a EXTRQ+blend idiom into SSE4.1 at
boot, because Rosetta 2 and Intel hosts raise #UD -> SIGILL on EXTRQ. The
matcher hard-coded the source register to xmm2 (ModRM 0xC2), but the compiler
allocates it freely: Dead Cells (PPSA15552) emits the identical idiom against
xmm1, so it slipped through unpatched and the game died with SIGILL right
after the first frame.

Read the register from the ModRM r/m field instead, covering xmm0-xmm7, and
require it to be consistent across the EXTRQ and the blend. The pure
match/encode logic is extracted into Sse4aExtrqBlendPatch, isolated from the
native page-patching, and unit-tested for every register plus the round trip
and rejection cases; DirectExecutionBackend just applies it.

Dead Cells now patches its xmm1 idioms and boots past the first frame.

* [Ngs2] Implement non-allocator sceNgs2SystemCreate / sceNgs2RackCreate

Dead Cells uses the non-allocator NGS2 create entry points, which were
unimplemented. sceNgs2SystemCreate came back as an unresolved import, so the
game got a garbage system handle; every downstream sceNgs2RackCreate /
sceNgs2RackGetVoiceHandle then failed, the voice handle stayed null, and once
gameplay started the audio path polled sceNgs2VoiceGetState/VoiceControl on
the null voice forever — freezing the game in-level at FLIP 0.

The non-allocator forms differ only in a caller buffer (rsi/rcx) vs an
allocator callback; the system/option and out-handle arguments sit at the
same positions, so they alias the existing WithAllocator implementations.
Resolves the NGS2 InvalidVoiceHandle storm (591+/run -> 0).

* [SaveData] Real save subsystem: ~/SharpEmu/Saves/<titleId>, events, full CRUD

Rework the SaveData HLE from a partial stub into a working subsystem:

- Storage moves to ~/SharpEmu/Saves/<titleId>/<dirName>/ (was next to the
  exe under user/savedata/<userId>/<titleId>), overridable via
  SHARPEMU_SAVEDATA_DIR. Metadata (title/subtitle/detail/userParam) and icon
  live in <slot>/sce_sys/. Pure path + param.json logic is isolated in a new
  SaveDataStorage type and unit-tested.
- Async event model: sceSaveDataGetEventResult now resolves (was an
  unresolved import a save worker polled forever), returning queued completion
  events or a clean 'no event' status; SyncSaveDataMemory posts a
  SAVE_DATA_MEMORY_SYNC_END event. Plus GetEventInfo/SetEventInfo/register
  callbacks.
- New exports: Mount/Mount2/Mount5/Umount, Delete/Delete5, GetParam/SetParam,
  SaveIcon/SaveIconByPath/LoadIcon, GetAllSize/GetProgress/GetMountInfo/
  IsMounted/GetSaveDataCount/GetMountedSaveDataCount/Abort, Initialize/
  Initialize2/Terminate, SaveDataMemory v1 aliases.
- Mounts are tracked so Umount2 really unregisters the /savedata0 mapping
  (new KernelMemoryCompatExports.UnregisterGuestPathMount) and params/icons
  resolve against the live mount; DirNameSearch surfaces param.json titles.

15 new unit tests (storage layout/sanitize/metadata + mount/event/param/delete
exports); full suite 277 passing.

* [Gpu] Metal: Cmd+F1 toggles Apple's Metal Performance HUD

Plain F1 keeps the built-in CPU-rasterized perf overlay; Cmd+F1 now toggles
the system Metal Performance HUD on the CAMetalLayer, Metal backend only.

Command-modified keys never reach keyDown: (AppKit routes them through the
key-equivalent chain), so the input view gains a performKeyEquivalent:
override that claims Cmd+F1 (also silencing the system beep) and leaves
everything else to the responder chain.

Configured per Apple's 'Customizing Metal Performance HUD':
developerHUDProperties with mode=default + logging=default, plus
MTL_HUD_LOG_SHADER_ENABLED=1 passed directly in the dictionary — HUD,
per-frame statistics logging, and shader-compile logging all enabled from
one property set; mode=disabled hides it again. Guarded by a
respondsToSelector: check for older macOS.

* [Gpu] Metal: also catch Cmd+F1 in keyDown: for the HUD toggle

Function keys reach keyDown: even with Command held (AppKit only reroutes
some chords through performKeyEquivalent:), so the HUD toggle was never
firing there. Handle Cmd+F1 in both the keyDown: and performKeyEquivalent:
paths, and keep it out of MetalHostInput so it can't also flip the plain-F1
perf overlay.

* [Audio] Diagnostics: NGS2 voice-param dump + AudioOut peak-amplitude trace

Two gated traces (idiomatic SHARPEMU_LOG_* style) that pinpoint where audio
dies for NGS2-based games:

- SHARPEMU_LOG_NGS2 now walks the sceNgs2VoiceControl param list and logs each
  {size,id} block header + payload bytes, confirming the real layout
  (header = u32 size, u32 id; waveform-block param id=0x10000001 carries the
  guest PCM pointer at +8; rate param id=0x10000005 carries the resample ratio).
- SHARPEMU_LOG_AUDIO_OUT logs sceAudioOutOutput call count and the peak
  amplitude of each submitted buffer.

Finding on void Terrarium: sceAudioOutOutput is called thousands of times on
both 8ch/float32 ports, but every buffer has peak=0.0 — the guest submits pure
silence. The host path (AudioOut -> PCM convert -> CoreAudio) is proven correct;
the silence originates in Ngs2SystemRender, which zeroes the output buffer
instead of mixing voices. Restoring audio for NGS2 games requires a real NGS2
software mixer (next).

* [Audio] NGS2 software mixer: decode + mix PS-ADPCM voices

NGS2-based games were silent because sceNgs2SystemRender only zeroed the
output buffer. This adds a real software mixer:

- Ngs2VagDecoder: clean-room PS-ADPCM ("VAGp") decoder producing mono PCM16
  with loop points resolved from the exact per-frame flag values (3=loop
  start, 6=loop end, 1/7=one-shot end).
- Voice control now parses the SceNgs2VoiceParamHead command list, decodes the
  waveform-blocks param's VAGp container once, and arms the voice.
- sceNgs2SystemRender mixes every armed voice belonging to the system into the
  leading grain of the render buffer as interleaved float32 (nearest-sample
  resample from the source rate to 48 kHz, additive into the front L/R pair),
  which is exactly what games copy to sceAudioOutOutput.

Verified on void Terrarium: previously peak=0.0 silence at AudioOut, now real
audible SFX/music. Voices are still armed on waveform assignment rather than an
explicit kick, so pooled/duplicate voices can overlap — trigger-state handling
is a follow-up.

* [Gpu] AGC: latch GPU-wait satisfaction to the produced value

Fixes a lost-wakeup race that stalled games at a black/splash screen. When a
RELEASE_MEM packet writes a completion label, the guest frequently resets that
label to 0 immediately to reuse it next frame. Our wake path
(GpuWaitRegistry.CollectSatisfied) re-reads *current* guest memory, so if the
reset lands before the wake pass runs, the transient satisfied window is missed
and the suspended DCB waits forever — even though the producing write executed
(traced as wrote=True) and its producer is marked completed.

RELEASE_MEM producers now call GpuWaitRegistry.LatchSatisfiedByValue with the
value they actually wrote, recording satisfaction at the moment of the write for
any waiter that value satisfies. CollectSatisfied honors the latch regardless of
the current (possibly-reset) memory value. This is fail-closed: a waiter only
latches when a real producer wrote a genuinely satisfying value.

Verified: Astro Bot's DEADBEEF sentinel wait (dcb.graphics waiting on a
release_mem label) that was permanently stuck is now resolved; void Terrarium is
unregressed (runs, audio intact, no producerless stalls). Astro still has
separate unresolved blockers (producer-behind-its-own-wait cascades and
producer=none-observed labels) tracked for follow-up; WRITE_DATA/DMA_DATA
producers could latch too but are left out until there is evidence they race.

* [Gpu] AGC: retry indirect dispatches whose GPU-computed dims aren't ready

GPU-driven games (Astro Bot) build their frame on the GPU: a compute dispatch
writes the thread-group dimensions for the next DISPATCH_INDIRECT into a guest
buffer. Our AGC parser reads those dimensions on the CPU at parse time, which
runs before the producing dispatch has executed on the render thread — so it
read 0/0/0 and dropped the work (agc.dispatch_reject zero-dimension), leaving
the scene unrendered (black) and cascading into stuck cross-queue fence waits.

Instead of dropping a zero-dimension INDIRECT dispatch, suspend the DCB on its
dimensions buffer (reusing the WAIT_REG_MEM suspend/resume + GpuWaitRegistry
machinery) until the producer writes non-zero dims, then re-parse and dispatch.
A bounded per-wait deadline (150 ms) resumes-and-drops a genuinely empty
indirect dispatch so it can never stall the queue, making the change
non-regressive: worst case matches the old drop behavior after a short wait.
Direct dispatches (dims inline) are unaffected.

Result: Astro Bot goes from a permanent black screen to actually rendering
(the presenter reports "Metal VideoOut presenting 3840x2160"). void Terrarium —
which issues no indirect dispatches — is unregressed (runs, audio intact, zero
rejects). Astro then hits a separate, newly-reached downstream crash (guest
TBB worker thread_set_state failure) tracked for follow-up.

* [ShaderCompiler] Metal: keep compute shaders within read_write and LDS limits

Two Metal limits made real Astro Bot compute shaders fail to compile/create,
which dropped their dispatches and cascaded into stuck GPU waits (splash hang):

- Textures with access::read_write are capped at 8 per function, but every
  storage image was declared read_write. Track each binding's actual access
  during body emission (ImageLoad->read, ImageStore->write, ImageAtomic and a
  load+store sharing one binding->read_write) and emit the minimal qualifier,
  so read-only/write-only storage images no longer count against the cap.

- Threadgroup memory is capped at 32 KB. A shader requesting the full 32 KB of
  LDS plus the separate 3-dword wave64 bridge overflowed by 12 bytes. Alias the
  bridge into the top of the LDS allocation when both are used, mirroring the
  SPIR-V translator's _waveScratchInLds path, keeping the total at 32 KB.

Verified on Astro Bot: "read_write access exceeds maximum (8)" and "Threadgroup
memory size (32780) exceeds maximum (32768)" are both gone; the 27 MSL golden
tests still pass (no golden used a storage image or LDS+wave64 shader).

* [Gpu] AGC: break cross-queue GPU wait deadlocks with a produced-value fallback

Real GPU-driven titles (Astro Bot) drive graphics and compute queues with
mutually dependent WAIT_REG_MEM fences: graphics waits on a compute EOP label,
compute waits on a graphics label. On hardware the two queues run concurrently
so the cycle resolves, but our submission parser is serial, so a label that gets
written -> reset for reuse -> re-waited across queues can wedge forever. The
latch fix helped the write-then-consume race but the cycle re-formed each frame
(graphics stuck at 3 flips, compute queues permanently suspended).

Producers now record the last value they wrote to each label
(GpuWaitRegistry.RecordProduced). A new deadlock breaker
(CollectDeadlockBroken, run from DrainResumableDcbs) releases any waiter stuck
past a 500 ms deadline whose condition is satisfied by that recorded value —
i.e. a real producer signalled the label at least once, guest memory has just
since been reset. It never fabricates a value, and the long deadline means
legitimate fences (which complete within a frame) never trip it.

Verified: Astro Bot goes from 3 flips (wedged on splash) to 25, loads its
splash level ("LevelDocument Loaded: ps_logo") and produces 2432x1368 frame
content. void Terrarium is untouched — 0 deadlock-break events, 1020 flips,
audio intact (its waits resolve far under the deadline). Tunable via
SHARPEMU_GPU_DEADLOCK_BREAK_MS.

* [Cpu] SSE4a EXTRQ patch: cover any blend destination register, not just xmm0

The EXTRQ+VPBLENDD idiom rewrite only matched when the blend destination was
xmm0 (VEX.vvvv byte 0x79). Sony's toolchain allocates that register freely: a
Dead Cells build emits `EXTRQ xmm4,0x28,0x00 ; VPBLENDD xmm3,xmm3,xmm4,2`
(VEX byte 0x61, dest xmm3). That instance stayed unpatched, so the AMD-only
EXTRQ reached Rosetta 2 and raised #UD -> SIGILL (0xC000001D) the moment the
game entered gameplay (loading level PrisonStart).

Read the destination register from VPBLENDD's VEX.vvvv / ModRM.reg as well as
the source from the ModRM r/m field, and emit PINSRD into that destination. Both
are still constrained to xmm0-xmm7 by the fixed VEX prefix. Match/encode stay in
the unit-tested helper.

Verified: Dead Cells now patches 14 EXTRQ blends (previously 0 on this build),
no SIGILL, and reaches PrisonStart. 15 patch unit tests pass, including the exact
xmm3/xmm4 bytes that faulted.

* [HLE] Implement Dead Cells' remaining unresolved imports

Three imports Dead Cells calls during boot/level-load were unresolved, so they
returned no defined value:
- scePadGetHandle (libScePad): returns the primary pad's handle (polled every
  frame for input); same validation as scePadOpen.
- sceNpEntitlementAccessGetAddcontEntitlementInfo (libSceNpEntitlementAccess):
  singular add-on-content lookup; we own no DLC, so zero the info out and return
  OK, matching the existing list variant.
- sceNpUniversalDataSystemEventPropertyArraySetString: telemetry setter, dropped.

Dead Cells now boots with zero unresolved imports. (It still stalls later at
PrisonStart level-load — a separate GPU/threading issue, not an import gap.)

* [Kernel] Fix pthread mutex deadlock: trylock semantics + stale-waiter clog

Hades hard-froze during boot on a "free but reserved" mutex: owner==0 yet
every acquisition failed forever. Two independent defects in the pthread
mutex compat layer combined to wedge it, both traced from real runs.

1. trylock incorrectly required an empty wait queue. POSIX
   pthread_mutex_trylock succeeds whenever the mutex is not currently held
   and owes no fairness to queued waiters; gating it on Waiters.Count==0 made
   a spin-on-trylock loop (which the game runs) spin forever against a single
   undrainable waiter even though owner==0. trylock now acquires on owner==0;
   the blocking lock still honours FIFO so genuine blocked waiters are not
   starved by a barging locker.

2. cond_timedwait timeouts leaked mutex re-acquire waiters. A cond wait's
   timeout enqueues a re-acquire waiter whose wake hand-off can be lost,
   orphaning it in the mutex queue. Multiple orphans from one thread piled at
   the FIFO head; the unlock hand-off then woke a dead wake-key and the mutex
   never drained. A thread can hold at most one pending acquisition on a
   mutex, so EnqueueMutexWaiterLocked now prunes any prior waiter for the same
   thread before enqueueing — collapsing the leaked pile.

Verified: Hades advances from a hard freeze at ~4.9M HLE calls (main and a
worker both blocked on the same free mutex) to 24.9M calls with no stall,
reaching the save-data/user-service boot stage. void tRrLM behaves
identically with and without the change (no regression); all 268 Libs tests
pass.
2026-07-18 20:32:00 +03:00
kostyaff 6dda6589d0 test: add Kernel/Loader unit tests (22 tests) (#373)
- SelfLoader: reject unknown magic, truncated headers; parse PS5 SELF embedded ELF
- KernelMemory: MapNamedFlexibleMemory/mprotect/munmap argument validation
- KernelEventQueue: create/delete/add/trigger/wait lifecycle

Co-authored-by: OMP <omp@local>
2026-07-18 20:19:55 +03:00
Aurélien Vivet 2ced3af114 AppContent: stub sceAppContentDownloadDataGetAvailableSpaceKb (#398)
Download data is not emulated as a real quota, so report a fixed 1 GiB
of free space and let titles skip the "storage full" path.
2026-07-18 18:44:30 +03:00
Berk 18708aa2d3 [GUI] Fixes and improvements for the GUI, including new image assets and updates to language files. (#400) 2026-07-18 17:44:04 +03:00
Berk a709ccca17 [shader_recompiler] Fix guest image byte count calculation for Vulkan video presenter (#395) 2026-07-18 16:09:26 +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
tensorcrush 847371d2de [AGC] Decode VOP3P and emit packed f16 arithmetic (first slice) (#145)
* [AGC] Decode VOP3P and emit packed f16 arithmetic (first slice)

On gfx10 the VOP3P family lives under its own 0b110011000 prefix (word0
top byte 0xCC), which the major-opcode switch currently routes to the
SMEM branch, so packed instructions were decoded as scalar memory ops
and emitted as silent no-ops. Intercept the exact 9-bit prefix ahead of
the switch, decode the five packed-f16 arithmetic opcodes with their
op_sel/op_sel_hi/neg_lo/neg_hi/clamp modifiers, and emit them as
UnpackHalf2x16 -> component-wise f32 vec2 ops -> PackHalf2x16 so no
Float16 capability is needed.

Bit layout and opcode numbers pinned to LLVM MC test encodings
(vop3p.s, gfx10_vop3p_literalv216.txt) and VOP3PInstructions.td.
Unsupported modifiers, packed constants and out-of-scope packed opcodes
fail with a clear error instead of emitting wrong results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* [AGC] Make packed f16 exact and drop v_pk_fma_f16 (review response)

Address the FP16 correctness review on the VOP3P slice.

Replace GLSL UnpackHalf2x16/PackHalf2x16 with explicit integer f16<->f32
conversions (EmitHalfToFloat/EmitFloatToHalf): exact widening with subnormal
normalisation, and narrowing with round-to-nearest-even, overflow-to-Inf and
NaN/Inf handling. Their subnormal and rounding behaviour no longer depends on
implementation-defined float-controls modes.

With exact conversions, v_pk_add_f16 and v_pk_mul_f16 are bit-exact to a true
f16 op (f32 result rounds losslessly to f16; a f16 product fits in f32). Emit
v_pk_min_f16/v_pk_max_f16 as fminnum_like/fmaxnum_like (NaN operand returns the
other; ordered numeric compare) instead of GLSL FMin/FMax.

v_pk_fma_f16 now fails emission loudly: a fused f16 FMA rounds once, an f32
multiply-add then pack double-rounds (fma(0x4100,0x7522,0x04EA) is 0x7A6B fused
vs 0x7A6A via f32). Exact fused emulation is a planned follow-up slice.

ShaderDump gains an Expect model (Translates/DecodeFails/EmitFails) and packed
regressions: arith, non-default modifiers, and loud-failure pins for the fma
case above and for clamp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: tensorcrush <tensorcrush@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Antigravity AI <antigravity@gemini.com>
2026-07-17 18:58:22 +03:00
Berk fbafd3f429 [GUI] Add native Vulkan host surface support and more (#337)
* [GUI] Add native Vulkan host surface support and more

* reuse

* [GUI] set width session menu
2026-07-17 18:57:10 +03:00
Mees van den Kieboom aa25f6e978 [Loader] Restore PS5 SELF header support (#342)
Accept both PS4 and PS5 SELF signatures without treating version, key type, or flags as layout markers. Add synthetic coverage for valid header variants and malformed structural fields.

Signed-off-by: missatjuhvdk1 <177474143+missatjuhvdk1@users.noreply.github.com>
Co-authored-by: missatjuhvdk1 <177474143+missatjuhvdk1@users.noreply.github.com>
2026-07-17 18:55:16 +03:00
Raiyan b9eee497aa [VideoOut] Scope the AMD integrated-GPU penalty to Windows (#339)
* [VideoOut] Prefer real integrated GPUs over software rasterizers (#325)

Penalize only AMD integrated GPUs (the #97 vkCreateGraphicsPipelines
crash) instead of all integrated devices, so Intel/Apple/Qualcomm iGPUs
outrank Cpu-type software rasterizers (Mesa lavapipe). Hoist
ScorePhysicalDevice to the outer class and add unit tests for the
ordering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* [VideoOut] Scope AMD iGPU penalty to Windows via a penalty helper

Extract ComputeDevicePenalty (the value subtracted from a device's base
score) and gate the #97 AMD-integrated penalty on Windows only. Mesa RADV
on Linux (e.g. the Steam Deck's AMD APU) is a different, working driver
and should keep its full integrated score. Add a Steam Deck test case and
drop inline comments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* [VideoOut] Move device-scoring helpers out of the const block

Relocate ScorePhysicalDevice and ComputeDevicePenalty below the leading
const cluster instead of splitting it, and trim the vendor-ID reference
comment to adapters an x86-64 host can realistically enumerate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 18:48:09 +03:00
Jose Olguin Lagos a9a4366ef4 test: cover Gen5 decoder fail-closed boundaries (#336)
Reimplement the five synthetic regressions from codefusion-repo/sharpemu#3 and #8 on sharpemu/sharpemu main befd20f415.

Keep the contributor-owned harness and corpus discussed in sharpemu/sharpemu#36 out of scope; use only a focused local recording memory fixture.

Signed-off-by: Jose Olguin Lagos <hellocodefusion@gmail.com>
2026-07-17 18:45:26 +03:00
ParantezTech 76e8f789c4 [github] edit contribution link in pull request template 2026-07-17 18:25:34 +03:00
ParantezTech 6fce0e3055 [github] update PR template and REUSE.toml 2026-07-17 18:20:44 +03:00
Berk befd20f415 Update contribution guidelines to enforce pull request template (#331)
Emphasize the importance of using the pull request template and outline consequences for non-compliance.
2026-07-17 13:52:41 +03:00
kuba 24b82a7f1c ci: rebuild the website when a release is published (#312)
* ci: rebuild the website when a release is published

* ci: add REUSE license header to notify-site workflow
2026-07-17 05:36:59 +02:00
Berk a9a4f511f0 Create pull request template for contributions
Added a pull request template to guide contributors in the SharpEmu Emulator Project.
2026-07-17 05:17:18 +03:00
Berk a44ce34a0a Add release process documentation to RELEASE-USE.md
Document the release process for SharpEmu, including preparation and publishing steps.
2026-07-17 05:00:22 +03:00
Berk 41ec56f813 chore: bump version to 0.0.2-beta.2 (#310) 2026-07-17 04:51:09 +03:00
Berk aa32d4bad0 added a release script to automate the release process (#309) 2026-07-17 04:48:46 +03:00
Hadi Abdulrahman faf3a397a8 [VideoOut] Fall back to 8-bit RGBA for unknown pixel formats instead of silently failing (#294)
Changes MapPixelFormatToGuestTextureFormat to default to format 56 (8-bit
RGBA) when the game uses a pixel format not yet in the known list, with a
stderr warning that reports the exact format value for project issue reports.

Previously, unknown formats returned 0, which caused RegisterKnownDisplayBuffer
to skip registration entirely. The GPU backend then couldn't find the buffer
during flip, producing vk.flip_capture_failed, and some games later hit a
Debug.Assert in ExecuteOrderedGuestFlipWait.

The fallback produces wrong colors for the affected games but lets them render
and display output, which is strictly better than a black screen or access
violation crash. The pixel format is printed to stderr so developers can
identify it and add proper support.

Co-authored-by: meowman <haadii2005@gamil.com>
2026-07-17 04:35:57 +03:00
samto6 488b285ecb [SaveData] Implement save data memory2 exports (#297)
* [SaveData] Implement save data memory2 exports

Astro Bot calls sceSaveDataSetupSaveDataMemory2 during boot and asserts
and null-writes when it fails, so the missing import surfaces as a
named crash. This implements setup plus the companion get, set, and
sync operations that make it useful. The store is one zero-filled file
per user and title at sce_sdmemory/memory.dat under the save root,
readiness is the backing file's existence, and get, set, and sync
return MEMORY_NOT_READY before setup. Struct offsets follow the
publicly documented homebrew savedata headers.

* [SaveData] Write setup result before mutating the memory backing file
2026-07-17 04:35:00 +03:00
samto6 1a4a2902d4 [HLE] Stub ContentExport, Font, and Pad calls Astro Bot needs to boot (#298)
Astro Bot asserts and null-writes when a subsystem init call fails, so
each missing import surfaces as a named crash. This stubs the blockers
observed during bring-up: content export init, eight font calls, and
pad tilt correction. sceFontGetHorizontalLayout writes the same
invented geometry as sceFontGetRenderCharGlyphMetrics and the rest
report success. Together with save data memory2 these take the title
to its splash image and font glyph rendering path.
2026-07-17 04:34:48 +03:00
kuba f544146d6d AGC: reduce inactive trace and draw allocations (#308) 2026-07-17 04:25:26 +03:00
kuba dbd74654c4 Vulkan: trim disabled draw diagnostics overhead (#307) 2026-07-17 04:18:06 +03:00
kuba 28485b60e2 CPU: avoid continuation emitter closure allocations (#306) 2026-07-17 04:17:58 +03:00
999sian 6b1abc1a38 perf(shader): cache pixel export masks (#288) 2026-07-17 04:17:39 +03:00
kostyaff 7494792249 [Tests] Add VirtualMemory and PhysicalVirtualMemory edge case tests (#266)
VirtualMemory: 4 new tests covering unmapped address returns false,
zero-length operations, page-boundary access, and cross-gap failures.

PhysicalVirtualMemory: 5 new tests covering lazy commit on demand,
reserve-only GetPointer, unmapped GetPointer returns null, free-list
first-fit reuse, and coalescing both neighbours on middle-range free.

Total: 9 new tests, 18 passed (8 VirtualMemory + 5 PhysicalVirtualMemory
+ 5 GuestMemoryAllocator). No production code changes.
2026-07-17 03:13:38 +03:00
jimmyjumbo 3585519007 [Audio] Correct float PCM endpoint conversion (#291) 2026-07-17 03:13:06 +03:00
Berk 8ee0fe8e44 Add files via upload 2026-07-17 03:03:05 +03:00
Tell-Shanks 0755ca15f7 core: report what occupies a fixed-address allocation on failure (#278)
* Implement DescribeAddressForDiagnostics method

Added a method to describe the state of a memory address for diagnostics.

* core: enrich allocation failure exception with host region diagnostic
2026-07-17 02:58:31 +03:00
jimmyjumbo 74c70cc2c1 [CI] Make versioned releases immutable (#289) 2026-07-17 02:54:35 +03:00
Berk 73e0ebf446 Update README order (#303) 2026-07-17 02:47:53 +03:00
288 changed files with 46736 additions and 2639 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 229 KiB

+27
View File
@@ -0,0 +1,27 @@
## Before submitting
Please read our contribution guidelines before opening a pull request:
➡️ [**CONTRIBUTING.md**](https://github.com/sharpemu/sharpemu/blob/main/CONTRIBUTING.md)
By opening this pull request, you confirm that you have read and agree to follow the contribution guidelines.
## Testing
If applicable, list the game(s) you tested and briefly describe the results.
Example:
- Demon's Souls (PPSA01341) Boots to splash screen.
- Dreaming Sarah Save/load works correctly.
If your changes do not affect runtime behavior (e.g. documentation, tooling, CI), write `N/A`.
## Checklist
By submitting this pull request, you confirm that:
- [ ] I have read and followed `CONTRIBUTING.md`.
- [ ] I tested my changes or marked the testing section as `N/A`.
- [ ] I wrote this pull request description myself and did not paste AI-generated explanations.
- [ ] I listed the game(s) I tested (or marked the testing section as `N/A`).
+31
View File
@@ -0,0 +1,31 @@
# Copyright (C) 2026 SharpEmu Emulator Project
# SPDX-License-Identifier: GPL-2.0-or-later
# Tells the website repo to rebuild when a release is published, so
# sharpemu.app/downloads lists the new build within a minute.
name: Notify website
on:
release:
types: [published, released, edited, deleted]
workflow_dispatch:
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Trigger sharpemu-site rebuild
env:
TOKEN: ${{ secrets.SITE_DISPATCH_TOKEN }}
run: |
if [ -z "$TOKEN" ]; then
echo "SITE_DISPATCH_TOKEN is not set — skipping website rebuild."
exit 0
fi
curl -fsS -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/sharpemu/sharpemu-site/dispatches \
-d '{"event_type":"release-published"}'
echo "Website rebuild requested."
+79 -49
View File
@@ -7,6 +7,8 @@ on:
push:
branches:
- "**"
tags:
- "v*"
paths-ignore:
- "**/*.md"
- "**/*.png"
@@ -53,6 +55,11 @@ jobs:
release_tag="v${version}"
release_name="SharpEmu v${version}"
if [ "${GITHUB_REF_TYPE}" = "tag" ] && [ "${GITHUB_REF_NAME}" != "${release_tag}" ]; then
echo "Release tag ${GITHUB_REF_NAME} does not match project version ${release_tag}." >&2
exit 1
fi
{
echo "short-sha=${short_sha}"
echo "safe-ref=${safe_ref}"
@@ -82,7 +89,6 @@ jobs:
DOTNET_NOLOGO: true
NUGET_PACKAGES: ${{ github.workspace }}\.nuget\packages
PUBLISH_DIR: ${{ github.workspace }}\artifacts\publish\win-x64
RELEASE_DIR: ${{ github.workspace }}\artifacts\release
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -114,24 +120,13 @@ jobs:
- name: Publish win-x64 CLI
run: dotnet publish src/SharpEmu.CLI/SharpEmu.CLI.csproj -c Release -r win-x64 --self-contained true --no-restore -p:PublishDir="${env:PUBLISH_DIR}"
- name: Create release archive
run: |
New-Item -ItemType Directory -Path $env:RELEASE_DIR -Force | Out-Null
$archiveName = "sharpemu-${{ needs.init.outputs.version }}-win-x64.zip"
$archivePath = Join-Path $env:RELEASE_DIR $archiveName
if (Test-Path $archivePath) {
Remove-Item $archivePath -Force
}
Compress-Archive -Path (Join-Path $env:PUBLISH_DIR '*') -DestinationPath $archivePath -CompressionLevel Optimal
- name: Upload build artifact
uses: actions/upload-artifact@v7
with:
name: sharpemu-win-x64-${{ needs.init.outputs.short-sha }}
path: ${{ env.RELEASE_DIR }}\sharpemu-${{ needs.init.outputs.version }}-win-x64.zip
path: ${{ env.PUBLISH_DIR }}
if-no-files-found: error
include-hidden-files: true
build-posix:
name: Build ${{ matrix.rid }}
@@ -151,7 +146,11 @@ jobs:
DOTNET_NOLOGO: true
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
@@ -176,6 +175,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"
@@ -183,19 +210,13 @@ jobs:
if: matrix.rid == 'osx-x64'
run: scripts/fetch-macos-moltenvk.sh "$PUBLISH_DIR"
- name: Create release archive
run: |
mkdir -p "$RELEASE_DIR"
# tar keeps the executable bit, which zip would drop.
tar -czf "$RELEASE_DIR/sharpemu-${{ needs.init.outputs.version }}-${{ matrix.rid }}.tar.gz" \
-C "$PUBLISH_DIR" .
- name: Upload build artifact
uses: actions/upload-artifact@v7
with:
name: sharpemu-${{ matrix.rid }}-${{ needs.init.outputs.short-sha }}
path: ${{ env.RELEASE_DIR }}/sharpemu-${{ needs.init.outputs.version }}-${{ matrix.rid }}.tar.gz
path: ${{ env.PUBLISH_DIR }}
if-no-files-found: error
include-hidden-files: true
release:
name: Publish GitHub Release
@@ -203,7 +224,9 @@ jobs:
- init
- build
- build-posix
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
# Versioned releases are immutable and tag-driven. Branch and manual runs
# still produce Actions artifacts without modifying a published release.
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
@@ -213,7 +236,29 @@ jobs:
with:
path: release
- name: Create or update release
- name: Package release assets
shell: bash
env:
SHORT_SHA: ${{ needs.init.outputs.short-sha }}
VERSION: ${{ needs.init.outputs.version }}
run: |
set -euo pipefail
win_dir="release/sharpemu-win-x64-${SHORT_SHA}"
linux_dir="release/sharpemu-linux-x64-${SHORT_SHA}"
macos_dir="release/sharpemu-osx-x64-${SHORT_SHA}"
for package_dir in "${win_dir}" "${linux_dir}" "${macos_dir}"; do
test -d "${package_dir}"
done
mkdir -p release-assets
(cd "${win_dir}" && zip -q -r "../../release-assets/sharpemu-${VERSION}-win-x64.zip" .)
chmod +x "${linux_dir}/SharpEmu" "${macos_dir}/SharpEmu"
tar -czf "release-assets/sharpemu-${VERSION}-linux-x64.tar.gz" -C "${linux_dir}" .
tar -czf "release-assets/sharpemu-${VERSION}-osx-x64.tar.gz" -C "${macos_dir}" .
- name: Create release
shell: bash
env:
GH_REPO: ${{ github.repository }}
@@ -222,35 +267,20 @@ jobs:
RELEASE_TAG: ${{ needs.init.outputs.release-tag }}
VERSION: ${{ needs.init.outputs.version }}
run: |
mapfile -t assets < <(find release -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release assets found." >&2
mapfile -t assets < <(find release-assets -maxdepth 1 -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
if [ "${#assets[@]}" -ne 3 ]; then
echo "Expected 3 release assets, found ${#assets[@]}." >&2
exit 1
fi
notes="Automated SharpEmu v${VERSION} build for commit ${GITHUB_SHA}."
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
gh release upload "${RELEASE_TAG}" "${assets[@]}" --clobber
gh release edit "${RELEASE_TAG}" --title "${RELEASE_NAME}" --notes "${notes}"
else
gh release create "${RELEASE_TAG}" "${assets[@]}" \
--title "${RELEASE_NAME}" \
--notes "${notes}" \
--target "${GITHUB_SHA}"
echo "Release ${RELEASE_TAG} already exists and will not be modified." >&2
exit 1
fi
keep=()
for asset_path in "${assets[@]}"; do
keep+=("$(basename "${asset_path}")")
done
mapfile -t release_assets < <(gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name' | sort)
for asset in "${release_assets[@]}"; do
case "${asset}" in
sharpemu-${VERSION}-*.zip|sharpemu-${VERSION}-*.tar.gz)
if ! printf '%s\n' "${keep[@]}" | grep -Fxq "${asset}"; then
gh release delete-asset "${RELEASE_TAG}" "${asset}" --yes
fi
;;
esac
done
gh release create "${RELEASE_TAG}" "${assets[@]}" \
--verify-tag \
--title "${RELEASE_NAME}" \
--notes "${notes}"
+3
View File
@@ -32,6 +32,8 @@ packages/
.nuget/
.dotnet-home/
.cache/
__pycache__/
*.py[cod]
.DS_Store
Thumbs.db
@@ -40,3 +42,4 @@ ehthumbs.db
.vs/
.idea/
.vscode/
+24
View File
@@ -5,6 +5,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
# Contributing
> [!IMPORTANT]
> The pull request template is mandatory.
>
> Pull requests that do not follow the template or leave the required checklist incomplete will be closed without review, even if the proposed code is technically correct or beneficial. Please review these contribution guidelines before submitting a pull request.
Contributions are always welcome!
Before opening a pull request, please keep the following in mind:
@@ -21,6 +26,25 @@ Before opening a pull request, please keep the following in mind:
If you're unsure about a design decision, feel free to open a discussion or draft PR first.
## Pull Request Expectations
Pull requests should provide real, observable emulator behavior rather than only suppressing errors or unresolved imports.
Changes that only return success, zero, or fabricated handles without implementing the expected state, output, or side effects will generally not be accepted. Functions that create resources, write output structures, register callbacks, or expose runtime state should model the behavior required by the guest.
When applicable, PRs should include:
- The affected game or application.
- Relevant logs or failing imports.
- Behavior before and after the change.
- Real game testing and known limitations.
Avoid submitting large collections of speculative NIDs or unrelated exports. Keep each PR focused on one problem or a closely related set of changes.
Large architectural changes should be discussed with the maintainers before implementation. Contributors are encouraged to ask first when they are uncertain whether a proposed direction fits the project.
Opening a PR does not guarantee that it will be merged. Maintainers evaluate changes based on correctness, evidence, testing, scope, maintenance cost, and the long-term direction of the project.
## AI-Assisted Contributions
AI-assisted development is welcome and may be used for research, reverse engineering, code generation, or documentation.
+8 -1
View File
@@ -9,11 +9,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<SharpEmuVersion>0.0.2</SharpEmuVersion>
<SharpEmuVersion>0.0.2-beta.5</SharpEmuVersion>
<Version>$(SharpEmuVersion)</Version>
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And $([MSBuild]::IsOSPlatform('Windows'))">win</_HostRidOSPrefix>
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And '$(_HostRidOSPrefix)' == '' And $([MSBuild]::IsOSPlatform('Linux'))">linux</_HostRidOSPrefix>
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And '$(_HostRidOSPrefix)' == '' And $([MSBuild]::IsOSPlatform('OSX'))">osx</_HostRidOSPrefix>
<_HostRidArch Condition="'$(_HostRidOSPrefix)' != '' And '$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)' == 'Arm64'">arm64</_HostRidArch>
<_HostRidArch Condition="'$(_HostRidOSPrefix)' != '' And '$(_HostRidArch)' == ''">x64</_HostRidArch>
<RuntimeIdentifier Condition="'$(_HostRidOSPrefix)' != ''">$(_HostRidOSPrefix)-$(_HostRidArch)</RuntimeIdentifier>
<BaseIntermediateOutputPath>$(RepoRoot)artifacts/obj/$(MSBuildProjectName)/</BaseIntermediateOutputPath>
<BaseOutputPath>$(RepoRoot)artifacts/bin/</BaseOutputPath>
+1
View File
@@ -11,6 +11,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<PackageVersion Include="Avalonia.Desktop" Version="11.3.18" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.18" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.18" />
<PackageVersion Include="FFmpeg.AutoGen" Version="7.1.1" />
<PackageVersion Include="Iced" Version="1.21.0" />
<PackageVersion Include="Microsoft.Build.Framework" Version="17.14.8" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
+13 -12
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.
@@ -41,6 +43,16 @@ This project is developed purely for research and educational purposes. There ar
SharpEmu focuses exclusively on the PlayStation 5.
Our goal is **not** to emulate PS4 games, as there is already an excellent emulator dedicated to that platform: **ShadPS4**.
## Games Tested
| Demons Souls Remake | Dreaming Sarah |
| :-----------------------------------------------------------: | :--------------------------------------------------------------------------------------------: |
| ![Bloodborne screenshot](./.github/images/demons-souls.jpg) | ![Dreaming Sarah](./.github/images/dreaming-sarah.jpg) |
| Void Terrarium | Dead Cells |
| :------------------------------------------------------------------------: | :------------------------------------------------------------------: |
| ![Void Terrarium](./.github/images/void-terrarium.jpg) | ![Dead Cells](./.github/images/dead-cells.jpg) |
## Status
The emulator can currently load the `eboot.bin` of real games, execute native CPU instructions, and partially handle kernel-related functionality. However, several critical components are still missing.
@@ -88,17 +100,6 @@ chmod +x ./SharpEmu
A Vulkan-capable GPU and current graphics driver are required. The macOS
release includes the MoltenVK Vulkan implementation.
## Games Tested
| Demons Souls Remake | Dreaming Sarah |
| :-----------------------------------------------------------: | :--------------------------------------------------------------------------------------------: |
| ![Bloodborne screenshot](./.github/images/demons-souls.jpg) | ![Dreaming Sarah](./.github/images/dreaming-sarah.jpg) |
| Void Terrarium | Dead Cells |
| :------------------------------------------------------------------------: | :------------------------------------------------------------------: |
| ![Void Terrarium](./.github/images/void-terrarium.jpg) | ![Dead Cells](./.github/images/dead-cells.jpg) |
> [!IMPORTANT]
> This project does **not** support or condone piracy.
> All games used during development and testing are dumped from consoles that we personally own.
+4
View File
@@ -5,10 +5,14 @@ path = [
"REUSE.toml",
"nuget.config",
"global.json",
"**/packages.lock.json",
"scripts/ps5_names.txt",
"src/SharpEmu.GUI/Languages/**",
"src/SharpEmu.ShaderCompiler.Metal/Templates/**",
"tests/SharpEmu.ShaderCompiler.Metal.Tests/Goldens/**",
"_logs/**",
".github/images/**",
".github/pull_request_template.md",
"assets/images/**"
]
precedence = "aggregate"
+4
View File
@@ -7,16 +7,20 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Folder Name="/src/">
<Project Path="src/SharpEmu.CLI/SharpEmu.CLI.csproj" />
<Project Path="src/SharpEmu.Core/SharpEmu.Core.csproj" />
<Project Path="src/SharpEmu.DebugClient/SharpEmu.DebugClient.csproj" />
<Project Path="src/SharpEmu.Debugger/SharpEmu.Debugger.csproj" />
<Project Path="src/SharpEmu.GUI/SharpEmu.GUI.csproj" />
<Project Path="src/SharpEmu.HLE/SharpEmu.HLE.csproj" />
<Project Path="src/SharpEmu.Libs/SharpEmu.Libs.csproj" />
<Project Path="src/SharpEmu.Logging/SharpEmu.Logging.csproj" />
<Project Path="src/SharpEmu.ShaderCompiler/SharpEmu.ShaderCompiler.csproj" />
<Project Path="src/SharpEmu.ShaderCompiler.Metal/SharpEmu.ShaderCompiler.Metal.csproj" />
<Project Path="src/SharpEmu.ShaderCompiler.Vulkan/SharpEmu.ShaderCompiler.Vulkan.csproj" />
<Project Path="src/SharpEmu.SourceGenerators/SharpEmu.SourceGenerators.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/SharpEmu.Libs.Tests/SharpEmu.Libs.Tests.csproj" />
<Project Path="tests/SharpEmu.ShaderCompiler.Metal.Tests/SharpEmu.ShaderCompiler.Metal.Tests.csproj" />
<Project Path="tests/SharpEmu.SourceGenerators.Tests/SharpEmu.SourceGenerators.Tests.csproj" />
</Folder>
</Solution>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

+20
View File
@@ -0,0 +1,20 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
# Aerolib Catalog
```bash
# NID to export name
python scripts/aerolib_catalog.py lookup Zxa0VhQVTsk
# Export name to NID
python scripts/aerolib_catalog.py lookup sceKernelWaitSema
# Search export names
python scripts/aerolib_catalog.py search VideoOut --limit 20
# Export all NID/name pairs to artifacts/aerolib.txt
python scripts/aerolib_catalog.py export
```
+51 -22
View File
@@ -9,38 +9,67 @@ Demon's Souls plays Bink 2 (.bk2) files through a Bink implementation linked
directly into eboot.bin. It does not use libSceVideodec, therefore an HLE video
decoder cannot observe or replace those frames.
SharpEmu observes successful guest .bk2 opens and, when a Bink bridge is
SharpEmu observes successful guest .bk2 opens and, when a Bink decoder is
available, presents its decoded BGRA frames at the normal guest-flip boundary.
This preserves the game's own timing and lets the host Vulkan presenter display
the movie without trying to execute the PS5-specific Bink GPU decode path.
Without an adapter, Bink movies are skipped by default: their open call returns
not-found so games that mark cinematics as optional progress to their next
state instead of waiting on an empty Bink GPU texture.
The default path decodes by calling FFmpeg's own C API directly from managed
code (`src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs`, via the
[FFmpeg.AutoGen](https://github.com/Ruslan-B/FFmpeg.AutoGen) P/Invoke
bindings) against a custom FFmpeg build
(`github.com/sharpemu/ffmpeg-core`, LGPL-2.1) that adds a Bink 2 decoder to
FFmpeg 7.1.2; see "Supplying the FFmpeg libraries" below for where those
libraries come from. No proprietary RAD SDK is needed to build or run
SharpEmu, and there is no C/C++ code of SharpEmu's own involved in decoding
-- SharpEmu.CLI.csproj only downloads a prebuilt release archive.
Set `SHARPEMU_BINK_MODE=guest` to leave decoding to the Bink implementation
statically linked into the game instead. Set `skip` only when explicitly
testing a title whose cinematics are optional.
Set SHARPEMU_BINK_MODE=dummy to retain the open and show a built-in,
non-decoded placeholder frame. This requires no SDK, but is a visual diagnostic
only; it does not decode the movie or alter its game logic. Set
SHARPEMU_BINK_MODE=native to force native bridge mode.
only; it does not decode the movie or alter its game logic.
SHARPEMU_BINK_MODE=native is equivalent to the default and mainly useful for
being explicit about it.
## Supplying the adapter
The experimental `SHARPEMU_BINK_MODE=ffmpeg` override is unrelated to the
default path above: instead of calling into FFmpeg in-process, it spawns a
standalone `ffmpeg` executable and reads raw frames from its stdout
(`src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs`). SharpEmu searches
`SHARPEMU_FFMPEG_PATH`, the executable directory, its `ffmpeg` subdirectory,
and then `PATH` (plus a couple of common Homebrew paths on macOS). That
`ffmpeg` build must contain a Bink 2 decoder itself; a stock FFmpeg build that
only recognizes the Bink container is not sufficient. Most users want the
default `native` mode instead, which always has Bink 2 support since it's
built against `ffmpeg-core` specifically.
Bink 2 is proprietary. Obtain a compatible Mac Bink 2 SDK from RAD Game Tools,
then compile sharpemu_bink2_bridge.c against the SDK's bink.h and Mac library.
The adapter deliberately contains only a three-function C ABI so the managed
emulator never depends on RAD's private binary ABI.
## Supplying the FFmpeg libraries
Place the resulting libsharpemu_bink2_bridge.dylib next to the SharpEmu
executable, or point to it explicitly:
`dotnet publish` fetches a prebuilt release of `github.com/sharpemu/ffmpeg-core`
(the tag is pinned in `SharpEmu.CLI.csproj`'s `FfmpegRuntimeTag`, matched to
the `FFmpeg.AutoGen` package version in `Directory.Packages.props` -- both
need to agree on the same FFmpeg ABI) and copies its dynamically linked
libraries into a `plugins` folder next to the published executable. No C
toolchain is required to build SharpEmu; publishing just downloads a zip.
`plugins` is a loose, unpacked folder rather than something embedded in the
single-file bundle, so the OS loader can resolve the libraries' own
inter-dependencies (`avcodec` depends on `avutil`, etc.) itself.
SHARPEMU_BINK2_BRIDGE=/absolute/path/libsharpemu_bink2_bridge.dylib \
./SharpEmu /path/to/eboot.bin
A plain `dotnet publish` with no `-r` still works: it defaults to the host
machine's own RID (see `Directory.Build.props`), so it fetches the matching
`ffmpeg-core` archive and populates `plugins` without any extra flags.
Passing an explicit `-r <rid>` (e.g. to cross-publish `linux-x64` from
Windows) still overrides that default normally.
The expected exports are sharpemu_bink2_open_utf8,
sharpemu_bink2_decode_next_bgra, and sharpemu_bink2_close. The supplied
adapter opens one movie, exposes BGRA pixels, and advances after each decoded
frame. The managed side validates dimensions and retains ownership of the
destination buffer.
To use a different set of FFmpeg libraries, drop them into the published
`plugins` folder yourself (matching FFmpeg's own file-naming and versioning
conventions, e.g. `avformat-61.dll` / `libavformat.so.61` / matching
`.dylib`) -- `FfmpegNativeBinkFrameSource` points `ffmpeg.RootPath` at that
folder and does not otherwise care where the files came from.
If the bridge is absent in native mode, SharpEmu logs one informational line
and retains the existing guest rendering path.
If the libraries are absent or fail to load, `FfmpegNativeBinkFrameSource.TryOpen`
degrades gracefully: SharpEmu logs one informational line ("Bink2 bridge
could not open movie ...") and leaves the guest's own rendering path
untouched, rather than crashing.
+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();
```
+57
View File
@@ -0,0 +1,57 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
# Guest write watch
`GuestWriteWatch` is an optional diagnostic tool. It helps you find managed
code and HLE code that damage guest memory. The tool starts only if you set one
or more `SHARPEMU_WATCH_*` environment variables.
The tool monitors writes through the SharpEmu managed virtual-memory APIs. It
does not monitor stores that native guest code makes directly. Use a platform
debugger or a hardware watchpoint to monitor these stores.
## Watch modes
- `SHARPEMU_WATCH_WRITE=0x<address>` logs a write that overlaps the eight-byte
block at the specified guest address.
- `SHARPEMU_WATCH_POOL_HEADER=1` monitors the pointer at offset `0x40`. It
monitors the first 64 direct mappings that have a size of 64 KiB and
protection value `0xF2`.
- `SHARPEMU_WATCH_VALUE_PATTERN=1` logs an eight-byte write if its lower 32 bits
are `1`. The upper 32 bits must look like a small guest-pointer prefix.
- `SHARPEMU_WATCH_VALUE1=1` logs short writes of value `1` in the high guest
memory range. The tool logs a maximum of 128 entries for each process.
- `SHARPEMU_WATCH_BULK_TORN=1` scans aligned 64-bit words in bulk writes. It
finds damaged pointer patterns and byte-shifted pointer patterns. The tool
logs a maximum of 64 entries for each process.
- `SHARPEMU_WATCH_BULK_DEST_HI=0x<high-dword>` scans only writes that have the
specified upper 32 bits in the destination address.
For each match, the tool logs the destination address, the data pattern, and the
managed call stack. The log uses the `watch_write` or `watch_bulk_torn` warning
tag.
Use these variables together to scan bulk writes in the
`0x00000080xxxxxxxx` region.
macOS and Linux:
```sh
SHARPEMU_WATCH_BULK_TORN=1 \
SHARPEMU_WATCH_BULK_DEST_HI=0x80 \
SharpEmu /path/to/eboot.bin
```
Windows PowerShell:
```powershell
$env:SHARPEMU_WATCH_BULK_TORN = "1"
$env:SHARPEMU_WATCH_BULK_DEST_HI = "0x80"
& .\SharpEmu.exe C:\path\to\game\eboot.bin
```
To reduce unnecessary log entries, use an exact `SHARPEMU_WATCH_WRITE`
address from a crash dump.
+90
View File
@@ -0,0 +1,90 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
## Release Script
SharpEmu releases are prepared and published using `scripts/release.py`.
The release process consists of two steps:
1. Prepare the version bump through a pull request.
2. Create and push the release tag after the pull request has been merged.
### Preparing a Release
Run:
```bash
python scripts/release.py prepare 0.0.2-beta.2
```
This command will:
- Verify that the working tree is clean.
- Update the local `main` branch.
- Create a new branch named `release/0.0.2-beta.2`.
- Update `SharpEmuVersion` in `Directory.Build.props`.
- Create a version bump commit.
- Push the release branch to the remote repository.
Afterwards, open a pull request from:
```text
release/0.0.2-beta.2
```
into:
```text
main
```
### Publishing a Release
Once the pull request has been merged, update your local repository:
```bash
git switch main
git pull --ff-only
```
Then create and push the release tag:
```bash
python scripts/release.py tag 0.0.2-beta.2
```
This command will:
- Verify that the working tree is clean.
- Confirm that `Directory.Build.props` contains the requested version.
- Create an annotated Git tag (`v0.0.2-beta.2`).
- Push the tag to the remote repository.
Pushing the tag automatically triggers the GitHub Release workflow.
### Version Format
Specify the version **without** the `v` prefix.
Examples:
```text
0.0.2
0.0.2-alpha.1
0.0.2-beta.1
0.0.2-beta.2
0.0.2-rc.1
```
The script automatically prefixes the Git tag with `v`.
### Notes
- Run `prepare` only from the `main` branch.
- Run `tag` only after the version bump pull request has been merged.
- Do not create release tags manually before merging the version bump.
- Both commands require a clean working tree.
- The version in `Directory.Build.props` must exactly match the version passed to the `tag` command.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.103",
"rollForward": "disable"
"rollForward": "latestFeature"
}
}
@@ -1,51 +0,0 @@
/*
* Copyright (C) 2026 SharpEmu Emulator Project
* SPDX-License-Identifier: GPL-2.0-or-later
*
* Build this small adapter with a licensed RAD Bink 2 SDK. The SDK and its
* headers are not distributed by SharpEmu. See docs/bink2-bridge.md.
*/
#include <stdint.h>
#include "bink.h"
typedef struct sharpemu_bink2_info {
uint32_t width;
uint32_t height;
uint32_t frames_per_second_numerator;
uint32_t frames_per_second_denominator;
} sharpemu_bink2_info;
int sharpemu_bink2_open_utf8(const char *path, HBINK *movie, sharpemu_bink2_info *info) {
HBINK bink;
if (!path || !movie || !info) return 0;
bink = BinkOpen(path, 0);
if (!bink) return 0;
*movie = bink;
info->width = bink->Width;
info->height = bink->Height;
info->frames_per_second_numerator = bink->FrameRate;
info->frames_per_second_denominator = bink->FrameRateDiv;
return 1;
}
int sharpemu_bink2_decode_next_bgra(HBINK movie, uint8_t *destination,
uint32_t stride, uint32_t destination_bytes) {
uint64_t needed;
if (!movie || !destination || stride < movie->Width * 4) return 0;
needed = (uint64_t)stride * movie->Height;
if (needed > destination_bytes) return 0;
/* Async Bink I/O has not filled the next frame yet; retry on the next host present. */
if (BinkWait(movie)) return 0;
BinkDoFrame(movie);
BinkCopyToBuffer(movie, destination, stride, movie->Height, 0, 0, BINKSURFACE32RA);
BinkNextFrame(movie);
return 1;
}
void sharpemu_bink2_close(HBINK movie) {
if (movie) BinkClose(movie);
}
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
# Copyright (C) 2026 SharpEmu Emulator Project
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
import argparse
import base64
import hashlib
import re
import sys
from pathlib import Path
NID_SUFFIX = bytes.fromhex("518d64a635ded8c1e6b039b1c3e55230")
NID_PATTERN = re.compile(r"^[A-Za-z0-9+-]{11}$")
DEFAULT_NAMES_FILE = Path(__file__).resolve().with_name("ps5_names.txt")
DEFAULT_EXPORT_FILE = Path(__file__).resolve().parents[1] / "artifacts" / "aerolib.txt"
def compute_nid(export_name: str) -> str:
digest = hashlib.sha1(export_name.encode("utf-8") + NID_SUFFIX).digest()
encoded = base64.b64encode(digest[:8][::-1]).decode("ascii")
return encoded.rstrip("=").replace("/", "-")
def read_names(path: Path) -> list[str]:
try:
return [
line.strip()
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
except OSError as error:
raise SystemExit(f"Unable to read catalog '{path}': {error}") from error
def write_pair(nid: str, export_name: str) -> None:
print(f"{nid}\t{export_name}")
def lookup(args: argparse.Namespace) -> int:
value = args.value.strip()
if NID_PATTERN.fullmatch(value):
for export_name in read_names(args.names):
if compute_nid(export_name) == value:
write_pair(value, export_name)
return 0
print(f"NID not found in catalog: {value}", file=sys.stderr)
return 1
names = set(read_names(args.names))
write_pair(compute_nid(value), value)
if value not in names:
print("Warning: export name is not present in the catalog.", file=sys.stderr)
return 0
def search(args: argparse.Namespace) -> int:
names = read_names(args.names)
if args.regex:
try:
pattern = re.compile(args.query, 0 if args.case_sensitive else re.IGNORECASE)
except re.error as error:
print(f"Invalid regular expression: {error}", file=sys.stderr)
return 2
matches = (name for name in names if pattern.search(name))
elif args.case_sensitive:
matches = (name for name in names if args.query in name)
else:
query = args.query.casefold()
matches = (name for name in names if query in name.casefold())
count = 0
for export_name in matches:
write_pair(compute_nid(export_name), export_name)
count += 1
if args.limit and count >= args.limit:
break
if count == 0:
print(f"No catalog names matched: {args.query}", file=sys.stderr)
return 1
return 0
def export_catalog(args: argparse.Namespace) -> int:
pairs = [(compute_nid(name), name) for name in read_names(args.names)]
if args.sort == "nid":
pairs.sort(key=lambda pair: (pair[0], pair[1]))
elif args.sort == "name":
pairs.sort(key=lambda pair: pair[1])
args.output.parent.mkdir(parents=True, exist_ok=True)
try:
with args.output.open("w", encoding="utf-8", newline="\n") as output:
output.write("# NID\tExportName\n")
for nid, export_name in pairs:
output.write(f"{nid}\t{export_name}\n")
except OSError as error:
print(f"Unable to write catalog '{args.output}': {error}", file=sys.stderr)
return 1
print(f"Wrote {len(pairs)} entries to {args.output}")
return 0
def create_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Inspect the SharpEmu PS5 export-name/NID catalog.",
epilog=(
"Examples:\n"
" python scripts/aerolib_catalog.py lookup Zxa0VhQVTsk\n"
" python scripts/aerolib_catalog.py lookup sceKernelWaitSema\n"
" python scripts/aerolib_catalog.py search VideoOut --limit 20\n"
" python scripts/aerolib_catalog.py export"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--names",
type=Path,
default=DEFAULT_NAMES_FILE,
help=f"source name list (default: {DEFAULT_NAMES_FILE})",
)
subparsers = parser.add_subparsers(dest="command", required=True)
lookup_parser = subparsers.add_parser(
"lookup", help="resolve a NID or calculate the NID for an export name"
)
lookup_parser.add_argument("value", help="11-character NID or exact export name")
lookup_parser.set_defaults(handler=lookup)
search_parser = subparsers.add_parser(
"search", help="find export names and print matching NID/name pairs"
)
search_parser.add_argument("query", help="name substring or regular expression")
search_parser.add_argument(
"--limit", type=int, default=50, help="maximum matches; 0 means unlimited"
)
search_parser.add_argument(
"--case-sensitive", action="store_true", help="match case exactly"
)
search_parser.add_argument(
"--regex", action="store_true", help="treat the query as a regular expression"
)
search_parser.set_defaults(handler=search)
export_parser = subparsers.add_parser(
"export", help="write every NID/name pair to a tab-separated text file"
)
export_parser.add_argument(
"output",
type=Path,
nargs="?",
default=DEFAULT_EXPORT_FILE,
help=f"output file (default: {DEFAULT_EXPORT_FILE})",
)
export_parser.add_argument(
"--sort",
choices=("source", "nid", "name"),
default="nid",
help="output ordering (default: nid)",
)
export_parser.set_defaults(handler=export_catalog)
return parser
def main() -> int:
parser = create_parser()
args = parser.parse_args()
return args.handler(args)
if __name__ == "__main__":
raise SystemExit(main())
+408
View File
@@ -0,0 +1,408 @@
#!/usr/bin/env python3
# Copyright (C) 2026 SharpEmu Emulator Project
# SPDX-License-Identifier: GPL-2.0-or-later
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from pathlib import Path
VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$")
VERSION_ELEMENT_PATTERN = re.compile(
r"(<SharpEmuVersion>)([^<]+)(</SharpEmuVersion>)"
)
class ReleaseError(RuntimeError):
pass
def run_git(
*args: str,
cwd: Path,
capture_output: bool = False,
) -> str:
command = ["git", *args]
try:
result = subprocess.run(
command,
cwd=cwd,
check=True,
text=True,
capture_output=capture_output,
)
except FileNotFoundError:
raise ReleaseError("Git was not found in PATH.") from None
except subprocess.CalledProcessError as error:
stderr = error.stderr.strip() if error.stderr else ""
detail = f"\n{stderr}" if stderr else ""
raise ReleaseError(
f"Git command failed: {' '.join(command)}{detail}"
) from error
return result.stdout.strip() if capture_output else ""
def find_repository_root(script_path: Path) -> Path:
root = run_git(
"rev-parse",
"--show-toplevel",
cwd=script_path.resolve().parent,
capture_output=True,
)
return Path(root)
def get_status(repository_root: Path) -> str:
return run_git(
"status",
"--porcelain",
cwd=repository_root,
capture_output=True,
)
def ensure_clean_worktree(repository_root: Path) -> None:
status = get_status(repository_root)
if status:
raise ReleaseError(
"The working tree is not clean.\n\n"
f"{status}\n\n"
"Commit, stash, or remove these changes first."
)
def get_current_branch(repository_root: Path) -> str:
branch = run_git(
"branch",
"--show-current",
cwd=repository_root,
capture_output=True,
)
if not branch:
raise ReleaseError(
"HEAD is detached. Switch to a branch before continuing."
)
return branch
def ensure_branch_does_not_exist(
repository_root: Path,
branch: str,
remote: str,
) -> None:
local_branch = run_git(
"branch",
"--list",
branch,
cwd=repository_root,
capture_output=True,
)
if local_branch:
raise ReleaseError(f"Branch {branch} already exists locally.")
remote_branch = run_git(
"ls-remote",
"--heads",
remote,
branch,
cwd=repository_root,
capture_output=True,
)
if remote_branch:
raise ReleaseError(
f"Branch {branch} already exists on {remote}."
)
def ensure_tag_does_not_exist(
repository_root: Path,
tag: str,
remote: str,
) -> None:
local_tag = run_git(
"tag",
"--list",
tag,
cwd=repository_root,
capture_output=True,
)
if local_tag:
raise ReleaseError(f"Tag {tag} already exists locally.")
remote_tag = run_git(
"ls-remote",
"--tags",
remote,
f"refs/tags/{tag}",
cwd=repository_root,
capture_output=True,
)
if remote_tag:
raise ReleaseError(f"Tag {tag} already exists on {remote}.")
def read_version(props_path: Path) -> str:
if not props_path.exists():
raise ReleaseError(f"Version file not found: {props_path}")
content = props_path.read_text(encoding="utf-8")
match = VERSION_ELEMENT_PATTERN.search(content)
if match is None:
raise ReleaseError(
f"SharpEmuVersion was not found in {props_path.name}."
)
return match.group(2).strip()
def update_version(props_path: Path, version: str) -> str:
content = props_path.read_text(encoding="utf-8")
current_version = read_version(props_path)
if current_version == version:
raise ReleaseError(
f"SharpEmuVersion is already set to {version}."
)
updated_content, replacement_count = VERSION_ELEMENT_PATTERN.subn(
rf"\g<1>{version}\g<3>",
content,
count=1,
)
if replacement_count != 1:
raise ReleaseError(
"Expected exactly one SharpEmuVersion element."
)
props_path.write_text(
updated_content,
encoding="utf-8",
newline="\n",
)
return current_version
def prepare_release(
repository_root: Path,
props_path: Path,
version: str,
remote: str,
) -> None:
ensure_clean_worktree(repository_root)
current_branch = get_current_branch(repository_root)
if current_branch != "main":
raise ReleaseError(
f"Prepare must be run from main, not {current_branch}."
)
run_git(
"pull",
"--ff-only",
remote,
"main",
cwd=repository_root,
)
branch = f"release/{version}"
ensure_branch_does_not_exist(
repository_root,
branch,
remote,
)
previous_version = read_version(props_path)
run_git(
"switch",
"-c",
branch,
cwd=repository_root,
)
try:
update_version(props_path, version)
relative_props_path = props_path.relative_to(repository_root)
run_git(
"add",
relative_props_path.as_posix(),
cwd=repository_root,
)
run_git(
"commit",
"-m",
f"chore: bump version to {version}",
cwd=repository_root,
)
run_git(
"push",
"-u",
remote,
branch,
cwd=repository_root,
)
except Exception:
print(
"\nPrepare failed. The release branch may still exist locally.",
file=sys.stderr,
)
raise
print()
print(f"Prepared release {previous_version} -> {version}")
print(f"Branch pushed: {branch}")
print()
print("Open a pull request from:")
print(f" {branch}")
print("into:")
print(" main")
print()
print("After merging the PR, run:")
print(f" python scripts/release.py tag {version}")
def create_release_tag(
repository_root: Path,
props_path: Path,
version: str,
remote: str,
) -> None:
ensure_clean_worktree(repository_root)
current_branch = get_current_branch(repository_root)
if current_branch != "main":
raise ReleaseError(
f"Tagging must be run from main, not {current_branch}."
)
run_git(
"pull",
"--ff-only",
remote,
"main",
cwd=repository_root,
)
current_version = read_version(props_path)
if current_version != version:
raise ReleaseError(
"Version mismatch:\n"
f" Directory.Build.props: {current_version}\n"
f" Requested tag: {version}"
)
tag = f"v{version}"
ensure_tag_does_not_exist(
repository_root,
tag,
remote,
)
run_git(
"tag",
"-a",
tag,
"-m",
f"SharpEmu {version}",
cwd=repository_root,
)
run_git(
"push",
remote,
tag,
cwd=repository_root,
)
print()
print(f"Successfully pushed tag {tag}.")
print("The release workflow should start automatically.")
def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Prepare or tag a SharpEmu release."
)
subparsers = parser.add_subparsers(
dest="command",
required=True,
)
for command in ("prepare", "tag"):
subparser = subparsers.add_parser(command)
subparser.add_argument(
"version",
help="Version without the v prefix, e.g. 0.0.2-beta.2.",
)
subparser.add_argument(
"--remote",
default="origin",
help="Git remote. Default: origin.",
)
arguments = parser.parse_args()
if not VERSION_PATTERN.fullmatch(arguments.version):
parser.error(
"Version must look like 0.0.2, "
"0.0.2-beta.2, or 0.0.2-rc.1."
)
return arguments
def main() -> int:
arguments = parse_arguments()
try:
repository_root = find_repository_root(Path(__file__))
props_path = repository_root / "Directory.Build.props"
if arguments.command == "prepare":
prepare_release(
repository_root,
props_path,
arguments.version,
arguments.remote,
)
else:
create_release_tag(
repository_root,
props_path,
arguments.version,
arguments.remote,
)
except ReleaseError as error:
print(f"Error: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+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."
+228 -71
View File
@@ -45,11 +45,6 @@ internal static partial class Program
[STAThread]
private static int Main(string[] args)
{
// Avoid blocking full collections while guest and render threads are
// running, and establish the GC mode before the runtime reserves the
// fixed guest address-space window.
System.Runtime.GCSettings.LatencyMode = System.Runtime.GCLatencyMode.SustainedLowLatency;
try
{
return Run(args);
@@ -69,10 +64,10 @@ internal static partial class Program
}
args = NormalizeInternalArguments(args, out var isMitigatedChild);
if (args.Length == 0 && !isMitigatedChild)
PreloadGlfw();
if (args.Length == 0)
{
// No arguments: open the desktop frontend. Any argument selects
// the classic CLI behavior below.
return GuiLauncher.Run();
}
@@ -219,6 +214,27 @@ internal static partial class Program
"as libvulkan.1.dylib.");
}
/// <summary>
/// SharpEmu.CLI.csproj publishes glfw into a "plugins" subfolder rather
/// than flat next to the executable, which falls outside the default OS
/// DLL/dlopen search path. Preloading it here by full path first means
/// any later bare-name lookup (however Silk.NET/GLFW itself resolves the
/// library) finds it already loaded in the process and reuses it -- the
/// same technique <see cref="PreloadMacVulkanLoader"/> already relies on
/// for the Vulkan loader.
/// </summary>
private static void PreloadGlfw()
{
var fileName = OperatingSystem.IsWindows() ? "glfw3.dll"
: OperatingSystem.IsMacOS() ? "libglfw.3.dylib"
: "libglfw.so.3";
var candidate = Path.Combine(AppContext.BaseDirectory, "plugins", fileName);
if (File.Exists(candidate))
{
NativeLibrary.TryLoad(candidate, out _);
}
}
private static int RunEmulator(string[] args, bool isMitigatedChild)
{
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
@@ -228,7 +244,17 @@ internal static partial class Program
return childExitCode;
}
if (!TryParseArguments(args, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath))
if (!TryExtractHostSurfaceArgument(args, out var emulatorArgs, out var hostSurface, out var hostSurfaceError))
{
Console.Error.WriteLine($"[LOADER][ERROR] {hostSurfaceError}");
return 1;
}
HostSessionControl.SetEmbeddedHostSurface(
hostSurface?.WindowHandle ?? 0,
hostSurface?.DisplayHandle ?? 0);
if (!TryParseArguments(emulatorArgs, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath))
{
PrintUsage();
return 1;
@@ -253,68 +279,156 @@ internal static partial class Program
return 2;
}
Console.Error.WriteLine("[DEBUG] Creating runtime...");
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
OrbisGen2Result result;
ConsoleCancelEventHandler? cancelHandler = null;
try
if (!TryGetDebugServerOptions(args, out var debugServerEnabled, out var debugServerOptions, out var debugServerError))
{
cancelHandler = (_, eventArgs) =>
{
eventArgs.Cancel = true;
VideoOutExports.NotifyHostInterrupt();
};
Console.CancelKeyPress += cancelHandler;
Console.Error.WriteLine($"[DEBUG] Running: {ebootPath}");
result = runtime.Run(ebootPath);
Console.Error.WriteLine($"[DEBUG] Result: {result}");
Log.Error($"Invalid --debug-server endpoint: {debugServerError}");
return 1;
}
catch (Exception ex)
SharpEmu.Debugger.DebuggerServerHost? debugHost = null;
if (debugServerEnabled)
{
Console.Error.WriteLine($"[DEBUG] Exception: {ex}");
Log.Error("SharpEmu failed to run.", ex);
return 3;
}
finally
{
if (cancelHandler is not null)
debugHost = new SharpEmu.Debugger.DebuggerServerHost(debugServerOptions);
try
{
Console.CancelKeyPress -= cancelHandler;
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;
}
}
Log.Info($"SharpEmu execution completed. Result={result} (0x{(int)result:X8})");
if (!string.IsNullOrWhiteSpace(runtime.LastSessionSummary))
Console.Error.WriteLine("[DEBUG] Creating runtime...");
try
{
Log.Info(runtime.LastSessionSummary);
if (hostSurface is not null && !VulkanVideoHost.TryAttachSurface(hostSurface))
{
Console.Error.WriteLine("[LOADER][ERROR] The requested GUI host surface is already active.");
return 3;
}
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
OrbisGen2Result result;
ConsoleCancelEventHandler? cancelHandler = null;
try
{
cancelHandler = (_, eventArgs) =>
{
eventArgs.Cancel = true;
VideoOutExports.NotifyHostInterrupt();
};
Console.CancelKeyPress += cancelHandler;
Console.Error.WriteLine($"[DEBUG] Running: {ebootPath}");
result = runtime.Run(ebootPath);
Console.Error.WriteLine($"[DEBUG] Result: {result}");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DEBUG] Exception: {ex}");
Log.Error("SharpEmu failed to run.", ex);
return 3;
}
finally
{
if (cancelHandler is not null)
{
Console.CancelKeyPress -= cancelHandler;
}
}
Log.Info($"SharpEmu execution completed. Result={result} (0x{(int)result:X8})");
if (!string.IsNullOrWhiteSpace(runtime.LastSessionSummary))
{
Log.Info(runtime.LastSessionSummary);
}
if (!string.IsNullOrWhiteSpace(runtime.LastBasicBlockTrace))
{
Log.Info("BB trace:");
Log.Info(runtime.LastBasicBlockTrace);
}
if (!string.IsNullOrWhiteSpace(runtime.LastMilestoneLog))
{
Log.Info(runtime.LastMilestoneLog);
}
if (result != OrbisGen2Result.ORBIS_GEN2_OK && !string.IsNullOrWhiteSpace(runtime.LastExecutionDiagnostics))
{
Log.Warn(runtime.LastExecutionDiagnostics);
}
if (runtimeOptions.ImportTraceLimit > 0 && !string.IsNullOrWhiteSpace(runtime.LastExecutionTrace))
{
Log.Info("Import trace:");
Log.Info(runtime.LastExecutionTrace);
}
return result == OrbisGen2Result.ORBIS_GEN2_OK ? 0 : 4;
}
finally
{
if (debugHost is not null)
{
debugHost.NotifyRunCompleted();
debugHost.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
HostSessionControl.SetEmbeddedHostSurface(0);
if (hostSurface is not null)
{
VulkanVideoHost.RequestClose();
VulkanVideoHost.DetachSurface(hostSurface);
hostSurface.Dispose();
}
}
}
private static bool TryExtractHostSurfaceArgument(
IReadOnlyList<string> args,
out string[] emulatorArgs,
out VulkanHostSurface? hostSurface,
out string? error)
{
const string hostSurfacePrefix = "--host-surface=";
var remaining = new List<string>(args.Count);
hostSurface = null;
error = null;
foreach (var argument in args)
{
if (!argument.StartsWith(hostSurfacePrefix, StringComparison.OrdinalIgnoreCase))
{
remaining.Add(argument);
continue;
}
if (hostSurface is not null)
{
emulatorArgs = [];
error = "more than one GUI host surface was specified";
return false;
}
var descriptor = argument[hostSurfacePrefix.Length..];
if (!VulkanHostSurface.TryCreateChildProcessSurface(descriptor, out hostSurface, out error))
{
emulatorArgs = [];
return false;
}
}
if (!string.IsNullOrWhiteSpace(runtime.LastBasicBlockTrace))
{
Log.Info("BB trace:");
Log.Info(runtime.LastBasicBlockTrace);
}
if (!string.IsNullOrWhiteSpace(runtime.LastMilestoneLog))
{
Log.Info(runtime.LastMilestoneLog);
}
if (result != OrbisGen2Result.ORBIS_GEN2_OK && !string.IsNullOrWhiteSpace(runtime.LastExecutionDiagnostics))
{
Log.Warn(runtime.LastExecutionDiagnostics);
}
if (runtimeOptions.ImportTraceLimit > 0 && !string.IsNullOrWhiteSpace(runtime.LastExecutionTrace))
{
Log.Info("Import trace:");
Log.Info(runtime.LastExecutionTrace);
}
return result == OrbisGen2Result.ORBIS_GEN2_OK ? 0 : 4;
emulatorArgs = remaining.ToArray();
return true;
}
private static void EnsureCliConsole()
@@ -411,7 +525,9 @@ internal static partial class Program
return handle != 0 && handle != -1;
}
private static string[] NormalizeInternalArguments(string[] args, out bool isMitigatedChild)
private static string[] NormalizeInternalArguments(
string[] args,
out bool isMitigatedChild)
{
isMitigatedChild = false;
var trustedMitigatedChild = string.Equals(
@@ -457,12 +573,7 @@ internal static partial class Program
return false;
}
var childArgs = new string[args.Length + 1];
childArgs[0] = MitigatedChildFlag;
for (var i = 0; i < args.Length; i++)
{
childArgs[i + 1] = args[i];
}
string[] childArgs = [MitigatedChildFlag, .. args];
var commandLine = BuildCommandLine(processPath, childArgs);
var startupInfoEx = new STARTUPINFOEX();
@@ -513,7 +624,7 @@ internal static partial class Program
nint jobHandle = 0;
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
var created = CreateProcessW(
processPath,
null,
cmdLineBuilder,
0,
0,
@@ -931,8 +1042,45 @@ internal static partial class Program
private static void PrintUsage()
{
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--log-level=<level>] [--log-file[=<path>]] <path-to-eboot.bin>");
Log.Info("Usage: SharpEmu.CLI [--strict] [--trace-imports[=N]] [--cpu-engine=<native>] [--log-level=<level>] [--log-file[=<path>]] [--debug-server[=host:port]] <path-to-eboot.bin>");
Log.Info(@"Example: SharpEmu.CLI --cpu-engine=native --trace-imports=64 --log-level=debug --log-file ""E:\Games\...\eboot.bin""");
Log.Info("Debug server: --debug-server starts a live debug listener (default 127.0.0.1:5714); connect with SharpEmu.DebugClient.");
}
/// <summary>
/// Detects the <c>--debug-server</c> flag and parses its optional
/// <c>host:port</c> endpoint. Returns false only when the flag is present but
/// its endpoint is malformed, so the caller can abort with a clear error.
/// </summary>
private static bool TryGetDebugServerOptions(
string[] args,
out bool enabled,
out SharpEmu.Debugger.Server.DebuggerServerOptions options,
out string error)
{
enabled = false;
options = new SharpEmu.Debugger.Server.DebuggerServerOptions();
error = string.Empty;
foreach (var argument in args)
{
if (string.Equals(argument, "--debug-server", StringComparison.OrdinalIgnoreCase))
{
enabled = true;
continue;
}
const string prefix = "--debug-server=";
if (argument.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
enabled = true;
if (!SharpEmu.Debugger.Server.DebuggerServerOptions.TryParseEndpoint(argument[prefix.Length..], out options, out error))
{
return false;
}
}
}
return true;
}
private static bool TryParseArguments(
@@ -966,6 +1114,15 @@ internal static partial class Program
continue;
}
// The debug-server endpoint is parsed separately (see
// TryGetDebugServerOptions); accept the flag here so it is not
// rejected as an unknown option or mistaken for the eboot path.
if (string.Equals(argument, "--debug-server", StringComparison.OrdinalIgnoreCase) ||
argument.StartsWith("--debug-server=", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (string.Equals(argument, "--trace-imports", StringComparison.OrdinalIgnoreCase))
{
importTraceLimit = DefaultImportTraceLimit;
@@ -1293,7 +1450,7 @@ internal static partial class Program
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateProcessW(
string applicationName,
string? applicationName,
StringBuilder commandLine,
nint processAttributes,
nint threadAttributes,
+68 -4
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>
@@ -19,6 +20,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
<!-- osx-x64 is the macOS target: the CPU backend executes guest x86-64
natively, so on Apple Silicon it runs under Rosetta 2. -->
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
<!-- A plain "dotnet publish" with no -r defaults $(RuntimeIdentifier) to
the host's own RID; see Directory.Build.props, which is where that
default actually has to live (PublishDir's RID suffix is decided
there, evaluated before this file, so a default set only here would
be too late for it). -->
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
@@ -48,18 +54,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == ''">
<ApplicationIcon>..\..\assets\images\SharpEmu.ico</ApplicationIcon>
<Win32Icon>..\..\assets\images\SharpEmu.ico</Win32Icon>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<Content Include="..\..\LICENSE.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
@@ -73,17 +79,75 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Content>
</ItemGroup>
<!-- Keep glfw as a loose file next to the executable; every other native
<!-- Native libraries (glfw, FFmpeg) publish into a subfolder next to the
executable instead of sitting loose beside it, so the publish
directory stays uncluttered as more native deps get added. The folder
name is a fixed constant, not derived from the RID/architecture: each
publish output only ever holds one architecture's binaries anyway, so
varying the name added a class of bugs (RID resolution timing, host-OS
vs. target-RID mixups) for no benefit. Runtime code (Program.cs's
PreloadGlfw, FfmpegNativeBinkFrameSource's RootPath) uses the same
literal "plugins" folder name. -->
<PropertyGroup>
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
</PropertyGroup>
<!-- Keep glfw as a loose file in the native subfolder; every other native
library is embedded into the single-file bundle. -->
<Target Name="KeepGlfwOutsideSingleFile" AfterTargets="ComputeResolvedFilesToPublishList">
<ItemGroup>
<_GlfwPublishFiles Include="@(ResolvedFileToPublish)"
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
<ResolvedFileToPublish Remove="@(_GlfwPublishFiles)" />
<ResolvedFileToPublish Include="@(_GlfwPublishFiles)">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<RelativePath>$(NativeLibraryFolderName)/%(Filename)%(Extension)</RelativePath>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
<PropertyGroup>
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
<FfmpegRuntimeDir>
$(BaseIntermediateOutputPath)ffmpeg-runtime/$(FfmpegRuntimeTag)/$(RuntimeIdentifier)</FfmpegRuntimeDir>
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'win-x64'">ffmpeg-windows-x64.zip</FfmpegRuntimePackage>
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'linux-x64'">ffmpeg-linux-x64.zip</FfmpegRuntimePackage>
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'osx-x64'">ffmpeg-macos-x64.zip</FfmpegRuntimePackage>
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'osx-arm64'">ffmpeg-macos-arm64.zip</FfmpegRuntimePackage>
<FfmpegRuntimeArchive>$(FfmpegRuntimeDir)/$(FfmpegRuntimePackage)</FfmpegRuntimeArchive>
<FfmpegRuntimeExtractDir>$(FfmpegRuntimeDir)/extracted</FfmpegRuntimeExtractDir>
</PropertyGroup>
<Target Name="FetchFfmpegRuntime"
BeforeTargets="Publish"
Condition="'$(RuntimeIdentifier)' != '' And '$(FfmpegRuntimePackage)' != ''">
<DownloadFile
SourceUrl="https://github.com/sharpemu/ffmpeg-core/releases/download/$(FfmpegRuntimeTag)/$(FfmpegRuntimePackage)"
DestinationFolder="$(FfmpegRuntimeDir)"
Condition="!Exists('$(FfmpegRuntimeArchive)')" />
<Unzip
SourceFiles="$(FfmpegRuntimeArchive)"
DestinationFolder="$(FfmpegRuntimeExtractDir)"
Condition="!Exists('$(FfmpegRuntimeExtractDir)')" />
</Target>
<Target Name="PublishFfmpegRuntime"
AfterTargets="Publish"
DependsOnTargets="FetchFfmpegRuntime"
Condition="'$(RuntimeIdentifier)' != '' And '$(FfmpegRuntimePackage)' != ''">
<!-- Keyed off the target $(RuntimeIdentifier), not the host OS: publishing
e.g. linux-x64 from a Windows machine is a supported cross-publish,
and the extracted archive's own layout (bin/*.dll vs lib/*.so*) only
depends on which platform's ffmpeg-core package was fetched. -->
<ItemGroup>
<_FfmpegRuntimeFiles Condition="$(RuntimeIdentifier.StartsWith('win'))"
Include="$(FfmpegRuntimeExtractDir)/bin/*.dll" />
<_FfmpegRuntimeFiles Condition="!$(RuntimeIdentifier.StartsWith('win'))"
Include="$(FfmpegRuntimeExtractDir)/lib/*.so;$(FfmpegRuntimeExtractDir)/lib/*.so.*;$(FfmpegRuntimeExtractDir)/lib/*.dylib" />
</ItemGroup>
<Copy SourceFiles="@(_FfmpegRuntimeFiles)"
DestinationFolder="$(PublishDir)$(NativeLibraryFolderName)"
SkipUnchangedFiles="true" />
</Target>
</Project>
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="SharpEmu" />
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Required by Avalonia NativeControlHost on Windows 10 and 11. -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<asmv3:windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</asmv3:windowsSettings>
</asmv3:application>
</assembly>
+28
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;
@@ -693,6 +713,13 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
return _virtualMemory.TryWrite(address, buffer);
}
/// <summary>
/// True when the disposed native backend left its session state alive
/// because guest workers were still executing guest code. The guest
/// address space must then stay mapped as well.
/// </summary>
internal bool NativeSessionLeaked { get; private set; }
public void Dispose()
{
if (_nativeCpuBackend is IDisposable disposableBackend)
@@ -700,6 +727,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
disposableBackend.Dispose();
}
NativeSessionLeaked = _nativeCpuBackend is DirectExecutionBackend { GuestSessionLeaked: true };
_nativeCpuBackend = null;
}
}
+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);
}
@@ -0,0 +1,71 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Core.Cpu.Emulation;
/// <summary>
/// Pure software implementation of the bit-field math behind AMD's SSE4a EXTRQ/INSERTQ
/// (immediate-form) instructions.
///
/// The direct-execution backend runs guest PS5 code natively on the host CPU. The PS5's Zen 2
/// cores implement AMD-only SSE4a (EXTRQ/INSERTQ), but Intel hosts - and Rosetta 2 on Apple
/// Silicon - do not, so they raise #UD (STATUS_ILLEGAL_INSTRUCTION) instead of executing the
/// opcode. SharpEmu already rewrites one specific compiled EXTRQ+VPBLENDD idiom at load time
/// (see <see cref="Native.Sse4aExtrqBlendPatch"/>), but any other occurrence of EXTRQ/INSERTQ -
/// a different register allocation, a title built with a different compiler version, and so on
/// - still aborts the title. This class ported from Kyty's
/// <c>Loader::X64InstructionEmulator::TryEmulateSse4a</c> provides the general bit-field
/// extract/insert so the illegal-instruction handler can finish *any* immediate-form
/// EXTRQ/INSERTQ in software and resume, instead of relying on a single hard-coded byte pattern.
///
/// The methods operate on plain 64-bit integers rather than the OS CONTEXT record so the bit
/// math can be unit-tested in isolation; the unsafe CONTEXT/XMM plumbing lives in the backend
/// adapter (<see cref="Native.DirectExecutionBackend"/>).
/// </summary>
public static class Sse4aBitFieldEmulator
{
public static bool IsValidBitField(int length, int index)
{
var len = length & 0x3F;
var idx = index & 0x3F;
return (len != 0 || idx == 0) && (len == 0 ? idx == 0 : idx + len <= 64);
}
public static ulong ExtractBitField(ulong value, int length, int index)
{
var len = length & 0x3F;
var idx = index & 0x3F;
if (!IsValidBitField(length, index))
{
return 0;
}
if (len == 0)
{
return value;
}
var mask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
return (value >> idx) & mask;
}
public static ulong InsertBitField(ulong destination, ulong source, int length, int index)
{
var len = length & 0x3F;
var idx = index & 0x3F;
if (!IsValidBitField(length, index))
{
return destination;
}
if (len == 0)
{
return source;
}
var fieldMask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
var destinationClearMask = fieldMask << idx;
var sourceField = (source & fieldMask) << idx;
return (destination & ~destinationClearMask) | sourceField;
}
}
@@ -0,0 +1,199 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Threading;
using Iced.Intel;
using SharpEmu.Core.Cpu.Emulation;
namespace SharpEmu.Core.Cpu.Native;
// General software fallback for the AMD-only instructions PS5 titles occasionally emit that a
// Zen 2-only host implements but Intel hosts (and Rosetta 2 on Apple Silicon) do not:
// - SSE4a EXTRQ/INSERTQ, immediate form
// - MONITORX/MWAITX
//
// This is a direct port of Kyty's Loader::X64InstructionEmulator (TryEmulateSse4a /
// TryEmulateMonitorxMwaitx). SharpEmu already special-cases exactly one compiled EXTRQ+VPBLENDD
// byte sequence at load time (Sse4aExtrqBlendPatch), which only helps the one idiom it was
// reverse-engineered from. This file is a general, fault-time fallback that engages for any
// immediate-form EXTRQ/INSERTQ or MONITORX/MWAITX the narrower patch (or a title using a
// different compiler/register allocation) does not cover, complementing rather than replacing
// it: the load-time patch still avoids paying the fault-and-recover cost on the hot path it was
// built for, while this method is the safety net for everything else.
//
// This is deliberately additive: DirectExecutionBackend.IllegalInstruction.cs (the BMI1/BMI2/ABM
// fallback) is untouched, and this method is only reached from VectoredHandler after that one
// has already declined to handle the fault.
public sealed partial class DirectExecutionBackend
{
// Byte offset of Xmm0 within the Win64 CONTEXT record: FltSave (the XMM_SAVE_AREA32/FXSAVE
// image) starts right after Rip at offset 256, and XmmRegisters[0] sits 160 bytes into that
// area (32-byte header + 8 legacy x87/MMX slots x 16 bytes). 256 + 160 = 416 (0x1A0). Cross-
// checked against this file's own Win64ContextSize (0x4D0): rebuilding the whole CONTEXT
// layout field-by-field from offset 0 lands on the same 0x4D0 total, which would not happen
// if this offset (or anything before it) were wrong.
private const int Win64ContextXmm0Offset = 0x1A0;
private static int _sse4aSoftwareFallbackAnnounced;
private static long _sse4aInstructionsEmulated;
private static int _monitorxSoftwareFallbackAnnounced;
private static long _monitorxInstructionsEmulated;
private unsafe bool TryRecoverAmdCompatInstruction(void* contextRecord, ulong rip)
{
if (TryRecoverMonitorxMwaitx(contextRecord, rip))
{
return true;
}
// MONITORX/MWAITX above only ever reads guest code memory and rewrites RIP, both of
// which the POSIX signal bridge (DirectExecutionBackend.PosixSignals.cs) faithfully
// round-trips through the real ucontext, so it works on every supported OS. EXTRQ/
// INSERTQ additionally read and write an XMM register: on Windows contextRecord is the
// live CONTEXT the OS resumes the thread from, so touching the Xmm0.. slots is visible
// to the guest, and on Linux the bridge copies the mcontext's FXSAVE image into the
// Xmm0.. slots and writes them back through sigreturn (_posixXmmContextBridged). On
// Darwin the XMM area is still a zeroed scratch buffer - running this there would
// silently compute a result from stale bytes and then discard whatever it "wrote", so
// the recovery declines until that bridge exists.
return (OperatingSystem.IsWindows() || _posixXmmContextBridged) &&
TryRecoverSse4aExtractInsert(contextRecord, rip);
}
private unsafe bool TryRecoverMonitorxMwaitx(void* contextRecord, ulong rip)
{
// MONITORX (0F 01 FA) and MWAITX (0F 01 FB) are fixed 3-byte encodings with no
// ModRM/SIB/displacement/immediate, so a raw byte compare is sufficient and unambiguous.
var opcode = new byte[3];
if (!TryReadHostBytes(rip, opcode) ||
opcode[0] != 0x0F || opcode[1] != 0x01 || (opcode[2] != 0xFA && opcode[2] != 0xFB))
{
return false;
}
// PS5 titles use this pair in idle/wait loops: MONITORX arms a monitor on a cache line
// and MWAITX blocks until that line is written (or a timeout elapses). Hosts without
// the extension raise #UD on either one. We do not model the monitor itself, only its
// observable effect on guest forward progress: MONITORX becomes a no-op (arming a
// watch we never honour has no side effect of its own) and MWAITX becomes a plain
// thread yield, i.e. treat the awaited condition as already satisfied so the guest
// loop keeps making progress instead of executing an illegal opcode forever.
if (opcode[2] == 0xFB)
{
Thread.Yield();
}
WriteCtxU64(contextRecord, CTX_RIP, rip + 3);
Interlocked.Increment(ref _monitorxInstructionsEmulated);
if (Interlocked.Exchange(ref _monitorxSoftwareFallbackAnnounced, 1) == 0)
{
Console.Error.WriteLine(
"[LOADER][INFO] Host lacks AMD MONITORX/MWAITX used by the guest; " +
"emulating those instructions in software.");
}
return true;
}
private unsafe bool TryRecoverSse4aExtractInsert(void* contextRecord, ulong rip)
{
if (!OperatingSystem.IsWindows() && !_posixXmmContextBridged ||
!TryReadFaultingInstruction(rip, out var instruction))
{
return false;
}
var isExtrq = instruction.Mnemonic == Mnemonic.Extrq;
var isInsertq = instruction.Mnemonic == Mnemonic.Insertq;
if (!isExtrq && !isInsertq)
{
return false;
}
if (isExtrq && instruction.OpCount != 3 || isInsertq && instruction.OpCount != 4)
{
return false;
}
if (instruction.GetOpKind(0) != OpKind.Register ||
!TryGetXmmOffset(instruction.GetOpRegister(0), out var destOffset))
{
return false;
}
var destLow = ReadCtxU64(contextRecord, destOffset);
if (isExtrq)
{
var length = (int)instruction.GetImmediate(1);
var index = (int)instruction.GetImmediate(2);
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
{
return false;
}
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.ExtractBitField(destLow, length, index));
WriteCtxU64(contextRecord, destOffset + 8, 0);
}
else
{
if (instruction.GetOpKind(1) != OpKind.Register ||
!TryGetXmmOffset(instruction.GetOpRegister(1), out var srcOffset))
{
return false;
}
var length = (int)instruction.GetImmediate(2);
var index = (int)instruction.GetImmediate(3);
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
{
return false;
}
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.InsertBitField(
destLow, ReadCtxU64(contextRecord, srcOffset), length, index));
WriteCtxU64(contextRecord, destOffset + 8, 0);
}
WriteCtxU64(contextRecord, CTX_RIP, rip + (ulong)instruction.Length);
Interlocked.Increment(ref _sse4aInstructionsEmulated);
if (Interlocked.Exchange(ref _sse4aSoftwareFallbackAnnounced, 1) == 0)
{
Console.Error.WriteLine(
"[LOADER][INFO] Host lacks SSE4a EXTRQ/INSERTQ used by the guest; " +
"emulating those instructions in software.");
}
return true;
}
// Maps an Iced XMM register to its byte offset in the Win64 CONTEXT record. Written as an
// explicit switch (rather than arithmetic on the Register enum) to match the style already
// used by TryGetGprSlot/TryGetGpr64Offset in DirectExecutionBackend.IllegalInstruction.cs.
private static bool TryGetXmmOffset(Register register, out int offset)
{
switch (register)
{
case Register.XMM0: offset = Win64ContextXmm0Offset + 16 * 0; return true;
case Register.XMM1: offset = Win64ContextXmm0Offset + 16 * 1; return true;
case Register.XMM2: offset = Win64ContextXmm0Offset + 16 * 2; return true;
case Register.XMM3: offset = Win64ContextXmm0Offset + 16 * 3; return true;
case Register.XMM4: offset = Win64ContextXmm0Offset + 16 * 4; return true;
case Register.XMM5: offset = Win64ContextXmm0Offset + 16 * 5; return true;
case Register.XMM6: offset = Win64ContextXmm0Offset + 16 * 6; return true;
case Register.XMM7: offset = Win64ContextXmm0Offset + 16 * 7; return true;
case Register.XMM8: offset = Win64ContextXmm0Offset + 16 * 8; return true;
case Register.XMM9: offset = Win64ContextXmm0Offset + 16 * 9; return true;
case Register.XMM10: offset = Win64ContextXmm0Offset + 16 * 10; return true;
case Register.XMM11: offset = Win64ContextXmm0Offset + 16 * 11; return true;
case Register.XMM12: offset = Win64ContextXmm0Offset + 16 * 12; return true;
case Register.XMM13: offset = Win64ContextXmm0Offset + 16 * 13; return true;
case Register.XMM14: offset = Win64ContextXmm0Offset + 16 * 14; return true;
case Register.XMM15: offset = Win64ContextXmm0Offset + 16 * 15; return true;
default:
offset = 0;
return false;
}
}
}
@@ -133,6 +133,11 @@ public sealed partial class DirectExecutionBackend
{
return -1;
}
if (exceptionCode == StatusIllegalInstruction &&
TryRecoverAmdCompatInstruction(contextRecord, rip))
{
return -1;
}
if (IsBenignHostDebugException(exceptionCode))
{
return -1;
@@ -478,7 +483,7 @@ public sealed partial class DirectExecutionBackend
if (count <= 16 || count % 65536 == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (SHARPEMU_IGNORE_INT41=1)");
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (default-on; set SHARPEMU_IGNORE_INT41=0 to disable)");
Console.Error.Flush();
}
return true;
@@ -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;
@@ -525,9 +530,12 @@ public sealed partial class DirectExecutionBackend
{
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
}
DeliverPendingGuestExceptionAtSafePoint(
cpuContext,
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
{
DeliverPendingGuestExceptionAtSafePoint(
cpuContext,
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
}
StoreImportVectorReturn(cpuContext, argPackPtr);
if (dispatchResolved &&
orbisGen2Result == OrbisGen2Result.ORBIS_GEN2_OK &&
@@ -1321,9 +1329,12 @@ public sealed partial class DirectExecutionBackend
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
}
}
DeliverPendingGuestExceptionAtSafePoint(
cpuContext,
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
{
DeliverPendingGuestExceptionAtSafePoint(
cpuContext,
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
}
StoreImportVectorReturn(cpuContext, argPackPtr);
if (returnValue != (int)OrbisGen2Result.ORBIS_GEN2_OK)
@@ -1345,8 +1356,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 +1367,7 @@ public sealed partial class DirectExecutionBackend
GuestThreadExecution.CurrentGuestThreadHandle,
blockContinuation,
blockWakeKey,
blockResumeHandler,
blockWakeHandler,
blockWaiter,
blockDeadlineTimestamp);
}
@@ -1395,11 +1404,13 @@ public sealed partial class DirectExecutionBackend
"vWU-odnS+fU" or // sceAmprMeasureCommandSizeReadFile
"sSAUCCU1dv4" or // sceAmprMeasureCommandSizeWriteKernelEventQueue_04_00
"C+IEj+BsAFM" or // sceAmprMeasureCommandSizeWriteAddressOnCompletion
"4fgtGfXDrFc" or // sceAmprMeasureCommandSizeWriteAddress_04_00
"tZDDEo2tE5k" or // sceAmprCommandBufferGetSize
"GnxKOHEawhk" or // sceAmprCommandBufferGetCurrentOffset
"gzndltBEzWc" or // sceAmprCommandBufferGetNumCommands
"H896Pt-yB4I" or // sceAmprCommandBufferWriteKernelEventQueue_04_00
"sJXyWHjP-F8" or // sceAmprCommandBufferWriteAddressOnCompletion
"j0+3uJMxYJY" or // sceAmprCommandBufferWriteAddress_04_00
"mPpPxv5CZt4" or // sceSystemServiceGetHdrToneMapLuminance
"1FZBKy8HeNU" or // sceVideoOutGetVblankStatus
"ASoW5WE-UPo" or // sceKernelAprSubmitCommandBufferAndGetResult
@@ -1407,6 +1418,8 @@ public sealed partial class DirectExecutionBackend
"eE4Szl8sil8" or // sceKernelAprSubmitCommandBuffer
"qvMUCyyaCSI" or // sceKernelAprSubmitCommandBufferAndGetId
"Q2V+iqvjgC0" or // vsnprintf
"AV6ipCNa4Rw" or // strcasecmp
"viiwFMaNamA" or // strstr
"q1cHNfGycLI" or // scePadRead
"xk0AcarP3V4" or // scePadOpen
"yH17Q6NWtVg" or // sceUserServiceGetEvent
@@ -1433,6 +1446,9 @@ public sealed partial class DirectExecutionBackend
var expectedMutexTrylockBusy =
string.Equals(nid, "K-jXhbt2gn4", StringComparison.Ordinal) &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
var expectedSemaphoreTrywaitAgain =
string.Equals(nid, "H2a+IN9TP0E", StringComparison.Ordinal) &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN;
var expectedNetAcceptWouldBlock =
string.Equals(nid, "PIWqhn9oSxc", StringComparison.Ordinal) &&
resultValue == unchecked((int)0x80410123);
@@ -1446,6 +1462,7 @@ public sealed partial class DirectExecutionBackend
!expectedTimedWaitTimeout &&
!expectedEqueueTimeout &&
!expectedMutexTrylockBusy &&
!expectedSemaphoreTrywaitAgain &&
!expectedNetAcceptWouldBlock &&
!expectedUserServiceNoEvent &&
!expectedPrivacyInvalidParameter)
@@ -1539,11 +1556,13 @@ public sealed partial class DirectExecutionBackend
"vWU-odnS+fU" or
"sSAUCCU1dv4" or
"C+IEj+BsAFM" or
"4fgtGfXDrFc" or
"tZDDEo2tE5k" or
"GnxKOHEawhk" or
"gzndltBEzWc" or
"H896Pt-yB4I" or
"sJXyWHjP-F8" or
"j0+3uJMxYJY" or
"mPpPxv5CZt4" or
"1FZBKy8HeNU" or
"ASoW5WE-UPo" or
@@ -1568,6 +1587,8 @@ public sealed partial class DirectExecutionBackend
"WkkeywLJcgU" or // wcslen
"Ovb2dSJOAuE" or // strcmp
"aesyjrHVWy4" or // strncmp
"AV6ipCNa4Rw" or // strcasecmp
"viiwFMaNamA" or // strstr
"pNtJdE3x49E" or // wcscmp
"fV2xHER+bKE" or // wcscoll
"E8wCoUEbfzk" or // wcsncmp
@@ -50,6 +50,19 @@ public sealed unsafe partial class DirectExecutionBackend
private const int LinuxUcontextGregsOffset = 40;
private const int LinuxGregsErrOffset = 19 * 8;
// The kernel's x86-64 sigcontext places the FXSAVE-image pointer right
// after the general registers it hands to the handler: err(152)
// trapno(160) oldmask(168) cr2(176) fpstate(184), all relative to
// GetPosixRegisterBase. glibc and musl both overlay this kernel layout
// verbatim (glibc's mcontext_t.fpregs is the same slot), so the offset
// is libc-independent. Inside the FXSAVE image the XMM registers start
// at +160 (32-byte header + 8 legacy x87/MMX slots x 16 bytes) - the
// same relative position they occupy in the Win64 CONTEXT's FltSave
// area (Win64ContextXmm0Offset = 256 + 160).
private const int LinuxGregsFpstateOffset = 184;
private const int FxsaveXmmOffset = 160;
private const int XmmBlockSize = 16 * 16;
// Byte offsets of the general registers relative to GetPosixRegisterBase,
// ordered to match the contiguous Win64 CONTEXT block CTX_RAX..CTX_RIP
// (rax, rcx, rdx, rbx, rsp, rbp, rsi, rdi, r8..r15, rip). Verified
@@ -71,6 +84,15 @@ public sealed unsafe partial class DirectExecutionBackend
[ThreadStatic]
private static int _posixSignalHandlerDepth;
// True while the current thread's in-flight POSIX fault carries the real
// XMM registers in the CONTEXT scratch buffer and writes to them will
// reach the mcontext on resume. Gates recovery paths (SSE4a EXTRQ/
// INSERTQ) that would otherwise compute results from a zeroed XMM area
// and silently discard what they "wrote". Darwin is not bridged yet, so
// the flag stays false there.
[ThreadStatic]
private static bool _posixXmmContextBridged;
private void SetupPosixExceptionHandler()
{
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_POSIX_SIGNALS"), "1", StringComparison.Ordinal))
@@ -252,6 +274,26 @@ public sealed unsafe partial class DirectExecutionBackend
WriteCtxU64(contextRecord, CTX_RAX + i * 8, *(ulong*)(registers + offsets[i]));
}
// Bridge the XMM registers alongside the GPRs where the layout is
// known: on Linux the fpstate pointer and FXSAVE image are kernel
// ABI, so recovery paths that read or write XMM state (SSE4a
// EXTRQ/INSERTQ) see the live registers and their writes reach the
// guest through sigreturn.
byte* fpstate = null;
if (OperatingSystem.IsLinux())
{
fpstate = *(byte**)(registers + LinuxGregsFpstateOffset);
if (fpstate != null)
{
Buffer.MemoryCopy(
fpstate + FxsaveXmmOffset,
contextRecord + Win64ContextXmm0Offset,
XmmBlockSize,
XmmBlockSize);
}
}
_posixXmmContextBridged = fpstate != null;
EXCEPTION_RECORD record = default;
record.ExceptionAddress = (void*)ReadCtxU64(contextRecord, CTX_RIP);
if (signal == PosixSigIll)
@@ -317,6 +359,14 @@ public sealed unsafe partial class DirectExecutionBackend
{
*(ulong*)(registers + offsets[i]) = ReadCtxU64(contextRecord, CTX_RAX + i * 8);
}
if (fpstate != null)
{
Buffer.MemoryCopy(
contextRecord + Win64ContextXmm0Offset,
fpstate + FxsaveXmmOffset,
XmmBlockSize,
XmmBlockSize);
}
return true;
}
@@ -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;
@@ -710,6 +712,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private readonly Dictionary<ulong, PendingGuestException> _pendingGuestExceptions = new Dictionary<ulong, PendingGuestException>();
// Import dispatch is the hottest managed path in UE titles. Most imports do
// not have an exception queued, so publish the dictionary population and let
// safe points skip _guestThreadGate entirely in the common case.
private int _pendingGuestExceptionCount;
private readonly HashSet<ulong> _activeGuestExceptionDeliveries = new HashSet<ulong>();
private int _guestThreadPumpDepth;
@@ -718,7 +725,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private string? _guestThreadYieldReason;
private bool _forcedGuestExit;
private volatile bool _forcedGuestExit;
private ulong _lastAvTraceRip;
@@ -894,6 +901,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
@@ -917,7 +967,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private bool ActiveForcedGuestExit
{
get => HasActiveExecutionThread ? _activeForcedGuestExit : _forcedGuestExit;
// Host shutdown is requested from a UI or VideoOut thread. Native guest
// workers have their own thread-local execution state, so they must also
// observe the backend-wide shutdown flag.
get => _forcedGuestExit || (HasActiveExecutionThread && _activeForcedGuestExit);
set
{
if (HasActiveExecutionThread)
@@ -1052,6 +1105,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);
@@ -1069,7 +1123,9 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
_logStrlenBursts = _logStrlenImports ||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_STRLEN_BURSTS"), "1", StringComparison.Ordinal);
_logGuestContext = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_CONTEXT"), "1", StringComparison.Ordinal);
_ignoreGuestInt41 = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_IGNORE_INT41"), "1", StringComparison.Ordinal);
var ignoreGuestInt41Env = Environment.GetEnvironmentVariable("SHARPEMU_IGNORE_INT41");
_ignoreGuestInt41 = !string.Equals(ignoreGuestInt41Env, "0", StringComparison.Ordinal) &&
!string.Equals(ignoreGuestInt41Env, "false", StringComparison.OrdinalIgnoreCase);
_ignoredGuestInt41Count = 0;
_logGuestThreads = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_GUEST_THREADS"), "1", StringComparison.Ordinal);
_logUsleep = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_USLEEP"), "1", StringComparison.Ordinal);
@@ -1242,6 +1298,12 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private unsafe bool TryCreateNativeImportIntrinsic(string nid, out nint address)
{
if (IsHlePreferredNid(nid))
{
address = 0;
return false;
}
if (nid == "1jfXLRVzisc" &&
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_USLEEP"), "1", StringComparison.Ordinal))
{
@@ -1353,6 +1415,54 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
0x75, 0xE7,
0xC3,
],
"AV6ipCNa4Rw" =>
[
0x0F, 0xB6, 0x07,
0x0F, 0xB6, 0x16,
0x8D, 0x48, 0xBF,
0x83, 0xF9, 0x19,
0x77, 0x03,
0x83, 0xC0, 0x20,
0x8D, 0x4A, 0xBF,
0x83, 0xF9, 0x19,
0x77, 0x03,
0x83, 0xC2, 0x20,
0x29, 0xD0,
0x75, 0x0C,
0x85, 0xD2,
0x74, 0x08,
0x48, 0xFF, 0xC7,
0x48, 0xFF, 0xC6,
0xEB, 0xD4,
0xC3,
],
"viiwFMaNamA" =>
[
0x0F, 0xB6, 0x16,
0x84, 0xD2,
0x74, 0x2D,
0x0F, 0xB6, 0x07,
0x84, 0xC0,
0x74, 0x2A,
0x38, 0xD0,
0x75, 0x1D,
0x4C, 0x8D, 0x47, 0x01,
0x4C, 0x8D, 0x4E, 0x01,
0x41, 0x0F, 0xB6, 0x09,
0x84, 0xC9,
0x74, 0x12,
0x41, 0x38, 0x08,
0x75, 0x08,
0x49, 0xFF, 0xC0,
0x49, 0xFF, 0xC1,
0xEB, 0xEB,
0x48, 0xFF, 0xC7,
0xEB, 0xD3,
0x48, 0x89, 0xF8,
0xC3,
0x31, 0xC0,
0xC3,
],
"pNtJdE3x49E" or "fV2xHER+bKE" =>
[
0x0F, 0xB7, 0x07,
@@ -1417,8 +1527,14 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
"Q3VBxCXhUHs" =>
[
0x48, 0x89, 0xF8,
0x48, 0x89, 0xD1,
0xF3, 0xA4,
0x48, 0x85, 0xD2,
0x74, 0x11,
0x44, 0x8A, 0x06,
0x44, 0x88, 0x07,
0x48, 0xFF, 0xC6,
0x48, 0xFF, 0xC7,
0x48, 0xFF, 0xCA,
0x75, 0xEF,
0xC3,
],
"8zTFvBIAIN8" =>
@@ -1552,7 +1668,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private static bool IsHlePreferredNid(string nid)
{
return string.Equals(nid, "QrZZdJ8XsX0", StringComparison.Ordinal);
return string.Equals(nid, "QrZZdJ8XsX0", StringComparison.Ordinal) ||
string.Equals(nid, "Q3VBxCXhUHs", StringComparison.Ordinal);
}
private static bool IsLibcLibrary(string libraryName)
@@ -2492,28 +2609,22 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private unsafe bool TryPatchSse4aExtrqBlend(nint address, byte* source)
{
// Rosetta does not implement AMD SSE4a EXTRQ. This exact sequence masks
// xmm2 to its low 40 bits, then copies the resulting second dword into
// xmm0. PEXTRB/PINSRD provides the same observable result in 12 bytes:
// extract source byte 4 and insert the zero-extended value into lane 1.
ReadOnlySpan<byte> pattern =
[
0x66, 0x0F, 0x78, 0xC2, 0x28, 0x00,
0xC4, 0xE3, 0x79, 0x02, 0xC2, 0x02,
];
for (var i = 0; i < pattern.Length; i++)
// Rosetta does not implement AMD SSE4a EXTRQ. Recognize the compiler's
// EXTRQ+blend idiom (against whichever xmm0-xmm7 it allocated) and rewrite
// it into an equivalent SSE4.1 sequence. Match/encode is isolated in
// Sse4aExtrqBlendPatch so it can be unit-tested; here we only patch bytes.
var window = new ReadOnlySpan<byte>(source, Sse4aExtrqBlendPatch.SequenceLength);
if (!Sse4aExtrqBlendPatch.TryMatch(window, out var destRegister, out var srcRegister))
{
if (source[i] != pattern[i])
{
return false;
}
return false;
}
Span<byte> replacement = stackalloc byte[Sse4aExtrqBlendPatch.SequenceLength];
if (!Sse4aExtrqBlendPatch.TryEncode(destRegister, srcRegister, replacement))
{
return false;
}
ReadOnlySpan<byte> replacement =
[
0x66, 0x0F, 0x3A, 0x14, 0xD0, 0x04,
0x66, 0x0F, 0x3A, 0x22, 0xC0, 0x01,
];
uint oldProtect = 0;
if (!VirtualProtect((void*)address, (nuint)replacement.Length, 64u, &oldProtect))
{
@@ -3212,43 +3323,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",
@@ -3409,6 +3483,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{
returnValue = 0;
error = null;
if (_forcedGuestExit)
{
error = "guest execution is shutting down";
return false;
}
if (entryPoint < 65536)
{
error = $"invalid guest callback entry=0x{entryPoint:X16}";
@@ -3559,11 +3638,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;
}
}
@@ -3575,7 +3653,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
}
GuestCpuContinuation continuation = default;
Func<int>? resumeHandler = null;
IGuestThreadBlockWaiter? blockWaiter = null;
while (!ActiveForcedGuestExit)
{
WakeExpiredBlockedGuestThreads();
@@ -3596,9 +3674,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;
@@ -3621,9 +3698,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)
{
@@ -3655,6 +3732,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
out string? error)
{
error = null;
if (_forcedGuestExit)
{
error = "guest execution is shutting down";
return false;
}
if (continuation.Rip < 65536 || continuation.Rsp == 0)
{
error = $"invalid guest continuation rip=0x{continuation.Rip:X16} rsp=0x{continuation.Rsp:X16}";
@@ -3831,8 +3913,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)
@@ -3874,10 +3955,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
// unwinding. Unity can begin its next stop-the-world cycle in
// that window; treating the new raise as part of the old delivery
// strands the collector waiting for an acknowledgement.
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
handler,
exceptionType,
external.ExceptionStackBase);
external.ExceptionStackBase));
return true;
}
@@ -3886,10 +3967,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
// managed thread corrupts the worker's control state. Queue the
// request and let that exact executor consume it at its next HLE
// boundary, where the original guest thread is safely paused.
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
handler,
exceptionType,
external.ExceptionStackBase);
external.ExceptionStackBase));
if (logGuestExceptions)
{
Console.Error.WriteLine(
@@ -3934,17 +4015,17 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
}
if (target.ExceptionDeliveryActive)
{
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
handler,
exceptionType,
exceptionStackBase);
exceptionStackBase));
return true;
}
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
handler,
exceptionType,
exceptionStackBase);
exceptionStackBase));
if (logGuestExceptions)
{
Console.Error.WriteLine(
@@ -3968,8 +4049,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;
@@ -3979,8 +4059,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;
}
@@ -4028,8 +4107,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
@@ -4039,12 +4117,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);
@@ -4109,7 +4186,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
RestoreInterruptedGuestThread();
if (target.State == GuestThreadRunState.Blocked &&
!target.ExecutorActive &&
_pendingGuestExceptions.Remove(threadHandle, out var queued))
TryRemovePendingGuestExceptionLocked(threadHandle, out var queued))
{
followUp = queued;
}
@@ -4195,6 +4272,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
CpuContext currentContext,
GuestCpuContinuation interruptedContinuation)
{
if (Volatile.Read(ref _pendingGuestExceptionCount) == 0)
{
return;
}
var threadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
if (threadHandle == 0)
{
@@ -4208,7 +4290,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return;
}
if (!_pendingGuestExceptions.Remove(threadHandle, out pending))
if (!TryRemovePendingGuestExceptionLocked(threadHandle, out pending))
{
return;
}
@@ -4270,6 +4352,27 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
}
}
private void QueuePendingGuestExceptionLocked(
ulong threadHandle,
PendingGuestException pending)
{
_pendingGuestExceptions[threadHandle] = pending;
Volatile.Write(ref _pendingGuestExceptionCount, _pendingGuestExceptions.Count);
}
private bool TryRemovePendingGuestExceptionLocked(
ulong threadHandle,
out PendingGuestException pending)
{
if (!_pendingGuestExceptions.Remove(threadHandle, out pending))
{
return false;
}
Volatile.Write(ref _pendingGuestExceptionCount, _pendingGuestExceptions.Count);
return true;
}
private static bool TryWriteGuestExceptionContext(
CpuContext context,
ulong address,
@@ -4364,6 +4467,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
_guestThreads.Clear();
_externalGuestThreads.Clear();
_pendingGuestExceptions.Clear();
Volatile.Write(ref _pendingGuestExceptionCount, 0);
_activeGuestExceptionDeliveries.Clear();
}
@@ -4685,6 +4789,21 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private void RunGuestThread(GuestThreadState thread, string reason)
{
if (_forcedGuestExit)
{
// Host shutdown: never enter guest code again. Teardown is about to
// free trampolines and the guest address space, and it only waits
// for executors that are already inside a slice.
lock (_guestThreadGate)
{
thread.State = GuestThreadRunState.Faulted;
thread.BlockReason = "host shutdown";
thread.HostThread = null;
thread.ExecutorActive = false;
}
return;
}
lock (_guestThreadGate)
{
if (!thread.ExecutorActive)
@@ -5127,64 +5246,51 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
BindTlsBase(context);
byte* ptr2 = (byte*)ptr;
ulong hostRspSlot = (ulong)hostRspStorage;
int offset = 0;
var emitter = new NativeCodeEmitter(ptr2);
void Emit(byte value) => ptr2[offset++] = value;
void EmitU64(ulong value)
{
*(ulong*)(ptr2 + offset) = value;
offset += sizeof(ulong);
}
void EmitMovR64Imm(byte rex, byte opcode, ulong value)
{
Emit(rex);
Emit(opcode);
EmitU64(value);
}
Emit(0x53); // push rbx
Emit(0x55); // push rbp
Emit(0x57); // push rdi
Emit(0x56); // push rsi
Emit(0x41); Emit(0x54); // push r12
Emit(0x41); Emit(0x55); // push r13
Emit(0x41); Emit(0x56); // push r14
Emit(0x41); Emit(0x57); // push r15
EmitHostNonvolatileXmmSave(ptr2, ref offset);
emitter.Emit(0x53); // push rbx
emitter.Emit(0x55); // push rbp
emitter.Emit(0x57); // push rdi
emitter.Emit(0x56); // push rsi
emitter.Emit(0x41); emitter.Emit(0x54); // push r12
emitter.Emit(0x41); emitter.Emit(0x55); // push r13
emitter.Emit(0x41); emitter.Emit(0x56); // push r14
emitter.Emit(0x41); emitter.Emit(0x57); // push r15
EmitHostNonvolatileXmmSave(ptr2, ref emitter.Offset);
// Restore the fiber's floating-point control environment before
// abandoning the host stack. This path is used when a blocked guest
// continuation migrates to another managed worker.
Emit(0x48); Emit(0x83); Emit(0xEC); Emit(0x08); // sub rsp,8
Emit(0xC7); Emit(0x04); Emit(0x24); // mov dword [rsp],imm32
*(uint*)(ptr2 + offset) = context.Mxcsr; offset += sizeof(uint);
Emit(0x0F); Emit(0xAE); Emit(0x14); Emit(0x24); // ldmxcsr [rsp]
Emit(0x66); Emit(0xC7); Emit(0x04); Emit(0x24); // mov word [rsp],imm16
*(ushort*)(ptr2 + offset) = context.FpuControlWord; offset += sizeof(ushort);
Emit(0xD9); Emit(0x2C); Emit(0x24); // fldcw [rsp]
Emit(0x48); Emit(0x83); Emit(0xC4); Emit(0x08); // add rsp,8
EmitMovR64Imm(0x49, 0xBA, hostRspSlot); // mov r10, hostRspSlot
Emit(0x49); Emit(0x89); Emit(0x22); // mov [r10], rsp
EmitMovR64Imm(0x48, 0xB8, context[CpuRegister.Rsp]); // mov rax, guest rsp
Emit(0x48); Emit(0x89); Emit(0xC4); // mov rsp, rax
Emit(0x48); Emit(0x83); Emit(0xEC); Emit(0x08); // reserve transfer slot
EmitMovR64Imm(0x48, 0xB8, entryPoint); // mov rax, entryPoint
Emit(0x48); Emit(0x89); Emit(0x04); Emit(0x24); // mov [rsp],rax
EmitMovR64Imm(0x48, 0xBB, context[CpuRegister.Rbx]); // mov rbx, imm64
EmitMovR64Imm(0x48, 0xBD, context[CpuRegister.Rbp]); // mov rbp, imm64
EmitMovR64Imm(0x48, 0xBF, context[CpuRegister.Rdi]); // mov rdi, imm64
EmitMovR64Imm(0x48, 0xBE, context[CpuRegister.Rsi]); // mov rsi, imm64
EmitMovR64Imm(0x48, 0xBA, context[CpuRegister.Rdx]); // mov rdx, imm64
EmitMovR64Imm(0x48, 0xB9, context[CpuRegister.Rcx]); // mov rcx, imm64
EmitMovR64Imm(0x49, 0xB8, context[CpuRegister.R8]); // mov r8, imm64
EmitMovR64Imm(0x49, 0xB9, context[CpuRegister.R9]); // mov r9, imm64
EmitMovR64Imm(0x49, 0xBA, context[CpuRegister.R10]); // mov r10, imm64
EmitMovR64Imm(0x49, 0xBC, context[CpuRegister.R12]); // mov r12, imm64
EmitMovR64Imm(0x49, 0xBD, context[CpuRegister.R13]); // mov r13, imm64
EmitMovR64Imm(0x49, 0xBE, context[CpuRegister.R14]); // mov r14, imm64
EmitMovR64Imm(0x49, 0xBF, context[CpuRegister.R15]); // mov r15, imm64
EmitMovR64Imm(0x49, 0xBB, context[CpuRegister.R11]); // mov r11, imm64
EmitMovR64Imm(0x48, 0xB8, context[CpuRegister.Rax]); // mov rax, imm64
Emit(0xC3); // ret through the synthetic transfer slot
emitter.Emit(0x48); emitter.Emit(0x83); emitter.Emit(0xEC); emitter.Emit(0x08); // sub rsp,8
emitter.Emit(0xC7); emitter.Emit(0x04); emitter.Emit(0x24); // mov dword [rsp],imm32
emitter.Emit(context.Mxcsr);
emitter.Emit(0x0F); emitter.Emit(0xAE); emitter.Emit(0x14); emitter.Emit(0x24); // ldmxcsr [rsp]
emitter.Emit(0x66); emitter.Emit(0xC7); emitter.Emit(0x04); emitter.Emit(0x24); // mov word [rsp],imm16
emitter.Emit(context.FpuControlWord);
emitter.Emit(0xD9); emitter.Emit(0x2C); emitter.Emit(0x24); // fldcw [rsp]
emitter.Emit(0x48); emitter.Emit(0x83); emitter.Emit(0xC4); emitter.Emit(0x08); // add rsp,8
emitter.EmitMovR64Immediate(0x49, 0xBA, hostRspSlot); // mov r10, hostRspSlot
emitter.Emit(0x49); emitter.Emit(0x89); emitter.Emit(0x22); // mov [r10], rsp
emitter.EmitMovR64Immediate(0x48, 0xB8, context[CpuRegister.Rsp]); // mov rax, guest rsp
emitter.Emit(0x48); emitter.Emit(0x89); emitter.Emit(0xC4); // mov rsp, rax
emitter.Emit(0x48); emitter.Emit(0x83); emitter.Emit(0xEC); emitter.Emit(0x08); // reserve transfer slot
emitter.EmitMovR64Immediate(0x48, 0xB8, entryPoint); // mov rax, entryPoint
emitter.Emit(0x48); emitter.Emit(0x89); emitter.Emit(0x04); emitter.Emit(0x24); // mov [rsp],rax
emitter.EmitMovR64Immediate(0x48, 0xBB, context[CpuRegister.Rbx]); // mov rbx, imm64
emitter.EmitMovR64Immediate(0x48, 0xBD, context[CpuRegister.Rbp]); // mov rbp, imm64
emitter.EmitMovR64Immediate(0x48, 0xBF, context[CpuRegister.Rdi]); // mov rdi, imm64
emitter.EmitMovR64Immediate(0x48, 0xBE, context[CpuRegister.Rsi]); // mov rsi, imm64
emitter.EmitMovR64Immediate(0x48, 0xBA, context[CpuRegister.Rdx]); // mov rdx, imm64
emitter.EmitMovR64Immediate(0x48, 0xB9, context[CpuRegister.Rcx]); // mov rcx, imm64
emitter.EmitMovR64Immediate(0x49, 0xB8, context[CpuRegister.R8]); // mov r8, imm64
emitter.EmitMovR64Immediate(0x49, 0xB9, context[CpuRegister.R9]); // mov r9, imm64
emitter.EmitMovR64Immediate(0x49, 0xBA, context[CpuRegister.R10]); // mov r10, imm64
emitter.EmitMovR64Immediate(0x49, 0xBC, context[CpuRegister.R12]); // mov r12, imm64
emitter.EmitMovR64Immediate(0x49, 0xBD, context[CpuRegister.R13]); // mov r13, imm64
emitter.EmitMovR64Immediate(0x49, 0xBE, context[CpuRegister.R14]); // mov r14, imm64
emitter.EmitMovR64Immediate(0x49, 0xBF, context[CpuRegister.R15]); // mov r15, imm64
emitter.EmitMovR64Immediate(0x49, 0xBB, context[CpuRegister.R11]); // mov r11, imm64
emitter.EmitMovR64Immediate(0x48, 0xB8, context[CpuRegister.Rax]); // mov rax, imm64
emitter.Emit(0xC3); // ret through the synthetic transfer slot
ActiveEntryReturnSentinelRip = (ulong)_guestReturnStub;
if (returnSlotAddress == 0 || !context.TryWriteUInt64(returnSlotAddress, (ulong)_guestReturnStub))
{
@@ -5248,6 +5354,45 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
}
}
// The continuation trampoline is rebuilt on every blocked-thread resume.
// Keep its tiny writer on the stack: capturing local emit functions create a
// managed display-class allocation on this extremely hot path.
private unsafe ref struct NativeCodeEmitter(byte* code)
{
private readonly byte* _code = code;
public int Offset;
public void Emit(byte value)
{
_code[Offset++] = value;
}
public void Emit(ushort value)
{
*(ushort*)(_code + Offset) = value;
Offset += sizeof(ushort);
}
public void Emit(uint value)
{
*(uint*)(_code + Offset) = value;
Offset += sizeof(uint);
}
private void Emit(ulong value)
{
*(ulong*)(_code + Offset) = value;
Offset += sizeof(ulong);
}
public void EmitMovR64Immediate(byte rex, byte opcode, ulong value)
{
Emit(rex);
Emit(opcode);
Emit(value);
}
}
private static ulong AlignDown(ulong value, ulong alignment)
{
if (alignment == 0)
@@ -5500,7 +5645,14 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{
num6 = CallNativeEntry(ptr);
Console.Error.WriteLine($"[LOADER][INFO] Guest returned: {num6}");
PumpUntilGuestThreadsIdle(context, "entry_return");
// A host stop has already invalidated the session. Draining guest
// continuations here can re-enter a blocked HLE call after its owner
// has exited, preventing the embedded GUI from receiving its exit
// callback.
if (!ActiveForcedGuestExit)
{
PumpUntilGuestThreadsIdle(context, "entry_return");
}
}
catch (AccessViolationException ex)
{
@@ -6153,8 +6305,68 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool Win32CloseHandle(nint hObject);
/// <summary>
/// Set when <see cref="Dispose"/> intentionally left the native session
/// state (import trampolines, TLS, exception handlers) alive because guest
/// worker threads were still executing guest code. The owning runtime must
/// then keep the guest address space mapped as well; freeing either under a
/// running worker faults the whole process, which hosts the GUI launcher.
/// </summary>
internal bool GuestSessionLeaked { get; private set; }
private bool WaitForGuestThreadQuiescence(TimeSpan timeout)
{
var deadline = Stopwatch.GetTimestamp() + (long)(timeout.TotalSeconds * Stopwatch.Frequency);
while (true)
{
var busyCount = 0;
string? busyName = null;
var now = Stopwatch.GetTimestamp();
using (LockGate("WaitForGuestThreadQuiescence"))
{
foreach (var thread in _guestThreads.Values)
{
if (thread.ExecutorActive)
{
busyCount++;
busyName ??= thread.Name;
}
}
}
if (busyCount == 0)
{
return true;
}
if (now >= deadline)
{
Console.Error.WriteLine(
$"[LOADER][WARN] {busyCount} guest worker(s) (first: '{busyName}') did not leave guest code " +
"during shutdown; the native session state stays alive to avoid faulting them.");
return false;
}
Thread.Sleep(5);
}
}
public unsafe void Dispose()
{
// Guest workers unwind cooperatively at their next import or block
// boundary once the forced-exit flag is set. Everything freed below is
// still reachable from a worker inside guest code, so drain the
// scheduler first and leak the session rather than fault a straggler.
_forcedGuestExit = true;
StopReadyThreadDispatcher();
StopStallWatchdog();
if (!WaitForGuestThreadQuiescence(TimeSpan.FromSeconds(5)))
{
GuestSessionLeaked = true;
return;
}
ClearGuestThreads();
if (ReferenceEquals(_posixSignalBackend, this))
{
// The signal handlers stay installed (they chain to the previous
+1 -1
View File
@@ -252,7 +252,7 @@ public static unsafe class JitStubs
var pattern = TlsAccessPattern;
var end = start + length - pattern.Length;
for (var ptr = start; ptr < end; ptr++)
for (var ptr = start; ptr <= end; ptr++)
{
if (MatchesPattern(ptr, pattern))
{
@@ -0,0 +1,115 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System;
namespace SharpEmu.Core.Cpu.Native;
/// <summary>
/// Recognizes Sony's AMD-only SSE4a EXTRQ+blend idiom and rewrites it into an
/// equivalent SSE4.1 sequence. SharpEmu executes guest x86-64 natively, but
/// Rosetta 2 and Intel hosts do not implement SSE4a, so the original opcode
/// raises #UD -> SIGILL. The compiler emits the idiom against whichever XMM
/// register it happens to allocate (Dead Cells uses xmm1, others xmm2), so the
/// source register is read from the ModRM r/m field rather than hard-coded.
///
/// The match/encode logic is deliberately free of native page-patching so it
/// can be unit-tested against handcrafted byte sequences.
/// </summary>
public static class Sse4aExtrqBlendPatch
{
/// <summary>Length in bytes of both the matched idiom and its replacement.</summary>
public const int SequenceLength = 12;
/// <summary>
/// Matches the 12-byte idiom, extracting the destination register D and the
/// source (scratch) register N:
/// <code>
/// EXTRQ xmmN, 0x28, 0x00 ; 66 0F 78 /0 28 00 mask xmmN to low 40 bits
/// VPBLENDD xmmD, xmmD, xmmN, 2 ; C4 E3 vvvv 02 /r 02 copy dword 1 into xmmD
/// </code>
/// N lives in the ModRM r/m field of both instructions; D (the blend
/// destination and src1) lives in the VPBLENDD ModRM reg field and VEX.vvvv.
/// Both are xmm0-xmm7 (the VEX byte1 0xE3 pins R/X/B, so no xmm8-15 extension).
/// The compiler allocates whichever registers it likes — Dead Cells builds use
/// D=xmm0 and D=xmm3, others differ — so both are read from the encoding.
/// </summary>
public static bool TryMatch(ReadOnlySpan<byte> source, out int destRegister, out int srcRegister)
{
destRegister = -1;
srcRegister = -1;
if (source.Length < SequenceLength)
{
return false;
}
// EXTRQ xmmN, 0x28, 0x00 : 66 0F 78, ModRM (mod=11 reg=000 rm=N), 28, 00.
if (source[0] != 0x66 || source[1] != 0x0F || source[2] != 0x78 ||
(source[3] & 0xF8) != 0xC0 || source[4] != 0x28 || source[5] != 0x00)
{
return false;
}
var n = source[3] & 0x07;
// VPBLENDD xmmD, xmmD, xmmN, 2 : C4 E3 <W=0 vvvv=~D L=0 pp=01> 02 ModRM 02.
// VEX.byte2 fixed bits (W, L, pp) must read 0b*0000*01; vvvv encodes ~D.
if (source[6] != 0xC4 || source[7] != 0xE3 || (source[8] & 0x87) != 0x01 ||
source[9] != 0x02 || source[11] != 0x02)
{
return false;
}
var d = (~(source[8] >> 3)) & 0x0F;
if (d > 7)
{
return false;
}
// ModRM: mod=11, reg=D (dest = src1), rm=N (src2 = the masked register).
if (source[10] != (0xC0 | (d << 3) | n))
{
return false;
}
destRegister = d;
srcRegister = n;
return true;
}
/// <summary>
/// Writes the SSE4.1 equivalent into <paramref name="destination"/>:
/// <code>
/// PEXTRB eax, xmmN, 4 ; 66 0F 3A 14 /r 04 extract byte 4 (zero-extended)
/// PINSRD xmmD, eax, 1 ; 66 0F 3A 22 /r 01 insert into xmmD dword lane 1
/// </code>
/// After EXTRQ masks xmmN to its low 40 bits, dword 1 is just byte 4
/// zero-extended, so the two-instruction extract/insert reproduces the exact
/// observable result the AMD idiom left in xmmD. eax is a caller-dead scratch
/// at every site the compiler emits this idiom.
/// </summary>
public static bool TryEncode(int destRegister, int srcRegister, Span<byte> destination)
{
if ((uint)destRegister > 7 || (uint)srcRegister > 7 || destination.Length < SequenceLength)
{
return false;
}
// PEXTRB eax, xmmN, 4 : ModRM (mod=11 reg=N rm=000 -> eax), imm8 = byte index 4.
destination[0] = 0x66;
destination[1] = 0x0F;
destination[2] = 0x3A;
destination[3] = 0x14;
destination[4] = (byte)(0xC0 | (srcRegister << 3));
destination[5] = 0x04;
// PINSRD xmmD, eax, 1 : ModRM (mod=11 reg=D -> xmmD, rm=000 -> eax), lane 1.
destination[6] = 0x66;
destination[7] = 0x0F;
destination[8] = 0x3A;
destination[9] = 0x22;
destination[10] = (byte)(0xC0 | (destRegister << 3));
destination[11] = 0x01;
return true;
}
}
@@ -40,6 +40,9 @@ public sealed class TrackedCpuMemory : ICpuMemory, ITrackedCpuMemory, IGuestMemo
return result;
}
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length) =>
_inner.TryCopy(destinationAddress, sourceAddress, length);
public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address)
{
if (_inner is IGuestMemoryAllocator allocator)
+90 -23
View File
@@ -16,7 +16,9 @@ namespace SharpEmu.Core.Loader;
public sealed class SelfLoader : ISelfLoader
{
private const uint SelfMagic = 0x4F153D1D;
private const uint ElfMagic = 0x7F454C46;
private const uint Ps4SelfMagic = 0x4F153D1D;
private const uint Ps5SelfMagic = 0x5414F5EE;
private const ulong SelfSegmentFlag = 0x800;
private const int PageSize = 0x1000;
private const ulong ImportStubBaseAddress = 0x0000_7000_0000_0000UL;
@@ -197,8 +199,32 @@ public sealed class SelfLoader : ISelfLoader
{
if (!physicalVm.TryAllocateAtExact(imageBase, totalImageSize, executable: true, out var allocatedBase))
{
throw new InvalidOperationException(
$"Could not allocate main image at required base 0x{imageBase:X16} (size=0x{totalImageSize:X}).");
// Exact allocation failed — the host may have already claimed
// part of this range (ASLR, Rosetta 2, or another process).
// Try backing the fixed range page by page to claim whatever
// free gaps exist. If the whole range is occupied the backfill
// returns false and we surface the original failure reason.
Console.Error.WriteLine(
$"[LOADER] Exact allocation at main image base 0x{imageBase:X16} " +
$"(size=0x{totalImageSize:X}) failed; attempting fixed-range backfill.");
if (!physicalVm.TryBackFixedRange(imageBase, totalImageSize, executable: true))
{
// TryBackFixedRange may have partially backed pages before
// failing. The earlier Clear() already reset all regions, so
// this second Clear() is idempotent for everything except the
// partial backfill — it frees only those orphaned pages.
physicalVm.Clear();
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
throw new InvalidOperationException(
$"Could not allocate main image at required base 0x{imageBase:X16} " +
$"(size=0x{totalImageSize:X}): {reason}. " +
"Try closing other applications, rebooting, or " +
(OperatingSystem.IsWindows()
? "setting SHARPEMU_DISABLE_MITIGATION_RELAUNCH=1."
: "ensuring no other process maps into this address range."));
}
allocatedBase = imageBase;
}
imageBase = allocatedBase;
@@ -354,10 +380,11 @@ public sealed class SelfLoader : ISelfLoader
throw new InvalidDataException("Input image is too small to contain an ELF header.");
}
if (imageData.Length >= sizeof(uint) && BinaryPrimitives.ReadUInt32BigEndian(imageData[..sizeof(uint)]) == SelfMagic)
var magic = BinaryPrimitives.ReadUInt32BigEndian(imageData[..sizeof(uint)]);
if (magic is Ps4SelfMagic or Ps5SelfMagic)
{
var selfHeader = ReadUnmanaged<SelfHeader>(imageData, 0);
if (!selfHeader.HasKnownLayout || selfHeader.Unknown != 0x22)
if (!selfHeader.HasKnownLayout)
{
throw new InvalidDataException("SELF header signature is not recognized.");
}
@@ -380,13 +407,11 @@ public sealed class SelfLoader : ISelfLoader
// acceptable here; anything else — most commonly a still-encrypted
// retail eboot — must be reported clearly rather than failing later
// with an opaque "not a valid ELF header" message.
const uint ElfMagicBigEndian = 0x7F454C46; // "\x7fELF"
var leadingWord = BinaryPrimitives.ReadUInt32BigEndian(imageData[..sizeof(uint)]);
if (leadingWord != ElfMagicBigEndian)
if (magic != ElfMagic)
{
throw new InvalidDataException(
$"Image is neither a decrypted ELF nor a recognized fake-signed SELF " +
$"(leading bytes 0x{leadingWord:X8}). This is almost certainly a still-encrypted " +
$"(leading bytes 0x{magic:X8}). This is almost certainly a still-encrypted " +
$"retail eboot — SharpEmu has no decryption keys and requires a decrypted / " +
$"fake-signed (fSELF) image.");
}
@@ -712,8 +737,9 @@ public sealed class SelfLoader : ISelfLoader
importedRelocations = BuildImportedRelocations(descriptors);
var stubEligibleNids = CollectStubEligibleNids(descriptors, moduleManager);
var stubImportNids = orderedImportNids
.Where(nid => ShouldCreateImportStub(nid, descriptors, moduleManager))
.Where(stubEligibleNids.Contains)
.ToArray();
var stubsByAddress = CreateImportStubMapping(virtualMemory, stubImportNids);
Console.WriteLine($"[LOADER] Created {stubsByAddress.Count} import stubs");
@@ -1158,6 +1184,35 @@ public sealed class SelfLoader : ISelfLoader
isWeak);
}
// Collects every NID that needs a trap import stub in a single pass over the
// descriptors. This mirrors ShouldCreateImportStub applied per NID, but avoids
// the O(nids * descriptors) rescan that filtering each unique NID against the
// full descriptor list would incur on large modules. A NID qualifies as soon as
// one of its descriptors is non-weak, or is weak but resolvable via the module
// manager.
private static HashSet<string> CollectStubEligibleNids(
IReadOnlyList<RelocationDescriptor> descriptors,
IModuleManager? moduleManager)
{
var eligible = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < descriptors.Count; i++)
{
var descriptor = descriptors[i];
var nid = descriptor.ImportNid;
if (nid is null || eligible.Contains(nid))
{
continue;
}
if (!descriptor.IsWeak || moduleManager?.TryGetExport(nid, out _) == true)
{
eligible.Add(nid);
}
}
return eligible;
}
private static bool ShouldCreateImportStub(
string nid,
IReadOnlyList<RelocationDescriptor> descriptors,
@@ -2429,6 +2484,19 @@ public sealed class SelfLoader : ISelfLoader
Debug.Assert(
!ShouldCreateImportStub("weak", [weak], moduleManager: null),
"An unresolved weak symbol incorrectly received a trap import stub.");
var strong = new RelocationDescriptor(
TargetAddress: 0x3000,
Addend: 0,
ImportNid: "strong",
SymbolValue: 0,
RelocationValueKind.Pointer,
IsDataImport: false);
var mixed = new List<RelocationDescriptor> { weak, strong };
var eligible = CollectStubEligibleNids(mixed, moduleManager: null);
Debug.Assert(
eligible.Contains("strong") && !eligible.Contains("weak"),
"CollectStubEligibleNids disagreed with the per-NID stub eligibility rule.");
}
private static ulong AlignUp(ulong value, ulong alignment)
@@ -2788,28 +2856,27 @@ public sealed class SelfLoader : ISelfLoader
private readonly ushort _size2;
private readonly ulong _fileSize;
private readonly ushort _segmentCount;
private readonly ushort _unknown;
private readonly ushort _flags;
private readonly uint _padding;
public ushort SegmentCount => _segmentCount;
public ushort Unknown => _unknown;
public ulong FileSize => _fileSize;
// Version, key type, and flags are signing metadata and vary across
// valid SELF images. They do not change the fixed header layout.
public bool HasKnownLayout =>
_ident0 == 0x4F &&
_ident1 == 0x15 &&
_ident2 == 0x3D &&
_ident3 == 0x1D &&
_ident4 == 0x00 &&
((_ident0 == 0x4F &&
_ident1 == 0x15 &&
_ident2 == 0x3D &&
_ident3 == 0x1D) ||
(_ident0 == 0x54 &&
_ident1 == 0x14 &&
_ident2 == 0xF5 &&
_ident3 == 0xEE)) &&
_ident5 == 0x01 &&
_ident6 == 0x01 &&
_ident7 == 0x12 &&
_ident8 == 0x01 &&
_ident9 == 0x01 &&
_ident10 == 0x00 &&
_ident11 == 0x00;
_ident7 == 0x12;
}
[StructLayout(LayoutKind.Sequential, Pack = 1)]
@@ -20,6 +20,11 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
private readonly Dictionary<(ulong DesiredAddress, ulong Alignment, bool Executable), ulong> _allocationSearchHints = new();
private readonly Dictionary<ulong, ProgramHeaderFlags> _pageProtections = new();
private bool _disposed;
[ThreadStatic]
private static CommittedRangeCache? _committedRangeCache;
private long _mappingGeneration;
private const ulong PageSize = 0x1000;
private const ulong GuestAllocationArenaAddress = 0x00006000_0000_0000;
private const ulong GuestAllocationArenaSize = 0x0100_0000;
@@ -28,6 +33,77 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
private const ulong FullCommitRegionLimit = 4UL << 30;
private const ulong DefaultLazyReservePrimeBytes = 0x0400_0000UL; // 64 MiB
private const ulong LazyReservePrimeChunkBytes = 0x0200_0000UL; // 32 MiB
private const int CommittedRangeCacheCapacity = 4;
private sealed class CommittedRangeCache
{
private readonly CommittedRange[] _ranges = new CommittedRange[CommittedRangeCacheCapacity];
private PhysicalVirtualMemory? _owner;
private long _generation;
private int _count;
private int _nextReplacement;
public bool Contains(
PhysicalVirtualMemory owner,
long generation,
ulong start,
ulong end)
{
if (!ReferenceEquals(_owner, owner) || _generation != generation)
{
return false;
}
for (var index = 0; index < _count; index++)
{
var range = _ranges[index];
if (start >= range.Start && end <= range.End)
{
return true;
}
}
return false;
}
public void Add(
PhysicalVirtualMemory owner,
long generation,
ulong start,
ulong end)
{
if (!ReferenceEquals(_owner, owner) || _generation != generation)
{
_owner = owner;
_generation = generation;
_count = 0;
_nextReplacement = 0;
}
for (var index = 0; index < _count; index++)
{
var range = _ranges[index];
if (start <= range.End && end >= range.Start)
{
_ranges[index] = new CommittedRange(
Math.Min(start, range.Start),
Math.Max(end, range.End));
return;
}
}
if (_count < _ranges.Length)
{
_ranges[_count++] = new CommittedRange(start, end);
return;
}
_ranges[_nextReplacement] = new CommittedRange(start, end);
_nextReplacement = (_nextReplacement + 1) % _ranges.Length;
}
}
private readonly record struct CommittedRange(ulong Start, ulong End);
// Raw Windows PAGE_* values retained for the internal region/protection
// bookkeeping: regions and saved old-protection values always carry the raw
@@ -198,6 +274,24 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return true;
}
public string DescribeAddressForDiagnostics(ulong address)
{
if (!_hostMemory.Query(address, out var info))
{
return "unable to query host memory at this address";
}
return info.State switch
{
HostRegionState.Free => "address reports free, but the exact-address reservation still failed",
HostRegionState.Reserved =>
$"already reserved by another host allocation (base=0x{info.AllocationBase:X16}, size=0x{info.RegionSize:X})",
HostRegionState.Committed =>
$"already committed by another host allocation (base=0x{info.AllocationBase:X16}, size=0x{info.RegionSize:X}, protect=0x{info.RawProtection:X})",
_ => $"in an unexpected host state (raw=0x{info.RawState:X})",
};
}
public ulong AllocateAt(ulong desiredAddress, ulong size, bool executable = true, bool allowAlternative = true)
{
if (size == 0)
@@ -331,6 +425,111 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return actualAddress;
}
public bool TryBackFixedRange(ulong address, ulong size, bool executable)
{
if (size == 0)
{
return false;
}
var start = AlignDown(address, PageSize);
var end = AlignUp(address + size, PageSize);
if (end <= start)
{
return false;
}
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
// Walk the range page-run by page-run. VirtualQuery reports the largest run
// of same-state pages from the queried address, so a single query advances
// us over whole free or occupied stretches. Only free stretches get backed;
// stretches already reserved or committed by another allocation are left as
// they are, which is exactly what a fixed mapping does on hardware.
//
// Because backing may span several disjoint free runs, allocations are
// staged: host pages are reserved/committed first, and the corresponding
// MemoryRegions are inserted only once every gap in the range has been
// backed. If any gap fails to back, every earlier host allocation is freed
// and no region is inserted, so the address space is left untouched.
var stagedAllocations = new List<(ulong Address, ulong Size)>();
var cursor = start;
while (cursor < end)
{
if (!_hostMemory.Query(cursor, out var info))
{
goto Rollback;
}
var queriedEnd = info.RegionSize > ulong.MaxValue - info.BaseAddress
? ulong.MaxValue
: info.BaseAddress + info.RegionSize;
var runEnd = Math.Min(end, queriedEnd);
if (runEnd <= cursor)
{
goto Rollback;
}
if (info.State == HostRegionState.Free)
{
var runSize = runEnd - cursor;
var allocated = _hostMemory.Allocate(cursor, runSize, hostProtection);
if (allocated != cursor)
{
if (allocated != 0)
{
_hostMemory.Free(allocated);
}
goto Rollback;
}
stagedAllocations.Add((cursor, runSize));
TraceVmem($"Backed fixed range gap: 0x{cursor:X16} - 0x{runEnd:X16} ({runSize} bytes)");
}
cursor = runEnd;
}
if (stagedAllocations.Count == 0)
{
return false;
}
// All gaps backed successfully — insert regions in one batch.
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
_gate.EnterWriteLock();
try
{
foreach (var (gapAddress, gapSize) in stagedAllocations)
{
InsertRegionSorted(new MemoryRegion
{
VirtualAddress = gapAddress,
Size = gapSize,
IsExecutable = executable,
IsReservedOnly = false,
Protection = protection
});
}
}
finally
{
_gate.ExitWriteLock();
}
return true;
Rollback:
foreach (var (gapAddress, _) in stagedAllocations)
{
_hostMemory.Free(gapAddress);
}
return false;
}
public bool TryAllocateAtOrAbove(
ulong desiredAddress,
ulong size,
@@ -422,6 +621,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
_gate.ExitWriteLock();
}
Interlocked.Increment(ref _mappingGeneration);
_hostMemory.Free(address);
}
@@ -593,6 +793,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
{
_allocationSearchHints.Clear();
}
Interlocked.Increment(ref _mappingGeneration);
}
finally
{
@@ -855,6 +1056,15 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
public bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source)
{
// A managed write into a page the guest-image write tracker has
// protected surfaces as a fatal AccessViolation — the runtime turns
// SIGSEGV in managed code into an exception before the resumable
// signal bridge can restore access (native guest stores recover
// there). Pre-visit the span so tracked pages are unprotected and
// their owners dirtied before the copy; guest addresses are
// host-identical, matching the tracker's fault addresses.
GuestImageWriteTracker.NotifyManagedWrite(virtualAddress, (ulong)source.Length);
var requiresExclusiveAccess = false;
_gate.EnterReadLock();
try
@@ -892,6 +1102,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
}
NotifyGuestWriteWatch(virtualAddress, source);
return true;
}
}
@@ -917,6 +1128,68 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
}
}
private static void NotifyGuestWriteWatch(ulong virtualAddress, ReadOnlySpan<byte> source)
{
if (GuestWriteWatch.Armed)
{
GuestWriteWatch.Check(virtualAddress, source);
}
}
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length)
{
if (length == 0)
{
return true;
}
if (length > int.MaxValue)
{
return false;
}
// Match TryWrite's managed-write notification before touching an
// identity-mapped guest page protected by the image tracker.
GuestImageWriteTracker.NotifyManagedWrite(destinationAddress, length);
_gate.EnterReadLock();
try
{
var sourceRegion = FindRegion(sourceAddress, length);
var destinationRegion = FindRegion(destinationAddress, length);
if (sourceRegion is null || destinationRegion is null ||
!TryResolveRegionOffset(sourceAddress, length, sourceRegion, out var sourceOffset) ||
!TryResolveRegionOffset(destinationAddress, length, destinationRegion, out var destinationOffset))
{
return false;
}
var sourcePointer = sourceRegion.VirtualAddress + sourceOffset;
var destinationPointer = destinationRegion.VirtualAddress + destinationOffset;
if ((sourceRegion.IsReservedOnly &&
!EnsureRangeCommitted(sourcePointer, length, sourceRegion)) ||
(destinationRegion.IsReservedOnly &&
!EnsureRangeCommitted(destinationPointer, length, destinationRegion)) ||
!CanReadWithoutProtectionChange(sourcePointer, length, sourceRegion) ||
!CanWriteWithoutProtectionChange(destinationPointer, length, destinationRegion))
{
return false;
}
// Span.CopyTo has memmove overlap semantics, so this allocation-free
// path safely serves both libc memcpy and libc memmove.
new ReadOnlySpan<byte>((void*)sourcePointer, checked((int)length)).CopyTo(
new Span<byte>((void*)destinationPointer, checked((int)length)));
NotifyGuestWriteWatch(
destinationAddress,
new ReadOnlySpan<byte>((void*)destinationPointer, checked((int)length)));
return true;
}
finally
{
_gate.ExitReadLock();
}
}
private bool TryReadExclusive(ulong virtualAddress, Span<byte> destination)
{
var region = FindRegion(virtualAddress, (ulong)destination.Length);
@@ -989,6 +1262,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
}
NotifyGuestWriteWatch(virtualAddress, source);
return true;
}
@@ -1013,6 +1287,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
}
}
NotifyGuestWriteWatch(virtualAddress, source);
return true;
}
@@ -1254,6 +1529,12 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
var startPage = AlignDown(address, PageSize);
var endPage = AlignUp(address + size, PageSize);
var mappingGeneration = Volatile.Read(ref _mappingGeneration);
var committedRangeCache = _committedRangeCache ??= new CommittedRangeCache();
if (committedRangeCache.Contains(this, mappingGeneration, startPage, endPage))
{
return true;
}
var commitProtection = GetCommitProtection(region);
var pageAddress = startPage;
@@ -1275,6 +1556,9 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
if (info.State == HostRegionState.Committed)
{
// The host query proved this whole range is committed. Retain
// that result instead of caching only the caller's small span.
CacheCommittedRange(info.BaseAddress, queriedEnd, mappingGeneration);
pageAddress = rangeEnd;
continue;
}
@@ -1290,12 +1574,23 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return false;
}
CacheCommittedRange(pageAddress, rangeEnd, mappingGeneration);
pageAddress = rangeEnd;
}
CacheCommittedRange(startPage, endPage, mappingGeneration);
return true;
}
private void CacheCommittedRange(ulong startPage, ulong endPage, long mappingGeneration)
{
(_committedRangeCache ??= new CommittedRangeCache()).Add(
this,
mappingGeneration,
startPage,
endPage);
}
private bool TryTemporarilyProtectForRead(
ulong address,
ulong size,
+8 -1
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Loader;
using SharpEmu.HLE;
namespace SharpEmu.Core.Memory;
@@ -93,8 +94,14 @@ public sealed class VirtualMemory : IVirtualMemory
}
CopyToRegions(virtualAddress, source, regionIndex);
return true;
}
if (GuestWriteWatch.Armed)
{
GuestWriteWatch.Check(virtualAddress, source);
}
return true;
}
private bool TryValidateRange(
@@ -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
@@ -189,6 +191,16 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
Console.Error.WriteLine($"[RUNTIME] DispatchEntry returned: {result}");
Console.Error.WriteLine($"[RUNTIME] Dispatch result: {result}");
// Stop is a host operation, not an emulation failure. The detailed
// trace and session-summary builders can traverse a partially torn
// down native backend, delaying the GUI exit callback indefinitely.
if (HostSessionControl.IsShutdownRequested)
{
Console.Error.WriteLine("[RUNTIME] Skipping post-exit diagnostics for host shutdown.");
return result;
}
LastExecutionTrace = _cpuDispatcher.LastImportResolutionTrace;
LastMilestoneLog = _cpuDispatcher.LastMilestoneLog;
LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary);
@@ -1152,6 +1164,16 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
disposableDispatcher.Dispose();
}
if (_cpuDispatcher is CpuDispatcher { NativeSessionLeaked: true })
{
// A guest worker is still inside guest code; unmapping the guest
// address space under it would fault the whole process, which
// hosts the GUI launcher in embedded sessions.
Console.Error.WriteLine(
"[RUNTIME] Guest workers were still active at teardown; keeping the guest address space mapped.");
return;
}
if (_virtualMemory is IDisposable disposableMemory)
{
disposableMemory.Dispose();
@@ -4,6 +4,7 @@
namespace SharpEmu.Core.Runtime;
using SharpEmu.Core.Cpu;
using SharpEmu.Core.Cpu.Debugging;
public readonly struct SharpEmuRuntimeOptions
{
@@ -12,4 +13,11 @@ public readonly struct SharpEmuRuntimeOptions
public bool StrictDynlibResolution { get; init; }
public int ImportTraceLimit { get; init; }
/// <summary>
/// An optional debugger to attach to guest execution. Flows through to
/// <see cref="CpuExecutionOptions.DebugHook"/>. Null (the default) runs with
/// no debugger attached.
/// </summary>
public ICpuDebugHook? DebugHook { get; init; }
}
@@ -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>
+41
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">
@@ -29,8 +30,32 @@ SPDX-License-Identifier: GPL-2.0-or-later
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
<SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" />
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
<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>
@@ -55,6 +80,22 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Setter Property="Padding" Value="10,3" />
</Style>
<!-- Session status/hotkey badges: the title-id pill geometry with a
tinted fill so state (RUNNING) and keys (F11) read at a glance. -->
<Style Selector="Border.badge">
<Setter Property="CornerRadius" Value="999" />
<Setter Property="Padding" Value="8,2" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Border.badge.running">
<Setter Property="Background" Value="#1E46C46B" />
<Setter Property="BorderBrush" Value="#5546C46B" />
</Style>
<Style Selector="Border.badge.key">
<Setter Property="Background" Value="#1E58A6FF" />
<Setter Property="BorderBrush" Value="#5558A6FF" />
</Style>
<Style Selector="TextBlock.sectionTitle">
<Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="SemiBold" />
+331 -316
View File
@@ -1,7 +1,6 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
@@ -10,32 +9,36 @@ using Microsoft.Win32.SafeHandles;
namespace SharpEmu.GUI;
/// <summary>
/// Launches the SharpEmu CLI as a child process with the same CET/CFG mitigation
/// opt-outs the CLI would apply to its own relaunched child, while capturing
/// stdout/stderr through pipes. The CLI's internal relaunch is suppressed via
/// SHARPEMU_DISABLE_MITIGATION_RELAUNCH so output is not lost to a detached
/// console. A kill-on-close job object ties the emulator's lifetime to the GUI.
/// Owns an isolated emulator process. Guest virtual memory is fixed-address and
/// cannot be reliably reused while guest-created host threads are still alive,
/// so the GUI must never execute a game in its own process.
/// </summary>
internal sealed class EmulatorProcess : IDisposable
{
private const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000;
private const uint CREATE_NO_WINDOW = 0x08000000;
private const int STARTF_USESTDHANDLES = 0x00000100;
private const uint HANDLE_FLAG_INHERIT = 0x00000001;
private const uint INFINITE = 0xFFFFFFFF;
private const int PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY = 0x00020007;
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
private const int JobObjectExtendedLimitInformation = 9;
private const ulong PROCESS_CREATION_MITIGATION_POLICY_CONTROL_FLOW_GUARD_ALWAYS_OFF = 0x00000002UL << 40;
private const ulong PROCESS_CREATION_MITIGATION_POLICY2_CET_USER_SHADOW_STACKS_ALWAYS_OFF = 0x00000002UL << 28;
private const ulong PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF = 0x00000002UL << 32;
private const ulong PROCESS_CREATION_MITIGATION_POLICY2_XTENDED_CONTROL_FLOW_GUARD_ALWAYS_OFF = 0x00000002UL << 40;
public const int HostStopExitCode = -2;
private const uint ExtendedStartupInfoPresent = 0x00080000;
private const uint CreateNoWindow = 0x08000000;
private const int StartfUseStdHandles = 0x00000100;
private const uint HandleFlagInherit = 0x00000001;
private const uint Infinite = 0xFFFFFFFF;
private const int ProcThreadAttributeMitigationPolicy = 0x00020007;
private const uint JobObjectLimitKillOnJobClose = 0x00002000;
private const int JobObjectExtendedLimitInformationClass = 9;
private const string MitigatedChildFlag = "--sharpemu-mitigated-child";
private const string MitigatedChildEnvironment = "SHARPEMU_MITIGATED_CHILD";
private const ulong ControlFlowGuardAlwaysOff = 0x00000002UL << 40;
private const ulong CetUserShadowStacksAlwaysOff = 0x00000002UL << 28;
private const ulong UserCetSetContextIpValidationAlwaysOff = 0x00000002UL << 32;
private static readonly object EnvironmentGate = new();
private readonly object _sync = new();
private nint _processHandle;
private nint _jobHandle;
private Process? _fallbackProcess;
private bool _running;
private bool _stopRequested;
private bool _disposed;
public event Action<string, bool>? OutputReceived;
@@ -55,29 +58,34 @@ internal sealed class EmulatorProcess : IDisposable
public void Start(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(exePath);
ArgumentNullException.ThrowIfNull(arguments);
lock (_sync)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ThrowIfDisposed();
if (_running)
{
throw new InvalidOperationException("The emulator process is already running.");
}
if (OperatingSystem.IsWindows())
{
StartWindows(exePath, arguments, workingDirectory);
}
else
{
StartFallback(exePath, arguments, workingDirectory);
}
_running = true;
_stopRequested = false;
}
if (OperatingSystem.IsWindows())
{
StartWindows(exePath, arguments, workingDirectory);
return;
}
StartFallback(exePath, arguments, workingDirectory);
}
public void Stop()
{
nint processHandle;
nint jobHandle;
Process? fallbackProcess;
lock (_sync)
{
if (!_running)
@@ -85,28 +93,35 @@ internal sealed class EmulatorProcess : IDisposable
return;
}
// Prefer terminating the job: it kills the whole tree, including
// any children the emulator spawned, even when the main process
// is wedged in a GPU driver call.
if (_jobHandle != 0)
{
_ = TerminateJobObject(_jobHandle, 1);
}
_stopRequested = true;
processHandle = _processHandle;
jobHandle = _jobHandle;
fallbackProcess = _fallbackProcess;
}
if (_processHandle != 0)
{
_ = TerminateProcess(_processHandle, 1);
}
if (jobHandle != 0)
{
_ = TerminateJobObject(jobHandle, unchecked((uint)HostStopExitCode));
return;
}
try
if (processHandle != 0)
{
_ = TerminateProcess(processHandle, unchecked((uint)HostStopExitCode));
return;
}
try
{
if (fallbackProcess is { HasExited: false })
{
_fallbackProcess?.Kill(entireProcessTree: true);
}
catch (InvalidOperationException)
{
// Already exited.
fallbackProcess.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
// The process exited while Stop was handling the request.
}
}
public void Dispose()
@@ -124,121 +139,206 @@ internal sealed class EmulatorProcess : IDisposable
Stop();
}
private void StartWindows(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
private void StartFallback(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
{
// The CLI would otherwise relaunch itself into a mitigated child whose
// console output cannot flow through our pipes.
Environment.SetEnvironmentVariable("SHARPEMU_DISABLE_MITIGATION_RELAUNCH", "1");
var securityAttributes = new SECURITY_ATTRIBUTES
var startInfo = new ProcessStartInfo(exePath)
{
nLength = Marshal.SizeOf<SECURITY_ATTRIBUTES>(),
bInheritHandle = 1,
WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory)
? Path.GetDirectoryName(exePath) ?? Environment.CurrentDirectory
: workingDirectory,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
if (!CreatePipe(out var stdoutRead, out var stdoutWrite, ref securityAttributes, 0) ||
!CreatePipe(out var stderrRead, out var stderrWrite, ref securityAttributes, 0))
foreach (var argument in arguments)
{
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create output pipes.");
startInfo.ArgumentList.Add(argument);
}
_ = SetHandleInformation(stdoutRead, HANDLE_FLAG_INHERIT, 0);
_ = SetHandleInformation(stderrRead, HANDLE_FLAG_INHERIT, 0);
var process = Process.Start(startInfo)
?? throw new InvalidOperationException("Could not start the emulator process.");
process.EnableRaisingEvents = true;
process.OutputDataReceived += (_, eventArgs) => ForwardOutput(eventArgs.Data, isError: false);
process.ErrorDataReceived += (_, eventArgs) => ForwardOutput(eventArgs.Data, isError: true);
process.Exited += (_, _) => OnExited(process.ExitCode);
var startupInfoEx = new STARTUPINFOEX();
startupInfoEx.StartupInfo.cb = Marshal.SizeOf<STARTUPINFOEX>();
startupInfoEx.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
startupInfoEx.StartupInfo.hStdOutput = stdoutWrite;
startupInfoEx.StartupInfo.hStdError = stderrWrite;
lock (_sync)
{
_fallbackProcess = process;
_running = true;
}
process.BeginOutputReadLine();
process.BeginErrorReadLine();
}
private unsafe void StartWindows(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
{
nint stdoutRead = 0;
nint stdoutWrite = 0;
nint stderrRead = 0;
nint stderrWrite = 0;
nint attributeList = 0;
nint mitigationPolicies = 0;
nint processHandle = 0;
nint threadHandle = 0;
try
{
var security = new SecurityAttributes
{
Size = Marshal.SizeOf<SecurityAttributes>(),
InheritHandle = 1,
};
if (!CreatePipe(out stdoutRead, out stdoutWrite, ref security, 0) ||
!CreatePipe(out stderrRead, out stderrWrite, ref security, 0))
{
throw new InvalidOperationException($"Could not create emulator output pipes (Win32 error {Marshal.GetLastWin32Error()}).");
}
if (!SetHandleInformation(stdoutRead, HandleFlagInherit, 0) ||
!SetHandleInformation(stderrRead, HandleFlagInherit, 0))
{
throw new InvalidOperationException($"Could not configure emulator output pipes (Win32 error {Marshal.GetLastWin32Error()}).");
}
nuint attributeListSize = 0;
_ = InitializeProcThreadAttributeList(0, 1, 0, ref attributeListSize);
attributeList = Marshal.AllocHGlobal((nint)attributeListSize);
if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref attributeListSize))
{
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to initialize the process attribute list.");
throw new InvalidOperationException($"Could not initialize process mitigation attributes (Win32 error {Marshal.GetLastWin32Error()}).");
}
startupInfoEx.lpAttributeList = attributeList;
var policy1 = PROCESS_CREATION_MITIGATION_POLICY_CONTROL_FLOW_GUARD_ALWAYS_OFF;
var policy2 =
PROCESS_CREATION_MITIGATION_POLICY2_CET_USER_SHADOW_STACKS_ALWAYS_OFF |
PROCESS_CREATION_MITIGATION_POLICY2_USER_CET_SET_CONTEXT_IP_VALIDATION_ALWAYS_OFF;
mitigationPolicies = Marshal.AllocHGlobal(sizeof(ulong) * 2);
Marshal.WriteInt64(mitigationPolicies, unchecked((long)policy1));
Marshal.WriteInt64(nint.Add(mitigationPolicies, sizeof(long)), unchecked((long)policy2));
Marshal.WriteInt64(mitigationPolicies, unchecked((long)ControlFlowGuardAlwaysOff));
Marshal.WriteInt64(
nint.Add(mitigationPolicies, sizeof(long)),
unchecked((long)(CetUserShadowStacksAlwaysOff | UserCetSetContextIpValidationAlwaysOff)));
if (!UpdateProcThreadAttribute(
attributeList,
0,
PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY,
mitigationPolicies,
(nuint)(sizeof(ulong) * 2),
0,
0))
attributeList,
0,
(nint)ProcThreadAttributeMitigationPolicy,
mitigationPolicies,
(nuint)(sizeof(ulong) * 2),
0,
0))
{
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to apply the mitigation policy.");
throw new InvalidOperationException($"Could not apply process mitigations (Win32 error {Marshal.GetLastWin32Error()}).");
}
var currentDirectory = workingDirectory ?? Environment.CurrentDirectory;
var created = CreateProcessW(
exePath,
new StringBuilder(BuildCommandLine(exePath, arguments)),
0,
0,
true,
EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW,
0,
currentDirectory,
ref startupInfoEx,
out var processInfo);
var startup = new StartupInfoEx();
startup.StartupInfo.Size = Marshal.SizeOf<StartupInfoEx>();
startup.StartupInfo.Flags = StartfUseStdHandles;
startup.StartupInfo.StdOutput = stdoutWrite;
startup.StartupInfo.StdError = stderrWrite;
startup.AttributeList = attributeList;
if (!created)
var childArguments = new List<string>(arguments.Count + 1) { MitigatedChildFlag };
childArguments.AddRange(arguments);
var commandLine = new StringBuilder(BuildCommandLine(exePath, childArguments));
ProcessInformation processInfo;
lock (EnvironmentGate)
{
var error = Marshal.GetLastWin32Error();
throw new Win32Exception(error, $"Failed to start '{exePath}' with CET/CFG mitigation disabled (Win32 error {error}: {new Win32Exception(error).Message}).");
var previousValue = Environment.GetEnvironmentVariable(MitigatedChildEnvironment);
try
{
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
if (!CreateProcessW(
null,
commandLine,
0,
0,
true,
ExtendedStartupInfoPresent | CreateNoWindow,
0,
string.IsNullOrWhiteSpace(workingDirectory)
? Path.GetDirectoryName(exePath) ?? Environment.CurrentDirectory
: workingDirectory,
ref startup,
out processInfo))
{
throw new InvalidOperationException($"Could not start the emulator process (Win32 error {Marshal.GetLastWin32Error()}).");
}
}
finally
{
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, previousValue);
}
}
CloseHandle(processInfo.hThread);
_processHandle = processInfo.hProcess;
processHandle = processInfo.Process;
threadHandle = processInfo.Thread;
CloseHandle(stdoutWrite);
stdoutWrite = 0;
CloseHandle(stderrWrite);
stderrWrite = 0;
_jobHandle = CreateJobObjectW(0, null);
if (_jobHandle != 0 &&
(!TryEnableKillOnJobClose(_jobHandle) || !AssignProcessToJobObject(_jobHandle, processInfo.hProcess)))
var jobHandle = CreateJobObjectW(0, null);
if (jobHandle != 0 &&
(!TryEnableKillOnJobClose(jobHandle) || !AssignProcessToJobObject(jobHandle, processHandle)))
{
CloseHandle(_jobHandle);
_jobHandle = 0;
CloseHandle(jobHandle);
jobHandle = 0;
}
StartReaderThread(stdoutRead, isError: false);
StartReaderThread(stderrRead, isError: true);
StartExitWatcherThread();
lock (_sync)
{
_processHandle = processHandle;
_jobHandle = jobHandle;
_running = true;
}
processHandle = 0;
StartPipeReader(stdoutRead, isError: false);
stdoutRead = 0;
StartPipeReader(stderrRead, isError: true);
stderrRead = 0;
StartWindowsExitWatcher(_processHandle);
}
catch
{
CloseHandle(stdoutRead);
CloseHandle(stderrRead);
if (processHandle != 0)
{
_ = TerminateProcess(processHandle, 1);
}
throw;
}
finally
{
// The child owns duplicated pipe write ends; closing ours lets the
// readers observe EOF when the child exits.
CloseHandle(stdoutWrite);
CloseHandle(stderrWrite);
if (threadHandle != 0)
{
CloseHandle(threadHandle);
}
if (processHandle != 0)
{
CloseHandle(processHandle);
}
if (stdoutRead != 0)
{
CloseHandle(stdoutRead);
}
if (stdoutWrite != 0)
{
CloseHandle(stdoutWrite);
}
if (stderrRead != 0)
{
CloseHandle(stderrRead);
}
if (stderrWrite != 0)
{
CloseHandle(stderrWrite);
}
if (attributeList != 0)
{
DeleteProcThreadAttributeList(attributeList);
Marshal.FreeHGlobal(attributeList);
}
if (mitigationPolicies != 0)
{
Marshal.FreeHGlobal(mitigationPolicies);
@@ -246,103 +346,56 @@ internal sealed class EmulatorProcess : IDisposable
}
}
private void StartFallback(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
private void StartPipeReader(nint handle, bool isError)
{
var startInfo = new ProcessStartInfo
var readerThread = new Thread(() =>
{
FileName = exePath,
WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
foreach (var argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
process.OutputDataReceived += (_, e) =>
{
if (e.Data is not null)
using var safeHandle = new SafeFileHandle(handle, ownsHandle: true);
using var stream = new FileStream(safeHandle, FileAccess.Read, 4096, isAsync: false);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
while (reader.ReadLine() is { } line)
{
OutputReceived?.Invoke(e.Data, false);
ForwardOutput(line, isError);
}
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data is not null)
{
OutputReceived?.Invoke(e.Data, true);
}
};
process.Exited += (_, _) =>
{
int exitCode;
try
{
exitCode = process.ExitCode;
}
catch (InvalidOperationException)
{
exitCode = -1;
}
OnExited(exitCode);
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
_fallbackProcess = process;
}
private void StartReaderThread(nint readHandle, bool isError)
{
var thread = new Thread(() =>
{
using var stream = new FileStream(new SafeFileHandle(readHandle, ownsHandle: true), FileAccess.Read);
using var reader = new StreamReader(stream, Encoding.UTF8);
try
{
while (reader.ReadLine() is { } line)
{
OutputReceived?.Invoke(line, isError);
}
}
catch (IOException)
{
// Pipe broken on process teardown.
}
})
}, 256 * 1024)
{
IsBackground = true,
Name = isError ? "SharpEmu stderr reader" : "SharpEmu stdout reader",
};
thread.Start();
readerThread.Start();
}
private void StartExitWatcherThread()
private void StartWindowsExitWatcher(nint processHandle)
{
var processHandle = _processHandle;
var thread = new Thread(() =>
var watcher = new Thread(() =>
{
_ = WaitForSingleObject(processHandle, INFINITE);
var exitCode = GetExitCodeProcess(processHandle, out var rawExitCode)
? unchecked((int)rawExitCode)
: -1;
_ = WaitForSingleObject(processHandle, Infinite);
var exitCode = 1;
if (GetExitCodeProcess(processHandle, out var nativeExitCode))
{
exitCode = unchecked((int)nativeExitCode);
}
OnExited(exitCode);
})
}, 128 * 1024)
{
IsBackground = true,
Name = "SharpEmu exit watcher",
};
thread.Start();
watcher.Start();
}
private void OnExited(int exitCode)
private void ForwardOutput(string? line, bool isError)
{
if (!string.IsNullOrEmpty(line))
{
OutputReceived?.Invoke(line, isError);
}
}
private void OnExited(int nativeExitCode)
{
int exitCode;
lock (_sync)
{
if (!_running)
@@ -350,13 +403,13 @@ internal sealed class EmulatorProcess : IDisposable
return;
}
exitCode = _stopRequested ? HostStopExitCode : nativeExitCode;
_running = false;
if (_processHandle != 0)
{
CloseHandle(_processHandle);
_processHandle = 0;
}
if (_jobHandle != 0)
{
CloseHandle(_jobHandle);
@@ -372,24 +425,19 @@ internal sealed class EmulatorProcess : IDisposable
private static bool TryEnableKillOnJobClose(nint jobHandle)
{
var extendedLimitInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
var info = new JobObjectExtendedLimitInformation
{
BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION
BasicLimitInformation = new JobObjectBasicLimitInformation
{
LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
LimitFlags = JobObjectLimitKillOnJobClose,
},
};
var size = Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>();
var size = Marshal.SizeOf<JobObjectExtendedLimitInformation>();
var memory = Marshal.AllocHGlobal(size);
try
{
Marshal.StructureToPtr(extendedLimitInfo, memory, false);
return SetInformationJobObject(
jobHandle,
JobObjectExtendedLimitInformation,
memory,
unchecked((uint)size));
Marshal.StructureToPtr(info, memory, false);
return SetInformationJobObject(jobHandle, JobObjectExtendedLimitInformationClass, memory, unchecked((uint)size));
}
finally
{
@@ -397,128 +445,124 @@ internal sealed class EmulatorProcess : IDisposable
}
}
private static string BuildCommandLine(string processPath, IReadOnlyList<string> args)
private static string BuildCommandLine(string processPath, IReadOnlyList<string> arguments)
{
var builder = new StringBuilder();
builder.Append(QuoteArgument(processPath));
for (var i = 0; i < args.Count; i++)
var builder = new StringBuilder(QuoteArgument(processPath));
foreach (var argument in arguments)
{
builder.Append(' ');
builder.Append(QuoteArgument(args[i]));
builder.Append(QuoteArgument(argument));
}
return builder.ToString();
}
private static string QuoteArgument(string argument)
private static string QuoteArgument(string value)
{
if (argument.Length == 0)
if (value.Length == 0)
{
return "\"\"";
}
var needsQuotes = false;
foreach (var c in argument)
if (!value.Any(static c => char.IsWhiteSpace(c) || c == '"'))
{
if (char.IsWhiteSpace(c) || c == '"')
{
needsQuotes = true;
break;
}
return value;
}
if (!needsQuotes)
{
return argument;
}
var builder = new StringBuilder(argument.Length + 2);
var builder = new StringBuilder(value.Length + 2);
builder.Append('"');
var backslashCount = 0;
foreach (var c in argument)
var slashCount = 0;
foreach (var character in value)
{
if (c == '\\')
if (character == '\\')
{
backslashCount++;
slashCount++;
continue;
}
if (c == '"')
if (character == '"')
{
builder.Append('\\', (backslashCount * 2) + 1);
builder.Append('"');
backslashCount = 0;
builder.Append('\\', (slashCount * 2) + 1);
builder.Append(character);
slashCount = 0;
continue;
}
if (backslashCount > 0)
if (slashCount > 0)
{
builder.Append('\\', backslashCount);
backslashCount = 0;
builder.Append('\\', slashCount);
slashCount = 0;
}
builder.Append(c);
builder.Append(character);
}
if (backslashCount > 0)
if (slashCount > 0)
{
builder.Append('\\', backslashCount * 2);
builder.Append('\\', slashCount * 2);
}
builder.Append('"');
return builder.ToString();
}
[StructLayout(LayoutKind.Sequential)]
private struct SECURITY_ATTRIBUTES
private void ThrowIfDisposed()
{
public int nLength;
public nint lpSecurityDescriptor;
public int bInheritHandle;
if (_disposed)
{
throw new ObjectDisposedException(nameof(EmulatorProcess));
}
}
[StructLayout(LayoutKind.Sequential)]
private struct SecurityAttributes
{
public int Size;
public nint SecurityDescriptor;
public int InheritHandle;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct STARTUPINFO
private struct StartupInfo
{
public int cb;
public nint lpReserved;
public nint lpDesktop;
public nint lpTitle;
public int dwX;
public int dwY;
public int dwXSize;
public int dwYSize;
public int dwXCountChars;
public int dwYCountChars;
public int dwFillAttribute;
public int dwFlags;
public short wShowWindow;
public short cbReserved2;
public nint lpReserved2;
public nint hStdInput;
public nint hStdOutput;
public nint hStdError;
public int Size;
public nint Reserved;
public nint Desktop;
public nint Title;
public int X;
public int Y;
public int XSize;
public int YSize;
public int XCountChars;
public int YCountChars;
public int FillAttribute;
public int Flags;
public short ShowWindow;
public short Reserved2Count;
public nint Reserved2;
public nint StdInput;
public nint StdOutput;
public nint StdError;
}
[StructLayout(LayoutKind.Sequential)]
private struct STARTUPINFOEX
private struct StartupInfoEx
{
public STARTUPINFO StartupInfo;
public nint lpAttributeList;
public StartupInfo StartupInfo;
public nint AttributeList;
}
[StructLayout(LayoutKind.Sequential)]
private struct PROCESS_INFORMATION
private struct ProcessInformation
{
public nint hProcess;
public nint hThread;
public int dwProcessId;
public int dwThreadId;
public nint Process;
public nint Thread;
public int ProcessId;
public int ThreadId;
}
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
private struct JobObjectBasicLimitInformation
{
public long PerProcessUserTimeLimit;
public long PerJobUserTimeLimit;
@@ -532,7 +576,7 @@ internal sealed class EmulatorProcess : IDisposable
}
[StructLayout(LayoutKind.Sequential)]
private struct IO_COUNTERS
private struct IoCounters
{
public ulong ReadOperationCount;
public ulong WriteOperationCount;
@@ -543,10 +587,10 @@ internal sealed class EmulatorProcess : IDisposable
}
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
private struct JobObjectExtendedLimitInformation
{
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
public IO_COUNTERS IoInfo;
public JobObjectBasicLimitInformation BasicLimitInformation;
public IoCounters IoInfo;
public nuint ProcessMemoryLimit;
public nuint JobMemoryLimit;
public nuint PeakProcessMemoryUsed;
@@ -555,66 +599,37 @@ internal sealed class EmulatorProcess : IDisposable
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreatePipe(
out nint hReadPipe,
out nint hWritePipe,
ref SECURITY_ATTRIBUTES lpPipeAttributes,
uint nSize);
private static extern bool CreatePipe(out nint readPipe, out nint writePipe, ref SecurityAttributes attributes, uint size);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetHandleInformation(nint hObject, uint dwMask, uint dwFlags);
private static extern bool SetHandleInformation(nint handle, uint mask, uint flags);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool InitializeProcThreadAttributeList(
nint lpAttributeList,
int dwAttributeCount,
int dwFlags,
ref nuint lpSize);
private static extern bool InitializeProcThreadAttributeList(nint list, int count, int flags, ref nuint size);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UpdateProcThreadAttribute(
nint lpAttributeList,
uint dwFlags,
nint attribute,
nint lpValue,
nuint cbSize,
nint lpPreviousValue,
nint lpReturnSize);
private static extern bool UpdateProcThreadAttribute(nint list, uint flags, nint attribute, nint value, nuint size, nint previousValue, nint returnSize);
[DllImport("kernel32.dll")]
private static extern void DeleteProcThreadAttributeList(nint lpAttributeList);
private static extern void DeleteProcThreadAttributeList(nint list);
[DllImport("kernel32.dll", EntryPoint = "CreateJobObjectW", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern nint CreateJobObjectW(nint lpJobAttributes, string? lpName);
private static extern nint CreateJobObjectW(nint attributes, string? name);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetInformationJobObject(
nint hJob,
int jobObjectInfoClass,
nint lpJobObjectInfo,
uint cbJobObjectInfoLength);
private static extern bool SetInformationJobObject(nint job, int infoClass, nint info, uint size);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AssignProcessToJobObject(nint hJob, nint hProcess);
private static extern bool AssignProcessToJobObject(nint job, nint process);
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateProcessW(
string applicationName,
StringBuilder commandLine,
nint processAttributes,
nint threadAttributes,
[MarshalAs(UnmanagedType.Bool)] bool inheritHandles,
uint creationFlags,
nint environment,
string currentDirectory,
ref STARTUPINFOEX startupInfo,
out PROCESS_INFORMATION processInformation);
private static extern bool CreateProcessW(string? applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(nint handle, uint milliseconds);
+620
View File
@@ -0,0 +1,620 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Avalonia;
using Avalonia.Controls;
using Avalonia.Platform;
using Avalonia.Threading;
using SharpEmu.Libs.VideoOut;
using System.Runtime.InteropServices;
namespace SharpEmu.GUI;
/// <summary>
/// Native child surface owned by Avalonia. The isolated emulator process uses
/// its platform handle to create the Vulkan presentation surface, keeping the
/// guest address space out of the GUI process.
/// </summary>
public sealed class GameSurfaceHost : NativeControlHost
{
private const uint SwpNoSize = 0x0001;
private const uint SwpNoMove = 0x0002;
private const uint SwpNoZOrder = 0x0004;
private const uint SwpNoActivate = 0x0010;
private const uint SwpShowWindow = 0x0040;
private const uint SwpHideWindow = 0x0080;
private const uint WsChild = 0x40000000;
private const uint WsVisible = 0x10000000;
private const uint WsClipSiblings = 0x04000000;
private const uint WsClipChildren = 0x02000000;
private const uint CsOwnDc = 0x0020;
private const uint WmSetCursor = 0x0020;
private const uint WmMouseMove = 0x0200;
private const int IdcArrow = 32512;
private const int CursorHideDelayMs = 2500;
private VulkanHostSurface? _surface;
private nint _windowHandle;
private nint _x11Display;
private string? _win32ClassName;
private WindowProcedure? _windowProcedure;
private nint _metalLayer;
private bool _presentationVisible = true;
private DispatcherTimer? _cursorIdleTimer;
private bool _cursorAutoHide;
private bool _cursorHidden;
private long _lastPointerActivity;
public GameSurfaceHost()
{
PropertyChanged += (_, change) =>
{
if (change.Property == BoundsProperty)
{
UpdateSurfaceSize();
}
};
LayoutUpdated += (_, _) =>
{
// Fullscreen can change a monitor's DPI scale without changing
// the logical Bounds. Refresh the native child from physical size.
UpdateSurfaceSize();
// NativeControlHost may make its HWND visible again as part of a
// later arrange pass. Keep a loading surface hidden until its
// child process reports a real first frame.
if (!_presentationVisible)
{
ApplyPresentationVisibility();
}
};
}
public event EventHandler<VulkanHostSurface>? SurfaceAvailable;
public event EventHandler<VulkanHostSurface>? SurfaceDestroyed;
public VulkanHostSurface? Surface => _surface;
public void RefreshSurfaceSize() => UpdateSurfaceSize();
/// <summary>
/// Hides the platform child without detaching the Vulkan surface. This
/// allows the launcher to return to its library while guest teardown is
/// still finishing on the render thread.
/// </summary>
public void SetPresentationVisible(bool visible)
{
_presentationVisible = visible;
ApplyPresentationVisibility();
}
/// <summary>
/// Auto-hides the mouse cursor over the game surface after a short idle
/// period; any pointer movement brings it back. Enabling (again) restarts
/// the idle countdown, so both "first frame presented" and "entered
/// fullscreen" can arm it. Windows-only; a no-op elsewhere.
/// </summary>
public void SetCursorAutoHide(bool enabled)
{
if (!OperatingSystem.IsWindows())
{
return;
}
_cursorAutoHide = enabled;
_lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp();
if (enabled)
{
_cursorIdleTimer ??= CreateCursorIdleTimer();
_cursorIdleTimer.Start();
return;
}
_cursorIdleTimer?.Stop();
ShowCursorNow();
}
private DispatcherTimer CreateCursorIdleTimer()
{
var timer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(250),
};
timer.Tick += (_, _) => HideCursorWhenIdle();
return timer;
}
private void HideCursorWhenIdle()
{
if (!_cursorAutoHide || _cursorHidden || _windowHandle == 0)
{
return;
}
var idleMs = (System.Diagnostics.Stopwatch.GetTimestamp() - _lastPointerActivity) *
1000 / System.Diagnostics.Stopwatch.Frequency;
if (idleMs < CursorHideDelayMs)
{
return;
}
// Only swallow the cursor while it is actually over the game surface;
// hovering launcher chrome (console, toolbar) must keep the arrow.
if (!GetCursorPos(out var point) || WindowFromPoint(point) != _windowHandle)
{
return;
}
_cursorHidden = true;
_ = SetCursor(0);
}
private void ShowCursorNow()
{
if (!_cursorHidden)
{
return;
}
_cursorHidden = false;
_ = SetCursor(LoadCursorW(0, IdcArrow));
}
private void ApplyPresentationVisibility()
{
if (_windowHandle == 0)
{
return;
}
var visible = _presentationVisible;
if (OperatingSystem.IsWindows())
{
// SW_HIDE can be ignored for a window's initial show state. Force
// the state through SetWindowPos so an old child swapchain cannot
// remain composed while the next game is loading.
var flags = SwpNoSize | SwpNoMove | SwpNoZOrder | SwpNoActivate |
(visible ? SwpShowWindow : SwpHideWindow);
_ = SetWindowPos(_windowHandle, 0, 0, 0, 0, 0, flags);
}
else if (OperatingSystem.IsLinux() && _x11Display != 0)
{
_ = visible
? XMapWindow(_x11Display, _windowHandle)
: XUnmapWindow(_x11Display, _windowHandle);
_ = XFlush(_x11Display);
}
else if (OperatingSystem.IsMacOS())
{
SendBool(_windowHandle, "setHidden:", !visible);
}
}
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle control)
{
PlatformHandle handle;
if (OperatingSystem.IsWindows())
{
handle = CreateWin32(control);
}
else if (OperatingSystem.IsLinux())
{
handle = CreateX11(control);
}
else if (OperatingSystem.IsMacOS())
{
handle = CreateMacOS();
}
else
{
throw new PlatformNotSupportedException("SharpEmu's embedded Vulkan surface is unsupported on this platform.");
}
UpdateSurfaceSize();
if (_surface is { } surface)
{
SurfaceAvailable?.Invoke(this, surface);
}
return handle;
}
protected override void DestroyNativeControlCore(IPlatformHandle control)
{
if (OperatingSystem.IsWindows())
{
SetCursorAutoHide(false);
}
var surface = _surface;
_surface = null;
if (OperatingSystem.IsWindows())
{
DestroyWin32();
}
else if (OperatingSystem.IsLinux())
{
DestroyX11();
}
else if (OperatingSystem.IsMacOS())
{
DestroyMacOS();
}
if (surface is not null)
{
SurfaceDestroyed?.Invoke(this, surface);
}
}
private PlatformHandle CreateWin32(IPlatformHandle control)
{
_win32ClassName = $"SharpEmuGameSurface-{Guid.NewGuid():N}";
_windowProcedure = WindowProcedureImpl;
var classInfo = new WndClassEx
{
Size = (uint)Marshal.SizeOf<WndClassEx>(),
Style = CsOwnDc,
WindowProcedure = Marshal.GetFunctionPointerForDelegate(_windowProcedure),
Instance = GetModuleHandleW(null),
ClassName = _win32ClassName,
};
if (RegisterClassExW(ref classInfo) == 0)
{
throw new InvalidOperationException($"Could not register the embedded game window class (Win32 error {Marshal.GetLastWin32Error()}).");
}
_windowHandle = CreateWindowExW(
0,
_win32ClassName,
"SharpEmu Game Surface",
WsChild | (_presentationVisible ? WsVisible : 0) | WsClipSiblings | WsClipChildren,
0,
0,
1,
1,
control.Handle,
0,
classInfo.Instance,
0);
if (_windowHandle == 0)
{
var error = Marshal.GetLastWin32Error();
_ = UnregisterClassW(_win32ClassName, classInfo.Instance);
throw new InvalidOperationException($"Could not create the embedded game window (Win32 error {error}).");
}
_surface = new VulkanHostSurface(
VulkanHostSurfaceKind.Win32,
_windowHandle,
classInfo.Instance);
return new PlatformHandle(_windowHandle, "HWND");
}
private PlatformHandle CreateX11(IPlatformHandle control)
{
_x11Display = XOpenDisplay(0);
if (_x11Display == 0)
{
throw new InvalidOperationException("Could not connect to the X11 server for the embedded game surface.");
}
_windowHandle = XCreateSimpleWindow(
_x11Display,
control.Handle,
0,
0,
1,
1,
0,
0,
0);
if (_windowHandle == 0)
{
XCloseDisplay(_x11Display);
_x11Display = 0;
throw new InvalidOperationException("Could not create the X11 embedded game surface.");
}
if (_presentationVisible)
{
_ = XMapWindow(_x11Display, _windowHandle);
}
_ = XFlush(_x11Display);
_surface = new VulkanHostSurface(VulkanHostSurfaceKind.Xlib, _windowHandle, _x11Display);
return new PlatformHandle(_windowHandle, "X11");
}
private PlatformHandle CreateMacOS()
{
_metalLayer = CreateObjectiveCObject("CAMetalLayer");
_windowHandle = CreateObjectiveCObject("NSView");
SendBool(_windowHandle, "setWantsLayer:", true);
SendPointer(_windowHandle, "setLayer:", _metalLayer);
SendBool(_windowHandle, "setHidden:", !_presentationVisible);
_surface = new VulkanHostSurface(VulkanHostSurfaceKind.Metal, _windowHandle, metalLayerHandle: _metalLayer);
return new PlatformHandle(_windowHandle, "NSView");
}
private void UpdateSurfaceSize()
{
if (_surface is null)
{
return;
}
var renderScale = (VisualRoot as TopLevel)?.RenderScaling ?? 1.0;
var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale));
var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale));
var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height;
if (Environment.GetEnvironmentVariable("SHARPEMU_TRACE_SURFACE_SIZE") == "1")
{
Console.Error.WriteLine(
$"[GUI][TRACE] GameSurfaceHost.UpdateSurfaceSize bounds={Bounds.Width}x{Bounds.Height} " +
$"scale={renderScale} computed={width}x{height} changed={sizeChanged} " +
$"prevSurface={_surface.PixelWidth}x{_surface.PixelHeight}");
}
_surface.UpdatePixelSize(width, height);
if (!sizeChanged)
{
return;
}
if (OperatingSystem.IsWindows() && _windowHandle != 0)
{
_ = SetWindowPos(
_windowHandle,
0,
0,
0,
width,
height,
SwpNoMove | SwpNoZOrder | SwpNoActivate);
}
else if (OperatingSystem.IsLinux() && _x11Display != 0 && _windowHandle != 0)
{
_ = XResizeWindow(_x11Display, _windowHandle, (uint)width, (uint)height);
_ = XFlush(_x11Display);
}
else if (OperatingSystem.IsMacOS() && _metalLayer != 0)
{
SendDouble(_metalLayer, "setContentsScale:", renderScale);
}
}
private void DestroyWin32()
{
if (_windowHandle != 0)
{
_ = DestroyWindow(_windowHandle);
_windowHandle = 0;
}
if (!string.IsNullOrWhiteSpace(_win32ClassName))
{
_ = UnregisterClassW(_win32ClassName, GetModuleHandleW(null));
_win32ClassName = null;
}
_windowProcedure = null;
}
private void DestroyX11()
{
if (_x11Display != 0 && _windowHandle != 0)
{
_ = XDestroyWindow(_x11Display, _windowHandle);
}
if (_x11Display != 0)
{
_ = XCloseDisplay(_x11Display);
}
_windowHandle = 0;
_x11Display = 0;
}
private void DestroyMacOS()
{
if (_windowHandle != 0)
{
SendVoid(_windowHandle, "release");
}
if (_metalLayer != 0)
{
SendVoid(_metalLayer, "release");
}
_windowHandle = 0;
_metalLayer = 0;
}
private nint WindowProcedureImpl(nint window, uint message, nint wParam, nint lParam)
{
if (message == WmMouseMove)
{
_lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp();
ShowCursorNow();
}
else if (message == WmSetCursor && _cursorHidden)
{
// Win32 re-resolves the cursor on every mouse message; returning
// TRUE here keeps the parent chain from restoring the arrow.
_ = SetCursor(0);
return 1;
}
return DefWindowProcW(window, message, wParam, lParam);
}
private static nint CreateObjectiveCObject(string className)
{
var classHandle = objc_getClass(className);
if (classHandle == 0)
{
throw new InvalidOperationException($"Objective-C class '{className}' is unavailable.");
}
var instance = objc_msgSend_id(classHandle, sel_registerName("alloc"));
instance = objc_msgSend_id(instance, sel_registerName("init"));
if (instance == 0)
{
throw new InvalidOperationException($"Could not create Objective-C '{className}'.");
}
return instance;
}
private static void SendVoid(nint receiver, string selector) =>
objc_msgSend_void(receiver, sel_registerName(selector));
private static void SendBool(nint receiver, string selector, bool value) =>
objc_msgSend_bool(receiver, sel_registerName(selector), value ? (byte)1 : (byte)0);
private static void SendPointer(nint receiver, string selector, nint value) =>
objc_msgSend_pointer(receiver, sel_registerName(selector), value);
private static void SendDouble(nint receiver, string selector, double value) =>
objc_msgSend_double(receiver, sel_registerName(selector), value);
private delegate nint WindowProcedure(nint window, uint message, nint wParam, nint lParam);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct WndClassEx
{
public uint Size;
public uint Style;
public nint WindowProcedure;
public int ClassExtra;
public int WindowExtra;
public nint Instance;
public nint Icon;
public nint Cursor;
public nint Background;
public string? MenuName;
public string? ClassName;
public nint IconSmall;
}
[DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", CharSet = CharSet.Unicode)]
private static extern nint GetModuleHandleW(string? moduleName);
[DllImport("user32.dll", EntryPoint = "RegisterClassExW", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern ushort RegisterClassExW(ref WndClassEx classInfo);
[DllImport("user32.dll", EntryPoint = "UnregisterClassW", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UnregisterClassW(string className, nint instance);
[DllImport("user32.dll", EntryPoint = "CreateWindowExW", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern nint CreateWindowExW(
uint extendedStyle,
string className,
string windowName,
uint style,
int x,
int y,
int width,
int height,
nint parent,
nint menu,
nint instance,
nint parameter);
[DllImport("user32.dll", EntryPoint = "DestroyWindow", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyWindow(nint window);
[DllImport("user32.dll", EntryPoint = "SetWindowPos", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetWindowPos(
nint window,
nint insertAfter,
int x,
int y,
int width,
int height,
uint flags);
[DllImport("user32.dll", EntryPoint = "DefWindowProcW", CharSet = CharSet.Unicode)]
private static extern nint DefWindowProcW(nint window, uint message, nint wParam, nint lParam);
[DllImport("user32.dll", EntryPoint = "SetCursor")]
private static extern nint SetCursor(nint cursor);
[DllImport("user32.dll", EntryPoint = "LoadCursorW", CharSet = CharSet.Unicode)]
private static extern nint LoadCursorW(nint instance, nint cursorName);
[DllImport("user32.dll", EntryPoint = "GetCursorPos")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetCursorPos(out NativePoint point);
[DllImport("user32.dll", EntryPoint = "WindowFromPoint")]
private static extern nint WindowFromPoint(NativePoint point);
[StructLayout(LayoutKind.Sequential)]
private struct NativePoint
{
public int X;
public int Y;
}
[DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
private static extern nint XOpenDisplay(nint displayName);
[DllImport("libX11.so.6", EntryPoint = "XCreateSimpleWindow")]
private static extern nint XCreateSimpleWindow(
nint display,
nint parent,
int x,
int y,
uint width,
uint height,
uint borderWidth,
ulong border,
ulong background);
[DllImport("libX11.so.6", EntryPoint = "XMapWindow")]
private static extern int XMapWindow(nint display, nint window);
[DllImport("libX11.so.6", EntryPoint = "XUnmapWindow")]
private static extern int XUnmapWindow(nint display, nint window);
[DllImport("libX11.so.6", EntryPoint = "XResizeWindow")]
private static extern int XResizeWindow(nint display, nint window, uint width, uint height);
[DllImport("libX11.so.6", EntryPoint = "XDestroyWindow")]
private static extern int XDestroyWindow(nint display, nint window);
[DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
private static extern int XCloseDisplay(nint display);
[DllImport("libX11.so.6", EntryPoint = "XFlush")]
private static extern int XFlush(nint display);
[DllImport("/usr/lib/libobjc.A.dylib")]
private static extern nint objc_getClass(string name);
[DllImport("/usr/lib/libobjc.A.dylib")]
private static extern nint sel_registerName(string name);
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
private static extern nint objc_msgSend_id(nint receiver, nint selector);
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
private static extern void objc_msgSend_void(nint receiver, nint selector);
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
private static extern void objc_msgSend_bool(nint receiver, nint selector, byte value);
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
private static extern void objc_msgSend_pointer(nint receiver, nint selector, nint value);
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
private static extern void objc_msgSend_double(nint receiver, nint selector, double value);
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Text;
namespace SharpEmu.GUI;
/// <summary>
/// Mirrors process-wide console output into the launcher console while
/// retaining the original streams for shell users and file logging.
/// </summary>
internal sealed class GuiConsoleMirror : IDisposable
{
private readonly TextWriter _originalOut;
private readonly TextWriter _originalError;
private int _disposed;
private GuiConsoleMirror(Action<string, bool> writeLine)
{
_originalOut = Console.Out;
_originalError = Console.Error;
Console.SetOut(new MirroringWriter(_originalOut, line => writeLine(line, false)));
Console.SetError(new MirroringWriter(_originalError, line => writeLine(line, true)));
}
public static GuiConsoleMirror Install(Action<string, bool> writeLine) => new(writeLine);
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
Console.SetOut(_originalOut);
Console.SetError(_originalError);
}
private sealed class MirroringWriter : TextWriter
{
private readonly TextWriter _inner;
private readonly Action<string> _writeLine;
private readonly StringBuilder _line = new();
private readonly object _gate = new();
public MirroringWriter(TextWriter inner, Action<string> writeLine)
{
_inner = inner;
_writeLine = writeLine;
}
public override Encoding Encoding => _inner.Encoding;
public override void Write(char value)
{
lock (_gate)
{
_inner.Write(value);
Append(value);
}
}
public override void Write(string? value)
{
if (value is null)
{
return;
}
lock (_gate)
{
_inner.Write(value);
foreach (var character in value)
{
Append(character);
}
}
}
public override void WriteLine(string? value)
{
lock (_gate)
{
_inner.WriteLine(value);
if (!string.IsNullOrEmpty(value))
{
_line.Append(value);
}
FlushLine();
}
}
private void Append(char value)
{
if (value == '\r')
{
return;
}
if (value == '\n')
{
FlushLine();
return;
}
_line.Append(value);
}
private void FlushLine()
{
_writeLine(_line.ToString());
_line.Clear();
}
}
}
+37 -1
View File
@@ -53,6 +53,9 @@ public sealed class GuiSettings
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
public List<string> EnvironmentToggles { get; set; } = new();
/// <summary>Internal render resolution scale (1.0 = native, 0.5 = half).</summary>
public double RenderResolutionScale { get; set; } = 1.0;
/// <summary>
/// Discord application ID used for Rich Presence; the default is the
/// SharpEmu application. Override to rebrand what Discord shows as
@@ -71,7 +74,7 @@ public sealed class GuiSettings
if (File.Exists(SettingsPath))
{
var json = File.ReadAllText(SettingsPath);
return JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
return NormalizeFromJson(json);
}
}
catch (Exception)
@@ -82,6 +85,39 @@ public sealed class GuiSettings
return new GuiSettings();
}
/// <summary>
/// Deserializes settings and normalizes null references and null or empty list
/// entries introduced by JSON. Empty scalar strings remain unchanged.
/// </summary>
internal static GuiSettings NormalizeFromJson(string json)
{
var settings = JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
settings.GameFolders = FilterNullOrEmpty(settings.GameFolders);
settings.ExcludedGames = FilterNullOrEmpty(settings.ExcludedGames);
settings.EnvironmentToggles = FilterNullOrEmpty(settings.EnvironmentToggles);
settings.LogLevel ??= "Info";
settings.Language ??= "en";
settings.DiscordClientId ??= "1525606762248540221";
if (settings.RenderResolutionScale <= 0 || settings.RenderResolutionScale > 2.0)
{
settings.RenderResolutionScale = 1.0;
}
return settings;
}
// JSON can populate non-nullable lists with null references and entries.
private static List<string> FilterNullOrEmpty(List<string>? source)
{
if (source is null)
{
return [];
}
return source.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
}
public void Save()
{
try
+45 -2
View File
@@ -125,5 +125,48 @@
"Dialog.PsExecutables": "ملفات PS التنفيذية",
"Dialog.SaveLogFile": "حدد مكان حفظ ملف السجل",
"Dialog.PlainTextFiles": "ملفات نصية عادية",
"Dialog.LogFiles": "ملفات السجل"
}
"Dialog.LogFiles": "ملفات السجل",
"Library.Context.GameSettings": "إعدادات اللعبة…",
"Options.Env.Tab": "البيئة",
"Options.Section.Environment": "متغيرات البيئة",
"Options.Env.Desc": "خيارات تُمرر إلى المحاكي كمتغيرات بيئة عند التشغيل.",
"Options.Env.Bthid.Desc": "الإبلاغ عن أن Bluetooth HID غير متاح للألعاب التي تنتظر برمجيات عجلة القيادة/FFB فيها إلى ما لا نهاية.\nاتركه معطلاً عادةً. بعض الألعاب تتجمد عند فشل التهيئة.",
"Options.Env.LoopGuard.Desc": "عدم إجبار الألعاب التي تكرر النداء نفسه لفترة طويلة على الإغلاق.\nجرّب هذا عندما تُغلق لعبة نفسها أثناء التحميل.",
"Options.Env.WritableApp0.Desc": "السماح للألعاب بإنشاء الملفات والكتابة داخل مجلد التثبيت الخاص بها.\nمطلوب للنسخ غير المحزومة التي تكتب بيانات الحفظ أو الإعدادات تحت ‎/app0.",
"Options.Env.VkValidation.Desc": "تفعيل طبقات التحقق في Vulkan لتصحيح أخطاء وحدة معالجة الرسوميات.\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) في وحدة التحكم.",
"Common.Save": "حفظ",
"Common.Cancel": "إلغاء",
"PerGame.Title": "إعدادات خاصة باللعبة — {0} ({1})",
"PerGame.InheritNote": "الصفوف غير المحددة ترث الإعدادات الافتراضية العامة.",
"PerGame.EnvToggles.Label": "مفاتيح البيئة",
"PerGame.EnvToggles.Desc": "تجاوز المجموعة العامة من مفاتيح ‎SHARPEMU_*‎ لهذه اللعبة.",
"Options.About": "حول",
"About.Github.Label": "GitHub",
"About.Github.Desc": "الكود المصدري والمشكلات وتطوير المشروع.",
"About.Github.LatestCommitLabel": "أحدث Commit",
"About.Github.LatestCommitDescription": "أحدث commit على الفرع main",
"About.Discord.Label": "دسكورد",
"About.Discord.Desc": "انضم إلى المجتمع واحصل على الدعم وتابع التطوير.",
"About.GithubButton": "ساهم على GitHub!",
"About.DiscordButton": "انضم إلى دسكوردنا!",
"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.ChecksumFailed": "فشل التحديث المنزَّل في اجتياز تحقق SHA-256.",
"Updater.Status.Unsupported": "يتطلب التحديث التلقائي إصدار x64 لنظام Windows أو Linux أو macOS."
}
+28 -1
View File
@@ -142,5 +142,32 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Participe da comunidade, obtenha suporte e acompanhe o desenvolvimento.",
"About.GithubButton": "Contribua no GitHub!",
"About.DiscordButton": "Entre no nosso Discord!"
"About.DiscordButton": "Entre no nosso Discord!",
"Library.Context.GameSettings": "Configurações do jogo…",
"Options.Env.WritableApp0.Desc": "Permite que os jogos criem e gravem arquivos dentro da própria pasta de instalação.\nNecessário para dumps não empacotados que gravam seus saves ou configurações em /app0.",
"Options.Env.LogIo.Desc": "Registra no console a abertura e leitura de arquivos e a resolução de caminhos.\nUse quando um jogo não encontrar seus arquivos de dados durante a inicialização.",
"Common.Save": "Salvar",
"Common.Cancel": "Cancelar",
"PerGame.Title": "Configurações por jogo — {0} ({1})",
"PerGame.InheritNote": "As linhas desmarcadas herdam os padrões globais.",
"PerGame.EnvToggles.Label": "Variáveis de ambiente",
"PerGame.EnvToggles.Desc": "Substitui o conjunto global de opções SHARPEMU_* para este jogo.",
"About.Github.LatestCommitLabel": "Último commit",
"About.Github.LatestCommitDescription": "Último commit na branch main",
"Updater.Auto.Label": "Verificar atualizações ao iniciar",
"Updater.Auto.Desc": "Consulta o GitHub sem atrasar a inicialização.",
"Updater.Label": "Atualizações",
"Updater.Check": "Verificar atualizações",
"Updater.DownloadRestart": "Baixar e reiniciar",
"Updater.Status.Ready": "Build atual: {0}",
"Updater.Status.Checking": "Verificando atualizações…",
"Updater.Status.Current": "Você está atualizado ({0}).",
"Updater.Status.Available": "Um novo build está disponível: {0}",
"Updater.Status.Downloading": "Baixando atualização… {0}%",
"Updater.Status.Installing": "Instalando atualização…",
"Updater.Status.Timeout": "A verificação de atualizações expirou após 10 segundos.",
"Updater.Status.Failed": "Não foi possível verificar as atualizações.",
"Updater.Status.ChecksumFailed": "A atualização baixada falhou na verificação SHA-256.",
"Updater.Status.Unsupported": "A atualização automática requer um build x64 para Windows, Linux ou macOS."
}
+44 -1
View File
@@ -125,5 +125,48 @@
"Dialog.PsExecutables": "PS-Ausführbare Dateien",
"Dialog.SaveLogFile": "Protokolldatei speichern unter",
"Dialog.PlainTextFiles": "Textdateien",
"Dialog.LogFiles": "Protokolldateien"
"Dialog.LogFiles": "Protokolldateien",
"Library.Context.GameSettings": "Spieleinstellungen…",
"Options.Env.Tab": "Umgebung",
"Options.Section.Environment": "UMGEBUNGSVARIABLEN",
"Options.Env.Desc": "Schalter, die dem Emulator beim Start als Umgebungsvariablen übergeben werden.",
"Options.Env.Bthid.Desc": "Bluetooth-HID als nicht verfügbar melden, wenn die Lenkrad-/FFB-Middleware eines Titels endlos wartet.\nNormalerweise ausgeschaltet lassen. Manche Titel frieren ein, wenn die Initialisierung fehlschlägt.",
"Options.Env.LoopGuard.Desc": "Titel nicht zwangsweise beenden, wenn sie denselben Aufruf zu lange wiederholen.\nAusprobieren, wenn ein Spiel sich beim Laden von selbst beendet.",
"Options.Env.WritableApp0.Desc": "Titeln erlauben, Dateien in ihrem Installationsordner anzulegen und zu schreiben.\nNötig für entpackte Dumps, die ihre Spielstände oder Konfiguration unter /app0 speichern.",
"Options.Env.VkValidation.Desc": "Vulkan-Validierungsschichten für GPU-Debugging aktivieren.\nLangsam. Erfordert ein installiertes Vulkan SDK.",
"Options.Env.DumpSpirv.Desc": "AGC-Shader und ihre SPIR-V-Übersetzungen im Ordner shader-dumps ablegen.\nBeim Melden von Shader- oder Grafikfehlern verwenden.",
"Options.Env.LogDirectMemory.Desc": "Direkte Speicherzuweisungen und Fehler in der Konsole protokollieren.\nVerwenden, wenn ein Spiel beim Start abbricht oder sich beendet.",
"Options.Env.LogIo.Desc": "Datei-Öffnen, -Lesen und Pfadauflösung in der Konsole protokollieren.\nVerwenden, wenn ein Spiel beim Start seine Datendateien nicht findet.",
"Options.Env.LogNp.Desc": "NP-Bibliotheksaufrufe (PlayStation Network) in der Konsole protokollieren.",
"Common.Save": "Speichern",
"Common.Cancel": "Abbrechen",
"PerGame.Title": "Spielspezifische Einstellungen — {0} ({1})",
"PerGame.InheritNote": "Nicht angehakte Zeilen übernehmen die globalen Standardwerte.",
"PerGame.EnvToggles.Label": "Umgebungsschalter",
"PerGame.EnvToggles.Desc": "Die globalen SHARPEMU_*-Schalter für dieses Spiel überschreiben.",
"Options.About": "Über",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Quellcode, Issues und Projektentwicklung.",
"About.Github.LatestCommitLabel": "Neuester Commit",
"About.Github.LatestCommitDescription": "Neuester Commit auf dem main-Branch",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Tritt der Community bei, erhalte Support und verfolge die Entwicklung.",
"About.GithubButton": "Auf GitHub mitwirken!",
"About.DiscordButton": "Tritt unserem Discord bei!",
"Updater.Auto.Label": "Beim Start nach Updates suchen",
"Updater.Auto.Desc": "Fragt GitHub ab, ohne den Start zu verzögern.",
"Updater.Label": "Updates",
"Updater.Check": "Nach Updates suchen",
"Updater.DownloadRestart": "Herunterladen und neu starten",
"Updater.Status.Ready": "Aktueller Build: {0}",
"Updater.Status.Checking": "Suche nach Updates…",
"Updater.Status.Current": "Du bist auf dem neuesten Stand ({0}).",
"Updater.Status.Available": "Ein neuer Build ist verfügbar: {0}",
"Updater.Status.Downloading": "Update wird heruntergeladen… {0}%",
"Updater.Status.Installing": "Update wird installiert…",
"Updater.Status.Timeout": "Die Updateprüfung ist nach 10 Sekunden abgelaufen.",
"Updater.Status.Failed": "Updates konnten nicht geprüft werden.",
"Updater.Status.ChecksumFailed": "Das heruntergeladene Update hat die SHA-256-Prüfung nicht bestanden.",
"Updater.Status.Unsupported": "Automatische Updates erfordern einen x64-Build für Windows, Linux oder macOS."
}
+44 -1
View File
@@ -125,5 +125,48 @@
"Dialog.PsExecutables": "PS-programmer",
"Dialog.SaveLogFile": "Vælg hvor logfilen skal gemmes",
"Dialog.PlainTextFiles": "Almindelige tekstfiler",
"Dialog.LogFiles": "Logfiler"
"Dialog.LogFiles": "Logfiler",
"Library.Context.GameSettings": "Spilindstillinger…",
"Options.Env.Tab": "Miljø",
"Options.Section.Environment": "MILJØVARIABLER",
"Options.Env.Desc": "Kontakter, der gives videre til emulatoren som miljøvariabler ved start.",
"Options.Env.Bthid.Desc": "Rapportér Bluetooth HID som utilgængelig for titler, hvis rat-/FFB-middleware venter i det uendelige.\nLad den normalt være slået fra. Nogle titler fryser, når initialiseringen fejler.",
"Options.Env.LoopGuard.Desc": "Tving ikke titler til at lukke, når de gentager det samme kald for længe.\nPrøv dette, når et spil lukker af sig selv under indlæsning.",
"Options.Env.WritableApp0.Desc": "Tillad titler at oprette og skrive filer i deres installationsmappe.\nKræves af upakkede dumps, der skriver deres gemte data eller konfiguration under /app0.",
"Options.Env.VkValidation.Desc": "Aktivér Vulkan-valideringslag til GPU-fejlfinding.\nLangsomt. Kræver at Vulkan SDK er installeret.",
"Options.Env.DumpSpirv.Desc": "Gem AGC-shadere og deres SPIR-V-oversættelser i mappen shader-dumps.\nBrug dette, når du rapporterer shader- eller grafikfejl.",
"Options.Env.LogDirectMemory.Desc": "Log direkte hukommelsestildelinger og fejl til konsollen.\nBrug dette, når et spil afbryder eller lukker under opstart.",
"Options.Env.LogIo.Desc": "Log åbning og læsning af filer samt stiopslag til konsollen.\nBrug dette, når et spil ikke kan finde sine datafiler under opstart.",
"Options.Env.LogNp.Desc": "Log NP-bibliotekskald (PlayStation Network) til konsollen.",
"Common.Save": "Gem",
"Common.Cancel": "Annuller",
"PerGame.Title": "Indstillinger pr. spil — {0} ({1})",
"PerGame.InheritNote": "Umarkerede rækker arver de globale standardværdier.",
"PerGame.EnvToggles.Label": "Miljøkontakter",
"PerGame.EnvToggles.Desc": "Tilsidesæt det globale sæt SHARPEMU_*-kontakter for dette spil.",
"Options.About": "Om",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Kildekode, issues og projektudvikling.",
"About.Github.LatestCommitLabel": "Seneste commit",
"About.Github.LatestCommitDescription": "Seneste commit på main-branchen",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Bliv en del af fællesskabet, få hjælp og følg udviklingen.",
"About.GithubButton": "Bidrag på GitHub!",
"About.DiscordButton": "Bliv medlem af vores Discord!",
"Updater.Auto.Label": "Søg efter opdateringer ved start",
"Updater.Auto.Desc": "Tjekker GitHub uden at forsinke opstarten.",
"Updater.Label": "Opdateringer",
"Updater.Check": "Søg efter opdateringer",
"Updater.DownloadRestart": "Download og genstart",
"Updater.Status.Ready": "Nuværende build: {0}",
"Updater.Status.Checking": "Søger efter opdateringer…",
"Updater.Status.Current": "Du er opdateret ({0}).",
"Updater.Status.Available": "Et nyt build er tilgængeligt: {0}",
"Updater.Status.Downloading": "Downloader opdatering… {0}%",
"Updater.Status.Installing": "Installerer opdatering…",
"Updater.Status.Timeout": "Opdateringstjekket fik timeout efter 10 sekunder.",
"Updater.Status.Failed": "Kunne ikke søge efter opdateringer.",
"Updater.Status.ChecksumFailed": "Den downloadede opdatering bestod ikke SHA-256-verifikationen.",
"Updater.Status.Unsupported": "Automatisk opdatering kræver et x64-build til Windows, Linux eller macOS."
}
+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."
}
+35 -1
View File
@@ -135,5 +135,39 @@
"About.Github.LatestCommitDescription": "Último commit en la rama main",
"About.Discord.Desc": "Únete a la comunidad, recibe soporte y sigue el desarrollo.",
"About.GithubButton": "Contribuye en GitHub!",
"About.DiscordButton": "Únete a nuestro Discord!"
"About.DiscordButton": "Únete a nuestro Discord!",
"Library.Context.GameSettings": "Ajustes del juego…",
"Options.Env.Tab": "Entorno",
"Options.Section.Environment": "VARIABLES DE ENTORNO",
"Options.Env.Desc": "Opciones que se pasan al emulador como variables de entorno al iniciar.",
"Options.Env.Bthid.Desc": "Indicar que Bluetooth HID no está disponible para títulos cuyo middleware de volante/FFB espera indefinidamente.\nDéjalo desactivado normalmente. Algunos títulos se congelan cuando la inicialización falla.",
"Options.Env.LoopGuard.Desc": "No forzar el cierre de títulos que repiten la misma llamada durante demasiado tiempo.\nPruébalo cuando un juego se cierre solo durante la carga.",
"Options.Env.WritableApp0.Desc": "Permitir que los títulos creen y escriban archivos dentro de su carpeta de instalación.\nNecesario para dumps sin empaquetar que guardan sus datos o configuración en /app0.",
"Options.Env.VkValidation.Desc": "Activar las capas de validación de Vulkan para depurar la GPU.\nLento. Requiere tener instalado el SDK de Vulkan.",
"Options.Env.DumpSpirv.Desc": "Volcar los shaders AGC y sus traducciones SPIR-V a la carpeta shader-dumps.\nÚsalo al informar de errores de shaders o de renderizado.",
"Options.Env.LogDirectMemory.Desc": "Registrar en la consola las asignaciones de memoria directa y sus fallos.\nÚsalo cuando un juego se aborte o se cierre durante el arranque.",
"Options.Env.LogIo.Desc": "Registrar en la consola la apertura y lectura de archivos y la resolución de rutas.\nÚsalo cuando un juego no encuentre sus archivos de datos durante el arranque.",
"Options.Env.LogNp.Desc": "Registrar en la consola las llamadas a la biblioteca NP (PlayStation Network).",
"Common.Save": "Guardar",
"Common.Cancel": "Cancelar",
"PerGame.Title": "Ajustes por juego — {0} ({1})",
"PerGame.InheritNote": "Las filas sin marcar heredan los valores globales.",
"PerGame.EnvToggles.Label": "Variables de entorno",
"PerGame.EnvToggles.Desc": "Sustituir el conjunto global de opciones SHARPEMU_* para este juego.",
"Updater.Auto.Label": "Buscar actualizaciones al iniciar",
"Updater.Auto.Desc": "Consulta GitHub sin retrasar el arranque.",
"Updater.Label": "Actualizaciones",
"Updater.Check": "Buscar actualizaciones",
"Updater.DownloadRestart": "Descargar y reiniciar",
"Updater.Status.Ready": "Build actual: {0}",
"Updater.Status.Checking": "Buscando actualizaciones…",
"Updater.Status.Current": "Estás al día ({0}).",
"Updater.Status.Available": "Hay un nuevo build disponible: {0}",
"Updater.Status.Downloading": "Descargando actualización… {0}%",
"Updater.Status.Installing": "Instalando actualización…",
"Updater.Status.Timeout": "La comprobación de actualizaciones caducó tras 10 segundos.",
"Updater.Status.Failed": "No se pudieron comprobar las actualizaciones.",
"Updater.Status.ChecksumFailed": "La actualización descargada no superó la verificación SHA-256.",
"Updater.Status.Unsupported": "La actualización automática requiere un build x64 de Windows, Linux o macOS."
}
+28 -1
View File
@@ -142,5 +142,32 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Rejoignez la communauté, obtenez de laide et suivez le développement.",
"About.GithubButton": "Contribuer sur GitHub !",
"About.DiscordButton": "Rejoindre notre Discord !"
"About.DiscordButton": "Rejoindre notre Discord !",
"Library.Context.GameSettings": "Paramètres du jeu…",
"Options.Env.WritableApp0.Desc": "Autoriser les jeux à créer et écrire des fichiers dans leur dossier dinstallation.\nNécessaire pour les dumps non empaquetés qui écrivent leurs sauvegardes ou leur configuration sous /app0.",
"Options.Env.LogIo.Desc": "Journaliser louverture et la lecture des fichiers ainsi que la résolution des chemins dans la console.\nÀ utiliser quand un jeu ne trouve pas ses fichiers de données au démarrage.",
"Common.Save": "Enregistrer",
"Common.Cancel": "Annuler",
"PerGame.Title": "Paramètres par jeu — {0} ({1})",
"PerGame.InheritNote": "Les lignes non cochées héritent des valeurs globales par défaut.",
"PerGame.EnvToggles.Label": "Variables denvironnement",
"PerGame.EnvToggles.Desc": "Remplacer lensemble global des options SHARPEMU_* pour ce jeu.",
"About.Github.LatestCommitLabel": "Dernier commit",
"About.Github.LatestCommitDescription": "Dernier commit sur la branche main",
"Updater.Auto.Label": "Vérifier les mises à jour au démarrage",
"Updater.Auto.Desc": "Interroge GitHub sans retarder le démarrage.",
"Updater.Label": "Mises à jour",
"Updater.Check": "Vérifier les mises à jour",
"Updater.DownloadRestart": "Télécharger et redémarrer",
"Updater.Status.Ready": "Build actuel : {0}",
"Updater.Status.Checking": "Recherche de mises à jour…",
"Updater.Status.Current": "Vous êtes à jour ({0}).",
"Updater.Status.Available": "Un nouveau build est disponible : {0}",
"Updater.Status.Downloading": "Téléchargement de la mise à jour… {0}%",
"Updater.Status.Installing": "Installation de la mise à jour…",
"Updater.Status.Timeout": "La vérification des mises à jour a expiré après 10 secondes.",
"Updater.Status.Failed": "Impossible de vérifier les mises à jour.",
"Updater.Status.ChecksumFailed": "La mise à jour téléchargée a échoué à la vérification SHA-256.",
"Updater.Status.Unsupported": "La mise à jour automatique nécessite un build x64 pour Windows, Linux ou macOS."
}
+29 -2
View File
@@ -142,5 +142,32 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Csatlakozz a közösséghe, kérj segítéget és kövesd nyomon a fejlesztést.",
"About.GithubButton": "Járulj hozzá GitHubon!",
"About.DiscordButton": "Csatlakozz a Discordunhoz!"
}
"About.DiscordButton": "Csatlakozz a Discordunhoz!",
"Library.Context.GameSettings": "Játékbeállítások…",
"Options.Env.WritableApp0.Desc": "Engedélyezi, hogy a játékok fájlokat hozzanak létre és írjanak a telepítési mappájukban.\nA kicsomagolt dumpokhoz szükséges, amelyek a mentéseiket vagy beállításaikat az /app0 alá írják.",
"Options.Env.LogIo.Desc": "A fájlmegnyitások, olvasások és útvonal-feloldások naplózása a konzolra.\nAkkor használd, ha egy játék indításkor nem találja az adatfájljait.",
"Common.Save": "Mentés",
"Common.Cancel": "Mégse",
"PerGame.Title": "Játékonkénti beállítások — {0} ({1})",
"PerGame.InheritNote": "A be nem jelölt sorok a globális alapértelmezéseket öröklik.",
"PerGame.EnvToggles.Label": "Környezeti kapcsolók",
"PerGame.EnvToggles.Desc": "A globális SHARPEMU_* kapcsolókészlet felülírása ennél a játéknál.",
"About.Github.LatestCommitLabel": "Legutóbbi commit",
"About.Github.LatestCommitDescription": "A main ág legutóbbi commitja",
"Updater.Auto.Label": "Frissítések keresése indításkor",
"Updater.Auto.Desc": "A GitHubot az indítás késleltetése nélkül ellenőrzi.",
"Updater.Label": "Frissítések",
"Updater.Check": "Frissítések keresése",
"Updater.DownloadRestart": "Letöltés és újraindítás",
"Updater.Status.Ready": "Jelenlegi build: {0}",
"Updater.Status.Checking": "Frissítések keresése…",
"Updater.Status.Current": "Naprakész vagy ({0}).",
"Updater.Status.Available": "Új build érhető el: {0}",
"Updater.Status.Downloading": "Frissítés letöltése… {0}%",
"Updater.Status.Installing": "Frissítés telepítése…",
"Updater.Status.Timeout": "A frissítés-ellenőrzés 10 másodperc után túllépte az időkorlátot.",
"Updater.Status.Failed": "Nem sikerült frissítéseket keresni.",
"Updater.Status.ChecksumFailed": "A letöltött frissítés nem ment át az SHA-256-ellenőrzésen.",
"Updater.Status.Unsupported": "Az automatikus frissítéshez Windows, Linux vagy macOS x64 build szükséges."
}
+44 -1
View File
@@ -130,5 +130,48 @@
"Dialog.PsExecutables": "Eseguibili PS",
"Dialog.SaveLogFile": "Scegli dove salvare il file di log",
"Dialog.PlainTextFiles": "File di testo semplice",
"Dialog.LogFiles": "File di log"
"Dialog.LogFiles": "File di log",
"Library.Context.GameSettings": "Impostazioni del gioco…",
"Options.Env.Tab": "Ambiente",
"Options.Section.Environment": "VARIABILI D'AMBIENTE",
"Options.Env.Desc": "Opzioni passate all'emulatore come variabili d'ambiente all'avvio.",
"Options.Env.Bthid.Desc": "Segnala il Bluetooth HID come non disponibile per i titoli il cui middleware volante/FFB attende all'infinito.\nNormalmente lascialo disattivato. Alcuni titoli si bloccano quando l'inizializzazione fallisce.",
"Options.Env.LoopGuard.Desc": "Non forzare la chiusura dei titoli che ripetono la stessa chiamata troppo a lungo.\nProvalo quando un gioco si chiude da solo durante il caricamento.",
"Options.Env.WritableApp0.Desc": "Consenti ai titoli di creare e scrivere file nella propria cartella di installazione.\nNecessario per i dump non pacchettizzati che scrivono salvataggi o configurazioni in /app0.",
"Options.Env.VkValidation.Desc": "Abilita i validation layer di Vulkan per il debug della GPU.\nLento. Richiede l'SDK di Vulkan installato.",
"Options.Env.DumpSpirv.Desc": "Esporta gli shader AGC e le loro traduzioni SPIR-V nella cartella shader-dumps.\nUsalo quando segnali bug di shader o di rendering.",
"Options.Env.LogDirectMemory.Desc": "Registra in console le allocazioni di memoria diretta e i relativi errori.\nUsalo quando un gioco si interrompe o si chiude durante l'avvio.",
"Options.Env.LogIo.Desc": "Registra in console l'apertura e la lettura dei file e la risoluzione dei percorsi.\nUsalo quando un gioco non trova i propri file di dati durante l'avvio.",
"Options.Env.LogNp.Desc": "Registra in console le chiamate alla libreria NP (PlayStation Network).",
"Common.Save": "Salva",
"Common.Cancel": "Annulla",
"PerGame.Title": "Impostazioni per gioco — {0} ({1})",
"PerGame.InheritNote": "Le righe non selezionate ereditano i valori globali.",
"PerGame.EnvToggles.Label": "Variabili d'ambiente",
"PerGame.EnvToggles.Desc": "Sovrascrivi l'insieme globale delle opzioni SHARPEMU_* per questo gioco.",
"Options.About": "Informazioni",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Codice sorgente, issue e sviluppo del progetto.",
"About.Github.LatestCommitLabel": "Ultimo commit",
"About.Github.LatestCommitDescription": "Ultimo commit sul branch main",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Unisciti alla community, ricevi supporto e segui lo sviluppo.",
"About.GithubButton": "Contribuisci su GitHub!",
"About.DiscordButton": "Unisciti al nostro Discord!",
"Updater.Auto.Label": "Controlla aggiornamenti all'avvio",
"Updater.Auto.Desc": "Interroga GitHub senza rallentare l'avvio.",
"Updater.Label": "Aggiornamenti",
"Updater.Check": "Controlla aggiornamenti",
"Updater.DownloadRestart": "Scarica e riavvia",
"Updater.Status.Ready": "Build attuale: {0}",
"Updater.Status.Checking": "Ricerca aggiornamenti…",
"Updater.Status.Current": "Sei aggiornato ({0}).",
"Updater.Status.Available": "È disponibile un nuovo build: {0}",
"Updater.Status.Downloading": "Download dell'aggiornamento… {0}%",
"Updater.Status.Installing": "Installazione dell'aggiornamento…",
"Updater.Status.Timeout": "Il controllo degli aggiornamenti è scaduto dopo 10 secondi.",
"Updater.Status.Failed": "Impossibile controllare gli aggiornamenti.",
"Updater.Status.ChecksumFailed": "L'aggiornamento scaricato non ha superato la verifica SHA-256.",
"Updater.Status.Unsupported": "L'aggiornamento automatico richiede un build x64 per Windows, Linux o macOS."
}
+45 -2
View File
@@ -125,5 +125,48 @@
"Dialog.PsExecutables": "PlayStation 実行ファイル",
"Dialog.SaveLogFile": "ログファイルの保存先を選択",
"Dialog.PlainTextFiles": "プレーンテキストファイル",
"Dialog.LogFiles": "ログファイル"
}
"Dialog.LogFiles": "ログファイル",
"Library.Context.GameSettings": "ゲーム設定…",
"Options.Env.Tab": "環境",
"Options.Section.Environment": "環境変数",
"Options.Env.Desc": "起動時に環境変数としてエミュレータへ渡されるスイッチです。",
"Options.Env.Bthid.Desc": "ハンドル/FFBミドルウェアが永久に待機するタイトル向けに、Bluetooth HIDを利用不可として報告します。\n通常はオフのままにしてください。初期化に失敗するとフリーズするタイトルもあります。",
"Options.Env.LoopGuard.Desc": "同じ呼び出しを長時間繰り返すタイトルを強制終了しません。\nロード中にゲームが勝手に終了する場合に試してください。",
"Options.Env.WritableApp0.Desc": "タイトルがインストールフォルダー内にファイルを作成・書き込みできるようにします。\nセーブや設定データを/app0以下に書き込む未パッケージのダンプに必要です。",
"Options.Env.VkValidation.Desc": "GPUデバッグ用のVulkan検証レイヤーを有効にします。\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": "NPPlayStation Network)ライブラリの呼び出しをコンソールに記録します。",
"Common.Save": "保存",
"Common.Cancel": "キャンセル",
"PerGame.Title": "ゲームごとの設定 — {0} ({1})",
"PerGame.InheritNote": "チェックされていない行はグローバルの既定値を継承します。",
"PerGame.EnvToggles.Label": "環境スイッチ",
"PerGame.EnvToggles.Desc": "このゲームに対してグローバルのSHARPEMU_*スイッチを上書きします。",
"Options.About": "情報",
"About.Github.Label": "GitHub",
"About.Github.Desc": "ソースコード、Issue、プロジェクトの開発。",
"About.Github.LatestCommitLabel": "最新コミット",
"About.Github.LatestCommitDescription": "mainブランチの最新コミット",
"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.ChecksumFailed": "ダウンロードしたアップデートはSHA-256検証に失敗しました。",
"Updater.Status.Unsupported": "自動アップデートにはWindows、Linux、またはmacOSのx64ビルドが必要です。"
}
+45 -2
View File
@@ -125,5 +125,48 @@
"Dialog.PsExecutables": "PlayStation 실행 파일",
"Dialog.SaveLogFile": "로그 파일 저장 위치 선택",
"Dialog.PlainTextFiles": "일반 텍스트 파일",
"Dialog.LogFiles": "로그 파일"
}
"Dialog.LogFiles": "로그 파일",
"Library.Context.GameSettings": "게임 설정…",
"Options.Env.Tab": "환경",
"Options.Section.Environment": "환경 변수",
"Options.Env.Desc": "실행 시 환경 변수로 에뮬레이터에 전달되는 스위치입니다.",
"Options.Env.Bthid.Desc": "휠/FFB 미들웨어가 무한 대기하는 타이틀을 위해 블루투스 HID를 사용 불가로 보고합니다.\n평소에는 꺼 두세요. 초기화에 실패하면 멈추는 타이틀도 있습니다.",
"Options.Env.LoopGuard.Desc": "같은 호출을 너무 오래 반복하는 타이틀을 강제 종료하지 않습니다.\n게임이 로딩 중 저절로 종료될 때 시도해 보세요.",
"Options.Env.WritableApp0.Desc": "타이틀이 설치 폴더 안에 파일을 만들고 쓸 수 있도록 허용합니다.\n세이브나 설정 데이터를 /app0 아래에 쓰는 비패키지 덤프에 필요합니다.",
"Options.Env.VkValidation.Desc": "GPU 디버깅을 위한 Vulkan 검증 레이어를 활성화합니다.\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) 라이브러리 호출을 콘솔에 기록합니다.",
"Common.Save": "저장",
"Common.Cancel": "취소",
"PerGame.Title": "게임별 설정 — {0} ({1})",
"PerGame.InheritNote": "선택하지 않은 항목은 전역 기본값을 따릅니다.",
"PerGame.EnvToggles.Label": "환경 스위치",
"PerGame.EnvToggles.Desc": "이 게임에 대해 전역 SHARPEMU_* 스위치 설정을 재정의합니다.",
"Options.About": "정보",
"About.Github.Label": "GitHub",
"About.Github.Desc": "소스 코드, 이슈, 프로젝트 개발.",
"About.Github.LatestCommitLabel": "최신 커밋",
"About.Github.LatestCommitDescription": "main 브랜치의 최신 커밋",
"About.Discord.Label": "디스코드",
"About.Discord.Desc": "커뮤니티에 참여해 지원을 받고 개발 소식을 확인하세요.",
"About.GithubButton": "GitHub에서 기여하기!",
"About.DiscordButton": "디스코드 참여하기!",
"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.ChecksumFailed": "다운로드한 업데이트가 SHA-256 검증에 실패했습니다.",
"Updater.Status.Unsupported": "자동 업데이트에는 Windows, Linux 또는 macOS x64 빌드가 필요합니다."
}
+44 -1
View File
@@ -125,5 +125,48 @@
"Dialog.PsExecutables": "PS-uitvoerbare bestanden",
"Dialog.SaveLogFile": "Selecteer waar het logbestand moet worden opgeslagen",
"Dialog.PlainTextFiles": "Platte tekstbestanden",
"Dialog.LogFiles": "Logbestanden"
"Dialog.LogFiles": "Logbestanden",
"Library.Context.GameSettings": "Game-instellingen…",
"Options.Env.Tab": "Omgeving",
"Options.Section.Environment": "OMGEVINGSVARIABELEN",
"Options.Env.Desc": "Schakelaars die bij het starten als omgevingsvariabelen aan de emulator worden doorgegeven.",
"Options.Env.Bthid.Desc": "Meld Bluetooth HID als niet beschikbaar voor titels waarvan de stuur-/FFB-middleware eindeloos blijft wachten.\nLaat dit normaal uit. Sommige titels bevriezen wanneer de initialisatie mislukt.",
"Options.Env.LoopGuard.Desc": "Titels die dezelfde aanroep te lang herhalen niet geforceerd afsluiten.\nProbeer dit wanneer een game zichzelf tijdens het laden afsluit.",
"Options.Env.WritableApp0.Desc": "Sta titels toe bestanden aan te maken en te schrijven in hun installatiemap.\nNodig voor uitgepakte dumps die hun save- of configuratiegegevens onder /app0 wegschrijven.",
"Options.Env.VkValidation.Desc": "Schakel Vulkan-validatielagen in voor GPU-debugging.\nTraag. Vereist een geïnstalleerde Vulkan SDK.",
"Options.Env.DumpSpirv.Desc": "Sla AGC-shaders en hun SPIR-V-vertalingen op in de map shader-dumps.\nGebruik dit bij het melden van shader- of renderfouten.",
"Options.Env.LogDirectMemory.Desc": "Log directe geheugentoewijzingen en fouten naar de console.\nGebruik dit wanneer een game tijdens het opstarten afbreekt of afsluit.",
"Options.Env.LogIo.Desc": "Log het openen en lezen van bestanden en het oplossen van paden naar de console.\nGebruik dit wanneer een game zijn databestanden niet kan vinden tijdens het opstarten.",
"Options.Env.LogNp.Desc": "Log NP-bibliotheekaanroepen (PlayStation Network) naar de console.",
"Common.Save": "Opslaan",
"Common.Cancel": "Annuleren",
"PerGame.Title": "Instellingen per game — {0} ({1})",
"PerGame.InheritNote": "Niet-aangevinkte rijen erven de globale standaardwaarden.",
"PerGame.EnvToggles.Label": "Omgevingsschakelaars",
"PerGame.EnvToggles.Desc": "Overschrijf de globale set SHARPEMU_*-schakelaars voor deze game.",
"Options.About": "Over",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Broncode, issues en projectontwikkeling.",
"About.Github.LatestCommitLabel": "Nieuwste commit",
"About.Github.LatestCommitDescription": "Nieuwste commit op de main-branch",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Word lid van de community, krijg ondersteuning en volg de ontwikkeling.",
"About.GithubButton": "Draag bij op GitHub!",
"About.DiscordButton": "Word lid van onze Discord!",
"Updater.Auto.Label": "Bij het opstarten controleren op updates",
"Updater.Auto.Desc": "Controleert GitHub zonder het opstarten te vertragen.",
"Updater.Label": "Updates",
"Updater.Check": "Controleren op updates",
"Updater.DownloadRestart": "Downloaden en opnieuw starten",
"Updater.Status.Ready": "Huidige build: {0}",
"Updater.Status.Checking": "Controleren op updates…",
"Updater.Status.Current": "Je bent up-to-date ({0}).",
"Updater.Status.Available": "Er is een nieuwe build beschikbaar: {0}",
"Updater.Status.Downloading": "Update downloaden… {0}%",
"Updater.Status.Installing": "Update installeren…",
"Updater.Status.Timeout": "De updatecontrole is na 10 seconden verlopen.",
"Updater.Status.Failed": "Kon niet controleren op updates.",
"Updater.Status.ChecksumFailed": "De gedownloade update is niet door de SHA-256-verificatie gekomen.",
"Updater.Status.Unsupported": "Automatisch updaten vereist een x64-build voor Windows, Linux of macOS."
}
+28 -1
View File
@@ -142,5 +142,32 @@
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Junte-se à comunidade, obtenha suporte e acompanhe o desenvolvimento.",
"About.GithubButton": "Contribua no GitHub!",
"About.DiscordButton": "Junte-se ao nosso Discord!"
"About.DiscordButton": "Junte-se ao nosso Discord!",
"Library.Context.GameSettings": "Definições do jogo…",
"Options.Env.WritableApp0.Desc": "Permitir que os jogos criem e escrevam ficheiros dentro da sua pasta de instalação.\nNecessário para dumps não empacotados que escrevem os seus dados guardados ou configurações em /app0.",
"Options.Env.LogIo.Desc": "Registar na consola a abertura e leitura de ficheiros e a resolução de caminhos.\nUtilize quando um jogo não encontrar os seus ficheiros de dados durante o arranque.",
"Common.Save": "Guardar",
"Common.Cancel": "Cancelar",
"PerGame.Title": "Definições por jogo — {0} ({1})",
"PerGame.InheritNote": "As linhas não assinaladas herdam as predefinições globais.",
"PerGame.EnvToggles.Label": "Variáveis de ambiente",
"PerGame.EnvToggles.Desc": "Substituir o conjunto global de opções SHARPEMU_* para este jogo.",
"About.Github.LatestCommitLabel": "Último commit",
"About.Github.LatestCommitDescription": "Último commit no ramo main",
"Updater.Auto.Label": "Procurar atualizações no arranque",
"Updater.Auto.Desc": "Consulta o GitHub sem atrasar o arranque.",
"Updater.Label": "Atualizações",
"Updater.Check": "Procurar atualizações",
"Updater.DownloadRestart": "Transferir e reiniciar",
"Updater.Status.Ready": "Build atual: {0}",
"Updater.Status.Checking": "A procurar atualizações…",
"Updater.Status.Current": "Está atualizado ({0}).",
"Updater.Status.Available": "Está disponível um novo build: {0}",
"Updater.Status.Downloading": "A transferir a atualização… {0}%",
"Updater.Status.Installing": "A instalar a atualização…",
"Updater.Status.Timeout": "A verificação de atualizações expirou após 10 segundos.",
"Updater.Status.Failed": "Não foi possível procurar atualizações.",
"Updater.Status.ChecksumFailed": "A atualização transferida falhou a verificação SHA-256.",
"Updater.Status.Unsupported": "A atualização automática requer um build x64 para Windows, Linux ou macOS."
}
+47 -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,32 @@
"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.",
"Updater.Status.ChecksumFailed": "Скачанное обновление не прошло проверку SHA-256."
}
+30 -1
View File
@@ -140,5 +140,34 @@
"Updater.Status.Installing": "Güncelleme kuruluyor…",
"Updater.Status.Timeout": "Güncelleme denetimi 10 saniye sonra zaman aşımına uğradı.",
"Updater.Status.Failed": "Güncellemeler denetlenemedi.",
"Updater.Status.Unsupported": "Otomatik güncelleme Windows, Linux veya macOS x64 build'i gerektirir."
"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.",
"Library.Context.GameSettings": "Oyun ayarları…",
"Options.Env.Tab": "Ortam",
"Options.Section.Environment": "ORTAM DEĞİŞKENLERİ",
"Options.Env.Desc": "Başlatma sırasında emülatöre ortam değişkeni olarak geçirilen anahtarlar.",
"Options.Env.Bthid.Desc": "Direksiyon/FFB katmanı sonsuza kadar bekleyen oyunlar için Bluetooth HID'i kullanılamıyor olarak bildir.\nNormalde kapalı bırakın. Bazı oyunlar başlatma başarısız olduğunda donar.",
"Options.Env.LoopGuard.Desc": "Aynı çağrıyı uzun süre tekrarlayan oyunları zorla kapatma.\nBir oyun yükleme sırasında kendiliğinden kapanıyorsa bunu deneyin.",
"Options.Env.WritableApp0.Desc": "Oyunların kurulum klasörlerinde dosya oluşturup yazmasına izin ver.\nKayıt veya yapılandırma verisini /app0 altına yazan paketlenmemiş dump'lar için gereklidir.",
"Options.Env.VkValidation.Desc": "GPU hata ayıklaması için Vulkan doğrulama katmanlarını etkinleştir.\nYavaştır. Vulkan SDK'nın kurulu olması gerekir.",
"Options.Env.DumpSpirv.Desc": "AGC shader'larını ve SPIR-V çevirilerini shader-dumps klasörüne kaydet.\nShader veya görüntü hatalarını bildirirken kullanın.",
"Options.Env.LogDirectMemory.Desc": "Doğrudan bellek tahsislerini ve hatalarını konsola günlükle.\nBir oyun açılış sırasında çöküyor veya kapanıyorsa kullanın.",
"Options.Env.LogIo.Desc": "Dosya açma, okuma ve yol çözümleme etkinliğini konsola günlükle.\nBir oyun açılışta veri dosyalarını bulamıyorsa kullanın.",
"Options.Env.LogNp.Desc": "NP (PlayStation Network) kütüphane çağrılarını konsola günlükle.",
"Common.Save": "Kaydet",
"Common.Cancel": "İptal",
"PerGame.Title": "Oyuna özel ayarlar — {0} ({1})",
"PerGame.InheritNote": "İşaretlenmemiş satırlar genel varsayılanları kullanır.",
"PerGame.EnvToggles.Label": "Ortam anahtarları",
"PerGame.EnvToggles.Desc": "Bu oyun için genel SHARPEMU_* anahtar kümesini geçersiz kıl.",
"Options.About": "Hakkında",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Kaynak kodu, hata kayıtları ve proje geliştirme.",
"About.Github.LatestCommitLabel": "Son Commit",
"About.Github.LatestCommitDescription": "main dalındaki son commit",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Topluluğa katılın, destek alın ve geliştirmeyi takip edin.",
"About.GithubButton": "GitHub'da katkıda bulun!",
"About.DiscordButton": "Discord'umuza katıl!"
}
+182 -167
View File
@@ -5,6 +5,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:SharpEmu.GUI"
xmlns:primitives="clr-namespace:Avalonia.Controls.Primitives;assembly=Avalonia.Controls"
x:Class="SharpEmu.GUI.MainWindow"
Title="SharpEmu"
Width="1280" Height="820"
@@ -18,7 +20,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
Icon="avares://SharpEmu.GUI/Assets/SharpEmu.ico"
KeyDown="OnKeyDown">
<Grid RowDefinitions="Auto,*,Auto">
<Grid x:Name="RootLayout" RowDefinitions="Auto,*,Auto">
<!-- Selected-game backdrop: key art behind the main content, dimmed by
a scrim so tiles and text stay readable. Fades on selection. -->
@@ -56,13 +58,19 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Grid>
<!-- Main content -->
<Grid Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto">
<Grid x:Name="MainContent" Grid.Row="1" Margin="32,24,32,20" RowDefinitions="Auto,*,Auto,Auto">
<!-- The game owns the full client area while running. Session controls
use a native popup so they can stay above this native child surface. -->
<Border x:Name="GameView" Grid.Row="0" Grid.RowSpan="4" IsVisible="False" Background="#000000" ClipToBounds="True">
<Grid x:Name="GameSurfaceContainer" />
</Border>
<!-- Library / Options page switcher, with the library toolbar sharing
the same row on the right. Plain buttons (not TabItem) so there is
no underline; LB/RB hint chips flank the pair and the gamepad's
shoulder buttons actually switch pages from anywhere. -->
<Grid Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,20">
<Grid x:Name="ContentToolbar" Grid.Row="0" ColumnDefinitions="Auto,*,Auto" Margin="0,0,0,20">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="14" VerticalAlignment="Center">
<Border Classes="padHint" VerticalAlignment="Center">
<TextBlock Text="LB" FontSize="11" FontWeight="Bold" Foreground="{StaticResource MutedBrush}" />
@@ -83,7 +91,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</StackPanel>
</Grid>
<Panel Grid.Row="1">
<Panel Grid.Row="1" x:Name="PagesHost">
<!-- Library page. The tile row gets extra top margin so it sits
closer to eye level (PS5 home-screen style) instead of hugging
@@ -118,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>
@@ -191,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>
@@ -219,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" />
@@ -234,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>
@@ -284,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">
@@ -431,6 +400,29 @@ SPDX-License-Identifier: GPL-2.0-or-later
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem x:Name="GraphicsTabItem" Header="Graphics" FontSize="15">
<ScrollViewer>
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="RenderingSectionTitle" Classes="sectionTitle" Text="RENDERING" />
<local:SettingRow x:Name="RenderResolutionRow" Label="Internal resolution"
Description="Render offscreen targets below native resolution and upscale on present. Lower values trade image quality for GPU headroom; takes effect on next launch.">
<ComboBox x:Name="RenderResolutionBox" Width="160" SelectedIndex="0"
VerticalAlignment="Center" CornerRadius="8">
<ComboBoxItem x:Name="RenderResolution100Item" Content="100% (native)" Tag="1.0" />
<ComboBoxItem x:Name="RenderResolution75Item" Content="75%" Tag="0.75" />
<ComboBoxItem x:Name="RenderResolution50Item" Content="50%" Tag="0.5" />
<ComboBoxItem x:Name="RenderResolution25Item" Content="25%" Tag="0.25" />
</ComboBox>
</local:SettingRow>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem x:Name="EnvTabItem" Header="Environment" FontSize="15">
<ScrollViewer>
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
@@ -442,93 +434,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>
@@ -568,7 +520,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Border>
<!-- Launch bar; capped so it does not sprawl on a maximized window. -->
<Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14" MaxWidth="1280">
<Border Grid.Row="3" x:Name="LaunchBar" Classes="card" Margin="0,12,0,0" Padding="14" MaxWidth="1280">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,*,Auto">
@@ -633,6 +585,69 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Border>
</Grid>
<!-- Avalonia's regular overlay layer cannot appear over a native child
HWND/X11/Metal surface. Keep the running-session controls in a native
popup so the game reaches the bottom status bar without losing Stop. -->
<primitives:Popup x:Name="SessionBarPopup"
IsOpen="False"
PlacementTarget="{Binding #GameView}"
Placement="Bottom"
VerticalOffset="-66"
Topmost="True"
ShouldUseOverlayLayer="False"
TakesFocusFromNativeControl="False"
IsLightDismissEnabled="False">
<Border Classes="card" Width="598" Height="58" CornerRadius="16" Padding="14,8">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="3" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock x:Name="SessionGameTitle" Text="GAME RUNNING" FontSize="13" FontWeight="SemiBold"
MaxWidth="240" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Border Classes="badge running" VerticalAlignment="Center">
<TextBlock Text="RUNNING" FontSize="9" FontWeight="Bold" LetterSpacing="1"
Foreground="{StaticResource SuccessBrush}" />
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="7">
<Border x:Name="SessionF11Badge" Classes="badge key" VerticalAlignment="Center">
<TextBlock Text="F11" FontSize="9" FontWeight="Bold"
Foreground="{StaticResource InfoBrush}" />
</Border>
<TextBlock x:Name="SessionHintText" Text="Fullscreen" FontSize="11"
Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center" />
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button x:Name="SessionConsoleButton" Classes="ghost" Content="≡ Console" />
<Button x:Name="SessionStopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
</StackPanel>
</Grid>
</Border>
</primitives:Popup>
<!-- This is a native popup rather than an Avalonia overlay because the
emulated Vulkan surface is a native child window. -->
<!-- Anchored to MainContent, not GameView: the surface host is parked in
a 1x1 corner while loading/closing, which would pull a GameView-
anchored popup into the corner with it. -->
<primitives:Popup x:Name="SessionLoadingPopup"
IsOpen="False"
PlacementTarget="{Binding #MainContent}"
Placement="Center"
Topmost="True"
ShouldUseOverlayLayer="False"
TakesFocusFromNativeControl="False"
IsLightDismissEnabled="False">
<Border Classes="card" Width="380" CornerRadius="18" Padding="22,18">
<StackPanel Spacing="12">
<TextBlock x:Name="SessionLoadingTitle" Text="Loading game" FontSize="16" FontWeight="SemiBold" />
<TextBlock x:Name="SessionLoadingDetail" Text="Preparing the emulation session..." FontSize="12"
Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
<ProgressBar IsIndeterminate="True" Height="5" />
</StackPanel>
</Border>
</primitives:Popup>
<!-- Status bar -->
<Grid x:Name="StatusBar" Grid.Row="2" Height="32" Background="{StaticResource ChromeBrush}"
ColumnDefinitions="*,Auto">
File diff suppressed because it is too large Load Diff
+127
View File
@@ -0,0 +1,127 @@
// 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 NormalizeFromJson(File.ReadAllText(path));
}
}
catch (Exception)
{
}
return null;
}
// A null list inherits global settings; only entries in a present list are sanitized.
internal static PerGameSettings? NormalizeFromJson(string json)
{
var settings = JsonSerializer.Deserialize<PerGameSettings>(json, SerializerOptions);
if (settings?.EnvironmentToggles is { } toggles)
{
settings.EnvironmentToggles = toggles.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
}
return settings;
}
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;
}
}
}

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