Compare commits

...

49 Commits

Author SHA1 Message Date
ParantezTech cc4f00003d chore: bump version to 0.0.2-beta.3 2026-07-17 18:59:43 +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
Berk 459ae7e3f7 Docs/readme images (#302)
* [readme] update showcase images

* [readme] update readme images and change version to 0.0.2
2026-07-17 02:44:28 +03:00
kuba 8f405caebe Vulkan: reduce guest buffer upload allocations (#301) 2026-07-17 02:36:39 +03:00
kuba dabf723b3e [Vulkan] Honor guest depth clear state (#290) 2026-07-17 02:29:53 +03:00
AlexC 2129a12684 [GUI] Last commit SHA (id) displayed on about section (#279)
* Latest commit info axamal structure

* Link latest commit to text

* Added localization for About>Last commit info

* Made the commit hash a button that redirects you to the commit in github

* Reorder about section

* Commit and update icon in about section to have consistency in section
2026-07-17 02:25:01 +03:00
kuba 2db1fae282 [Tests/HLE] Cover APR resolve, stat, and streaming flow (#272) 2026-07-16 19:57:45 +03:00
kuba 33f96252da [CPU] Reject context transfers to unmapped guest addresses (#273) 2026-07-16 19:57:34 +03:00
kuba f7981a7ed7 [Tests/CPU] Verify import trampoline volatile-state ABI (#274) 2026-07-16 19:57:23 +03:00
kuba e10efa3ae1 [HLE] Make guest printf formatting locale-invariant (#271) 2026-07-16 18:39:48 +02:00
Peter Bonanni 16a2131b67 [PlayGo] Reject authoritative unknown chunk loci (#242) 2026-07-16 19:22:33 +03:00
Mees van den Kieboom 9883a9445d [AGC] Implement RDNA2 buffer/image/DS 32-bit atomic instructions (#222)
Adds decode and SPIR-V translation for the missing MUBUF, MIMG and DS
atomic instructions in the Gen5 shader translator, generalizing the
existing BufferAtomicAdd path. Covers swap, cmpswap, add, sub,
smin/smax, umin/umax, and/or/xor, inc and dec, plus the DS RTN
variants. Image atomics go through OpImageTexelPointer on the storage
image binding.

Notable: DS_CMPST operand order (DATA0 = comparator, DATA1 = new
value) is reversed relative to buffer/image cmpswap, which a dedicated
test locks in. ATOMIC_INC/DEC are approximated with
OpAtomicIIncrement/IDecrement, exact for the common 0xFFFFFFFF clamp.

Verified with 9 new synthetic decoder and end-to-end SPIR-V tests
(part of the #36 test corpus effort); full suite passes 35/35.

Signed-off-by: missatjuhvdk1 <177474143+missatjuhvdk1@users.noreply.github.com>
Co-authored-by: missatjuhvdk1 <177474143+missatjuhvdk1@users.noreply.github.com>
2026-07-16 17:28:39 +03:00
Peter Bonanni f2d9051358 Fix macOS fixed-address allocation collisions (#246) 2026-07-16 16:45:08 +03:00
Dafenx 53d414e096 Bound Vulkan host buffer pool memory (#201)
Co-authored-by: Dafenx <196083014+Dafenxz0@users.noreply.github.com>
2026-07-16 16:44:29 +03:00
MikeyLITE69 52d2874fa8 cpu: emulate BMI1/BMI2/ABM instructions in software when the host lacks them (#249)
## What

The native backend runs guest code directly on the host CPU. When the host doesn't
implement a BMI1/BMI2/ABM instruction that the PS5's Zen 2 cores do, it raises #UD
(STATUS_ILLEGAL_INSTRUCTION). Today the vectored handler just logs the faulting bytes
and gives up, so the title dies.

This adds a software fallback. On an illegal-instruction fault we decode the opcode
with Iced (the decoder already used elsewhere in the backend), evaluate it against the
trapped register/memory state, write the result and flags back into the CONTEXT record,
step RIP past the instruction, and resume.

Instructions covered (32- and 64-bit): ANDN, BLSI, BLSMSK, BLSR, BEXTR, BZHI, TZCNT,
LZCNT, RORX, SARX, SHLX, SHRX, PDEP, PEXT.

## Why

Users on CPUs without these extensions currently can't get past code that uses them.
This is a generic fix (no game-specific hacks) that improves compatibility on older
hosts. MULX is intentionally left out for now — its dest_hi/dest_lo operand ordering
is easy to get subtly wrong, so I'd rather add it separately with its own tests.

## How it's structured

- `BmiInstructionEmulator` holds the pure bit/flag semantics with no dependency on the
  unsafe CONTEXT plumbing, so it can be unit-tested directly.
- `DirectExecutionBackend.IllegalInstruction.cs` is the thin unsafe adapter (decode →
  read operands → emulate → write back → advance RIP). Anything it doesn't fully model
  returns false and falls through to the existing diagnostics unchanged, so it can never
  mis-handle an opcode it doesn't recognize.
- One hook in `DirectExecutionBackend.Exceptions.cs`, next to the other TryRecover* calls.
- Emits a single one-time "emulating in software" log line, not per-instruction spam.

## How I verified

- Added xUnit tests covering every instruction in both widths plus the CF/ZF/SF/OF
  edge cases (src == 0, shift-count masking, index beyond operand width, etc.).
- Cross-checked all the expected values against an independent reference implementation
  written from the Intel/AMD definitions; results match.
- `dotnet build` + `dotnet test` pass locally.

## Notes

New files follow .editorconfig (4-space, SPDX headers, REUSE-compliant).
2026-07-16 16:38:27 +03:00
Gutemberg Ribeiro c5a82c1065 [Perf] Behavior-preserving hot-path allocation/LINQ wins (#264)
* [Perf] Gate event-flag tracing so it allocates nothing when disabled

TraceEventFlag built its interpolated argument (and, on the wait path,
FormatFrameChain + a new StringBuilder(256) in FormatGuestWaitObject plus
~12 guest-memory reads) on every call, then checked the env var inside the
method — so every sceKernelSetEventFlag/Clear/Poll/Wait paid a string
allocation and an Environment.GetEnvironmentVariable P/Invoke even with
tracing off. Cache the flag once in a static readonly bool and gate every
call site, matching the semaphore/event-queue pattern. Behavior is
unchanged when SHARPEMU_LOG_EVENT_FLAG=1.

* [Perf] Hoist IsNoBlockLeaf classification to import-stub setup

The leaf-dispatch path called IsNoBlockLeafImport(nid) — a ~30-literal
string pattern match — on every leaf import. IsLeaf/NidHash were already
precomputed on ImportStubEntry at stub setup; add IsNoBlockLeaf alongside
them and read the field in the hot path. Behavior unchanged.

* [Perf] Cache trace env-var flags read on hot paths

Two SHARPEMU_LOG_* env vars were read via Environment.GetEnvironmentVariable
(a P/Invoke + transient string) on hot paths: SHARPEMU_LOG_FIBER on every
fiber context transfer, and SHARPEMU_LOG_DIRECT_MEMORY on every direct-memory
op (~8 sites). Cache both once — _logFiber alongside the other backend _log*
flags, and _traceDirectMemory behind the existing ShouldTraceDirectMemory
helper. Behavior unchanged.

* [Perf] Avoid per-iteration thread snapshot in the idle pump loop

PumpUntilGuestThreadsIdle allocated a full GuestThreadState[] snapshot
(via LINQ Values.ToArray()) on every spin just to tally three run-state
booleans. Tally them under the lock with an allocation-free helper, and
only materialize the snapshot inside the gated (default-off) diagnostic
dump. SnapshotGuestThreads now uses Values.CopyTo instead of LINQ.
Behavior unchanged.

* [Perf] De-LINQ GetPixelColorExportMask on the per-draw path

GetPixelColorExportMask ran a Select/OfType/Where/Aggregate chain over all
shader instructions, allocating iterators + closures, and is called per
render target (twice per draw via CreateRenderState and HasPixelColorExport).
Replace with a manual scan producing the identical mask — no allocation, and
it removes an authored-LINQ use the repo bans.

* [Perf] De-LINQ per-draw render-target selection in AgcExports

The bound-render-target selection used Where(...).OrderBy(...).ToArray()
(plus a second Where/ToArray fallback) on every translated draw, allocating
LINQ iterators + closures. Replace with an explicit filter into a pre-sized
list + List.Sort by slot; slots are distinct so this matches the stable
OrderBy. Same result, no per-draw LINQ allocations.

* [Perf] De-LINQ per-draw render-target validation in the Vulkan presenter

SubmitOffscreenTranslatedDraw validated its render targets with
targets.Any(...) twice plus targets.Select(a=>a.Address).Distinct().Count()
(a per-draw HashSet), and broadcast a single blend with
Enumerable.Repeat(...).ToArray(). Targets are <= 8, so replace with manual
scans (invalid-target check; combined dimension-mismatch + pairwise
aliasing check) and Array.Fill. Same results, no per-draw LINQ allocations.

* [Perf] Binary-search VirtualQuery region lookup (SortedList)

_mappedRegions was an unordered Dictionary, so TryFindVirtualQueryRegionLocked
scanned every region for containment/next — O(n) per sceKernelVirtualQuery and
O(n^2) when an allocator walks the address space with the findNext flag. Store
regions in a SortedList keyed by base address (every write already uses the
region's own address as the key) and find the containing/next region with a
binary search over the sorted keys. Also drops a now-redundant Values.OrderBy.

Non-overlapping regions assumed (mmap semantics), so only the floor region can
contain the query. Behavior-preserving; worth spot-checking VirtualQuery-heavy
titles.

* [Perf] Remove stray CLI packages.lock.json committed by mistake

git add -A in an earlier commit swept in a regenerated
src/SharpEmu.CLI/packages.lock.json. main tracks no lock files (central
package management, no RestorePackagesWithLockFile), and REUSE.toml no
longer covers packages.lock.json, so the committed file failed the REUSE
Compliance check. Remove it.
2026-07-16 16:37:28 +03:00
can a8be1daca4 Fix sceNetHtonl/Htons/Ntohl/Ntohs discarding their converted result (#211)
Each of the four byte-order helpers computed the swap into Rax and then
returned via ctx.SetReturn(0). SetReturn writes its argument into Rax, so it
immediately overwrote the converted value with 0 and the guest saw every
sceNetHtonl / sceNetHtons / sceNetNtohl / sceNetNtohs call return 0.

Leave the swapped value in Rax and return ORBIS_GEN2_OK as the dispatch status
instead, matching how the value-returning exports in this file (e.g.
sceNetPoolCreate) already work.

Add a NetExports test suite covering the swaps, the 16-bit width masking, an
htonl/ntohl round-trip, and a regression guard that a non-zero input never
converts to 0.
2026-07-16 16:24:34 +03:00
999sian 26dbad8ac8 fix(input): map POSIX stick endpoints exactly (#244) 2026-07-16 16:19:33 +03:00
Dafenx 2196a1f786 Gate Vulkan texture upload traces (#220)
Co-authored-by: Dafenx <196083014+Dafenxz0@users.noreply.github.com>
2026-07-16 16:19:11 +03:00
kostyaff 8db2c0622d [VideoOut] Implement sceVideoOutIsOutputSupported stub (fixes #257) (#270)
Quake (PPSA01880, Kex Engine) crashes at startup because
sceVideoOutIsOutputSupported (NID Nv8c-Kb+DUM) is unimplemented.
The game calls it to check video output capabilities before
initializing rendering.

Add HLE export stub: returns 1 (supported) for SceVideoOutBusTypeMain,
0 otherwise. The emulator renders via Vulkan and supports any pixel
format or aspect ratio on the main bus.

NID verified via Ps5Nid.Compute SHA1 algorithm.
Export name confirmed in scripts/ps5_names.txt.
2026-07-16 16:07:21 +03:00
mskomek 53da00fa89 GUI: add cross-platform release updater (#243) 2026-07-16 14:01:42 +03:00
Spooks b4b95014f1 Fix/deadcells crash (#262)
* Boot compatibility fixes for UE titles, GUI toggles, and DeS render/boot work

Checkpoint of the Monster Truck Championship and Demon's Souls boot work.
Each piece is independently useful and verified against the titles.

- playgo: scePlayGoGetLocus now returns BAD_CHUNK_ID for chunk ids outside
  the known set, matching real firmware. Titles enumerate chunk ids until
  that error; answering OK for every id made the scan wrap the ushort range
  and spin forever. Missing-sidecar and no-app0 fallbacks report a
  fully-installed single chunk 0 so scePlayGoOpen keeps succeeding.
- kernel: restore the SHARPEMU_WRITABLE_APP0 opt-in. Unpackaged UE dumps
  write their Saved tree under /app0 during PS5 component init and treat
  the denial as a fatal boot error.
- pad: accept handle 0 as the primary pad across all pad calls. Real
  firmware hands out small non-negative handles and some titles read state
  with handle 0.
- bthid: env-gated experiment hooks for the Thrustmaster wheel middleware
  investigation (fail-only-RegisterCallback modes and a synthetic
  enumeration callback with a zeroed event struct). All default off.
- gui: add SHARPEMU_LOG_IO and SHARPEMU_WRITABLE_APP0 toggles to the
  Environment tab.
- videoout: per-swapchain-image render-finished semaphores (the shared
  semaphore raced the swapchain); whole-mip-chain layout init for offscreen
  guest images (sampled binds read mips stuck in Undefined); GPU-resident
  texture availability now canonicalizes through the texture format table
  and accepts compatibility-class aliases, cutting per-frame CPU texture
  re-reads (143 GB -> 55 GB per 300 s in Demon's Souls, 0.2 -> 0.5 fps).
- hle: add sceSystemServiceGetNoticeScreenSkipFlag,
  sceSystemServiceGetMainAppTitleId (title id published from the runtime),
  and sceNpWebApi2CreateUserContext (refuses so the online layer backs off).
- rtc: SHARPEMU_RTC_PROBE_RANGE diagnostic dumps the code around a busy-wait
  caller of sceRtcGetCurrentTick once; costs nothing when unset.

* [ShaderCompiler] Fix VReadlaneB32 scalar destination field

The scalar destination lives in the low vdst byte (bits 0-7); it was read
from bits 8-14, the VOP3B carry-out field readlane does not have, sending
every readlane result to s0. Verified against raw gfx10 encodings and
LLVM's assembler tests (v_readlane_b32 s5, v1, s2 -> low byte 0x05).

* [VideoOut] Survive device loss and flip-order asserts without dying

Two ways a frame could take down the whole presenter:

- Device loss between any two Vulkan calls in a frame unwound the window
  thread, and the Dispose-time fence check then threw again, masking the
  original error. Catch the loss at the frame boundary, retire
  presentations and guest submissions whose fences can never signal, and
  keep the window loop pumping so the game (audio, logic) carries on.
- The ordered-flip capture invariant is violated ~100 times per run by
  Demon's Souls (PPSA01342); on debug builds the Debug.Assert fail-fasts
  the process with nothing in the log. Downgrade it to a once-per-version
  warning until the capture/wait ordering is understood.

* Fix Dead Cells shader cache regression

---------

Co-authored-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com>
2026-07-16 13:57:16 +03:00
ParantezTech 7b91c964fc [VulkanVideoPresenter] revert back icon 2026-07-16 04:51:25 +03:00
Spooks 9bacb883f1 Fix Linux aligned mapping retention (#247)
* Fix Linux aligned mapping retention

* Cover Linux aligned mapping retention
2026-07-15 19:34:35 -06:00
101 changed files with 10803 additions and 1240 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 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."
+17 -23
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}"
@@ -203,7 +210,9 @@ jobs:
- init
- build
- build-posix
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
# Versioned releases are immutable and tag-driven. Branch and manual runs
# still produce Actions artifacts without modifying a published release.
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
@@ -213,7 +222,7 @@ jobs:
with:
path: release
- name: Create or update release
- name: Create release
shell: bash
env:
GH_REPO: ${{ github.repository }}
@@ -231,26 +240,11 @@ jobs:
notes="Automated SharpEmu v${VERSION} build for commit ${GITHUB_SHA}."
if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then
gh release upload "${RELEASE_TAG}" "${assets[@]}" --clobber
gh release edit "${RELEASE_TAG}" --title "${RELEASE_NAME}" --notes "${notes}"
else
gh release create "${RELEASE_TAG}" "${assets[@]}" \
--title "${RELEASE_NAME}" \
--notes "${notes}" \
--target "${GITHUB_SHA}"
echo "Release ${RELEASE_TAG} already exists and will not be modified." >&2
exit 1
fi
keep=()
for asset_path in "${assets[@]}"; do
keep+=("$(basename "${asset_path}")")
done
mapfile -t release_assets < <(gh release view "${RELEASE_TAG}" --json assets --jq '.assets[].name' | sort)
for asset in "${release_assets[@]}"; do
case "${asset}" in
sharpemu-${VERSION}-*.zip|sharpemu-${VERSION}-*.tar.gz)
if ! printf '%s\n' "${keep[@]}" | grep -Fxq "${asset}"; then
gh release delete-asset "${RELEASE_TAG}" "${asset}" --yes
fi
;;
esac
done
gh release create "${RELEASE_TAG}" "${assets[@]}" \
--verify-tag \
--title "${RELEASE_NAME}" \
--notes "${notes}"
+5
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:
+1 -1
View File
@@ -9,7 +9,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<SharpEmuVersion>0.0.1</SharpEmuVersion>
<SharpEmuVersion>0.0.2-beta.3</SharpEmuVersion>
<Version>$(SharpEmuVersion)</Version>
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
+10 -19
View File
@@ -41,6 +41,16 @@ This project is developed purely for research and educational purposes. There ar
SharpEmu focuses exclusively on the PlayStation 5.
Our goal is **not** to emulate PS4 games, as there is already an excellent emulator dedicated to that platform: **ShadPS4**.
## Games Tested
| Demons Souls Remake | Dreaming Sarah |
| :-----------------------------------------------------------: | :--------------------------------------------------------------------------------------------: |
| ![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,25 +98,6 @@ chmod +x ./SharpEmu
A Vulkan-capable GPU and current graphics driver are required. The macOS
release includes the MoltenVK Vulkan implementation.
## Games Tested
* **Demon's Souls Remake**
* [Demon's Souls [PPSA01341]](https://github.com/sharpemu/sharpemu/issues/2)
* Demon's Souls is now video loop. Shaders are ready to be converted to SPIR-V/Vulkan. We are continuing our work on this.
![DeS videoOut submit first frame](./.github/images/des-videoout-shaders.jpg)
* **Poppy Playtime Chapter 1**
* [Poppy Playtime Chapter 1 [PPSA20591]](https://github.com/sharpemu/sharpemu/issues/3)
* **SILENT HILL: The Short Message**
* [SILENT HILL: The Short Message [PPSA10112]](https://github.com/sharpemu/sharpemu/issues/4)
* **Dreaming Sarah**
* [Dreaming Sarah [PPSA02929]](https://github.com/sharpemu/sharpemu/issues/9)
* Real texture rendering for this game;
![Splash texture](./.github/images/dreaming-sarah.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.
+2
View File
@@ -5,10 +5,12 @@ path = [
"REUSE.toml",
"nuget.config",
"global.json",
"**/packages.lock.json",
"scripts/ps5_names.txt",
"src/SharpEmu.GUI/Languages/**",
"_logs/**",
".github/images/**",
".github/pull_request_template.md",
"assets/images/**"
]
precedence = "aggregate"
Binary file not shown.

After

Width:  |  Height:  |  Size: 653 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+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.
+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())
+127 -55
View File
@@ -63,11 +63,15 @@ internal static partial class Program
private static int Run(string[] args)
{
args = NormalizeInternalArguments(args, out var isMitigatedChild);
if (args.Length == 0 && !isMitigatedChild)
if (Updater.TryApply(args, out var updateExitCode))
{
return updateExitCode;
}
args = NormalizeInternalArguments(args, out var isMitigatedChild);
if (args.Length == 0)
{
// No arguments: open the desktop frontend. Any argument selects
// the classic CLI behavior below.
return GuiLauncher.Run();
}
@@ -223,7 +227,17 @@ internal static partial class Program
return childExitCode;
}
if (!TryParseArguments(args, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath))
if (!TryExtractHostSurfaceArgument(args, out var emulatorArgs, out var hostSurface, out var hostSurfaceError))
{
Console.Error.WriteLine($"[LOADER][ERROR] {hostSurfaceError}");
return 1;
}
HostSessionControl.SetEmbeddedHostSurface(
hostSurface?.WindowHandle ?? 0,
hostSurface?.DisplayHandle ?? 0);
if (!TryParseArguments(emulatorArgs, out var ebootPath, out var runtimeOptions, out var logLevel, out var logFilePath))
{
PrintUsage();
return 1;
@@ -250,66 +264,122 @@ internal static partial class Program
Console.Error.WriteLine("[DEBUG] Creating runtime...");
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
OrbisGen2Result result;
ConsoleCancelEventHandler? cancelHandler = null;
try
{
cancelHandler = (_, eventArgs) =>
if (hostSurface is not null && !VulkanVideoHost.TryAttachSurface(hostSurface))
{
eventArgs.Cancel = true;
VideoOutExports.NotifyHostInterrupt();
};
Console.CancelKeyPress += cancelHandler;
Console.Error.WriteLine("[LOADER][ERROR] The requested GUI host surface is already active.");
return 3;
}
Console.Error.WriteLine($"[DEBUG] Running: {ebootPath}");
result = runtime.Run(ebootPath);
Console.Error.WriteLine($"[DEBUG] Result: {result}");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DEBUG] Exception: {ex}");
Log.Error("SharpEmu failed to run.", ex);
return 3;
using var runtime = SharpEmuRuntime.CreateDefault(runtimeOptions);
OrbisGen2Result result;
ConsoleCancelEventHandler? cancelHandler = null;
try
{
cancelHandler = (_, eventArgs) =>
{
eventArgs.Cancel = true;
VideoOutExports.NotifyHostInterrupt();
};
Console.CancelKeyPress += cancelHandler;
Console.Error.WriteLine($"[DEBUG] Running: {ebootPath}");
result = runtime.Run(ebootPath);
Console.Error.WriteLine($"[DEBUG] Result: {result}");
}
catch (Exception ex)
{
Console.Error.WriteLine($"[DEBUG] Exception: {ex}");
Log.Error("SharpEmu failed to run.", ex);
return 3;
}
finally
{
if (cancelHandler is not null)
{
Console.CancelKeyPress -= cancelHandler;
}
}
Log.Info($"SharpEmu execution completed. Result={result} (0x{(int)result:X8})");
if (!string.IsNullOrWhiteSpace(runtime.LastSessionSummary))
{
Log.Info(runtime.LastSessionSummary);
}
if (!string.IsNullOrWhiteSpace(runtime.LastBasicBlockTrace))
{
Log.Info("BB trace:");
Log.Info(runtime.LastBasicBlockTrace);
}
if (!string.IsNullOrWhiteSpace(runtime.LastMilestoneLog))
{
Log.Info(runtime.LastMilestoneLog);
}
if (result != OrbisGen2Result.ORBIS_GEN2_OK && !string.IsNullOrWhiteSpace(runtime.LastExecutionDiagnostics))
{
Log.Warn(runtime.LastExecutionDiagnostics);
}
if (runtimeOptions.ImportTraceLimit > 0 && !string.IsNullOrWhiteSpace(runtime.LastExecutionTrace))
{
Log.Info("Import trace:");
Log.Info(runtime.LastExecutionTrace);
}
return result == OrbisGen2Result.ORBIS_GEN2_OK ? 0 : 4;
}
finally
{
if (cancelHandler is not null)
HostSessionControl.SetEmbeddedHostSurface(0);
if (hostSurface is not null)
{
Console.CancelKeyPress -= cancelHandler;
VulkanVideoHost.RequestClose();
VulkanVideoHost.DetachSurface(hostSurface);
hostSurface.Dispose();
}
}
}
private static bool TryExtractHostSurfaceArgument(
IReadOnlyList<string> args,
out string[] emulatorArgs,
out VulkanHostSurface? hostSurface,
out string? error)
{
const string hostSurfacePrefix = "--host-surface=";
var remaining = new List<string>(args.Count);
hostSurface = null;
error = null;
foreach (var argument in args)
{
if (!argument.StartsWith(hostSurfacePrefix, StringComparison.OrdinalIgnoreCase))
{
remaining.Add(argument);
continue;
}
if (hostSurface is not null)
{
emulatorArgs = [];
error = "more than one GUI host surface was specified";
return false;
}
var descriptor = argument[hostSurfacePrefix.Length..];
if (!VulkanHostSurface.TryCreateChildProcessSurface(descriptor, out hostSurface, out error))
{
emulatorArgs = [];
return false;
}
}
Log.Info($"SharpEmu execution completed. Result={result} (0x{(int)result:X8})");
if (!string.IsNullOrWhiteSpace(runtime.LastSessionSummary))
{
Log.Info(runtime.LastSessionSummary);
}
if (!string.IsNullOrWhiteSpace(runtime.LastBasicBlockTrace))
{
Log.Info("BB trace:");
Log.Info(runtime.LastBasicBlockTrace);
}
if (!string.IsNullOrWhiteSpace(runtime.LastMilestoneLog))
{
Log.Info(runtime.LastMilestoneLog);
}
if (result != OrbisGen2Result.ORBIS_GEN2_OK && !string.IsNullOrWhiteSpace(runtime.LastExecutionDiagnostics))
{
Log.Warn(runtime.LastExecutionDiagnostics);
}
if (runtimeOptions.ImportTraceLimit > 0 && !string.IsNullOrWhiteSpace(runtime.LastExecutionTrace))
{
Log.Info("Import trace:");
Log.Info(runtime.LastExecutionTrace);
}
return result == OrbisGen2Result.ORBIS_GEN2_OK ? 0 : 4;
emulatorArgs = remaining.ToArray();
return true;
}
private static void EnsureCliConsole()
@@ -406,7 +476,9 @@ internal static partial class Program
return handle != 0 && handle != -1;
}
private static string[] NormalizeInternalArguments(string[] args, out bool isMitigatedChild)
private static string[] NormalizeInternalArguments(
string[] args,
out bool isMitigatedChild)
{
isMitigatedChild = false;
var trustedMitigatedChild = string.Equals(
+1
View File
@@ -54,6 +54,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'win-x64' Or '$(RuntimeIdentifier)' == ''">
<ApplicationIcon>..\..\assets\images\SharpEmu.ico</ApplicationIcon>
<Win32Icon>..\..\assets\images\SharpEmu.ico</Win32Icon>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<PropertyGroup>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="SharpEmu" />
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Required by Avalonia NativeControlHost on Windows 10 and 11. -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
+588
View File
@@ -0,0 +1,588 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[10.0.3, )",
"resolved": "10.0.3",
"contentHash": "0B6nZyCHWXnvmlB559oduOspVdNOnpNXPjhpWVMovLPAsDVG7A4jJR9rzECf67JUzxP8/ee/wA8clwIzJcWNFA=="
},
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.BuildServices": {
"type": "Transitive",
"resolved": "11.3.2",
"contentHash": "qHDToxto1e3hci5YqbG9n0Ty8mlp3zBUN5wT66wKqaDVzXyQ0do3EnRILd4Ke9jpvsktaPpgE0YjEk7hornryQ=="
},
"Avalonia.FreeDesktop": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "aUwv8BNruRUOaUfMu4U3uibIUS60/rSHgGOhd8zBkLkpxY3JFJvgRbeq5ZzHIyKXCuKi18PO00YHAgCarp3wdw==",
"dependencies": {
"Avalonia": "11.3.18",
"Tmds.DBus.Protocol": "0.21.3"
}
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"Avalonia.Remote.Protocol": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "vw+6ZfgTuu72dA9aVWn6u56t2nrBd5MoMU0wo/qI9XJAl/c0oYYphIvwLvJP1JorubQY4UE3d0ac8ULBhrGBiA=="
},
"Avalonia.Skia": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "/B4aXmNRNjG8I5U/a1xJI+bIi0XO6DDzS3mBrIKlVnJRY2CyZiUeESRQXLnIU77Z9TvqkUROs+D47s085YjFtA==",
"dependencies": {
"Avalonia": "11.3.18",
"HarfBuzzSharp": "8.3.1.1",
"HarfBuzzSharp.NativeAssets.Linux": "8.3.1.1",
"HarfBuzzSharp.NativeAssets.WebAssembly": "8.3.1.1",
"SkiaSharp": "2.88.9",
"SkiaSharp.NativeAssets.Linux": "2.88.9",
"SkiaSharp.NativeAssets.WebAssembly": "2.88.9"
}
},
"Avalonia.Win32": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "eioUHkM2PeLPETd1aEks3rvb9plbba6buIrNdrqCpwE/qgHKUjvRNBd5mUQfAbGgTLiAes524gB8uUMDhrsJVQ==",
"dependencies": {
"Avalonia": "11.3.18",
"Avalonia.Angle.Windows.Natives": "2.1.25547.20250602"
}
},
"Avalonia.X11": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "m4Ki/G5Dovnq+6QzfS0iGbK8V77Q6oTjToMLOB0CxPCCrl3Oxywh6kIjuGJDPaN6kopMmjxlNShyQf+vPYL+JA==",
"dependencies": {
"Avalonia": "11.3.18",
"Avalonia.FreeDesktop": "11.3.18",
"Avalonia.Skia": "11.3.18"
}
},
"HarfBuzzSharp": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "tLZN66oe/uiRPTZfrCU4i8ScVGwqHNh5MHrXj0yVf4l7Mz0FhTGnQ71RGySROTmdognAs0JtluHkL41pIabWuQ==",
"dependencies": {
"HarfBuzzSharp.NativeAssets.Win32": "8.3.1.1",
"HarfBuzzSharp.NativeAssets.macOS": "8.3.1.1"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.WebAssembly": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "loJweK2u/mH/3C2zBa0ggJlITIszOkK64HLAZB7FUT670dTg965whLFYHDQo69NmC4+d9UN0icLC9VHidXaVCA=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"MicroCom.Runtime": {
"type": "Transitive",
"resolved": "0.11.0",
"contentHash": "MEnrZ3UIiH40hjzMDsxrTyi8dtqB5ziv3iBeeU4bXsL/7NLSal9F1lZKpK+tfBRnUoDSdtcW3KufE4yhATOMCA=="
},
"Microsoft.DotNet.PlatformAbstractions": {
"type": "Transitive",
"resolved": "3.1.6",
"contentHash": "jek4XYaQ/PGUwDKKhwR8K47Uh1189PFzMeLqO83mXrXQVIpARZCcfuDedH50YDTepBkfijCZN5U/vZi++erxtg=="
},
"Microsoft.Extensions.DependencyModel": {
"type": "Transitive",
"resolved": "9.0.9",
"contentHash": "fNGvKct2De8ghm0Bpfq0iWthtzIWabgOTi+gJhNOPhNJIowXNEUE2eZNW/zNCzrHVA3PXg2yZ+3cWZndC2IqYA=="
},
"Silk.NET.Core": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "D7AT/nnwlB+4RZ84XY8QNGBZMJI5z9l4CSSETIJ1wCfRJzRt/341y3MRZ4HbnFz4r/IGaWOEZr86iE+0/65yyQ==",
"dependencies": {
"Microsoft.DotNet.PlatformAbstractions": "3.1.6",
"Microsoft.Extensions.DependencyModel": "9.0.9"
}
},
"Silk.NET.GLFW": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "UIs4sH57xlPUNHQ/1bt9rymPWlGy8IMDCNv86h0iM4TOA1CkIx0XM/n/tA4AReh1zQkNrvkxPEdZ3Blvy1dyXg==",
"dependencies": {
"Silk.NET.Core": "2.23.0",
"Ultz.Native.GLFW": "3.4.0"
}
},
"Silk.NET.Input.Common": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "QbJVV7kFBHEByayXCYdJtXXI9Sp4a+QAf0IdGV6uCWkFYcEmqBYW3aaNGvFOdSwTBDbHL5T/OtOCrGh4qYhk7A==",
"dependencies": {
"Silk.NET.Windowing.Common": "2.23.0"
}
},
"Silk.NET.Input.Glfw": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "KGHYqsv/IQRJtD6dloYh2tN4CkaM40vxM2kj0cGKBoCQiBDYHHhJiyDTyMPx0W7Fz5IgnhnG42ELmIAa0DH69A==",
"dependencies": {
"Silk.NET.Input.Common": "2.23.0",
"Silk.NET.Windowing.Glfw": "2.23.0"
}
},
"Silk.NET.Maths": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "r8PdIVzME8EH0qAgbmRPO87I4GfgR2j8TofT7EMuRJDf1QluoQwnVypDoFJjQ2ZBSRsGYk5unYxxogI05Ogsmw=="
},
"Silk.NET.Windowing.Common": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "ThStSinmY9KQI8DGiF5XEhkLJVnBcgRTBTzL9ijg1wMZAYuckz7ykrNw04fjRm2Gryh6tCNGbvz2XaY0efeFzg==",
"dependencies": {
"Silk.NET.Core": "2.23.0",
"Silk.NET.Maths": "2.23.0"
}
},
"Silk.NET.Windowing.Glfw": {
"type": "Transitive",
"resolved": "2.23.0",
"contentHash": "aYBudKmENmvLRn9p15HbdvlQTnnXskcDfTfbYwSb/4fr263rGLwYuDw/txUEc2jihHJiWCp5+75Y7z5wTJWl7g==",
"dependencies": {
"Silk.NET.GLFW": "2.23.0",
"Silk.NET.Windowing.Common": "2.23.0"
}
},
"SkiaSharp": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "3MD5VHjXXieSHCleRLuaTXmL2pD0mB7CcOB1x2kA1I4bhptf4e3R27iM93264ZYuAq6mkUyX5XbcxnZvMJYc1Q==",
"dependencies": {
"SkiaSharp.NativeAssets.Win32": "2.88.9",
"SkiaSharp.NativeAssets.macOS": "2.88.9"
}
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.WebAssembly": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "kt06RccBHSnAs2wDYdBSfsjIDbY3EpsOVqnlDgKdgvyuRA8ZFDaHRdWNx1VHjGgYzmnFCGiTJBnXFl5BqGwGnA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
},
"sharpemu.core": {
"type": "Project",
"dependencies": {
"Iced": "[1.21.0, )",
"SharpEmu.HLE": "[0.0.2-beta.2, )",
"SharpEmu.Libs": "[0.0.2-beta.2, )",
"SharpEmu.Logging": "[0.0.2-beta.2, )"
}
},
"sharpemu.gui": {
"type": "Project",
"dependencies": {
"Avalonia": "[11.3.18, )",
"Avalonia.Desktop": "[11.3.18, )",
"Avalonia.Fonts.Inter": "[11.3.18, )",
"Avalonia.Themes.Fluent": "[11.3.18, )",
"SharpEmu.Core": "[0.0.2-beta.2, )",
"SharpEmu.Libs": "[0.0.2-beta.2, )",
"SharpEmu.Logging": "[0.0.2-beta.2, )",
"Tmds.DBus.Protocol": "[0.21.3, )"
}
},
"sharpemu.hle": {
"type": "Project",
"dependencies": {
"SharpEmu.Logging": "[0.0.2-beta.2, )"
}
},
"sharpemu.libs": {
"type": "Project",
"dependencies": {
"SharpEmu.HLE": "[0.0.2-beta.2, )",
"SharpEmu.ShaderCompiler": "[0.0.2-beta.2, )",
"SharpEmu.ShaderCompiler.Vulkan": "[0.0.2-beta.2, )",
"Silk.NET.Input": "[2.23.0, )",
"Silk.NET.Vulkan": "[2.23.0, )",
"Silk.NET.Vulkan.Extensions.EXT": "[2.23.0, )",
"Silk.NET.Vulkan.Extensions.KHR": "[2.23.0, )",
"Silk.NET.Windowing": "[2.23.0, )"
}
},
"sharpemu.logging": {
"type": "Project"
},
"sharpemu.shadercompiler": {
"type": "Project",
"dependencies": {
"SharpEmu.HLE": "[0.0.2-beta.2, )"
}
},
"sharpemu.shadercompiler.vulkan": {
"type": "Project",
"dependencies": {
"SharpEmu.ShaderCompiler": "[0.0.2-beta.2, )"
}
},
"Avalonia": {
"type": "CentralTransitive",
"requested": "[11.3.18, )",
"resolved": "11.3.18",
"contentHash": "2C4UxhWUObWGgYKWic1x5BMMWGJP6SElb91WeOxs+X/iR26rtkqpxFFwwo50FXS9AyYnHfk8QKXDEfe7oT/kZA==",
"dependencies": {
"Avalonia.BuildServices": "11.3.2",
"Avalonia.Remote.Protocol": "11.3.18",
"MicroCom.Runtime": "0.11.0"
}
},
"Avalonia.Desktop": {
"type": "CentralTransitive",
"requested": "[11.3.18, )",
"resolved": "11.3.18",
"contentHash": "bilMPa5vYiis6fbNovb6esKytBnOCEGojBa1XFegLCRHCP6g6PvZwS0XF/YOAGkENRlHG8dI7lohOpQ9bIkq1g==",
"dependencies": {
"Avalonia": "11.3.18",
"Avalonia.Native": "11.3.18",
"Avalonia.Skia": "11.3.18",
"Avalonia.Win32": "11.3.18",
"Avalonia.X11": "11.3.18"
}
},
"Avalonia.Fonts.Inter": {
"type": "CentralTransitive",
"requested": "[11.3.18, )",
"resolved": "11.3.18",
"contentHash": "27u6hB3Y2Ue586yjfeVakberY73VNQXtuKwe/P927XG1QPlhsfmOyifLHDDpSHG85Zl1x/Xv9IZ3+tk9FnjcZQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"Avalonia.Themes.Fluent": {
"type": "CentralTransitive",
"requested": "[11.3.18, )",
"resolved": "11.3.18",
"contentHash": "+Q/TJoynD0zNuu5w2gD+xcTl7GNKJFxlPYAndRLs/mTDrNbbsvv/271WyIysbMPsXSjCyBDp7RCZzQkpD6x5Bg==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"Iced": {
"type": "CentralTransitive",
"requested": "[1.21.0, )",
"resolved": "1.21.0",
"contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg=="
},
"Silk.NET.Input": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "Xzl+tVwAp2eEd8blmGQjmJrsZPnp3PWG0KJjiAQHaY2Zr/ELVWeAROKXmZdCAvexzmte2JVGEy/dxnMycbxlpg==",
"dependencies": {
"Silk.NET.Input.Common": "2.23.0",
"Silk.NET.Input.Glfw": "2.23.0"
}
},
"Silk.NET.Vulkan": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "3/irtlSWXZ3eTi8N6nelI6L34NTB8ZJHpqVMNzZx2aX7Ek9YEQ34NoQW8/Tljrtmkg8KRhHW8hKTEzZaKV8PgA==",
"dependencies": {
"Silk.NET.Core": "2.23.0"
}
},
"Silk.NET.Vulkan.Extensions.EXT": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "+Oth189ksRiL6HvGCwIdnsYHawqrbO8y49u1H61z3wsfcHhQZeVDYe/wF5LD7fk3NcdgDvwFD3mLm1QWhdZySw==",
"dependencies": {
"Silk.NET.Core": "2.23.0",
"Silk.NET.Vulkan": "2.23.0"
}
},
"Silk.NET.Vulkan.Extensions.KHR": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "uRaf4j+SmH3DumjSSSUbFg33BnsGZUyXGj93O9NgGKZSJN3OTmNmQDxRew+/KiVLcgH6qzbto8aNGZ++j9GFWg==",
"dependencies": {
"Silk.NET.Core": "2.23.0",
"Silk.NET.Vulkan": "2.23.0"
}
},
"Silk.NET.Windowing": {
"type": "CentralTransitive",
"requested": "[2.23.0, )",
"resolved": "2.23.0",
"contentHash": "OPNPmt/lRyUKVYrFLQXVxyATqD3MKLc1iY1oKx1/2GppgmZxVZPwN12tekrQ4C7408kgB1L5JD1Wnirqqeb2kg==",
"dependencies": {
"Silk.NET.Windowing.Common": "2.23.0",
"Silk.NET.Windowing.Glfw": "2.23.0"
}
},
"Tmds.DBus.Protocol": {
"type": "CentralTransitive",
"requested": "[0.21.3, )",
"resolved": "0.21.3",
"contentHash": "hDwB8WsQoyALQKqIbwzS68UKdlnafDm4T/DkO/JrA/YIneP/rKv96SxYPVXeh3FP4i/SXfShrYftKLtciJAIlw=="
}
},
"net10.0/linux-x64": {
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
}
},
"net10.0/osx-arm64": {
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
}
},
"net10.0/osx-x64": {
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
}
},
"net10.0/win-x64": {
"Avalonia.Angle.Windows.Natives": {
"type": "Transitive",
"resolved": "2.1.25547.20250602",
"contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A=="
},
"Avalonia.Native": {
"type": "Transitive",
"resolved": "11.3.18",
"contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==",
"dependencies": {
"Avalonia": "11.3.18"
}
},
"HarfBuzzSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg=="
},
"HarfBuzzSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ=="
},
"HarfBuzzSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "8.3.1.1",
"contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA=="
},
"SkiaSharp.NativeAssets.Linux": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==",
"dependencies": {
"SkiaSharp": "2.88.9"
}
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "2.88.9",
"contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w=="
},
"Ultz.Native.GLFW": {
"type": "Transitive",
"resolved": "3.4.0",
"contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA=="
}
}
}
}
+8
View File
@@ -693,6 +693,13 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
return _virtualMemory.TryWrite(address, buffer);
}
/// <summary>
/// True when the disposed native backend left its session state alive
/// because guest workers were still executing guest code. The guest
/// address space must then stay mapped as well.
/// </summary>
internal bool NativeSessionLeaked { get; private set; }
public void Dispose()
{
if (_nativeCpuBackend is IDisposable disposableBackend)
@@ -700,6 +707,7 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable
disposableBackend.Dispose();
}
NativeSessionLeaked = _nativeCpuBackend is DirectExecutionBackend { GuestSessionLeaked: true };
_nativeCpuBackend = null;
}
}
@@ -0,0 +1,305 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Numerics;
namespace SharpEmu.Core.Cpu.Emulation;
/// <summary>
/// Pure software implementations of the BMI1, BMI2 and ABM general-purpose-register
/// bit-manipulation instructions.
///
/// The direct-execution backend runs guest PS5 code natively on the host CPU. The PS5's
/// Zen 2 cores implement BMI1/BMI2/ABM, but a host CPU that predates those extensions raises
/// #UD (STATUS_ILLEGAL_INSTRUCTION) when it meets one of these opcodes. This class provides the
/// register-only arithmetic so the exception handler can finish the instruction in software and
/// resume, instead of aborting the title.
///
/// The methods deliberately operate on plain integers rather than on the OS CONTEXT record so the
/// semantics can be unit-tested in isolation; the unsafe register/memory plumbing lives in the
/// backend adapter. Each method returns the result already masked to the operand width and, where
/// the instruction is defined to touch flags, updates <paramref name="eflags"/> in place. Flags
/// documented as "undefined" by the vendor manuals are left untouched so behaviour stays
/// deterministic across hosts.
/// </summary>
public static class BmiInstructionEmulator
{
private const uint FlagCarry = 1u << 0;
private const uint FlagZero = 1u << 6;
private const uint FlagSign = 1u << 7;
private const uint FlagOverflow = 1u << 11;
private static ulong WidthMask(GprOperandSize size) =>
size == GprOperandSize.Bits64 ? ulong.MaxValue : 0xFFFF_FFFFUL;
private static int WidthBits(GprOperandSize size) => (int)size;
private static bool SignSet(ulong value, GprOperandSize size) =>
size == GprOperandSize.Bits64 ? (value >> 63) != 0 : (value & 0x8000_0000UL) != 0;
private static uint WithFlag(uint eflags, uint flag, bool set) =>
set ? eflags | flag : eflags & ~flag;
// Applies the CF/ZF/SF/OF set shared by ANDN/BLS*/BZHI: OF is always cleared, ZF and SF follow
// the result, and the caller supplies CF because each instruction defines it differently.
private static uint ApplyLogicFlags(uint eflags, ulong result, GprOperandSize size, bool carry)
{
eflags = WithFlag(eflags, FlagCarry, carry);
eflags = WithFlag(eflags, FlagZero, result == 0);
eflags = WithFlag(eflags, FlagSign, SignSet(result, size));
eflags = WithFlag(eflags, FlagOverflow, false);
return eflags;
}
/// <summary>ANDN: <c>dest = (~src1) &amp; src2</c>. CF and OF are cleared.</summary>
public static ulong Andn(ulong src1, ulong src2, GprOperandSize size, ref uint eflags)
{
var result = (~src1 & src2) & WidthMask(size);
eflags = ApplyLogicFlags(eflags, result, size, carry: false);
return result;
}
/// <summary>BLSI: isolate the lowest set bit, <c>dest = (-src) &amp; src</c>. CF = (src != 0).</summary>
public static ulong Blsi(ulong src, GprOperandSize size, ref uint eflags)
{
var mask = WidthMask(size);
var s = src & mask;
var result = ((0UL - s) & s) & mask;
eflags = ApplyLogicFlags(eflags, result, size, carry: s != 0);
return result;
}
/// <summary>BLSMSK: mask up to and including the lowest set bit, <c>dest = (src - 1) ^ src</c>. CF = (src == 0).</summary>
public static ulong Blsmsk(ulong src, GprOperandSize size, ref uint eflags)
{
var mask = WidthMask(size);
var s = src & mask;
var result = ((s - 1) ^ s) & mask;
eflags = ApplyLogicFlags(eflags, result, size, carry: s == 0);
return result;
}
/// <summary>BLSR: reset the lowest set bit, <c>dest = (src - 1) &amp; src</c>. CF = (src == 0).</summary>
public static ulong Blsr(ulong src, GprOperandSize size, ref uint eflags)
{
var mask = WidthMask(size);
var s = src & mask;
var result = ((s - 1) & s) & mask;
eflags = ApplyLogicFlags(eflags, result, size, carry: s == 0);
return result;
}
/// <summary>
/// BEXTR: extract <c>len</c> bits of <paramref name="src"/> starting at bit <c>start</c>, where
/// start = control[7:0] and len = control[15:8]. Only ZF (per result) and cleared CF/OF are defined.
/// </summary>
public static ulong Bextr(ulong src, ulong control, GprOperandSize size, ref uint eflags)
{
var bits = WidthBits(size);
var start = (int)(control & 0xFF);
var length = (int)((control >> 8) & 0xFF);
ulong result;
if (start >= bits)
{
result = 0;
}
else
{
var shifted = (src & WidthMask(size)) >> start;
if (length == 0)
{
result = 0;
}
else if (length >= bits)
{
result = shifted;
}
else
{
result = shifted & ((1UL << length) - 1);
}
}
result &= WidthMask(size);
eflags = WithFlag(eflags, FlagZero, result == 0);
eflags = WithFlag(eflags, FlagCarry, false);
eflags = WithFlag(eflags, FlagOverflow, false);
return result;
}
/// <summary>
/// BZHI: zero the bits of <paramref name="src"/> from bit position <c>index[7:0]</c> upward.
/// CF is set when the requested position is at or beyond the operand width.
/// </summary>
public static ulong Bzhi(ulong src, ulong index, GprOperandSize size, ref uint eflags)
{
var bits = WidthBits(size);
var mask = WidthMask(size);
var s = src & mask;
var n = (int)(index & 0xFF);
ulong result;
bool carry;
if (n >= bits)
{
result = s;
carry = true;
}
else
{
result = s & ((1UL << n) - 1);
carry = false;
}
eflags = ApplyLogicFlags(eflags, result, size, carry);
return result;
}
/// <summary>TZCNT: count trailing zero bits. If src == 0 the result is the operand width and CF is set.</summary>
public static ulong Tzcnt(ulong src, GprOperandSize size, ref uint eflags)
{
var bits = WidthBits(size);
var s = src & WidthMask(size);
ulong result;
bool carry;
if (s == 0)
{
result = (ulong)bits;
carry = true;
}
else
{
result = (ulong)(size == GprOperandSize.Bits64
? BitOperations.TrailingZeroCount(s)
: BitOperations.TrailingZeroCount((uint)s));
carry = false;
}
eflags = WithFlag(eflags, FlagCarry, carry);
eflags = WithFlag(eflags, FlagZero, result == 0);
return result;
}
/// <summary>LZCNT: count leading zero bits. If src == 0 the result is the operand width and CF is set.</summary>
public static ulong Lzcnt(ulong src, GprOperandSize size, ref uint eflags)
{
var bits = WidthBits(size);
var s = src & WidthMask(size);
ulong result;
bool carry;
if (s == 0)
{
result = (ulong)bits;
carry = true;
}
else
{
result = (ulong)(size == GprOperandSize.Bits64
? BitOperations.LeadingZeroCount(s)
: BitOperations.LeadingZeroCount((uint)s));
carry = false;
}
eflags = WithFlag(eflags, FlagCarry, carry);
eflags = WithFlag(eflags, FlagZero, result == 0);
return result;
}
/// <summary>RORX: rotate <paramref name="src"/> right by <paramref name="count"/> (masked to the operand width). No flags.</summary>
public static ulong Rorx(ulong src, int count, GprOperandSize size)
{
var bits = WidthBits(size);
var mask = WidthMask(size);
var s = src & mask;
var rotate = count & (bits - 1);
if (rotate == 0)
{
return s;
}
return ((s >> rotate) | (s << (bits - rotate))) & mask;
}
/// <summary>SARX: arithmetic shift right by <paramref name="count"/> (masked to the operand width). No flags.</summary>
public static ulong Sarx(ulong src, int count, GprOperandSize size)
{
var bits = WidthBits(size);
var mask = WidthMask(size);
var shift = count & (bits - 1);
if (size == GprOperandSize.Bits64)
{
return (ulong)((long)src >> shift);
}
return (ulong)(uint)((int)(uint)src >> shift) & mask;
}
/// <summary>SHLX: logical shift left by <paramref name="count"/> (masked to the operand width). No flags.</summary>
public static ulong Shlx(ulong src, int count, GprOperandSize size)
{
var bits = WidthBits(size);
var mask = WidthMask(size);
var shift = count & (bits - 1);
return (src << shift) & mask;
}
/// <summary>SHRX: logical shift right by <paramref name="count"/> (masked to the operand width). No flags.</summary>
public static ulong Shrx(ulong src, int count, GprOperandSize size)
{
var bits = WidthBits(size);
var mask = WidthMask(size);
var s = src & mask;
var shift = count & (bits - 1);
return s >> shift;
}
/// <summary>PDEP: deposit contiguous low bits of <paramref name="src"/> into the positions selected by <paramref name="mask"/>. No flags.</summary>
public static ulong Pdep(ulong src, ulong mask, GprOperandSize size)
{
var bits = WidthBits(size);
var selector = mask & WidthMask(size);
ulong result = 0;
var bit = 0;
for (var i = 0; i < bits; i++)
{
var position = 1UL << i;
if ((selector & position) != 0)
{
if (((src >> bit) & 1UL) != 0)
{
result |= position;
}
bit++;
}
}
return result & WidthMask(size);
}
/// <summary>PEXT: gather the bits of <paramref name="src"/> selected by <paramref name="mask"/> into contiguous low bits. No flags.</summary>
public static ulong Pext(ulong src, ulong mask, GprOperandSize size)
{
var bits = WidthBits(size);
var selector = mask & WidthMask(size);
ulong result = 0;
var bit = 0;
for (var i = 0; i < bits; i++)
{
if ((selector & (1UL << i)) != 0)
{
if (((src >> i) & 1UL) != 0)
{
result |= 1UL << bit;
}
bit++;
}
}
return result & WidthMask(size);
}
}
@@ -0,0 +1,14 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Core.Cpu.Emulation;
/// <summary>
/// Operand width for the emulated general-purpose-register instructions. The numeric value is the
/// bit width, so it can double as the "count leading/trailing zeros of an all-zero source" result.
/// </summary>
public enum GprOperandSize
{
Bits32 = 32,
Bits64 = 64,
}
@@ -128,6 +128,11 @@ public sealed partial class DirectExecutionBackend
{
return -1;
}
if (exceptionCode == StatusIllegalInstruction &&
TryRecoverIllegalInstruction(contextRecord, rip))
{
return -1;
}
if (IsBenignHostDebugException(exceptionCode))
{
return -1;
@@ -0,0 +1,362 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using System.Threading;
using Iced.Intel;
using SharpEmu.Core.Cpu.Emulation;
namespace SharpEmu.Core.Cpu.Native;
// Software fallback for the BMI1/BMI2/ABM general-purpose-register instructions.
//
// Guest code runs natively, so the guest and host share the same virtual address space and the
// same registers (the OS delivers them in the CONTEXT record on a fault). When the host CPU lacks
// one of these extensions it raises #UD instead of executing the opcode; without this the title
// simply aborts. Here we decode the faulting instruction, evaluate it against the trapped register
// and memory state, write the result back into the CONTEXT, step RIP past the instruction and ask
// the OS to continue. Only the register-only BMI/ABM forms are handled; anything else returns false
// and falls through to the existing diagnostics unchanged, so this can never mis-handle an opcode it
// does not fully model.
public sealed partial class DirectExecutionBackend
{
// Windows x64 CONTEXT.EFlags lives just past the segment selectors. The GPR offsets it shares
// with the rest of the backend are the CTX_* constants declared in DirectExecutionBackend.cs.
private const int CTX_EFLAGS = 68;
// STATUS_ILLEGAL_INSTRUCTION (#UD surfaced by the Windows vectored handler).
private const uint StatusIllegalInstruction = 0xC000001Du;
private const int MaxInstructionBytes = 15;
// Instruction-window sizes tried in turn so a fault near a page boundary still decodes.
private static readonly int[] DecodeWindowSizes = { MaxInstructionBytes, 11, 8, 4, 2 };
private static int _bmiSoftwareFallbackAnnounced;
private static long _bmiInstructionsEmulated;
private unsafe bool TryRecoverIllegalInstruction(void* contextRecord, ulong rip)
{
if (!TryReadFaultingInstruction(rip, out var instruction))
{
return false;
}
if (instruction.Op0Kind != OpKind.Register ||
!TryGetGprSlot(instruction.Op0Register, out var destOffset, out var size))
{
return false;
}
if (!TryEvaluate(contextRecord, in instruction, size, out var result, out var flagsChanged, out var eflags))
{
return false;
}
WriteCtxU64(contextRecord, destOffset, result);
if (flagsChanged)
{
WriteCtxU32(contextRecord, CTX_EFLAGS, eflags);
}
WriteCtxU64(contextRecord, CTX_RIP, rip + (ulong)instruction.Length);
Interlocked.Increment(ref _bmiInstructionsEmulated);
if (Interlocked.Exchange(ref _bmiSoftwareFallbackAnnounced, 1) == 0)
{
Console.Error.WriteLine(
"[LOADER][INFO] Host lacks a BMI/ABM extension used by the guest; " +
"emulating those instructions in software.");
}
return true;
}
private unsafe bool TryEvaluate(
void* contextRecord,
in Instruction instruction,
GprOperandSize size,
out ulong result,
out bool flagsChanged,
out uint eflags)
{
result = 0;
flagsChanged = false;
eflags = ReadCtxU32(contextRecord, CTX_EFLAGS);
switch (instruction.Mnemonic)
{
case Mnemonic.Andn:
if (!TryReadOperand(contextRecord, in instruction, 1, size, out var andnSrc1) ||
!TryReadOperand(contextRecord, in instruction, 2, size, out var andnSrc2))
{
return false;
}
result = BmiInstructionEmulator.Andn(andnSrc1, andnSrc2, size, ref eflags);
flagsChanged = true;
return true;
case Mnemonic.Blsi:
case Mnemonic.Blsmsk:
case Mnemonic.Blsr:
if (!TryReadOperand(contextRecord, in instruction, 1, size, out var blsSrc))
{
return false;
}
result = instruction.Mnemonic switch
{
Mnemonic.Blsi => BmiInstructionEmulator.Blsi(blsSrc, size, ref eflags),
Mnemonic.Blsmsk => BmiInstructionEmulator.Blsmsk(blsSrc, size, ref eflags),
_ => BmiInstructionEmulator.Blsr(blsSrc, size, ref eflags),
};
flagsChanged = true;
return true;
case Mnemonic.Bextr:
if (!TryReadOperand(contextRecord, in instruction, 1, size, out var bextrSrc) ||
!TryReadOperand(contextRecord, in instruction, 2, size, out var bextrControl))
{
return false;
}
result = BmiInstructionEmulator.Bextr(bextrSrc, bextrControl, size, ref eflags);
flagsChanged = true;
return true;
case Mnemonic.Bzhi:
if (!TryReadOperand(contextRecord, in instruction, 1, size, out var bzhiSrc) ||
!TryReadOperand(contextRecord, in instruction, 2, size, out var bzhiIndex))
{
return false;
}
result = BmiInstructionEmulator.Bzhi(bzhiSrc, bzhiIndex, size, ref eflags);
flagsChanged = true;
return true;
case Mnemonic.Tzcnt:
case Mnemonic.Lzcnt:
if (!TryReadOperand(contextRecord, in instruction, 1, size, out var cntSrc))
{
return false;
}
result = instruction.Mnemonic == Mnemonic.Tzcnt
? BmiInstructionEmulator.Tzcnt(cntSrc, size, ref eflags)
: BmiInstructionEmulator.Lzcnt(cntSrc, size, ref eflags);
flagsChanged = true;
return true;
case Mnemonic.Rorx:
if (instruction.Op2Kind != OpKind.Immediate8 ||
!TryReadOperand(contextRecord, in instruction, 1, size, out var rorxSrc))
{
return false;
}
result = BmiInstructionEmulator.Rorx(rorxSrc, instruction.Immediate8, size);
return true;
case Mnemonic.Sarx:
case Mnemonic.Shlx:
case Mnemonic.Shrx:
if (!TryReadOperand(contextRecord, in instruction, 1, size, out var shiftSrc) ||
!TryReadOperand(contextRecord, in instruction, 2, size, out var shiftCount))
{
return false;
}
result = instruction.Mnemonic switch
{
Mnemonic.Sarx => BmiInstructionEmulator.Sarx(shiftSrc, (int)shiftCount, size),
Mnemonic.Shlx => BmiInstructionEmulator.Shlx(shiftSrc, (int)shiftCount, size),
_ => BmiInstructionEmulator.Shrx(shiftSrc, (int)shiftCount, size),
};
return true;
case Mnemonic.Pdep:
case Mnemonic.Pext:
if (!TryReadOperand(contextRecord, in instruction, 1, size, out var packSrc) ||
!TryReadOperand(contextRecord, in instruction, 2, size, out var packMask))
{
return false;
}
result = instruction.Mnemonic == Mnemonic.Pdep
? BmiInstructionEmulator.Pdep(packSrc, packMask, size)
: BmiInstructionEmulator.Pext(packSrc, packMask, size);
return true;
default:
return false;
}
}
private unsafe bool TryReadFaultingInstruction(ulong rip, out Instruction instruction)
{
// Try the full instruction window first, then shrink so a fault near the end of a mapped
// page (where fewer than 15 bytes are readable) still decodes.
foreach (var attempt in DecodeWindowSizes)
{
var buffer = new byte[attempt];
if (!TryReadHostBytes(rip, buffer))
{
continue;
}
var decoder = Decoder.Create(64, new ByteArrayCodeReader(buffer));
decoder.IP = rip;
decoder.Decode(out instruction);
if (instruction.Code != Code.INVALID && instruction.Length > 0 && instruction.Length <= attempt)
{
return true;
}
}
instruction = default;
return false;
}
private unsafe bool TryReadOperand(
void* contextRecord,
in Instruction instruction,
int operandIndex,
GprOperandSize size,
out ulong value)
{
value = 0;
switch (instruction.GetOpKind(operandIndex))
{
case OpKind.Register:
if (!TryGetGprSlot(instruction.GetOpRegister(operandIndex), out var offset, out _))
{
return false;
}
var raw = ReadCtxU64(contextRecord, offset);
value = size == GprOperandSize.Bits64 ? raw : raw & 0xFFFF_FFFFUL;
return true;
case OpKind.Memory:
if (!TryComputeMemoryAddress(contextRecord, in instruction, out var address))
{
return false;
}
var byteCount = size == GprOperandSize.Bits64 ? 8 : 4;
var buffer = new byte[byteCount];
if (!TryReadHostBytes(address, buffer))
{
return false;
}
value = byteCount == 8
? BinaryPrimitives.ReadUInt64LittleEndian(buffer)
: BinaryPrimitives.ReadUInt32LittleEndian(buffer);
return true;
default:
return false;
}
}
private unsafe bool TryComputeMemoryAddress(void* contextRecord, in Instruction instruction, out ulong address)
{
address = 0;
// FS/GS-relative operands need the guest segment base, which is not modelled here.
if (instruction.SegmentPrefix != Register.None)
{
return false;
}
if (instruction.IsIPRelativeMemoryOperand)
{
address = instruction.IPRelativeMemoryAddress;
return true;
}
var effective = instruction.MemoryDisplacement64;
if (instruction.MemoryBase != Register.None)
{
if (!TryGetGpr64Offset(instruction.MemoryBase, out var baseOffset))
{
return false;
}
effective += ReadCtxU64(contextRecord, baseOffset);
}
if (instruction.MemoryIndex != Register.None)
{
if (!TryGetGpr64Offset(instruction.MemoryIndex, out var indexOffset))
{
return false;
}
effective += ReadCtxU64(contextRecord, indexOffset) * (ulong)instruction.MemoryIndexScale;
}
address = effective;
return true;
}
// Maps a 32- or 64-bit GPR to its CONTEXT offset and reports the operand width it implies.
private static bool TryGetGprSlot(Register register, out int offset, out GprOperandSize size)
{
switch (register)
{
case Register.EAX: offset = CTX_RAX; size = GprOperandSize.Bits32; return true;
case Register.ECX: offset = CTX_RCX; size = GprOperandSize.Bits32; return true;
case Register.EDX: offset = CTX_RDX; size = GprOperandSize.Bits32; return true;
case Register.EBX: offset = CTX_RBX; size = GprOperandSize.Bits32; return true;
case Register.ESP: offset = CTX_RSP; size = GprOperandSize.Bits32; return true;
case Register.EBP: offset = CTX_RBP; size = GprOperandSize.Bits32; return true;
case Register.ESI: offset = CTX_RSI; size = GprOperandSize.Bits32; return true;
case Register.EDI: offset = CTX_RDI; size = GprOperandSize.Bits32; return true;
case Register.R8D: offset = CTX_R8; size = GprOperandSize.Bits32; return true;
case Register.R9D: offset = CTX_R9; size = GprOperandSize.Bits32; return true;
case Register.R10D: offset = CTX_R10; size = GprOperandSize.Bits32; return true;
case Register.R11D: offset = CTX_R11; size = GprOperandSize.Bits32; return true;
case Register.R12D: offset = CTX_R12; size = GprOperandSize.Bits32; return true;
case Register.R13D: offset = CTX_R13; size = GprOperandSize.Bits32; return true;
case Register.R14D: offset = CTX_R14; size = GprOperandSize.Bits32; return true;
case Register.R15D: offset = CTX_R15; size = GprOperandSize.Bits32; return true;
default:
if (TryGetGpr64Offset(register, out offset))
{
size = GprOperandSize.Bits64;
return true;
}
size = GprOperandSize.Bits32;
return false;
}
}
private static bool TryGetGpr64Offset(Register register, out int offset)
{
switch (register)
{
case Register.RAX: offset = CTX_RAX; return true;
case Register.RCX: offset = CTX_RCX; return true;
case Register.RDX: offset = CTX_RDX; return true;
case Register.RBX: offset = CTX_RBX; return true;
case Register.RSP: offset = CTX_RSP; return true;
case Register.RBP: offset = CTX_RBP; return true;
case Register.RSI: offset = CTX_RSI; return true;
case Register.RDI: offset = CTX_RDI; return true;
case Register.R8: offset = CTX_R8; return true;
case Register.R9: offset = CTX_R9; return true;
case Register.R10: offset = CTX_R10; return true;
case Register.R11: offset = CTX_R11; return true;
case Register.R12: offset = CTX_R12; return true;
case Register.R13: offset = CTX_R13; return true;
case Register.R14: offset = CTX_R14; return true;
case Register.R15: offset = CTX_R15; return true;
default: offset = 0; return false;
}
}
}
@@ -598,7 +598,7 @@ public sealed partial class DirectExecutionBackend
}
*(ulong*)(argPackPtr + 96) = unchecked((ulong)transferStub);
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_FIBER"), "1", StringComparison.Ordinal))
if (_logFiber)
{
Console.Error.WriteLine(
$"[LOADER][TRACE] fiber.context-transfer rip=0x{transferTarget.Rip:X16} " +
@@ -1292,7 +1292,7 @@ public sealed partial class DirectExecutionBackend
}
int returnValue;
if (IsNoBlockLeafImport(importStubEntry.Nid))
if (importStubEntry.IsNoBlockLeaf)
{
cpuContext.ClearRaxWriteFlag();
returnValue = export.Function(cpuContext);
@@ -48,6 +48,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
// hashing are hoisted to stub-setup time.
public bool IsLeaf { get; }
public bool IsNoBlockLeaf { get; }
public bool SuppressStrlenTrace { get; }
public bool IsLoopGuardBoundary { get; }
@@ -59,6 +61,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
string nid,
ExportedFunction? export,
bool isLeaf,
bool isNoBlockLeaf,
bool suppressStrlenTrace,
bool isLoopGuardBoundary,
ulong nidHash)
@@ -67,6 +70,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
Nid = nid;
Export = export;
IsLeaf = isLeaf;
IsNoBlockLeaf = isNoBlockLeaf;
SuppressStrlenTrace = suppressStrlenTrace;
IsLoopGuardBoundary = isLoopGuardBoundary;
NidHash = nidHash;
@@ -318,6 +322,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private bool _logUsleep;
private bool _logFiber;
private bool _logBootstrap;
private bool _logAllImports;
@@ -712,7 +718,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private string? _guestThreadYieldReason;
private bool _forcedGuestExit;
private volatile bool _forcedGuestExit;
private ulong _lastAvTraceRip;
@@ -911,7 +917,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private bool ActiveForcedGuestExit
{
get => HasActiveExecutionThread ? _activeForcedGuestExit : _forcedGuestExit;
// Host shutdown is requested from a UI or VideoOut thread. Native guest
// workers have their own thread-local execution state, so they must also
// observe the backend-wide shutdown flag.
get => _forcedGuestExit || (HasActiveExecutionThread && _activeForcedGuestExit);
set
{
if (HasActiveExecutionThread)
@@ -1067,6 +1076,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
_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);
_logFiber = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_FIBER"), "1", StringComparison.Ordinal);
_logBootstrap = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_BOOTSTRAP"), "1", StringComparison.Ordinal);
_logAllImports = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_ALL_IMPORTS"), "1", StringComparison.Ordinal);
_logImportFrames = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_IMPORT_FRAMES"), "1", StringComparison.Ordinal);
@@ -1164,6 +1174,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
text2,
resolvedExport,
IsLeafImport(text2),
IsNoBlockLeafImport(text2),
ShouldSuppressStrlenTrace(text2),
IsImportLoopGuardBoundary(text2),
StableHash64(text2));
@@ -1760,14 +1771,16 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
frameAddress = 0;
transferStub = 0;
error = null;
if (target.Rip < 65536 || target.Rsp == 0)
if (ActiveCpuContext is not { } activeContext)
{
error = $"invalid guest context transfer target rip=0x{target.Rip:X16} rsp=0x{target.Rsp:X16}";
error = "guest context transfer without an active CPU context";
return false;
}
if (target.Rsp < sizeof(ulong) ||
ActiveCpuContext is not { } activeContext ||
!activeContext.TryWriteUInt64(target.Rsp - sizeof(ulong), target.Rip))
if (!TryValidateGuestContextTransferTarget(activeContext.Memory, target, out error))
{
return false;
}
if (!activeContext.TryWriteUInt64(target.Rsp - sizeof(ulong), target.Rip))
{
error = $"guest context transfer slot is not writable at 0x{target.Rsp - sizeof(ulong):X16}";
return false;
@@ -1811,6 +1824,30 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return true;
}
internal static bool TryValidateGuestContextTransferTarget(
ICpuMemory memory,
in GuestCpuContinuation target,
out string? error)
{
if (target.Rip < 65536 || target.Rsp < sizeof(ulong))
{
error = $"invalid guest context transfer target rip=0x{target.Rip:X16} rsp=0x{target.Rsp:X16}";
return false;
}
Span<byte> ripProbe = stackalloc byte[1];
if (!memory.TryRead(target.Rip, ripProbe))
{
error =
$"guest context transfer target rip=0x{target.Rip:X16} is not mapped guest memory " +
$"(rsp=0x{target.Rsp:X16})";
return false;
}
error = null;
return true;
}
private unsafe nint GetOrCreateGuestContextTransferStub()
{
if (Volatile.Read(ref _guestContextTransferStub) != 0)
@@ -3264,31 +3301,15 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{
Pump(callerContext, reason);
var threads = SnapshotGuestThreads();
if (threads.Length == 0)
// Tally run states under the lock without allocating a snapshot every
// spin (this loop can iterate rapidly); the full snapshot is only
// materialized for the gated diagnostic dump below.
GetGuestThreadActivity(out var threadCount, out var hasReadyThread, out var hasRunningThread, out var hasBlockedThread);
if (threadCount == 0)
{
return;
}
var hasReadyThread = false;
var hasRunningThread = false;
var hasBlockedThread = false;
foreach (var thread in threads)
{
switch (thread.State)
{
case GuestThreadRunState.Ready:
hasReadyThread = true;
break;
case GuestThreadRunState.Running:
hasRunningThread = true;
break;
case GuestThreadRunState.Blocked:
hasBlockedThread = true;
break;
}
}
if (hasReadyThread)
{
continue;
@@ -3301,7 +3322,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
if (_logGuestThreads && Stopwatch.GetTimestamp() >= nextSnapshotTimestamp)
{
foreach (var thread in threads)
foreach (var thread in SnapshotGuestThreads())
{
Console.Error.WriteLine(
$"[LOADER][TRACE] guest_thread.idle_wait reason={reason} handle=0x{thread.ThreadHandle:X16} " +
@@ -3321,7 +3342,36 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{
using (LockGate("SnapshotGuestThreads"))
{
return _guestThreads.Values.ToArray();
var snapshot = new GuestThreadState[_guestThreads.Count];
_guestThreads.Values.CopyTo(snapshot, 0);
return snapshot;
}
}
// Allocation-free run-state tally for the idle spin loop.
private void GetGuestThreadActivity(out int count, out bool hasReady, out bool hasRunning, out bool hasBlocked)
{
hasReady = false;
hasRunning = false;
hasBlocked = false;
using (LockGate("GetGuestThreadActivity"))
{
count = _guestThreads.Count;
foreach (var thread in _guestThreads.Values)
{
switch (thread.State)
{
case GuestThreadRunState.Ready:
hasReady = true;
break;
case GuestThreadRunState.Running:
hasRunning = true;
break;
case GuestThreadRunState.Blocked:
hasBlocked = true;
break;
}
}
}
}
@@ -3362,6 +3412,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{
returnValue = 0;
error = null;
if (_forcedGuestExit)
{
error = "guest execution is shutting down";
return false;
}
if (entryPoint < 65536)
{
error = $"invalid guest callback entry=0x{entryPoint:X16}";
@@ -3608,6 +3663,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
out string? error)
{
error = null;
if (_forcedGuestExit)
{
error = "guest execution is shutting down";
return false;
}
if (continuation.Rip < 65536 || continuation.Rsp == 0)
{
error = $"invalid guest continuation rip=0x{continuation.Rip:X16} rsp=0x{continuation.Rsp:X16}";
@@ -4638,6 +4698,21 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private void RunGuestThread(GuestThreadState thread, string reason)
{
if (_forcedGuestExit)
{
// Host shutdown: never enter guest code again. Teardown is about to
// free trampolines and the guest address space, and it only waits
// for executors that are already inside a slice.
lock (_guestThreadGate)
{
thread.State = GuestThreadRunState.Faulted;
thread.BlockReason = "host shutdown";
thread.HostThread = null;
thread.ExecutorActive = false;
}
return;
}
lock (_guestThreadGate)
{
if (!thread.ExecutorActive)
@@ -5080,64 +5155,51 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
BindTlsBase(context);
byte* ptr2 = (byte*)ptr;
ulong hostRspSlot = (ulong)hostRspStorage;
int offset = 0;
var emitter = new NativeCodeEmitter(ptr2);
void Emit(byte value) => ptr2[offset++] = value;
void EmitU64(ulong value)
{
*(ulong*)(ptr2 + offset) = value;
offset += sizeof(ulong);
}
void EmitMovR64Imm(byte rex, byte opcode, ulong value)
{
Emit(rex);
Emit(opcode);
EmitU64(value);
}
Emit(0x53); // push rbx
Emit(0x55); // push rbp
Emit(0x57); // push rdi
Emit(0x56); // push rsi
Emit(0x41); Emit(0x54); // push r12
Emit(0x41); Emit(0x55); // push r13
Emit(0x41); Emit(0x56); // push r14
Emit(0x41); Emit(0x57); // push r15
EmitHostNonvolatileXmmSave(ptr2, ref offset);
emitter.Emit(0x53); // push rbx
emitter.Emit(0x55); // push rbp
emitter.Emit(0x57); // push rdi
emitter.Emit(0x56); // push rsi
emitter.Emit(0x41); emitter.Emit(0x54); // push r12
emitter.Emit(0x41); emitter.Emit(0x55); // push r13
emitter.Emit(0x41); emitter.Emit(0x56); // push r14
emitter.Emit(0x41); emitter.Emit(0x57); // push r15
EmitHostNonvolatileXmmSave(ptr2, ref emitter.Offset);
// Restore the fiber's floating-point control environment before
// abandoning the host stack. This path is used when a blocked guest
// continuation migrates to another managed worker.
Emit(0x48); Emit(0x83); Emit(0xEC); Emit(0x08); // sub rsp,8
Emit(0xC7); Emit(0x04); Emit(0x24); // mov dword [rsp],imm32
*(uint*)(ptr2 + offset) = context.Mxcsr; offset += sizeof(uint);
Emit(0x0F); Emit(0xAE); Emit(0x14); Emit(0x24); // ldmxcsr [rsp]
Emit(0x66); Emit(0xC7); Emit(0x04); Emit(0x24); // mov word [rsp],imm16
*(ushort*)(ptr2 + offset) = context.FpuControlWord; offset += sizeof(ushort);
Emit(0xD9); Emit(0x2C); Emit(0x24); // fldcw [rsp]
Emit(0x48); Emit(0x83); Emit(0xC4); Emit(0x08); // add rsp,8
EmitMovR64Imm(0x49, 0xBA, hostRspSlot); // mov r10, hostRspSlot
Emit(0x49); Emit(0x89); Emit(0x22); // mov [r10], rsp
EmitMovR64Imm(0x48, 0xB8, context[CpuRegister.Rsp]); // mov rax, guest rsp
Emit(0x48); Emit(0x89); Emit(0xC4); // mov rsp, rax
Emit(0x48); Emit(0x83); Emit(0xEC); Emit(0x08); // reserve transfer slot
EmitMovR64Imm(0x48, 0xB8, entryPoint); // mov rax, entryPoint
Emit(0x48); Emit(0x89); Emit(0x04); Emit(0x24); // mov [rsp],rax
EmitMovR64Imm(0x48, 0xBB, context[CpuRegister.Rbx]); // mov rbx, imm64
EmitMovR64Imm(0x48, 0xBD, context[CpuRegister.Rbp]); // mov rbp, imm64
EmitMovR64Imm(0x48, 0xBF, context[CpuRegister.Rdi]); // mov rdi, imm64
EmitMovR64Imm(0x48, 0xBE, context[CpuRegister.Rsi]); // mov rsi, imm64
EmitMovR64Imm(0x48, 0xBA, context[CpuRegister.Rdx]); // mov rdx, imm64
EmitMovR64Imm(0x48, 0xB9, context[CpuRegister.Rcx]); // mov rcx, imm64
EmitMovR64Imm(0x49, 0xB8, context[CpuRegister.R8]); // mov r8, imm64
EmitMovR64Imm(0x49, 0xB9, context[CpuRegister.R9]); // mov r9, imm64
EmitMovR64Imm(0x49, 0xBA, context[CpuRegister.R10]); // mov r10, imm64
EmitMovR64Imm(0x49, 0xBC, context[CpuRegister.R12]); // mov r12, imm64
EmitMovR64Imm(0x49, 0xBD, context[CpuRegister.R13]); // mov r13, imm64
EmitMovR64Imm(0x49, 0xBE, context[CpuRegister.R14]); // mov r14, imm64
EmitMovR64Imm(0x49, 0xBF, context[CpuRegister.R15]); // mov r15, imm64
EmitMovR64Imm(0x49, 0xBB, context[CpuRegister.R11]); // mov r11, imm64
EmitMovR64Imm(0x48, 0xB8, context[CpuRegister.Rax]); // mov rax, imm64
Emit(0xC3); // ret through the synthetic transfer slot
emitter.Emit(0x48); emitter.Emit(0x83); emitter.Emit(0xEC); emitter.Emit(0x08); // sub rsp,8
emitter.Emit(0xC7); emitter.Emit(0x04); emitter.Emit(0x24); // mov dword [rsp],imm32
emitter.Emit(context.Mxcsr);
emitter.Emit(0x0F); emitter.Emit(0xAE); emitter.Emit(0x14); emitter.Emit(0x24); // ldmxcsr [rsp]
emitter.Emit(0x66); emitter.Emit(0xC7); emitter.Emit(0x04); emitter.Emit(0x24); // mov word [rsp],imm16
emitter.Emit(context.FpuControlWord);
emitter.Emit(0xD9); emitter.Emit(0x2C); emitter.Emit(0x24); // fldcw [rsp]
emitter.Emit(0x48); emitter.Emit(0x83); emitter.Emit(0xC4); emitter.Emit(0x08); // add rsp,8
emitter.EmitMovR64Immediate(0x49, 0xBA, hostRspSlot); // mov r10, hostRspSlot
emitter.Emit(0x49); emitter.Emit(0x89); emitter.Emit(0x22); // mov [r10], rsp
emitter.EmitMovR64Immediate(0x48, 0xB8, context[CpuRegister.Rsp]); // mov rax, guest rsp
emitter.Emit(0x48); emitter.Emit(0x89); emitter.Emit(0xC4); // mov rsp, rax
emitter.Emit(0x48); emitter.Emit(0x83); emitter.Emit(0xEC); emitter.Emit(0x08); // reserve transfer slot
emitter.EmitMovR64Immediate(0x48, 0xB8, entryPoint); // mov rax, entryPoint
emitter.Emit(0x48); emitter.Emit(0x89); emitter.Emit(0x04); emitter.Emit(0x24); // mov [rsp],rax
emitter.EmitMovR64Immediate(0x48, 0xBB, context[CpuRegister.Rbx]); // mov rbx, imm64
emitter.EmitMovR64Immediate(0x48, 0xBD, context[CpuRegister.Rbp]); // mov rbp, imm64
emitter.EmitMovR64Immediate(0x48, 0xBF, context[CpuRegister.Rdi]); // mov rdi, imm64
emitter.EmitMovR64Immediate(0x48, 0xBE, context[CpuRegister.Rsi]); // mov rsi, imm64
emitter.EmitMovR64Immediate(0x48, 0xBA, context[CpuRegister.Rdx]); // mov rdx, imm64
emitter.EmitMovR64Immediate(0x48, 0xB9, context[CpuRegister.Rcx]); // mov rcx, imm64
emitter.EmitMovR64Immediate(0x49, 0xB8, context[CpuRegister.R8]); // mov r8, imm64
emitter.EmitMovR64Immediate(0x49, 0xB9, context[CpuRegister.R9]); // mov r9, imm64
emitter.EmitMovR64Immediate(0x49, 0xBA, context[CpuRegister.R10]); // mov r10, imm64
emitter.EmitMovR64Immediate(0x49, 0xBC, context[CpuRegister.R12]); // mov r12, imm64
emitter.EmitMovR64Immediate(0x49, 0xBD, context[CpuRegister.R13]); // mov r13, imm64
emitter.EmitMovR64Immediate(0x49, 0xBE, context[CpuRegister.R14]); // mov r14, imm64
emitter.EmitMovR64Immediate(0x49, 0xBF, context[CpuRegister.R15]); // mov r15, imm64
emitter.EmitMovR64Immediate(0x49, 0xBB, context[CpuRegister.R11]); // mov r11, imm64
emitter.EmitMovR64Immediate(0x48, 0xB8, context[CpuRegister.Rax]); // mov rax, imm64
emitter.Emit(0xC3); // ret through the synthetic transfer slot
ActiveEntryReturnSentinelRip = (ulong)_guestReturnStub;
if (returnSlotAddress == 0 || !context.TryWriteUInt64(returnSlotAddress, (ulong)_guestReturnStub))
{
@@ -5201,6 +5263,45 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
}
}
// The continuation trampoline is rebuilt on every blocked-thread resume.
// Keep its tiny writer on the stack: capturing local emit functions create a
// managed display-class allocation on this extremely hot path.
private unsafe ref struct NativeCodeEmitter(byte* code)
{
private readonly byte* _code = code;
public int Offset;
public void Emit(byte value)
{
_code[Offset++] = value;
}
public void Emit(ushort value)
{
*(ushort*)(_code + Offset) = value;
Offset += sizeof(ushort);
}
public void Emit(uint value)
{
*(uint*)(_code + Offset) = value;
Offset += sizeof(uint);
}
private void Emit(ulong value)
{
*(ulong*)(_code + Offset) = value;
Offset += sizeof(ulong);
}
public void EmitMovR64Immediate(byte rex, byte opcode, ulong value)
{
Emit(rex);
Emit(opcode);
Emit(value);
}
}
private static ulong AlignDown(ulong value, ulong alignment)
{
if (alignment == 0)
@@ -5453,7 +5554,14 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
{
num6 = CallNativeEntry(ptr);
Console.Error.WriteLine($"[LOADER][INFO] Guest returned: {num6}");
PumpUntilGuestThreadsIdle(context, "entry_return");
// A host stop has already invalidated the session. Draining guest
// continuations here can re-enter a blocked HLE call after its owner
// has exited, preventing the embedded GUI from receiving its exit
// callback.
if (!ActiveForcedGuestExit)
{
PumpUntilGuestThreadsIdle(context, "entry_return");
}
}
catch (AccessViolationException ex)
{
@@ -6106,8 +6214,68 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool Win32CloseHandle(nint hObject);
/// <summary>
/// Set when <see cref="Dispose"/> intentionally left the native session
/// state (import trampolines, TLS, exception handlers) alive because guest
/// worker threads were still executing guest code. The owning runtime must
/// then keep the guest address space mapped as well; freeing either under a
/// running worker faults the whole process, which hosts the GUI launcher.
/// </summary>
internal bool GuestSessionLeaked { get; private set; }
private bool WaitForGuestThreadQuiescence(TimeSpan timeout)
{
var deadline = Stopwatch.GetTimestamp() + (long)(timeout.TotalSeconds * Stopwatch.Frequency);
while (true)
{
var busyCount = 0;
string? busyName = null;
var now = Stopwatch.GetTimestamp();
using (LockGate("WaitForGuestThreadQuiescence"))
{
foreach (var thread in _guestThreads.Values)
{
if (thread.ExecutorActive)
{
busyCount++;
busyName ??= thread.Name;
}
}
}
if (busyCount == 0)
{
return true;
}
if (now >= deadline)
{
Console.Error.WriteLine(
$"[LOADER][WARN] {busyCount} guest worker(s) (first: '{busyName}') did not leave guest code " +
"during shutdown; the native session state stays alive to avoid faulting them.");
return false;
}
Thread.Sleep(5);
}
}
public unsafe void Dispose()
{
// Guest workers unwind cooperatively at their next import or block
// boundary once the forced-exit flag is set. Everything freed below is
// still reachable from a worker inside guest code, so drain the
// scheduler first and leak the session rather than fault a straggler.
_forcedGuestExit = true;
StopReadyThreadDispatcher();
StopStallWatchdog();
if (!WaitForGuestThreadQuiescence(TimeSpan.FromSeconds(5)))
{
GuestSessionLeaked = true;
return;
}
ClearGuestThreads();
if (ReferenceEquals(_posixSignalBackend, this))
{
// The signal handlers stay installed (they chain to the previous
+22 -21
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,9 @@ public sealed class SelfLoader : ISelfLoader
{
if (!physicalVm.TryAllocateAtExact(imageBase, totalImageSize, executable: true, out var allocatedBase))
{
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
throw new InvalidOperationException(
$"Could not allocate main image at required base 0x{imageBase:X16} (size=0x{totalImageSize:X}).");
$"Could not allocate main image at required base 0x{imageBase:X16} (size=0x{totalImageSize:X}): {reason}.");
}
imageBase = allocatedBase;
@@ -354,10 +357,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 +384,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.");
}
@@ -2788,28 +2790,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)]
@@ -198,6 +198,24 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return true;
}
public string DescribeAddressForDiagnostics(ulong address)
{
if (!_hostMemory.Query(address, out var info))
{
return "unable to query host memory at this address";
}
return info.State switch
{
HostRegionState.Free => "address reports free, but the exact-address reservation still failed",
HostRegionState.Reserved =>
$"already reserved by another host allocation (base=0x{info.AllocationBase:X16}, size=0x{info.RegionSize:X})",
HostRegionState.Committed =>
$"already committed by another host allocation (base=0x{info.AllocationBase:X16}, size=0x{info.RegionSize:X}, protect=0x{info.RawProtection:X})",
_ => $"in an unexpected host state (raw=0x{info.RawState:X})",
};
}
public ulong AllocateAt(ulong desiredAddress, ulong size, bool executable = true, bool allowAlternative = true)
{
if (size == 0)
@@ -349,10 +367,8 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
var requestedCursor = AlignUp(desiredAddress, effectiveAlignment);
var cursor = GetAllocationSearchCursor(desiredAddress, requestedCursor, effectiveAlignment, executable);
// POSIX treats the requested address as a hint, and Rosetta may
// relocate whole guest windows. Over-allocate once so the returned
// host range always contains an aligned guest-visible start.
if (!OperatingSystem.IsWindows())
// macOS needs alignment over-allocation; Linux uses exact-address search.
if (OperatingSystem.IsMacOS())
{
var reserveSize = effectiveAlignment > PageSize
? alignedSize + effectiveAlignment
@@ -12,6 +12,7 @@ using SharpEmu.Libs.Kernel;
using SharpEmu.Libs.AppContent;
using SharpEmu.Libs.SaveData;
using SharpEmu.Libs.Fiber;
using SharpEmu.Libs.SystemService;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Linq;
@@ -141,6 +142,7 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
var image = LoadImage(normalizedEbootPath);
VideoOutExports.ConfigureApplicationInfo(image.Title, image.TitleId, image.Version);
SaveDataExports.ConfigureApplicationInfo(image.TitleId);
SystemServiceExports.ConfigureApplicationInfo(image.TitleId);
_ = RegisterLoadedModule(normalizedEbootPath, image, isMain: true, isSystemModule: false);
KernelRuntimeCompatExports.ConfigureProcessProcParamAddress(image.ProcParamAddress);
Console.Error.WriteLine($"[RUNTIME] Entry: 0x{image.EntryPoint:X16}");
@@ -187,6 +189,16 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
Console.Error.WriteLine($"[RUNTIME] DispatchEntry returned: {result}");
Console.Error.WriteLine($"[RUNTIME] Dispatch result: {result}");
// Stop is a host operation, not an emulation failure. The detailed
// trace and session-summary builders can traverse a partially torn
// down native backend, delaying the GUI exit callback indefinitely.
if (HostSessionControl.IsShutdownRequested)
{
Console.Error.WriteLine("[RUNTIME] Skipping post-exit diagnostics for host shutdown.");
return result;
}
LastExecutionTrace = _cpuDispatcher.LastImportResolutionTrace;
LastMilestoneLog = _cpuDispatcher.LastMilestoneLog;
LastSessionSummary = BuildSessionSummary(_cpuDispatcher.LastSessionSummary);
@@ -1150,6 +1162,16 @@ public sealed class SharpEmuRuntime : ISharpEmuRuntime
disposableDispatcher.Dispose();
}
if (_cpuDispatcher is CpuDispatcher { NativeSessionLeaked: true })
{
// A guest worker is still inside guest code; unmapping the guest
// address space under it would fault the whole process, which
// hosts the GUI launcher in embedded sessions.
Console.Error.WriteLine(
"[RUNTIME] Guest workers were still active at teardown; keeping the guest address space mapped.");
return;
}
if (_virtualMemory is IDisposable disposableMemory)
{
disposableMemory.Dispose();
+4
View File
@@ -14,6 +14,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<PackageReference Include="Iced" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="SharpEmu.Libs.Tests" />
</ItemGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
+17
View File
@@ -29,6 +29,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<SolidColorBrush x:Key="DangerBrush" Color="#E5484D" />
<SolidColorBrush x:Key="DangerHoverBrush" Color="#F2555A" />
<SolidColorBrush x:Key="SuccessBrush" Color="#46C46B" />
<SolidColorBrush x:Key="InfoBrush" Color="#58A6FF" />
<SolidColorBrush x:Key="TileHoverBrush" Color="#1A2130" />
<SolidColorBrush x:Key="TileSelectedBrush" Color="#212A3F" />
</Application.Resources>
@@ -55,6 +56,22 @@ SPDX-License-Identifier: GPL-2.0-or-later
<Setter Property="Padding" Value="10,3" />
</Style>
<!-- Session status/hotkey badges: the title-id pill geometry with a
tinted fill so state (RUNNING) and keys (F11) read at a glance. -->
<Style Selector="Border.badge">
<Setter Property="CornerRadius" Value="999" />
<Setter Property="Padding" Value="8,2" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Border.badge.running">
<Setter Property="Background" Value="#1E46C46B" />
<Setter Property="BorderBrush" Value="#5546C46B" />
</Style>
<Style Selector="Border.badge.key">
<Setter Property="Background" Value="#1E58A6FF" />
<Setter Property="BorderBrush" Value="#5558A6FF" />
</Style>
<Style Selector="TextBlock.sectionTitle">
<Setter Property="FontSize" Value="11" />
<Setter Property="FontWeight" Value="SemiBold" />
+331 -316
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(
exePath,
commandLine,
0,
0,
true,
ExtendedStartupInfoPresent | CreateNoWindow,
0,
string.IsNullOrWhiteSpace(workingDirectory)
? Path.GetDirectoryName(exePath) ?? Environment.CurrentDirectory
: workingDirectory,
ref startup,
out processInfo))
{
throw new InvalidOperationException($"Could not start the emulator process (Win32 error {Marshal.GetLastWin32Error()}).");
}
}
finally
{
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, previousValue);
}
}
CloseHandle(processInfo.hThread);
_processHandle = processInfo.hProcess;
processHandle = processInfo.Process;
threadHandle = processInfo.Thread;
CloseHandle(stdoutWrite);
stdoutWrite = 0;
CloseHandle(stderrWrite);
stderrWrite = 0;
_jobHandle = CreateJobObjectW(0, null);
if (_jobHandle != 0 &&
(!TryEnableKillOnJobClose(_jobHandle) || !AssignProcessToJobObject(_jobHandle, processInfo.hProcess)))
var jobHandle = CreateJobObjectW(0, null);
if (jobHandle != 0 &&
(!TryEnableKillOnJobClose(jobHandle) || !AssignProcessToJobObject(jobHandle, processHandle)))
{
CloseHandle(_jobHandle);
_jobHandle = 0;
CloseHandle(jobHandle);
jobHandle = 0;
}
StartReaderThread(stdoutRead, isError: false);
StartReaderThread(stderrRead, isError: true);
StartExitWatcherThread();
lock (_sync)
{
_processHandle = processHandle;
_jobHandle = jobHandle;
_running = true;
}
processHandle = 0;
StartPipeReader(stdoutRead, isError: false);
stdoutRead = 0;
StartPipeReader(stderrRead, isError: true);
stderrRead = 0;
StartWindowsExitWatcher(_processHandle);
}
catch
{
CloseHandle(stdoutRead);
CloseHandle(stderrRead);
if (processHandle != 0)
{
_ = TerminateProcess(processHandle, 1);
}
throw;
}
finally
{
// The child owns duplicated pipe write ends; closing ours lets the
// readers observe EOF when the child exits.
CloseHandle(stdoutWrite);
CloseHandle(stderrWrite);
if (threadHandle != 0)
{
CloseHandle(threadHandle);
}
if (processHandle != 0)
{
CloseHandle(processHandle);
}
if (stdoutRead != 0)
{
CloseHandle(stdoutRead);
}
if (stdoutWrite != 0)
{
CloseHandle(stdoutWrite);
}
if (stderrRead != 0)
{
CloseHandle(stderrRead);
}
if (stderrWrite != 0)
{
CloseHandle(stderrWrite);
}
if (attributeList != 0)
{
DeleteProcThreadAttributeList(attributeList);
Marshal.FreeHGlobal(attributeList);
}
if (mitigationPolicies != 0)
{
Marshal.FreeHGlobal(mitigationPolicies);
@@ -246,103 +346,56 @@ internal sealed class EmulatorProcess : IDisposable
}
}
private void StartFallback(string exePath, IReadOnlyList<string> arguments, string? workingDirectory)
private void StartPipeReader(nint handle, bool isError)
{
var startInfo = new ProcessStartInfo
var readerThread = new Thread(() =>
{
FileName = exePath,
WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
foreach (var argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
process.OutputDataReceived += (_, e) =>
{
if (e.Data is not null)
using var safeHandle = new SafeFileHandle(handle, ownsHandle: true);
using var stream = new FileStream(safeHandle, FileAccess.Read, 4096, isAsync: false);
using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
while (reader.ReadLine() is { } line)
{
OutputReceived?.Invoke(e.Data, false);
ForwardOutput(line, isError);
}
};
process.ErrorDataReceived += (_, e) =>
{
if (e.Data is not null)
{
OutputReceived?.Invoke(e.Data, true);
}
};
process.Exited += (_, _) =>
{
int exitCode;
try
{
exitCode = process.ExitCode;
}
catch (InvalidOperationException)
{
exitCode = -1;
}
OnExited(exitCode);
};
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
_fallbackProcess = process;
}
private void StartReaderThread(nint readHandle, bool isError)
{
var thread = new Thread(() =>
{
using var stream = new FileStream(new SafeFileHandle(readHandle, ownsHandle: true), FileAccess.Read);
using var reader = new StreamReader(stream, Encoding.UTF8);
try
{
while (reader.ReadLine() is { } line)
{
OutputReceived?.Invoke(line, isError);
}
}
catch (IOException)
{
// Pipe broken on process teardown.
}
})
}, 256 * 1024)
{
IsBackground = true,
Name = isError ? "SharpEmu stderr reader" : "SharpEmu stdout reader",
};
thread.Start();
readerThread.Start();
}
private void StartExitWatcherThread()
private void StartWindowsExitWatcher(nint processHandle)
{
var processHandle = _processHandle;
var thread = new Thread(() =>
var watcher = new Thread(() =>
{
_ = WaitForSingleObject(processHandle, INFINITE);
var exitCode = GetExitCodeProcess(processHandle, out var rawExitCode)
? unchecked((int)rawExitCode)
: -1;
_ = WaitForSingleObject(processHandle, Infinite);
var exitCode = 1;
if (GetExitCodeProcess(processHandle, out var nativeExitCode))
{
exitCode = unchecked((int)nativeExitCode);
}
OnExited(exitCode);
})
}, 128 * 1024)
{
IsBackground = true,
Name = "SharpEmu exit watcher",
};
thread.Start();
watcher.Start();
}
private void OnExited(int exitCode)
private void ForwardOutput(string? line, bool isError)
{
if (!string.IsNullOrEmpty(line))
{
OutputReceived?.Invoke(line, isError);
}
}
private void OnExited(int nativeExitCode)
{
int exitCode;
lock (_sync)
{
if (!_running)
@@ -350,13 +403,13 @@ internal sealed class EmulatorProcess : IDisposable
return;
}
exitCode = _stopRequested ? HostStopExitCode : nativeExitCode;
_running = false;
if (_processHandle != 0)
{
CloseHandle(_processHandle);
_processHandle = 0;
}
if (_jobHandle != 0)
{
CloseHandle(_jobHandle);
@@ -372,24 +425,19 @@ internal sealed class EmulatorProcess : IDisposable
private static bool TryEnableKillOnJobClose(nint jobHandle)
{
var extendedLimitInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
var info = new JobObjectExtendedLimitInformation
{
BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION
BasicLimitInformation = new JobObjectBasicLimitInformation
{
LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
LimitFlags = JobObjectLimitKillOnJobClose,
},
};
var size = Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>();
var size = Marshal.SizeOf<JobObjectExtendedLimitInformation>();
var memory = Marshal.AllocHGlobal(size);
try
{
Marshal.StructureToPtr(extendedLimitInfo, memory, false);
return SetInformationJobObject(
jobHandle,
JobObjectExtendedLimitInformation,
memory,
unchecked((uint)size));
Marshal.StructureToPtr(info, memory, false);
return SetInformationJobObject(jobHandle, JobObjectExtendedLimitInformationClass, memory, unchecked((uint)size));
}
finally
{
@@ -397,128 +445,124 @@ internal sealed class EmulatorProcess : IDisposable
}
}
private static string BuildCommandLine(string processPath, IReadOnlyList<string> args)
private static string BuildCommandLine(string processPath, IReadOnlyList<string> arguments)
{
var builder = new StringBuilder();
builder.Append(QuoteArgument(processPath));
for (var i = 0; i < args.Count; i++)
var builder = new StringBuilder(QuoteArgument(processPath));
foreach (var argument in arguments)
{
builder.Append(' ');
builder.Append(QuoteArgument(args[i]));
builder.Append(QuoteArgument(argument));
}
return builder.ToString();
}
private static string QuoteArgument(string argument)
private static string QuoteArgument(string value)
{
if (argument.Length == 0)
if (value.Length == 0)
{
return "\"\"";
}
var needsQuotes = false;
foreach (var c in argument)
if (!value.Any(static c => char.IsWhiteSpace(c) || c == '"'))
{
if (char.IsWhiteSpace(c) || c == '"')
{
needsQuotes = true;
break;
}
return value;
}
if (!needsQuotes)
{
return argument;
}
var builder = new StringBuilder(argument.Length + 2);
var builder = new StringBuilder(value.Length + 2);
builder.Append('"');
var backslashCount = 0;
foreach (var c in argument)
var slashCount = 0;
foreach (var character in value)
{
if (c == '\\')
if (character == '\\')
{
backslashCount++;
slashCount++;
continue;
}
if (c == '"')
if (character == '"')
{
builder.Append('\\', (backslashCount * 2) + 1);
builder.Append('"');
backslashCount = 0;
builder.Append('\\', (slashCount * 2) + 1);
builder.Append(character);
slashCount = 0;
continue;
}
if (backslashCount > 0)
if (slashCount > 0)
{
builder.Append('\\', backslashCount);
backslashCount = 0;
builder.Append('\\', slashCount);
slashCount = 0;
}
builder.Append(c);
builder.Append(character);
}
if (backslashCount > 0)
if (slashCount > 0)
{
builder.Append('\\', backslashCount * 2);
builder.Append('\\', slashCount * 2);
}
builder.Append('"');
return builder.ToString();
}
[StructLayout(LayoutKind.Sequential)]
private struct SECURITY_ATTRIBUTES
private void ThrowIfDisposed()
{
public int nLength;
public nint lpSecurityDescriptor;
public int bInheritHandle;
if (_disposed)
{
throw new ObjectDisposedException(nameof(EmulatorProcess));
}
}
[StructLayout(LayoutKind.Sequential)]
private struct SecurityAttributes
{
public int Size;
public nint SecurityDescriptor;
public int InheritHandle;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct STARTUPINFO
private struct StartupInfo
{
public int cb;
public nint lpReserved;
public nint lpDesktop;
public nint lpTitle;
public int dwX;
public int dwY;
public int dwXSize;
public int dwYSize;
public int dwXCountChars;
public int dwYCountChars;
public int dwFillAttribute;
public int dwFlags;
public short wShowWindow;
public short cbReserved2;
public nint lpReserved2;
public nint hStdInput;
public nint hStdOutput;
public nint hStdError;
public int Size;
public nint Reserved;
public nint Desktop;
public nint Title;
public int X;
public int Y;
public int XSize;
public int YSize;
public int XCountChars;
public int YCountChars;
public int FillAttribute;
public int Flags;
public short ShowWindow;
public short Reserved2Count;
public nint Reserved2;
public nint StdInput;
public nint StdOutput;
public nint StdError;
}
[StructLayout(LayoutKind.Sequential)]
private struct STARTUPINFOEX
private struct StartupInfoEx
{
public STARTUPINFO StartupInfo;
public nint lpAttributeList;
public StartupInfo StartupInfo;
public nint AttributeList;
}
[StructLayout(LayoutKind.Sequential)]
private struct PROCESS_INFORMATION
private struct ProcessInformation
{
public nint hProcess;
public nint hThread;
public int dwProcessId;
public int dwThreadId;
public nint Process;
public nint Thread;
public int ProcessId;
public int ThreadId;
}
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
private struct JobObjectBasicLimitInformation
{
public long PerProcessUserTimeLimit;
public long PerJobUserTimeLimit;
@@ -532,7 +576,7 @@ internal sealed class EmulatorProcess : IDisposable
}
[StructLayout(LayoutKind.Sequential)]
private struct IO_COUNTERS
private struct IoCounters
{
public ulong ReadOperationCount;
public ulong WriteOperationCount;
@@ -543,10 +587,10 @@ internal sealed class EmulatorProcess : IDisposable
}
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
private struct JobObjectExtendedLimitInformation
{
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
public IO_COUNTERS IoInfo;
public JobObjectBasicLimitInformation BasicLimitInformation;
public IoCounters IoInfo;
public nuint ProcessMemoryLimit;
public nuint JobMemoryLimit;
public nuint PeakProcessMemoryUsed;
@@ -555,66 +599,37 @@ internal sealed class EmulatorProcess : IDisposable
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreatePipe(
out nint hReadPipe,
out nint hWritePipe,
ref SECURITY_ATTRIBUTES lpPipeAttributes,
uint nSize);
private static extern bool CreatePipe(out nint readPipe, out nint writePipe, ref SecurityAttributes attributes, uint size);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetHandleInformation(nint hObject, uint dwMask, uint dwFlags);
private static extern bool SetHandleInformation(nint handle, uint mask, uint flags);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool InitializeProcThreadAttributeList(
nint lpAttributeList,
int dwAttributeCount,
int dwFlags,
ref nuint lpSize);
private static extern bool InitializeProcThreadAttributeList(nint list, int count, int flags, ref nuint size);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UpdateProcThreadAttribute(
nint lpAttributeList,
uint dwFlags,
nint attribute,
nint lpValue,
nuint cbSize,
nint lpPreviousValue,
nint lpReturnSize);
private static extern bool UpdateProcThreadAttribute(nint list, uint flags, nint attribute, nint value, nuint size, nint previousValue, nint returnSize);
[DllImport("kernel32.dll")]
private static extern void DeleteProcThreadAttributeList(nint lpAttributeList);
private static extern void DeleteProcThreadAttributeList(nint list);
[DllImport("kernel32.dll", EntryPoint = "CreateJobObjectW", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern nint CreateJobObjectW(nint lpJobAttributes, string? lpName);
private static extern nint CreateJobObjectW(nint attributes, string? name);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetInformationJobObject(
nint hJob,
int jobObjectInfoClass,
nint lpJobObjectInfo,
uint cbJobObjectInfoLength);
private static extern bool SetInformationJobObject(nint job, int infoClass, nint info, uint size);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AssignProcessToJobObject(nint hJob, nint hProcess);
private static extern bool AssignProcessToJobObject(nint job, nint process);
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateProcessW(
string applicationName,
StringBuilder commandLine,
nint processAttributes,
nint threadAttributes,
[MarshalAs(UnmanagedType.Bool)] bool inheritHandles,
uint creationFlags,
nint environment,
string currentDirectory,
ref STARTUPINFOEX startupInfo,
out PROCESS_INFORMATION processInformation);
private static extern bool CreateProcessW(string applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(nint handle, uint milliseconds);
+613
View File
@@ -0,0 +1,613 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Avalonia;
using Avalonia.Controls;
using Avalonia.Platform;
using Avalonia.Threading;
using SharpEmu.Libs.VideoOut;
using System.Runtime.InteropServices;
namespace SharpEmu.GUI;
/// <summary>
/// Native child surface owned by Avalonia. The isolated emulator process uses
/// its platform handle to create the Vulkan presentation surface, keeping the
/// guest address space out of the GUI process.
/// </summary>
public sealed class GameSurfaceHost : NativeControlHost
{
private const uint SwpNoSize = 0x0001;
private const uint SwpNoMove = 0x0002;
private const uint SwpNoZOrder = 0x0004;
private const uint SwpNoActivate = 0x0010;
private const uint SwpShowWindow = 0x0040;
private const uint SwpHideWindow = 0x0080;
private const uint WsChild = 0x40000000;
private const uint WsVisible = 0x10000000;
private const uint WsClipSiblings = 0x04000000;
private const uint WsClipChildren = 0x02000000;
private const uint CsOwnDc = 0x0020;
private const uint WmSetCursor = 0x0020;
private const uint WmMouseMove = 0x0200;
private const int IdcArrow = 32512;
private const int CursorHideDelayMs = 2500;
private VulkanHostSurface? _surface;
private nint _windowHandle;
private nint _x11Display;
private string? _win32ClassName;
private WindowProcedure? _windowProcedure;
private nint _metalLayer;
private bool _presentationVisible = true;
private DispatcherTimer? _cursorIdleTimer;
private bool _cursorAutoHide;
private bool _cursorHidden;
private long _lastPointerActivity;
public GameSurfaceHost()
{
PropertyChanged += (_, change) =>
{
if (change.Property == BoundsProperty)
{
UpdateSurfaceSize();
}
};
LayoutUpdated += (_, _) =>
{
// Fullscreen can change a monitor's DPI scale without changing
// the logical Bounds. Refresh the native child from physical size.
UpdateSurfaceSize();
// NativeControlHost may make its HWND visible again as part of a
// later arrange pass. Keep a loading surface hidden until its
// child process reports a real first frame.
if (!_presentationVisible)
{
ApplyPresentationVisibility();
}
};
}
public event EventHandler<VulkanHostSurface>? SurfaceAvailable;
public event EventHandler<VulkanHostSurface>? SurfaceDestroyed;
public VulkanHostSurface? Surface => _surface;
public void RefreshSurfaceSize() => UpdateSurfaceSize();
/// <summary>
/// Hides the platform child without detaching the Vulkan surface. This
/// allows the launcher to return to its library while guest teardown is
/// still finishing on the render thread.
/// </summary>
public void SetPresentationVisible(bool visible)
{
_presentationVisible = visible;
ApplyPresentationVisibility();
}
/// <summary>
/// Auto-hides the mouse cursor over the game surface after a short idle
/// period; any pointer movement brings it back. Enabling (again) restarts
/// the idle countdown, so both "first frame presented" and "entered
/// fullscreen" can arm it. Windows-only; a no-op elsewhere.
/// </summary>
public void SetCursorAutoHide(bool enabled)
{
if (!OperatingSystem.IsWindows())
{
return;
}
_cursorAutoHide = enabled;
_lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp();
if (enabled)
{
_cursorIdleTimer ??= CreateCursorIdleTimer();
_cursorIdleTimer.Start();
return;
}
_cursorIdleTimer?.Stop();
ShowCursorNow();
}
private DispatcherTimer CreateCursorIdleTimer()
{
var timer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(250),
};
timer.Tick += (_, _) => HideCursorWhenIdle();
return timer;
}
private void HideCursorWhenIdle()
{
if (!_cursorAutoHide || _cursorHidden || _windowHandle == 0)
{
return;
}
var idleMs = (System.Diagnostics.Stopwatch.GetTimestamp() - _lastPointerActivity) *
1000 / System.Diagnostics.Stopwatch.Frequency;
if (idleMs < CursorHideDelayMs)
{
return;
}
// Only swallow the cursor while it is actually over the game surface;
// hovering launcher chrome (console, toolbar) must keep the arrow.
if (!GetCursorPos(out var point) || WindowFromPoint(point) != _windowHandle)
{
return;
}
_cursorHidden = true;
_ = SetCursor(0);
}
private void ShowCursorNow()
{
if (!_cursorHidden)
{
return;
}
_cursorHidden = false;
_ = SetCursor(LoadCursorW(0, IdcArrow));
}
private void ApplyPresentationVisibility()
{
if (_windowHandle == 0)
{
return;
}
var visible = _presentationVisible;
if (OperatingSystem.IsWindows())
{
// SW_HIDE can be ignored for a window's initial show state. Force
// the state through SetWindowPos so an old child swapchain cannot
// remain composed while the next game is loading.
var flags = SwpNoSize | SwpNoMove | SwpNoZOrder | SwpNoActivate |
(visible ? SwpShowWindow : SwpHideWindow);
_ = SetWindowPos(_windowHandle, 0, 0, 0, 0, 0, flags);
}
else if (OperatingSystem.IsLinux() && _x11Display != 0)
{
_ = visible
? XMapWindow(_x11Display, _windowHandle)
: XUnmapWindow(_x11Display, _windowHandle);
_ = XFlush(_x11Display);
}
else if (OperatingSystem.IsMacOS())
{
SendBool(_windowHandle, "setHidden:", !visible);
}
}
protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle control)
{
PlatformHandle handle;
if (OperatingSystem.IsWindows())
{
handle = CreateWin32(control);
}
else if (OperatingSystem.IsLinux())
{
handle = CreateX11(control);
}
else if (OperatingSystem.IsMacOS())
{
handle = CreateMacOS();
}
else
{
throw new PlatformNotSupportedException("SharpEmu's embedded Vulkan surface is unsupported on this platform.");
}
UpdateSurfaceSize();
if (_surface is { } surface)
{
SurfaceAvailable?.Invoke(this, surface);
}
return handle;
}
protected override void DestroyNativeControlCore(IPlatformHandle control)
{
if (OperatingSystem.IsWindows())
{
SetCursorAutoHide(false);
}
var surface = _surface;
_surface = null;
if (OperatingSystem.IsWindows())
{
DestroyWin32();
}
else if (OperatingSystem.IsLinux())
{
DestroyX11();
}
else if (OperatingSystem.IsMacOS())
{
DestroyMacOS();
}
if (surface is not null)
{
SurfaceDestroyed?.Invoke(this, surface);
}
}
private PlatformHandle CreateWin32(IPlatformHandle control)
{
_win32ClassName = $"SharpEmuGameSurface-{Guid.NewGuid():N}";
_windowProcedure = WindowProcedureImpl;
var classInfo = new WndClassEx
{
Size = (uint)Marshal.SizeOf<WndClassEx>(),
Style = CsOwnDc,
WindowProcedure = Marshal.GetFunctionPointerForDelegate(_windowProcedure),
Instance = GetModuleHandleW(null),
ClassName = _win32ClassName,
};
if (RegisterClassExW(ref classInfo) == 0)
{
throw new InvalidOperationException($"Could not register the embedded game window class (Win32 error {Marshal.GetLastWin32Error()}).");
}
_windowHandle = CreateWindowExW(
0,
_win32ClassName,
"SharpEmu Game Surface",
WsChild | (_presentationVisible ? WsVisible : 0) | WsClipSiblings | WsClipChildren,
0,
0,
1,
1,
control.Handle,
0,
classInfo.Instance,
0);
if (_windowHandle == 0)
{
var error = Marshal.GetLastWin32Error();
_ = UnregisterClassW(_win32ClassName, classInfo.Instance);
throw new InvalidOperationException($"Could not create the embedded game window (Win32 error {error}).");
}
_surface = new VulkanHostSurface(
VulkanHostSurfaceKind.Win32,
_windowHandle,
classInfo.Instance);
return new PlatformHandle(_windowHandle, "HWND");
}
private PlatformHandle CreateX11(IPlatformHandle control)
{
_x11Display = XOpenDisplay(0);
if (_x11Display == 0)
{
throw new InvalidOperationException("Could not connect to the X11 server for the embedded game surface.");
}
_windowHandle = XCreateSimpleWindow(
_x11Display,
control.Handle,
0,
0,
1,
1,
0,
0,
0);
if (_windowHandle == 0)
{
XCloseDisplay(_x11Display);
_x11Display = 0;
throw new InvalidOperationException("Could not create the X11 embedded game surface.");
}
if (_presentationVisible)
{
_ = XMapWindow(_x11Display, _windowHandle);
}
_ = XFlush(_x11Display);
_surface = new VulkanHostSurface(VulkanHostSurfaceKind.Xlib, _windowHandle, _x11Display);
return new PlatformHandle(_windowHandle, "X11");
}
private PlatformHandle CreateMacOS()
{
_metalLayer = CreateObjectiveCObject("CAMetalLayer");
_windowHandle = CreateObjectiveCObject("NSView");
SendBool(_windowHandle, "setWantsLayer:", true);
SendPointer(_windowHandle, "setLayer:", _metalLayer);
SendBool(_windowHandle, "setHidden:", !_presentationVisible);
_surface = new VulkanHostSurface(VulkanHostSurfaceKind.Metal, _windowHandle, metalLayerHandle: _metalLayer);
return new PlatformHandle(_windowHandle, "NSView");
}
private void UpdateSurfaceSize()
{
if (_surface is null)
{
return;
}
var renderScale = (VisualRoot as TopLevel)?.RenderScaling ?? 1.0;
var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale));
var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale));
var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height;
_surface.UpdatePixelSize(width, height);
if (!sizeChanged)
{
return;
}
if (OperatingSystem.IsWindows() && _windowHandle != 0)
{
_ = SetWindowPos(
_windowHandle,
0,
0,
0,
width,
height,
SwpNoMove | SwpNoZOrder | SwpNoActivate);
}
else if (OperatingSystem.IsLinux() && _x11Display != 0 && _windowHandle != 0)
{
_ = XResizeWindow(_x11Display, _windowHandle, (uint)width, (uint)height);
_ = XFlush(_x11Display);
}
else if (OperatingSystem.IsMacOS() && _metalLayer != 0)
{
SendDouble(_metalLayer, "setContentsScale:", renderScale);
}
}
private void DestroyWin32()
{
if (_windowHandle != 0)
{
_ = DestroyWindow(_windowHandle);
_windowHandle = 0;
}
if (!string.IsNullOrWhiteSpace(_win32ClassName))
{
_ = UnregisterClassW(_win32ClassName, GetModuleHandleW(null));
_win32ClassName = null;
}
_windowProcedure = null;
}
private void DestroyX11()
{
if (_x11Display != 0 && _windowHandle != 0)
{
_ = XDestroyWindow(_x11Display, _windowHandle);
}
if (_x11Display != 0)
{
_ = XCloseDisplay(_x11Display);
}
_windowHandle = 0;
_x11Display = 0;
}
private void DestroyMacOS()
{
if (_windowHandle != 0)
{
SendVoid(_windowHandle, "release");
}
if (_metalLayer != 0)
{
SendVoid(_metalLayer, "release");
}
_windowHandle = 0;
_metalLayer = 0;
}
private nint WindowProcedureImpl(nint window, uint message, nint wParam, nint lParam)
{
if (message == WmMouseMove)
{
_lastPointerActivity = System.Diagnostics.Stopwatch.GetTimestamp();
ShowCursorNow();
}
else if (message == WmSetCursor && _cursorHidden)
{
// Win32 re-resolves the cursor on every mouse message; returning
// TRUE here keeps the parent chain from restoring the arrow.
_ = SetCursor(0);
return 1;
}
return DefWindowProcW(window, message, wParam, lParam);
}
private static nint CreateObjectiveCObject(string className)
{
var classHandle = objc_getClass(className);
if (classHandle == 0)
{
throw new InvalidOperationException($"Objective-C class '{className}' is unavailable.");
}
var instance = objc_msgSend_id(classHandle, sel_registerName("alloc"));
instance = objc_msgSend_id(instance, sel_registerName("init"));
if (instance == 0)
{
throw new InvalidOperationException($"Could not create Objective-C '{className}'.");
}
return instance;
}
private static void SendVoid(nint receiver, string selector) =>
objc_msgSend_void(receiver, sel_registerName(selector));
private static void SendBool(nint receiver, string selector, bool value) =>
objc_msgSend_bool(receiver, sel_registerName(selector), value ? (byte)1 : (byte)0);
private static void SendPointer(nint receiver, string selector, nint value) =>
objc_msgSend_pointer(receiver, sel_registerName(selector), value);
private static void SendDouble(nint receiver, string selector, double value) =>
objc_msgSend_double(receiver, sel_registerName(selector), value);
private delegate nint WindowProcedure(nint window, uint message, nint wParam, nint lParam);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct WndClassEx
{
public uint Size;
public uint Style;
public nint WindowProcedure;
public int ClassExtra;
public int WindowExtra;
public nint Instance;
public nint Icon;
public nint Cursor;
public nint Background;
public string? MenuName;
public string? ClassName;
public nint IconSmall;
}
[DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", CharSet = CharSet.Unicode)]
private static extern nint GetModuleHandleW(string? moduleName);
[DllImport("user32.dll", EntryPoint = "RegisterClassExW", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern ushort RegisterClassExW(ref WndClassEx classInfo);
[DllImport("user32.dll", EntryPoint = "UnregisterClassW", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UnregisterClassW(string className, nint instance);
[DllImport("user32.dll", EntryPoint = "CreateWindowExW", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern nint CreateWindowExW(
uint extendedStyle,
string className,
string windowName,
uint style,
int x,
int y,
int width,
int height,
nint parent,
nint menu,
nint instance,
nint parameter);
[DllImport("user32.dll", EntryPoint = "DestroyWindow", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyWindow(nint window);
[DllImport("user32.dll", EntryPoint = "SetWindowPos", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetWindowPos(
nint window,
nint insertAfter,
int x,
int y,
int width,
int height,
uint flags);
[DllImport("user32.dll", EntryPoint = "DefWindowProcW", CharSet = CharSet.Unicode)]
private static extern nint DefWindowProcW(nint window, uint message, nint wParam, nint lParam);
[DllImport("user32.dll", EntryPoint = "SetCursor")]
private static extern nint SetCursor(nint cursor);
[DllImport("user32.dll", EntryPoint = "LoadCursorW", CharSet = CharSet.Unicode)]
private static extern nint LoadCursorW(nint instance, nint cursorName);
[DllImport("user32.dll", EntryPoint = "GetCursorPos")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetCursorPos(out NativePoint point);
[DllImport("user32.dll", EntryPoint = "WindowFromPoint")]
private static extern nint WindowFromPoint(NativePoint point);
[StructLayout(LayoutKind.Sequential)]
private struct NativePoint
{
public int X;
public int Y;
}
[DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
private static extern nint XOpenDisplay(nint displayName);
[DllImport("libX11.so.6", EntryPoint = "XCreateSimpleWindow")]
private static extern nint XCreateSimpleWindow(
nint display,
nint parent,
int x,
int y,
uint width,
uint height,
uint borderWidth,
ulong border,
ulong background);
[DllImport("libX11.so.6", EntryPoint = "XMapWindow")]
private static extern int XMapWindow(nint display, nint window);
[DllImport("libX11.so.6", EntryPoint = "XUnmapWindow")]
private static extern int XUnmapWindow(nint display, nint window);
[DllImport("libX11.so.6", EntryPoint = "XResizeWindow")]
private static extern int XResizeWindow(nint display, nint window, uint width, uint height);
[DllImport("libX11.so.6", EntryPoint = "XDestroyWindow")]
private static extern int XDestroyWindow(nint display, nint window);
[DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
private static extern int XCloseDisplay(nint display);
[DllImport("libX11.so.6", EntryPoint = "XFlush")]
private static extern int XFlush(nint display);
[DllImport("/usr/lib/libobjc.A.dylib")]
private static extern nint objc_getClass(string name);
[DllImport("/usr/lib/libobjc.A.dylib")]
private static extern nint sel_registerName(string name);
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
private static extern nint objc_msgSend_id(nint receiver, nint selector);
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
private static extern void objc_msgSend_void(nint receiver, nint selector);
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
private static extern void objc_msgSend_bool(nint receiver, nint selector, byte value);
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
private static extern void objc_msgSend_pointer(nint receiver, nint selector, nint value);
[DllImport("/usr/lib/libobjc.A.dylib", EntryPoint = "objc_msgSend")]
private static extern void objc_msgSend_double(nint receiver, nint selector, double value);
}
+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();
}
}
}
+2
View File
@@ -48,6 +48,8 @@ public sealed class GuiSettings
/// <summary>Publish launcher/game status to Discord Rich Presence.</summary>
public bool DiscordRichPresence { get; set; } = true;
public bool CheckForUpdatesOnStartup { get; set; } = true;
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
public List<string> EnvironmentToggles { get; set; } = new();
+20 -1
View File
@@ -31,9 +31,11 @@
"Options.Env.Desc": "Switches passed to the emulator as environment variables at launch.",
"Options.Env.Bthid.Desc": "Report Bluetooth HID as unavailable for titles whose wheel/FFB middleware polls forever.\nLeave off normally. Some titles freeze when init fails.",
"Options.Env.LoopGuard.Desc": "Do not force quit titles that repeat the same call for too long.\nTry this when a game exits on its own while loading.",
"Options.Env.WritableApp0.Desc": "Allow titles to create and write files inside their install folder.\nNeeded by unpackaged dumps that write their save or config data under /app0.",
"Options.Env.VkValidation.Desc": "Enable Vulkan validation layers for GPU debugging.\nSlow. Requires the Vulkan SDK to be installed.",
"Options.Env.DumpSpirv.Desc": "Dump AGC shaders and their SPIR-V translations to the shader-dumps folder.\nUse when reporting shader or rendering bugs.",
"Options.Env.LogDirectMemory.Desc": "Log direct memory allocations and failures to the console.\nUse when a game aborts or exits during boot.",
"Options.Env.LogIo.Desc": "Log file open, read, and path-resolve activity to the console.\nUse when a game cannot find its data files during boot.",
"Options.Env.LogNp.Desc": "Log NP (PlayStation Network) library calls to the console.",
"Options.Section.Emulation": "EMULATION",
"Options.Section.Logging": "LOGGING",
@@ -139,8 +141,25 @@
"Options.About" : "About",
"About.Github.Label": "GitHub",
"About.Github.Desc": "Source code, issues and project development.",
"About.Github.LatestCommitLabel": "Latest Commit",
"About.Github.LatestCommitDescription": "Latest commit on the main branch",
"About.Discord.Label": "Discord",
"About.Discord.Desc": "Join the community, get support and follow development.",
"About.GithubButton": "Contribute in GitHub!",
"About.DiscordButton": "Join our Discord!"
"About.DiscordButton": "Join our Discord!",
"Updater.Auto.Label": "Check for updates on startup",
"Updater.Auto.Desc": "Checks GitHub without delaying startup.",
"Updater.Label": "Updates",
"Updater.Check": "Check for updates",
"Updater.DownloadRestart": "Download and restart",
"Updater.Status.Ready": "Current build: {0}",
"Updater.Status.Checking": "Checking for updates…",
"Updater.Status.Current": "You are up to date ({0}).",
"Updater.Status.Available": "A new build is available: {0}",
"Updater.Status.Downloading": "Downloading update… {0}%",
"Updater.Status.Installing": "Installing update…",
"Updater.Status.Timeout": "Update check timed out after 10 seconds.",
"Updater.Status.Failed": "Could not check for updates.",
"Updater.Status.Unsupported": "Automatic updating requires a Windows, Linux or macOS x64 build."
}
+2
View File
@@ -131,6 +131,8 @@
"About.Github.Label": "GitHub",
"About.Github.Desc": "Código fuente, issues y desarrollo del proyecto.",
"About.Discord.Label": "Discord",
"About.Github.LatestCommitLabel": "Último Commit",
"About.Github.LatestCommitDescription": "Último commit en la rama main",
"About.Discord.Desc": "Únete a la comunidad, recibe soporte y sigue el desarrollo.",
"About.GithubButton": "Contribuye en GitHub!",
"About.DiscordButton": "Únete a nuestro Discord!"
+16 -1
View File
@@ -125,5 +125,20 @@
"Dialog.PsExecutables": "PS çalıştırılabilirleri",
"Dialog.SaveLogFile": "Günlük dosyasının kaydedileceği yeri seçin",
"Dialog.PlainTextFiles": "Düz Metin Dosyaları",
"Dialog.LogFiles": "Günlük Dosyaları"
"Dialog.LogFiles": "Günlük Dosyaları",
"Updater.Auto.Label": "Açılışta güncellemeleri denetle",
"Updater.Auto.Desc": "Açılışı geciktirmeden GitHub'ı denetler.",
"Updater.Label": "Güncellemeler",
"Updater.Check": "Güncellemeleri denetle",
"Updater.DownloadRestart": "İndir ve yeniden başlat",
"Updater.Status.Ready": "Mevcut build: {0}",
"Updater.Status.Checking": "Güncellemeler denetleniyor…",
"Updater.Status.Current": "Güncelsiniz ({0}).",
"Updater.Status.Available": "Yeni build mevcut: {0}",
"Updater.Status.Downloading": "Güncelleme indiriliyor… %{0}",
"Updater.Status.Installing": "Güncelleme kuruluyor…",
"Updater.Status.Timeout": "Güncelleme denetimi 10 saniye sonra zaman aşımına uğradı.",
"Updater.Status.Failed": "Güncellemeler denetlenemedi.",
"Updater.Status.Unsupported": "Otomatik güncelleme Windows, Linux veya macOS x64 build'i gerektirir."
}
+138 -4
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}" />
@@ -315,6 +323,16 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ToggleSwitch Grid.Column="1" x:Name="DiscordToggle" OnContent="On" OffContent="Off"
VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock x:Name="AutoUpdateLabel" Text="Check for updates on startup" FontSize="13" />
<TextBlock x:Name="AutoUpdateDesc" Text="Checks GitHub without delaying startup."
FontSize="11" Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
<ToggleSwitch Grid.Column="1" x:Name="AutoUpdateToggle" OnContent="On" OffContent="Off"
IsChecked="True" VerticalAlignment="Center" />
</Grid>
</StackPanel>
</Border>
<Border Classes="card">
@@ -323,6 +341,40 @@ SPDX-License-Identifier: GPL-2.0-or-later
Classes="sectionTitle"
Text="ABOUT" />
<!--Latest commit info-->
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Image Source="avares://SharpEmu.GUI/Assets/commit-icon.png"
Width="24" Height="24" VerticalAlignment="Center" />
<StackPanel Spacing="2" VerticalAlignment="Center">
<TextBlock x:Name="LatestCommitLabel" Text="Latest commit" FontSize="13"/>
<TextBlock x:Name="LatestCommitDescription" Text="Latest commit on the main branch" FontSize="11" Foreground="{StaticResource MutedBrush}"/>
</StackPanel>
</StackPanel>
<Button Grid.Column="1" x:Name="LatestCommitHashText" Classes="ghost"
Content="Loading…" FontSize="12" FontFamily="Consolas,monospace"
VerticalAlignment="Center" IsEnabled="False" />
</Grid>
<!--Update-->
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Image Source="avares://SharpEmu.GUI/Assets/update-icon.png"
Width="24"
Height="24"
VerticalAlignment="Center" />
<StackPanel Spacing="2" VerticalAlignment="Center">
<TextBlock x:Name="UpdateLabel" Text="Updates" FontSize="13" />
<TextBlock x:Name="UpdateStatusText" Text="Current build: dev" FontSize="11"
Foreground="{StaticResource MutedBrush}" TextWrapping="Wrap" />
</StackPanel>
</StackPanel>
<Button Grid.Column="1" x:Name="UpdateButton" Classes="ghost"
Content="Check for updates" VerticalAlignment="Center" />
</Grid>
<!--Github-->
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0"
@@ -420,6 +472,17 @@ SPDX-License-Identifier: GPL-2.0-or-later
VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="SHARPEMU_WRITABLE_APP0" FontSize="13" FontFamily="Consolas,monospace" />
<TextBlock x:Name="EnvWritableApp0Desc"
Text="Allow titles to create and write files inside their install folder.&#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"
VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="SHARPEMU_VK_VALIDATION" FontSize="13" FontFamily="Consolas,monospace" />
@@ -453,6 +516,17 @@ SPDX-License-Identifier: GPL-2.0-or-later
VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="SHARPEMU_LOG_IO" FontSize="13" FontFamily="Consolas,monospace" />
<TextBlock x:Name="EnvLogIoDesc"
Text="Log file open, read, and path-resolve activity to the console.&#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"
VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="*,Auto">
<StackPanel VerticalAlignment="Center" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="SHARPEMU_LOG_NP" FontSize="13" FontFamily="Consolas,monospace" />
@@ -502,7 +576,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Border>
<!-- Launch bar; capped so it does not sprawl on a maximized window. -->
<Border Grid.Row="3" Classes="card" Margin="0,12,0,0" Padding="14" MaxWidth="1280">
<Border Grid.Row="3" x:Name="LaunchBar" Classes="card" Margin="0,12,0,0" Padding="14" MaxWidth="1280">
<StackPanel Spacing="14">
<Grid ColumnDefinitions="Auto,*,Auto">
@@ -567,6 +641,66 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Border>
</Grid>
<!-- Avalonia's regular overlay layer cannot appear over a native child
HWND/X11/Metal surface. Keep the running-session controls in a native
popup so the game reaches the bottom status bar without losing Stop. -->
<primitives:Popup x:Name="SessionBarPopup"
IsOpen="False"
PlacementTarget="{Binding #GameView}"
Placement="Bottom"
VerticalOffset="-66"
Topmost="True"
ShouldUseOverlayLayer="False"
TakesFocusFromNativeControl="False"
IsLightDismissEnabled="False">
<Border Classes="card" Width="598" Height="58" CornerRadius="16" Padding="14,8">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Spacing="3" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock x:Name="SessionGameTitle" Text="GAME RUNNING" FontSize="13" FontWeight="SemiBold"
MaxWidth="240" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
<Border Classes="badge running" VerticalAlignment="Center">
<TextBlock Text="RUNNING" FontSize="9" FontWeight="Bold" LetterSpacing="1"
Foreground="{StaticResource SuccessBrush}" />
</Border>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="7">
<Border x:Name="SessionF11Badge" Classes="badge key" VerticalAlignment="Center">
<TextBlock Text="F11" FontSize="9" FontWeight="Bold"
Foreground="{StaticResource InfoBrush}" />
</Border>
<TextBlock x:Name="SessionHintText" Text="Fullscreen" FontSize="11"
Foreground="{StaticResource MutedBrush}" VerticalAlignment="Center" />
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button x:Name="SessionConsoleButton" Classes="ghost" Content="≡ Console" />
<Button x:Name="SessionStopButton" Classes="danger" Content="■ Stop" IsEnabled="False" />
</StackPanel>
</Grid>
</Border>
</primitives:Popup>
<!-- This is a native popup rather than an Avalonia overlay because the
emulated Vulkan surface is a native child window. -->
<primitives:Popup x:Name="SessionLoadingPopup"
IsOpen="False"
PlacementTarget="{Binding #GameView}"
Placement="Center"
Topmost="True"
ShouldUseOverlayLayer="False"
TakesFocusFromNativeControl="False"
IsLightDismissEnabled="False">
<Border Classes="card" Width="380" CornerRadius="18" Padding="22,18">
<StackPanel Spacing="12">
<TextBlock x:Name="SessionLoadingTitle" Text="Loading game" FontSize="16" FontWeight="SemiBold" />
<TextBlock x:Name="SessionLoadingDetail" Text="Preparing the emulation session..." FontSize="12"
Foreground="{StaticResource MutedBrush}" TextTrimming="CharacterEllipsis" />
<ProgressBar IsIndeterminate="True" Height="5" />
</StackPanel>
</Border>
</primitives:Popup>
<!-- Status bar -->
<Grid x:Name="StatusBar" Grid.Row="2" Height="32" Background="{StaticResource ChromeBrush}"
ColumnDefinitions="*,Auto">
File diff suppressed because it is too large Load Diff
+6 -14
View File
@@ -17,6 +17,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<!-- Dependency-free; provides the BuildInfo provenance shown in the
title bar. -->
<ItemGroup>
<!-- The GUI owns the native presentation control while each game runs in
an isolated emulator process. -->
<ProjectReference Include="..\SharpEmu.Core\SharpEmu.Core.csproj" />
<ProjectReference Include="..\SharpEmu.Libs\SharpEmu.Libs.csproj" />
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
</ItemGroup>
@@ -32,6 +36,8 @@ SPDX-License-Identifier: GPL-2.0-or-later
<AvaloniaResource Include="..\..\assets\images\SharpEmu.ico" Link="Assets/SharpEmu.ico" />
<AvaloniaResource Include="..\..\assets\images\github.png" Link="Assets/github.png" />
<AvaloniaResource Include="..\..\assets\images\discord.png" Link="Assets/discord.png" />
<AvaloniaResource Include="..\..\assets\images\update-icon.png" Link="Assets/update-icon.png" />
<AvaloniaResource Include="..\..\assets\images\commit-icon.png" Link="Assets/commit-icon.png" />
</ItemGroup>
<ItemGroup>
@@ -39,18 +45,4 @@ SPDX-License-Identifier: GPL-2.0-or-later
<LogicalName>Languages.%(Filename)%(Extension)</LogicalName>
</EmbeddedResource>
</ItemGroup>
<!-- The controller readers (DualSense raw HID + Xbox XInput) are shared
with the emulator's host input backend. They are dependency-free, so
they are compiled in directly rather than pulling a reference to all
of SharpEmu.HLE into the launcher. -->
<ItemGroup>
<Compile Include="..\SharpEmu.HLE\Host\HostGamepadState.cs" Link="Input/HostGamepadState.cs" />
<Compile Include="..\SharpEmu.HLE\Host\Windows\WindowsHidNative.cs" Link="Input/WindowsHidNative.cs" />
<Compile Include="..\SharpEmu.HLE\Host\Windows\WindowsDualSenseReader.cs" Link="Input/WindowsDualSenseReader.cs" />
<Compile Include="..\SharpEmu.HLE\Host\Windows\WindowsXInputReader.cs" Link="Input/WindowsXInputReader.cs" />
</ItemGroup>
</Project>
+253
View File
@@ -0,0 +1,253 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
using System.Formats.Tar;
using System.IO.Compression;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using System.Text.Json;
namespace SharpEmu.GUI;
/// <summary>Self-contained Windows updater; the emulator layers do not depend on it.</summary>
public static class Updater
{
private const string ApplyArgument = "--sharpemu-apply-update";
private const string LatestReleaseUrl = "https://api.github.com/repos/sharpemu/sharpemu/releases/latest";
private static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(10);
private static readonly HttpClient Http = CreateHttpClient();
public sealed record UpdateInfo(string Sha, string Name, string DownloadUrl, long Size);
public static async Task<UpdateInfo?> CheckAsync(string? currentSha, CancellationToken cancellationToken = default)
{
var platform = CurrentPlatform();
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(CheckTimeout);
using var response = await Http.GetAsync(LatestReleaseUrl, timeout.Token);
response.EnsureSuccessStatusCode();
return ParseRelease(
await response.Content.ReadAsStringAsync(timeout.Token),
currentSha,
platform.Rid,
platform.Extension);
}
public static async Task DownloadAndRestartAsync(
UpdateInfo update,
IProgress<int>? progress = null,
CancellationToken cancellationToken = default)
{
var root = Path.Combine(Path.GetTempPath(), "SharpEmu.Update");
var payload = Path.Combine(root, "payload");
if (Directory.Exists(root))
{
Directory.Delete(root, recursive: true);
}
Directory.CreateDirectory(root);
var archive = Path.Combine(root, update.Name);
using (var response = await Http.GetAsync(update.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
{
response.EnsureSuccessStatusCode();
await using var input = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var output = File.Create(archive);
var buffer = new byte[81920];
long written = 0;
int read;
while ((read = await input.ReadAsync(buffer, cancellationToken)) > 0)
{
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
written += read;
progress?.Report(update.Size == 0 ? 0 : (int)(written * 100 / update.Size));
}
if (written != update.Size)
{
throw new InvalidDataException($"Downloaded {written} bytes; expected {update.Size}.");
}
}
var platform = CurrentPlatform();
var stagedExe = ExtractArchive(archive, payload, platform.Extension, platform.ExecutableName);
var start = new ProcessStartInfo(stagedExe)
{
UseShellExecute = false,
WorkingDirectory = payload,
};
start.ArgumentList.Add(ApplyArgument);
start.ArgumentList.Add(Environment.ProcessId.ToString());
start.ArgumentList.Add(AppContext.BaseDirectory);
using var helper = Process.Start(start)
?? throw new InvalidOperationException("The update installer could not be started.");
}
/// <summary>Runs from the downloaded executable after the old GUI exits.</summary>
public static bool TryApply(string[] args, out int exitCode)
{
exitCode = 0;
if (args.Length != 3 || args[0] != ApplyArgument)
{
return false;
}
try
{
if (int.TryParse(args[1], out var oldPid))
{
try
{
if (!Process.GetProcessById(oldPid).WaitForExit(30_000))
{
throw new TimeoutException("SharpEmu did not close within 30 seconds.");
}
}
catch (ArgumentException)
{
// The old process has already exited.
}
}
var source = AppContext.BaseDirectory;
var target = Path.GetFullPath(args[2]);
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(source, file);
if (relative.Equals("gui-settings.json", StringComparison.OrdinalIgnoreCase) ||
relative.StartsWith("user" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
relative.StartsWith("logs" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
relative.StartsWith("Languages" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var destination = Path.Combine(target, relative);
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
File.Copy(file, destination, overwrite: true);
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(destination, File.GetUnixFileMode(file));
}
}
using var restarted = Process.Start(new ProcessStartInfo(
Path.Combine(target, CurrentPlatform().ExecutableName))
{
UseShellExecute = false,
WorkingDirectory = target,
}) ?? throw new InvalidOperationException("The updated SharpEmu could not be started.");
}
catch (Exception ex)
{
exitCode = 1;
try
{
File.WriteAllText(Path.Combine(args[2], "update-error.log"), ex.ToString());
}
catch
{
// Best-effort diagnostics only.
}
}
return true;
}
private static UpdateInfo? ParseRelease(
string json,
string? currentSha,
string rid,
string extension)
{
using var document = JsonDocument.Parse(json);
var candidates = new List<(DateTimeOffset Created, UpdateInfo Update)>();
foreach (var asset in document.RootElement.GetProperty("assets").EnumerateArray())
{
var name = asset.GetProperty("name").GetString() ?? "";
var marker = $"-{rid}-";
var markerIndex = name.LastIndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (!name.EndsWith(extension, StringComparison.OrdinalIgnoreCase) ||
markerIndex < 0)
{
continue;
}
var sha = name[(markerIndex + marker.Length)..^extension.Length];
if (sha.Length < 7 || !sha.All(Uri.IsHexDigit))
{
continue;
}
candidates.Add((
asset.GetProperty("created_at").GetDateTimeOffset(),
new UpdateInfo(
sha,
name,
asset.GetProperty("browser_download_url").GetString()!,
asset.GetProperty("size").GetInt64())));
}
var latest = candidates.OrderByDescending(candidate => candidate.Created).FirstOrDefault().Update;
return latest is null || string.Equals(latest.Sha, currentSha, StringComparison.OrdinalIgnoreCase)
? null
: latest;
}
private static string ExtractArchive(
string archive,
string payload,
string extension,
string executableName)
{
if (extension == ".zip")
{
ZipFile.ExtractToDirectory(archive, payload);
}
else
{
Directory.CreateDirectory(payload);
using var compressed = File.OpenRead(archive);
using var gzip = new GZipStream(compressed, CompressionMode.Decompress);
TarFile.ExtractToDirectory(gzip, payload, overwriteFiles: false);
}
var executable = Path.Combine(payload, executableName);
if (!File.Exists(executable))
{
throw new InvalidDataException($"The update archive does not contain {executableName}.");
}
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(executable, File.GetUnixFileMode(executable) | UnixFileMode.UserExecute);
}
return executable;
}
private static HttpClient CreateHttpClient()
{
var client = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("SharpEmu", "0.0.1"));
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
return client;
}
private static PlatformInfo CurrentPlatform()
{
if (RuntimeInformation.ProcessArchitecture != Architecture.X64)
{
throw new PlatformNotSupportedException("SharpEmu releases require an x64 process.");
}
if (OperatingSystem.IsWindows()) return new("win-x64", ".zip", "SharpEmu.exe");
if (OperatingSystem.IsLinux()) return new("linux-x64", ".tar.gz", "SharpEmu");
if (OperatingSystem.IsMacOS()) return new("osx-x64", ".tar.gz", "SharpEmu");
throw new PlatformNotSupportedException();
}
private sealed record PlatformInfo(string Rid, string Extension, string ExecutableName);
}
+109 -2
View File
@@ -69,11 +69,118 @@ public sealed class PosixHostInput : IHostInput
{
// GLFW only delivers key events to the focused window, so a
// delivering keyboard implies focus.
return _source?.HasKeyboardFocus ?? false;
return _source?.HasKeyboardFocus ?? IsEmbeddedX11WindowFocused();
}
public bool IsKeyDown(int virtualKey)
{
return _source?.IsKeyDown(virtualKey) ?? false;
var source = _source;
if (source is not null)
{
return source.IsKeyDown(virtualKey);
}
return IsEmbeddedX11WindowFocused() && IsEmbeddedX11KeyDown(virtualKey);
}
private static bool IsEmbeddedX11WindowFocused()
{
if (!OperatingSystem.IsLinux())
{
return false;
}
var display = HostSessionControl.EmbeddedHostDisplay;
var window = HostSessionControl.EmbeddedHostWindow;
if (display == 0 || window == 0 || XGetInputFocus(display, out var focusedWindow, out _) == 0 || focusedWindow == 0)
{
return false;
}
return GetTopLevelWindow(display, focusedWindow) == GetTopLevelWindow(display, window);
}
private static bool IsEmbeddedX11KeyDown(int virtualKey)
{
var display = HostSessionControl.EmbeddedHostDisplay;
var keysym = ToX11Keysym(virtualKey);
if (display == 0 || keysym == 0)
{
return false;
}
var keycode = XKeysymToKeycode(display, keysym);
if (keycode == 0)
{
return false;
}
var keymap = new byte[32];
XQueryKeymap(display, keymap);
return (keymap[keycode >> 3] & (1 << (keycode & 7))) != 0;
}
private static nint GetTopLevelWindow(nint display, nint window)
{
var current = window;
for (var depth = 0; depth < 16; depth++)
{
if (XQueryTree(display, current, out var root, out var parent, out var children, out _) == 0)
{
return 0;
}
if (children != 0)
{
XFree(children);
}
if (parent == 0 || parent == root)
{
return current;
}
current = parent;
}
return 0;
}
private static nuint ToX11Keysym(int virtualKey)
{
return virtualKey switch
{
0x08 => 0xFF08, // Backspace
0x09 => 0xFF09, // Tab
0x0D => 0xFF0D, // Return
0x1B => 0xFF1B, // Escape
0x25 => 0xFF51, // Left
0x26 => 0xFF52, // Up
0x27 => 0xFF53, // Right
0x28 => 0xFF54, // Down
>= 0x41 and <= 0x5A => (nuint)virtualKey,
_ => 0,
};
}
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern int XGetInputFocus(nint display, out nint focus, out int revertTo);
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern int XQueryKeymap(nint display, [System.Runtime.InteropServices.Out] byte[] keysReturn);
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern byte XKeysymToKeycode(nint display, nuint keysym);
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern int XQueryTree(
nint display,
nint window,
out nint root,
out nint parent,
out nint children,
out uint childCount);
[System.Runtime.InteropServices.DllImport("libX11.so.6")]
private static extern int XFree(nint data);
}
+73 -13
View File
@@ -163,13 +163,18 @@ internal sealed unsafe class PosixHostMemory : IHostMemory
private const int PROT_EXEC = 0x4;
private const int MAP_PRIVATE = 0x02;
private const int MAP_FIXED = 0x10;
private static readonly int MAP_ANON = OperatingSystem.IsMacOS() ? 0x1000 : 0x20;
private static readonly int MAP_NORESERVE = OperatingSystem.IsMacOS() ? 0 : 0x4000;
// Linux-only: fail instead of clobbering an existing mapping.
private const int MAP_FIXED_NOREPLACE = 0x100000;
private const int KERN_SUCCESS = 0;
// On Darwin fixed placement is represented by the absence of
// VM_FLAGS_ANYWHERE. Do not add VM_FLAGS_OVERWRITE: overlap must fail.
private const int VM_FLAGS_FIXED = 0;
private static readonly nint MAP_FAILED = -1;
private static readonly object Gate = new();
@@ -181,6 +186,7 @@ internal sealed unsafe class PosixHostMemory : IHostMemory
public ulong Size;
public uint DefaultProtect;
public Dictionary<ulong, uint>? PageProtects;
public bool UsesMachAllocation;
public ulong End => Base + Size;
@@ -251,6 +257,7 @@ internal sealed unsafe class PosixHostMemory : IHostMemory
}
nint result;
var usesMachAllocation = false;
if (address != null)
{
// Win32 maps at exactly the requested address or fails
@@ -280,15 +287,17 @@ internal sealed unsafe class PosixHostMemory : IHostMemory
if (result == MAP_FAILED && OperatingSystem.IsMacOS())
{
// The hint-only attempt above didn't land at the requested
// address. This is routinely the case for the PS5's fixed
// 32 GiB image base under Rosetta 2 on Apple Silicon, where
// the kernel never honors that hint. Retry with true
// MAP_FIXED: this can clobber an untracked host mapping
// (dyld, the runtime's JIT heap, Rosetta) if one already
// sits exactly there, but without it guest images that
// require this base never load at all on macOS.
Trace($"exact mmap hint failed, retrying with MAP_FIXED: addr=0x{(ulong)address:X16}");
result = mmap((nint)address, (nuint)alignedSize, posixProtect, flags | MAP_FIXED, -1, 0);
// address. mach_vm_allocate with fixed placement and no
// overwrite flag atomically maps the requested range or
// fails if any host mapping already owns it. Unlike
// MAP_FIXED, it cannot clobber CLR, dyld, JIT, or Rosetta
// memory that is absent from our shadow table.
Trace($"exact mmap hint failed, retrying with fixed Mach allocation: addr=0x{(ulong)address:X16}");
result = AllocateDarwinFixed(
(nint)address,
alignedSize,
posixProtect);
usesMachAllocation = result != MAP_FAILED;
}
if (result == MAP_FAILED || (ulong)result != (ulong)address)
@@ -316,7 +325,8 @@ internal sealed unsafe class PosixHostMemory : IHostMemory
{
Base = (ulong)result,
Size = alignedSize,
DefaultProtect = protect
DefaultProtect = protect,
UsesMachAllocation = usesMachAllocation,
};
return (void*)result;
@@ -335,8 +345,15 @@ internal sealed unsafe class PosixHostMemory : IHostMemory
return false;
}
Regions.Remove((ulong)address);
return munmap((nint)address, (nuint)region.Size) == 0;
var released = region.UsesMachAllocation
? mach_vm_deallocate(mach_task_self(), (ulong)address, region.Size) == KERN_SUCCESS
: munmap((nint)address, (nuint)region.Size) == 0;
if (released)
{
Regions.Remove((ulong)address);
}
return released;
}
}
@@ -504,6 +521,40 @@ internal sealed unsafe class PosixHostMemory : IHostMemory
};
}
private static nint AllocateDarwinFixed(nint requestedAddress, ulong size, int posixProtect)
{
var address = (ulong)requestedAddress;
var result = mach_vm_allocate(
mach_task_self(),
ref address,
size,
VM_FLAGS_FIXED);
if (result != KERN_SUCCESS || address != (ulong)requestedAddress)
{
if (result == KERN_SUCCESS)
{
_ = mach_vm_deallocate(mach_task_self(), address, size);
}
Trace(
$"fixed Mach allocation failed: addr=0x{(ulong)requestedAddress:X16} " +
$"size=0x{size:X} kern_return={result}");
return MAP_FAILED;
}
if (mprotect((nint)address, (nuint)size, posixProtect) == 0)
{
return (nint)address;
}
var error = Marshal.GetLastPInvokeError();
_ = mach_vm_deallocate(mach_task_self(), address, size);
Trace(
$"fixed Mach allocation protection failed: addr=0x{address:X16} " +
$"size=0x{size:X} errno={error}");
return MAP_FAILED;
}
private static void Trace(string message)
{
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_VMEM"), "1", StringComparison.Ordinal))
@@ -524,5 +575,14 @@ internal sealed unsafe class PosixHostMemory : IHostMemory
[DllImport("libc", SetLastError = true)]
private static extern int mprotect(nint addr, nuint length, int prot);
[DllImport("libSystem.B.dylib")]
private static extern uint mach_task_self();
[DllImport("libSystem.B.dylib")]
private static extern int mach_vm_allocate(uint target, ref ulong address, ulong size, int flags);
[DllImport("libSystem.B.dylib")]
private static extern int mach_vm_deallocate(uint target, ulong address, ulong size);
}
}
@@ -10,7 +10,7 @@ namespace SharpEmu.HLE.Host.Windows;
/// Supports USB (input report 0x01) and Bluetooth (extended report 0x31,
/// activated by requesting feature report 0x05), with hot-plug retry.
/// </summary>
internal static class WindowsDualSenseReader
public static class WindowsDualSenseReader
{
private const ushort SonyVendorId = 0x054C;
private const ushort DualSenseProductId = 0x0CE6;
@@ -35,7 +35,7 @@ internal static class WindowsDualSenseReader
private static byte _playerLeds = 0x04; // center LED = player 1
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
internal static void EnsureStarted()
public static void EnsureStarted()
{
// The GUI source-links this reader and calls it directly, without the
// host-platform resolution that otherwise guarantees Windows.
@@ -61,7 +61,7 @@ internal static class WindowsDualSenseReader
}
}
internal static bool TryGetState(out HostGamepadState state)
public static bool TryGetState(out HostGamepadState state)
{
lock (Gate)
{
@@ -67,7 +67,19 @@ internal sealed partial class WindowsHostInput : IHostInput
}
GetWindowThreadProcessId(foregroundWindow, out var processId);
return processId == (uint)Environment.ProcessId;
if (processId == (uint)Environment.ProcessId)
{
return true;
}
// The GUI runs the emulator in an isolated child process. Its native
// Vulkan surface is a child of the GUI window, so the foreground
// window belongs to the launcher process rather than this one.
var embeddedHostWindow = HostSessionControl.EmbeddedHostWindow;
var hostTopLevelWindow = embeddedHostWindow == 0
? 0
: GetAncestor(embeddedHostWindow, GetAncestorRoot);
return hostTopLevelWindow != 0 && foregroundWindow == hostTopLevelWindow;
}
public bool IsKeyDown(int virtualKey) =>
@@ -81,4 +93,9 @@ internal sealed partial class WindowsHostInput : IHostInput
[LibraryImport("user32.dll")]
private static partial uint GetWindowThreadProcessId(nint hWnd, out uint processId);
[LibraryImport("user32.dll")]
private static partial nint GetAncestor(nint hWnd, uint gaFlags);
private const uint GetAncestorRoot = 2;
}
@@ -11,7 +11,7 @@ namespace SharpEmu.HLE.Host.Windows;
/// <see cref="HostGamepadState"/> conventions. Supports rumble and hot-plug
/// retry; the first connected slot (of four) is used.
/// </summary>
internal static partial class WindowsXInputReader
public static partial class WindowsXInputReader
{
private const uint ErrorSuccess = 0;
private const int SlotCount = 4;
@@ -43,7 +43,7 @@ internal static partial class WindowsXInputReader
private static byte _triggerRight;
/// <summary>Starts the background reader once; safe to call repeatedly.</summary>
internal static void EnsureStarted()
public static void EnsureStarted()
{
// The GUI source-links this reader and calls it directly, without the
// host-platform resolution that otherwise guarantees Windows.
@@ -69,7 +69,7 @@ internal static partial class WindowsXInputReader
}
}
internal static bool TryGetState(out HostGamepadState state)
public static bool TryGetState(out HostGamepadState state)
{
lock (Gate)
{
+72 -1
View File
@@ -10,17 +10,88 @@ namespace SharpEmu.HLE;
public static class HostSessionControl
{
private static Action<string>? _shutdownHandler;
private static string? _pendingShutdownReason;
private static int _shutdownRequested;
private static long _embeddedHostWindow;
private static long _embeddedHostDisplay;
/// <summary>
/// Indicates that the active host session is being stopped. Runtime code
/// uses this to skip expensive post-exit diagnostics before returning the
/// GUI to its library.
/// </summary>
public static bool IsShutdownRequested => Volatile.Read(ref _shutdownRequested) != 0;
/// <summary>
/// Native GUI surface used by an isolated emulator child. Input backends
/// use it to treat the launcher window as the active game window.
/// </summary>
public static nint EmbeddedHostWindow => unchecked((nint)Interlocked.Read(ref _embeddedHostWindow));
/// <summary>X11 Display* paired with <see cref="EmbeddedHostWindow"/> when available.</summary>
public static nint EmbeddedHostDisplay => unchecked((nint)Interlocked.Read(ref _embeddedHostDisplay));
public static void SetEmbeddedHostSurface(nint window, nint display = 0)
{
Interlocked.Exchange(ref _embeddedHostDisplay, unchecked((long)display));
Interlocked.Exchange(ref _embeddedHostWindow, unchecked((long)window));
}
/// <summary>
/// Starts a fresh session after the previous guest has fully left its
/// execution backend.
/// </summary>
public static void ResetShutdownRequest()
{
Interlocked.Exchange(ref _pendingShutdownReason, null);
Volatile.Write(ref _shutdownRequested, 0);
}
public static void SetShutdownHandler(Action<string>? handler)
{
Volatile.Write(ref _shutdownHandler, handler);
if (handler is null)
{
Interlocked.Exchange(ref _pendingShutdownReason, null);
return;
}
var pendingReason = Interlocked.Exchange(ref _pendingShutdownReason, null);
if (pendingReason is not null)
{
Invoke(handler, pendingReason);
}
}
public static void RequestShutdown(string reason)
{
Volatile.Write(ref _shutdownRequested, 1);
var handler = Volatile.Read(ref _shutdownHandler);
if (handler is not null)
{
Invoke(handler, reason);
return;
}
// Stop can be pressed while the GUI session is starting. Retain the
// request until the native backend installs its cooperative handler.
Volatile.Write(ref _pendingShutdownReason, reason);
handler = Volatile.Read(ref _shutdownHandler);
if (handler is not null)
{
var pendingReason = Interlocked.Exchange(ref _pendingShutdownReason, null);
if (pendingReason is not null)
{
Invoke(handler, pendingReason);
}
}
}
private static void Invoke(Action<string> handler, string reason)
{
try
{
Volatile.Read(ref _shutdownHandler)?.Invoke(reason);
handler(reason);
}
catch (Exception exception)
{
+1
View File
@@ -14,6 +14,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ItemGroup>
<InternalsVisibleTo Include="SharpEmu.Core" />
<InternalsVisibleTo Include="SharpEmu.Libs.Tests" />
</ItemGroup>
<ItemGroup>
+171 -96
View File
@@ -121,6 +121,7 @@ public static partial class AgcExports
private const uint CbBlend0Control = 0x1E0;
private const uint PaScModeCntl0 = 0x292;
// GFX10 DB context registers (register byte address minus 0x28000, / 4).
private const uint DbRenderControl = 0x000;
private const uint DbDepthView = 0x002;
private const uint DbDepthSizeXy = 0x007;
private const uint DbDepthClear = 0x00B;
@@ -3728,13 +3729,16 @@ public static partial class AgcExports
_labelProducers.Add(producer);
}
foreach (var waiting in GpuWaitRegistry.SnapshotInRange(memory, address, length))
if (_traceAgc)
{
TraceAgc(
$"agc.wait_producer_scheduled label=0x{waiting.Address:X16} " +
$"waiters={waiting.Count} producer_seq={producer.Sequence} " +
$"queue={producer.QueueName} submission={producer.SubmissionId} " +
$"packet=0x{packetAddress:X16} action='{debugName}'");
foreach (var waiting in GpuWaitRegistry.SnapshotInRange(memory, address, length))
{
TraceAgc(
$"agc.wait_producer_scheduled label=0x{waiting.Address:X16} " +
$"waiters={waiting.Count} producer_seq={producer.Sequence} " +
$"queue={producer.QueueName} submission={producer.SubmissionId} " +
$"packet=0x{packetAddress:X16} action='{debugName}'");
}
}
return producer;
@@ -3752,16 +3756,19 @@ public static partial class AgcExports
producer.Completed = true;
}
foreach (var waiting in GpuWaitRegistry.SnapshotInRange(
producer.Memory,
producer.Address,
producer.Length))
if (_traceAgc)
{
TraceAgc(
$"agc.wait_producer_completed label=0x{waiting.Address:X16} " +
$"waiters={waiting.Count} producer_seq={producer.Sequence} " +
$"queue={producer.QueueName} submission={producer.SubmissionId} " +
$"action='{producer.DebugName}'");
foreach (var waiting in GpuWaitRegistry.SnapshotInRange(
producer.Memory,
producer.Address,
producer.Length))
{
TraceAgc(
$"agc.wait_producer_completed label=0x{waiting.Address:X16} " +
$"waiters={waiting.Count} producer_seq={producer.Sequence} " +
$"queue={producer.QueueName} submission={producer.SubmissionId} " +
$"action='{producer.DebugName}'");
}
}
}
@@ -3806,6 +3813,14 @@ public static partial class AgcExports
}
}
// Producer-backed waits are trace-only. Keep the producer lookup above
// because producerless waits are always warned, but do not build the
// detailed condition strings when AGC tracing is disabled.
if (producer is not null && !_traceAgc)
{
return;
}
var prefix = stale ? "agc.wait_stale" : "agc.wait_suspended";
var current = currentValue.HasValue
? $"0x{currentValue.Value:X16}"
@@ -5291,7 +5306,7 @@ public static partial class AgcExports
var hasDepthOnlyCandidate = hasExportShader &&
!hasPixelShader &&
depthTarget is not null &&
(depthState.TestEnable || depthState.WriteEnable);
(depthState.TestEnable || depthState.WriteEnable || depthState.ClearEnable);
if (hasDepthOnlyCandidate &&
TryCreateTranslatedDepthOnlyGuestDraw(
ctx,
@@ -5936,22 +5951,37 @@ public static partial class AgcExports
// Every bound color target the shader exports to. Deferred renderers
// draw a multi-render-target G-buffer (up to eight slots) in one pass.
// Fall back to slot 0 if we cannot match any export to a bound target.
var pixelColorExportMasks = pixelState.Program.PixelColorExportMasks;
var allBoundTargets = GetRenderTargets(state.CxRegisters);
var renderTargets = allBoundTargets
.Where(target => HasPixelColorExport(pixelState, target.Slot))
.OrderBy(target => target.Slot)
.ToArray();
if (renderTargets.Length == 0)
// At most 8 slots; a manual filter avoids the per-draw LINQ iterator/
// closure allocations. Slots are distinct, so sorting by slot is stable.
var selectedTargets = new List<RenderTargetDescriptor>(allBoundTargets.Count);
foreach (var target in allBoundTargets)
{
renderTargets = allBoundTargets
.Where(target => target.Slot == 0)
.ToArray();
if (GetPixelColorExportMask(pixelColorExportMasks, target.Slot) != 0)
{
selectedTargets.Add(target);
}
}
if (selectedTargets.Count == 0)
{
foreach (var target in allBoundTargets)
{
if (target.Slot == 0)
{
selectedTargets.Add(target);
}
}
}
selectedTargets.Sort(static (left, right) => left.Slot.CompareTo(right.Slot));
var renderTargets = selectedTargets.ToArray();
if (_traceAgcShader && allBoundTargets.Count > 1)
{
TraceAgcShader(
$"agc.mrt_filter ps=0x{pixelShaderAddress:X16} " +
$"bound=[{string.Join(",", allBoundTargets.Select(t => $"s{t.Slot}:0x{t.Address:X}:exp{(HasPixelColorExport(pixelState, t.Slot) ? 1 : 0)}"))}] " +
$"bound=[{string.Join(",", allBoundTargets.Select(t => $"s{t.Slot}:0x{t.Address:X}:exp{(GetPixelColorExportMask(pixelColorExportMasks, t.Slot) != 0 ? 1 : 0)}"))}] " +
$"kept={renderTargets.Length}");
}
@@ -6072,54 +6102,39 @@ public static partial class AgcExports
_graphicsShaderCache.TryAdd(shaderKey, compiled);
}
var imageBindings = pixelEvaluation.ImageBindings
.Concat(exportEvaluation.ImageBindings);
var textures = new List<TranslatedImageBinding>(
pixelEvaluation.ImageBindings.Count +
exportEvaluation.ImageBindings.Count);
foreach (var binding in imageBindings)
if (!TryAppendTranslatedImageBindings(
pixelEvaluation.ImageBindings,
textures,
pixelShaderAddress,
exportShaderAddress,
out error) ||
!TryAppendTranslatedImageBindings(
exportEvaluation.ImageBindings,
textures,
pixelShaderAddress,
exportShaderAddress,
out error))
{
if (!TryDecodeTextureDescriptor(binding.ResourceDescriptor, out var texture))
{
// A garbage/zeroed texture descriptor (from a per-draw descriptor
// setup race — the same root as scalar-load-failed) would drop
// the whole draw, so Demon's Souls' deferred-lighting/composite
// passes that produce the composite's feeder targets never run
// and the frame stays black. Substitute a 1x1 fallback binding so
// the pass still renders (that one sampled texture reads black)
// rather than dropping it entirely. STRICT reverts.
if (_strictShaderDescriptors)
{
error = $"invalid texture descriptor at pc=0x{binding.Pc:X}";
ReturnPooledEvaluationArrays(exportEvaluation);
ReturnPooledEvaluationArrays(pixelEvaluation);
return false;
}
texture = new TextureDescriptor(
0, 1, 1, Gen5TextureFormatR8G8B8A8Unorm, 0, 0, 0, 0, 0, 1, 0xFAC);
}
if (_traceAgcShader || _tracePixelShaderAddress == pixelShaderAddress)
{
Console.Error.WriteLine(
"[LOADER][TRACE] " +
$"agc.texture_binding ps=0x{pixelShaderAddress:X16} es=0x{exportShaderAddress:X16} " +
$"pc=0x{binding.Pc:X} op={binding.Opcode} storage={(Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode) ? 1 : 0)} " +
$"decoded={FormatTextureDescriptor(texture)} " +
$"raw={FormatShaderDwords(binding.ResourceDescriptor)} sampler={FormatShaderDwords(binding.SamplerDescriptor)}");
}
textures.Add(
new TranslatedImageBinding(
texture,
Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode),
binding.MipLevel ?? 0,
binding.SamplerDescriptor));
ReturnPooledEvaluationArrays(exportEvaluation);
ReturnPooledEvaluationArrays(pixelEvaluation);
return false;
}
var globalMemoryBindings = pixelEvaluation.GlobalMemoryBindings
.Concat(exportEvaluation.GlobalMemoryBindings)
.ToArray();
var globalMemoryBindings = new Gen5GlobalMemoryBinding[
pixelEvaluation.GlobalMemoryBindings.Count +
exportEvaluation.GlobalMemoryBindings.Count];
for (var index = 0; index < pixelEvaluation.GlobalMemoryBindings.Count; index++)
{
globalMemoryBindings[index] = pixelEvaluation.GlobalMemoryBindings[index];
}
for (var index = 0; index < exportEvaluation.GlobalMemoryBindings.Count; index++)
{
globalMemoryBindings[pixelEvaluation.GlobalMemoryBindings.Count + index] =
exportEvaluation.GlobalMemoryBindings[index];
}
IReadOnlyList<Gen5VertexInputBinding> vertexInputs =
exportEvaluation.VertexInputs ?? [];
state.UcRegisters.TryGetValue(VgtPrimitiveType, out var primitiveType);
@@ -6134,6 +6149,13 @@ public static partial class AgcExports
renderTargets[index].NumberType);
}
var pixelUserDataCount = Math.Min(pixelEvaluation.InitialScalarRegisters.Count, 8);
var pixelUserData = new uint[pixelUserDataCount];
for (var index = 0; index < pixelUserDataCount; index++)
{
pixelUserData[index] = pixelEvaluation.InitialScalarRegisters[index];
}
draw = new TranslatedGuestDraw(
exportShaderAddress,
pixelShaderAddress,
@@ -6151,11 +6173,11 @@ public static partial class AgcExports
DecodeDepthTarget(state.CxRegisters),
guestTargets,
ApplyTransparentPremultipliedFillClear(
CreateRenderState(state.CxRegisters, renderTargets, pixelState),
CreateRenderState(state.CxRegisters, renderTargets, pixelColorExportMasks),
textures,
vertexInputs,
pixelEvaluation.InitialScalarRegisters),
pixelEvaluation.InitialScalarRegisters.Take(8).ToArray(),
pixelUserData,
state.CxRegisters.TryGetValue(CbBlend0Control, out var rawBlend) ? rawBlend : 0,
state.CxRegisters.TryGetValue(
CbColor0Info + renderTargets.FirstOrDefault().Slot * CbColorRegisterStride,
@@ -6167,6 +6189,55 @@ public static partial class AgcExports
return true;
}
private static bool TryAppendTranslatedImageBindings(
IReadOnlyList<Gen5ImageBinding> bindings,
List<TranslatedImageBinding> textures,
ulong pixelShaderAddress,
ulong exportShaderAddress,
out string error)
{
foreach (var binding in bindings)
{
if (!TryDecodeTextureDescriptor(binding.ResourceDescriptor, out var texture))
{
// A garbage/zeroed texture descriptor (from a per-draw descriptor
// setup race — the same root as scalar-load-failed) would drop
// the whole draw, so deferred-lighting/composite passes that
// produce the composite's feeder targets never run. Keep the
// existing 1x1 fallback unless strict diagnostics are requested.
if (_strictShaderDescriptors)
{
error = $"invalid texture descriptor at pc=0x{binding.Pc:X}";
return false;
}
texture = new TextureDescriptor(
0, 1, 1, Gen5TextureFormatR8G8B8A8Unorm, 0, 0, 0, 0, 0, 1, 0xFAC);
}
var isStorage =
Gen5ShaderTranslator.IsStorageImageOperation(binding.Opcode);
if (_traceAgcShader || _tracePixelShaderAddress == pixelShaderAddress)
{
Console.Error.WriteLine(
"[LOADER][TRACE] " +
$"agc.texture_binding ps=0x{pixelShaderAddress:X16} es=0x{exportShaderAddress:X16} " +
$"pc=0x{binding.Pc:X} op={binding.Opcode} storage={(isStorage ? 1 : 0)} " +
$"decoded={FormatTextureDescriptor(texture)} " +
$"raw={FormatShaderDwords(binding.ResourceDescriptor)} sampler={FormatShaderDwords(binding.SamplerDescriptor)}");
}
textures.Add(
new TranslatedImageBinding(
texture,
isStorage,
binding.MipLevel ?? 0,
binding.SamplerDescriptor));
}
error = string.Empty;
return true;
}
private static int _tracedAstroTitlePixelGlobals;
private static int _tracedAstroTitlePixelGlobalProbe;
@@ -6395,15 +6466,10 @@ public static partial class AgcExports
return true;
}
private static bool HasPixelColorExport(Gen5ShaderState state, uint target) =>
GetPixelColorExportMask(state, target) != 0;
private static uint GetPixelColorExportMask(Gen5ShaderState state, uint target) =>
state.Program.Instructions
.Select(instruction => instruction.Control)
.OfType<Gen5ExportControl>()
.Where(export => export.Target == target)
.Aggregate(0u, (mask, export) => mask | (export.EnableMask & 0xFu));
private static uint GetPixelColorExportMask(uint packedMasks, uint target) =>
target < ColorTargetCount
? (packedMasks >> (int)(target * 4)) & 0xFu
: 0;
private static uint GetInterpolatedAttributeCount(Gen5ShaderState state)
{
@@ -6601,7 +6667,7 @@ public static partial class AgcExports
private static GuestRenderState CreateRenderState(
IReadOnlyDictionary<uint, uint> registers,
IReadOnlyList<RenderTargetDescriptor> targets,
Gen5ShaderState pixelState)
uint pixelColorExportMasks)
{
if (targets.Count == 0)
{
@@ -6610,15 +6676,21 @@ public static partial class AgcExports
var target = targets[0];
var scissor = DecodeScissor(registers, target.Width, target.Height);
return new GuestRenderState(
targets.Select(target =>
var blends = new GuestBlendState[targets.Count];
for (var index = 0; index < targets.Count; index++)
{
var blend = DecodeBlendState(registers, targets[index].Slot);
blends[index] = blend with
{
var blend = DecodeBlendState(registers, target.Slot);
return blend with
{
WriteMask = blend.WriteMask & GetPixelColorExportMask(pixelState, target.Slot),
};
}).ToArray(),
WriteMask = blend.WriteMask &
GetPixelColorExportMask(
pixelColorExportMasks,
targets[index].Slot),
};
}
return new GuestRenderState(
blends,
scissor,
DecodeViewport(registers, target.Width, target.Height, scissor),
DecodeRasterState(registers),
@@ -6627,27 +6699,30 @@ public static partial class AgcExports
// DB_DEPTH_CONTROL (context register 0x200): Z_ENABLE bit1, Z_WRITE_ENABLE
// bit2, ZFUNC bits[6:4] (GCN compare, matches Vulkan CompareOp ordering).
// DB_RENDER_CONTROL (context register 0x000): DEPTH_CLEAR_ENABLE bit0.
private const uint DbDepthControl = 0x200;
private static GuestDepthState DecodeDepthState(
internal static GuestDepthState DecodeDepthState(
IReadOnlyDictionary<uint, uint> registers)
{
if (!registers.TryGetValue(DbDepthControl, out var control))
{
return GuestDepthState.Default;
}
var hasDepthControl = registers.TryGetValue(DbDepthControl, out var control);
registers.TryGetValue(DbRenderControl, out var renderControl);
var testEnable = (control & 0x2u) != 0;
var writeEnable = (control & 0x4u) != 0;
var compareOp = (control >> 4) & 0x7u;
return new GuestDepthState(testEnable, writeEnable, compareOp);
var compareOp = hasDepthControl
? (control >> 4) & 0x7u
: GuestDepthState.Default.CompareOp;
var clearEnable = (renderControl & 0x1u) != 0;
return new GuestDepthState(testEnable, writeEnable, compareOp, clearEnable);
}
private static GuestDepthTarget? DecodeDepthTarget(
IReadOnlyDictionary<uint, uint> registers)
{
var depthState = DecodeDepthState(registers);
if (!depthState.TestEnable && !depthState.WriteEnable)
if (!depthState.TestEnable &&
!depthState.WriteEnable &&
!depthState.ClearEnable)
{
return null;
}
+13 -2
View File
@@ -52,8 +52,19 @@ internal static class AudioPcmConversion
}
var bits = BinaryPrimitives.ReadInt32LittleEndian(sample);
var value = Math.Clamp(BitConverter.Int32BitsToSingle(bits), -1.0f, 1.0f);
return checked((short)MathF.Round(value * short.MaxValue));
return ConvertFloatSample(BitConverter.Int32BitsToSingle(bits));
}
private static short ConvertFloatSample(float value)
{
if (float.IsNaN(value))
{
return 0;
}
value = Math.Clamp(value, -1.0f, 1.0f);
var scale = value < 0.0f ? 32768.0f : short.MaxValue;
return checked((short)MathF.Round(value * scale));
}
private static short ApplyVolume(short sample, float volume)
@@ -0,0 +1,18 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
namespace SharpEmu.Libs.ContentExport;
// No host media library exists to export captures into, so initialization
// reports success.
public static class ContentExportExports
{
[SysAbiExport(
Nid = "0GnN4QCgIfs",
ExportName = "sceContentExportInit2",
Target = Generation.Gen5,
LibraryName = "libSceContentExport")]
public static int ContentExportInit2(CpuContext ctx) => ctx.SetReturn(0);
}
+79
View File
@@ -74,6 +74,85 @@ public static class FontExports
public static int CreateRendererWithEdition(CpuContext ctx) =>
CreateOpaqueHandle(ctx, ctx[CpuRegister.Rcx], 0x100, magic: 0x0F07);
[SysAbiExport(
Nid = "3OdRkSjOcog",
ExportName = "sceFontBindRenderer",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int BindRenderer(CpuContext ctx) => SetSuccess(ctx);
[SysAbiExport(
Nid = "N1EBMeGhf7E",
ExportName = "sceFontSetScalePixel",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int SetScalePixel(CpuContext ctx) => SetSuccess(ctx);
[SysAbiExport(
Nid = "TMtqoFQjjbA",
ExportName = "sceFontSetEffectSlant",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int SetEffectSlant(CpuContext ctx) => SetSuccess(ctx);
[SysAbiExport(
Nid = "v0phZwa4R5o",
ExportName = "sceFontSetEffectWeight",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int SetEffectWeight(CpuContext ctx) => SetSuccess(ctx);
[SysAbiExport(
Nid = "6vGCkkQJOcI",
ExportName = "sceFontSetupRenderScalePixel",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int SetupRenderScalePixel(CpuContext ctx) => SetSuccess(ctx);
[SysAbiExport(
Nid = "lz9y9UFO2UU",
ExportName = "sceFontSetupRenderEffectSlant",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int SetupRenderEffectSlant(CpuContext ctx) => SetSuccess(ctx);
[SysAbiExport(
Nid = "XIGorvLusDQ",
ExportName = "sceFontSetupRenderEffectWeight",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int SetupRenderEffectWeight(CpuContext ctx) => SetSuccess(ctx);
[SysAbiExport(
Nid = "imxVx8lm+KM",
ExportName = "sceFontGetHorizontalLayout",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int GetHorizontalLayout(CpuContext ctx)
{
var layoutAddress = ctx[CpuRegister.Rsi];
if (layoutAddress == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
// Baseline, line advance, decoration extent: the same invented geometry
// as GetRenderCharGlyphMetrics.
var values = new[] { 12.0f, 16.0f, 0.0f };
for (var index = 0; index < values.Length; index++)
{
if (!TryWriteUInt32(
ctx,
layoutAddress + (ulong)(index * sizeof(float)),
BitConverter.SingleToUInt32Bits(values[index])))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
return SetSuccess(ctx);
}
[SysAbiExport(
Nid = "cKYtVmeSTcw",
ExportName = "sceFontOpenFontSet",
+3 -2
View File
@@ -91,9 +91,10 @@ internal readonly record struct GuestRasterState(
internal readonly record struct GuestDepthState(
bool TestEnable,
bool WriteEnable,
uint CompareOp)
uint CompareOp,
bool ClearEnable = false)
{
public static GuestDepthState Default { get; } = new(false, false, 7);
public static GuestDepthState Default { get; } = new(false, false, 7, false);
}
/// <summary>Factors/funcs are raw guest CB_BLEND*_CONTROL register bitfields; the
@@ -25,6 +25,11 @@ public static class KernelEventFlagCompatExports
private static readonly ConcurrentDictionary<ulong, EventFlagState> _eventFlags = new();
private static long _nextEventFlagHandle = 1;
// Cached once: gating every call site avoids building the interpolated
// trace string (and FormatFrameChain/FormatGuestWaitObject) when disabled.
private static readonly bool _traceEventFlag = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_EVENT_FLAG"), "1", StringComparison.Ordinal);
private sealed class EventFlagState
{
public required string Name { get; init; }
@@ -79,7 +84,7 @@ public static class KernelEventFlagCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TraceEventFlag($"create handle=0x{handle:X16} name='{name}' attr=0x{attributes:X2} bits=0x{initialPattern:X16}");
if (_traceEventFlag) TraceEventFlag($"create handle=0x{handle:X16} name='{name}' attr=0x{attributes:X2} bits=0x{initialPattern:X16}");
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
@@ -101,7 +106,7 @@ public static class KernelEventFlagCompatExports
Monitor.PulseAll(state.Gate);
}
TraceEventFlag($"delete handle=0x{handle:X16} name='{state.Name}'");
if (_traceEventFlag) TraceEventFlag($"delete handle=0x{handle:X16} name='{state.Name}'");
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
@@ -124,7 +129,7 @@ public static class KernelEventFlagCompatExports
{
state.Bits |= pattern;
Monitor.PulseAll(state.Gate);
TraceEventFlag($"set handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} ret=0x{returnRip:X16}");
if (_traceEventFlag) TraceEventFlag($"set handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} ret=0x{returnRip:X16}");
}
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetEventFlagWakeKey(handle));
@@ -148,7 +153,7 @@ public static class KernelEventFlagCompatExports
lock (state.Gate)
{
state.Bits &= pattern;
TraceEventFlag($"clear handle=0x{handle:X16} mask=0x{pattern:X16} bits=0x{state.Bits:X16}");
if (_traceEventFlag) TraceEventFlag($"clear handle=0x{handle:X16} mask=0x{pattern:X16} bits=0x{state.Bits:X16}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
@@ -189,7 +194,7 @@ public static class KernelEventFlagCompatExports
}
ApplyClearMode(state, pattern, waitMode);
TraceEventFlag($"poll handle=0x{handle:X16} pattern=0x{pattern:X16} mode=0x{waitMode:X2} bits=0x{state.Bits:X16}");
if (_traceEventFlag) TraceEventFlag($"poll handle=0x{handle:X16} pattern=0x{pattern:X16} mode=0x{waitMode:X2} bits=0x{state.Bits:X16}");
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
}
@@ -287,8 +292,8 @@ public static class KernelEventFlagCompatExports
return true;
},
deadline);
TraceEventFlag($"wait-unsatisfied handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} guest_thread=0x{currentGuestThread:X16} fiber=0x{currentFiber:X16} managed={managedThread} block={requestedBlock} ret=0x{returnRip:X16} frames={FormatFrameChain(ctx)}");
TraceEventFlag($"wait-object handle=0x{handle:X16} name='{state.Name}' {FormatGuestWaitObject(ctx)}");
if (_traceEventFlag) TraceEventFlag($"wait-unsatisfied handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} guest_thread=0x{currentGuestThread:X16} fiber=0x{currentFiber:X16} managed={managedThread} block={requestedBlock} ret=0x{returnRip:X16} frames={FormatFrameChain(ctx)}");
if (_traceEventFlag) TraceEventFlag($"wait-object handle=0x{handle:X16} name='{state.Name}' {FormatGuestWaitObject(ctx)}");
if (!requestedBlock)
{
var scheduler = GuestThreadExecution.Scheduler;
@@ -298,7 +303,7 @@ public static class KernelEventFlagCompatExports
}
state.WaitingThreads++;
TraceEventFlag($"wait-pump handle=0x{handle:X16} pattern=0x{pattern:X16} waiters={state.WaitingThreads} guest_thread=0x{currentGuestThread:X16} fiber=0x{currentFiber:X16} managed={managedThread} ret=0x{returnRip:X16}");
if (_traceEventFlag) TraceEventFlag($"wait-pump handle=0x{handle:X16} pattern=0x{pattern:X16} waiters={state.WaitingThreads} guest_thread=0x{currentGuestThread:X16} fiber=0x{currentFiber:X16} managed={managedThread} ret=0x{returnRip:X16}");
var releaseWaiter = true;
try
{
@@ -318,7 +323,7 @@ public static class KernelEventFlagCompatExports
{
state.WaitingThreads = Math.Max(0, state.WaitingThreads - 1);
releaseWaiter = false;
TraceEventFlag($"wait-wake handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} waiters={state.WaitingThreads} ret=0x{returnRip:X16}");
if (_traceEventFlag) TraceEventFlag($"wait-wake handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} waiters={state.WaitingThreads} ret=0x{returnRip:X16}");
return SetReturn(ctx, pumpedWaitResult);
}
@@ -329,7 +334,7 @@ public static class KernelEventFlagCompatExports
releaseWaiter = false;
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
_ = TryWriteResultPattern(ctx, resultAddress, state.Bits);
TraceEventFlag($"wait-timeout handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} ret=0x{returnRip:X16}");
if (_traceEventFlag) TraceEventFlag($"wait-timeout handle=0x{handle:X16} pattern=0x{pattern:X16} bits=0x{state.Bits:X16} ret=0x{returnRip:X16}");
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT);
}
@@ -346,7 +351,7 @@ public static class KernelEventFlagCompatExports
}
state.WaitingThreads++;
TraceEventFlag($"wait-block handle=0x{handle:X16} pattern=0x{pattern:X16} waiters={state.WaitingThreads} guest_thread=0x{currentGuestThread:X16} fiber=0x{currentFiber:X16} managed={managedThread} ret=0x{returnRip:X16}");
if (_traceEventFlag) TraceEventFlag($"wait-block handle=0x{handle:X16} pattern=0x{pattern:X16} waiters={state.WaitingThreads} guest_thread=0x{currentGuestThread:X16} fiber=0x{currentFiber:X16} managed={managedThread} ret=0x{returnRip:X16}");
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
finally
@@ -381,7 +386,7 @@ public static class KernelEventFlagCompatExports
state.Bits = setPattern;
state.WaitingThreads = 0;
Monitor.PulseAll(state.Gate);
TraceEventFlag(
if (_traceEventFlag) TraceEventFlag(
$"cancel handle=0x{handle:X16} bits=0x{setPattern:X16} " +
$"guest_thread=0x{GuestThreadExecution.CurrentGuestThreadHandle:X16} ret=0x{GetCurrentReturnRip():X16}");
}
@@ -476,7 +481,7 @@ public static class KernelEventFlagCompatExports
}
state.WaitingThreads = Math.Max(0, state.WaitingThreads - 1);
TraceEventFlag(
if (_traceEventFlag) TraceEventFlag(
$"wait-wake pattern=0x{pattern:X16} mode=0x{waitMode:X2} bits=0x{state.Bits:X16} waiters={state.WaitingThreads}");
return true;
}
@@ -568,7 +573,7 @@ public static class KernelEventFlagCompatExports
private static void TraceEventFlag(string message)
{
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_EVENT_FLAG"), "1", StringComparison.Ordinal))
if (_traceEventFlag)
{
Console.Error.WriteLine($"[LOADER][TRACE] event_flag.{message}");
}
@@ -102,7 +102,11 @@ public static partial class KernelMemoryCompatExports
private static readonly object _guestMountGate = new();
private static readonly Dictionary<ulong, DirectAllocation> _directAllocations = new();
private static readonly Dictionary<ulong, LibcHeapAllocation> _libcAllocations = new();
private static readonly Dictionary<ulong, MappedRegion> _mappedRegions = new();
// Keyed by (and kept sorted on) region base address so VirtualQuery can find a
// containing/next region with a binary search instead of an O(n) scan. Every
// write uses the region's own Address as the key (see AddMappedRegionSliceLocked
// and the mmap sites), so Values enumerate in ascending address order.
private static readonly SortedList<ulong, MappedRegion> _mappedRegions = new();
private static readonly Dictionary<ulong, string> _mappedRegionNames = new();
private static readonly Dictionary<string, string> _guestMounts = new(StringComparer.OrdinalIgnoreCase);
private static readonly HashSet<string> _tracedStatResults = new(StringComparer.Ordinal);
@@ -1574,7 +1578,27 @@ public static partial class KernelMemoryCompatExports
ExportName = "stat",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libc")]
public static int PosixStat(CpuContext ctx) => KernelStat(ctx);
public static int PosixStat(CpuContext ctx)
{
var result = KernelStat(ctx);
if (result == (int)OrbisGen2Result.ORBIS_GEN2_OK)
{
return 0;
}
// stat(2) follows the libc/POSIX ABI: failures return -1 and expose
// the reason through errno. Returning the raw Orbis kernel code here
// makes callers treat a missing file as a non-negative success value.
var errno = result switch
{
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT => Einval,
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT => Efault,
_ => 2, // ENOENT
};
KernelRuntimeCompatExports.TrySetErrno(ctx, errno);
ctx[CpuRegister.Rax] = ulong.MaxValue;
return -1;
}
[SysAbiExport(
Nid = "gEpBkcwxUjw",
@@ -4013,7 +4037,7 @@ public static partial class KernelMemoryCompatExports
_ => unchecked((int)argumentSource.NextGpArg())
};
var formatted = value.ToString();
var formatted = value.ToString(CultureInfo.InvariantCulture);
if (showSign && value >= 0)
formatted = "+" + formatted;
else if (spaceForSign && value >= 0)
@@ -4033,7 +4057,7 @@ public static partial class KernelMemoryCompatExports
_ => (uint)argumentSource.NextGpArg()
};
var formatted = value.ToString();
var formatted = value.ToString(CultureInfo.InvariantCulture);
sb.Append(PadString(formatted, width, leftAlign, padWithZero && !leftAlign));
}
break;
@@ -4050,8 +4074,8 @@ public static partial class KernelMemoryCompatExports
};
var formatted = specifier == 'x'
? value.ToString("x")
: value.ToString("X");
? value.ToString("x", CultureInfo.InvariantCulture)
: value.ToString("X", CultureInfo.InvariantCulture);
if (alternateForm && value != 0)
formatted = specifier == 'x' ? "0x" + formatted : "0X" + formatted;
@@ -4155,7 +4179,10 @@ public static partial class KernelMemoryCompatExports
var formatStr = precision >= 0
? $"{{0:{specifier}{precision}}}"
: $"{{0:{specifier}}}";
var formatted = string.Format(formatStr, value);
var formatted = string.Format(
CultureInfo.InvariantCulture,
formatStr,
value);
if (showSign && value >= 0)
formatted = "+" + formatted;
@@ -4219,31 +4246,50 @@ public static partial class KernelMemoryCompatExports
{
private readonly CpuContext _ctx;
private int _gpIndex;
private int _fpIndex;
private int _stackIndex;
public RegisterPrintfArgumentSource(CpuContext ctx, int gpIndex)
{
_ctx = ctx;
_gpIndex = gpIndex;
_fpIndex = 0;
_stackIndex = 0;
}
public ulong NextGpArg()
{
var index = _gpIndex++;
return index switch
var index = _gpIndex;
if (index < 6)
{
0 => _ctx[CpuRegister.Rdi],
1 => _ctx[CpuRegister.Rsi],
2 => _ctx[CpuRegister.Rdx],
3 => _ctx[CpuRegister.Rcx],
4 => _ctx[CpuRegister.R8],
5 => _ctx[CpuRegister.R9],
_ => ReadStackArg(_ctx, (ulong)(index - 6) * 8)
};
_gpIndex++;
return index switch
{
0 => _ctx[CpuRegister.Rdi],
1 => _ctx[CpuRegister.Rsi],
2 => _ctx[CpuRegister.Rdx],
3 => _ctx[CpuRegister.Rcx],
4 => _ctx[CpuRegister.R8],
_ => _ctx[CpuRegister.R9],
};
}
return ReadStackArg(_ctx, (ulong)(_stackIndex++) * 8);
}
public double NextFloatArg()
{
return BitConverter.Int64BitsToDouble(unchecked((long)NextGpArg()));
ulong bits;
if (_fpIndex < 8)
{
_ctx.GetXmmRegister(_fpIndex++, out bits, out _);
}
else
{
bits = ReadStackArg(_ctx, (ulong)(_stackIndex++) * 8);
}
return BitConverter.Int64BitsToDouble(unchecked((long)bits));
}
}
@@ -4806,8 +4852,19 @@ public static partial class KernelMemoryCompatExports
private static bool IsMutatingOpen(int flags) =>
(flags & (O_WRONLY | O_RDWR | O_CREAT | O_TRUNC | O_APPEND)) != 0;
// Dev-build dumps (unpackaged UE titles, etc.) may write their Saved/ tree under
// /app0, which is read-only on retail hardware. Opt in via SHARPEMU_WRITABLE_APP0=1
// to allow those writes so such dumps can boot; defaults off to keep retail semantics.
private static readonly bool _writableApp0 =
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_WRITABLE_APP0"), "1", StringComparison.Ordinal);
public static bool IsReadOnlyGuestMutationPath(string guestPath)
{
if (_writableApp0)
{
return false;
}
var normalized = NormalizeGuestStatCachePath(guestPath);
return normalized is not null &&
(string.Equals(normalized, "/app0", StringComparison.OrdinalIgnoreCase) ||
@@ -5427,7 +5484,9 @@ public static partial class KernelMemoryCompatExports
var affected = new List<MappedRegion>();
var cursor = address;
foreach (var region in _mappedRegions.Values.OrderBy(static region => region.Address))
// _mappedRegions is a SortedList keyed by address, so Values already
// enumerate in ascending address order.
foreach (var region in _mappedRegions.Values)
{
if (!TryAddU64(region.Address, region.Length, out var regionEnd) || regionEnd <= cursor)
{
@@ -5588,9 +5647,33 @@ public static partial class KernelMemoryCompatExports
private static bool TryFindVirtualQueryRegionLocked(ulong queryAddress, bool findNext, out MappedRegion region)
{
region = default;
var foundNext = false;
foreach (var candidate in _mappedRegions.Values)
var keys = _mappedRegions.Keys;
var values = _mappedRegions.Values;
var count = keys.Count;
// First index whose region address is >= queryAddress.
var lo = 0;
var hi = count;
while (lo < hi)
{
var mid = (int)(((uint)lo + (uint)hi) >> 1);
if (keys[mid] < queryAddress)
{
lo = mid + 1;
}
else
{
hi = mid;
}
}
// Regions do not overlap, so only the one with the greatest base address
// <= queryAddress can contain it — index lo when it starts exactly at
// queryAddress, otherwise lo - 1.
var floorIndex = (lo < count && keys[lo] == queryAddress) ? lo : lo - 1;
if (floorIndex >= 0)
{
var candidate = values[floorIndex];
if (TryAddU64(candidate.Address, candidate.Length, out var candidateEnd) &&
queryAddress >= candidate.Address &&
queryAddress < candidateEnd)
@@ -5598,20 +5681,16 @@ public static partial class KernelMemoryCompatExports
region = candidate;
return true;
}
if (!findNext || candidate.Address < queryAddress)
{
continue;
}
if (!foundNext || candidate.Address < region.Address)
{
region = candidate;
foundNext = true;
}
}
return foundNext;
// findNext: the region with the smallest base address >= queryAddress.
if (findNext && lo < count)
{
region = values[lo];
return true;
}
return false;
}
private static void TraceDirectMemoryCall(
@@ -5640,10 +5719,12 @@ public static partial class KernelMemoryCompatExports
$"[LOADER][TRACE] {operation}: ret=0x{returnRip:X16} len=0x{length:X16} align=0x{alignment:X16} type=0x{memoryType:X8} out=0x{outAddress:X16} selected=0x{selectedAddress:X16} result={result?.ToString() ?? "<pending>"}");
}
private static bool ShouldTraceDirectMemory()
{
return string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_DIRECT_MEMORY"), "1", StringComparison.Ordinal);
}
// Cached once so the ~8 direct-memory call sites don't each do a
// GetEnvironmentVariable P/Invoke per operation.
private static readonly bool _traceDirectMemory = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_DIRECT_MEMORY"), "1", StringComparison.Ordinal);
private static bool ShouldTraceDirectMemory() => _traceDirectMemory;
private static bool TryReleaseDirectMemoryRangeLocked(ulong start, ulong length)
{
@@ -77,7 +77,10 @@ public static class KernelSemaphoreCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TraceSemaphore($"create handle=0x{handle:X8} name='{name}' attr=0x{attr:X} init={initialCount} max={maxCount}");
if (_traceSema)
{
TraceSemaphore($"create handle=0x{handle:X8} name='{name}' attr=0x{attr:X} init={initialCount} max={maxCount}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
@@ -118,7 +121,10 @@ public static class KernelSemaphoreCompatExports
_ = TryWriteUInt32(ctx, timeoutAddress, timeoutUsec);
}
TraceSemaphore($"wait handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)}");
if (_traceSema)
{
TraceSemaphore($"wait handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
@@ -158,7 +164,10 @@ public static class KernelSemaphoreCompatExports
if (acquired)
{
TraceSemaphore($"wait-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
if (_traceSema)
{
TraceSemaphore($"wait-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -167,7 +176,10 @@ public static class KernelSemaphoreCompatExports
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
}
TraceSemaphore($"wait-timeout handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
if (_traceSema)
{
TraceSemaphore($"wait-timeout handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
}
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_TIMED_OUT;
}
@@ -179,7 +191,10 @@ public static class KernelSemaphoreCompatExports
WakePredicate,
deadline))
{
TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
if (_traceSema)
{
TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
@@ -201,9 +216,12 @@ public static class KernelSemaphoreCompatExports
: long.MaxValue;
lock (semaphore.Gate)
{
TraceSemaphore(
$"wait-host-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} " +
$"count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} {FormatCallSite(ctx)}");
if (_traceSema)
{
TraceSemaphore(
$"wait-host-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} " +
$"count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} {FormatCallSite(ctx)}");
}
while (semaphore.Count < needCount)
{
var remaining = deadlineMs - Environment.TickCount64;
@@ -219,8 +237,11 @@ public static class KernelSemaphoreCompatExports
semaphore.Count -= needCount;
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
TraceSemaphore(
$"wait-host-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
if (_traceSema)
{
TraceSemaphore(
$"wait-host-wake handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
}
if (timeoutAddress != 0)
{
_ = TryWriteUInt32(ctx, timeoutAddress, 0);
@@ -253,12 +274,18 @@ public static class KernelSemaphoreCompatExports
{
if (semaphore.Count < needCount)
{
TraceSemaphore($"poll-busy handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
if (_traceSema)
{
TraceSemaphore($"poll-busy handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
}
semaphore.Count -= needCount;
TraceSemaphore($"poll handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
if (_traceSema)
{
TraceSemaphore($"poll handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
}
@@ -290,7 +317,10 @@ public static class KernelSemaphoreCompatExports
semaphore.Count += signalCount;
// Wake host-thread waiters parked in the fallback path.
Monitor.PulseAll(semaphore.Gate);
TraceSemaphore($"signal handle=0x{handle:X8} name='{semaphore.Name}' signal={signalCount} count={semaphore.Count} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
if (_traceSema)
{
TraceSemaphore($"signal handle=0x{handle:X8} name='{semaphore.Name}' signal={signalCount} count={semaphore.Count} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
}
}
// Wake cooperatively-blocked guest threads; their wake predicate
@@ -326,7 +356,10 @@ public static class KernelSemaphoreCompatExports
semaphore.Count = setCount < 0 ? semaphore.InitialCount : setCount;
semaphore.WaitingThreads = 0;
Monitor.PulseAll(semaphore.Gate);
TraceSemaphore($"cancel handle=0x{handle:X8} name='{semaphore.Name}' set={setCount} count={semaphore.Count}");
if (_traceSema)
{
TraceSemaphore($"cancel handle=0x{handle:X8} name='{semaphore.Name}' set={setCount} count={semaphore.Count}");
}
}
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(GetSemaphoreWakeKey(handle));
@@ -346,7 +379,10 @@ public static class KernelSemaphoreCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
}
TraceSemaphore($"delete handle=0x{handle:X8} name='{semaphore.Name}'");
if (_traceSema)
{
TraceSemaphore($"delete handle=0x{handle:X8} name='{semaphore.Name}'");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
@@ -385,7 +421,10 @@ public static class KernelSemaphoreCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TraceSemaphore($"posix-init address=0x{semaphoreAddress:X16} handle=0x{handle:X8} count={initialCount}");
if (_traceSema)
{
TraceSemaphore($"posix-init address=0x{semaphoreAddress:X16} handle=0x{handle:X8} count={initialCount}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
@@ -582,6 +621,11 @@ public static class KernelSemaphoreCompatExports
private static void TraceSemaphore(string message)
{
if (!_traceSema)
{
return;
}
Console.Error.WriteLine($"[LOADER][TRACE] sema.{message}");
}
+12 -4
View File
@@ -344,8 +344,10 @@ public static class NetExports
public static int NetHtonl(CpuContext ctx)
{
var value = unchecked((uint)ctx[CpuRegister.Rdi]);
// The byte-swapped result is the return value and already lives in Rax; return OK as the
// dispatch status without going through SetReturn, which would overwrite Rax with 0.
ctx[CpuRegister.Rax] = BinaryPrimitives.ReverseEndianness(value);
return ctx.SetReturn(0);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
@@ -356,8 +358,10 @@ public static class NetExports
public static int NetHtons(CpuContext ctx)
{
var value = unchecked((ushort)ctx[CpuRegister.Rdi]);
// The byte-swapped result is the return value and already lives in Rax; return OK as the
// dispatch status without going through SetReturn, which would overwrite Rax with 0.
ctx[CpuRegister.Rax] = BinaryPrimitives.ReverseEndianness(value);
return ctx.SetReturn(0);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
@@ -368,8 +372,10 @@ public static class NetExports
public static int NetNtohl(CpuContext ctx)
{
var value = unchecked((uint)ctx[CpuRegister.Rdi]);
// The byte-swapped result is the return value and already lives in Rax; return OK as the
// dispatch status without going through SetReturn, which would overwrite Rax with 0.
ctx[CpuRegister.Rax] = BinaryPrimitives.ReverseEndianness(value);
return ctx.SetReturn(0);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
@@ -380,8 +386,10 @@ public static class NetExports
public static int NetNtohs(CpuContext ctx)
{
var value = unchecked((ushort)ctx[CpuRegister.Rdi]);
// The byte-swapped result is the return value and already lives in Rax; return OK as the
// dispatch status without going through SetReturn, which would overwrite Rax with 0.
ctx[CpuRegister.Rax] = BinaryPrimitives.ReverseEndianness(value);
return ctx.SetReturn(0);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
+13
View File
@@ -43,6 +43,19 @@ public static class NpWebApi2Exports
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "sk54bi6FtYM",
ExportName = "sceNpWebApi2CreateUserContext",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNpWebApi2")]
public static int NpWebApi2CreateUserContext(CpuContext ctx)
{
// No PSN backend: refuse user-context creation so the title's online
// layer backs off instead of driving a half-created context handle.
TraceNpWebApi2("create-user-context", unchecked((int)ctx[CpuRegister.Rdi]), ctx[CpuRegister.Rsi]);
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
}
[SysAbiExport(
Nid = "bEvXpcEk200",
ExportName = "sceNpWebApi2Terminate",
+106 -2
View File
@@ -1,6 +1,8 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
using System.Threading;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Pad;
@@ -16,6 +18,19 @@ public static class BluetoothHidExports
"1",
StringComparison.Ordinal);
// EXPERIMENT: fire the registered callback once with a zeroed event struct.
// Direct execution shares the host address space with the guest, so an
// AllocHGlobal buffer is directly readable by guest code.
// SHARPEMU_BTHID_FIRE_CALLBACK=1 enables; SHARPEMU_BTHID_EVENT_CODE and
// SHARPEMU_BTHID_EVENT_SIZE (default 0 / 256) shape the synthetic event.
private static readonly bool _fireCallback = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_BTHID_FIRE_CALLBACK"),
"1",
StringComparison.Ordinal);
private static ulong _callbackFunction;
private static int _fired;
private static int Result(CpuContext ctx) =>
ctx.SetReturn(_reportUnavailable ? BluetoothHidUnavailable : 0);
@@ -31,12 +46,101 @@ public static class BluetoothHidExports
ExportName = "sceBluetoothHidRegisterDevice",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceBluetoothHid")]
public static int BluetoothHidRegisterDevice(CpuContext ctx) => Result(ctx);
public static int BluetoothHidRegisterDevice(CpuContext ctx)
{
var result = Result(ctx);
if (_fireCallback && _callbackFunction >= 0x10000 &&
Interlocked.Exchange(ref _fired, 1) == 0)
{
FireEnumerationComplete(ctx);
}
return result;
}
[SysAbiExport(
Nid = "4Ypfo9RIwfM",
ExportName = "sceBluetoothHidRegisterCallback",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceBluetoothHid")]
public static int BluetoothHidRegisterCallback(CpuContext ctx) => Result(ctx);
public static int BluetoothHidRegisterCallback(CpuContext ctx)
{
_callbackFunction = ctx[CpuRegister.Rdi];
// EXPERIMENT: failing ONLY the callback registration (with a generic kernel
// error, unlike the BT-specific unavailable code) may make wheel/FFB
// middleware disable its Bluetooth search loop instead of polling forever.
// SHARPEMU_BTHID_CB_FAIL=nf -> NOT_FOUND, =ni -> NOT_IMPLEMENTED.
var mode = Environment.GetEnvironmentVariable("SHARPEMU_BTHID_CB_FAIL");
if (string.Equals(mode, "nf", StringComparison.OrdinalIgnoreCase))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
}
if (string.Equals(mode, "ni", StringComparison.OrdinalIgnoreCase))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_IMPLEMENTED);
}
return Result(ctx);
}
private static void FireEnumerationComplete(CpuContext ctx)
{
var scheduler = GuestThreadExecution.Scheduler;
if (scheduler is null)
{
return;
}
var eventCode = ParseEnvUInt64("SHARPEMU_BTHID_EVENT_CODE", 0);
var eventSize = (int)ParseEnvUInt64("SHARPEMU_BTHID_EVENT_SIZE", 256);
if (eventSize < 1)
{
eventSize = 256;
}
// Leaked by design: the guest may retain the pointer past the callback.
var eventStruct = Marshal.AllocHGlobal(eventSize);
for (var offset = 0; offset < eventSize; offset++)
{
Marshal.WriteByte(eventStruct, offset, 0);
}
Console.Error.WriteLine(
$"[BTHID][EXPERIMENT] firing callback=0x{_callbackFunction:X} code={eventCode} size={eventSize} struct=0x{eventStruct.ToInt64():X}");
if (!scheduler.TryCallGuestFunction(
ctx,
_callbackFunction,
unchecked((ulong)eventStruct.ToInt64()),
eventCode,
0,
0,
"sceBluetoothHid synthetic enumeration event",
out var error))
{
Console.Error.WriteLine($"[BTHID][EXPERIMENT] callback failed: {error}");
}
else
{
Console.Error.WriteLine("[BTHID][EXPERIMENT] callback returned OK");
}
}
private static ulong ParseEnvUInt64(string name, ulong fallback)
{
var value = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrWhiteSpace(value))
{
return fallback;
}
value = value.Trim();
var hex = value.StartsWith("0x", StringComparison.OrdinalIgnoreCase);
return ulong.TryParse(
hex ? value[2..] : value,
hex ? System.Globalization.NumberStyles.HexNumber : System.Globalization.NumberStyles.Integer,
null,
out var parsed) ? parsed : fallback;
}
}
+2 -2
View File
@@ -253,9 +253,9 @@ public static class HostWindowInput
};
}
private static byte ToStickByte(float value)
internal static byte ToStickByte(float value)
{
return (byte)Math.Clamp((int)(128.0f + value * 127.0f), 0, 255);
return (byte)Math.Clamp((int)MathF.Round((value + 1.0f) * 127.5f), 0, 255);
}
private static HostGamepadButtons MapButton(ButtonName name) => name switch
+28 -10
View File
@@ -23,6 +23,11 @@ public static class PadExports
private const int PrimaryPadHandle = 1;
private const int ControllerInformationSize = 0x1C;
private const int PadDataSize = 0x78;
// Real firmware hands out small non-negative handles; 0 is valid. Some titles
// (Monster Truck Championship) read pad state with handle 0, and rejecting it
// leaves their controller/FFB init path polling a never-valid state forever.
private static bool IsPrimaryPadHandle(int handle) => handle is 0 or PrimaryPadHandle;
private static readonly long InputSampleIntervalTicks = Math.Max(1, Stopwatch.Frequency / 1000);
[ThreadStatic]
@@ -104,7 +109,7 @@ public static class PadExports
public static int PadClose(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
return handle == PrimaryPadHandle
return IsPrimaryPadHandle(handle)
? ctx.SetReturn(0)
: ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
@@ -117,7 +122,20 @@ public static class PadExports
public static int PadSetMotionSensorState(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
return handle == PrimaryPadHandle
return IsPrimaryPadHandle(handle)
? ctx.SetReturn(0)
: ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
[SysAbiExport(
Nid = "vDLMoJLde8I",
ExportName = "scePadSetTiltCorrectionState",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libScePad")]
public static int PadSetTiltCorrectionState(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
return IsPrimaryPadHandle(handle)
? ctx.SetReturn(0)
: ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
@@ -131,7 +149,7 @@ public static class PadExports
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var informationAddress = ctx[CpuRegister.Rsi];
if (handle != PrimaryPadHandle)
if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
@@ -166,7 +184,7 @@ public static class PadExports
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var informationAddress = ctx[CpuRegister.Rsi];
if (handle != PrimaryPadHandle)
if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
@@ -207,7 +225,7 @@ public static class PadExports
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var dataAddress = ctx[CpuRegister.Rsi];
if (handle != PrimaryPadHandle)
if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
@@ -232,7 +250,7 @@ public static class PadExports
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var dataAddress = ctx[CpuRegister.Rsi];
var count = unchecked((int)ctx[CpuRegister.Rdx]);
if (handle != PrimaryPadHandle)
if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
@@ -266,7 +284,7 @@ public static class PadExports
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var parameterAddress = ctx[CpuRegister.Rsi];
if (handle != PrimaryPadHandle)
if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
@@ -310,7 +328,7 @@ public static class PadExports
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var parameterAddress = ctx[CpuRegister.Rsi];
if (handle != PrimaryPadHandle)
if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
@@ -340,7 +358,7 @@ public static class PadExports
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
var parameterAddress = ctx[CpuRegister.Rsi];
if (handle != PrimaryPadHandle)
if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
@@ -369,7 +387,7 @@ public static class PadExports
public static int PadResetLightBar(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
if (handle != PrimaryPadHandle)
if (!IsPrimaryPadHandle(handle))
{
return ctx.SetReturn(OrbisPadErrorInvalidHandle);
}
+73 -11
View File
@@ -4,6 +4,7 @@
using SharpEmu.HLE;
using System.Buffers.Binary;
using System.Text.RegularExpressions;
using System.Xml.Linq;
namespace SharpEmu.Libs.PlayGo;
@@ -418,12 +419,21 @@ public static class PlayGoExports
$"[LOADER][TRACE] playgo.unknown_chunk_id id={chunkId} entries={numberOfEntries} " +
$"known=[{string.Join(',', knownChunkIds)}]");
}
// Real firmware rejects chunk ids outside the package's chunk set.
// Titles rely on this as an enumeration terminator: Monster Truck
// scans ids 0,1,2,... until BAD_CHUNK_ID, and answering OK for every
// id makes that scan wrap the ushort range and spin forever.
loci[i] = PlayGoLocusNotDownloaded;
return ctx.Memory.TryWrite(outLoci, loci)
? OrbisPlayGoErrorBadChunkId
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
loci[i] = PlayGoLocusLocalFast;
}
TracePlayGoLocus(numberOfEntries, chunkIds, outLoci);
TracePlayGoLocus(ctx, numberOfEntries, chunkIds, outLoci);
return ctx.Memory.TryWrite(outLoci, loci)
? (int)OrbisGen2Result.ORBIS_GEN2_OK
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
@@ -663,7 +673,8 @@ public static class PlayGoExports
{
lock (_stateGate)
{
return _metadata.ChunkIds.Length == 0 || Array.BinarySearch(_metadata.ChunkIds, chunkId) >= 0;
return _metadata.ChunkIdKnowledge == PlayGoChunkIdKnowledge.Unknown ||
Array.BinarySearch(_metadata.ChunkIds, chunkId) >= 0;
}
}
@@ -672,7 +683,12 @@ public static class PlayGoExports
var app0Root = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
if (string.IsNullOrWhiteSpace(app0Root))
{
return PlayGoMetadata.Empty;
// No app0 override to probe for sidecar files: same fully-installed
// single-chunk fallback as below, or scePlayGoOpen fails fatally.
return new PlayGoMetadata(
true,
[(ushort)0],
PlayGoChunkIdKnowledge.Authoritative);
}
var playGoDat = Path.Combine(app0Root, "sce_sys", "playgo-chunk.dat");
@@ -682,13 +698,24 @@ public static class PlayGoExports
var hasMetadata = File.Exists(playGoDat) || File.Exists(scenarioJson) || File.Exists(chunkDefsXml);
if (!hasMetadata)
{
// Full installs may omit PlayGo sidecar metadata.
TracePlayGo("metadata_missing; using fully-installed default chunk");
return new PlayGoMetadata(true, [(ushort)0]);
// No PlayGo sidecar: report a fully-installed single chunk. Available must
// stay true or scePlayGoOpen fails with NotSupportPlayGo (fatal PS5-component
// init failure for UE titles); chunk 0 reports LocalFast and every other id
// returns BAD_CHUNK_ID, terminating title-side chunk enumeration.
TracePlayGo("metadata_missing; fully-installed single chunk");
return new PlayGoMetadata(
true,
[(ushort)0],
PlayGoChunkIdKnowledge.Authoritative);
}
var chunkIds = LoadChunkIds(chunkDefsXml);
return new PlayGoMetadata(true, chunkIds);
return new PlayGoMetadata(
true,
chunkIds,
chunkIds.Length == 0
? PlayGoChunkIdKnowledge.Unknown
: PlayGoChunkIdKnowledge.Authoritative);
}
private static ushort[] LoadChunkIds(string chunkDefsXml)
@@ -701,6 +728,8 @@ public static class PlayGoExports
try
{
var xml = File.ReadAllText(chunkDefsXml);
_ = XDocument.Parse(xml, LoadOptions.None);
var chunkIds = new HashSet<ushort>();
AddChunkIds(xml, DefaultChunkPattern, chunkIds);
AddChunkIds(xml, ChunkIdPattern, chunkIds);
@@ -717,6 +746,10 @@ public static class PlayGoExports
{
return Array.Empty<ushort>();
}
catch (System.Xml.XmlException)
{
return Array.Empty<ushort>();
}
}
private static void AddChunkIds(string xml, Regex pattern, HashSet<ushort> chunkIds)
@@ -738,7 +771,7 @@ public static class PlayGoExports
}
}
private static void TracePlayGoLocus(uint entries, ulong chunkIds, ulong outLoci)
private static void TracePlayGoLocus(CpuContext ctx, uint entries, ulong chunkIds, ulong outLoci)
{
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_PLAYGO"), "1", StringComparison.Ordinal))
{
@@ -748,13 +781,42 @@ public static class PlayGoExports
var count = Interlocked.Increment(ref _locusTraceDiagnostics);
if (entries != 1 || count <= 32 || count % 1000 == 0)
{
_ = ctx.TryReadUInt16(chunkIds, out var firstChunkId);
Console.Error.WriteLine(
$"[LOADER][TRACE] playgo.get_locus entries={entries} chunk_ids=0x{chunkIds:X16} out=0x{outLoci:X16}");
$"[LOADER][TRACE] playgo.get_locus entries={entries} first_chunk={firstChunkId} " +
$"chunk_ids=0x{chunkIds:X16} out=0x{outLoci:X16}");
}
}
private sealed record PlayGoMetadata(bool Available, ushort[] ChunkIds)
internal static void ResetForTests()
{
public static readonly PlayGoMetadata Empty = new(false, Array.Empty<ushort>());
lock (_stateGate)
{
_initialized = false;
_opened = false;
_metadata = PlayGoMetadata.Empty;
_installSpeed = PlayGoInstallSpeedTrickle;
_languageMask = ulong.MaxValue;
}
Interlocked.Exchange(ref _unknownChunkDiagnostics, 0);
Interlocked.Exchange(ref _locusTraceDiagnostics, 0);
}
private enum PlayGoChunkIdKnowledge
{
Unknown,
Authoritative,
}
private sealed record PlayGoMetadata(
bool Available,
ushort[] ChunkIds,
PlayGoChunkIdKnowledge ChunkIdKnowledge)
{
public static readonly PlayGoMetadata Empty = new(
false,
Array.Empty<ushort>(),
PlayGoChunkIdKnowledge.Unknown);
}
}
+63
View File
@@ -241,6 +241,64 @@ public static class RtcExports
LibraryName = "libSceRtc")]
public static int RtcGetCurrentRawNetworkTick(CpuContext ctx) => RtcGetCurrentTick(ctx);
// Diagnostic: middleware busy-wait loops typically poll sceRtcGetCurrentTick, so the
// caller's return address pinpoints the loop. SHARPEMU_RTC_PROBE_RANGE=<start>-<end>
// (hex guest addresses) dumps 0x100 bytes of code around the first matching caller,
// once, for offline disassembly. Costs nothing when the variable is unset.
private static readonly ulong[]? _rtcProbeRange = ParseRtcProbeRange();
private static int _rtcProbeDone;
private static ulong[]? ParseRtcProbeRange()
{
var value = Environment.GetEnvironmentVariable("SHARPEMU_RTC_PROBE_RANGE");
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var parts = value.Split('-', 2, StringSplitOptions.TrimEntries);
return parts.Length == 2 &&
TryParseHexAddress(parts[0], out var start) &&
TryParseHexAddress(parts[1], out var end) &&
start < end
? [start, end]
: null;
}
private static bool TryParseHexAddress(string value, out ulong address)
{
if (value.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
value = value[2..];
}
return ulong.TryParse(
value,
System.Globalization.NumberStyles.HexNumber,
null,
out address);
}
private static void ProbeRtcCaller(CpuContext ctx)
{
if (Volatile.Read(ref _rtcProbeDone) != 0 ||
!ctx.TryReadUInt64(ctx[CpuRegister.Rsp], out var ret) ||
ret < _rtcProbeRange![0] ||
ret >= _rtcProbeRange[1] ||
Interlocked.CompareExchange(ref _rtcProbeDone, 1, 0) != 0)
{
return;
}
var start = ret - 0x60;
Span<byte> code = stackalloc byte[0x100];
if (ctx.Memory.TryRead(start, code))
{
Console.Error.WriteLine(
$"[LOADER][DIAG] rtc.caller_code ret=0x{ret:X} @0x{start:X}: {System.Convert.ToHexString(code)}");
}
}
[SysAbiExport(
Nid = "18B2NS1y9UU",
ExportName = "sceRtcGetCurrentTick",
@@ -254,6 +312,11 @@ public static class RtcExports
return unchecked((int)0x80B50002);
}
if (_rtcProbeRange is not null)
{
ProbeRtcCaller(ctx);
}
var tickValue = unchecked((ulong)(DateTime.UtcNow.Ticks / DateTimeTicksPerMicrosecond));
if (!ctx.TryWriteUInt64(tickAddress, tickValue))
{
@@ -3,6 +3,7 @@
using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
using System.Buffers;
using System.Buffers.Binary;
using System.Text;
@@ -14,6 +15,7 @@ public static class SaveDataExports
private const int OrbisSaveDataErrorExists = unchecked((int)0x809F0007);
private const int OrbisSaveDataErrorNotFound = unchecked((int)0x809F0008);
private const int OrbisSaveDataErrorInternal = unchecked((int)0x809F000B);
private const int OrbisSaveDataErrorMemoryNotReady = unchecked((int)0x809F0012);
private const int SaveDataTitleIdSize = 10;
private const int SaveDataDirNameSize = 32;
private const int SaveDataParamSize = 0x530;
@@ -29,7 +31,10 @@ public static class SaveDataExports
private const uint MountModeCreate = 1u << 2;
private const uint MountModeCreate2 = 1u << 5;
private const int MountResultSize = 0x40;
// Emulator guard against corrupt or misread sizes, not a platform limit.
private const ulong SaveDataMemoryMaxSize = 64UL * 1024 * 1024;
private static readonly object _stateGate = new();
private static readonly object _memoryGate = new();
private static readonly HashSet<int> _preparedTransactionResources = [];
private static string? _titleId;
@@ -472,6 +477,19 @@ public static class SaveDataExports
private static string ResolveTitleSaveRoot(int userId, string titleId) =>
Path.Combine(ResolveSaveDataRoot(), userId.ToString(), SanitizePathSegment(titleId));
private static string ResolveSaveDataMemoryPath(int userId) =>
Path.Combine(ResolveTitleSaveRoot(userId, ResolveConfiguredTitleId()), "sce_sdmemory", "memory.dat");
private static bool TryReadMemoryData(
CpuContext ctx, ulong address, out ulong buffer, out ulong size, out ulong offset)
{
size = 0;
offset = 0;
return ctx.TryReadUInt64(address, out buffer) &&
ctx.TryReadUInt64(address + 0x08, out size) &&
ctx.TryReadUInt64(address + 0x10, out offset);
}
private static string ResolveSaveDataRoot()
{
var configured = Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
@@ -670,4 +688,197 @@ public static class SaveDataExports
TraceSaveData($"commit commit=0x{commitAddress:X16}");
return ctx.SetReturn(0);
}
// Save data memory: a small per-user blob titles read and write without
// mounting anything, backed by one zero-filled file per user and title.
[SysAbiExport(
Nid = "oQySEUfgXRA",
ExportName = "sceSaveDataSetupSaveDataMemory2",
Target = Generation.Gen5,
LibraryName = "libSceSaveData")]
public static int SaveDataSetupSaveDataMemory2(CpuContext ctx)
{
var paramAddress = ctx[CpuRegister.Rdi];
var resultAddress = ctx[CpuRegister.Rsi];
if (paramAddress == 0)
{
return ctx.SetReturn(OrbisSaveDataErrorParameter);
}
if (!TryReadInt32(ctx, paramAddress + 0x04, out var userId) ||
!ctx.TryReadUInt64(paramAddress + 0x08, out var memorySize))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
if (userId < 0 || memorySize == 0 || memorySize > SaveDataMemoryMaxSize)
{
return ctx.SetReturn(OrbisSaveDataErrorParameter);
}
try
{
var path = ResolveSaveDataMemoryPath(userId);
lock (_memoryGate)
{
var backing = new FileInfo(path);
var existedSize = backing.Exists ? (ulong)backing.Length : 0;
// The result write comes first so a faulted result pointer
// cannot leave created or grown setup state behind.
if (resultAddress != 0 && !ctx.TryWriteUInt64(resultAddress, existedSize))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
if (existedSize < memorySize)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
using var stream = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite);
stream.SetLength((long)memorySize);
}
TraceSaveData($"memory-setup2 user={userId} size=0x{memorySize:X} existed=0x{existedSize:X}");
}
return ctx.SetReturn(0);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return ctx.SetReturn(OrbisSaveDataErrorInternal);
}
}
[SysAbiExport(
Nid = "QwOO7vegnV8",
ExportName = "sceSaveDataGetSaveDataMemory2",
Target = Generation.Gen5,
LibraryName = "libSceSaveData")]
public static int SaveDataGetSaveDataMemory2(CpuContext ctx) =>
TransferSaveDataMemory(ctx, write: false);
[SysAbiExport(
Nid = "cduy9v4YmT4",
ExportName = "sceSaveDataSetSaveDataMemory2",
Target = Generation.Gen5,
LibraryName = "libSceSaveData")]
public static int SaveDataSetSaveDataMemory2(CpuContext ctx) =>
TransferSaveDataMemory(ctx, write: true);
// Writes go straight through to the backing file, so a ready state is
// all sync has to confirm.
[SysAbiExport(
Nid = "wiT9jeC7xPw",
ExportName = "sceSaveDataSyncSaveDataMemory",
Target = Generation.Gen5,
LibraryName = "libSceSaveData")]
public static int SaveDataSyncSaveDataMemory(CpuContext ctx)
{
var syncAddress = ctx[CpuRegister.Rdi];
if (syncAddress == 0)
{
return ctx.SetReturn(OrbisSaveDataErrorParameter);
}
if (!TryReadInt32(ctx, syncAddress, out var userId))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
if (userId < 0)
{
return ctx.SetReturn(OrbisSaveDataErrorParameter);
}
return ctx.SetReturn(
File.Exists(ResolveSaveDataMemoryPath(userId)) ? 0 : OrbisSaveDataErrorMemoryNotReady);
}
private static int TransferSaveDataMemory(CpuContext ctx, bool write)
{
var requestAddress = ctx[CpuRegister.Rdi];
if (requestAddress == 0)
{
return ctx.SetReturn(OrbisSaveDataErrorParameter);
}
if (!TryReadInt32(ctx, requestAddress, out var userId) ||
!ctx.TryReadUInt64(requestAddress + 0x08, out var dataAddress))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
if (userId < 0)
{
return ctx.SetReturn(OrbisSaveDataErrorParameter);
}
try
{
var path = ResolveSaveDataMemoryPath(userId);
lock (_memoryGate)
{
if (!File.Exists(path))
{
return ctx.SetReturn(OrbisSaveDataErrorMemoryNotReady);
}
if (dataAddress == 0)
{
return ctx.SetReturn(0);
}
if (!TryReadMemoryData(ctx, dataAddress, out var bufAddress, out var bufSize, out var offset))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
using var stream = new FileStream(
path, FileMode.Open, write ? FileAccess.ReadWrite : FileAccess.Read);
var length = (ulong)stream.Length;
if (bufAddress == 0 || bufSize > length || offset > length - bufSize)
{
return ctx.SetReturn(OrbisSaveDataErrorParameter);
}
// The guarded file length bounds bufSize, so one rented buffer
// covers the transfer and a guest fault never partially writes.
var buffer = ArrayPool<byte>.Shared.Rent((int)Math.Max(bufSize, 1));
try
{
var span = buffer.AsSpan(0, (int)bufSize);
stream.Seek((long)offset, SeekOrigin.Begin);
if (write)
{
if (!ctx.Memory.TryRead(bufAddress, span))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
stream.Write(span);
}
else
{
stream.ReadExactly(span);
if (!ctx.Memory.TryWrite(bufAddress, span))
{
return ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
TraceSaveData(
$"memory-{(write ? "set2" : "get2")} user={userId} offset=0x{offset:X} size=0x{bufSize:X}");
return ctx.SetReturn(0);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return ctx.SetReturn(OrbisSaveDataErrorInternal);
}
}
}
@@ -15,6 +15,62 @@ public static class SystemServiceExports
private const int DisplaySafeAreaInfoSize = sizeof(float) + 128;
private const int HdrToneMapLuminanceSize = sizeof(float) * 3;
private const int TitleIdFieldSize = 0x10;
private static string? _mainAppTitleId;
public static void ConfigureApplicationInfo(string? titleId)
{
_mainAppTitleId = string.IsNullOrWhiteSpace(titleId) ? null : titleId.Trim();
}
[SysAbiExport(
Nid = "3RQ5aQfnstU",
ExportName = "sceSystemServiceGetNoticeScreenSkipFlag",
Target = Generation.Gen5,
LibraryName = "libSceSystemService")]
public static int SystemServiceGetNoticeScreenSkipFlag(CpuContext ctx)
{
var flagAddress = ctx[CpuRegister.Rdi];
if (flagAddress == 0)
{
return ctx.SetReturn(OrbisSystemServiceErrorParameter);
}
// No system notice screen to skip in the emulator; report "do not skip".
Span<byte> flagBytes = stackalloc byte[sizeof(int)];
BinaryPrimitives.WriteInt32LittleEndian(flagBytes, 0);
return ctx.Memory.TryWrite(flagAddress, flagBytes)
? ctx.SetReturn(0)
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
[SysAbiExport(
Nid = "4veE0XiIugA",
ExportName = "sceSystemServiceGetMainAppTitleId",
Target = Generation.Gen5,
LibraryName = "libSceSystemService")]
public static int SystemServiceGetMainAppTitleId(CpuContext ctx)
{
var titleIdAddress = ctx[CpuRegister.Rdi];
if (titleIdAddress == 0)
{
return ctx.SetReturn(OrbisSystemServiceErrorParameter);
}
// Title IDs are a fixed 9-char format written into a 0x10-byte field;
// bound the length so a malformed param.json cannot drive an unbounded
// stack allocation or overrun the guest buffer.
var titleId = _mainAppTitleId ?? "PPSA00000";
var length = Math.Min(titleId.Length, TitleIdFieldSize - 1);
Span<byte> titleIdBytes = stackalloc byte[TitleIdFieldSize];
titleIdBytes.Clear();
System.Text.Encoding.ASCII.GetBytes(titleId.AsSpan(0, length), titleIdBytes);
return ctx.Memory.TryWrite(titleIdAddress, titleIdBytes[..(length + 1)])
? ctx.SetReturn(0)
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
[SysAbiExport(
Nid = "fZo48un7LK4",
ExportName = "sceSystemServiceParamGetInt",
+4 -1
View File
@@ -154,7 +154,10 @@ public static class PerfOverlay
_lastGen2 = gen2;
var cpuTime = GetProcessCpuTime();
_cpuPercent = (cpuTime - _lastCpuTime).TotalSeconds / seconds * 100.0;
// Normalize across logical processors (Task Manager convention):
// raw process time / wall time reads 100% per fully busy core.
_cpuPercent = (cpuTime - _lastCpuTime).TotalSeconds / seconds * 100.0 /
Environment.ProcessorCount;
_lastCpuTime = cpuTime;
var drawsPerFrame = _fps > 0.5 ? _drawsPerSecond / _fps : 0;
+52 -7
View File
@@ -156,15 +156,29 @@ public static class VideoOutExports
private static void RequestHostShutdown(string reason)
{
Console.Error.WriteLine($"[LOADER][INFO] Host shutdown requested: {reason}");
VulkanVideoPresenter.RequestClose();
var embedded = VulkanVideoHost.IsEmbedded;
AudioOutExports.ShutdownAllPorts();
Interlocked.Exchange(ref _vblankStopRequested, 1);
HostSessionControl.RequestShutdown(reason);
ThreadPool.QueueUserWorkItem(static _ =>
// A hosted game can still be issuing AGC work after it requests its
// own shutdown. Keep the Vulkan resources alive until the GUI session
// reaches its guest-safe exit path and disposes the host surface.
if (!embedded)
{
Thread.Sleep(2000);
Environment.Exit(0);
});
VulkanVideoPresenter.RequestClose();
}
// The embedded GUI owns the process lifetime. A guest shutdown should
// end only that session rather than terminating the launcher itself.
if (!embedded)
{
ThreadPool.QueueUserWorkItem(static _ =>
{
Thread.Sleep(2000);
Environment.Exit(0);
});
}
}
private sealed class VideoOutPortState
@@ -259,6 +273,22 @@ public static class VideoOutExports
}
}
[SysAbiExport(
Nid = "Nv8c-Kb+DUM",
ExportName = "sceVideoOutIsOutputSupported",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceVideoOut")]
public static int VideoOutIsOutputSupported(CpuContext ctx)
{
var busType = unchecked((int)ctx[CpuRegister.Rdi]);
_ = ctx[CpuRegister.Rsi]; // pixelFormat
_ = ctx[CpuRegister.Rdx]; // aspectRatio
// The emulator supports any output configuration on the main bus.
// Return 1 (supported) for SceVideoOutBusTypeMain, 0 otherwise.
return busType == SceVideoOutBusTypeMain ? 1 : 0;
}
[SysAbiExport(
Nid = "uquVH4-Du78",
ExportName = "sceVideoOutClose",
@@ -1630,8 +1660,12 @@ public static class VideoOutExports
// Maps the PS5 VideoOut pixel format space to the AGC "guest texture format" tags
// the backend keys its guest-image registry on (see VulkanVideoPresenter.
// GetGuestTextureFormat: format=10 => 56 for 8-bit RGBA variants, format=9 => 9 for 10-bit).
private static uint MapPixelFormatToGuestTextureFormat(ulong pixelFormat) =>
NormalizePixelFormat(pixelFormat) switch
// Unknown formats default to 56 (8-bit RGBA) with a logged warning so games
// display something rather than silently failing the flip pipeline.
private static uint MapPixelFormatToGuestTextureFormat(ulong pixelFormat)
{
var normalized = NormalizePixelFormat(pixelFormat);
var result = normalized switch
{
SceVideoOutPixelFormatA8R8G8B8Srgb or
SceVideoOutPixelFormatA8B8G8R8Srgb or
@@ -1649,6 +1683,17 @@ public static class VideoOutExports
_ => 0u,
};
if (result == 0u)
{
Console.Error.WriteLine(
$"[LOADER][WARN] vk: unknown pixel format 0x{pixelFormat:X16} (normalized=0x{normalized:X16}) " +
$"— falling back to format 56 (8-bit RGBA). Report this format to the project.");
result = 56u;
}
return result;
}
internal static bool TryPackRgba8Pixel(
ulong pixelFormat,
byte red,
@@ -0,0 +1,109 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using Silk.NET.Vulkan;
using VkBuffer = Silk.NET.Vulkan.Buffer;
namespace SharpEmu.Libs.VideoOut;
internal readonly record struct VulkanHostBufferPoolKey(
BufferUsageFlags Usage,
ulong Capacity);
internal readonly record struct VulkanHostBufferAllocation(
VkBuffer Buffer,
DeviceMemory Memory,
VulkanHostBufferPoolKey Key,
nint Mapped);
internal sealed class VulkanHostBufferPool : IDisposable
{
private readonly Dictionary<VulkanHostBufferPoolKey, Stack<VulkanHostBufferAllocation>>
_available = [];
private readonly Dictionary<ulong, VulkanHostBufferAllocation> _allocations = [];
private readonly HashSet<ulong> _cachedHandles = [];
private readonly Action<VulkanHostBufferAllocation> _destroy;
public VulkanHostBufferPool(
ulong maximumCachedBytes,
Action<VulkanHostBufferAllocation> destroy)
{
MaximumCachedBytes = maximumCachedBytes;
_destroy = destroy;
}
public ulong MaximumCachedBytes { get; }
public ulong CachedBytes { get; private set; }
public bool TryRent(
VulkanHostBufferPoolKey key,
out VulkanHostBufferAllocation allocation)
{
if (!_available.TryGetValue(key, out var available) ||
!available.TryPop(out allocation))
{
allocation = default;
return false;
}
_cachedHandles.Remove(allocation.Buffer.Handle);
CachedBytes -= allocation.Key.Capacity;
return true;
}
public void Register(VulkanHostBufferAllocation allocation)
{
if (allocation.Buffer.Handle == 0)
{
throw new ArgumentException("A pooled buffer must have a valid handle.", nameof(allocation));
}
_allocations.Add(allocation.Buffer.Handle, allocation);
}
public bool Return(VkBuffer buffer, DeviceMemory memory)
{
if (!_allocations.TryGetValue(buffer.Handle, out var allocation) ||
allocation.Memory.Handle != memory.Handle)
{
return false;
}
if (!_cachedHandles.Add(buffer.Handle))
{
return true;
}
if (allocation.Key.Capacity > MaximumCachedBytes - CachedBytes)
{
_cachedHandles.Remove(buffer.Handle);
_allocations.Remove(buffer.Handle);
_destroy(allocation);
return true;
}
if (!_available.TryGetValue(allocation.Key, out var available))
{
available = [];
_available.Add(allocation.Key, available);
}
available.Push(allocation);
CachedBytes += allocation.Key.Capacity;
return true;
}
public void Dispose()
{
foreach (var allocation in _allocations.Values)
{
_destroy(allocation);
}
_allocations.Clear();
_available.Clear();
_cachedHandles.Clear();
CachedBytes = 0;
}
}
@@ -0,0 +1,270 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.VideoOut;
/// <summary>
/// A native child surface owned by a host UI. The presenter consumes this
/// directly, avoiding a second top-level GLFW window when the GUI is active.
/// </summary>
public enum VulkanHostSurfaceKind
{
Win32,
Xlib,
Metal,
}
/// <summary>
/// Platform-native handles required to create a Vulkan presentation surface.
/// The GUI owns their lifetime and updates the physical pixel size on resize.
/// </summary>
public sealed class VulkanHostSurface : IDisposable
{
private int _pixelWidth;
private int _pixelHeight;
private int _resizeGeneration;
private readonly bool _ownsDisplay;
private readonly bool _pollNativeSize;
private long _nextNativeSizePoll;
public VulkanHostSurface(
VulkanHostSurfaceKind kind,
nint windowHandle,
nint displayHandle = 0,
nint metalLayerHandle = 0,
bool ownsDisplay = false,
bool pollNativeSize = false)
{
Kind = kind;
WindowHandle = windowHandle;
DisplayHandle = displayHandle;
MetalLayerHandle = metalLayerHandle;
_ownsDisplay = ownsDisplay;
_pollNativeSize = pollNativeSize;
}
public VulkanHostSurfaceKind Kind { get; }
public nint WindowHandle { get; }
/// <summary>X11 Display* when <see cref="Kind"/> is <see cref="VulkanHostSurfaceKind.Xlib"/>.</summary>
public nint DisplayHandle { get; }
/// <summary>CAMetalLayer* when <see cref="Kind"/> is <see cref="VulkanHostSurfaceKind.Metal"/>.</summary>
public nint MetalLayerHandle { get; }
public int PixelWidth => Volatile.Read(ref _pixelWidth);
public int PixelHeight => Volatile.Read(ref _pixelHeight);
internal int ResizeGeneration => Volatile.Read(ref _resizeGeneration);
public void UpdatePixelSize(int width, int height)
{
width = Math.Max(width, 1);
height = Math.Max(height, 1);
if (Volatile.Read(ref _pixelWidth) == width && Volatile.Read(ref _pixelHeight) == height)
{
return;
}
Volatile.Write(ref _pixelWidth, width);
Volatile.Write(ref _pixelHeight, height);
Interlocked.Increment(ref _resizeGeneration);
}
/// <summary>
/// The child emulator cannot receive Avalonia resize notifications. Poll
/// the native host at a bounded rate so embedded child swapchains still
/// follow normal resize and F11 transitions.
/// </summary>
internal void RefreshChildProcessPixelSize()
{
if (!_pollNativeSize)
{
return;
}
var now = System.Diagnostics.Stopwatch.GetTimestamp();
var due = Volatile.Read(ref _nextNativeSizePoll);
if (now < due || Interlocked.CompareExchange(
ref _nextNativeSizePoll,
now + (System.Diagnostics.Stopwatch.Frequency / 8),
due) != due)
{
return;
}
if (Kind == VulkanHostSurfaceKind.Win32 && GetClientRect(WindowHandle, out var rect))
{
UpdatePixelSize(rect.Right - rect.Left, rect.Bottom - rect.Top);
return;
}
if (Kind == VulkanHostSurfaceKind.Xlib && DisplayHandle != 0 &&
XGetGeometry(
DisplayHandle,
WindowHandle,
out _,
out _,
out _,
out var width,
out var height,
out _,
out _) != 0)
{
UpdatePixelSize(unchecked((int)width), unchecked((int)height));
}
}
/// <summary>
/// Serializes a native child handle for a separately hosted emulator
/// process. Metal object pointers are process-local, so macOS falls back
/// to a standalone child window until an IPC Metal host is implemented.
/// </summary>
public bool TryGetChildProcessDescriptor(out string descriptor)
{
descriptor = string.Empty;
if (Kind == VulkanHostSurfaceKind.Metal || WindowHandle == 0)
{
return false;
}
var kind = Kind == VulkanHostSurfaceKind.Win32 ? "win32" : "xlib";
descriptor = string.Create(
System.Globalization.CultureInfo.InvariantCulture,
$"{kind}:{unchecked((ulong)WindowHandle):X}:{Math.Max(PixelWidth, 1)}:{Math.Max(PixelHeight, 1)}:{unchecked((ulong)DisplayHandle):X}");
return true;
}
/// <summary>
/// Reconstructs a surface in the isolated emulator process. X11 clients
/// must open their own Display connection; Display* values cannot cross a
/// process boundary.
/// </summary>
public static bool TryCreateChildProcessSurface(
string descriptor,
out VulkanHostSurface? surface,
out string? error)
{
surface = null;
error = null;
var parts = descriptor.Split(':');
if (parts.Length != 5 ||
!ulong.TryParse(parts[1], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var window) ||
!int.TryParse(parts[2], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var width) ||
!int.TryParse(parts[3], System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var height) ||
!ulong.TryParse(parts[4], System.Globalization.NumberStyles.AllowHexSpecifier, System.Globalization.CultureInfo.InvariantCulture, out var nativeDisplay))
{
error = "invalid host-surface descriptor";
return false;
}
if (window == 0 || width <= 0 || height <= 0)
{
error = "host-surface descriptor has an invalid size or handle";
return false;
}
if (string.Equals(parts[0], "win32", StringComparison.OrdinalIgnoreCase))
{
surface = new VulkanHostSurface(
VulkanHostSurfaceKind.Win32,
unchecked((nint)window),
unchecked((nint)nativeDisplay),
pollNativeSize: true);
}
else if (string.Equals(parts[0], "xlib", StringComparison.OrdinalIgnoreCase))
{
var display = XOpenDisplay(0);
if (display == 0)
{
error = "could not open an X11 display for the host surface";
return false;
}
surface = new VulkanHostSurface(
VulkanHostSurfaceKind.Xlib,
unchecked((nint)window),
display,
ownsDisplay: true,
pollNativeSize: true);
}
else
{
error = $"unsupported host-surface kind '{parts[0]}'";
return false;
}
surface.UpdatePixelSize(width, height);
return true;
}
public void Dispose()
{
if (_ownsDisplay && DisplayHandle != 0 && OperatingSystem.IsLinux())
{
_ = XCloseDisplay(DisplayHandle);
}
}
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XOpenDisplay")]
private static extern nint XOpenDisplay(nint displayName);
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XCloseDisplay")]
private static extern int XCloseDisplay(nint display);
[System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetClientRect", SetLastError = true)]
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
private static extern bool GetClientRect(nint window, out Rect rect);
[System.Runtime.InteropServices.DllImport("libX11.so.6", EntryPoint = "XGetGeometry")]
private static extern int XGetGeometry(
nint display,
nint drawable,
out nint root,
out int x,
out int y,
out uint width,
out uint height,
out uint borderWidth,
out uint depth);
[System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
private struct Rect
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
}
/// <summary>
/// Small public bridge between a desktop UI and the internal Vulkan
/// presenter. Launchers can host a surface without depending on renderer
/// submission internals.
/// </summary>
public static class VulkanVideoHost
{
/// <summary>
/// Raised after the first successful Vulkan present to an embedded host
/// surface. UI hosts use this to retire their launch affordance only once
/// a real frame can be seen.
/// </summary>
public static event Action<VulkanHostSurface>? FirstFramePresented
{
add => VulkanVideoPresenter.FirstHostFramePresented += value;
remove => VulkanVideoPresenter.FirstHostFramePresented -= value;
}
public static bool TryAttachSurface(VulkanHostSurface surface) =>
VulkanVideoPresenter.TryAttachHostSurface(surface);
public static void DetachSurface(VulkanHostSurface surface) =>
VulkanVideoPresenter.DetachHostSurface(surface);
public static void RequestClose() => VulkanVideoPresenter.RequestClose();
public static bool IsEmbedded => VulkanVideoPresenter.UsesHostSurface;
}
File diff suppressed because it is too large Load Diff
@@ -948,6 +948,27 @@ public static partial class Gen5SpirvTranslator
vector);
break;
}
case "VPkAddF16":
case "VPkMulF16":
case "VPkMinF16":
case "VPkMaxF16":
if (!TryEmitPackedF16(instruction, out result, out error))
{
return false;
}
break;
case "VPkFmaF16":
// Deliberately loud: a fused f16 FMA rounds the product+add once,
// whereas doing the multiply-add in f32 and rounding to f16 at the
// end double-rounds. Concrete miss: fma(0x4100, 0x7522, 0x04EA) is
// 0x7A6B fused but 0x7A6A via f32. Exact emulation (round-to-odd
// f32 product then RNE pack) is a planned follow-up slice.
error =
$"unsupported vop3p opcode {instruction.Opcode} " +
"(fused f16 FMA requires single-rounding; deferred to a later slice)";
return false;
default:
error = $"unsupported vector opcode {instruction.Opcode}";
return false;
@@ -976,6 +997,213 @@ public static partial class Gen5SpirvTranslator
return true;
}
// Packed f16 (VOP3P) arithmetic. Each source register holds two f16 values,
// one per result lane. Every f16<->f32 conversion is done with the explicit
// integer sequences below (EmitHalfToFloat / EmitFloatToHalf) instead of
// GLSL UnpackHalf2x16 / PackHalf2x16, whose subnormal and rounding behaviour
// is implementation-defined without float-controls execution modes. The two
// lanes are computed independently: each operand half is widened exactly to
// f32, op_sel/op_sel_hi pick the source half and neg_lo/neg_hi negate it, the
// op runs in f32, and the result is rounded back to f16 with round-to-nearest-
// even. For add and mul this is bit-exact to a true f16 op (the f32 result
// rounds losslessly to f16 by the double-rounding theorem; a f16 product even
// fits in f32 exactly). min/max carry no rounding, so they are exact once the
// conversions are. v_pk_fma_f16 is intentionally not routed here because a
// fused f16 FMA cannot be reproduced by an f32 multiply-add plus a pack.
private bool TryEmitPackedF16(
Gen5ShaderInstruction instruction,
out uint result,
out string error)
{
result = 0;
error = string.Empty;
if (instruction.Control is not Gen5Vop3pControl control)
{
error = $"missing vop3p control for {instruction.Opcode}";
return false;
}
if (control.Clamp)
{
error = $"unsupported vop3p modifiers (clamp) for {instruction.Opcode}";
return false;
}
for (var index = 0; index < 2; index++)
{
var source = instruction.Sources[index];
if (source.Kind is not (Gen5OperandKind.VectorRegister or Gen5OperandKind.ScalarRegister))
{
error =
$"unsupported vop3p operand {source} for {instruction.Opcode} (first slice: registers only)";
return false;
}
}
var low = EmitPackedF16Lane(instruction, control, highLane: false);
var high = EmitPackedF16Lane(instruction, control, highLane: true);
result = BitwiseOr(low, ShiftLeftLogical(high, UInt(16)));
return true;
}
// Computes one result lane (low or high) as a packed 16-bit f16 value.
private uint EmitPackedF16Lane(
Gen5ShaderInstruction instruction,
Gen5Vop3pControl control,
bool highLane)
{
var left = EmitPackedF16Operand(instruction, control, 0, highLane);
var right = EmitPackedF16Operand(instruction, control, 1, highLane);
var value = instruction.Opcode switch
{
"VPkAddF16" => _module.AddInstruction(SpirvOp.FAdd, _floatType, left, right),
"VPkMulF16" => _module.AddInstruction(SpirvOp.FMul, _floatType, left, right),
"VPkMinF16" => EmitPackedF16MinMax(left, right, isMax: false),
"VPkMaxF16" => EmitPackedF16MinMax(left, right, isMax: true),
_ => left,
};
return EmitFloatToHalf(Bitcast(_uintType, value));
}
// Reads source `index`, selects the half feeding this lane (op_sel / op_sel_hi),
// widens it exactly to f32 and applies the lane's negate modifier (neg_lo / neg_hi).
private uint EmitPackedF16Operand(
Gen5ShaderInstruction instruction,
Gen5Vop3pControl control,
int index,
bool highLane)
{
var raw = GetRawSource(instruction, index);
var selectMask = highLane ? control.OpSelHiMask : control.OpSelMask;
var half = ((selectMask >> index) & 1) != 0
? ShiftRightLogical(raw, UInt(16))
: raw;
var value = Bitcast(_floatType, EmitHalfToFloat(half));
var negateMask = highLane ? control.NegHiMask : control.NegLoMask;
if (((negateMask >> index) & 1) != 0)
{
value = _module.AddInstruction(SpirvOp.FNegate, _floatType, value);
}
return value;
}
// fminnum_like / fmaxnum_like: if one operand is NaN return the other; if both
// are NaN return a NaN; otherwise the ordered smaller/larger. The ordering of
// -0/+0 is unspecified under these opcodes, so the ordered compare is enough.
private uint EmitPackedF16MinMax(uint left, uint right, bool isMax)
{
var compare = _module.AddInstruction(
isMax ? SpirvOp.FOrdGreaterThan : SpirvOp.FOrdLessThan,
_boolType,
left,
right);
var numeric = _module.AddInstruction(
SpirvOp.Select, _floatType, compare, left, right);
var leftNan = _module.AddInstruction(SpirvOp.IsNan, _boolType, left);
var rightNan = _module.AddInstruction(SpirvOp.IsNan, _boolType, right);
var withRight = _module.AddInstruction(
SpirvOp.Select, _floatType, rightNan, left, numeric);
return _module.AddInstruction(
SpirvOp.Select, _floatType, leftNan, right, withRight);
}
// Widens an f16 value held in the low 16 bits of `halfBits` to an f32 bit
// pattern, exactly (subnormals normalised, Inf/NaN and signed zero preserved).
// Mirrors the branchless HalfToFloat reference validated against System.Half.
private uint EmitHalfToFloat(uint halfBits)
{
var sign = ShiftLeftLogical(BitwiseAnd(halfBits, UInt(0x8000)), UInt(16));
var exponent = BitwiseAnd(ShiftRightLogical(halfBits, UInt(10)), UInt(0x1F));
var mantissa = BitwiseAnd(halfBits, UInt(0x3FF));
var normal = BitwiseOr(
ShiftLeftLogical(IAdd(exponent, UInt(112)), UInt(23)),
ShiftLeftLogical(mantissa, UInt(13)));
var infinityNan = BitwiseOr(UInt(0x7F80_0000), ShiftLeftLogical(mantissa, UInt(13)));
// Subnormal: normalise the mantissa. FindUMsb of (mantissa | 1) keeps the
// op defined when mantissa is 0; that lane is discarded by the select below.
var highBit = Ext(75, _uintType, BitwiseOr(mantissa, UInt(1)));
var shift = ISubU(UInt(23), highBit);
var subFraction = BitwiseAnd(ShiftLeftLogical(mantissa, shift), UInt(0x7F_FFFF));
var subnormal = SelectU(
IsNotZero(mantissa),
BitwiseOr(ShiftLeftLogical(IAdd(highBit, UInt(103)), UInt(23)), subFraction),
UInt(0));
var magnitude = SelectU(
Equal(exponent, 0),
subnormal,
SelectU(Equal(exponent, 31), infinityNan, normal));
return BitwiseOr(sign, magnitude);
}
// Narrows an f32 bit pattern to an f16 value in the low 16 bits, rounding to
// nearest even (subnormals, overflow-to-Inf and NaN/Inf handled). Mirrors the
// branchless FloatToHalf reference validated exhaustively against System.Half.
private uint EmitFloatToHalf(uint bits)
{
var sign = BitwiseAnd(ShiftRightLogical(bits, UInt(16)), UInt(0x8000));
var absolute = BitwiseAnd(bits, UInt(0x7FFF_FFFF));
var isInfinityNan = UCmp(SpirvOp.UGreaterThanEqual, absolute, UInt(0x7F80_0000));
var isNan = UCmp(SpirvOp.UGreaterThan, absolute, UInt(0x7F80_0000));
var infinityNan = BitwiseOr(
BitwiseOr(sign, UInt(0x7C00)),
SelectU(isNan, UInt(0x200), UInt(0)));
var exponent = ShiftRightLogical(absolute, UInt(23));
var mantissa = BitwiseAnd(absolute, UInt(0x7F_FFFF));
var significand = BitwiseOr(mantissa, UInt(0x80_0000));
// Normal path: round the 24-bit significand down to 11 bits (>> 13) with
// round-to-nearest-even; the carry folds naturally into the exponent.
var roundBit = BitwiseAnd(ShiftRightLogical(significand, UInt(13)), UInt(1));
var rounded = ShiftRightLogical(IAdd(IAdd(significand, UInt(0xFFF)), roundBit), UInt(13));
var halfExponent = ISubU(exponent, UInt(112));
var normalBits = IAdd(ShiftLeftLogical(halfExponent, UInt(10)), ISubU(rounded, UInt(0x400)));
var normal = SelectU(
UCmp(SpirvOp.UGreaterThanEqual, exponent, UInt(113)),
SelectU(UCmp(SpirvOp.UGreaterThanEqual, normalBits, UInt(0x7C00)), UInt(0x7C00), normalBits),
UInt(0));
// Subnormal path: value = round(significand >> (126 - exponent)) with RNE.
// The shift is clamped to 25 so it stays defined; on this path it is >= 14.
var distance = ISubU(UInt(126), exponent);
var shift = SelectU(UCmp(SpirvOp.UGreaterThan, distance, UInt(25)), UInt(25), distance);
var shiftMask = ISubU(ShiftLeftLogical(UInt(1), shift), UInt(1));
var halfWay = ShiftLeftLogical(UInt(1), ISubU(shift, UInt(1)));
var lowBits = BitwiseAnd(significand, shiftMask);
var quotient = ShiftRightLogical(significand, shift);
var roundUp = _module.AddInstruction(
SpirvOp.LogicalOr,
_boolType,
UCmp(SpirvOp.UGreaterThan, lowBits, halfWay),
_module.AddInstruction(
SpirvOp.LogicalAnd,
_boolType,
Equal(lowBits, halfWay),
IsNotZero(BitwiseAnd(quotient, UInt(1)))));
var subnormal = IAdd(quotient, SelectU(roundUp, UInt(1), UInt(0)));
var isSubnormal = UCmp(SpirvOp.ULessThanEqual, exponent, UInt(112));
var finite = SelectU(isSubnormal, subnormal, normal);
return SelectU(isInfinityNan, infinityNan, BitwiseOr(sign, finite));
}
private uint SelectU(uint condition, uint whenTrue, uint whenFalse) =>
_module.AddInstruction(SpirvOp.Select, _uintType, condition, whenTrue, whenFalse);
private uint UCmp(SpirvOp operation, uint left, uint right) =>
_module.AddInstruction(operation, _boolType, left, right);
private uint Equal(uint value, uint constant) =>
_module.AddInstruction(SpirvOp.IEqual, _boolType, value, UInt(constant));
private uint ISubU(uint left, uint right) =>
_module.AddInstruction(SpirvOp.ISub, _uintType, left, right);
private bool TryEmitVectorCompare(
Gen5ShaderInstruction instruction,
out string error)
@@ -1802,24 +1802,6 @@ public static partial class Gen5SpirvTranslator
switch (instruction.Opcode)
{
case "DsAddU32":
{
if (instruction.Sources.Count < 2)
{
error = "missing LDS atomic-add source";
return false;
}
var address = GetRawSource(instruction, 0);
_module.AddInstruction(
SpirvOp.AtomicIAdd,
_uintType,
LdsPointer(address, control.Offset0),
UInt(2), // Workgroup scope.
UInt(0x108), // AcquireRelease | WorkgroupMemory.
GetRawSource(instruction, 1));
return true;
}
case "DsWriteB32":
{
if (instruction.Sources.Count < 2)
@@ -1964,6 +1946,11 @@ public static partial class Gen5SpirvTranslator
return true;
}
default:
if (Gen5ShaderTranslator.IsDataShareAtomic(instruction.Opcode))
{
return TryEmitDataShareAtomic(instruction, control, out error);
}
error = $"unsupported LDS opcode {instruction.Opcode}";
return false;
}
@@ -2004,6 +1991,126 @@ public static partial class Gen5SpirvTranslator
Store(pointer, selected);
}
private bool TryEmitDataShareAtomic(
Gen5ShaderInstruction instruction,
Gen5DataShareControl control,
out string error)
{
error = string.Empty;
var atomicOp = instruction.Opcode switch
{
"DsAddU32" or "DsAddRtnU32" => SpirvOp.AtomicIAdd,
"DsSubU32" or "DsSubRtnU32" => SpirvOp.AtomicISub,
"DsIncU32" or "DsIncRtnU32" => SpirvOp.AtomicIIncrement,
"DsDecU32" or "DsDecRtnU32" => SpirvOp.AtomicIDecrement,
"DsMinI32" or "DsMinRtnI32" => SpirvOp.AtomicSMin,
"DsMaxI32" or "DsMaxRtnI32" => SpirvOp.AtomicSMax,
"DsMinU32" or "DsMinRtnU32" => SpirvOp.AtomicUMin,
"DsMaxU32" or "DsMaxRtnU32" => SpirvOp.AtomicUMax,
"DsAndB32" or "DsAndRtnB32" => SpirvOp.AtomicAnd,
"DsOrB32" or "DsOrRtnB32" => SpirvOp.AtomicOr,
"DsXorB32" or "DsXorRtnB32" => SpirvOp.AtomicXor,
"DsWrxchgRtnB32" => SpirvOp.AtomicExchange,
"DsCmpstB32" or "DsCmpstRtnB32" => SpirvOp.AtomicCompareExchange,
_ => SpirvOp.Nop,
};
if (atomicOp == SpirvOp.Nop)
{
error = $"unsupported LDS opcode {instruction.Opcode}";
return false;
}
var address = GetRawSource(instruction, 0);
var pointer = LdsPointer(address, control.Offset0);
EmitExecConditional(() =>
{
var original = EmitAtomic(
atomicOp,
_uintType,
pointer,
scope: 2,
semantics: 0x108,
// DS_CMPST sources: DATA0 is the comparator, DATA1 the new value.
value: () => GetRawSource(
instruction,
atomicOp == SpirvOp.AtomicCompareExchange ? 2 : 1),
comparator: () => GetRawSource(instruction, 1));
if (instruction.Destinations.Count > 0)
{
StoreV(instruction.Destinations[0].Value, original);
}
});
return true;
}
// Maps the AMD atomic-op name suffix shared by buffer/image atomics to a SPIR-V opcode.
// Inc/Dec approximate the AMD wrap-clamp semantics (MEM = tmp >= DATA ? 0 : tmp + 1),
// which is exact for the common 0xFFFFFFFF clamp operand.
private static bool TryGetAtomicOp(string name, out SpirvOp op)
{
op = name switch
{
"Swap" => SpirvOp.AtomicExchange,
"Cmpswap" => SpirvOp.AtomicCompareExchange,
"Add" => SpirvOp.AtomicIAdd,
"Sub" => SpirvOp.AtomicISub,
"Smin" => SpirvOp.AtomicSMin,
"Umin" => SpirvOp.AtomicUMin,
"Smax" => SpirvOp.AtomicSMax,
"Umax" => SpirvOp.AtomicUMax,
"And" => SpirvOp.AtomicAnd,
"Or" => SpirvOp.AtomicOr,
"Xor" => SpirvOp.AtomicXor,
"Inc" => SpirvOp.AtomicIIncrement,
"Dec" => SpirvOp.AtomicIDecrement,
_ => SpirvOp.Nop,
};
return op != SpirvOp.Nop;
}
private uint EmitAtomic(
SpirvOp op,
uint type,
uint pointer,
uint scope,
uint semantics,
Func<uint> value,
Func<uint> comparator)
{
if (op is SpirvOp.AtomicIIncrement or SpirvOp.AtomicIDecrement)
{
return _module.AddInstruction(
op,
type,
pointer,
UInt(scope),
UInt(semantics));
}
if (op == SpirvOp.AtomicCompareExchange)
{
// The unequal semantics must not contain Release; downgrade it to Acquire.
return _module.AddInstruction(
op,
type,
pointer,
UInt(scope),
UInt(semantics),
UInt((semantics & ~0x8u) | 0x2u),
value(),
comparator());
}
return _module.AddInstruction(
op,
type,
pointer,
UInt(scope),
UInt(semantics),
value());
}
private bool TryEmitInterpolation(
Gen5ShaderInstruction instruction,
Gen5InterpolationControl interpolation,
@@ -2239,22 +2346,27 @@ public static partial class Gen5SpirvTranslator
byteAddress = ApplyGuestBufferByteBias(bindingIndex, byteAddress);
var dwordAddress = ShiftRightLogical(byteAddress, UInt(2));
if (instruction.Opcode is "BufferAtomicAdd" or "BufferAtomicUMax")
if (instruction.Opcode.StartsWith("BufferAtomic", StringComparison.Ordinal))
{
if (!TryGetAtomicOp(instruction.Opcode["BufferAtomic".Length..], out var atomicOp))
{
error = $"unsupported buffer opcode {instruction.Opcode}";
return false;
}
EmitExecConditional(() =>
{
var inRange = IsBufferWordInRange(bindingIndex, dwordAddress);
EmitConditional(inRange, () =>
{
var original = _module.AddInstruction(
instruction.Opcode == "BufferAtomicAdd"
? SpirvOp.AtomicIAdd
: SpirvOp.AtomicUMax,
var original = EmitAtomic(
atomicOp,
_uintType,
BufferWordPointer(bindingIndex, dwordAddress),
UInt(1),
UInt(0x48),
LoadV(control.VectorData));
scope: 1,
semantics: 0x48,
value: () => LoadV(control.VectorData),
comparator: () => LoadV(control.VectorData + 1));
if (control.Glc)
{
StoreV(control.VectorData, original);
@@ -3196,6 +3308,60 @@ public static partial class Gen5SpirvTranslator
return true;
}
if (instruction.Opcode.StartsWith("ImageAtomic", StringComparison.Ordinal))
{
if (!resource.IsStorage)
{
error = "image atomic is not bound as storage";
return false;
}
if (resource.ComponentKind == ImageComponentKind.Float ||
!TryGetAtomicOp(instruction.Opcode["ImageAtomic".Length..], out var atomicOp))
{
error = $"unsupported storage image opcode {instruction.Opcode}";
return false;
}
var signed = resource.ComponentKind == ImageComponentKind.Sint;
var atomicImageSize = _module.AddInstruction(
SpirvOp.ImageQuerySize,
_module.TypeVector(_intType, 2),
imageObject);
var coordinates = BuildClampedIntegerCoordinates(
image,
0,
atomicImageSize);
EmitExecConditional(() =>
{
var pointer = _module.AddInstruction(
SpirvOp.ImageTexelPointer,
_module.TypePointer(SpirvStorageClass.Image, resource.ComponentType),
resource.Variable,
coordinates,
UInt(0));
uint LoadData(uint register) => signed
? Bitcast(_intType, LoadV(register))
: LoadV(register);
var original = EmitAtomic(
atomicOp,
resource.ComponentType,
pointer,
scope: 1,
semantics: 0x808,
value: () => LoadData(image.VectorData),
comparator: () => LoadData(image.VectorData + 1));
if (image.Glc)
{
StoreV(
image.VectorData,
signed ? Bitcast(_uintType, original) : original);
}
});
return true;
}
if (resource.IsStorage &&
instruction.Opcode is not ("ImageLoad" or "ImageLoadMip"))
{
@@ -40,6 +40,7 @@ public enum SpirvOp : ushort
FunctionEnd = 56,
FunctionCall = 57,
Variable = 59,
ImageTexelPointer = 60,
Load = 61,
Store = 62,
AccessChain = 65,
@@ -142,8 +143,19 @@ public enum SpirvOp : ushort
BitCount = 205,
ControlBarrier = 224,
MemoryBarrier = 225,
AtomicExchange = 229,
AtomicCompareExchange = 230,
AtomicIIncrement = 232,
AtomicIDecrement = 233,
AtomicIAdd = 234,
AtomicISub = 235,
AtomicSMin = 236,
AtomicUMin = 237,
AtomicSMax = 238,
AtomicUMax = 239,
AtomicAnd = 240,
AtomicOr = 241,
AtomicXor = 242,
Phi = 245,
LoopMerge = 246,
SelectionMerge = 247,
@@ -237,6 +237,17 @@ public sealed record Gen5SdwaControl(
bool Clamp,
uint? ScalarDestination) : Gen5InstructionControl;
// Packed (VOP3P) source and destination modifiers. Each mask holds one bit per
// source operand. OpSel/OpSelHi pick which 16-bit half of a source feeds the low
// and high result lanes respectively; NegLo/NegHi negate the value routed to each
// lane. Clamp saturates each output half to [0, 1].
public sealed record Gen5Vop3pControl(
uint OpSelMask,
uint OpSelHiMask,
uint NegLoMask,
uint NegHiMask,
bool Clamp) : Gen5InstructionControl;
public sealed record Gen5DppControl(
uint Control,
bool FetchInactive,
@@ -325,9 +336,31 @@ public sealed record Gen5ShaderProgram(
ulong Address,
IReadOnlyList<Gen5ShaderInstruction> Instructions)
{
private const uint PixelColorTargetCount = 8;
private const int PixelColorMaskBits = 4;
private readonly uint _pixelColorExportMasks = ComputePixelColorExportMasks(Instructions);
private const int ScalarRegisterCount = 256;
private IReadOnlySet<uint>? _runtimeScalarRegisters;
public uint PixelColorExportMasks => _pixelColorExportMasks;
private static uint ComputePixelColorExportMasks(
IReadOnlyList<Gen5ShaderInstruction> instructions)
{
var masks = 0u;
foreach (var instruction in instructions)
{
if (instruction.Control is Gen5ExportControl export &&
export.Target < PixelColorTargetCount)
{
masks |= (export.EnableMask & 0xFu) <<
(int)(export.Target * PixelColorMaskBits);
}
}
return masks;
}
public IEnumerable<Gen5ImageControl> ImageResources =>
Instructions
.Select(instruction => instruction.Control)
@@ -562,6 +562,22 @@ public static class Gen5ShaderTranslator
return DecodeSop(word, out name, out sizeDwords, out error);
}
// gfx10 moved VOP3P (packed 16-bit math) to its own 0b110011000 prefix
// (word0 top byte 0xCC), separate from the VOP3 block. Match the full
// 9-bit prefix here, before the coarse major-opcode switch, so packed
// instructions are not misread as one of the neighbouring encodings.
if ((word & 0xFF800000u) == 0xCC000000u)
{
encoding = Gen5ShaderEncoding.Vop3p;
if (!ctx.TryReadUInt32(baseAddress + pc + sizeof(uint), out var vop3pExtra))
{
error = $"vop3p-extra-read-failed pc=0x{pc:X}";
return false;
}
return DecodeVop3p(word, vop3pExtra, out name, out sizeDwords, out error);
}
switch (word >> 26)
{
case 0x33:
@@ -1160,6 +1176,37 @@ public static class Gen5ShaderTranslator
return true;
}
private static bool DecodeVop3p(
uint word,
uint extra,
out string name,
out uint sizeDwords,
out string error)
{
var opcode = (word >> 16) & 0x7F;
var src0 = extra & 0x1FF;
var src1 = (extra >> 9) & 0x1FF;
var src2 = (extra >> 18) & 0x1FF;
sizeDwords = src0 == 0xFF || src1 == 0xFF || src2 == 0xFF ? 3u : 2u;
error = string.Empty;
// Opcode numbers taken from LLVM's AMDGPU VOP3PInstructions.td and the
// gfx9/gfx10 MC test encodings; they are unchanged across gfx9 and gfx10.
// Unhandled packed opcodes (integer, fma_mix, ...) stay opaque here and
// fail loudly at emission rather than being silently mis-emitted.
name = opcode switch
{
0x0E => "VPkFmaF16",
0x0F => "VPkAddF16",
0x10 => "VPkMulF16",
0x11 => "VPkMinF16",
0x12 => "VPkMaxF16",
_ => $"Vop3pRaw{opcode:X2}",
};
return true;
}
private static bool DecodeDs(
uint word,
out string name,
@@ -1172,9 +1219,33 @@ public static class Gen5ShaderTranslator
name = opcode switch
{
0x00 => "DsAddU32",
0x01 => "DsSubU32",
0x03 => "DsIncU32",
0x04 => "DsDecU32",
0x05 => "DsMinI32",
0x06 => "DsMaxI32",
0x07 => "DsMinU32",
0x08 => "DsMaxU32",
0x09 => "DsAndB32",
0x0A => "DsOrB32",
0x0B => "DsXorB32",
0x0D => "DsWriteB32",
0x0E => "DsWrite2B32",
0x0F => "DsWrite2St64B32",
0x10 => "DsCmpstB32",
0x20 => "DsAddRtnU32",
0x21 => "DsSubRtnU32",
0x23 => "DsIncRtnU32",
0x24 => "DsDecRtnU32",
0x25 => "DsMinRtnI32",
0x26 => "DsMaxRtnI32",
0x27 => "DsMinRtnU32",
0x28 => "DsMaxRtnU32",
0x29 => "DsAndRtnB32",
0x2A => "DsOrRtnB32",
0x2B => "DsXorRtnB32",
0x2D => "DsWrxchgRtnB32",
0x30 => "DsCmpstRtnB32",
0x35 => "DsSwizzleB32",
0x36 => "DsReadB32",
0x37 => "DsRead2B32",
@@ -1272,8 +1343,19 @@ public static class Gen5ShaderTranslator
0x23 => "BufferLoadSbyteD16Hi",
0x24 => "BufferLoadShortD16",
0x25 => "BufferLoadShortD16Hi",
0x30 => "BufferAtomicSwap",
0x31 => "BufferAtomicCmpswap",
0x32 => "BufferAtomicAdd",
0x38 => "BufferAtomicUMax",
0x33 => "BufferAtomicSub",
0x35 => "BufferAtomicSmin",
0x36 => "BufferAtomicUmin",
0x37 => "BufferAtomicSmax",
0x38 => "BufferAtomicUmax",
0x39 => "BufferAtomicAnd",
0x3A => "BufferAtomicOr",
0x3B => "BufferAtomicXor",
0x3C => "BufferAtomicInc",
0x3D => "BufferAtomicDec",
_ => $"MubufRaw{opcode:X2}",
};
sizeDwords = (extra >> 24) == 0xFF ? 3u : 2u;
@@ -1388,7 +1470,18 @@ public static class Gen5ShaderTranslator
0x08 => "ImageStore",
0x09 => "ImageStoreMip",
0x0E => "ImageGetResinfo",
0x0F => "ImageAtomicSwap",
0x10 => "ImageAtomicCmpswap",
0x11 => "ImageAtomicAdd",
0x12 => "ImageAtomicSub",
0x14 => "ImageAtomicSmin",
0x15 => "ImageAtomicUmin",
0x16 => "ImageAtomicSmax",
0x17 => "ImageAtomicUmax",
0x18 => "ImageAtomicAnd",
0x19 => "ImageAtomicOr",
0x1A => "ImageAtomicXor",
0x1B => "ImageAtomicInc",
0x1C => "ImageAtomicDec",
0x20 => "ImageSample",
0x22 => "ImageSampleD",
@@ -1488,6 +1581,18 @@ public static class Gen5ShaderTranslator
name.StartsWith("ImageStore", StringComparison.Ordinal) ||
name.StartsWith("ImageAtomic", StringComparison.Ordinal);
public static bool IsDataShareAtomic(string name) => name switch
{
"DsAddU32" or "DsSubU32" or "DsIncU32" or "DsDecU32" or
"DsMinI32" or "DsMaxI32" or "DsMinU32" or "DsMaxU32" or
"DsAndB32" or "DsOrB32" or "DsXorB32" or "DsCmpstB32" or
"DsAddRtnU32" or "DsSubRtnU32" or "DsIncRtnU32" or "DsDecRtnU32" or
"DsMinRtnI32" or "DsMaxRtnI32" or "DsMinRtnU32" or "DsMaxRtnU32" or
"DsAndRtnB32" or "DsOrRtnB32" or "DsXorRtnB32" or
"DsWrxchgRtnB32" or "DsCmpstRtnB32" => true,
_ => false,
};
private static Gen5ShaderInstruction CreateInstruction(
uint pc,
Gen5ShaderEncoding encoding,
@@ -1767,9 +1872,9 @@ public static class Gen5ShaderTranslator
destinations = [Gen5Operand.Vector(word & 0xFF)];
if (opcode == "VReadlaneB32")
{
// VReadlaneB32 writes to scalar destination (bits 8-14), not vector.
// Bits 0-7 are unused for this opcode.
destinations = [Gen5Operand.Scalar((word >> 8) & 0x7F)];
// The scalar destination lives in the low vdst byte (bits 0-7);
// bits 8-14 are the VOP3B carry-out sdst, which readlane lacks.
destinations = [Gen5Operand.Scalar(word & 0xFF)];
}
var isVop3B = IsVop3BOpcode((word >> 16) & 0x3FF);
control = new Gen5Vop3Control(
@@ -1781,6 +1886,28 @@ public static class Gen5ShaderTranslator
isVop3B ? (word >> 8) & 0x7F : null);
break;
}
case Gen5ShaderEncoding.Vop3p:
{
var extra = words[1];
sources =
[
Gen5Operand.Source(extra & 0x1FF, literal),
Gen5Operand.Source((extra >> 9) & 0x1FF, literal),
Gen5Operand.Source((extra >> 18) & 0x1FF, literal),
];
destinations = [Gen5Operand.Vector(word & 0xFF)];
// op_sel_hi is split across both dwords: bits [1:0] live in word1
// [28:27], bit [2] in word0 [14].
var opSelHi = ((extra >> 27) & 0x3) | (((word >> 14) & 0x1) << 2);
control = new Gen5Vop3pControl(
(word >> 11) & 0x7,
opSelHi,
(extra >> 29) & 0x7,
(word >> 8) & 0x7,
((word >> 15) & 1) != 0);
break;
}
case Gen5ShaderEncoding.Ds:
{
var extra = words[1];
@@ -1794,10 +1921,6 @@ public static class Gen5ShaderTranslator
((word >> 17) & 1) != 0);
sources = opcode switch
{
"DsAddU32" => [
Gen5Operand.Vector(vectorAddress),
Gen5Operand.Vector(vectorData0),
],
"DsWriteB32" => [
Gen5Operand.Vector(vectorAddress),
Gen5Operand.Vector(vectorData0),
@@ -1826,6 +1949,17 @@ public static class Gen5ShaderTranslator
Gen5Operand.Vector(vectorData1),
],
"DsSwizzleB32" => [Gen5Operand.Vector(vectorData0)],
// DS_CMPST operand order is reversed vs buffer/image cmpswap:
// DATA0 holds the comparator, DATA1 holds the new value.
"DsCmpstB32" or "DsCmpstRtnB32" => [
Gen5Operand.Vector(vectorAddress),
Gen5Operand.Vector(vectorData0),
Gen5Operand.Vector(vectorData1),
],
_ when IsDataShareAtomic(opcode) => [
Gen5Operand.Vector(vectorAddress),
Gen5Operand.Vector(vectorData0),
],
_ => [Gen5Operand.Vector(vectorAddress)],
};
destinations = opcode switch
@@ -1848,6 +1982,10 @@ public static class Gen5ShaderTranslator
Gen5Operand.Vector(vectorDestination + 2),
Gen5Operand.Vector(vectorDestination + 3),
],
_ when IsDataShareAtomic(opcode) &&
opcode.Contains("Rtn", StringComparison.Ordinal) => [
Gen5Operand.Vector(vectorDestination),
],
_ => [],
};
break;
@@ -1953,8 +2091,8 @@ public static class Gen5ShaderTranslator
"BufferStoreDwordx2" => 2u,
"BufferStoreDwordx3" => 3u,
"BufferStoreDwordx4" => 4u,
"BufferAtomicAdd" => 1u,
"BufferAtomicUMax" => 1u,
"BufferAtomicCmpswap" => 2u,
_ when opcode.StartsWith("BufferAtomic", StringComparison.Ordinal) => 1u,
_ => 0u,
};
sources =
@@ -0,0 +1,134 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.ShaderCompiler;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
// Decodes synthetic GFX10 atomic instructions and checks the opcode names and operand wiring
// that the SPIR-V translator relies on. Word layouts follow the RDNA2 ISA manual:
// MUBUF op=word0[24:18], MIMG op=word0[24:18], DS op=word0[25:18].
public sealed class Gen5ShaderAtomicDecodeTests
{
private const ulong ShaderAddress = 0x1_0000_0000;
private const uint EndPgm = 0xBF810000;
// Compute-stage register block: COMPUTE_USER_DATA_0 and COMPUTE_PGM_RSRC2,
// required since TryCreateState validates the USER_SGPR count.
internal const uint ComputeUserDataRegister = 0x240;
internal const uint ComputePgmRsrc2Register = 0x213;
[Fact]
public void BufferAtomicUmax_DecodesControlAndDestination()
{
// BUFFER_ATOMIC_UMAX v1, off, s[0:3], 128 offset:8 glc
var instruction = DecodeSingle(0xE0E04008, 0x80000100);
Assert.Equal("BufferAtomicUmax", instruction.Opcode);
var control = Assert.IsType<Gen5BufferMemoryControl>(instruction.Control);
Assert.Equal(1u, control.DwordCount);
Assert.Equal(1u, control.VectorData);
Assert.Equal(0u, control.ScalarResource);
Assert.Equal(8, control.OffsetBytes);
Assert.True(control.Glc);
Assert.Equal(new[] { Gen5Operand.Vector(1) }, instruction.Destinations);
}
[Fact]
public void BufferAtomicCmpswap_UsesTwoDataRegisters()
{
// BUFFER_ATOMIC_CMPSWAP v[1:2], off, s[0:3], 128 glc
var instruction = DecodeSingle(0xE0C44000, 0x80000100);
Assert.Equal("BufferAtomicCmpswap", instruction.Opcode);
var control = Assert.IsType<Gen5BufferMemoryControl>(instruction.Control);
Assert.Equal(2u, control.DwordCount);
Assert.Equal(1u, control.VectorData);
}
[Fact]
public void ImageAtomicAdd_KeepsDataRegisterAsDestination()
{
// IMAGE_ATOMIC_ADD v2, v[0:1], s[4:11] dmask:0x1 dim:2D glc
var instruction = DecodeSingle(0xF0442100, 0x00010200);
Assert.Equal("ImageAtomicAdd", instruction.Opcode);
var control = Assert.IsType<Gen5ImageControl>(instruction.Control);
Assert.Equal(2u, control.VectorData);
Assert.Equal(4u, control.ScalarResource);
Assert.True(control.Glc);
Assert.Equal(new[] { Gen5Operand.Vector(2) }, instruction.Destinations);
}
[Fact]
public void DsAddU32_HasAddressAndDataSourcesButNoDestination()
{
// DS_ADD_U32 v0, v1
var instruction = DecodeSingle(0xD8000000, 0x00000100);
Assert.Equal("DsAddU32", instruction.Opcode);
Assert.Equal(
new[] { Gen5Operand.Vector(0), Gen5Operand.Vector(1) },
instruction.Sources);
Assert.Empty(instruction.Destinations);
}
[Fact]
public void DsAddRtnU32_WritesReturnRegister()
{
// DS_ADD_RTN_U32 v3, v0, v1
var instruction = DecodeSingle(0xD8800000, 0x03000100);
Assert.Equal("DsAddRtnU32", instruction.Opcode);
Assert.Equal(
new[] { Gen5Operand.Vector(0), Gen5Operand.Vector(1) },
instruction.Sources);
Assert.Equal(new[] { Gen5Operand.Vector(3) }, instruction.Destinations);
}
[Fact]
public void DsCmpstRtnB32_OrdersComparatorBeforeNewValue()
{
// DS_CMPST_RTN_B32 v3, v0, v1, v2 - DATA0 (v1) is the comparator, DATA1 (v2) the
// new value, reversed relative to buffer/image cmpswap.
var instruction = DecodeSingle(0xD8C00000, 0x03020100);
Assert.Equal("DsCmpstRtnB32", instruction.Opcode);
Assert.Equal(
new[] { Gen5Operand.Vector(0), Gen5Operand.Vector(1), Gen5Operand.Vector(2) },
instruction.Sources);
Assert.Equal(new[] { Gen5Operand.Vector(3) }, instruction.Destinations);
}
private static Gen5ShaderInstruction DecodeSingle(params uint[] words)
{
var memory = new FakeCpuMemory(ShaderAddress, 0x1000);
var ctx = new CpuContext(memory, Generation.Gen5);
WriteProgram(memory, ShaderAddress, words);
Assert.True(
Gen5ShaderTranslator.TryCreateState(
ctx,
ShaderAddress,
0,
new Dictionary<uint, uint> { [ComputePgmRsrc2Register] = 0 },
ComputeUserDataRegister,
out var state,
out var error),
error);
return state.Program.Instructions[0];
}
internal static void WriteProgram(FakeCpuMemory memory, ulong address, uint[] words)
{
Span<byte> buffer = stackalloc byte[4];
foreach (var word in words.Append(EndPgm))
{
BinaryPrimitives.WriteUInt32LittleEndian(buffer, word);
Assert.True(memory.TryWrite(address, buffer));
address += sizeof(uint);
}
}
}
@@ -0,0 +1,165 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.ShaderCompiler;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
public sealed class Gen5ShaderDecoderBoundaryTests
{
private const ulong ShaderAddress = 0x1_0000_0000;
private const uint Export = 0xF8000000;
private const uint Nop = 0xBF800000;
private const int MaximumInstructionCount = 4096;
[Fact]
public void MissingAddress_IsRejectedWithoutReadingGuestMemory()
{
var memory = new RecordingCpuMemory(ShaderAddress, []);
var decoded = Decode(memory, 0, out var program, out var error);
Assert.False(decoded);
Assert.Empty(program.Instructions);
Assert.Equal("missing", error);
Assert.Empty(memory.Reads);
}
[Fact]
public void UnreadableFirstWord_IsRejectedAtProgramStart()
{
var memory = new RecordingCpuMemory(ShaderAddress, []);
var decoded = Decode(memory, ShaderAddress, out var program, out var error);
Assert.False(decoded);
Assert.Empty(program.Instructions);
Assert.Equal("read-failed pc=0x0", error);
Assert.Collection(
memory.Reads,
read => Assert.Equal(new MemoryRead(ShaderAddress, sizeof(uint), false), read));
}
[Fact]
public void TruncatedTwoDwordInstruction_IsRejectedAtMissingWord()
{
var memory = RecordingCpuMemory.FromWords(ShaderAddress, Export);
var decoded = Decode(memory, ShaderAddress, out var program, out var error);
Assert.False(decoded);
Assert.Empty(program.Instructions);
Assert.Equal("read-failed pc=0x4", error);
Assert.Collection(
memory.Reads,
read => Assert.Equal(new MemoryRead(ShaderAddress, sizeof(uint), true), read),
read => Assert.Equal(
new MemoryRead(ShaderAddress + sizeof(uint), sizeof(uint), false),
read));
}
[Fact]
public void UnknownTopLevelEncoding_IsRejectedWithoutSpeculativeReads()
{
const uint unknownTopLevel = 0xE4000000;
var memory = RecordingCpuMemory.FromWords(ShaderAddress, unknownTopLevel);
var decoded = Decode(memory, ShaderAddress, out var program, out var error);
Assert.False(decoded);
Assert.Empty(program.Instructions);
Assert.Equal("unknown-top pc=0x0 word=0xE4000000", error);
Assert.Collection(
memory.Reads,
read => Assert.Equal(new MemoryRead(ShaderAddress, sizeof(uint), true), read));
}
[Fact]
public void MaximumInstructionCountWithoutEndPgm_IsRejectedAsUnterminated()
{
var words = new uint[MaximumInstructionCount];
Array.Fill(words, Nop);
var memory = RecordingCpuMemory.FromWords(ShaderAddress, words);
var decoded = Decode(memory, ShaderAddress, out var program, out var error);
Assert.False(decoded);
Assert.Empty(program.Instructions);
Assert.Equal("unterminated", error);
Assert.Equal(MaximumInstructionCount, memory.Reads.Count);
Assert.All(memory.Reads, read => Assert.True(read.Succeeded));
Assert.Equal(
new MemoryRead(
ShaderAddress + ((MaximumInstructionCount - 1) * sizeof(uint)),
sizeof(uint),
true),
memory.Reads[^1]);
}
private static bool Decode(
RecordingCpuMemory memory,
ulong address,
out Gen5ShaderProgram program,
out string error) =>
Gen5ShaderTranslator.TryDecodeProgram(
new CpuContext(memory, Generation.Gen5),
address,
out program,
out error);
private readonly record struct MemoryRead(ulong Address, int Length, bool Succeeded);
private sealed class RecordingCpuMemory(ulong baseAddress, byte[] storage) : ICpuMemory
{
public List<MemoryRead> Reads { get; } = [];
public static RecordingCpuMemory FromWords(ulong baseAddress, params uint[] words)
{
var storage = new byte[words.Length * sizeof(uint)];
for (var index = 0; index < words.Length; index++)
{
BinaryPrimitives.WriteUInt32LittleEndian(
storage.AsSpan(index * sizeof(uint), sizeof(uint)),
words[index]);
}
return new RecordingCpuMemory(baseAddress, storage);
}
public bool TryRead(ulong virtualAddress, Span<byte> destination)
{
var succeeded = TryResolve(virtualAddress, destination.Length, out var offset);
Reads.Add(new MemoryRead(virtualAddress, destination.Length, succeeded));
if (succeeded)
{
storage.AsSpan(offset, destination.Length).CopyTo(destination);
}
return succeeded;
}
public bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source) => false;
private bool TryResolve(ulong virtualAddress, int length, out int offset)
{
offset = 0;
if (virtualAddress < baseAddress)
{
return false;
}
var relative = virtualAddress - baseAddress;
if (relative > (ulong)storage.Length ||
(ulong)length > (ulong)storage.Length - relative)
{
return false;
}
offset = (int)relative;
return true;
}
}
}
@@ -0,0 +1,41 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.ShaderCompiler;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
public sealed class Gen5ShaderProgramTests
{
[Fact]
public void PixelColorExportMasksPackAllColorTargets()
{
var program = new Gen5ShaderProgram(
0,
[
Export(0, 0x1),
Export(0, 0x4),
Export(1, 0x2),
Export(2, 0x3),
Export(3, 0x4),
Export(4, 0x5),
Export(5, 0x6),
Export(6, 0x7),
Export(7, 0x8),
Export(8, 0xF),
]);
Assert.Equal(0x8765_4325u, program.PixelColorExportMasks);
Assert.Equal(0x8765_4325u, program.PixelColorExportMasks);
}
private static Gen5ShaderInstruction Export(uint target, uint enableMask) => new(
0,
Gen5ShaderEncoding.Exp,
"exp",
[],
[],
[],
new Gen5ExportControl(target, enableMask, false, false, false));
}
@@ -0,0 +1,137 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Vulkan;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
// End-to-end pipeline tests: synthetic GFX10 program -> decode -> scalar evaluation -> SPIR-V.
// Each test asserts the expected OpAtomic* instructions land in the emitted module.
public sealed class Gen5SpirvAtomicTranslationTests
{
private const ulong ShaderAddress = 0x1_0000_0000;
private const ulong BufferAddress = 0x1_0000_1000;
[Fact]
public void BufferAtomics_EmitAtomicOpcodes()
{
// BUFFER_ATOMIC_UMAX v1, BUFFER_ATOMIC_CMPSWAP v[1:2], BUFFER_ATOMIC_INC v1,
// all against the V# in s[0:3].
var opcodes = CompileCompute(
[
0xE0E04008, 0x80000100,
0xE0C44000, 0x80000100,
0xE0F00000, 0x80000100,
],
BufferDescriptorRegisters());
Assert.Contains((ushort)SpirvOp.AtomicUMax, opcodes);
Assert.Contains((ushort)SpirvOp.AtomicCompareExchange, opcodes);
Assert.Contains((ushort)SpirvOp.AtomicIIncrement, opcodes);
}
[Fact]
public void DataShareAtomics_EmitAtomicOpcodes()
{
// DS_ADD_RTN_U32 v3, v0, v1; DS_CMPST_RTN_B32 v3, v0, v1, v2; DS_MAX_U32 v0, v1.
var opcodes = CompileCompute(
[
0xD8800000, 0x03000100,
0xD8C00000, 0x03020100,
0xD8200000, 0x00000100,
],
new Dictionary<uint, uint>());
Assert.Contains((ushort)SpirvOp.AtomicIAdd, opcodes);
Assert.Contains((ushort)SpirvOp.AtomicCompareExchange, opcodes);
Assert.Contains((ushort)SpirvOp.AtomicUMax, opcodes);
}
[Fact]
public void ImageAtomicAdd_EmitsTexelPointerAndAtomicAdd()
{
// IMAGE_ATOMIC_ADD v2, v[0:1], s[4:11] dmask:0x1 dim:2D glc against an R32ui T#.
var opcodes = CompileCompute(
[0xF0442100, 0x00010200],
new Dictionary<uint, uint>
{
// Descriptor word1 dataFormat (bits 28:20) = 20 selects R32ui/Uint.
[5] = 20u << 20,
});
Assert.Contains((ushort)SpirvOp.ImageTexelPointer, opcodes);
Assert.Contains((ushort)SpirvOp.AtomicIAdd, opcodes);
}
private static Dictionary<uint, uint> BufferDescriptorRegisters() => new()
{
// V# in s[0:3]: base=BufferAddress, stride=0, numRecords=64 bytes, type=0.
[0] = unchecked((uint)BufferAddress),
[1] = (uint)(BufferAddress >> 32),
[2] = 64,
[3] = 0,
};
private static HashSet<ushort> CompileCompute(
uint[] programWords,
Dictionary<uint, uint> userDataSgprs)
{
var memory = new FakeCpuMemory(ShaderAddress, 0x2000);
var ctx = new CpuContext(memory, Generation.Gen5);
Gen5ShaderAtomicDecodeTests.WriteProgram(memory, ShaderAddress, programWords);
// COMPUTE_PGM_RSRC2 advertises 16 user SGPRs; the user data words at
// COMPUTE_USER_DATA_0 + index seed s[0..15] for the scalar evaluator.
var shaderRegisters = new Dictionary<uint, uint>
{
[Gen5ShaderAtomicDecodeTests.ComputePgmRsrc2Register] = 16u << 1,
};
foreach (var (sgpr, value) in userDataSgprs)
{
shaderRegisters[Gen5ShaderAtomicDecodeTests.ComputeUserDataRegister + sgpr] = value;
}
Assert.True(
Gen5ShaderTranslator.TryCreateState(
ctx,
ShaderAddress,
0,
shaderRegisters,
Gen5ShaderAtomicDecodeTests.ComputeUserDataRegister,
out var state,
out var error),
error);
Assert.True(
Gen5ShaderScalarEvaluator.TryEvaluate(ctx, state, out var evaluation, out error),
error);
Assert.True(
Gen5SpirvTranslator.TryCompileComputeShader(
state,
evaluation,
1,
1,
1,
out var shader,
out error),
error);
return CollectOpcodes(shader.Spirv);
}
private static HashSet<ushort> CollectOpcodes(byte[] spirv)
{
var opcodes = new HashSet<ushort>();
// 5-word SPIR-V header, then (wordCount << 16 | opcode) packed instructions.
for (var offset = 5 * sizeof(uint); offset + sizeof(uint) <= spirv.Length;)
{
var word = BinaryPrimitives.ReadUInt32LittleEndian(
spirv.AsSpan(offset, sizeof(uint)));
opcodes.Add((ushort)word);
offset += Math.Max((int)(word >> 16), 1) * sizeof(uint);
}
return opcodes;
}
}
@@ -0,0 +1,99 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.Libs.Ampr;
using SharpEmu.Libs.Kernel;
using System.Buffers.Binary;
using Xunit;
namespace SharpEmu.Libs.Tests.Ampr;
public sealed class AprStreamingContractTests
{
[Fact]
public void ResolveStatAndReadFile_UsesSharedAprFileId()
{
const ulong memoryBase = 0x1_0000_0000;
const ulong pathListAddress = memoryBase + 0x100;
const ulong pathAddress = memoryBase + 0x200;
const ulong idsAddress = memoryBase + 0x800;
const ulong statAddress = memoryBase + 0x900;
const ulong commandBufferAddress = memoryBase + 0x1000;
const ulong recordBufferAddress = memoryBase + 0x1100;
const ulong destinationAddress = memoryBase + 0x2000;
const ulong stackAddress = memoryBase + 0x3000;
byte[] fileContents = [10, 11, 12, 13, 14, 15, 16, 17];
var hostPath = Path.GetTempFileName();
try
{
File.WriteAllBytes(hostPath, fileContents);
var memory = new FakeCpuMemory(memoryBase, 0x4000);
var context = new CpuContext(memory, Generation.Gen5);
memory.WriteCString(pathAddress, hostPath);
WriteUInt64(memory, pathListAddress, pathAddress);
context[CpuRegister.Rdi] = pathListAddress;
context[CpuRegister.Rsi] = 1;
context[CpuRegister.Rdx] = idsAddress;
Assert.Equal(0, KernelMemoryCompatExports.KernelAprResolveFilepathsToIds(context));
Span<byte> idBytes = stackalloc byte[sizeof(uint)];
Assert.True(memory.TryRead(idsAddress, idBytes));
var fileId = BinaryPrimitives.ReadUInt32LittleEndian(idBytes);
Assert.NotEqual(uint.MaxValue, fileId);
context[CpuRegister.Rdi] = fileId;
context[CpuRegister.Rsi] = statAddress;
Assert.Equal(0, KernelMemoryCompatExports.KernelAprGetFileStat(context));
Span<byte> stat = stackalloc byte[120];
Assert.True(memory.TryRead(statAddress, stat));
Assert.Equal(fileContents.Length, BinaryPrimitives.ReadInt64LittleEndian(stat[72..]));
context[CpuRegister.Rdi] = commandBufferAddress;
context[CpuRegister.Rsi] = recordBufferAddress;
context[CpuRegister.Rdx] = 0x100;
Assert.Equal(0, AmprExports.CommandBufferConstructor(context));
const ulong readOffset = 2;
const ulong readSize = 4;
WriteUInt64(memory, stackAddress + sizeof(ulong), readOffset);
context[CpuRegister.Rsp] = stackAddress;
context[CpuRegister.Rdi] = commandBufferAddress;
context[CpuRegister.Rcx] = fileId;
context[CpuRegister.R8] = destinationAddress;
context[CpuRegister.R9] = readSize;
Assert.Equal(0, AmprExports.AprCommandBufferReadFile(context));
Span<byte> destination = stackalloc byte[(int)readSize];
Assert.True(memory.TryRead(destinationAddress, destination));
Assert.Equal(fileContents.AsSpan((int)readOffset, (int)readSize), destination);
Span<byte> record = stackalloc byte[0x30];
Assert.True(memory.TryRead(recordBufferAddress, record));
Assert.Equal(1U, BinaryPrimitives.ReadUInt32LittleEndian(record));
Assert.Equal(fileId, BinaryPrimitives.ReadUInt32LittleEndian(record[0x04..]));
Assert.Equal(destinationAddress, BinaryPrimitives.ReadUInt64LittleEndian(record[0x08..]));
Assert.Equal(readSize, BinaryPrimitives.ReadUInt64LittleEndian(record[0x10..]));
Assert.Equal(readOffset, BinaryPrimitives.ReadUInt64LittleEndian(record[0x18..]));
Assert.Equal(readSize, BinaryPrimitives.ReadUInt64LittleEndian(record[0x20..]));
}
finally
{
File.Delete(hostPath);
}
}
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
{
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(bytes, value);
Assert.True(memory.TryWrite(address, bytes));
}
}
@@ -0,0 +1,57 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.Libs.Audio;
using Xunit;
namespace SharpEmu.Libs.Tests.Audio;
public sealed class AudioPcmConversionTests
{
[Fact]
public void FloatFullScaleMapsToSignedPcmEndpoints()
{
Span<byte> source = stackalloc byte[sizeof(float) * 2];
WriteFloat(source, 0, -1.0f);
WriteFloat(source, 1, 1.0f);
Span<byte> destination = stackalloc byte[AudioPcmConversion.OutputFrameSize];
AudioPcmConversion.ConvertToStereoPcm16(
source,
destination,
frames: 1,
channels: 2,
bytesPerSample: sizeof(float),
isFloat: true,
volume: 1.0f);
Assert.Equal(short.MinValue, BinaryPrimitives.ReadInt16LittleEndian(destination));
Assert.Equal(short.MaxValue, BinaryPrimitives.ReadInt16LittleEndian(destination[2..]));
}
[Fact]
public void FloatNaNMapsToSilence()
{
Span<byte> source = stackalloc byte[sizeof(float)];
WriteFloat(source, 0, float.NaN);
Span<byte> destination = stackalloc byte[AudioPcmConversion.OutputFrameSize];
AudioPcmConversion.ConvertToStereoPcm16(
source,
destination,
frames: 1,
channels: 1,
bytesPerSample: sizeof(float),
isFloat: true,
volume: 1.0f);
Assert.Equal(0, BinaryPrimitives.ReadInt16LittleEndian(destination));
Assert.Equal(0, BinaryPrimitives.ReadInt16LittleEndian(destination[2..]));
}
private static void WriteFloat(Span<byte> destination, int sample, float value) =>
BinaryPrimitives.WriteInt32LittleEndian(
destination[(sample * sizeof(float))..],
BitConverter.SingleToInt32Bits(value));
}
@@ -0,0 +1,243 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Cpu.Emulation;
using Xunit;
namespace SharpEmu.Libs.Tests.Cpu;
// These exercise the pure BMI1/BMI2/ABM semantics used by the illegal-instruction software
// fallback. The expected values were computed by hand from the Intel/AMD definitions so a
// regression in the bit math or the flag handling fails here without needing a live guest.
public sealed class BmiInstructionEmulatorTests
{
private const uint Carry = 1u << 0;
private const uint Zero = 1u << 6;
private const uint Sign = 1u << 7;
private const uint Overflow = 1u << 11;
private const uint DirectionFlag = 1u << 10; // Unrelated flag; must be preserved.
[Fact]
public void Andn_ClearsSourceBitsAndFlags()
{
uint flags = Overflow | Carry | DirectionFlag;
var result = BmiInstructionEmulator.Andn(0x0000_00F0, 0x0000_00FF, GprOperandSize.Bits32, ref flags);
Assert.Equal(0x0000_000FUL, result);
Assert.Equal(0u, flags & Carry);
Assert.Equal(0u, flags & Overflow);
Assert.Equal(0u, flags & Zero);
Assert.Equal(0u, flags & Sign);
Assert.Equal(DirectionFlag, flags & DirectionFlag);
}
[Fact]
public void Andn_SetsZeroFlagWhenResultIsZero()
{
uint flags = 0;
var result = BmiInstructionEmulator.Andn(0xFF, 0xFF, GprOperandSize.Bits32, ref flags);
Assert.Equal(0UL, result);
Assert.Equal(Zero, flags & Zero);
}
[Fact]
public void Andn_SetsSignFlagOnHighBit32()
{
uint flags = 0;
var result = BmiInstructionEmulator.Andn(0, 0x8000_0000, GprOperandSize.Bits32, ref flags);
Assert.Equal(0x8000_0000UL, result);
Assert.Equal(Sign, flags & Sign);
}
[Fact]
public void Andn_MasksTo64Bits()
{
uint flags = 0;
var result = BmiInstructionEmulator.Andn(0xFFFF_FFFF_FFFF_FF00, 0xFF, GprOperandSize.Bits64, ref flags);
Assert.Equal(0xFFUL, result);
}
[Theory]
[InlineData(0x0000_000CUL, 0x0000_0004UL, false)] // isolate lowest set bit
[InlineData(0x0000_0000UL, 0x0000_0000UL, true)] // src == 0 -> CF clear, ZF set
public void Blsi_IsolatesLowestBit(ulong src, ulong expected, bool zero)
{
uint flags = 0;
var result = BmiInstructionEmulator.Blsi(src, GprOperandSize.Bits32, ref flags);
Assert.Equal(expected, result);
Assert.Equal(src != 0 ? Carry : 0u, flags & Carry);
Assert.Equal(zero ? Zero : 0u, flags & Zero);
}
[Fact]
public void Blsmsk_MasksThroughLowestBit()
{
uint flags = 0;
var result = BmiInstructionEmulator.Blsmsk(0x0000_000C, GprOperandSize.Bits32, ref flags);
Assert.Equal(0x0000_0007UL, result);
Assert.Equal(0u, flags & Carry);
}
[Fact]
public void Blsmsk_SetsCarryWhenSourceZero32()
{
uint flags = 0;
var result = BmiInstructionEmulator.Blsmsk(0, GprOperandSize.Bits32, ref flags);
Assert.Equal(0xFFFF_FFFFUL, result);
Assert.Equal(Carry, flags & Carry);
Assert.Equal(Sign, flags & Sign);
}
[Fact]
public void Blsr_ResetsLowestBit()
{
uint flags = 0;
var result = BmiInstructionEmulator.Blsr(0x0000_000C, GprOperandSize.Bits32, ref flags);
Assert.Equal(0x0000_0008UL, result);
Assert.Equal(0u, flags & Carry);
}
[Fact]
public void Blsr_SetsCarryAndZeroWhenSourceZero()
{
uint flags = 0;
var result = BmiInstructionEmulator.Blsr(0, GprOperandSize.Bits32, ref flags);
Assert.Equal(0UL, result);
Assert.Equal(Carry, flags & Carry);
Assert.Equal(Zero, flags & Zero);
}
[Fact]
public void Bextr_ExtractsBitField()
{
uint flags = 0;
// start = 4, len = 8 -> bits [11:4] of 0x12345678 == 0x67
var control = (8UL << 8) | 4UL;
var result = BmiInstructionEmulator.Bextr(0x1234_5678, control, GprOperandSize.Bits32, ref flags);
Assert.Equal(0x0000_0067UL, result);
Assert.Equal(0u, flags & Zero);
Assert.Equal(0u, flags & Carry);
Assert.Equal(0u, flags & Overflow);
}
[Fact]
public void Bextr_StartBeyondWidthYieldsZero()
{
uint flags = 0;
var control = (8UL << 8) | 40UL; // start = 40 >= 32
var result = BmiInstructionEmulator.Bextr(0xFFFF_FFFF, control, GprOperandSize.Bits32, ref flags);
Assert.Equal(0UL, result);
Assert.Equal(Zero, flags & Zero);
}
[Fact]
public void Bzhi_ZeroesHighBits()
{
uint flags = 0;
var result = BmiInstructionEmulator.Bzhi(0xFFFF_FFFF, 8, GprOperandSize.Bits32, ref flags);
Assert.Equal(0x0000_00FFUL, result);
Assert.Equal(0u, flags & Carry);
}
[Fact]
public void Bzhi_SetsCarryWhenIndexAtOrBeyondWidth()
{
uint flags = 0;
var result = BmiInstructionEmulator.Bzhi(0xFFFF_FFFF, 32, GprOperandSize.Bits32, ref flags);
Assert.Equal(0xFFFF_FFFFUL, result);
Assert.Equal(Carry, flags & Carry);
}
[Theory]
[InlineData(0x0000_0008UL, GprOperandSize.Bits32, 3UL, false)]
[InlineData(0x0000_0001UL, GprOperandSize.Bits32, 0UL, false)]
[InlineData(0x0000_0000UL, GprOperandSize.Bits32, 32UL, true)]
[InlineData(0x1_0000_0000UL, GprOperandSize.Bits64, 32UL, false)]
public void Tzcnt_CountsTrailingZeros(ulong src, GprOperandSize size, ulong expected, bool carry)
{
uint flags = 0;
var result = BmiInstructionEmulator.Tzcnt(src, size, ref flags);
Assert.Equal(expected, result);
Assert.Equal(carry ? Carry : 0u, flags & Carry);
}
[Theory]
[InlineData(0x0000_0001UL, GprOperandSize.Bits32, 31UL, false)]
[InlineData(0x8000_0000UL, GprOperandSize.Bits32, 0UL, false)]
[InlineData(0x0000_0000UL, GprOperandSize.Bits32, 32UL, true)]
[InlineData(0x0000_0001UL, GprOperandSize.Bits64, 63UL, false)]
[InlineData(0x0000_0000UL, GprOperandSize.Bits64, 64UL, true)]
public void Lzcnt_CountsLeadingZeros(ulong src, GprOperandSize size, ulong expected, bool carry)
{
uint flags = 0;
var result = BmiInstructionEmulator.Lzcnt(src, size, ref flags);
Assert.Equal(expected, result);
Assert.Equal(carry ? Carry : 0u, flags & Carry);
}
[Theory]
[InlineData(0x1234_5678UL, 8, GprOperandSize.Bits32, 0x7812_3456UL)]
[InlineData(0x1234_5678UL, 0, GprOperandSize.Bits32, 0x1234_5678UL)]
[InlineData(0x0123_4567_89AB_CDEFUL, 4, GprOperandSize.Bits64, 0xF012_3456_789A_BCDEUL)]
public void Rorx_RotatesRight(ulong src, int count, GprOperandSize size, ulong expected)
{
Assert.Equal(expected, BmiInstructionEmulator.Rorx(src, count, size));
}
[Theory]
[InlineData(0x8000_0000UL, 4, GprOperandSize.Bits32, 0xF800_0000UL)]
[InlineData(0x8000_0000UL, 36, GprOperandSize.Bits32, 0xF800_0000UL)] // count masked to 4
[InlineData(0x8000_0000_0000_0000UL, 4, GprOperandSize.Bits64, 0xF800_0000_0000_0000UL)]
public void Sarx_ArithmeticShiftRight(ulong src, int count, GprOperandSize size, ulong expected)
{
Assert.Equal(expected, BmiInstructionEmulator.Sarx(src, count, size));
}
[Theory]
[InlineData(0x0000_0001UL, 4, GprOperandSize.Bits32, 0x0000_0010UL)]
[InlineData(0x0000_0001UL, 33, GprOperandSize.Bits32, 0x0000_0002UL)] // count masked to 1
public void Shlx_LogicalShiftLeft(ulong src, int count, GprOperandSize size, ulong expected)
{
Assert.Equal(expected, BmiInstructionEmulator.Shlx(src, count, size));
}
[Theory]
[InlineData(0x8000_0000UL, 4, GprOperandSize.Bits32, 0x0800_0000UL)]
[InlineData(0x8000_0000_0000_0000UL, 4, GprOperandSize.Bits64, 0x0800_0000_0000_0000UL)]
public void Shrx_LogicalShiftRight(ulong src, int count, GprOperandSize size, ulong expected)
{
Assert.Equal(expected, BmiInstructionEmulator.Shrx(src, count, size));
}
[Fact]
public void PdepAndPext_AreInverseForContiguousSource()
{
var deposited = BmiInstructionEmulator.Pdep(0x0000_000F, 0x0000_00AA, GprOperandSize.Bits32);
Assert.Equal(0x0000_00AAUL, deposited);
var extracted = BmiInstructionEmulator.Pext(0x0000_00AA, 0x0000_00AA, GprOperandSize.Bits32);
Assert.Equal(0x0000_000FUL, extracted);
}
[Fact]
public void Pext_GathersSelectedBits()
{
// mask selects bit positions 1,3,5,7; source has 1s only at 5 and 7 -> packed 0b1100
var extracted = BmiInstructionEmulator.Pext(0xF0, 0xAA, GprOperandSize.Bits32);
Assert.Equal(0x0000_000CUL, extracted);
}
}
@@ -0,0 +1,61 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Cpu.Native;
using SharpEmu.HLE;
using Xunit;
namespace SharpEmu.Libs.Tests.Cpu;
public sealed class GuestContextTransferValidationTests
{
private const ulong MemoryBase = 0x1_0000_0000;
[Fact]
public void MappedGuestInstructionPointer_IsAccepted()
{
var memory = new FakeCpuMemory(MemoryBase, 0x1000);
var continuation = CreateContinuation(MemoryBase + 0x100, MemoryBase + 0x800);
Assert.True(DirectExecutionBackend.TryValidateGuestContextTransferTarget(
memory,
continuation,
out var error));
Assert.Null(error);
}
[Fact]
public void UnmappedGuestInstructionPointer_IsRejected()
{
var memory = new FakeCpuMemory(MemoryBase, 0x1000);
var continuation = CreateContinuation(MemoryBase + 0x2000, MemoryBase + 0x800);
Assert.False(DirectExecutionBackend.TryValidateGuestContextTransferTarget(
memory,
continuation,
out var error));
Assert.Contains("not mapped guest memory", error);
}
[Theory]
[InlineData(0UL, MemoryBase + 0x800)]
[InlineData(MemoryBase + 0x100, 0UL)]
public void InvalidInstructionOrStackPointer_IsRejected(ulong rip, ulong rsp)
{
var memory = new FakeCpuMemory(MemoryBase, 0x1000);
var continuation = CreateContinuation(rip, rsp);
Assert.False(DirectExecutionBackend.TryValidateGuestContextTransferTarget(
memory,
continuation,
out var error));
Assert.Contains("invalid guest context transfer target", error);
}
private static GuestCpuContinuation CreateContinuation(ulong rip, ulong rsp) =>
new()
{
Rip = rip,
Rsp = rsp,
};
}
@@ -0,0 +1,96 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Cpu.Native;
using SharpEmu.HLE;
using System.Buffers.Binary;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Xunit;
namespace SharpEmu.Libs.Tests.Cpu;
public sealed class ImportTrampolineAbiTests
{
[Fact]
public unsafe void GeneratedTrampoline_PreservesVolatileGuestState()
{
if (RuntimeInformation.ProcessArchitecture != Architecture.X64)
{
// The production backend emits and initializes x86-64 callback
// thunks, so inspect it only in an x64 test process. Apple Silicon
// runs this path through the same Rosetta environment as SharpEmu.
return;
}
var code = CreateTrampolineBytes();
AssertContains(code, [0x48, 0x81, 0xEC, 0xB0, 0x00, 0x00, 0x00]); // sub rsp,0xB0
AssertContains(code, [0x48, 0x89, 0x04, 0x24]); // mov [rsp],rax
AssertContains(code, [0x4C, 0x89, 0x54, 0x24, 0x08]); // mov [rsp+8],r10
AssertContains(code, [0x4C, 0x89, 0x5C, 0x24, 0x10]); // mov [rsp+16],r11
AssertContains(code, [0x0F, 0xAE, 0x5C, 0x24, 0x18]); // stmxcsr [rsp+24]
AssertContains(code, [0xD9, 0x7C, 0x24, 0x1C]); // fnstcw [rsp+28]
AssertContains(code, [0x4C, 0x8D, 0xA4, 0x24, 0xB0, 0, 0, 0]); // lea r12,[rsp+0xB0]
for (var xmm = 0; xmm < 8; xmm++)
{
Span<byte> save = new byte[9];
save[0] = 0xF3;
save[1] = 0x0F;
save[2] = 0x7F;
save[3] = (byte)(0x84 | (xmm << 3));
save[4] = 0x24;
BinaryPrimitives.WriteInt32LittleEndian(save[5..], 0x30 + (xmm * 0x10));
AssertContains(code, save);
}
for (var xmm = 0; xmm < 2; xmm++)
{
Span<byte> restore = new byte[10];
restore[0] = 0xF3;
restore[1] = 0x41;
restore[2] = 0x0F;
restore[3] = 0x6F;
restore[4] = (byte)(0x84 | (xmm << 3));
restore[5] = 0x24;
BinaryPrimitives.WriteInt32LittleEndian(restore[6..], -0x80 + (xmm * 0x10));
AssertContains(code, restore);
}
}
private static unsafe byte[] CreateTrampolineBytes()
{
var backend = (DirectExecutionBackend)RuntimeHelpers.GetUninitializedObject(
typeof(DirectExecutionBackend));
var trampolineList = typeof(DirectExecutionBackend).GetField(
"_importHandlerTrampolines",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(trampolineList);
trampolineList.SetValue(backend, new List<nint>());
var createTrampoline = typeof(DirectExecutionBackend).GetMethod(
"CreateImportHandlerTrampoline",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(createTrampoline);
var trampoline = (nint)createTrampoline.Invoke(backend, [0])!;
Assert.NotEqual(0, trampoline);
try
{
return new ReadOnlySpan<byte>((void*)trampoline, 512).ToArray();
}
finally
{
Assert.True(HostMemory.Free((void*)trampoline, 0, HostMemory.MEM_RELEASE));
}
}
private static void AssertContains(ReadOnlySpan<byte> code, ReadOnlySpan<byte> expected)
{
Assert.True(
code.IndexOf(expected) >= 0,
$"Generated trampoline did not contain {Convert.ToHexString(expected)}.");
}
}
@@ -0,0 +1,44 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Font;
using Xunit;
namespace SharpEmu.Libs.Tests.Font;
public sealed class FontExportsTests
{
private const ulong Base = 0x1_0000_0000;
private const ulong LayoutAddress = Base + 0x100;
private readonly FakeCpuMemory _memory = new(Base, 0x1000);
private readonly CpuContext _ctx;
public FontExportsTests()
{
_ctx = new CpuContext(_memory, Generation.Gen5);
}
// SceFontHorizontalLayout is three floats; the sentinel directly after
// them must survive the call.
[Fact]
public void GetHorizontalLayout_WritesExactlyThreeFloats()
{
const uint Sentinel = 0xDEADBEEF;
Span<byte> sentinelBytes = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(sentinelBytes, Sentinel);
Assert.True(_ctx.Memory.TryWrite(LayoutAddress + 12, sentinelBytes));
_ctx[CpuRegister.Rsi] = LayoutAddress;
Assert.Equal(0, FontExports.GetHorizontalLayout(_ctx));
Span<byte> layout = stackalloc byte[16];
Assert.True(_ctx.Memory.TryRead(LayoutAddress, layout));
Assert.Equal(12.0f, BinaryPrimitives.ReadSingleLittleEndian(layout));
Assert.Equal(16.0f, BinaryPrimitives.ReadSingleLittleEndian(layout[4..]));
Assert.Equal(0.0f, BinaryPrimitives.ReadSingleLittleEndian(layout[8..]));
Assert.Equal(Sentinel, BinaryPrimitives.ReadUInt32LittleEndian(layout[12..]));
}
}
@@ -0,0 +1,66 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
using System.Globalization;
using System.Text;
using Xunit;
namespace SharpEmu.Libs.Tests.Kernel;
public sealed class KernelMemoryCompatExportsTests
{
[Fact]
public void PosixStat_MissingFileReturnsMinusOne()
{
const ulong memoryBase = 0x1_0000_0000;
const ulong pathAddress = memoryBase + 0x100;
const ulong statAddress = memoryBase + 0x400;
var memory = new FakeCpuMemory(memoryBase, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
memory.WriteCString(pathAddress, "/__sharpemu_test_missing__/shader.cache");
context[CpuRegister.Rdi] = pathAddress;
context[CpuRegister.Rsi] = statAddress;
var result = KernelMemoryCompatExports.PosixStat(context);
Assert.Equal(-1, result);
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
}
[Fact]
public void Sprintf_ReadsVariadicDoubleFromXmmRegister()
{
const ulong memoryBase = 0x1_0000_0000;
const ulong destinationAddress = memoryBase + 0x100;
const ulong formatAddress = memoryBase + 0x200;
var memory = new FakeCpuMemory(memoryBase, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
memory.WriteCString(formatAddress, "%.4f");
context[CpuRegister.Rdi] = destinationAddress;
context[CpuRegister.Rsi] = formatAddress;
context.SetXmmRegister(
0,
unchecked((ulong)BitConverter.DoubleToInt64Bits(0.5576)),
0);
var previousCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("es-ES");
var result = KernelMemoryCompatExports.Sprintf(context);
Assert.Equal(0, result);
Assert.Equal(6UL, context[CpuRegister.Rax]);
Span<byte> output = stackalloc byte[7];
Assert.True(memory.TryRead(destinationAddress, output));
Assert.Equal("0.5576\0", Encoding.UTF8.GetString(output));
}
finally
{
CultureInfo.CurrentCulture = previousCulture;
}
}
}
@@ -0,0 +1,89 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.Core.Loader;
using SharpEmu.Core.Memory;
using Xunit;
namespace SharpEmu.Libs.Tests.Loader;
public sealed class SelfLoaderTests
{
private const uint Ps4SelfMagic = 0x4F153D1D;
private const uint Ps5SelfMagic = 0x5414F5EE;
private const int SelfHeaderSize = 0x20;
private const int ElfHeaderSize = 0x40;
[Theory]
[InlineData(Ps4SelfMagic, (byte)0x00, 0x0000_0101u, (ushort)0x22)]
[InlineData(Ps5SelfMagic, (byte)0x00, 0x0000_0101u, (ushort)0x22)]
[InlineData(Ps5SelfMagic, (byte)0x10, 0x1000_0101u, (ushort)0x32)]
public void Load_AcceptsSupportedSelfHeaderVariants(
uint magic,
byte version,
uint keyType,
ushort flags)
{
var imageData = CreateSelfImage(magic, version, keyType, flags);
var image = new SelfLoader().Load(imageData, new VirtualMemory());
Assert.True(image.IsSelf);
Assert.Equal(2, image.ElfHeader.AbiVersion);
Assert.Empty(image.ProgramHeaders);
Assert.Empty(image.MappedRegions);
}
[Theory]
[InlineData(0x05, (byte)0x00)]
[InlineData(0x06, (byte)0x02)]
[InlineData(0x07, (byte)0x00)]
public void Load_RejectsUnsupportedStructuralSelfHeaderValues(int offset, byte value)
{
var imageData = CreateSelfImage(Ps5SelfMagic, 0x10, 0x1000_0101, 0x32);
imageData[offset] = value;
Assert.Throws<InvalidDataException>(() =>
new SelfLoader().Load(imageData, new VirtualMemory()));
}
private static byte[] CreateSelfImage(uint magic, byte version, uint keyType, ushort flags)
{
var imageData = new byte[SelfHeaderSize + ElfHeaderSize];
var selfHeader = imageData.AsSpan(0, SelfHeaderSize);
BinaryPrimitives.WriteUInt32BigEndian(selfHeader, magic);
selfHeader[0x04] = version;
selfHeader[0x05] = 0x01;
selfHeader[0x06] = 0x01;
selfHeader[0x07] = 0x12;
BinaryPrimitives.WriteUInt32LittleEndian(selfHeader[0x08..], keyType);
BinaryPrimitives.WriteUInt16LittleEndian(selfHeader[0x0C..], SelfHeaderSize);
BinaryPrimitives.WriteUInt64LittleEndian(selfHeader[0x10..], (ulong)imageData.Length);
BinaryPrimitives.WriteUInt16LittleEndian(selfHeader[0x18..], 0);
BinaryPrimitives.WriteUInt16LittleEndian(selfHeader[0x1A..], flags);
WriteMinimalElfHeader(imageData.AsSpan(SelfHeaderSize, ElfHeaderSize));
return imageData;
}
private static void WriteMinimalElfHeader(Span<byte> header)
{
header.Clear();
header[0x00] = 0x7F;
header[0x01] = (byte)'E';
header[0x02] = (byte)'L';
header[0x03] = (byte)'F';
header[0x04] = 2;
header[0x05] = 1;
header[0x06] = 1;
header[0x07] = 9;
header[0x08] = 2;
BinaryPrimitives.WriteUInt16LittleEndian(header[0x10..], 3);
BinaryPrimitives.WriteUInt16LittleEndian(header[0x12..], 62);
BinaryPrimitives.WriteUInt32LittleEndian(header[0x14..], 1);
BinaryPrimitives.WriteUInt64LittleEndian(header[0x20..], ElfHeaderSize);
BinaryPrimitives.WriteUInt16LittleEndian(header[0x34..], ElfHeaderSize);
BinaryPrimitives.WriteUInt16LittleEndian(header[0x36..], 0x38);
}
}
@@ -95,6 +95,32 @@ public sealed class GuestMemoryAllocatorTests
Assert.Equal(0UL, (ulong)memory.GetPointer(address));
}
[Fact]
public void AlignedAllocationDoesNotRetainOverallocatedMappingsOutsideMacOS()
{
if (OperatingSystem.IsMacOS())
{
return;
}
const ulong desiredAddress = 0x00005000_0000_0123;
const ulong alignment = 0x10000;
const ulong alignedAddress = 0x00005000_0001_0000;
const ulong allocationSize = 0x2000;
using var host = new RelocatingHostMemory(alignedAddress);
using var memory = new PhysicalVirtualMemory(host);
Assert.True(memory.TryAllocateAtOrAbove(desiredAddress, 0x1234, false, alignment, out var actualAddress));
Assert.Equal(alignedAddress + alignment, actualAddress);
Assert.Equal(
[
(alignedAddress, allocationSize),
(alignedAddress + alignment, allocationSize),
],
host.AllocationCalls);
Assert.Equal([alignedAddress + 0x1000], host.FreedAddresses);
}
private sealed class FakeHostMemory : IHostMemory
{
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) =>
@@ -197,6 +223,63 @@ public sealed class GuestMemoryAllocatorTests
}
}
private sealed class RelocatingHostMemory(ulong firstAddress) : IHostMemory, IDisposable
{
private bool _relocatedFirstAllocation;
public List<(ulong Address, ulong Size)> AllocationCalls { get; } = [];
public List<ulong> FreedAddresses { get; } = [];
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection)
{
AllocationCalls.Add((desiredAddress, size));
if (!_relocatedFirstAllocation)
{
_relocatedFirstAllocation = true;
return firstAddress + 0x1000;
}
return desiredAddress;
}
public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection) => 0;
public bool Commit(ulong address, ulong size, HostPageProtection protection) => true;
public bool Free(ulong address)
{
FreedAddresses.Add(address);
return true;
}
public bool Protect(ulong address, ulong size, HostPageProtection protection, out uint rawOldProtection)
{
rawOldProtection = 0;
return true;
}
public bool ProtectRaw(ulong address, ulong size, uint rawProtection, out uint rawOldProtection)
{
rawOldProtection = 0;
return true;
}
public bool Query(ulong address, out HostRegionInfo info)
{
info = default;
return false;
}
public void FlushInstructionCache(ulong address, ulong size)
{
}
public void Dispose()
{
}
}
private sealed class LazyHostMemory(ulong address) : IHostMemory, IDisposable
{
public bool CommitSucceeds { get; set; } = true;
@@ -0,0 +1,229 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Memory;
using SharpEmu.Core.Loader;
using SharpEmu.HLE.Host;
using Xunit;
namespace SharpEmu.Libs.Tests.Memory;
// PhysicalVirtualMemory is the host-backed (identity-mapped) implementation.
// Reserve-only regions (> 4 GiB, non-executable) defer commit until first
// access; TryAllocateGuestMemory serves a first-fit free-list with coalescing.
// These tests pin that behaviour through fake IHostMemory implementations.
public sealed class PhysicalVirtualMemoryTests
{
// 1. Lazy commit: a reserve-only region has its pages committed on demand
// when read; freshly committed pages read as zero.
[Fact]
public void LazyReadCommitsPageOnDemandAndReadsZero()
{
using var host = new LazyZeroedHostMemory();
using var memory = new PhysicalVirtualMemory(host);
// > 4 GiB, non-executable -> reserve-only with lazy commit.
var address = memory.AllocateAt(0, (4UL << 30) + 0x1000, executable: false);
Assert.NotEqual(0UL, address);
// Discard the priming commits AllocateAt issues up front; we want to
// observe the on-demand commit triggered by the read itself.
host.CommitCalls.Clear();
var buffer = new byte[1];
Assert.True(memory.TryRead(address, buffer));
Assert.Equal(0, buffer[0]);
// The touched page (page-aligned to `address`) was committed on demand.
var page = address & ~0xFFFUL;
Assert.Equal([(page, 0x1000UL, HostPageProtection.ReadWrite)], host.CommitCalls);
}
// 2. Reserve-only region: GetPointer commits the page before returning it,
// so callers receive a valid (non-null) pointer. An unmapped address yields null.
[Fact]
public unsafe void GetPointerOnReserveOnlyRegionCommitsAndReturnsValidPointer()
{
using var host = new LazyZeroedHostMemory();
using var memory = new PhysicalVirtualMemory(host);
var address = memory.AllocateAt(0, (4UL << 30) + 0x1000, executable: false);
host.CommitCalls.Clear();
var pointer = memory.GetPointer(address + 0x123);
Assert.NotEqual(0UL, (ulong)pointer);
Assert.Equal(address + 0x123, (ulong)pointer);
var page = (address + 0x123) & ~0xFFFUL;
Assert.Equal([(page, 0x1000UL, HostPageProtection.ReadWrite)], host.CommitCalls);
}
[Fact]
public unsafe void GetPointerOnUnmappedAddressReturnsNull()
{
using var host = new LazyZeroedHostMemory();
using var memory = new PhysicalVirtualMemory(host);
Assert.Equal(0UL, (ulong)memory.GetPointer(0x0001_0000));
}
// 3. Free-list reuse: a freed range is served back by first-fit allocation,
// preferring the lowest fitting free range over the larger trailing span.
[Fact]
public void FreedRangeIsReusedByFirstFitAllocation()
{
using var memory = new PhysicalVirtualMemory(new FakeHostMemory());
Assert.True(memory.TryAllocateGuestMemory(0x4000, 0x1000, out var first));
Assert.True(memory.TryAllocateGuestMemory(0x4000, 0x1000, out var second));
Assert.NotEqual(first, second);
Assert.True(memory.TryFreeGuestMemory(first));
// A smaller allocation must reuse first's freed slot (lowest fitting range),
// not the larger trailing free range.
Assert.True(memory.TryAllocateGuestMemory(0x2000, 0x1000, out var reused));
Assert.Equal(first, reused);
}
// 4. Coalescing: freeing the middle of three adjacent ranges merges both the
// left and right free neighbours in a single TryFreeGuestMemory call,
// restoring the full span for subsequent first-fit reuse.
[Fact]
public void FreeingMiddleRangeCoalescesBothNeighbours()
{
using var memory = new PhysicalVirtualMemory(new FakeHostMemory());
// Three adjacent 0x1000 allocations: offsets 0x1000, 0x2000, 0x3000.
Assert.True(memory.TryAllocateGuestMemory(0x1000, 0x1000, out var first));
Assert.True(memory.TryAllocateGuestMemory(0x1000, 0x1000, out var second));
Assert.True(memory.TryAllocateGuestMemory(0x1000, 0x1000, out var third));
// Free the outer ranges first, leaving two separate free ranges.
Assert.True(memory.TryFreeGuestMemory(first));
Assert.True(memory.TryFreeGuestMemory(third));
// Freeing the middle range must coalesce both neighbours at once.
Assert.True(memory.TryFreeGuestMemory(second));
// The whole arena is now one coalesced free range; a full-arena allocation
// reuses first's base address.
Assert.True(memory.TryAllocateGuestMemory(0x000F_F000, 0x1000, out var coalesced));
Assert.Equal(first, coalesced);
}
/// <summary>
/// Host memory backed by a single real, zero-initialised page. Reserve/Allocate
/// report the page-aligned buffer address so lazy-commit read paths can actually
/// dereference the returned pointer. Query always reports Reserved, so
/// EnsureRangeCommitted issues a Commit on first access.
/// </summary>
private sealed unsafe class LazyZeroedHostMemory : IHostMemory, IDisposable
{
private readonly void* _allocation;
private readonly ulong _address;
private bool _freed;
public LazyZeroedHostMemory()
{
_allocation = System.Runtime.InteropServices.NativeMemory.AllocZeroed(0x2000);
_address = ((ulong)_allocation + 0xFFF) & ~0xFFFUL;
}
public List<(ulong Address, ulong Size, HostPageProtection Protection)> CommitCalls { get; } = [];
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) => _address;
public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection) => _address;
public bool Commit(ulong address, ulong size, HostPageProtection protection)
{
CommitCalls.Add((address, size, protection));
return true;
}
public bool Free(ulong address)
{
// The real buffer is released in Dispose; keep Free a no-op so
// PhysicalVirtualMemory.Clear does not double-free it.
return true;
}
public bool Protect(ulong address, ulong size, HostPageProtection protection, out uint rawOldProtection)
{
rawOldProtection = 0;
return true;
}
public bool ProtectRaw(ulong address, ulong size, uint rawProtection, out uint rawOldProtection)
{
rawOldProtection = 0;
return true;
}
public bool Query(ulong address, out HostRegionInfo info)
{
var pageAddress = address & ~0xFFFUL;
info = new HostRegionInfo(
pageAddress,
pageAddress,
0x1000,
HostRegionState.Reserved,
0,
HostPageProtection.NoAccess,
0,
0);
return true;
}
public void FlushInstructionCache(ulong address, ulong size)
{
}
public void Dispose()
{
if (!_freed)
{
System.Runtime.InteropServices.NativeMemory.Free(_allocation);
_freed = true;
}
}
}
// Minimal host memory for free-list tests: Allocate honours the desired
// address (or a fallback), everything else succeeds as a no-op. The guest
// allocation arena never dereferences, so no real backing is required.
private sealed class FakeHostMemory : IHostMemory
{
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) =>
desiredAddress != 0 ? desiredAddress : 0x00007000_0000_0000;
public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection) =>
Allocate(desiredAddress, size, protection);
public bool Commit(ulong address, ulong size, HostPageProtection protection) => true;
public bool Free(ulong address) => true;
public bool Protect(ulong address, ulong size, HostPageProtection protection, out uint rawOldProtection)
{
rawOldProtection = 0;
return true;
}
public bool ProtectRaw(ulong address, ulong size, uint rawProtection, out uint rawOldProtection)
{
rawOldProtection = 0;
return true;
}
public bool Query(ulong address, out HostRegionInfo info)
{
info = default;
return false;
}
public void FlushInstructionCache(ulong address, ulong size)
{
}
}
}
@@ -0,0 +1,62 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
using SharpEmu.HLE.Host;
using SharpEmu.HLE.Host.Posix;
using Xunit;
namespace SharpEmu.Libs.Tests.Memory;
public sealed unsafe class PosixHostMemoryTests
{
private const int ProtRead = 0x1;
private const int ProtWrite = 0x2;
private const int MapPrivate = 0x02;
private const int MapAnonymousDarwin = 0x1000;
private static readonly nint MapFailed = -1;
[Fact]
public void ExactAllocationDoesNotReplaceUntrackedDarwinMapping()
{
if (!OperatingSystem.IsMacOS())
{
return;
}
var size = checked((nuint)Environment.SystemPageSize);
var externalMapping = mmap(
0,
size,
ProtRead | ProtWrite,
MapPrivate | MapAnonymousDarwin,
-1,
0);
Assert.NotEqual(MapFailed, externalMapping);
try
{
var sentinel = new Span<byte>((void*)externalMapping, checked((int)size));
sentinel.Fill(0xA5);
var hostMemory = new PosixHostMemory();
var result = hostMemory.Allocate(
(ulong)externalMapping,
(ulong)size,
HostPageProtection.ReadWrite);
Assert.Equal(0UL, result);
Assert.All(sentinel.ToArray(), value => Assert.Equal(0xA5, value));
}
finally
{
Assert.Equal(0, munmap(externalMapping, size));
}
}
[DllImport("libc", SetLastError = true)]
private static extern nint mmap(nint addr, nuint length, int prot, int flags, int fd, long offset);
[DllImport("libc", SetLastError = true)]
private static extern int munmap(nint addr, nuint length);
}
@@ -84,4 +84,74 @@ public sealed class VirtualMemoryTests
Assert.False(memory.TryRead(0x3000, unchanged));
Assert.True(memory.TryWrite(0x3000, [9]));
}
[Fact]
public void TryReadAndTryWriteOnUnmappedAddressReturnFalse()
{
var memory = new VirtualMemory();
memory.Map(0x1000, 0x100, 0, [], ProgramHeaderFlags.Read | ProgramHeaderFlags.Write);
// Addresses with no covering region fail for both reads and writes.
Span<byte> value = stackalloc byte[1];
Assert.False(memory.TryRead(0x2000, value));
Assert.False(memory.TryWrite(0x2000, value));
// With no regions mapped at all, every access fails.
var empty = new VirtualMemory();
Assert.False(empty.TryRead(0x1000, value));
Assert.False(empty.TryWrite(0x1000, value));
}
// Zero-length operations succeed on a mapped address and touch nothing, but
// still require a mapped address.
[Fact]
public void ZeroLengthOperationsOnMappedAddressReturnTrue()
{
var memory = new VirtualMemory();
memory.Map(0x1000, 0x100, 0, [0x01], ProgramHeaderFlags.Read | ProgramHeaderFlags.Write);
Assert.True(memory.TryRead(0x1000, []));
Assert.True(memory.TryWrite(0x1000, []));
Span<byte> value = stackalloc byte[1];
Assert.True(memory.TryRead(0x1000, value));
Assert.Equal(0x01, value[0]);
Assert.False(memory.TryRead(0x2000, []));
Assert.False(memory.TryWrite(0x2000, []));
}
// A page-aligned region must be accessible at both offset 0 and the final byte.
[Fact]
public void MappingAtPageBoundarySupportsOffsetZeroAndLastByte()
{
const ulong pageSize = 0x1000;
var memory = new VirtualMemory();
memory.Map(pageSize, pageSize, 0, [], ProgramHeaderFlags.Read | ProgramHeaderFlags.Write);
Assert.True(memory.TryWrite(pageSize, [0x5A]));
Assert.True(memory.TryWrite(pageSize + pageSize - 1, [0xA5]));
Span<byte> head = stackalloc byte[1];
Span<byte> tail = stackalloc byte[1];
Assert.True(memory.TryRead(pageSize, head));
Assert.True(memory.TryRead(pageSize + pageSize - 1, tail));
Assert.Equal(0x5A, head[0]);
Assert.Equal(0xA5, tail[0]);
}
// A read/write that starts in one region but extends into the unmapped gap
// between two non-adjacent regions must fail across the entire range.
[Fact]
public void ReadWriteAcrossGapBetweenNonAdjacentRegionsReturnsFalse()
{
const ulong pageSize = 0x1000;
var memory = new VirtualMemory();
const ProgramHeaderFlags protection = ProgramHeaderFlags.Read | ProgramHeaderFlags.Write;
memory.Map(pageSize, pageSize, 0, [], protection);
memory.Map(pageSize * 3, pageSize, 0, [], protection);
Assert.False(memory.TryRead(pageSize, new byte[(int)pageSize * 2]));
Assert.False(memory.TryWrite(pageSize, new byte[(int)pageSize * 2]));
}
}
@@ -0,0 +1,94 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.Libs.Network;
using Xunit;
namespace SharpEmu.Libs.Tests.Network;
// The libSceNet byte-order helpers take their operand in Rdi and return the converted value in
// Rax. They swap endianness unconditionally, which is correct on the little-endian hosts (and
// little-endian guest) the emulator targets, so network (big-endian) order is always a byte swap.
public sealed class NetExportsTests
{
private readonly CpuContext _ctx = new(new FakeCpuMemory(0x1_0000_0000, 0x1000), Generation.Gen5);
[Fact]
public void Htonl_SwapsAllFourBytes()
{
_ctx[CpuRegister.Rdi] = 0x01020304;
Assert.Equal(0, NetExports.NetHtonl(_ctx));
Assert.Equal(0x04030201UL, _ctx[CpuRegister.Rax]);
}
[Fact]
public void Ntohl_SwapsAllFourBytes()
{
_ctx[CpuRegister.Rdi] = 0x01020304;
Assert.Equal(0, NetExports.NetNtohl(_ctx));
Assert.Equal(0x04030201UL, _ctx[CpuRegister.Rax]);
}
[Fact]
public void Htons_SwapsLowTwoBytesOnly()
{
// High bits above the 16-bit short must be ignored, not folded into the result.
_ctx[CpuRegister.Rdi] = 0xFFFF_0102;
Assert.Equal(0, NetExports.NetHtons(_ctx));
Assert.Equal(0x0201UL, _ctx[CpuRegister.Rax]);
}
[Fact]
public void Ntohs_SwapsLowTwoBytesOnly()
{
_ctx[CpuRegister.Rdi] = 0xFFFF_0102;
Assert.Equal(0, NetExports.NetNtohs(_ctx));
Assert.Equal(0x0201UL, _ctx[CpuRegister.Rax]);
}
[Fact]
public void Htonl_IgnoresBitsAboveThe32BitWord()
{
_ctx[CpuRegister.Rdi] = 0xDEADBEEF_01020304;
Assert.Equal(0, NetExports.NetHtonl(_ctx));
Assert.Equal(0x04030201UL, _ctx[CpuRegister.Rax]);
}
[Theory]
[InlineData(0xDEADBEEFUL)]
[InlineData(0x00000000UL)]
[InlineData(0xFFFFFFFFUL)]
[InlineData(0x00000001UL)]
public void HtonlThenNtohl_RoundTripsToOriginal(ulong value)
{
_ctx[CpuRegister.Rdi] = value;
NetExports.NetHtonl(_ctx);
_ctx[CpuRegister.Rdi] = _ctx[CpuRegister.Rax];
NetExports.NetNtohl(_ctx);
Assert.Equal(value, _ctx[CpuRegister.Rax]);
}
// Regression guard: a non-palindromic value must not come back as 0. The functions previously
// computed the swap into Rax and then called SetReturn(0), which overwrote Rax, so every
// sceNetHtonl/Htons/Ntohl/Ntohs call returned 0 regardless of input.
[Fact]
public void ByteOrderConversions_DoNotReturnZeroForNonZeroInput()
{
_ctx[CpuRegister.Rdi] = 0x01020304;
NetExports.NetHtonl(_ctx);
Assert.NotEqual(0UL, _ctx[CpuRegister.Rax]);
_ctx[CpuRegister.Rax] = 0;
_ctx[CpuRegister.Rdi] = 0x0102;
NetExports.NetHtons(_ctx);
Assert.NotEqual(0UL, _ctx[CpuRegister.Rax]);
}
}
@@ -0,0 +1,19 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Pad;
using Xunit;
namespace SharpEmu.Libs.Tests.Pad;
public sealed class HostWindowInputTests
{
[Theory]
[InlineData(-1.0f, 0)]
[InlineData(0.0f, 128)]
[InlineData(1.0f, 255)]
public void ToStickByteMapsFullSilkRange(float value, int expected)
{
Assert.Equal((byte)expected, HostWindowInput.ToStickByte(value));
}
}
@@ -0,0 +1,33 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.Libs.Pad;
using Xunit;
namespace SharpEmu.Libs.Tests.Pad;
public sealed class PadExportsTests
{
private const ulong Base = 0x1_0000_0000;
private const int InvalidHandle = unchecked((int)0x80920003);
private readonly FakeCpuMemory _memory = new(Base, 0x1000);
private readonly CpuContext _ctx;
public PadExportsTests()
{
_ctx = new CpuContext(_memory, Generation.Gen5);
}
[Theory]
[InlineData(0, 0)]
[InlineData(1, 0)]
[InlineData(2, InvalidHandle)]
[InlineData(-1, InvalidHandle)]
public void SetTiltCorrectionState_ValidatesHandle(int handle, int expected)
{
_ctx[CpuRegister.Rdi] = unchecked((ulong)handle);
Assert.Equal(expected, PadExports.PadSetTiltCorrectionState(_ctx));
}
}
@@ -0,0 +1,209 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.PlayGo;
using Xunit;
namespace SharpEmu.Libs.Tests.PlayGo;
[CollectionDefinition("PlayGoState", DisableParallelization = true)]
public sealed class PlayGoStateCollection
{
public const string Name = "PlayGoState";
}
[Collection(PlayGoStateCollection.Name)]
public sealed class PlayGoExportsTests : IDisposable
{
private const int BadChunkId = unchecked((int)0x80B2000C);
private const byte LocusNotDownloaded = 0;
private const byte LocusLocalFast = 3;
private const ulong MemoryBase = 0x1_0000_0000;
private const ulong InitParamsAddress = MemoryBase + 0x100;
private const ulong InitBufferAddress = MemoryBase + 0x1000;
private const ulong HandleAddress = MemoryBase + 0x200;
private const ulong ChunkIdsAddress = MemoryBase + 0x300;
private const ulong LociAddress = MemoryBase + 0x400;
private readonly string? _originalApp0Root;
private readonly string _app0Root;
private readonly FakeCpuMemory _memory = new(MemoryBase, 0x10000);
private readonly CpuContext _ctx;
public PlayGoExportsTests()
{
_originalApp0Root = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
_app0Root = Path.Combine(Path.GetTempPath(), $"sharpemu-playgo-{Guid.NewGuid():N}");
Directory.CreateDirectory(_app0Root);
Environment.SetEnvironmentVariable("SHARPEMU_APP0_DIR", _app0Root);
PlayGoExports.ResetForTests();
_ctx = new CpuContext(_memory, Generation.Gen5);
}
[Fact]
public void GetLocus_MetadataFreeApp0_RejectsPastAuthoritativeDefaultChunk()
{
var handle = InitializeAndOpen();
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, GetLocus(handle, [0], 0x7F));
Assert.Equal(new byte[] { LocusLocalFast }, ReadLoci(1));
Assert.Equal(BadChunkId, GetLocus(handle, [1]));
Assert.Equal(new byte[] { LocusNotDownloaded }, ReadLoci(1));
}
[Fact]
public void GetLocus_ParsedChunkDefinitions_WritesPrefixAndRejectsFirstUnknownChunk()
{
File.WriteAllText(
Path.Combine(_app0Root, "playgo-chunkdefs.xml"),
"<playgo default_chunk=\"2\"><chunk id=\"2\"/><chunk id=\"7\"/></playgo>");
var handle = InitializeAndOpen();
Assert.Equal(BadChunkId, GetLocus(handle, [2, 3], 0xCC));
Assert.Equal(new byte[] { LocusLocalFast, LocusNotDownloaded }, ReadLoci(2));
}
[Theory]
[InlineData(UnusableMetadataKind.DatOnly)]
[InlineData(UnusableMetadataKind.MalformedChunkDefinitions)]
[InlineData(UnusableMetadataKind.UnrecognizedChunkDefinitions)]
public void GetLocus_UnparseableMetadata_RemainsPermissive(UnusableMetadataKind metadataKind)
{
switch (metadataKind)
{
case UnusableMetadataKind.DatOnly:
var sceSys = Directory.CreateDirectory(Path.Combine(_app0Root, "sce_sys"));
File.WriteAllBytes(Path.Combine(sceSys.FullName, "playgo-chunk.dat"), [0x70, 0x6C, 0x67, 0x6F]);
break;
case UnusableMetadataKind.MalformedChunkDefinitions:
File.WriteAllText(
Path.Combine(_app0Root, "playgo-chunkdefs.xml"),
"<playgo><chunk id=\"2\"></playgo>");
break;
case UnusableMetadataKind.UnrecognizedChunkDefinitions:
File.WriteAllText(Path.Combine(_app0Root, "playgo-chunkdefs.xml"), "<not-playgo/>");
break;
default:
throw new ArgumentOutOfRangeException(nameof(metadataKind), metadataKind, null);
}
var handle = InitializeAndOpen();
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, GetLocus(handle, [42]));
Assert.Equal(new byte[] { LocusLocalFast }, ReadLoci(1));
}
[Fact]
public void GetLocus_FaultingChunkAndOutputPointers_ReturnMemoryFault()
{
var handle = InitializeAndOpen();
const ulong faultingAddress = 0xDEAD_0000_0000;
Assert.True(_memory.TryWrite(LociAddress, new byte[] { 0xA5 }));
SetGetLocusArguments(handle, faultingAddress, 1, LociAddress);
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT,
PlayGoExports.PlayGoGetLocus(_ctx));
Assert.Equal(new byte[] { 0xA5 }, ReadLoci(1));
WriteChunkIds([1]);
SetGetLocusArguments(handle, ChunkIdsAddress, 1, faultingAddress);
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT,
PlayGoExports.PlayGoGetLocus(_ctx));
}
[Fact]
public void GetLocus_AllEntriesSentinel_DoesNotReadChunksOrWriteLoci()
{
var handle = InitializeAndOpen();
Assert.True(_memory.TryWrite(LociAddress, [0xA5]));
SetGetLocusArguments(handle, 0xDEAD_0000_0000, uint.MaxValue, LociAddress);
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
PlayGoExports.PlayGoGetLocus(_ctx));
Assert.Equal(new byte[] { 0xA5 }, ReadLoci(1));
}
public void Dispose()
{
PlayGoExports.ResetForTests();
Environment.SetEnvironmentVariable("SHARPEMU_APP0_DIR", _originalApp0Root);
Directory.Delete(_app0Root, recursive: true);
}
private uint InitializeAndOpen()
{
Span<byte> initParams = stackalloc byte[16];
BinaryPrimitives.WriteUInt64LittleEndian(initParams, InitBufferAddress);
BinaryPrimitives.WriteUInt32LittleEndian(initParams[8..], 0x200000);
Assert.True(_memory.TryWrite(InitParamsAddress, initParams));
_ctx[CpuRegister.Rdi] = InitParamsAddress;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
PlayGoExports.PlayGoInitialize(_ctx));
_ctx[CpuRegister.Rdi] = HandleAddress;
_ctx[CpuRegister.Rsi] = 0;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
PlayGoExports.PlayGoOpen(_ctx));
Span<byte> handleBytes = stackalloc byte[sizeof(uint)];
Assert.True(_memory.TryRead(HandleAddress, handleBytes));
return BinaryPrimitives.ReadUInt32LittleEndian(handleBytes);
}
private int GetLocus(uint handle, ushort[] chunkIds, byte? fill = null)
{
WriteChunkIds(chunkIds);
if (fill is { } fillValue)
{
var initialLoci = new byte[chunkIds.Length];
Array.Fill(initialLoci, fillValue);
Assert.True(_memory.TryWrite(LociAddress, initialLoci));
}
SetGetLocusArguments(handle, ChunkIdsAddress, (uint)chunkIds.Length, LociAddress);
return PlayGoExports.PlayGoGetLocus(_ctx);
}
private void WriteChunkIds(ushort[] chunkIds)
{
var bytes = new byte[chunkIds.Length * sizeof(ushort)];
for (var i = 0; i < chunkIds.Length; i++)
{
BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(i * sizeof(ushort)), chunkIds[i]);
}
Assert.True(_memory.TryWrite(ChunkIdsAddress, bytes));
}
private byte[] ReadLoci(int count)
{
var loci = new byte[count];
Assert.True(_memory.TryRead(LociAddress, loci));
return loci;
}
private void SetGetLocusArguments(uint handle, ulong chunkIds, uint count, ulong outLoci)
{
_ctx[CpuRegister.Rdi] = handle;
_ctx[CpuRegister.Rsi] = chunkIds;
_ctx[CpuRegister.Rdx] = count;
_ctx[CpuRegister.Rcx] = outLoci;
}
public enum UnusableMetadataKind
{
DatOnly,
MalformedChunkDefinitions,
UnrecognizedChunkDefinitions,
}
}
@@ -0,0 +1,256 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.SaveData;
using Xunit;
namespace SharpEmu.Libs.Tests.SaveData;
[CollectionDefinition("SaveDataMemoryState", DisableParallelization = true)]
public sealed class SaveDataMemoryStateCollection;
// The save data memory exports persist to a real backing file whose resolved
// path depends on process-wide environment and title configuration. The
// fixture pins both and the collection keeps other environment-mutating tests
// from running alongside.
[Collection("SaveDataMemoryState")]
public sealed class SaveDataMemoryExportsTests : IDisposable
{
private const ulong Base = 0x1_0000_0000;
private const ulong SetupParamAddress = Base + 0x100;
private const ulong SetupResultAddress = Base + 0x180;
private const ulong DataStructAddress = Base + 0x200;
private const ulong RequestAddress = Base + 0x280;
private const ulong SyncParamAddress = Base + 0x300;
private const ulong PayloadAddress = Base + 0x400;
private const ulong ReadbackAddress = Base + 0x800;
private const ulong LargePayloadAddress = Base + 0x1000;
private const ulong LargeReadbackAddress = Base + 0x40000;
private const ulong MemorySize = 0x2000;
private const ulong UnmappedAddress = Base + 0x100000;
private const int MemoryNotReady = unchecked((int)0x809F0012);
private const int ParameterError = unchecked((int)0x809F0000);
private const int UserId = 0x1001;
private const string TitleId = "SDMEMTEST";
private readonly FakeCpuMemory _memory = new(Base, 0x80000);
private readonly CpuContext _ctx;
private readonly string _root;
private readonly string? _previousRoot;
public SaveDataMemoryExportsTests()
{
_ctx = new CpuContext(_memory, Generation.Gen5);
_root = Path.Combine(Path.GetTempPath(), $"sharpemu-sdmemory-{Guid.NewGuid():N}");
_previousRoot = Environment.GetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR");
Environment.SetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR", _root);
SaveDataExports.ConfigureApplicationInfo(TitleId);
}
private string MemoryPath =>
Path.Combine(_root, UserId.ToString(), TitleId, "sce_sdmemory", "memory.dat");
public void Dispose()
{
SaveDataExports.ConfigureApplicationInfo(null);
Environment.SetEnvironmentVariable("SHARPEMU_SAVEDATA_DIR", _previousRoot);
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public void Setup_ReportsExistingSizeOnSecondCall()
{
Assert.Equal(0, Setup());
Assert.True(_ctx.TryReadUInt64(SetupResultAddress, out var existedSize));
Assert.Equal(0ul, existedSize);
Assert.Equal(0, Setup());
Assert.True(_ctx.TryReadUInt64(SetupResultAddress, out existedSize));
Assert.Equal(MemorySize, existedSize);
}
[Fact]
public void SetThenGet_RoundTripsThroughBackingFile()
{
Assert.Equal(0, Setup());
var payload = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF };
Assert.True(_ctx.Memory.TryWrite(PayloadAddress, payload));
WriteRequest(PayloadAddress, (ulong)payload.Length, offset: 0x40);
Assert.Equal(0, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke()));
WriteRequest(ReadbackAddress, (ulong)payload.Length, offset: 0x40);
Assert.Equal(0, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
var readback = new byte[payload.Length];
Assert.True(_ctx.Memory.TryRead(ReadbackAddress, readback));
Assert.Equal(payload, readback);
}
[Fact]
public void SetThenGet_LargePayload_RoundTrips()
{
const ulong size = 0x30000;
Assert.Equal(0, Setup(0x40000));
var payload = new byte[size];
for (var i = 0; i < payload.Length; i++)
{
payload[i] = (byte)(i * 31 + 7);
}
Assert.True(_ctx.Memory.TryWrite(LargePayloadAddress, payload));
WriteRequest(LargePayloadAddress, size, offset: 0x100);
Assert.Equal(0, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke()));
WriteRequest(LargeReadbackAddress, size, offset: 0x100);
Assert.Equal(0, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
var readback = new byte[size];
Assert.True(_ctx.Memory.TryRead(LargeReadbackAddress, readback));
Assert.Equal(payload, readback);
}
[Fact]
public void Setup_AbsurdSize_ReturnsParameterError()
{
Assert.Equal(ParameterError, Setup(ulong.MaxValue));
}
[Fact]
public void Setup_InvalidResultPointer_DoesNotCreateBackingFile()
{
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT,
Setup(resultAddress: UnmappedAddress));
Assert.False(File.Exists(MemoryPath));
}
[Fact]
public void Setup_InvalidResultPointer_DoesNotGrowExistingFile()
{
Assert.Equal(0, Setup(0x1000));
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT,
Setup(MemorySize, UnmappedAddress));
Assert.Equal(0x1000, new FileInfo(MemoryPath).Length);
}
[Fact]
public void Setup_GrowingExistingMemory_ZeroExtendsAndPreservesContent()
{
Assert.Equal(0, Setup(0x1000));
var payload = new byte[] { 0x11, 0x22 };
Assert.True(_ctx.Memory.TryWrite(PayloadAddress, payload));
WriteRequest(PayloadAddress, (ulong)payload.Length, offset: 0xFF0);
Assert.Equal(0, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke()));
Assert.Equal(0, Setup(MemorySize));
Assert.True(_ctx.TryReadUInt64(SetupResultAddress, out var existedSize));
Assert.Equal(0x1000ul, existedSize);
WriteRequest(ReadbackAddress, 0x20, offset: 0xFF0);
Assert.Equal(0, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
var readback = new byte[0x20];
Assert.True(_ctx.Memory.TryRead(ReadbackAddress, readback));
Assert.Equal(payload, readback[..2]);
Assert.All(readback[2..], b => Assert.Equal(0, b));
}
[Fact]
public void GetSetSync_BeforeSetup_ReturnMemoryNotReady()
{
WriteRequest(ReadbackAddress, 0x10, offset: 0);
Assert.Equal(MemoryNotReady, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
Assert.Equal(MemoryNotReady, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke()));
Assert.Equal(MemoryNotReady, Sync());
}
[Fact]
public void GetAndSync_AfterSetup_Succeed()
{
Assert.Equal(0, Setup());
WriteRequest(ReadbackAddress, 0x10, offset: 0);
Assert.Equal(0, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
Assert.Equal(0, Sync());
}
[Fact]
public void Get_BackingFileRemovedAfterSetup_ReturnsMemoryNotReady()
{
Assert.Equal(0, Setup());
Directory.Delete(_root, recursive: true);
WriteRequest(ReadbackAddress, 0x10, offset: 0);
Assert.Equal(MemoryNotReady, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
}
[Theory]
[InlineData(MemorySize, 1ul)]
[InlineData(0ul, MemorySize + 1)]
[InlineData(ulong.MaxValue, 0x10ul)]
public void Get_OutOfRange_ReturnsParameterError(ulong offset, ulong size)
{
Assert.Equal(0, Setup());
WriteRequest(ReadbackAddress, size, offset);
Assert.Equal(ParameterError, SaveDataExports.SaveDataGetSaveDataMemory2(Invoke()));
}
[Theory]
[InlineData(MemorySize, 1ul)]
[InlineData(0ul, MemorySize + 1)]
[InlineData(ulong.MaxValue, 0x10ul)]
public void Set_OutOfRange_ReturnsParameterError(ulong offset, ulong size)
{
Assert.Equal(0, Setup());
WriteRequest(PayloadAddress, size, offset);
Assert.Equal(ParameterError, SaveDataExports.SaveDataSetSaveDataMemory2(Invoke()));
}
private int Setup(ulong memorySize = MemorySize, ulong resultAddress = SetupResultAddress)
{
Span<byte> param = stackalloc byte[0x40];
param.Clear();
BinaryPrimitives.WriteInt32LittleEndian(param[0x04..], UserId);
BinaryPrimitives.WriteUInt64LittleEndian(param[0x08..], memorySize);
Assert.True(_ctx.Memory.TryWrite(SetupParamAddress, param));
_ctx[CpuRegister.Rdi] = SetupParamAddress;
_ctx[CpuRegister.Rsi] = resultAddress;
return SaveDataExports.SaveDataSetupSaveDataMemory2(_ctx);
}
private int Sync()
{
Span<byte> param = stackalloc byte[0x28];
param.Clear();
BinaryPrimitives.WriteInt32LittleEndian(param, UserId);
Assert.True(_ctx.Memory.TryWrite(SyncParamAddress, param));
_ctx[CpuRegister.Rdi] = SyncParamAddress;
return SaveDataExports.SaveDataSyncSaveDataMemory(_ctx);
}
private void WriteRequest(ulong bufAddress, ulong bufSize, ulong offset)
{
Span<byte> data = stackalloc byte[0x18];
BinaryPrimitives.WriteUInt64LittleEndian(data, bufAddress);
BinaryPrimitives.WriteUInt64LittleEndian(data[0x08..], bufSize);
BinaryPrimitives.WriteUInt64LittleEndian(data[0x10..], offset);
Assert.True(_ctx.Memory.TryWrite(DataStructAddress, data));
Span<byte> request = stackalloc byte[0x10];
request.Clear();
BinaryPrimitives.WriteInt32LittleEndian(request, UserId);
BinaryPrimitives.WriteUInt64LittleEndian(request[0x08..], DataStructAddress);
Assert.True(_ctx.Memory.TryWrite(RequestAddress, request));
}
private CpuContext Invoke()
{
_ctx[CpuRegister.Rdi] = RequestAddress;
return _ctx;
}
}
@@ -0,0 +1,133 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.VideoOut;
using Xunit;
namespace SharpEmu.Libs.Tests.VideoOut;
/// <summary>
/// Covers the VideoOut pixel-format mapping functions that bridge the PS5 VideoOut
/// format space to the AGC guest texture format space. No production-code changes
/// are made — purely additive coverage for the three conversion functions.
/// </summary>
public sealed class VideoOutPixelFormatTests
{
// ---- GetBytesPerPixel ----
[Fact]
public void GetBytesPerPixel_Known8BitRgbaFormats_Returns4()
{
Assert.Equal(4u, InvokeGetBytesPerPixel(0x80000000UL)); // A8R8G8B8Srgb
Assert.Equal(4u, InvokeGetBytesPerPixel(0x80002200UL)); // A8B8G8R8Srgb
Assert.Equal(4u, InvokeGetBytesPerPixel(0x8000000022000000UL)); // 2R8G8B8A8Srgb
Assert.Equal(4u, InvokeGetBytesPerPixel(0x8000000000000000UL)); // 2B8G8R8A8Srgb
}
[Fact]
public void GetBytesPerPixel_Known10BitFormats_Returns4()
{
Assert.Equal(4u, InvokeGetBytesPerPixel(0x88060000UL)); // A2R10G10B10
Assert.Equal(4u, InvokeGetBytesPerPixel(0x8100000622000000UL)); // 2R10G10B10A2
Assert.Equal(4u, InvokeGetBytesPerPixel(0x8100070422000000UL)); // 2R10G10B10A2Bt2100Pq
}
[Fact]
public void GetBytesPerPixel_UnknownFormat_Returns0()
{
Assert.Equal(0u, InvokeGetBytesPerPixel(0x00000000UL));
Assert.Equal(0u, InvokeGetBytesPerPixel(0xDEADBEEFUL));
Assert.Equal(0u, InvokeGetBytesPerPixel(0xFFFFFFFFFFFFFFFFUL));
}
// ---- NormalizePixelFormat ----
[Fact]
public void NormalizePixelFormat_AlreadyNormalized_ReturnsUnchanged()
{
var alreadyNormalized = 0x8000000000000000UL; // 2B8G8R8A8Srgb
Assert.Equal(alreadyNormalized, InvokeNormalizePixelFormat(alreadyNormalized));
}
[Fact]
public void NormalizePixelFormat_Low32BitsMatch_ReturnsLow32()
{
var format = 0xDEAD000088060000UL; // high bits garbage, low = A2R10G10B10
Assert.Equal(0x88060000UL, InvokeNormalizePixelFormat(format));
}
[Fact]
public void NormalizePixelFormat_High32BitsMatch_ReturnsHigh32()
{
var format = 0x8806000000000001UL; // high = A2R10G10B10, low = unknown
Assert.Equal(0x88060000UL, InvokeNormalizePixelFormat(format));
}
[Fact]
public void NormalizePixelFormat_CompletelyUnknown_ReturnsOriginal()
{
var format = 0xCAFEBABECAFEBABEUL;
Assert.Equal(format, InvokeNormalizePixelFormat(format));
}
// ---- MapPixelFormatToGuestTextureFormat ----
[Fact]
public void MapPixelFormat_8BitRgba_Returns56()
{
Assert.Equal(56u, InvokeMapPixelFormat(0x80000000UL));
Assert.Equal(56u, InvokeMapPixelFormat(0x8000000022000000UL));
Assert.Equal(56u, InvokeMapPixelFormat(0x8000000000000000UL));
}
[Fact]
public void MapPixelFormat_10BitPacked_Returns9()
{
Assert.Equal(9u, InvokeMapPixelFormat(0x88060000UL));
Assert.Equal(9u, InvokeMapPixelFormat(0x8100000622000000UL));
Assert.Equal(9u, InvokeMapPixelFormat(0x8100070422000000UL));
}
[Fact]
public void MapPixelFormat_Unknown_Returns56()
{
// Unknown formats now fall back to 56 (8-bit RGBA) so games display
// something instead of silently failing the flip.
Assert.Equal(56u, InvokeMapPixelFormat(0x00000000UL));
Assert.Equal(56u, InvokeMapPixelFormat(0xDEADBEEFUL));
}
// ---- Self-check activation ----
[Fact]
public void StaticConstructor_RunsSelfChecks_WithoutThrowing()
{
// RunPixelFormatSelfChecks is called from the static constructor.
// If the checks fail, Debug.Assert will fail in a debug build.
// This test ensures the constructor executed without throwing.
Assert.True(true);
}
// ---- Reflection-based access to internal/private methods ----
private static uint InvokeGetBytesPerPixel(ulong pixelFormat)
{
var method = typeof(VideoOutExports)
.GetMethod("GetBytesPerPixel", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
return (uint)method!.Invoke(null, [pixelFormat])!;
}
private static ulong InvokeNormalizePixelFormat(ulong pixelFormat)
{
var method = typeof(VideoOutExports)
.GetMethod("NormalizePixelFormat", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
return (ulong)method!.Invoke(null, [pixelFormat])!;
}
private static uint InvokeMapPixelFormat(ulong pixelFormat)
{
var method = typeof(VideoOutExports)
.GetMethod("MapPixelFormatToGuestTextureFormat", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
return (uint)method!.Invoke(null, [pixelFormat])!;
}
}
@@ -0,0 +1,89 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Agc;
using SharpEmu.Libs.Gpu;
using SharpEmu.Libs.VideoOut;
using Xunit;
namespace SharpEmu.Libs.Tests.VideoOut;
public sealed class VulkanDepthAttachmentTests
{
private static readonly GuestDepthTarget Target = new(
ReadAddress: 0x1000,
WriteAddress: 0x1000,
Width: 1920,
Height: 1080,
GuestFormat: 1,
SwizzleMode: 0,
ClearDepth: 1f,
ReadOnly: false);
[Fact]
public void GuestDepthTarget_AttachesForDepthWork()
{
var state = new GuestDepthState(
TestEnable: true,
WriteEnable: true,
CompareOp: 3);
Assert.True(VulkanVideoPresenter.ShouldAttachGuestDepth(Target, state));
}
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
public void GuestDepthTarget_AttachesForEitherDepthOperation(
bool testEnable,
bool writeEnable)
{
var state = new GuestDepthState(testEnable, writeEnable, CompareOp: 3);
Assert.True(VulkanVideoPresenter.ShouldAttachGuestDepth(Target, state));
}
[Fact]
public void GuestDepthTarget_RequiresTargetAndDepthWork()
{
var state = GuestDepthState.Default;
Assert.False(VulkanVideoPresenter.ShouldAttachGuestDepth(Target, state));
Assert.False(VulkanVideoPresenter.ShouldAttachGuestDepth(
target: null,
new GuestDepthState(true, false, CompareOp: 3)));
}
[Fact]
public void GuestDepthTarget_AttachesForDepthClear()
{
var state = new GuestDepthState(
TestEnable: false,
WriteEnable: false,
CompareOp: 7,
ClearEnable: true);
Assert.True(VulkanVideoPresenter.ShouldAttachGuestDepth(Target, state));
}
[Theory]
[InlineData(0x41u, true)]
[InlineData(0x40u, false)]
public void DepthState_DecodesRenderControlClearBit(
uint renderControl,
bool clearEnable)
{
var registers = new Dictionary<uint, uint>
{
[0x000] = renderControl,
[0x200] = 0x776,
};
var state = AgcExports.DecodeDepthState(registers);
Assert.True(state.TestEnable);
Assert.True(state.WriteEnable);
Assert.Equal(7u, state.CompareOp);
Assert.Equal(clearEnable, state.ClearEnable);
}
}
@@ -0,0 +1,60 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.VideoOut;
using Silk.NET.Vulkan;
using VkBuffer = Silk.NET.Vulkan.Buffer;
using Xunit;
namespace SharpEmu.Libs.Tests.VideoOut;
public sealed class VulkanHostBufferPoolTests
{
[Fact]
public void ReturnedAllocationCanBeRentedAgain()
{
var destroyed = new List<VulkanHostBufferAllocation>();
using var pool = new VulkanHostBufferPool(1024, destroyed.Add);
var key = new VulkanHostBufferPoolKey(BufferUsageFlags.StorageBufferBit, 256);
var allocation = Allocation(1, 2, key);
pool.Register(allocation);
Assert.True(pool.Return(allocation.Buffer, allocation.Memory));
Assert.Equal(256UL, pool.CachedBytes);
Assert.True(pool.TryRent(key, out var rented));
Assert.Equal(allocation, rented);
Assert.Equal(0UL, pool.CachedBytes);
Assert.Empty(destroyed);
}
[Fact]
public void ReturnDestroysAllocationThatWouldExceedBudget()
{
var destroyed = new List<VulkanHostBufferAllocation>();
using var pool = new VulkanHostBufferPool(256, destroyed.Add);
var key = new VulkanHostBufferPoolKey(BufferUsageFlags.VertexBufferBit, 512);
var allocation = Allocation(3, 4, key);
pool.Register(allocation);
Assert.True(pool.Return(allocation.Buffer, allocation.Memory));
Assert.Equal(0UL, pool.CachedBytes);
Assert.Equal([allocation], destroyed);
Assert.False(pool.TryRent(key, out _));
}
[Fact]
public void UnknownAllocationIsNotClaimedByPool()
{
using var pool = new VulkanHostBufferPool(1024, _ => { });
Assert.False(pool.Return(new VkBuffer(9), new DeviceMemory(10)));
}
private static VulkanHostBufferAllocation Allocation(
ulong buffer,
ulong memory,
VulkanHostBufferPoolKey key) =>
new(new VkBuffer(buffer), new DeviceMemory(memory), key, 0);
}

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