Compare commits

...

20 Commits

Author SHA1 Message Date
ParantezTech 3fdf76308d [readme] update readme images and change version to 0.0.2 2026-07-17 02:43:20 +03:00
ParantezTech 7b46921546 [readme] update showcase images 2026-07-16 23:26:49 +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
54 changed files with 3811 additions and 287 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

+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</SharpEmuVersion>
<Version>$(SharpEmuVersion)</Version>
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
+39 -47
View File
@@ -23,11 +23,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
<strong>Join our Discord for development updates, compatibility discussions, support, and community chat.</strong>
</p>
---
> [!NOTE]
> SharpEmu supports Windows x64, Linux x64, and macOS x64. Apple Silicon Macs
> can run the macOS x64 build through Rosetta 2.
---
> [!NOTE]
> SharpEmu supports Windows x64, Linux x64, and macOS x64. Apple Silicon Macs
> can run the macOS x64 build through Rosetta 2.
> [!WARNING]
> SharpEmu is an experimental PS5 emulator developed from scratch in C#. The current focus is on accuracy and infrastructure setup rather than game-specific compatibility.
@@ -60,51 +60,43 @@ Current capabilities include:
Some games have reached like `sceVideoOut` and AGC stages.
SharpEmu supports Windows, Linux, and macOS hosts. Video output uses Vulkan on
Windows and Linux, and MoltenVK on macOS. Platform support is still experimental,
so compatibility and performance vary by game, operating system, and GPU driver.
## Using
Download the release archive for your operating system, extract it, and launch
SharpEmu with the path to a legally obtained game's `eboot.bin`.
Windows PowerShell:
```powershell
.\SharpEmu.exe "C:\path\to\game\eboot.bin" 2>&1 |
Tee-Object -FilePath "SharpEmu.log"
```
Linux and macOS:
```bash
chmod +x ./SharpEmu
./SharpEmu "/path/to/game/eboot.bin" 2>&1 |
tee SharpEmu.log
```
A Vulkan-capable GPU and current graphics driver are required. The macOS
release includes the MoltenVK Vulkan implementation.
SharpEmu supports Windows, Linux, and macOS hosts. Video output uses Vulkan on
Windows and Linux, and MoltenVK on macOS. Platform support is still experimental,
so compatibility and performance vary by game, operating system, and GPU driver.
## Using
Download the release archive for your operating system, extract it, and launch
SharpEmu with the path to a legally obtained game's `eboot.bin`.
Windows PowerShell:
```powershell
.\SharpEmu.exe "C:\path\to\game\eboot.bin" 2>&1 |
Tee-Object -FilePath "SharpEmu.log"
```
Linux and macOS:
```bash
chmod +x ./SharpEmu
./SharpEmu "/path/to/game/eboot.bin" 2>&1 |
tee SharpEmu.log
```
A Vulkan-capable GPU and current graphics driver are required. The macOS
release includes the MoltenVK Vulkan implementation.
## 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)
| Demons Souls Remake | Dreaming Sarah |
| :-----------------------------------------------------------: | :--------------------------------------------------------------------------------------------: |
| ![Bloodborne screenshot](./.github/images/demons-souls.jpg) | ![Dreaming Sarah](./.github/images/dreaming-sarah.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)
| Void Terrarium | Dead Cells |
| :------------------------------------------------------------------------: | :------------------------------------------------------------------: |
| ![Void Terrarium](./.github/images/void-terrarium.jpg) | ![Dead Cells](./.github/images/dead-cells.jpg) |
> [!IMPORTANT]
@@ -114,7 +106,7 @@ release includes the MoltenVK Vulkan implementation.
## Build
1. Install the .NET SDK version specified in [`global.json`](./global.json).
1. Install the .NET SDK version specified in [`global.json`](./global.json).
2. Clone the repository: `git clone https://github.com/sharpemu/sharpemu.git`
3. Open the solution file (`SharpEmu.slnx`) in **VSCode**.
4. Build the project: `dotnet build` or `dotnet publish`
+5
View File
@@ -63,6 +63,11 @@ internal static partial class Program
private static int Run(string[] args)
{
if (Updater.TryApply(args, out var updateExitCode))
{
return updateExitCode;
}
args = NormalizeInternalArguments(args, out var isMitigatedChild);
if (args.Length == 0 && !isMitigatedChild)
{
@@ -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;
@@ -1067,6 +1073,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 +1171,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
text2,
resolvedExport,
IsLeafImport(text2),
IsNoBlockLeafImport(text2),
ShouldSuppressStrlenTrace(text2),
IsImportLoopGuardBoundary(text2),
StableHash64(text2));
@@ -1760,14 +1768,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 +1821,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 +3298,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 +3319,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 +3339,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;
}
}
}
}
@@ -349,10 +349,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}");
+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>
+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();
+18 -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",
@@ -142,5 +144,20 @@
"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."
}
+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."
}
+42
View File
@@ -315,6 +315,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">
@@ -322,6 +332,16 @@ SPDX-License-Identifier: GPL-2.0-or-later
<TextBlock x:Name="AboutSectionTitle"
Classes="sectionTitle"
Text="ABOUT" />
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" 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>
<Button Grid.Column="1" x:Name="UpdateButton" Classes="ghost"
Content="Check for updates" VerticalAlignment="Center" />
</Grid>
<!--Github-->
<Grid ColumnDefinitions="*,Auto">
@@ -420,6 +440,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 +484,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" />
+93 -1
View File
@@ -57,6 +57,9 @@ public partial class MainWindow : Window
private bool _isRunning;
private int _autoScrollTicks;
private int _activePageIndex;
private Updater.UpdateInfo? _availableUpdate;
private string _updateStatusKey = "Updater.Status.Ready";
private object?[] _updateStatusArgs = [BuildInfo.CommitSha ?? "dev"];
// Discord Rich Presence state.
private readonly long _launcherStartUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
@@ -131,17 +134,24 @@ public partial class MainWindow : Window
_settings.DiscordRichPresence = DiscordToggle.IsChecked == true;
UpdateDiscordPresence();
};
AutoUpdateToggle.IsCheckedChanged += (_, _) =>
_settings.CheckForUpdatesOnStartup = AutoUpdateToggle.IsChecked == true;
UpdateButton.Click += async (_, _) => await OnUpdateButtonAsync();
SelectLogFilePathButton.Click += async (_, _) => await SelectLogFilePathAsync();
EnvBthidToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle("SHARPEMU_BTHID_UNAVAILABLE", EnvBthidToggle.IsChecked == true);
EnvLoopGuardToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle("SHARPEMU_DISABLE_IMPORT_LOOP_GUARD", EnvLoopGuardToggle.IsChecked == true);
EnvWritableApp0Toggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle("SHARPEMU_WRITABLE_APP0", EnvWritableApp0Toggle.IsChecked == true);
EnvVkValidationToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle("SHARPEMU_VK_VALIDATION", EnvVkValidationToggle.IsChecked == true);
EnvDumpSpirvToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle("SHARPEMU_DUMP_SPIRV", EnvDumpSpirvToggle.IsChecked == true);
EnvLogDirectMemoryToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle("SHARPEMU_LOG_DIRECT_MEMORY", EnvLogDirectMemoryToggle.IsChecked == true);
EnvLogIoToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle("SHARPEMU_LOG_IO", EnvLogIoToggle.IsChecked == true);
EnvLogNpToggle.IsCheckedChanged += (_, _) =>
SetEnvironmentToggle("SHARPEMU_LOG_NP", EnvLogNpToggle.IsChecked == true);
LanguageBox.SelectionChanged += (_, _) => OnLanguageChanged();
@@ -371,6 +381,10 @@ public partial class MainWindow : Window
ApplySettingsToControls();
LocateEmulator();
UpdateDiscordPresence();
if (_settings.CheckForUpdatesOnStartup)
{
_ = CheckForUpdatesAsync();
}
await RescanLibraryAsync();
}
@@ -427,9 +441,11 @@ public partial class MainWindow : Window
EnvDesc.Text = loc.Get("Options.Env.Desc");
EnvBthidDesc.Text = loc.Get("Options.Env.Bthid.Desc");
EnvLoopGuardDesc.Text = loc.Get("Options.Env.LoopGuard.Desc");
EnvWritableApp0Desc.Text = loc.Get("Options.Env.WritableApp0.Desc");
EnvVkValidationDesc.Text = loc.Get("Options.Env.VkValidation.Desc");
EnvDumpSpirvDesc.Text = loc.Get("Options.Env.DumpSpirv.Desc");
EnvLogDirectMemoryDesc.Text = loc.Get("Options.Env.LogDirectMemory.Desc");
EnvLogIoDesc.Text = loc.Get("Options.Env.LogIo.Desc");
EnvLogNpDesc.Text = loc.Get("Options.Env.LogNp.Desc");
EmulationSectionTitle.Text = loc.Get("Options.Section.Emulation");
LoggingSectionTitle.Text = loc.Get("Options.Section.Logging");
@@ -472,8 +488,10 @@ public partial class MainWindow : Window
DiscordLabel.Text = loc.Get("Options.Discord.Label");
DiscordDesc.Text = loc.Get("Options.Discord.Desc");
AutoUpdateLabel.Text = loc.Get("Updater.Auto.Label");
AutoUpdateDesc.Text = loc.Get("Updater.Auto.Desc");
foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle })
foreach (var toggle in new[] { StrictToggle, LogToFileToggle, OverrideLogFileToggle, TitleMusicToggle, DiscordToggle, AutoUpdateToggle })
{
toggle.OnContent = loc.Get("Common.On");
toggle.OffContent = loc.Get("Common.Off");
@@ -497,6 +515,8 @@ public partial class MainWindow : Window
DiscordServerDesc.Text = loc.Get("About.Discord.Desc");
GithubButton.Content = loc.Get("About.GithubButton");
DiscordButton.Content = loc.Get("About.DiscordButton");
UpdateLabel.Text = loc.Get("Updater.Label");
RefreshUpdateText();
UpdateEmptyStateTexts();
UpdateSelectedGameTexts();
@@ -612,15 +632,87 @@ public partial class MainWindow : Window
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
TitleMusicToggle.IsChecked = _settings.PlayTitleMusic;
DiscordToggle.IsChecked = _settings.DiscordRichPresence;
AutoUpdateToggle.IsChecked = _settings.CheckForUpdatesOnStartup;
EnvBthidToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_BTHID_UNAVAILABLE");
EnvLoopGuardToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_DISABLE_IMPORT_LOOP_GUARD");
EnvWritableApp0Toggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_WRITABLE_APP0");
EnvVkValidationToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_VK_VALIDATION");
EnvDumpSpirvToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_DUMP_SPIRV");
EnvLogDirectMemoryToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_DIRECT_MEMORY");
EnvLogIoToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_IO");
EnvLogNpToggle.IsChecked = _settings.EnvironmentToggles.Contains("SHARPEMU_LOG_NP");
UpdateLogFilePathText();
}
private async Task OnUpdateButtonAsync()
{
if (_availableUpdate is null)
{
await CheckForUpdatesAsync();
return;
}
UpdateButton.IsEnabled = false;
try
{
var progress = new Progress<int>(value =>
SetUpdateStatus("Updater.Status.Downloading", value));
await Updater.DownloadAndRestartAsync(_availableUpdate, progress);
SetUpdateStatus("Updater.Status.Installing");
Close();
}
catch
{
SetUpdateStatus("Updater.Status.Failed");
UpdateButton.IsEnabled = true;
}
}
private async Task CheckForUpdatesAsync()
{
_availableUpdate = null;
UpdateButton.IsEnabled = false;
SetUpdateStatus("Updater.Status.Checking");
try
{
_availableUpdate = await Updater.CheckAsync(BuildInfo.CommitSha);
SetUpdateStatus(
_availableUpdate is null ? "Updater.Status.Current" : "Updater.Status.Available",
_availableUpdate?.Sha ?? BuildInfo.CommitSha ?? "dev");
}
catch (OperationCanceledException)
{
SetUpdateStatus("Updater.Status.Timeout");
}
catch (PlatformNotSupportedException)
{
SetUpdateStatus("Updater.Status.Unsupported");
}
catch
{
SetUpdateStatus("Updater.Status.Failed");
}
finally
{
UpdateButton.IsEnabled = true;
RefreshUpdateText();
}
}
private void SetUpdateStatus(string key, params object?[] args)
{
_updateStatusKey = key;
_updateStatusArgs = args;
RefreshUpdateText();
}
private void RefreshUpdateText()
{
UpdateStatusText.Text = Localization.Instance.Format(_updateStatusKey, _updateStatusArgs);
UpdateButton.Content = Localization.Instance.Get(
_availableUpdate is null ? "Updater.Check" : "Updater.DownloadRestart");
}
// Environment variables set on this process at the previous launch; children
// inherit the process environment, so stale names must be cleared explicitly.
private readonly HashSet<string> _appliedEnvironmentVariables = new(StringComparer.OrdinalIgnoreCase);
+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);
}
+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);
}
}
+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>
+39 -14
View File
@@ -5937,16 +5937,30 @@ public static partial class AgcExports
// 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 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 (HasPixelColorExport(pixelState, target.Slot))
{
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(
@@ -6398,12 +6412,23 @@ public static partial class AgcExports
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(Gen5ShaderState state, uint target)
{
// Called per render target (twice per draw via CreateRenderState +
// HasPixelColorExport); a manual scan avoids the per-call LINQ iterator
// and closure allocations. Same result as the previous
// Select/OfType/Where/Aggregate chain.
var mask = 0u;
foreach (var instruction in state.Program.Instructions)
{
if (instruction.Control is Gen5ExportControl export && export.Target == target)
{
mask |= export.EnableMask & 0xFu;
}
}
return mask;
}
private static uint GetInterpolatedAttributeCount(Gen5ShaderState state)
{
@@ -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)
{
+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
+15 -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,7 @@ 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);
}
@@ -131,7 +136,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 +171,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 +212,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 +237,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 +271,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 +315,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 +345,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 +374,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))
{
@@ -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",
@@ -259,6 +259,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",
@@ -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;
}
}
@@ -124,6 +124,7 @@ internal static unsafe class VulkanVideoPresenter
// stays tighter than the drain budget because queued draws pin their
// pooled guest-data arrays until the render thread uploads them.
private const int MaxPendingGuestWork = 64;
private const ulong MaximumCachedHostBufferBytes = 128UL * 1024 * 1024;
// A captured 4K flip can consume tens of MiB of device-local memory.
// Retain only a short presentation queue while always preserving the
// newest generation; older immutable versions are retired immediately.
@@ -608,6 +609,42 @@ internal static unsafe class VulkanVideoPresenter
shaderAddress);
}
// Manual scans (targets are <= 8) so the per-draw validation does not
// allocate LINQ iterators/closures or a Distinct HashSet.
private static bool AnyRenderTargetInvalid(IReadOnlyList<GuestRenderTarget> targets)
{
foreach (var target in targets)
{
if (target.Address == 0 || target.Width == 0 || target.Height == 0)
{
return true;
}
}
return false;
}
private static bool RenderTargetsMismatchedOrAliased(IReadOnlyList<GuestRenderTarget> targets, GuestRenderTarget first)
{
for (var i = 0; i < targets.Count; i++)
{
if (targets[i].Width != first.Width || targets[i].Height != first.Height)
{
return true;
}
for (var j = i + 1; j < targets.Count; j++)
{
if (targets[i].Address == targets[j].Address)
{
return true;
}
}
}
return false;
}
public static void SubmitOffscreenTranslatedDraw(
byte[] pixelSpirv,
IReadOnlyList<GuestDrawTexture> textures,
@@ -627,16 +664,13 @@ internal static unsafe class VulkanVideoPresenter
if (pixelSpirv.Length == 0 ||
targets.Count == 0 ||
targets.Count > 8 ||
targets.Any(target =>
target.Address == 0 || target.Width == 0 || target.Height == 0))
AnyRenderTargetInvalid(targets))
{
return;
}
var firstTarget = targets[0];
if (targets.Any(target =>
target.Width != firstTarget.Width || target.Height != firstTarget.Height) ||
targets.Select(target => target.Address).Distinct().Count() != targets.Count)
if (RenderTargetsMismatchedOrAliased(targets, firstTarget))
{
Console.Error.WriteLine(
"[LOADER][WARN] Vulkan skipped MRT draw with mismatched dimensions or aliased targets.");
@@ -654,9 +688,11 @@ internal static unsafe class VulkanVideoPresenter
var effectiveRenderState = renderState ?? GuestRenderState.Default;
if (effectiveRenderState.Blends.Count == 1 && targets.Count > 1)
{
var broadcastBlends = new GuestBlendState[targets.Count];
Array.Fill(broadcastBlends, effectiveRenderState.Blends[0]);
effectiveRenderState = effectiveRenderState with
{
Blends = Enumerable.Repeat(effectiveRenderState.Blends[0], targets.Count).ToArray(),
Blends = broadcastBlends,
};
}
lock (_gate)
@@ -2357,9 +2393,7 @@ internal static unsafe class VulkanVideoPresenter
new(ReferenceEqualityComparer.Instance);
private readonly Dictionary<DescriptorLayoutKey, DescriptorLayoutBundle>
_descriptorLayouts = new();
private readonly Dictionary<HostBufferPoolKey, Stack<HostBufferAllocation>>
_hostBufferPool = new();
private readonly Dictionary<ulong, HostBufferAllocation> _hostBufferAllocations = new();
private readonly VulkanHostBufferPool _hostBufferPool;
private readonly List<GuestBufferAllocation> _guestBufferAllocations = [];
private readonly Queue<PendingGuestSubmission> _pendingGuestSubmissions = new();
private readonly Dictionary<string, ulong> _lastSubmittedTimelineByGuestQueue =
@@ -2381,10 +2415,6 @@ internal static unsafe class VulkanVideoPresenter
GuestRasterState Raster,
GuestDepthState Depth);
private readonly record struct HostBufferPoolKey(
BufferUsageFlags Usage,
ulong Capacity);
private readonly record struct DescriptorLayoutKey(
ShaderStageFlags Stages,
string Resources);
@@ -2397,12 +2427,6 @@ internal static unsafe class VulkanVideoPresenter
DescriptorSetLayout DescriptorSetLayout,
PipelineLayout PipelineLayout);
private sealed record HostBufferAllocation(
VkBuffer Buffer,
DeviceMemory Memory,
HostBufferPoolKey Key,
nint Mapped);
private readonly record struct DirtyGuestBufferRange(ulong Offset, ulong Length);
private sealed class GuestBufferAllocation
@@ -2592,6 +2616,9 @@ internal static unsafe class VulkanVideoPresenter
public Presenter(uint width, uint height)
{
_hostBufferPool = new VulkanHostBufferPool(
MaximumCachedHostBufferBytes,
DestroyHostBufferAllocation);
var options = WindowOptions.DefaultVulkan;
options.Size = new Vector2D<int>((int)DefaultWindowWidth, (int)DefaultWindowHeight);
options.Title = VideoOutExports.GetWindowTitle();
@@ -2646,6 +2673,12 @@ internal static unsafe class VulkanVideoPresenter
}
}
if (PngSplashLoader.TryLoadIcon(out var iconPixels, out var iconWidth, out var iconHeight))
{
var icon = new RawImage((int)iconWidth, (int)iconHeight, iconPixels);
_window.SetWindowIcon(ref icon);
}
WaitForRenderDocAttachIfRequested();
_vk = Vk.GetApi();
CreateInstance();
@@ -4154,6 +4187,48 @@ internal static unsafe class VulkanVideoPresenter
_submitTimeline;
}
private void TransitionNewGuestImageToSampled(Image image, uint mipLevels)
{
var commandBuffer = AllocateGuestCommandBuffer();
var beginInfo = new CommandBufferBeginInfo
{
SType = StructureType.CommandBufferBeginInfo,
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
};
Check(
_vk.BeginCommandBuffer(commandBuffer, &beginInfo),
"vkBeginCommandBuffer(guest image init)");
var barrier = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = 0,
DstAccessMask = AccessFlags.ShaderReadBit,
OldLayout = ImageLayout.Undefined,
NewLayout = ImageLayout.ShaderReadOnlyOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = image,
SubresourceRange = ColorSubresourceRange(0, mipLevels),
};
_vk.CmdPipelineBarrier(
commandBuffer,
PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.AllCommandsBit,
0,
0,
null,
0,
null,
1,
&barrier);
Check(
_vk.EndCommandBuffer(commandBuffer),
"vkEndCommandBuffer(guest image init)");
// Same-queue submission order makes the transition visible to any
// later use of the image; no CPU-side wait is needed.
SubmitGuestCommandBuffer(commandBuffer, [], []);
}
private void EnsureGuestSubmissionCapacity()
{
CollectCompletedGuestSubmissions(waitForOldest: false);
@@ -4214,23 +4289,43 @@ internal static unsafe class VulkanVideoPresenter
return;
}
Check(result, $"vkWaitForFences(guest: {oldest.DebugName})");
if (result == Result.ErrorDeviceLost)
{
_deviceLost = true;
}
else
{
Check(result, $"vkWaitForFences(guest: {oldest.DebugName})");
}
}
while (_pendingGuestSubmissions.TryPeek(out var submission))
{
var status = _vk.GetFenceStatus(_device, submission.Fence);
if (status == Result.NotReady)
if (status == Result.NotReady && !_deviceLost)
{
break;
}
Check(status, $"vkGetFenceStatus(guest: {submission.DebugName})");
if (status == Result.ErrorDeviceLost)
{
// Pending fences never signal on a lost device; retire the
// submission anyway so teardown and back-pressure survive.
_deviceLost = true;
}
else if (status != Result.NotReady)
{
Check(status, $"vkGetFenceStatus(guest: {submission.DebugName})");
}
_pendingGuestSubmissions.Dequeue();
foreach (var image in submission.TraceImages)
if (!_deviceLost)
{
TraceGuestImageContents(image);
foreach (var image in submission.TraceImages)
{
TraceGuestImageContents(image);
}
}
foreach (var resources in submission.Resources)
@@ -4508,13 +4603,20 @@ internal static unsafe class VulkanVideoPresenter
$"queue={_activeGuestQueue.Name} submission={_activeGuestQueue.SubmissionId} " +
$"handle={work.VideoOutHandle} index={work.DisplayBufferIndex} " +
$"capture_complete={(captured ? 1 : 0)}");
#if DEBUG
System.Diagnostics.Debug.Assert(
work.Version == 0 || captured,
"An ordered wait-safe marker must execute after its flip capture.");
#endif
// Demon's Souls executes wait-safe markers before their flip capture;
// an assert here would fail-fast the process, so warn once instead.
// Dedup on a flag, not the (per-frame-unique) version, to bound growth.
if (work.Version != 0 && !captured && !_loggedFlipWaitOrderViolation)
{
_loggedFlipWaitOrderViolation = true;
Console.Error.WriteLine(
$"[LOADER][WARN] vk.flip_wait_order version={work.Version} " +
"executed before its flip capture; continuing.");
}
}
private bool _loggedFlipWaitOrderViolation;
private GuestImageResource CreateGuestFlipSnapshot(
GuestImageResource source,
long version)
@@ -5241,7 +5343,8 @@ internal static unsafe class VulkanVideoPresenter
resources.IndexBuffer = CreateHostBuffer(
indexBuffer.Data.AsSpan(0, indexBuffer.Length),
BufferUsageFlags.IndexBufferBit,
out resources.IndexMemory);
out resources.IndexMemory,
out _);
resources.Index32Bit = indexBuffer.Is32Bit;
if (indexBuffer.Pooled)
{
@@ -6764,7 +6867,8 @@ internal static unsafe class VulkanVideoPresenter
var vkFormat = GetTextureFormat(texture.Format, texture.NumberType);
var expectedSize = GetTextureByteCount(texture.Format, rowLength, height);
if (_tracedTextureUploads.Add((texture.Address, width, height, vkFormat)))
if (ShouldTraceVulkanResources() &&
_tracedTextureUploads.Add((texture.Address, width, height, vkFormat)))
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.texture addr=0x{texture.Address:X16} " +
@@ -7536,8 +7640,8 @@ internal static unsafe class VulkanVideoPresenter
var buffer = CreateHostBuffer(
guestBuffer.Data.AsSpan(0, guestBuffer.Length),
BufferUsageFlags.StorageBufferBit,
out var memory);
var allocation = _hostBufferAllocations[buffer.Handle];
out var memory,
out var mapped);
if (guestBuffer.Pooled)
{
GuestDataPool.Return(guestBuffer.Data);
@@ -7550,7 +7654,7 @@ internal static unsafe class VulkanVideoPresenter
WriteBackToGuest = false,
Buffer = buffer,
Memory = memory,
Mapped = allocation.Mapped,
Mapped = mapped,
Offset = 0,
Size = (ulong)Math.Max(guestBuffer.Length, sizeof(uint)),
GuestOffset = 0,
@@ -7770,7 +7874,8 @@ internal static unsafe class VulkanVideoPresenter
var buffer = CreateHostBuffer(
source,
BufferUsageFlags.VertexBufferBit,
out var memory);
out var memory,
out _);
var size = (ulong)Math.Max(guestBuffer.Length, sizeof(uint));
if (_setDebugUtilsObjectName is not null)
{
@@ -7839,19 +7944,15 @@ internal static unsafe class VulkanVideoPresenter
private VkBuffer CreateHostBuffer(
ReadOnlySpan<byte> data,
BufferUsageFlags usage,
out DeviceMemory memory)
out DeviceMemory memory,
out nint mapped)
{
var size = (ulong)Math.Max(data.Length, sizeof(uint));
var capacity = BitOperations.RoundUpToPowerOf2(size);
var key = new HostBufferPoolKey(usage, capacity);
if (!_hostBufferPool.TryGetValue(key, out var available))
{
available = new Stack<HostBufferAllocation>();
_hostBufferPool.Add(key, available);
}
var key = new VulkanHostBufferPoolKey(usage, capacity);
HostBufferAllocation allocation;
if (available.TryPop(out var pooled))
VulkanHostBufferAllocation allocation;
if (_hostBufferPool.TryRent(key, out var pooled))
{
allocation = pooled;
}
@@ -7869,15 +7970,16 @@ internal static unsafe class VulkanVideoPresenter
Check(
_vk.MapMemory(_device, allocatedMemory, 0, capacity, 0, &persistentMapping),
"vkMapMemory(host persistent)");
allocation = new HostBufferAllocation(
allocation = new VulkanHostBufferAllocation(
buffer,
allocatedMemory,
key,
(nint)persistentMapping);
_hostBufferAllocations.Add(buffer.Handle, allocation);
_hostBufferPool.Register(allocation);
}
memory = allocation.Memory;
mapped = allocation.Mapped;
fixed (byte* source = data)
{
System.Buffer.MemoryCopy(
@@ -7897,10 +7999,8 @@ internal static unsafe class VulkanVideoPresenter
return;
}
if (_hostBufferAllocations.TryGetValue(buffer.Handle, out var allocation) &&
allocation.Memory.Handle == memory.Handle)
if (_hostBufferPool.Return(buffer, memory))
{
_hostBufferPool[allocation.Key].Push(allocation);
return;
}
@@ -7911,6 +8011,13 @@ internal static unsafe class VulkanVideoPresenter
}
}
private void DestroyHostBufferAllocation(VulkanHostBufferAllocation allocation)
{
_vk.UnmapMemory(_device, allocation.Memory);
_vk.DestroyBuffer(_device, allocation.Buffer, null);
_vk.FreeMemory(_device, allocation.Memory, null);
}
private static PrimitiveTopology GetPrimitiveTopology(uint primitiveType) =>
primitiveType switch
{
@@ -8294,7 +8401,7 @@ internal static unsafe class VulkanVideoPresenter
return (properties.OptimalTilingFeatures & FormatFeatureFlags.ColorAttachmentBit) != 0;
}
private static Format GetTextureFormat(uint format, uint numberType) =>
internal static Format GetTextureFormat(uint format, uint numberType) =>
(format, numberType) switch
{
(9, _) => Format.A2B10G10R10UnormPack32,
@@ -10164,6 +10271,9 @@ internal static unsafe class VulkanVideoPresenter
_vk.AllocateMemory(_device, &allocationInfo, null, out var memory),
"vkAllocateMemory(offscreen)");
Check(_vk.BindImageMemory(_device, image, memory, 0), "vkBindImageMemory(offscreen)");
// Rendering and uploads only define the mips they touch; define the whole
// chain once so full-chain sampled binds never read Undefined layout.
TransitionNewGuestImageToSampled(image, mipLevels);
var viewInfo = new ImageViewCreateInfo
{
@@ -10931,7 +11041,7 @@ internal static unsafe class VulkanVideoPresenter
return view;
}
private static bool IsCompatibleViewFormat(Format imageFormat, Format viewFormat)
internal static bool IsCompatibleViewFormat(Format imageFormat, Format viewFormat)
{
if (imageFormat == viewFormat)
{
@@ -10997,6 +11107,23 @@ internal static unsafe class VulkanVideoPresenter
}
private void Render(double _)
{
try
{
RenderCore();
}
catch (Exception exception)
{
// Device loss can strike between any two Vulkan calls in the frame;
// keep the window loop pumping instead of tearing the presenter down.
if (!TryMarkDeviceLost(exception))
{
throw;
}
}
}
private void RenderCore()
{
if (Volatile.Read(ref _presenterCloseRequested))
{
@@ -11010,6 +11137,18 @@ internal static unsafe class VulkanVideoPresenter
return;
}
if (_deviceLost)
{
// Drain queued work so producers aren't back-pressured, then
// return without any Vulkan call (fences never signal post-loss).
while (TryTakeGuestWork(out var lostWork))
{
CompleteGuestWork(lostWork);
}
return;
}
// Reuse of a frame slot waits only on that slot's fence, keeping
// up to MaxFramesInFlight frames pipelined between CPU and GPU.
var frameSlot = _currentFrameSlot;
@@ -13592,13 +13731,7 @@ internal static unsafe class VulkanVideoPresenter
DestroyGuestBufferAllocation(allocation);
}
_guestBufferAllocations.Clear();
foreach (var allocation in _hostBufferAllocations.Values)
{
_vk.DestroyBuffer(_device, allocation.Buffer, null);
_vk.FreeMemory(_device, allocation.Memory, null);
}
_hostBufferAllocations.Clear();
_hostBufferPool.Clear();
_hostBufferPool.Dispose();
foreach (var guestImage in _guestImages.Values)
{
DestroyGuestImage(guestImage);
@@ -13830,20 +13963,38 @@ internal static unsafe class VulkanVideoPresenter
return;
}
if (result == Result.ErrorDeviceLost)
{
throw new VulkanDeviceLostException(operation);
}
throw new InvalidOperationException($"{operation} failed with {result}.");
}
private static void Check(Result result, string operation)
{
if (result == Result.ErrorDeviceLost)
{
throw new VulkanDeviceLostException(operation);
}
if (result != Result.Success)
{
throw new InvalidOperationException($"{operation} failed with {result}.");
}
}
// Typed so the frame-boundary catch can recognize device loss without
// depending on the exact wording of the exception message.
private sealed class VulkanDeviceLostException(string operation)
: InvalidOperationException($"{operation} failed with {Result.ErrorDeviceLost}.");
private bool TryMarkDeviceLost(Exception exception)
{
if (!exception.Message.Contains(nameof(Result.ErrorDeviceLost), StringComparison.Ordinal))
// Prefer the typed signal; fall back to the message for losses that
// surface through other layers (e.g. Silk.NET bindings).
if (exception is not VulkanDeviceLostException &&
!exception.Message.Contains(nameof(Result.ErrorDeviceLost), StringComparison.Ordinal))
{
return false;
}
@@ -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,
@@ -1172,9 +1172,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 +1296,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 +1423,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 +1534,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 +1825,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(
@@ -1794,10 +1852,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 +1880,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 +1913,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 +2022,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,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,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,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;
}
}
}
@@ -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,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);
}
@@ -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,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,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);
}