Compare commits

...

32 Commits

Author SHA1 Message Date
ParantezTech 0945b2c000 [VideoPresenter] Fix logical width/height calculation 2026-07-20 16:48:36 +03:00
TarkusTK 25d741b35b [Gpu] Sample 2D array textures with real layers (#471)
Texture arrays were uploaded and viewed as plain 2D images, so every
layer index in a shader resolved to slice 0. The guest-texture gate
also rejected array, 3D and cube descriptor types outright, sending
those resources to the 1x1 black fallback.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Remove flaky Gen5ScalarMemoryFallbackTests

* Restore Gen5ScalarMemoryFallbackTests

---------

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

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

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

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

(cherry picked from commit 7af6f4b6f314fe302619c0d44f4db00971c5bf24)

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

* [Libs] Implement sceAgcGetIsTrinityMode, NpReachability and Trophy2 info

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

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

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

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

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

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

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

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

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

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

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

  send maps WouldBlock onto the existing net error path.

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

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

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

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

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

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

* [Kernel] Implement the POSIX _nanosleep symbol

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

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

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

* [Kernel] Implement the POSIX-named pthread aliases

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

v2 inserts a memoryType argument ahead of v1's protection, shifting the
remaining arguments down one register and pushing alignment onto the
stack. Extract v1's body into a shared MapDirectMemoryCore and route both
exports through it; v2 reads its shifted arguments and the stack alignment
and accepts the memoryType (which only selects cache/GPU attributes this
HLE does not model per mapping, so it does not affect placement).
2026-07-19 14:35:40 +03:00
cse.aadi e56e74f960 Fix space-in-path game launching on Windows (#432) 2026-07-19 14:25:43 +03:00
kadu04t 0f224ec036 Gui Settings Null list Entries (#430) 2026-07-19 13:53:56 +03:00
kostyaff 85dc98dedc test: add Fiber exports contract tests (13 tests) (#428) 2026-07-19 13:53:33 +03:00
wearr 5d7d8e0edd [Kernel] add NID B5GmVDKwpn0 (pthread_yield) (#426) 2026-07-19 13:49:51 +03:00
wearr a60bfc9c83 [Kernel] Implement pthread semaphore exports (#424) 2026-07-19 04:18:16 +03:00
Berk 0b83b34cda chore: bump version to 0.0.2-beta.4 (#423) 2026-07-19 03:42:52 +03:00
68 changed files with 5591 additions and 304 deletions
+29 -26
View File
@@ -89,7 +89,6 @@ jobs:
DOTNET_NOLOGO: true
NUGET_PACKAGES: ${{ github.workspace }}\.nuget\packages
PUBLISH_DIR: ${{ github.workspace }}\artifacts\publish\win-x64
RELEASE_DIR: ${{ github.workspace }}\artifacts\release
steps:
- name: Checkout repository
uses: actions/checkout@v6
@@ -121,24 +120,13 @@ jobs:
- name: Publish win-x64 CLI
run: dotnet publish src/SharpEmu.CLI/SharpEmu.CLI.csproj -c Release -r win-x64 --self-contained true --no-restore -p:PublishDir="${env:PUBLISH_DIR}"
- name: Create release archive
run: |
New-Item -ItemType Directory -Path $env:RELEASE_DIR -Force | Out-Null
$archiveName = "sharpemu-${{ needs.init.outputs.version }}-win-x64.zip"
$archivePath = Join-Path $env:RELEASE_DIR $archiveName
if (Test-Path $archivePath) {
Remove-Item $archivePath -Force
}
Compress-Archive -Path (Join-Path $env:PUBLISH_DIR '*') -DestinationPath $archivePath -CompressionLevel Optimal
- name: Upload build artifact
uses: actions/upload-artifact@v7
with:
name: sharpemu-win-x64-${{ needs.init.outputs.short-sha }}
path: ${{ env.RELEASE_DIR }}\sharpemu-${{ needs.init.outputs.version }}-win-x64.zip
path: ${{ env.PUBLISH_DIR }}
if-no-files-found: error
include-hidden-files: true
build-posix:
name: Build ${{ matrix.rid }}
@@ -158,7 +146,6 @@ jobs:
DOTNET_NOLOGO: true
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
PUBLISH_DIR: ${{ github.workspace }}/artifacts/publish/${{ matrix.rid }}
RELEASE_DIR: ${{ github.workspace }}/artifacts/release
SPIRV_HEADERS_COMMIT: ad9184e76a66b1001c29db9b0a3e87f646c64de0
# SpirvModuleBuilder emits SPIR-V 1.5 and VulkanVideoPresenter requests Vulkan 1.2.
SPIRV_TARGET_ENV: vulkan1.2
@@ -223,19 +210,13 @@ jobs:
if: matrix.rid == 'osx-x64'
run: scripts/fetch-macos-moltenvk.sh "$PUBLISH_DIR"
- name: Create release archive
run: |
mkdir -p "$RELEASE_DIR"
# tar keeps the executable bit, which zip would drop.
tar -czf "$RELEASE_DIR/sharpemu-${{ needs.init.outputs.version }}-${{ matrix.rid }}.tar.gz" \
-C "$PUBLISH_DIR" .
- name: Upload build artifact
uses: actions/upload-artifact@v7
with:
name: sharpemu-${{ matrix.rid }}-${{ needs.init.outputs.short-sha }}
path: ${{ env.RELEASE_DIR }}/sharpemu-${{ needs.init.outputs.version }}-${{ matrix.rid }}.tar.gz
path: ${{ env.PUBLISH_DIR }}
if-no-files-found: error
include-hidden-files: true
release:
name: Publish GitHub Release
@@ -255,6 +236,28 @@ jobs:
with:
path: release
- name: Package release assets
shell: bash
env:
SHORT_SHA: ${{ needs.init.outputs.short-sha }}
VERSION: ${{ needs.init.outputs.version }}
run: |
set -euo pipefail
win_dir="release/sharpemu-win-x64-${SHORT_SHA}"
linux_dir="release/sharpemu-linux-x64-${SHORT_SHA}"
macos_dir="release/sharpemu-osx-x64-${SHORT_SHA}"
for package_dir in "${win_dir}" "${linux_dir}" "${macos_dir}"; do
test -d "${package_dir}"
done
mkdir -p release-assets
(cd "${win_dir}" && zip -q -r "../../release-assets/sharpemu-${VERSION}-win-x64.zip" .)
chmod +x "${linux_dir}/SharpEmu" "${macos_dir}/SharpEmu"
tar -czf "release-assets/sharpemu-${VERSION}-linux-x64.tar.gz" -C "${linux_dir}" .
tar -czf "release-assets/sharpemu-${VERSION}-osx-x64.tar.gz" -C "${macos_dir}" .
- name: Create release
shell: bash
env:
@@ -264,9 +267,9 @@ jobs:
RELEASE_TAG: ${{ needs.init.outputs.release-tag }}
VERSION: ${{ needs.init.outputs.version }}
run: |
mapfile -t assets < <(find release -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release assets found." >&2
mapfile -t assets < <(find release-assets -maxdepth 1 -type f \( -name '*.zip' -o -name '*.tar.gz' \) | sort)
if [ "${#assets[@]}" -ne 3 ]; then
echo "Expected 3 release assets, found ${#assets[@]}." >&2
exit 1
fi
+1
View File
@@ -42,3 +42,4 @@ ehthumbs.db
.vs/
.idea/
.vscode/
+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.2-beta.3</SharpEmuVersion>
<SharpEmuVersion>0.0.2-beta.4</SharpEmuVersion>
<Version>$(SharpEmuVersion)</Version>
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
+57
View File
@@ -0,0 +1,57 @@
<!--
Copyright (C) 2026 SharpEmu Emulator Project
SPDX-License-Identifier: GPL-2.0-or-later
-->
# Guest write watch
`GuestWriteWatch` is an optional diagnostic tool. It helps you find managed
code and HLE code that damage guest memory. The tool starts only if you set one
or more `SHARPEMU_WATCH_*` environment variables.
The tool monitors writes through the SharpEmu managed virtual-memory APIs. It
does not monitor stores that native guest code makes directly. Use a platform
debugger or a hardware watchpoint to monitor these stores.
## Watch modes
- `SHARPEMU_WATCH_WRITE=0x<address>` logs a write that overlaps the eight-byte
block at the specified guest address.
- `SHARPEMU_WATCH_POOL_HEADER=1` monitors the pointer at offset `0x40`. It
monitors the first 64 direct mappings that have a size of 64 KiB and
protection value `0xF2`.
- `SHARPEMU_WATCH_VALUE_PATTERN=1` logs an eight-byte write if its lower 32 bits
are `1`. The upper 32 bits must look like a small guest-pointer prefix.
- `SHARPEMU_WATCH_VALUE1=1` logs short writes of value `1` in the high guest
memory range. The tool logs a maximum of 128 entries for each process.
- `SHARPEMU_WATCH_BULK_TORN=1` scans aligned 64-bit words in bulk writes. It
finds damaged pointer patterns and byte-shifted pointer patterns. The tool
logs a maximum of 64 entries for each process.
- `SHARPEMU_WATCH_BULK_DEST_HI=0x<high-dword>` scans only writes that have the
specified upper 32 bits in the destination address.
For each match, the tool logs the destination address, the data pattern, and the
managed call stack. The log uses the `watch_write` or `watch_bulk_torn` warning
tag.
Use these variables together to scan bulk writes in the
`0x00000080xxxxxxxx` region.
macOS and Linux:
```sh
SHARPEMU_WATCH_BULK_TORN=1 \
SHARPEMU_WATCH_BULK_DEST_HI=0x80 \
SharpEmu /path/to/eboot.bin
```
Windows PowerShell:
```powershell
$env:SHARPEMU_WATCH_BULK_TORN = "1"
$env:SHARPEMU_WATCH_BULK_DEST_HI = "0x80"
& .\SharpEmu.exe C:\path\to\game\eboot.bin
```
To reduce unnecessary log entries, use an exact `SHARPEMU_WATCH_WRITE`
address from a crash dump.
+2 -2
View File
@@ -607,7 +607,7 @@ internal static partial class Program
nint jobHandle = 0;
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
var created = CreateProcessW(
processPath,
null,
cmdLineBuilder,
0,
0,
@@ -1433,7 +1433,7 @@ internal static partial class Program
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateProcessW(
string applicationName,
string? applicationName,
StringBuilder commandLine,
nint processAttributes,
nint threadAttributes,
+5
View File
@@ -13,4 +13,9 @@ SPDX-License-Identifier: GPL-2.0-or-later
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<asmv3:windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</asmv3:windowsSettings>
</asmv3:application>
</assembly>
@@ -0,0 +1,71 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Core.Cpu.Emulation;
/// <summary>
/// Pure software implementation of the bit-field math behind AMD's SSE4a EXTRQ/INSERTQ
/// (immediate-form) instructions.
///
/// The direct-execution backend runs guest PS5 code natively on the host CPU. The PS5's Zen 2
/// cores implement AMD-only SSE4a (EXTRQ/INSERTQ), but Intel hosts - and Rosetta 2 on Apple
/// Silicon - do not, so they raise #UD (STATUS_ILLEGAL_INSTRUCTION) instead of executing the
/// opcode. SharpEmu already rewrites one specific compiled EXTRQ+VPBLENDD idiom at load time
/// (see <see cref="Native.Sse4aExtrqBlendPatch"/>), but any other occurrence of EXTRQ/INSERTQ -
/// a different register allocation, a title built with a different compiler version, and so on
/// - still aborts the title. This class ported from Kyty's
/// <c>Loader::X64InstructionEmulator::TryEmulateSse4a</c> provides the general bit-field
/// extract/insert so the illegal-instruction handler can finish *any* immediate-form
/// EXTRQ/INSERTQ in software and resume, instead of relying on a single hard-coded byte pattern.
///
/// The methods operate on plain 64-bit integers rather than the OS CONTEXT record so the bit
/// math can be unit-tested in isolation; the unsafe CONTEXT/XMM plumbing lives in the backend
/// adapter (<see cref="Native.DirectExecutionBackend"/>).
/// </summary>
public static class Sse4aBitFieldEmulator
{
public static bool IsValidBitField(int length, int index)
{
var len = length & 0x3F;
var idx = index & 0x3F;
return (len != 0 || idx == 0) && (len == 0 ? idx == 0 : idx + len <= 64);
}
public static ulong ExtractBitField(ulong value, int length, int index)
{
var len = length & 0x3F;
var idx = index & 0x3F;
if (!IsValidBitField(length, index))
{
return 0;
}
if (len == 0)
{
return value;
}
var mask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
return (value >> idx) & mask;
}
public static ulong InsertBitField(ulong destination, ulong source, int length, int index)
{
var len = length & 0x3F;
var idx = index & 0x3F;
if (!IsValidBitField(length, index))
{
return destination;
}
if (len == 0)
{
return source;
}
var fieldMask = len == 64 ? ulong.MaxValue : (1UL << len) - 1;
var destinationClearMask = fieldMask << idx;
var sourceField = (source & fieldMask) << idx;
return (destination & ~destinationClearMask) | sourceField;
}
}
@@ -0,0 +1,197 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Threading;
using Iced.Intel;
using SharpEmu.Core.Cpu.Emulation;
namespace SharpEmu.Core.Cpu.Native;
// General software fallback for the AMD-only instructions PS5 titles occasionally emit that a
// Zen 2-only host implements but Intel hosts (and Rosetta 2 on Apple Silicon) do not:
// - SSE4a EXTRQ/INSERTQ, immediate form
// - MONITORX/MWAITX
//
// This is a direct port of Kyty's Loader::X64InstructionEmulator (TryEmulateSse4a /
// TryEmulateMonitorxMwaitx). SharpEmu already special-cases exactly one compiled EXTRQ+VPBLENDD
// byte sequence at load time (Sse4aExtrqBlendPatch), which only helps the one idiom it was
// reverse-engineered from. This file is a general, fault-time fallback that engages for any
// immediate-form EXTRQ/INSERTQ or MONITORX/MWAITX the narrower patch (or a title using a
// different compiler/register allocation) does not cover, complementing rather than replacing
// it: the load-time patch still avoids paying the fault-and-recover cost on the hot path it was
// built for, while this method is the safety net for everything else.
//
// This is deliberately additive: DirectExecutionBackend.IllegalInstruction.cs (the BMI1/BMI2/ABM
// fallback) is untouched, and this method is only reached from VectoredHandler after that one
// has already declined to handle the fault.
public sealed partial class DirectExecutionBackend
{
// Byte offset of Xmm0 within the Win64 CONTEXT record: FltSave (the XMM_SAVE_AREA32/FXSAVE
// image) starts right after Rip at offset 256, and XmmRegisters[0] sits 160 bytes into that
// area (32-byte header + 8 legacy x87/MMX slots x 16 bytes). 256 + 160 = 416 (0x1A0). Cross-
// checked against this file's own Win64ContextSize (0x4D0): rebuilding the whole CONTEXT
// layout field-by-field from offset 0 lands on the same 0x4D0 total, which would not happen
// if this offset (or anything before it) were wrong.
private const int Win64ContextXmm0Offset = 0x1A0;
private static int _sse4aSoftwareFallbackAnnounced;
private static long _sse4aInstructionsEmulated;
private static int _monitorxSoftwareFallbackAnnounced;
private static long _monitorxInstructionsEmulated;
private unsafe bool TryRecoverAmdCompatInstruction(void* contextRecord, ulong rip)
{
if (TryRecoverMonitorxMwaitx(contextRecord, rip))
{
return true;
}
// MONITORX/MWAITX above only ever reads guest code memory and rewrites RIP, both of
// which the POSIX signal bridge (DirectExecutionBackend.PosixSignals.cs) faithfully
// round-trips through the real ucontext, so it works on every supported OS. EXTRQ/
// INSERTQ additionally read and write an XMM register: on Windows contextRecord is the
// live CONTEXT the OS resumes the thread from, so touching the Xmm0.. slots is visible
// to the guest, but on POSIX contextRecord is a CONTEXT-shaped scratch buffer that the
// bridge only populates with the 17 general-purpose registers - the XMM region is never
// read from or written back to the real mcontext/ucontext. Running this on POSIX would
// silently compute a result from stale/zeroed XMM bytes and then discard whatever it
// "wrote", so keep it Windows-only, matching Kyty's own scope for the identical fix.
return OperatingSystem.IsWindows() && TryRecoverSse4aExtractInsert(contextRecord, rip);
}
private unsafe bool TryRecoverMonitorxMwaitx(void* contextRecord, ulong rip)
{
// MONITORX (0F 01 FA) and MWAITX (0F 01 FB) are fixed 3-byte encodings with no
// ModRM/SIB/displacement/immediate, so a raw byte compare is sufficient and unambiguous.
var opcode = new byte[3];
if (!TryReadHostBytes(rip, opcode) ||
opcode[0] != 0x0F || opcode[1] != 0x01 || (opcode[2] != 0xFA && opcode[2] != 0xFB))
{
return false;
}
// PS5 titles use this pair in idle/wait loops: MONITORX arms a monitor on a cache line
// and MWAITX blocks until that line is written (or a timeout elapses). Hosts without
// the extension raise #UD on either one. We do not model the monitor itself, only its
// observable effect on guest forward progress: MONITORX becomes a no-op (arming a
// watch we never honour has no side effect of its own) and MWAITX becomes a plain
// thread yield, i.e. treat the awaited condition as already satisfied so the guest
// loop keeps making progress instead of executing an illegal opcode forever.
if (opcode[2] == 0xFB)
{
Thread.Yield();
}
WriteCtxU64(contextRecord, CTX_RIP, rip + 3);
Interlocked.Increment(ref _monitorxInstructionsEmulated);
if (Interlocked.Exchange(ref _monitorxSoftwareFallbackAnnounced, 1) == 0)
{
Console.Error.WriteLine(
"[LOADER][INFO] Host lacks AMD MONITORX/MWAITX used by the guest; " +
"emulating those instructions in software.");
}
return true;
}
private unsafe bool TryRecoverSse4aExtractInsert(void* contextRecord, ulong rip)
{
if (!OperatingSystem.IsWindows() || !TryReadFaultingInstruction(rip, out var instruction))
{
return false;
}
var isExtrq = instruction.Mnemonic == Mnemonic.Extrq;
var isInsertq = instruction.Mnemonic == Mnemonic.Insertq;
if (!isExtrq && !isInsertq)
{
return false;
}
if (isExtrq && instruction.OpCount != 3 || isInsertq && instruction.OpCount != 4)
{
return false;
}
if (instruction.GetOpKind(0) != OpKind.Register ||
!TryGetXmmOffset(instruction.GetOpRegister(0), out var destOffset))
{
return false;
}
var destLow = ReadCtxU64(contextRecord, destOffset);
if (isExtrq)
{
var length = (int)instruction.GetImmediate(1);
var index = (int)instruction.GetImmediate(2);
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
{
return false;
}
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.ExtractBitField(destLow, length, index));
WriteCtxU64(contextRecord, destOffset + 8, 0);
}
else
{
if (instruction.GetOpKind(1) != OpKind.Register ||
!TryGetXmmOffset(instruction.GetOpRegister(1), out var srcOffset))
{
return false;
}
var length = (int)instruction.GetImmediate(2);
var index = (int)instruction.GetImmediate(3);
if (!Sse4aBitFieldEmulator.IsValidBitField(length, index))
{
return false;
}
WriteCtxU64(contextRecord, destOffset, Sse4aBitFieldEmulator.InsertBitField(
destLow, ReadCtxU64(contextRecord, srcOffset), length, index));
WriteCtxU64(contextRecord, destOffset + 8, 0);
}
WriteCtxU64(contextRecord, CTX_RIP, rip + (ulong)instruction.Length);
Interlocked.Increment(ref _sse4aInstructionsEmulated);
if (Interlocked.Exchange(ref _sse4aSoftwareFallbackAnnounced, 1) == 0)
{
Console.Error.WriteLine(
"[LOADER][INFO] Host lacks SSE4a EXTRQ/INSERTQ used by the guest; " +
"emulating those instructions in software.");
}
return true;
}
// Maps an Iced XMM register to its byte offset in the Win64 CONTEXT record. Written as an
// explicit switch (rather than arithmetic on the Register enum) to match the style already
// used by TryGetGprSlot/TryGetGpr64Offset in DirectExecutionBackend.IllegalInstruction.cs.
private static bool TryGetXmmOffset(Register register, out int offset)
{
switch (register)
{
case Register.XMM0: offset = Win64ContextXmm0Offset + 16 * 0; return true;
case Register.XMM1: offset = Win64ContextXmm0Offset + 16 * 1; return true;
case Register.XMM2: offset = Win64ContextXmm0Offset + 16 * 2; return true;
case Register.XMM3: offset = Win64ContextXmm0Offset + 16 * 3; return true;
case Register.XMM4: offset = Win64ContextXmm0Offset + 16 * 4; return true;
case Register.XMM5: offset = Win64ContextXmm0Offset + 16 * 5; return true;
case Register.XMM6: offset = Win64ContextXmm0Offset + 16 * 6; return true;
case Register.XMM7: offset = Win64ContextXmm0Offset + 16 * 7; return true;
case Register.XMM8: offset = Win64ContextXmm0Offset + 16 * 8; return true;
case Register.XMM9: offset = Win64ContextXmm0Offset + 16 * 9; return true;
case Register.XMM10: offset = Win64ContextXmm0Offset + 16 * 10; return true;
case Register.XMM11: offset = Win64ContextXmm0Offset + 16 * 11; return true;
case Register.XMM12: offset = Win64ContextXmm0Offset + 16 * 12; return true;
case Register.XMM13: offset = Win64ContextXmm0Offset + 16 * 13; return true;
case Register.XMM14: offset = Win64ContextXmm0Offset + 16 * 14; return true;
case Register.XMM15: offset = Win64ContextXmm0Offset + 16 * 15; return true;
default:
offset = 0;
return false;
}
}
}
@@ -133,6 +133,11 @@ public sealed partial class DirectExecutionBackend
{
return -1;
}
if (exceptionCode == StatusIllegalInstruction &&
TryRecoverAmdCompatInstruction(contextRecord, rip))
{
return -1;
}
if (IsBenignHostDebugException(exceptionCode))
{
return -1;
@@ -530,9 +530,12 @@ public sealed partial class DirectExecutionBackend
{
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
}
DeliverPendingGuestExceptionAtSafePoint(
cpuContext,
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
{
DeliverPendingGuestExceptionAtSafePoint(
cpuContext,
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, num7));
}
StoreImportVectorReturn(cpuContext, argPackPtr);
if (dispatchResolved &&
orbisGen2Result == OrbisGen2Result.ORBIS_GEN2_OK &&
@@ -1326,9 +1329,12 @@ public sealed partial class DirectExecutionBackend
GuestThreadExecution.RestoreImportCallFrame(previousImportCallFrame);
}
}
DeliverPendingGuestExceptionAtSafePoint(
cpuContext,
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
if (Volatile.Read(ref _pendingGuestExceptionCount) != 0)
{
DeliverPendingGuestExceptionAtSafePoint(
cpuContext,
CaptureImportBoundaryContinuation(cpuContext, argPackPtr, returnRip));
}
StoreImportVectorReturn(cpuContext, argPackPtr);
if (returnValue != (int)OrbisGen2Result.ORBIS_GEN2_OK)
@@ -1438,6 +1444,9 @@ public sealed partial class DirectExecutionBackend
var expectedMutexTrylockBusy =
string.Equals(nid, "K-jXhbt2gn4", StringComparison.Ordinal) &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
var expectedSemaphoreTrywaitAgain =
string.Equals(nid, "H2a+IN9TP0E", StringComparison.Ordinal) &&
result == OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN;
var expectedNetAcceptWouldBlock =
string.Equals(nid, "PIWqhn9oSxc", StringComparison.Ordinal) &&
resultValue == unchecked((int)0x80410123);
@@ -1451,6 +1460,7 @@ public sealed partial class DirectExecutionBackend
!expectedTimedWaitTimeout &&
!expectedEqueueTimeout &&
!expectedMutexTrylockBusy &&
!expectedSemaphoreTrywaitAgain &&
!expectedNetAcceptWouldBlock &&
!expectedUserServiceNoEvent &&
!expectedPrivacyInvalidParameter)
@@ -712,6 +712,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
private readonly Dictionary<ulong, PendingGuestException> _pendingGuestExceptions = new Dictionary<ulong, PendingGuestException>();
// Import dispatch is the hottest managed path in UE titles. Most imports do
// not have an exception queued, so publish the dictionary population and let
// safe points skip _guestThreadGate entirely in the common case.
private int _pendingGuestExceptionCount;
private readonly HashSet<ulong> _activeGuestExceptionDeliveries = new HashSet<ulong>();
private int _guestThreadPumpDepth;
@@ -3948,10 +3953,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
// unwinding. Unity can begin its next stop-the-world cycle in
// that window; treating the new raise as part of the old delivery
// strands the collector waiting for an acknowledgement.
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
handler,
exceptionType,
external.ExceptionStackBase);
external.ExceptionStackBase));
return true;
}
@@ -3960,10 +3965,10 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
// managed thread corrupts the worker's control state. Queue the
// request and let that exact executor consume it at its next HLE
// boundary, where the original guest thread is safely paused.
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
handler,
exceptionType,
external.ExceptionStackBase);
external.ExceptionStackBase));
if (logGuestExceptions)
{
Console.Error.WriteLine(
@@ -4008,17 +4013,17 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
}
if (target.ExceptionDeliveryActive)
{
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
handler,
exceptionType,
exceptionStackBase);
exceptionStackBase));
return true;
}
_pendingGuestExceptions[threadHandle] = new PendingGuestException(
QueuePendingGuestExceptionLocked(threadHandle, new PendingGuestException(
handler,
exceptionType,
exceptionStackBase);
exceptionStackBase));
if (logGuestExceptions)
{
Console.Error.WriteLine(
@@ -4179,7 +4184,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
RestoreInterruptedGuestThread();
if (target.State == GuestThreadRunState.Blocked &&
!target.ExecutorActive &&
_pendingGuestExceptions.Remove(threadHandle, out var queued))
TryRemovePendingGuestExceptionLocked(threadHandle, out var queued))
{
followUp = queued;
}
@@ -4265,6 +4270,11 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
CpuContext currentContext,
GuestCpuContinuation interruptedContinuation)
{
if (Volatile.Read(ref _pendingGuestExceptionCount) == 0)
{
return;
}
var threadHandle = GuestThreadExecution.CurrentGuestThreadHandle;
if (threadHandle == 0)
{
@@ -4278,7 +4288,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
return;
}
if (!_pendingGuestExceptions.Remove(threadHandle, out pending))
if (!TryRemovePendingGuestExceptionLocked(threadHandle, out pending))
{
return;
}
@@ -4340,6 +4350,27 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
}
}
private void QueuePendingGuestExceptionLocked(
ulong threadHandle,
PendingGuestException pending)
{
_pendingGuestExceptions[threadHandle] = pending;
Volatile.Write(ref _pendingGuestExceptionCount, _pendingGuestExceptions.Count);
}
private bool TryRemovePendingGuestExceptionLocked(
ulong threadHandle,
out PendingGuestException pending)
{
if (!_pendingGuestExceptions.Remove(threadHandle, out pending))
{
return false;
}
Volatile.Write(ref _pendingGuestExceptionCount, _pendingGuestExceptions.Count);
return true;
}
private static bool TryWriteGuestExceptionContext(
CpuContext context,
ulong address,
@@ -4434,6 +4465,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
_guestThreads.Clear();
_externalGuestThreads.Clear();
_pendingGuestExceptions.Clear();
Volatile.Write(ref _pendingGuestExceptionCount, 0);
_activeGuestExceptionDeliveries.Clear();
}
@@ -40,6 +40,9 @@ public sealed class TrackedCpuMemory : ICpuMemory, ITrackedCpuMemory, IGuestMemo
return result;
}
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length) =>
_inner.TryCopy(destinationAddress, sourceAddress, length);
public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address)
{
if (_inner is IGuestMemoryAllocator allocator)
@@ -20,6 +20,11 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
private readonly Dictionary<(ulong DesiredAddress, ulong Alignment, bool Executable), ulong> _allocationSearchHints = new();
private readonly Dictionary<ulong, ProgramHeaderFlags> _pageProtections = new();
private bool _disposed;
[ThreadStatic]
private static CommittedRangeCache? _committedRangeCache;
private long _mappingGeneration;
private const ulong PageSize = 0x1000;
private const ulong GuestAllocationArenaAddress = 0x00006000_0000_0000;
private const ulong GuestAllocationArenaSize = 0x0100_0000;
@@ -28,6 +33,77 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
private const ulong FullCommitRegionLimit = 4UL << 30;
private const ulong DefaultLazyReservePrimeBytes = 0x0400_0000UL; // 64 MiB
private const ulong LazyReservePrimeChunkBytes = 0x0200_0000UL; // 32 MiB
private const int CommittedRangeCacheCapacity = 4;
private sealed class CommittedRangeCache
{
private readonly CommittedRange[] _ranges = new CommittedRange[CommittedRangeCacheCapacity];
private PhysicalVirtualMemory? _owner;
private long _generation;
private int _count;
private int _nextReplacement;
public bool Contains(
PhysicalVirtualMemory owner,
long generation,
ulong start,
ulong end)
{
if (!ReferenceEquals(_owner, owner) || _generation != generation)
{
return false;
}
for (var index = 0; index < _count; index++)
{
var range = _ranges[index];
if (start >= range.Start && end <= range.End)
{
return true;
}
}
return false;
}
public void Add(
PhysicalVirtualMemory owner,
long generation,
ulong start,
ulong end)
{
if (!ReferenceEquals(_owner, owner) || _generation != generation)
{
_owner = owner;
_generation = generation;
_count = 0;
_nextReplacement = 0;
}
for (var index = 0; index < _count; index++)
{
var range = _ranges[index];
if (start <= range.End && end >= range.Start)
{
_ranges[index] = new CommittedRange(
Math.Min(start, range.Start),
Math.Max(end, range.End));
return;
}
}
if (_count < _ranges.Length)
{
_ranges[_count++] = new CommittedRange(start, end);
return;
}
_ranges[_nextReplacement] = new CommittedRange(start, end);
_nextReplacement = (_nextReplacement + 1) % _ranges.Length;
}
}
private readonly record struct CommittedRange(ulong Start, ulong End);
// Raw Windows PAGE_* values retained for the internal region/protection
// bookkeeping: regions and saved old-protection values always carry the raw
@@ -349,6 +425,87 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return actualAddress;
}
public bool TryBackFixedRange(ulong address, ulong size, bool executable)
{
if (size == 0)
{
return false;
}
var start = AlignDown(address, PageSize);
var end = AlignUp(address + size, PageSize);
if (end <= start)
{
return false;
}
var hostProtection = executable ? HostPageProtection.ReadWriteExecute : HostPageProtection.ReadWrite;
// Walk the range page-run by page-run. VirtualQuery reports the largest run
// of same-state pages from the queried address, so a single query advances
// us over whole free or occupied stretches. Only free stretches get backed;
// stretches already reserved or committed by another allocation are left as
// they are, which is exactly what a fixed mapping does on hardware.
var cursor = start;
var backedAny = false;
while (cursor < end)
{
if (!_hostMemory.Query(cursor, out var info))
{
return false;
}
var queriedEnd = info.RegionSize > ulong.MaxValue - info.BaseAddress
? ulong.MaxValue
: info.BaseAddress + info.RegionSize;
var runEnd = Math.Min(end, queriedEnd);
if (runEnd <= cursor)
{
return false;
}
if (info.State == HostRegionState.Free)
{
var runSize = runEnd - cursor;
var allocated = _hostMemory.Allocate(cursor, runSize, hostProtection);
if (allocated != cursor)
{
if (allocated != 0)
{
_hostMemory.Free(allocated);
}
return false;
}
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
_gate.EnterWriteLock();
try
{
InsertRegionSorted(new MemoryRegion
{
VirtualAddress = cursor,
Size = runSize,
IsExecutable = executable,
IsReservedOnly = false,
Protection = protection
});
}
finally
{
_gate.ExitWriteLock();
}
TraceVmem($"Backed fixed range gap: 0x{cursor:X16} - 0x{runEnd:X16} ({runSize} bytes)");
backedAny = true;
}
cursor = runEnd;
}
return backedAny;
}
public bool TryAllocateAtOrAbove(
ulong desiredAddress,
ulong size,
@@ -440,6 +597,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
_gate.ExitWriteLock();
}
Interlocked.Increment(ref _mappingGeneration);
_hostMemory.Free(address);
}
@@ -611,6 +769,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
{
_allocationSearchHints.Clear();
}
Interlocked.Increment(ref _mappingGeneration);
}
finally
{
@@ -919,6 +1078,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
}
NotifyGuestWriteWatch(virtualAddress, source);
return true;
}
}
@@ -944,6 +1104,68 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
}
}
private static void NotifyGuestWriteWatch(ulong virtualAddress, ReadOnlySpan<byte> source)
{
if (GuestWriteWatch.Armed)
{
GuestWriteWatch.Check(virtualAddress, source);
}
}
public bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length)
{
if (length == 0)
{
return true;
}
if (length > int.MaxValue)
{
return false;
}
// Match TryWrite's managed-write notification before touching an
// identity-mapped guest page protected by the image tracker.
GuestImageWriteTracker.NotifyManagedWrite(destinationAddress, length);
_gate.EnterReadLock();
try
{
var sourceRegion = FindRegion(sourceAddress, length);
var destinationRegion = FindRegion(destinationAddress, length);
if (sourceRegion is null || destinationRegion is null ||
!TryResolveRegionOffset(sourceAddress, length, sourceRegion, out var sourceOffset) ||
!TryResolveRegionOffset(destinationAddress, length, destinationRegion, out var destinationOffset))
{
return false;
}
var sourcePointer = sourceRegion.VirtualAddress + sourceOffset;
var destinationPointer = destinationRegion.VirtualAddress + destinationOffset;
if ((sourceRegion.IsReservedOnly &&
!EnsureRangeCommitted(sourcePointer, length, sourceRegion)) ||
(destinationRegion.IsReservedOnly &&
!EnsureRangeCommitted(destinationPointer, length, destinationRegion)) ||
!CanReadWithoutProtectionChange(sourcePointer, length, sourceRegion) ||
!CanWriteWithoutProtectionChange(destinationPointer, length, destinationRegion))
{
return false;
}
// Span.CopyTo has memmove overlap semantics, so this allocation-free
// path safely serves both libc memcpy and libc memmove.
new ReadOnlySpan<byte>((void*)sourcePointer, checked((int)length)).CopyTo(
new Span<byte>((void*)destinationPointer, checked((int)length)));
NotifyGuestWriteWatch(
destinationAddress,
new ReadOnlySpan<byte>((void*)destinationPointer, checked((int)length)));
return true;
}
finally
{
_gate.ExitReadLock();
}
}
private bool TryReadExclusive(ulong virtualAddress, Span<byte> destination)
{
var region = FindRegion(virtualAddress, (ulong)destination.Length);
@@ -1016,6 +1238,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
Buffer.MemoryCopy(srcPtr, destPtr, (nuint)source.Length, (nuint)source.Length);
}
NotifyGuestWriteWatch(virtualAddress, source);
return true;
}
@@ -1040,6 +1263,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
}
}
NotifyGuestWriteWatch(virtualAddress, source);
return true;
}
@@ -1281,6 +1505,12 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
var startPage = AlignDown(address, PageSize);
var endPage = AlignUp(address + size, PageSize);
var mappingGeneration = Volatile.Read(ref _mappingGeneration);
var committedRangeCache = _committedRangeCache ??= new CommittedRangeCache();
if (committedRangeCache.Contains(this, mappingGeneration, startPage, endPage))
{
return true;
}
var commitProtection = GetCommitProtection(region);
var pageAddress = startPage;
@@ -1302,6 +1532,9 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
if (info.State == HostRegionState.Committed)
{
// The host query proved this whole range is committed. Retain
// that result instead of caching only the caller's small span.
CacheCommittedRange(info.BaseAddress, queriedEnd, mappingGeneration);
pageAddress = rangeEnd;
continue;
}
@@ -1317,12 +1550,23 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
return false;
}
CacheCommittedRange(pageAddress, rangeEnd, mappingGeneration);
pageAddress = rangeEnd;
}
CacheCommittedRange(startPage, endPage, mappingGeneration);
return true;
}
private void CacheCommittedRange(ulong startPage, ulong endPage, long mappingGeneration)
{
(_committedRangeCache ??= new CommittedRangeCache()).Add(
this,
mappingGeneration,
startPage,
endPage);
}
private bool TryTemporarilyProtectForRead(
ulong address,
ulong size,
+8 -1
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Loader;
using SharpEmu.HLE;
namespace SharpEmu.Core.Memory;
@@ -93,8 +94,14 @@ public sealed class VirtualMemory : IVirtualMemory
}
CopyToRegions(virtualAddress, source, regionIndex);
return true;
}
if (GuestWriteWatch.Armed)
{
GuestWriteWatch.Check(virtualAddress, source);
}
return true;
}
private bool TryValidateRange(
+2 -2
View File
@@ -248,7 +248,7 @@ internal sealed class EmulatorProcess : IDisposable
{
Environment.SetEnvironmentVariable(MitigatedChildEnvironment, "1");
if (!CreateProcessW(
exePath,
null,
commandLine,
0,
0,
@@ -629,7 +629,7 @@ internal sealed class EmulatorProcess : IDisposable
[DllImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateProcessW(string applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
private static extern bool CreateProcessW(string? applicationName, StringBuilder commandLine, nint processAttributes, nint threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool inheritHandles, uint flags, nint environment, string currentDirectory, ref StartupInfoEx startupInfo, out ProcessInformation processInformation);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(nint handle, uint milliseconds);
+7
View File
@@ -351,6 +351,13 @@ public sealed class GameSurfaceHost : NativeControlHost
var width = Math.Max(1, (int)Math.Round(Bounds.Width * renderScale));
var height = Math.Max(1, (int)Math.Round(Bounds.Height * renderScale));
var sizeChanged = _surface.PixelWidth != width || _surface.PixelHeight != height;
if (Environment.GetEnvironmentVariable("SHARPEMU_TRACE_SURFACE_SIZE") == "1")
{
Console.Error.WriteLine(
$"[GUI][TRACE] GameSurfaceHost.UpdateSurfaceSize bounds={Bounds.Width}x{Bounds.Height} " +
$"scale={renderScale} computed={width}x{height} changed={sizeChanged} " +
$"prevSurface={_surface.PixelWidth}x{_surface.PixelHeight}");
}
_surface.UpdatePixelSize(width, height);
if (!sizeChanged)
+37 -1
View File
@@ -53,6 +53,9 @@ public sealed class GuiSettings
/// <summary>Names of SHARPEMU_* switches set to "1" in the emulator's environment at launch.</summary>
public List<string> EnvironmentToggles { get; set; } = new();
/// <summary>Internal render resolution scale (1.0 = native, 0.5 = half).</summary>
public double RenderResolutionScale { get; set; } = 1.0;
/// <summary>
/// Discord application ID used for Rich Presence; the default is the
/// SharpEmu application. Override to rebrand what Discord shows as
@@ -71,7 +74,7 @@ public sealed class GuiSettings
if (File.Exists(SettingsPath))
{
var json = File.ReadAllText(SettingsPath);
return JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
return NormalizeFromJson(json);
}
}
catch (Exception)
@@ -82,6 +85,39 @@ public sealed class GuiSettings
return new GuiSettings();
}
/// <summary>
/// Deserializes settings and normalizes null references and null or empty list
/// entries introduced by JSON. Empty scalar strings remain unchanged.
/// </summary>
internal static GuiSettings NormalizeFromJson(string json)
{
var settings = JsonSerializer.Deserialize<GuiSettings>(json, SerializerOptions) ?? new GuiSettings();
settings.GameFolders = FilterNullOrEmpty(settings.GameFolders);
settings.ExcludedGames = FilterNullOrEmpty(settings.ExcludedGames);
settings.EnvironmentToggles = FilterNullOrEmpty(settings.EnvironmentToggles);
settings.LogLevel ??= "Info";
settings.Language ??= "en";
settings.DiscordClientId ??= "1525606762248540221";
if (settings.RenderResolutionScale <= 0 || settings.RenderResolutionScale > 2.0)
{
settings.RenderResolutionScale = 1.0;
}
return settings;
}
// JSON can populate non-nullable lists with null references and entries.
private static List<string> FilterNullOrEmpty(List<string>? source)
{
if (source is null)
{
return [];
}
return source.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
}
public void Save()
{
try
+23
View File
@@ -400,6 +400,29 @@ SPDX-License-Identifier: GPL-2.0-or-later
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem x:Name="GraphicsTabItem" Header="Graphics" FontSize="15">
<ScrollViewer>
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
<Border Classes="card">
<StackPanel Spacing="14">
<TextBlock x:Name="RenderingSectionTitle" Classes="sectionTitle" Text="RENDERING" />
<local:SettingRow x:Name="RenderResolutionRow" Label="Internal resolution"
Description="Render offscreen targets below native resolution and upscale on present. Lower values trade image quality for GPU headroom; takes effect on next launch.">
<ComboBox x:Name="RenderResolutionBox" Width="160" SelectedIndex="0"
VerticalAlignment="Center" CornerRadius="8">
<ComboBoxItem x:Name="RenderResolution100Item" Content="100% (native)" Tag="1.0" />
<ComboBoxItem x:Name="RenderResolution75Item" Content="75%" Tag="0.75" />
<ComboBoxItem x:Name="RenderResolution50Item" Content="50%" Tag="0.5" />
<ComboBoxItem x:Name="RenderResolution25Item" Content="25%" Tag="0.25" />
</ComboBox>
</local:SettingRow>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</TabItem>
<TabItem x:Name="EnvTabItem" Header="Environment" FontSize="15">
<ScrollViewer>
<StackPanel Margin="0,14,0,16" Spacing="16" MaxWidth="1280" HorizontalAlignment="Left">
+38 -2
View File
@@ -192,6 +192,18 @@ public partial class MainWindow : Window
// it is open already uses the new values.
LogLevelBox.SelectionChanged += (_, _) => _settings.LogLevel = SelectedLogLevel();
TraceImportsBox.ValueChanged += (_, _) => _settings.ImportTraceLimit = (int)(TraceImportsBox.Value ?? 0);
RenderResolutionBox.SelectionChanged += (_, _) =>
{
if (RenderResolutionBox.SelectedItem is ComboBoxItem { Tag: string tag } &&
double.TryParse(
tag,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var scale))
{
_settings.RenderResolutionScale = scale;
}
};
StrictToggle.IsCheckedChanged += (_, _) => _settings.StrictDynlibResolution = StrictToggle.IsChecked == true;
LogToFileToggle.IsCheckedChanged += (_, _) => _settings.LogToFile = LogToFileToggle.IsChecked == true;
OverrideLogFileToggle.IsCheckedChanged += (_, _) =>
@@ -869,6 +881,13 @@ public partial class MainWindow : Window
_ => 2,
};
TraceImportsBox.Value = Math.Clamp(_settings.ImportTraceLimit, 0, 4096);
RenderResolutionBox.SelectedIndex = _settings.RenderResolutionScale switch
{
>= 0.875 => 0,
>= 0.625 => 1,
>= 0.375 => 2,
_ => 3,
};
StrictToggle.IsChecked = _settings.StrictDynlibResolution;
LogToFileToggle.IsChecked = _settings.LogToFile;
OverrideLogFileToggle.IsChecked = _settings.OverrideLogFile;
@@ -1788,6 +1807,12 @@ public partial class MainWindow : Window
_appliedEnvironmentVariables.Add(name);
}
Environment.SetEnvironmentVariable(
"SHARPEMU_RENDER_SCALE",
_settings.RenderResolutionScale.ToString(
"0.###",
System.Globalization.CultureInfo.InvariantCulture));
if (SharpEmuLog.TryParseLevel(effective.LogLevel, out var logLevel))
{
SharpEmuLog.MinimumLevel = logLevel;
@@ -2030,8 +2055,6 @@ public partial class MainWindow : Window
RestoreGameViewToFull();
GameView.Background = Brushes.Black;
GameView.IsHitTestVisible = true;
_gameSurfaceHost?.SetPresentationVisible(true);
_gameSurfaceHost?.SetCursorAutoHide(true);
LibraryPage.IsVisible = false;
OptionsPage.IsVisible = false;
LibraryToolbar.IsVisible = false;
@@ -2040,6 +2063,19 @@ public partial class MainWindow : Window
LaunchBar.IsVisible = false;
HideSessionLoading();
UpdateSessionBarVisibility();
// Defer so the layout pass from the margin change above settles first.
Dispatcher.UIThread.Post(() =>
{
if (!_isRunning || _isStopping)
{
return;
}
_gameSurfaceHost?.RefreshSurfaceSize();
_gameSurfaceHost?.SetPresentationVisible(true);
_gameSurfaceHost?.SetCursorAutoHide(true);
});
}
});
}
+13 -1
View File
@@ -49,7 +49,7 @@ public sealed class PerGameSettings
var path = PathFor(titleId);
if (File.Exists(path))
{
return JsonSerializer.Deserialize<PerGameSettings>(File.ReadAllText(path), SerializerOptions);
return NormalizeFromJson(File.ReadAllText(path));
}
}
catch (Exception)
@@ -59,6 +59,18 @@ public sealed class PerGameSettings
return null;
}
// A null list inherits global settings; only entries in a present list are sanitized.
internal static PerGameSettings? NormalizeFromJson(string json)
{
var settings = JsonSerializer.Deserialize<PerGameSettings>(json, SerializerOptions);
if (settings?.EnvironmentToggles is { } toggles)
{
settings.EnvironmentToggles = toggles.Where(entry => !string.IsNullOrEmpty(entry)).ToList();
}
return settings;
}
public void Save(string titleId)
{
if (string.IsNullOrWhiteSpace(titleId))
+4
View File
@@ -24,6 +24,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ProjectReference Include="..\SharpEmu.Logging\SharpEmu.Logging.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="SharpEmu.Libs.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.Desktop" />
+43 -2
View File
@@ -32,6 +32,7 @@ public static unsafe class GuestImageWriteTracker
public int Armed;
public int FirstCpuWriteSeen;
public int PendingFirstCpuWrite;
public long WriteGeneration;
public bool TraceLifetime;
public long SourceSequence;
public long FirstCpuWriteTraceSequence;
@@ -155,10 +156,21 @@ public static unsafe class GuestImageWriteTracker
{
// Never resize an object that is still reachable from the
// signal handler's lock-free snapshot. Retire it and publish
// a fresh immutable range.
// a fresh immutable range, carrying the write generation so
// resizes do not hide guest CPU rewrites from cache owners.
var writeGeneration = Volatile.Read(ref range.WriteGeneration);
DisarmLocked(range, "replace-range");
_rangesByAddress.Remove(address);
range = null;
range = new TrackedRange
{
Address = address,
ByteCount = byteCount,
Start = start,
End = start + length,
WriteGeneration = writeGeneration,
};
_rangesByAddress[address] = range;
RebuildSnapshotLocked();
}
if (range is null)
@@ -272,6 +284,31 @@ public static unsafe class GuestImageWriteTracker
}
}
/// <summary>
/// Returns the monotonic first-write generation for a tracked allocation.
/// Unlike the consuming dirty flag, this remains changed after another
/// cache owner consumes and re-arms the range.
/// </summary>
public static bool TryGetWriteGeneration(ulong address, out long generation)
{
generation = 0;
if (!_enabled)
{
return false;
}
lock (_gate)
{
if (!_rangesByAddress.TryGetValue(address, out var range))
{
return false;
}
generation = Volatile.Read(ref range.WriteGeneration);
return true;
}
}
/// <summary>
/// Prepares pages touched by a managed HLE memory write. Native guest
/// stores fault and enter <see cref="TryHandleWriteFault"/> through the
@@ -425,6 +462,10 @@ public static unsafe class GuestImageWriteTracker
}
var wasArmed = Interlocked.Exchange(ref range.Armed, 0) != 0;
if (wasArmed)
{
Interlocked.Increment(ref range.WriteGeneration);
}
if (wasArmed &&
range.TraceLifetime &&
Interlocked.CompareExchange(ref range.FirstCpuWriteSeen, 1, 0) == 0)
+1 -1
View File
@@ -17,7 +17,7 @@ public static class GuestTlsTemplate
// Must match CpuDispatcher/DirectExecutionBackend's mapped prefix. PS5
// modules can require more than one host page of Variant II static TLS;
// Dreaming Sarah's startup image, for example, reaches 0x1870 bytes.
public const ulong StartupStaticTlsReservation = 0x10000UL;
public const ulong StartupStaticTlsReservation = 0x20000UL; // Was 0x10000UL, but thats too small for GTA V
private static readonly object _gate = new();
private static readonly SortedDictionary<ulong, ModuleTemplate> _modules = new();
private static readonly Dictionary<ulong, ThreadDtv> _threadDtvs = new();
+203
View File
@@ -0,0 +1,203 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using System.Globalization;
using System.Threading;
namespace SharpEmu.HLE;
// This tool monitors guest-memory writes only when a watch mode is active.
public static class GuestWriteWatch
{
private const ulong WatchBytes = 8;
private const int MaxBulkReports = 64;
private static readonly ulong WatchBase = Parse(
Environment.GetEnvironmentVariable("SHARPEMU_WATCH_WRITE"));
private static readonly bool WatchPoolHeaders = IsEnabled("SHARPEMU_WATCH_POOL_HEADER");
private static readonly ulong[] PoolSlots = new ulong[64];
private static int _poolSlotCount;
private static readonly bool WatchValuePattern = IsEnabled("SHARPEMU_WATCH_VALUE_PATTERN");
private static readonly bool WatchValue1 = IsEnabled("SHARPEMU_WATCH_VALUE1");
private const ulong DirectBandLow = 0x100_0000_0000;
private const ulong DirectBandHigh = 0x1000_0000_0000;
private static int _value1Reports;
private static readonly bool WatchBulkTorn = IsEnabled("SHARPEMU_WATCH_BULK_TORN");
private static readonly ulong BulkDestHigh = Parse(
Environment.GetEnvironmentVariable("SHARPEMU_WATCH_BULK_DEST_HI"));
private static int _bulkTornReports;
private static int _bulkShiftReports;
public static bool Armed =>
WatchBase != 0 || WatchPoolHeaders || WatchValuePattern || WatchValue1 || WatchBulkTorn;
public static void OnDirectMapping(ulong mappedAddress, ulong length, int protection)
{
if (!WatchPoolHeaders || !IsPoolMapping(length, protection))
{
return;
}
var index = Interlocked.Increment(ref _poolSlotCount) - 1;
if (index < PoolSlots.Length)
{
Volatile.Write(ref PoolSlots[index], mappedAddress + 0x40);
Console.Error.WriteLine(
$"[LOADER][WARN] watch_write armed on pool header slot 0x{mappedAddress + 0x40:X16}");
}
}
public static void Check(ulong address, ReadOnlySpan<byte> data)
{
if (WatchBulkTorn &&
data.Length >= 8 &&
(BulkDestHigh != 0
? (address >> 32) == BulkDestHigh
: address >= DirectBandLow && address < DirectBandHigh))
{
for (var offset = FirstAlignedOffset(address); offset + 8 <= data.Length; offset += 8)
{
var qword = BinaryPrimitives.ReadUInt64LittleEndian(data.Slice(offset, 8));
var kind = ClassifyBulkValue(qword);
if (kind is not null && ReserveBulkReport(kind))
{
Console.Error.WriteLine(
$"[LOADER][WARN] watch_bulk_torn HIT ({kind}) " +
$"dest=0x{address + (ulong)offset:X16} (base=0x{address:X16}+0x{offset:X}) " +
$"len={data.Length} qword=0x{qword:X16}{Environment.NewLine}{Environment.StackTrace}");
Console.Error.Flush();
return;
}
}
}
if (WatchValue1 &&
address >= DirectBandLow && address < DirectBandHigh &&
data.Length is >= 1 and <= 8 &&
LittleEndianValue(data) == 1 &&
Interlocked.Increment(ref _value1Reports) <= 128)
{
Report(address, data);
return;
}
if (WatchValuePattern && data.Length == 8)
{
var value = BinaryPrimitives.ReadUInt64LittleEndian(data);
if ((value & 0xFFFFFFFF) == 1 && value >> 32 is > 0 and <= 0xFFFF)
{
Report(address, data);
return;
}
}
if (WatchBase != 0 && Overlaps(address, data.Length, WatchBase))
{
Report(address, data);
return;
}
var slots = Math.Min(Volatile.Read(ref _poolSlotCount), PoolSlots.Length);
for (var i = 0; i < slots; i++)
{
var slot = Volatile.Read(ref PoolSlots[i]);
if (slot != 0 && Overlaps(address, data.Length, slot))
{
Report(address, data);
return;
}
}
}
internal static string? ClassifyBulkValue(ulong qword)
{
var low32 = qword & 0xFFFFFFFF;
var high32 = qword >> 32;
if (low32 == 1 && high32 is > 0 and <= 0xFFFF)
{
return "torn";
}
var prefix = low32 & 0xFF00_0000;
var hasShiftedPointerPrefix = prefix is 0x0800_0000 or 0x8000_0000;
return high32 == 0 && hasShiftedPointerPrefix && (low32 & 0xFF) == 0
? "shift"
: null;
}
internal static int FirstAlignedOffset(ulong address) =>
(int)((8 - (address & 7)) & 7);
internal static bool IsPoolMapping(ulong length, int protection) =>
length == 0x10000 && protection == 0xF2;
internal static bool Overlaps(ulong address, int length, ulong slot)
{
if (length <= 0)
{
return false;
}
var writeLength = (ulong)length - 1;
var writeEnd = address > ulong.MaxValue - writeLength
? ulong.MaxValue
: address + writeLength;
var slotEnd = slot > ulong.MaxValue - (WatchBytes - 1)
? ulong.MaxValue
: slot + WatchBytes - 1;
return address <= slotEnd && slot <= writeEnd;
}
private static ulong LittleEndianValue(ReadOnlySpan<byte> data)
{
ulong value = 0;
for (var i = 0; i < data.Length; i++)
{
value |= (ulong)data[i] << (i * 8);
}
return value;
}
private static void Report(ulong address, ReadOnlySpan<byte> data)
{
Console.Error.WriteLine(
$"[LOADER][WARN] watch_write HIT addr=0x{address:X16} len={data.Length} " +
$"first_qword=0x{LittleEndianValue(data):X16}{Environment.NewLine}{Environment.StackTrace}");
Console.Error.Flush();
}
private static bool IsEnabled(string name) =>
string.Equals(Environment.GetEnvironmentVariable(name), "1", StringComparison.Ordinal);
private static bool ReserveBulkReport(string kind) =>
kind == "torn"
? Interlocked.Increment(ref _bulkTornReports) <= MaxBulkReports
: Interlocked.Increment(ref _bulkShiftReports) <= MaxBulkReports;
internal static ulong Parse(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return 0;
}
text = text.Trim();
if (text.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
text = text[2..];
}
return ulong.TryParse(text, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var value)
? value
: 0;
}
}
+2
View File
@@ -10,4 +10,6 @@ public interface ICpuMemory
bool TryWrite(ulong virtualAddress, ReadOnlySpan<byte> source);
bool TryCompare(ulong virtualAddress, ReadOnlySpan<byte> expected) => false;
bool TryCopy(ulong destinationAddress, ulong sourceAddress, ulong length) => false;
}
+11
View File
@@ -15,6 +15,17 @@ public interface IGuestAddressSpace : IGuestMemoryAllocator
{
ulong AllocateAt(ulong desiredAddress, ulong size, bool executable = true, bool allowAlternative = true);
/// <summary>
/// Backs an entire fixed-address range, matching the guest's
/// <c>SCE_KERNEL_MAP_FIXED</c> contract. Unlike <see cref="AllocateAt"/>, which
/// reserves the range in one all-or-nothing host call, this walks the range and
/// fills only the sub-ranges that are not already backed. That keeps a fixed
/// mapping whole when part of the requested window is already occupied — the
/// partial-overlap case where the single-call reservation fails outright and
/// leaves the remainder unmapped for the guest to fault into.
/// </summary>
bool TryBackFixedRange(ulong address, ulong size, bool executable);
bool TryAllocateAtOrAbove(ulong desiredAddress, ulong size, bool executable, ulong alignment, out ulong actualAddress);
bool TryProtect(ulong address, ulong size, GuestPageProtection protection);
+290 -26
View File
@@ -147,6 +147,10 @@ public static partial class AgcExports
private const uint Gen5TextureFormatR16G16B16A16Float = 12;
private const uint Gen5TextureType1D = 8;
private const uint Gen5TextureType2D = 9;
private const uint Gen5TextureType3D = 10;
private const uint Gen5TextureTypeCube = 11;
private const uint Gen5TextureType1DArray = 12;
private const uint Gen5TextureType2DArray = 13;
private const ulong MaxPresentedTextureBytes = 128UL * 1024UL * 1024UL;
private const ulong VideoOutPixelFormatA8R8G8B8Srgb = 0x80000000;
private const ulong VideoOutPixelFormatA8B8G8R8Srgb = 0x80002200;
@@ -438,7 +442,8 @@ public static partial class AgcExports
TextureDescriptor Descriptor,
bool IsStorage,
uint MipLevel,
IReadOnlyList<uint> SamplerDescriptor);
IReadOnlyList<uint> SamplerDescriptor,
bool IsArrayed = false);
private readonly record struct RenderTargetWriter(
ulong Sequence,
@@ -640,6 +645,21 @@ public static partial class AgcExports
public static int GetRegisterDefaults2Internal(CpuContext ctx) =>
ReturnRegisterDefaults(ctx, internalDefaults: true);
/// <summary>
/// Reports that the GPU is not running in Trinity mode, matching the base
/// console this backend emulates.
/// </summary>
[SysAbiExport(
Nid = "BfBDZGbti7A",
ExportName = "sceAgcGetIsTrinityMode",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int GetIsTrinityMode(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "f3dg2CSgRKY",
ExportName = "sceAgcCreateShader",
@@ -5944,7 +5964,8 @@ public static partial class AgcExports
binding,
exportEvaluation.ImageBindings),
binding.MipLevel ?? 0,
binding.SamplerDescriptor));
binding.SamplerDescriptor,
Gen5ShaderTranslator.IsArrayedImageBinding(binding)));
}
IReadOnlyList<Gen5VertexInputBinding> vertexInputs =
@@ -6401,7 +6422,8 @@ public static partial class AgcExports
texture,
isStorage,
binding.MipLevel ?? 0,
binding.SamplerDescriptor));
binding.SamplerDescriptor,
Gen5ShaderTranslator.IsArrayedImageBinding(binding)));
}
error = string.Empty;
@@ -7372,6 +7394,7 @@ public static partial class AgcExports
binding.IsStorage,
binding.MipLevel,
binding.SamplerDescriptor,
binding.IsArrayed,
out var texture))
{
textures.Add(texture);
@@ -7767,7 +7790,10 @@ public static partial class AgcExports
TextureDescriptor descriptor,
uint sourceWidth,
int logicalByteCount,
byte[] source)
byte[] source,
bool baseMipInTail = false,
int tailElementX = 0,
int tailElementY = 0)
{
if (!GnmTiling.NeedsDetile(descriptor.TileMode) ||
!TryGetTextureElementLayout(
@@ -7780,6 +7806,48 @@ public static partial class AgcExports
return null;
}
if (baseMipInTail)
{
if (!GnmTiling.TryGetBlockElementDimensions(
descriptor.TileMode,
bytesPerElement,
out var blockWidth,
out var blockHeight))
{
return null;
}
var blockByteCount = (long)blockWidth * blockHeight * bytesPerElement;
if (source.Length < blockByteCount ||
(long)elementsWide * elementsHigh * bytesPerElement > logicalByteCount)
{
return null;
}
var blockLinear = new byte[blockByteCount];
if (!GnmTiling.TryDetile(
source,
blockLinear,
descriptor.TileMode,
blockWidth,
blockHeight,
bytesPerElement))
{
return null;
}
var tailLinear = new byte[logicalByteCount];
var rowBytes = elementsWide * bytesPerElement;
for (var y = 0; y < elementsHigh; y++)
{
var sourceOffset = (((long)tailElementY + y) * blockWidth + tailElementX) * bytesPerElement;
blockLinear.AsSpan((int)sourceOffset, rowBytes)
.CopyTo(tailLinear.AsSpan(y * rowBytes, rowBytes));
}
return tailLinear;
}
var linear = new byte[logicalByteCount];
return GnmTiling.TryDetile(
source,
@@ -7817,18 +7885,23 @@ public static partial class AgcExports
bool isStorage,
uint mipLevel,
IReadOnlyList<uint> samplerDescriptor,
bool isArrayed,
out GuestDrawTexture texture)
{
texture = default!;
if ((descriptor.Type != Gen5TextureType1D &&
descriptor.Type != Gen5TextureType2D) ||
descriptor.Type != Gen5TextureType2D &&
descriptor.Type != Gen5TextureType3D &&
descriptor.Type != Gen5TextureTypeCube &&
descriptor.Type != Gen5TextureType1DArray &&
descriptor.Type != Gen5TextureType2DArray) ||
descriptor.Width == 0 ||
descriptor.Height == 0 ||
descriptor.Width > 8192 ||
descriptor.Height > 8192)
{
TraceTextureFallback(descriptor, "invalid-descriptor");
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType);
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType, isArrayed);
return true;
}
@@ -7849,18 +7922,22 @@ public static partial class AgcExports
TraceTextureFallback(
descriptor,
$"invalid-byte-count:{sourceByteCount}");
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType);
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType, isArrayed);
return true;
}
var physicalSourceByteCount = sourceByteCount;
if (GnmTiling.NeedsDetile(descriptor.TileMode) &&
var elementsWide = 0;
var elementsHigh = 0;
var bytesPerElement = 0;
var hasElementLayout = GnmTiling.NeedsDetile(descriptor.TileMode) &&
TryGetTextureElementLayout(
descriptor,
sourceWidth,
out var elementsWide,
out var elementsHigh,
out var bytesPerElement) &&
out elementsWide,
out elementsHigh,
out bytesPerElement);
if (hasElementLayout &&
GnmTiling.TryGetTiledByteCount(
descriptor.TileMode,
elementsWide,
@@ -7874,13 +7951,50 @@ public static partial class AgcExports
if (physicalSourceByteCount > MaxPresentedTextureBytes ||
physicalSourceByteCount > int.MaxValue)
{
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType);
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType, isArrayed);
return true;
}
if (!isStorage &&
var resourceMipLevels = descriptor.HasExtendedDescriptor
? descriptor.ResourceMipLevels
: 1u;
var baseMipByteOffset = 0UL;
var baseMipInTail = false;
var mipTailElementX = 0;
var mipTailElementY = 0;
var chainSliceBytes = physicalSourceByteCount;
if (hasElementLayout && resourceMipLevels > 1 &&
GnmTiling.TryGetBaseMipPlacement(
descriptor.TileMode,
elementsWide,
elementsHigh,
bytesPerElement,
resourceMipLevels,
out baseMipByteOffset,
out baseMipInTail,
out mipTailElementX,
out mipTailElementY,
out var placedChainSliceBytes))
{
chainSliceBytes = placedChainSliceBytes;
}
var wantsArrayUpload = isArrayed &&
!isStorage &&
descriptor.Address != 0 &&
GuestGpu.Current.IsGpuGuestImageAvailable(
(descriptor.Type == Gen5TextureType2DArray ||
descriptor.Type == Gen5TextureType1DArray) &&
descriptor.Depth > 1;
var arrayUploadLayers = wantsArrayUpload ? descriptor.Depth : 1u;
// Upload-known (not plain availability): the presenter's answer goes
// generation-stale when the guest CPU rewrites a CPU-backed image
// (video planes, streamed font atlases), which routes this draw back
// through the texel copy below so the refresh path re-uploads.
if (!isStorage &&
!wantsArrayUpload &&
descriptor.Address != 0 &&
GuestGpu.Current.IsGuestImageUploadKnown(
descriptor.Address,
descriptor.Format,
descriptor.NumberType))
@@ -7901,7 +8015,8 @@ public static partial class AgcExports
Pitch: sourceWidth,
TileMode: descriptor.TileMode,
DstSelect: descriptor.DstSelect,
Sampler: ToGuestSampler(samplerDescriptor));
Sampler: ToGuestSampler(samplerDescriptor),
ArrayedView: isArrayed);
return true;
}
@@ -7924,14 +8039,17 @@ public static partial class AgcExports
// and run the same AddrLib-derived detile path used below for
// sampled textures before seeding the Vulkan image.
var storageSource = new byte[(int)physicalSourceByteCount];
if (ctx.Memory.TryRead(descriptor.Address, storageSource))
if (ctx.Memory.TryRead(descriptor.Address + baseMipByteOffset, storageSource))
{
readSucceeded = true;
var linearStorage = TryDetileTextureSource(
descriptor,
sourceWidth,
checked((int)sourceByteCount),
storageSource) ?? storageSource
storageSource,
baseMipInTail,
mipTailElementX,
mipTailElementY) ?? storageSource
.AsSpan(0, checked((int)sourceByteCount))
.ToArray();
if (linearStorage.AsSpan().IndexOfAnyExcept((byte)0) >= 0)
@@ -7987,6 +8105,19 @@ public static partial class AgcExports
// (skipping would leave the draw with no pixels and a fallback
// texture for the frame — visible flicker on animated textures).
var sampler = ToGuestSampler(samplerDescriptor);
// Track the guest allocation before reading its texels so a CPU
// rewrite landing after the copy still bumps the write generation.
// The generation rides on the texture and is recorded by the
// presenter after upload, where the upload-known skip compares it
// against the tracker to force fresh texels for rewritten memory.
SharpEmu.HLE.GuestImageWriteTracker.Track(
descriptor.Address,
physicalSourceByteCount,
source: "agc.decoded-texture");
var hasWriteGeneration =
SharpEmu.HLE.GuestImageWriteTracker.TryGetWriteGeneration(
descriptor.Address,
out var writeGeneration);
if (!_textureCopySkipDisabled &&
descriptor.Address != 0 &&
!SharpEmu.HLE.GuestImageWriteTracker.PeekDirty(descriptor.Address) &&
@@ -8000,7 +8131,9 @@ public static partial class AgcExports
descriptor.DstSelect,
descriptor.TileMode,
sourceWidth,
sampler)))
sampler,
isArrayed,
arrayUploadLayers)))
{
texture = new GuestDrawTexture(
descriptor.Address,
@@ -8018,17 +8151,77 @@ public static partial class AgcExports
Pitch: sourceWidth,
TileMode: descriptor.TileMode,
DstSelect: descriptor.DstSelect,
Sampler: sampler);
Sampler: sampler,
ArrayedView: isArrayed,
ArrayLayers: arrayUploadLayers);
return true;
}
if (wantsArrayUpload)
{
var arrayLayers = arrayUploadLayers;
var layerBytes = checked((int)sourceByteCount);
var totalBytes = (long)layerBytes * arrayLayers;
if (totalBytes <= int.MaxValue)
{
var layered = new byte[totalBytes];
var uploadedLayers = 0u;
for (var layer = 0u; layer < arrayLayers; layer++)
{
var sliceSource = new byte[(int)physicalSourceByteCount];
if (!ctx.Memory.TryRead(
descriptor.Address + layer * chainSliceBytes + baseMipByteOffset,
sliceSource))
{
break;
}
var sliceLinear = TryDetileTextureSource(
descriptor,
sourceWidth,
layerBytes,
sliceSource,
baseMipInTail,
mipTailElementX,
mipTailElementY) ?? sliceSource.AsSpan(0, layerBytes).ToArray();
sliceLinear.AsSpan(0, layerBytes)
.CopyTo(layered.AsSpan(checked((int)(layer * layerBytes))));
uploadedLayers++;
}
if (uploadedLayers == arrayLayers)
{
texture = new GuestDrawTexture(
descriptor.Address,
descriptor.Width,
descriptor.Height,
descriptor.Format,
descriptor.NumberType,
layered,
IsFallback: false,
IsStorage: false,
MipLevels: descriptor.MipLevels,
MipLevel: mipLevel,
BaseMipLevel: descriptor.ViewBaseLevel,
ResourceMipLevels: descriptor.ResourceMipLevels,
Pitch: sourceWidth,
TileMode: descriptor.TileMode,
DstSelect: descriptor.DstSelect,
Sampler: sampler,
ArrayedView: true,
ArrayLayers: arrayLayers);
return true;
}
}
}
var source = new byte[(int)physicalSourceByteCount];
if (!ctx.Memory.TryRead(descriptor.Address, source))
if (!ctx.Memory.TryRead(descriptor.Address + baseMipByteOffset, source))
{
TraceTextureFallback(
descriptor,
$"guest-read-failed:{sourceByteCount}");
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType);
texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType, isArrayed);
return true;
}
@@ -8060,7 +8253,10 @@ public static partial class AgcExports
descriptor,
sourceWidth,
checked((int)sourceByteCount),
source) ?? source.AsSpan(0, checked((int)sourceByteCount)).ToArray();
source,
baseMipInTail,
mipTailElementX,
mipTailElementY) ?? source.AsSpan(0, checked((int)sourceByteCount)).ToArray();
DumpLinearTextureIfRequested(descriptor, sourceWidth, rgba);
texture = new GuestDrawTexture(
descriptor.Address,
@@ -8078,7 +8274,9 @@ public static partial class AgcExports
Pitch: sourceWidth,
TileMode: descriptor.TileMode,
DstSelect: descriptor.DstSelect,
Sampler: ToGuestSampler(samplerDescriptor));
Sampler: ToGuestSampler(samplerDescriptor),
WriteGeneration: hasWriteGeneration ? writeGeneration : -1,
ArrayedView: isArrayed);
return true;
}
@@ -8357,7 +8555,8 @@ public static partial class AgcExports
private static GuestDrawTexture CreateFallbackGuestDrawTexture(
bool isStorage,
uint format,
uint numberType)
uint numberType,
bool isArrayed = false)
{
var fallbackFormat = format == 0 ? 10u : format;
var fallbackNumberType = numberType;
@@ -8371,7 +8570,8 @@ public static partial class AgcExports
IsFallback: true,
IsStorage: isStorage,
MipLevels: 1,
MipLevel: 0);
MipLevel: 0,
ArrayedView: isArrayed);
}
private static GuestSampler ToGuestSampler(IReadOnlyList<uint> descriptor) =>
@@ -8747,7 +8947,8 @@ public static partial class AgcExports
texture,
isStorage,
binding.MipLevel ?? 0,
binding.SamplerDescriptor));
binding.SamplerDescriptor,
Gen5ShaderTranslator.IsArrayedImageBinding(binding)));
hasStorageBinding |= isStorage;
var descriptorState = descriptorValid ? string.Empty : "/invalid-desc";
@@ -11397,6 +11598,69 @@ public static partial class AgcExports
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
private static int RemoveResourcesForOwner(SubmittedGpuState state, uint owner)
{
var stale = new List<uint>();
foreach (var (handle, resource) in state.RegisteredResources)
{
if (resource.Owner == owner)
{
stale.Add(handle);
}
}
foreach (var handle in stale)
{
state.RegisteredResources.Remove(handle);
}
return stale.Count;
}
[SysAbiExport(
Nid = "ZLJk9r2+2Aw",
ExportName = "sceAgcDriverUnregisterOwnerAndResources",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int DriverUnregisterOwnerAndResources(CpuContext ctx)
{
var owner = (uint)ctx[CpuRegister.Rdi];
var state = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
int resources;
lock (state.Gate)
{
if (!state.ResourceOwners.Remove(owner))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
resources = RemoveResourcesForOwner(state, owner);
state.ComputeQueues.Remove(owner);
}
TraceAgc($"agc.driver_unregister_owner owner={owner} resources={resources}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "SCoAN5fYlUM",
ExportName = "sceAgcDriverUnregisterAllResourcesForOwner",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int DriverUnregisterAllResourcesForOwner(CpuContext ctx)
{
var owner = (uint)ctx[CpuRegister.Rdi];
var state = _submittedGpuStates.GetValue(ctx.Memory, static _ => new SubmittedGpuState());
int resources;
lock (state.Gate)
{
resources = RemoveResourcesForOwner(state, owner);
}
TraceAgc($"agc.driver_unregister_owner_resources owner={owner} resources={resources}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "pWLG7WOpVcw",
ExportName = "sceAgcDriverUnregisterResource",
+158
View File
@@ -194,6 +194,164 @@ internal static class GnmTiling
}
}
public static bool TryGetBlockElementDimensions(
uint swizzleMode,
int bytesPerElement,
out int blockWidth,
out int blockHeight)
{
blockWidth = 0;
blockHeight = 0;
if (bytesPerElement <= 0 ||
!TryGetSwizzleKind(swizzleMode, out _, out var blockBytes))
{
return false;
}
var bppLog2 = BitLog2((uint)bytesPerElement);
if (bppLog2 < 0)
{
return false;
}
(blockWidth, blockHeight) = SquareBlockDimensions(blockBytes >> bppLog2);
return blockWidth != 0 && blockHeight != 0;
}
/// <summary>
/// Locates mip 0 in a GFX10 mip chain, which AddrLib stores smallest-first
/// (Gfx10Lib::ComputeSurfaceInfoMacroTiled/MicroTiled).
/// </summary>
public static bool TryGetBaseMipPlacement(
uint swizzleMode,
int elementsWide,
int elementsHigh,
int bytesPerElement,
uint resourceMipLevels,
out ulong byteOffset,
out bool inMipTail,
out int tailElementX,
out int tailElementY,
out ulong chainSliceBytes)
{
byteOffset = 0;
inMipTail = false;
tailElementX = 0;
tailElementY = 0;
chainSliceBytes = 0;
if (resourceMipLevels <= 1 ||
!ShouldDetile(swizzleMode) ||
elementsWide <= 0 ||
elementsHigh <= 0 ||
bytesPerElement <= 0 ||
!TryGetSwizzleKind(swizzleMode, out _, out var blockBytes))
{
return false;
}
var bppLog2 = BitLog2((uint)bytesPerElement);
if (bppLog2 < 0)
{
return false;
}
var (blockWidth, blockHeight) = SquareBlockDimensions(blockBytes >> bppLog2);
var blockSizeLog2 = BitLog2((uint)blockBytes);
if (blockWidth == 0 || blockHeight == 0 || blockSizeLog2 < 8)
{
return false;
}
var mipLevels = (int)Math.Min(resourceMipLevels, 16u);
var maxMipsInTail = blockSizeLog2 <= 8 ? 0
: blockSizeLog2 <= 11
? 1 + (1 << (blockSizeLog2 - 9))
: blockSizeLog2 - 4;
var tailWidth = (blockSizeLog2 & 1) != 0 ? blockWidth >> 1 : blockWidth;
var tailHeight = (blockSizeLog2 & 1) != 0 ? blockHeight : blockHeight >> 1;
var firstMipInTail = mipLevels;
var mipSizes = new ulong[mipLevels];
for (var i = 0; i < mipLevels; i++)
{
var mipWidth = Math.Max(elementsWide >> i, 1);
var mipHeight = Math.Max(elementsHigh >> i, 1);
if (maxMipsInTail > 0 &&
mipWidth <= tailWidth &&
mipHeight <= tailHeight &&
mipLevels - i <= maxMipsInTail)
{
firstMipInTail = i;
break;
}
var alignedWidth = (ulong)(mipWidth + blockWidth - 1) / (ulong)blockWidth * (ulong)blockWidth;
var alignedHeight = (ulong)(mipHeight + blockHeight - 1) / (ulong)blockHeight * (ulong)blockHeight;
mipSizes[i] = alignedWidth * alignedHeight * (ulong)bytesPerElement;
}
if (firstMipInTail == 0)
{
var m = maxMipsInTail - 1;
var mipOffset = m > 6 ? 16 << m : m << 8;
var mipX = ((mipOffset >> 9) & 1) |
((mipOffset >> 10) & 2) |
((mipOffset >> 11) & 4) |
((mipOffset >> 12) & 8) |
((mipOffset >> 13) & 16) |
((mipOffset >> 14) & 32);
var mipY = ((mipOffset >> 8) & 1) |
((mipOffset >> 9) & 2) |
((mipOffset >> 10) & 4) |
((mipOffset >> 11) & 8) |
((mipOffset >> 12) & 16) |
((mipOffset >> 13) & 32);
if ((blockSizeLog2 & 1) != 0)
{
(mipX, mipY) = (mipY, mipX);
if ((bppLog2 & 1) != 0)
{
mipY = (mipY << 1) | (mipX & 1);
mipX >>= 1;
}
}
var (microWidth, microHeight) = SquareBlockDimensions(256 >> bppLog2);
if (microWidth == 0 || microHeight == 0)
{
return false;
}
tailElementX = mipX * microWidth;
tailElementY = mipY * microHeight;
if (tailElementX + elementsWide > blockWidth ||
tailElementY + elementsHigh > blockHeight)
{
tailElementX = 0;
tailElementY = 0;
return false;
}
inMipTail = true;
chainSliceBytes = (ulong)blockBytes;
return true;
}
byteOffset = firstMipInTail < mipLevels ? (ulong)blockBytes : 0;
chainSliceBytes = byteOffset;
for (var i = firstMipInTail - 1; i >= 1; i--)
{
byteOffset += mipSizes[i];
}
for (var i = 0; i < firstMipInTail; i++)
{
chainSliceBytes += mipSizes[i];
}
return true;
}
/// <summary>
/// Deswizzles <paramref name="tiled"/> into linear row-major order.
/// Elements are pixels for uncompressed formats and 4x4 blocks for
+90 -11
View File
@@ -17,7 +17,9 @@ public static class AvPlayerExports
private const int FrameBufferCount = 3;
private const int FrameInfoSize = 40;
private const int FrameInfoExSize = 104;
private const int StreamInfoSize = 40;
// This structure is 32 bytes. A larger write can damage the guest stack.
private const int StreamInfoSize = 32;
private const int StreamInfoExSize = 32;
private const int MaxGuestPathLength = 4096;
private static readonly object StateGate = new();
private static readonly Dictionary<ulong, PlayerState> Players = new();
@@ -404,7 +406,8 @@ public static class AvPlayerExports
ExportName = "sceAvPlayerGetStreamInfoEx",
Target = Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerSetDecoderMode(CpuContext ctx) => ValidatePlayer(ctx);
public static int AvPlayerGetStreamInfoEx(CpuContext ctx) =>
GetStreamInfoCore(ctx, StreamInfoExSize);
[SysAbiExport(
Nid = "XC9wM+xULz8",
@@ -561,12 +564,48 @@ public static class AvPlayerExports
}
}
internal static void RegisterPlayerForTest(
ulong handle,
int width,
int height,
ulong durationMilliseconds)
{
PlayerState? previous;
lock (StateGate)
{
Players.Remove(handle, out previous);
Players[handle] = new PlayerState
{
Handle = handle,
Width = width,
Height = height,
DurationMilliseconds = durationMilliseconds,
};
}
previous?.Dispose();
}
internal static void RemovePlayerForTest(ulong handle)
{
PlayerState? player;
lock (StateGate)
{
Players.Remove(handle, out player);
}
player?.Dispose();
}
[SysAbiExport(
Nid = "d8FcbzfAdQw",
ExportName = "sceAvPlayerGetStreamInfo",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerGetStreamInfo(CpuContext ctx)
public static int AvPlayerGetStreamInfo(CpuContext ctx) =>
GetStreamInfoCore(ctx, StreamInfoSize);
private static int GetStreamInfoCore(CpuContext ctx, int infoSize)
{
var streamIndex = unchecked((uint)ctx[CpuRegister.Rsi]);
var infoAddress = ctx[CpuRegister.Rdx];
@@ -578,7 +617,7 @@ public static class AvPlayerExports
return SetReturn(ctx, InvalidParameters);
}
Span<byte> info = stackalloc byte[StreamInfoSize];
Span<byte> info = stackalloc byte[infoSize];
info.Clear();
BinaryPrimitives.WriteUInt32LittleEndian(info[0..], streamIndex); // 0=video, 1=audio
if (streamIndex == 0)
@@ -1009,7 +1048,7 @@ public static class AvPlayerExports
{
return false;
}
var ffprobe = Path.Combine(Path.GetDirectoryName(ffmpeg) ?? string.Empty, "ffprobe");
var ffprobe = GetFfprobePath(ffmpeg, OperatingSystem.IsWindows());
if (!File.Exists(ffprobe))
{
return false;
@@ -1092,13 +1131,33 @@ public static class AvPlayerExports
}
}
private static string? FindFfmpeg()
private static string? FindFfmpeg() =>
FindFfmpeg(
Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH"),
Environment.GetEnvironmentVariable("PATH"),
OperatingSystem.IsWindows());
internal static string? FindFfmpeg(
string? configured,
string? searchPath,
bool isWindows)
{
var configured = Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH");
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
{
return configured;
}
var executable = isWindows ? "ffmpeg.exe" : "ffmpeg";
foreach (var directory in (searchPath ?? string.Empty)
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
var candidate = Path.Combine(RemovePathQuotes(directory), executable);
if (File.Exists(candidate))
{
return candidate;
}
}
foreach (var candidate in new[] { "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg" })
{
if (File.Exists(candidate))
@@ -1109,6 +1168,16 @@ public static class AvPlayerExports
return null;
}
internal static string GetFfprobePath(string ffmpeg, bool isWindows) =>
Path.Combine(
Path.GetDirectoryName(ffmpeg) ?? string.Empty,
isWindows ? "ffprobe.exe" : "ffprobe");
private static string RemovePathQuotes(string directory) =>
directory.Length >= 2 && directory[0] == '"' && directory[^1] == '"'
? directory[1..^1]
: directory;
internal static string? ResolveGuestPath(string guestPath)
{
if (string.IsNullOrWhiteSpace(guestPath))
@@ -1118,7 +1187,9 @@ public static class AvPlayerExports
var normalized = guestPath.Replace('\\', '/');
var fileReference = normalized.StartsWith("file:", StringComparison.OrdinalIgnoreCase);
var unrealProjectRelative = false;
var unrealProjectRelative =
normalized.StartsWith("../", StringComparison.Ordinal) ||
normalized.StartsWith("./", StringComparison.Ordinal);
if (normalized.StartsWith("file://", StringComparison.OrdinalIgnoreCase) &&
Uri.TryCreate(normalized, UriKind.Absolute, out var uri) &&
uri.IsFile)
@@ -1149,7 +1220,10 @@ public static class AvPlayerExports
if (unrealProjectRelative)
{
normalized = RemoveUnrealLeadingDotSegments(normalized);
if (!TryRemoveUnrealLeadingDotSegments(normalized, out normalized))
{
return null;
}
}
var app0 = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
@@ -1233,15 +1307,20 @@ public static class AvPlayerExports
}
}
private static string RemoveUnrealLeadingDotSegments(string guestPath)
private static bool TryRemoveUnrealLeadingDotSegments(
string guestPath,
out string normalized)
{
var removedParent = false;
while (guestPath.StartsWith("../", StringComparison.Ordinal) ||
guestPath.StartsWith("./", StringComparison.Ordinal))
{
removedParent |= guestPath.StartsWith("../", StringComparison.Ordinal);
guestPath = guestPath[(guestPath.IndexOf('/') + 1)..];
}
return guestPath;
normalized = guestPath;
return !removedParent || guestPath.Contains('/');
}
private static bool TryDecodeFileReference(string encoded, out string decoded)
+9 -2
View File
@@ -27,7 +27,12 @@ internal sealed record GuestDrawTexture(
uint Pitch = 0,
uint TileMode = 0,
uint DstSelect = 0xFAC,
GuestSampler Sampler = default);
GuestSampler Sampler = default,
// Guest CPU write-tracker generation of the memory RgbaPixels was read
// from; -1 when the range is untracked or the pixels were not read here.
long WriteGeneration = -1,
bool ArrayedView = false,
uint ArrayLayers = 1);
/// <summary>Raw guest sampler descriptor dwords, copied verbatim from guest memory.</summary>
internal readonly record struct GuestSampler(
@@ -48,7 +53,9 @@ internal readonly record struct TextureContentIdentity(
uint DstSelect,
uint TileMode,
uint Pitch,
GuestSampler Sampler);
GuestSampler Sampler,
bool Arrayed = false,
uint ArrayLayers = 1);
internal sealed record GuestMemoryBuffer(
ulong BaseAddress,
+2 -2
View File
@@ -362,7 +362,7 @@ public static class KernelExports
ExportName = "open",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int Open(CpuContext ctx) => KernelMemoryCompatExports.KernelOpenUnderscore(ctx);
public static int Open(CpuContext ctx) => KernelMemoryCompatExports.PosixOpen(ctx);
[SysAbiExport(
Nid = "1G3lF1Gg1k8",
@@ -376,7 +376,7 @@ public static class KernelExports
ExportName = "fstat",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libc")]
public static int Fstat(CpuContext ctx) => KernelMemoryCompatExports.KernelFstat(ctx);
public static int Fstat(CpuContext ctx) => KernelMemoryCompatExports.PosixFstat(ctx);
[SysAbiExport(
Nid = "hcuQgD53UxM",
@@ -1,4 +1,4 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
@@ -65,7 +65,10 @@ public static partial class KernelMemoryCompatExports
private const uint HostPageExecuteReadWrite = 0x40;
private const uint HostPageExecuteWriteCopy = 0x80;
private const uint HostPageGuard = 0x100;
private const int Enoent = 2;
private const int Ebadf = 9;
private const int Enomem = 12;
private const int Eacces = 13;
private const int Efault = 14;
private const int Einval = 22;
private const int Erange = 34;
@@ -1105,10 +1108,13 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
var payload = GC.AllocateUninitializedArray<byte>(count);
if (count > 0 && (!TryReadCompat(ctx, source, payload) || !TryWriteCompat(ctx, destination, payload)))
if (count > 0 && !ctx.Memory.TryCopy(destination, source, (ulong)count))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
var payload = GC.AllocateUninitializedArray<byte>(count);
if (!TryReadCompat(ctx, source, payload) || !TryWriteCompat(ctx, destination, payload))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
}
ctx[CpuRegister.Rax] = destination;
@@ -1542,7 +1548,13 @@ public static partial class KernelMemoryCompatExports
ExportName = "close",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixClose(CpuContext ctx) => KernelCloseCore(ctx, unchecked((int)ctx[CpuRegister.Rdi]));
public static int PosixClose(CpuContext ctx)
{
var result = KernelCloseCore(ctx, unchecked((int)ctx[CpuRegister.Rdi]));
return result == (int)OrbisGen2Result.ORBIS_GEN2_OK
? 0
: PosixFailure(ctx, result, notFoundErrno: Ebadf);
}
[SysAbiExport(
Nid = "UK2Tl2DWUns",
@@ -1599,6 +1611,29 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
// Translates a failed raw Orbis kernel result into the libc/POSIX ABI:
// return -1 with errno set. The raw sceKernel* implementations report the
// 0x8002xxxx sentinel through the return value, but the POSIX-named exports
// (open/fstat/close/read/write/stat) are called by libc code that expects a
// negative result on failure. Leaking the raw sentinel makes callers store
// it as a "valid" fd or handle and later dereference it - the null-pointer
// access violation seen when Unity's IL2CPP file layer probes an absent
// il2cpp.usym. fd-based calls pass notFoundErrno=Ebadf; path-based calls
// leave the Enoent default.
private static int PosixFailure(CpuContext ctx, int orbisResult, int notFoundErrno = Enoent)
{
var errno = orbisResult switch
{
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT => Einval,
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT => Efault,
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_PERMISSION_DENIED => Eacces,
_ => notFoundErrno,
};
KernelRuntimeCompatExports.TrySetErrno(ctx, errno);
ctx[CpuRegister.Rax] = ulong.MaxValue;
return -1;
}
[SysAbiExport(
Nid = "E6ao34wPw+U",
ExportName = "stat",
@@ -1607,23 +1642,29 @@ public static partial class KernelMemoryCompatExports
public static int PosixStat(CpuContext ctx)
{
var result = KernelStat(ctx);
if (result == (int)OrbisGen2Result.ORBIS_GEN2_OK)
{
return 0;
}
return result == (int)OrbisGen2Result.ORBIS_GEN2_OK
? 0
: PosixFailure(ctx, result);
}
// 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;
// POSIX open(2): translates a failed raw open into -1/errno. On success
// KernelOpenUnderscore already writes the fd into RAX (the import bridge
// prefers a written RAX over the return value), so returning 0 is correct.
public static int PosixOpen(CpuContext ctx)
{
var result = KernelOpenUnderscore(ctx);
return result == (int)OrbisGen2Result.ORBIS_GEN2_OK
? 0
: PosixFailure(ctx, result);
}
// POSIX fstat(2): a bad fd maps to EBADF rather than the path-oriented ENOENT.
public static int PosixFstat(CpuContext ctx)
{
var result = KernelFstat(ctx);
return result == (int)OrbisGen2Result.ORBIS_GEN2_OK
? 0
: PosixFailure(ctx, result, notFoundErrno: Ebadf);
}
[SysAbiExport(
@@ -1637,6 +1678,7 @@ public static partial class KernelMemoryCompatExports
var count = ctx[CpuRegister.Rsi];
var idsAddress = ctx[CpuRegister.Rdx];
var sizesAddress = ctx[CpuRegister.Rcx];
var errorIndexAddress = ctx[CpuRegister.R8];
if (pathListAddress == 0 || count == 0 || sizesAddress == 0 || count > 1024)
{
KernelRuntimeCompatExports.TrySetErrno(ctx, Einval);
@@ -1661,12 +1703,8 @@ public static partial class KernelMemoryCompatExports
var hostPath = ResolveGuestPath(guestPath);
if (!TryGetAprFileSize(hostPath, out var fileSize))
{
// Per-file resolve: a missing entry gets an invalid id
// (0xFFFFFFFF, already written above) and size 0, and the batch
// CONTINUES. Aborting the whole batch on the first miss left the
// remaining paths unresolved and could stall the guest's asset
// streaming when a batch happens to include an absent (e.g.
// patch/DLC) file; the caller checks per-file id/size.
// Stop at the first miss and report its index.
// The caller can then use its normal file-open fallback.
LogIoTrace("apr_resolve", guestPath, $"host='{hostPath}' index={i} count={count} result=not_found");
if (sizesAddress != 0 &&
!TryWriteUInt64Compat(ctx, sizesAddress + (i * sizeof(ulong)), 0))
@@ -1675,7 +1713,16 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
continue;
if (errorIndexAddress != 0 &&
!TryWriteUInt32Compat(ctx, errorIndexAddress, (uint)i))
{
KernelRuntimeCompatExports.TrySetErrno(ctx, Efault);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
KernelRuntimeCompatExports.TrySetErrno(ctx, 2); // ENOENT
ctx[CpuRegister.Rax] = ulong.MaxValue;
return -1;
}
var fileId = AmprFileRegistry.Register(guestPath, hostPath);
@@ -2093,7 +2140,15 @@ public static partial class KernelMemoryCompatExports
ExportName = "read",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixRead(CpuContext ctx) => KernelReadUnderscore(ctx);
public static int PosixRead(CpuContext ctx)
{
// On success KernelReadUnderscore writes the byte count into RAX, which
// the import bridge prefers over this return value.
var result = KernelReadUnderscore(ctx);
return result == (int)OrbisGen2Result.ORBIS_GEN2_OK
? 0
: PosixFailure(ctx, result, notFoundErrno: Ebadf);
}
[SysAbiExport(
Nid = "Cg4srZ6TKbU",
@@ -2292,7 +2347,15 @@ public static partial class KernelMemoryCompatExports
ExportName = "write",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixWrite(CpuContext ctx) => KernelWriteUnderscore(ctx);
public static int PosixWrite(CpuContext ctx)
{
// On success KernelWriteUnderscore writes the byte count into RAX, which
// the import bridge prefers over this return value.
var result = KernelWriteUnderscore(ctx);
return result == (int)OrbisGen2Result.ORBIS_GEN2_OK
? 0
: PosixFailure(ctx, result, notFoundErrno: Ebadf);
}
[SysAbiExport(
Nid = "4wSze92BhLI",
@@ -2327,6 +2390,37 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "smIj7eqzZE8",
ExportName = "clock_getres",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int ClockGetres(CpuContext ctx)
{
var timespecAddress = ctx[CpuRegister.Rsi];
// POSIX allows a null resolution pointer: the call then only validates
// the clock id, which every id a title passes here does.
if (timespecAddress == 0)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
// clock_gettime above is backed by DateTimeOffset.UtcNow, whose tick is
// 100 ns, so that is the honest resolution to report rather than the 1 ns
// a caller might otherwise assume it can rely on.
const ulong ResolutionNanoseconds = 100;
if (!ctx.TryWriteUInt64(timespecAddress, 0) ||
!ctx.TryWriteUInt64(timespecAddress + sizeof(long), ResolutionNanoseconds))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "vNe1w4diLCs",
ExportName = "__tls_get_addr",
@@ -2827,12 +2921,51 @@ public static partial class KernelMemoryCompatExports
LibraryName = "libKernel")]
public static int KernelMapDirectMemory(CpuContext ctx)
{
var inOutAddressPointer = ctx[CpuRegister.Rdi];
var length = ctx[CpuRegister.Rsi];
var protection = unchecked((int)ctx[CpuRegister.Rdx]);
var flags = ctx[CpuRegister.Rcx];
var directMemoryStart = ctx[CpuRegister.R8];
var alignment = ctx[CpuRegister.R9];
return MapDirectMemoryCore(
ctx,
inOutAddressPointer: ctx[CpuRegister.Rdi],
length: ctx[CpuRegister.Rsi],
protection: unchecked((int)ctx[CpuRegister.Rdx]),
flags: ctx[CpuRegister.Rcx],
directMemoryStart: ctx[CpuRegister.R8],
alignment: ctx[CpuRegister.R9]);
}
[SysAbiExport(
Nid = "BQQniolj9tQ",
ExportName = "sceKernelMapDirectMemory2",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int KernelMapDirectMemory2(CpuContext ctx)
{
// The "2" variant inserts a memoryType argument (rdx) ahead of v1's
// protection, shifting protection/flags/directMemoryStart down one
// register each and pushing alignment onto the stack (the 7th argument,
// at [rsp + 8], above the return address). The memoryType only selects
// cache/GPU access attributes, which this HLE does not model per
// mapping, so it is accepted but does not affect placement.
ulong alignment = 0;
_ = ctx.TryReadUInt64(ctx[CpuRegister.Rsp] + sizeof(ulong), out alignment);
return MapDirectMemoryCore(
ctx,
inOutAddressPointer: ctx[CpuRegister.Rdi],
length: ctx[CpuRegister.Rsi],
protection: unchecked((int)ctx[CpuRegister.Rcx]),
flags: ctx[CpuRegister.R8],
directMemoryStart: ctx[CpuRegister.R9],
alignment: alignment);
}
private static int MapDirectMemoryCore(
CpuContext ctx,
ulong inOutAddressPointer,
ulong length,
int protection,
ulong flags,
ulong directMemoryStart,
ulong alignment)
{
if (ShouldTraceDirectMemory())
{
Console.Error.WriteLine(
@@ -2945,6 +3078,7 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
GuestWriteWatch.OnDirectMapping(mappedAddress, length, protection);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -3327,7 +3461,7 @@ public static partial class KernelMemoryCompatExports
public static int KernelDirectMemoryQuery(CpuContext ctx)
{
var offset = ctx[CpuRegister.Rdi];
_ = ctx[CpuRegister.Rsi]; // flags
var flags = ctx[CpuRegister.Rsi];
var infoAddress = ctx[CpuRegister.Rdx];
var infoSize = ctx[CpuRegister.Rcx];
if (infoAddress == 0 || infoSize < 24)
@@ -3335,27 +3469,90 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
if (offset >= DirectMemorySizeBytes)
{
// Real hardware returns EACCES here (0x8002000D), not ENOENT.
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DELETED;
}
var findNext = (flags & 1) != 0;
var found = false;
var matchStart = 0UL;
var matchEnd = 0UL;
var matchMemoryType = 0;
lock (_memoryGate)
{
foreach (var block in _directAllocations.Values)
var candidates = _directAllocations.Values
.Where(block => findNext
? block.Start + block.Length > offset
: offset >= block.Start && offset < block.Start + block.Length)
.OrderBy(block => block.Start);
foreach (var block in candidates)
{
if (offset < block.Start || offset >= block.Start + block.Length)
{
continue;
}
if (!ctx.TryWriteUInt64(infoAddress, block.Start) ||
!ctx.TryWriteUInt64(infoAddress + sizeof(ulong), block.Start + block.Length) ||
!TryWriteInt32(ctx, infoAddress + (sizeof(ulong) * 2), block.MemoryType))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
found = true;
matchStart = block.Start;
matchEnd = block.Start + block.Length;
matchMemoryType = block.MemoryType;
break;
}
}
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
if (!found)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DELETED;
}
if (!ctx.TryWriteUInt64(infoAddress, matchStart) ||
!ctx.TryWriteUInt64(infoAddress + sizeof(ulong), matchEnd) ||
!TryWriteInt32(ctx, infoAddress + (sizeof(ulong) * 2), matchMemoryType))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
/// <summary>
/// POSIX alias of <see cref="KernelMprotect"/>; identical (addr, len, prot)
/// argument order. Imported by libcohtml, whose embedded V8 changes page
/// permissions through this name when moving JIT pages between writable and
/// executable.
/// </summary>
[SysAbiExport(
Nid = "YQOfxL4QfeU",
ExportName = "mprotect",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixMprotect(CpuContext ctx) => KernelMprotect(ctx);
/// <summary>
/// POSIX alias of <see cref="KernelMunmap"/>; identical (addr, len) argument
/// order.
/// </summary>
[SysAbiExport(
Nid = "UqDGjXA5yUM",
ExportName = "munmap",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixMunmap(CpuContext ctx) => KernelMunmap(ctx);
/// <summary>
/// Reports the 16 KiB page granularity this backend maps and aligns against
/// (<see cref="OrbisPageSize"/>), not the host's 4 KiB. An allocator that
/// rounded to the host value would hand back sub-page offsets that every
/// mapping call here then rejects for misalignment.
/// </summary>
[SysAbiExport(
Nid = "k+AXqu2-eBc",
ExportName = "getpagesize",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixGetPageSize(CpuContext ctx)
{
ctx[CpuRegister.Rax] = OrbisPageSize;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
@@ -4488,7 +4685,8 @@ public static partial class KernelMemoryCompatExports
allowSearch: false,
allowAllocateAtAlternative: false,
"reserve fixed range",
out _);
out _,
backPartialOverlap: true);
}
internal static bool IsGuestRangeBacked(CpuContext ctx, ulong address, ulong length)
@@ -4689,7 +4887,7 @@ public static partial class KernelMemoryCompatExports
!guestPath.StartsWith("/", StringComparison.Ordinal) &&
!guestPath.StartsWith("\\", StringComparison.Ordinal))
{
var relative = guestPath.Replace('/', Path.DirectorySeparatorChar);
var relative = NormalizeMountRelativePath(guestPath);
return Path.Combine(app0Root, relative);
}
}
@@ -4764,12 +4962,40 @@ public static partial class KernelMemoryCompatExports
return _cachedApp0Root;
}
// Resolves "." and ".." inside a mount-relative guest path and clamps the
// result at the mount root, so a guest path can never escape into the host
// filesystem. Unreal Engine titles depend on this: their base directory is
// <app>/binaries/<platform>, so they address content with "../../../"
// prefixes that land back inside /app0 on real hardware. Combining those
// raw against the app0 root walked out of the game folder entirely, so the
// title enumerated an unrelated host directory and never found its .pak files.
private static string NormalizeMountRelativePath(string relativePath)
{
return relativePath
.TrimStart('/', '\\')
.Replace('/', Path.DirectorySeparatorChar)
.Replace('\\', Path.DirectorySeparatorChar);
var segments = relativePath.Split(
new[] { '/', '\\' },
StringSplitOptions.RemoveEmptyEntries);
var resolved = new List<string>(segments.Length);
foreach (var segment in segments)
{
if (segment == ".")
{
continue;
}
if (segment == "..")
{
if (resolved.Count > 0)
{
resolved.RemoveAt(resolved.Count - 1);
}
continue;
}
resolved.Add(segment);
}
return string.Join(Path.DirectorySeparatorChar, resolved);
}
private static string ResolveDevlogAppRoot()
@@ -5592,6 +5818,74 @@ public static partial class KernelMemoryCompatExports
return true;
}
/// <summary>
/// Inserts <paramref name="replacement"/> into the tracked-region table,
/// carving it out of any regions it overlaps and preserving the parts of
/// those regions that fall outside it.
/// </summary>
/// <remarks>
/// The table is a SortedList keyed by start address, so assigning directly
/// destroys any existing entry that happens to share a start address. That
/// silently discarded enclosing reservations: a title reserves a large range
/// and then commits a small mapping at the same base, and the record of
/// everything past the small mapping disappears — leaving sceKernelVirtualQuery
/// unable to find memory the guest legitimately owns.
///
/// Carving also keeps the table non-overlapping. Previously a new region
/// starting inside an existing one produced two overlapping entries, which
/// the ordered scan in TryFindVirtualQueryRegionLocked is not written to
/// expect.
/// </remarks>
private static void ReplaceMappedRegionRangeLocked(MappedRegion replacement)
{
if (replacement.Length == 0 ||
!TryAddU64(replacement.Address, replacement.Length, out var replacementEnd))
{
_mappedRegions[replacement.Address] = replacement;
return;
}
var start = replacement.Address;
List<MappedRegion>? overlapping = null;
foreach (var region in _mappedRegions.Values)
{
if (region.Length == 0 ||
!TryAddU64(region.Address, region.Length, out var regionEnd))
{
continue;
}
if (region.Address < replacementEnd && regionEnd > start)
{
(overlapping ??= []).Add(region);
}
}
if (overlapping is not null)
{
foreach (var region in overlapping)
{
_mappedRegions.Remove(region.Address);
}
foreach (var region in overlapping)
{
var regionEnd = region.Address + region.Length;
if (region.Address < start)
{
AddMappedRegionSliceLocked(region, region.Address, start, region.Protection);
}
if (regionEnd > replacementEnd)
{
AddMappedRegionSliceLocked(region, replacementEnd, regionEnd, region.Protection);
}
}
}
_mappedRegions[start] = replacement;
}
private static void AddMappedRegionSliceLocked(
MappedRegion source,
ulong start,
@@ -41,18 +41,87 @@ public static class KernelPthreadCompatExports
private sealed class PthreadMutexState
{
public ulong OwnerThreadId { get; set; }
public int RecursionCount { get; set; }
private long _ownerThreadId;
private int _recursionCount;
private int _queuedWaiterCount;
public Lock SyncRoot { get; } = new();
public ulong OwnerThreadId
{
get => unchecked((ulong)Volatile.Read(ref _ownerThreadId));
set => Volatile.Write(ref _ownerThreadId, unchecked((long)value));
}
public int RecursionCount
{
get => Volatile.Read(ref _recursionCount);
set => Volatile.Write(ref _recursionCount, value);
}
public int QueuedWaiterCount => Volatile.Read(ref _queuedWaiterCount);
public int Type { get; set; } = MutexTypeErrorCheck;
public int Protocol { get; set; }
public LinkedList<PthreadMutexWaiter> Waiters { get; } = new();
public bool TryAcquireUncontended(ulong threadId, bool allowWaiterBarge)
{
if (!allowWaiterBarge && QueuedWaiterCount != 0)
{
return false;
}
return TryAcquireOwner(threadId);
}
public bool TryAcquireOwner(ulong threadId)
{
if (Interlocked.CompareExchange(
ref _ownerThreadId,
unchecked((long)threadId),
0) != 0)
{
return false;
}
Volatile.Write(ref _recursionCount, 1);
return true;
}
public bool TryReleaseUncontended(ulong threadId)
{
if (QueuedWaiterCount != 0 || RecursionCount != 1)
{
return false;
}
Volatile.Write(ref _recursionCount, 0);
if (Interlocked.CompareExchange(
ref _ownerThreadId,
0,
unchecked((long)threadId)) == unchecked((long)threadId))
{
return true;
}
Volatile.Write(ref _recursionCount, 1);
return false;
}
public int IncrementRecursion() => Interlocked.Increment(ref _recursionCount);
public int DecrementRecursion() => Interlocked.Decrement(ref _recursionCount);
public void WaiterAddedLocked() => Interlocked.Increment(ref _queuedWaiterCount);
public void WaiterRemovedLocked() => Interlocked.Decrement(ref _queuedWaiterCount);
}
private sealed class PthreadMutexWaiter
{
public required ulong ThreadId { get; init; }
public required string WakeKey { get; init; }
public required bool Cooperative { get; init; }
public required bool Cooperative { get; set; }
public ManualResetEventSlim? HostSignal { get; set; }
public LinkedListNode<PthreadMutexWaiter>? Node { get; set; }
public int Granted;
}
@@ -94,7 +163,10 @@ public static class KernelPthreadCompatExports
public static int PthreadSelf(CpuContext ctx)
{
var currentThreadHandle = KernelPthreadState.GetCurrentThreadHandle();
GuestThreadExecution.Scheduler?.RegisterGuestThreadContext(currentThreadHandle, ctx);
if (GuestThreadExecution.CurrentGuestThreadHandle != currentThreadHandle)
{
GuestThreadExecution.Scheduler?.RegisterGuestThreadContext(currentThreadHandle, ctx);
}
ctx[CpuRegister.Rax] = currentThreadHandle;
TracePthreadSelf(ctx, currentThreadHandle);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
@@ -139,6 +211,13 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "B5GmVDKwpn0",
ExportName = "pthread_yield",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixPthreadYield(CpuContext ctx) => PthreadYield(ctx);
[SysAbiExport(
Nid = "GBUY7ywdULE",
ExportName = "scePthreadRename",
@@ -571,6 +650,30 @@ public static class KernelPthreadCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
/// <summary>
/// The POSIX-named alias of <see cref="PthreadOnce"/>. libKernel exports the
/// same routine under two NIDs, and shipped middleware links the plain name:
/// DOOM's libcohtml, PlayFab and party modules all import this one rather
/// than scePthreadOnce.
/// </summary>
[SysAbiExport(
Nid = "Z4QosVuAsA0",
ExportName = "pthread_once",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadOncePOSIX(CpuContext ctx) => PthreadOnce(ctx);
/// <summary>
/// The POSIX-named alias of <see cref="PthreadRename"/>, following the same
/// two-NID pattern as <see cref="PthreadOncePOSIX"/>.
/// </summary>
[SysAbiExport(
Nid = "9vyP6Z7bqzc",
ExportName = "pthread_rename_np",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadRenameNpPOSIX(CpuContext ctx) => PthreadRename(ctx);
private static int PthreadMutexInitCore(CpuContext ctx, ulong mutexAddress, ulong attrAddress)
{
if (mutexAddress == 0)
@@ -621,7 +724,7 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
lock (state)
lock (state.SyncRoot)
{
if (state.OwnerThreadId != 0 || state.RecursionCount != 0 || state.Waiters.Count != 0)
{
@@ -653,11 +756,63 @@ public static class KernelPthreadCompatExports
}
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
if (state.TryAcquireUncontended(currentThreadId, allowWaiterBarge: tryOnly))
{
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (state.OwnerThreadId == currentThreadId)
{
if (state.Type == MutexTypeRecursive)
{
state.IncrementRecursion();
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (!tryOnly && state.Type == MutexTypeAdaptiveNp &&
IsGuestTrackedSelfLock(ctx, mutexAddress, currentThreadId))
{
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
}
if (state.Type == MutexTypeAdaptiveNp)
{
var adaptiveResult = tryOnly
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
: (int)OrbisGen2Result.ORBIS_GEN2_OK;
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock-idempotent", mutexAddress, resolvedAddress, state, currentThreadId, adaptiveResult);
return adaptiveResult;
}
if (state.Type == MutexTypeNormal)
{
if (tryOnly)
{
TracePthreadMutex(ctx, "trylock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
state.IncrementRecursion();
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
var ownedResult = tryOnly
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, ownedResult);
return ownedResult;
}
var canCooperativelyBlock = !tryOnly &&
GuestThreadExecution.IsGuestThread &&
GuestThreadExecution.TryGetCurrentImportCallFrame(out _);
PthreadMutexWaiter? waiter = null;
lock (state)
var acquiredWhileQueueing = false;
lock (state.SyncRoot)
{
if (state.OwnerThreadId == currentThreadId)
{
@@ -668,7 +823,30 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (state.Type is MutexTypeNormal or MutexTypeAdaptiveNp)
if (!tryOnly && state.Type == MutexTypeAdaptiveNp &&
IsGuestTrackedSelfLock(ctx, mutexAddress, currentThreadId))
{
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
}
if (state.Type == MutexTypeAdaptiveNp)
{
if (tryOnly)
{
TracePthreadMutex(ctx, "trylock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY);
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
// Gen5 runtime wrappers can layer an adaptive lock call over
// scePthreadMutexLock for one logical acquisition, followed by
// only one unlock. Keep the duplicate acquisition idempotent so
// the matching unlock fully releases the HLE mutex.
TracePthreadMutex(ctx, "lock-idempotent", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (state.Type == MutexTypeNormal)
{
if (tryOnly)
{
@@ -677,7 +855,7 @@ public static class KernelPthreadCompatExports
}
// Several Gen5 runtimes layer their own owner/count bookkeeping
// over a NORMAL or ADAPTIVE kernel mutex. Returning EDEADLK here
// over a NORMAL kernel mutex. Returning EDEADLK here
// leaves that guest bookkeeping out of sync with the HLE owner and
// turns the wrapper into a permanent lock/unlock retry loop. Keep
// the compatibility recursion used by the original implementation;
@@ -703,10 +881,10 @@ public static class KernelPthreadCompatExports
// waiter wedge a spin-on-trylock loop forever even though the mutex
// is free (owner==0). The blocking lock still honours FIFO so real
// blocked waiters are not starved by a barging locker.
if (state.OwnerThreadId == 0 && (tryOnly || state.Waiters.Count == 0))
if (state.OwnerThreadId == 0 &&
(tryOnly || state.Waiters.Count == 0) &&
state.TryAcquireOwner(currentThreadId))
{
state.OwnerThreadId = currentThreadId;
state.RecursionCount = 1;
TracePthreadMutex(ctx, tryOnly ? "trylock" : "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
@@ -718,6 +896,14 @@ public static class KernelPthreadCompatExports
}
waiter = EnqueueMutexWaiterLocked(state, currentThreadId, canCooperativelyBlock);
acquiredWhileQueueing = TryGrantMutexWaiterLocked(state, waiter);
}
if (acquiredWhileQueueing)
{
waiter!.HostSignal?.Dispose();
TracePthreadMutex(ctx, "lock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (canCooperativelyBlock && waiter is not null &&
@@ -751,8 +937,29 @@ public static class KernelPthreadCompatExports
}
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
string? nextWakeKey = null;
lock (state)
if (state.OwnerThreadId == currentThreadId)
{
if (state.RecursionCount > 1)
{
state.DecrementRecursion();
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (state.TryReleaseUncontended(currentThreadId))
{
if (state.QueuedWaiterCount != 0)
{
WakeFirstMutexWaiter(state);
}
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
}
PthreadMutexWaiter? nextWaiter = null;
lock (state.SyncRoot)
{
if (state.RecursionCount <= 0)
{
@@ -770,16 +977,29 @@ public static class KernelPthreadCompatExports
if (state.RecursionCount == 0)
{
state.OwnerThreadId = 0;
nextWakeKey = state.Waiters.First?.Value.Cooperative == true
? state.Waiters.First.Value.WakeKey
: null;
Monitor.PulseAll(state);
// Hand the mutex directly to the head waiter instead of only
// waking it and relying on it to re-acquire. A woken waiter that
// fails to self-grant (its wake races or is lost) would leave the
// mutex "free with a queued waiter"; the fast-acquire path refuses
// such a mutex (OwnerThreadId == 0 && Waiters.Count == 0), so every
// later locker — including the game's main thread — then queues
// behind a head that never advances and the process wedges.
if (state.Waiters.First is { } headNode &&
TryGrantMutexWaiterLocked(state, headNode.Value))
{
nextWaiter = headNode.Value;
if (!nextWaiter.Cooperative)
{
nextWaiter.HostSignal!.Set();
}
}
}
}
if (nextWakeKey is not null)
if (nextWaiter is { Cooperative: true })
{
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWakeKey, 1);
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWaiter.WakeKey, 1);
}
TracePthreadMutex(ctx, "unlock", mutexAddress, resolvedAddress, state, currentThreadId, (int)OrbisGen2Result.ORBIS_GEN2_OK);
@@ -1239,7 +1459,7 @@ public static class KernelPthreadCompatExports
}
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
lock (mutexState)
lock (mutexState.SyncRoot)
{
if (mutexState.OwnerThreadId == 0 && mutexState.RecursionCount == 0)
{
@@ -1252,10 +1472,10 @@ public static class KernelPthreadCompatExports
// mutex held (the unlock below is skipped), wedging every thread
// that later blocks on pthread_mutex_lock. Adopt ownership so the
// unlock/wait/re-lock cycle is balanced and releases the mutex.
mutexState.OwnerThreadId = currentThreadId;
mutexState.RecursionCount = 1;
_ = mutexState.TryAcquireOwner(currentThreadId);
}
else if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
if (mutexState.OwnerThreadId != currentThreadId || mutexState.RecursionCount != 1)
{
return mutexState.OwnerThreadId == currentThreadId
? (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT
@@ -1424,6 +1644,7 @@ public static class KernelPthreadCompatExports
if (node.Value.ThreadId == threadId)
{
state.Waiters.Remove(node);
state.WaiterRemovedLocked();
node.Value.Node = null;
}
@@ -1438,8 +1659,10 @@ public static class KernelPthreadCompatExports
WakeKey = cooperative
? wakeKey ?? $"pthread_mutex_waiter:{Interlocked.Increment(ref _nextSynchronizationWaiterId)}"
: string.Empty,
HostSignal = cooperative ? null : new ManualResetEventSlim(initialState: false),
};
waiter.Node = state.Waiters.AddLast(waiter);
state.WaiterAddedLocked();
return waiter;
}
@@ -1449,7 +1672,7 @@ public static class KernelPthreadCompatExports
var mutex = new PthreadMutexState();
PthreadMutexWaiter first;
PthreadMutexWaiter second;
lock (mutex)
lock (mutex.SyncRoot)
{
first = EnqueueMutexWaiterLocked(mutex, 0x101, cooperative: false);
second = EnqueueMutexWaiterLocked(mutex, 0x202, cooperative: false);
@@ -1493,26 +1716,72 @@ public static class KernelPthreadCompatExports
return false;
}
if (!state.TryAcquireOwner(waiter.ThreadId))
{
return false;
}
state.Waiters.Remove(waiter.Node);
state.WaiterRemovedLocked();
waiter.Node = null;
state.OwnerThreadId = waiter.ThreadId;
state.RecursionCount = 1;
Volatile.Write(ref waiter.Granted, 1);
Monitor.PulseAll(state);
return true;
}
private static void WakeFirstMutexWaiter(PthreadMutexState state)
{
PthreadMutexWaiter? nextWaiter;
lock (state.SyncRoot)
{
if (state.OwnerThreadId != 0)
{
return;
}
nextWaiter = state.Waiters.First?.Value;
if (nextWaiter is { Cooperative: false })
{
nextWaiter.HostSignal!.Set();
}
}
if (nextWaiter is { Cooperative: true })
{
_ = GuestThreadExecution.Scheduler?.WakeBlockedThreads(nextWaiter.WakeKey, 1);
}
}
private static int WaitForHostMutexLock(PthreadMutexState state, PthreadMutexWaiter waiter)
{
lock (state)
ManualResetEventSlim? hostSignal = null;
try
{
while (!TryGrantMutexWaiterLocked(state, waiter))
while (true)
{
Monitor.Wait(state);
lock (state.SyncRoot)
{
if (waiter.HostSignal is null)
{
waiter.Cooperative = false;
waiter.HostSignal = new ManualResetEventSlim(initialState: false);
}
hostSignal = waiter.HostSignal;
if (TryGrantMutexWaiterLocked(state, waiter))
{
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
hostSignal.Reset();
}
hostSignal.Wait();
}
}
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
finally
{
hostSignal?.Dispose();
}
}
private static bool TryGrantBlockedMutexLock(
@@ -1523,7 +1792,7 @@ public static class KernelPthreadCompatExports
PthreadMutexWaiter waiter)
{
var granted = false;
lock (state)
lock (state.SyncRoot)
{
granted = TryGrantMutexWaiterLocked(state, waiter);
}
@@ -1557,6 +1826,10 @@ public static class KernelPthreadCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
private static bool IsGuestTrackedSelfLock(CpuContext ctx, ulong mutexAddress, ulong currentThreadId) =>
KernelMemoryCompatExports.TryReadUInt64Compat(ctx, mutexAddress + 8, out var guestOwner) &&
guestOwner == currentThreadId;
private static bool CompleteCondWaiterLocked(
PthreadCondState state,
PthreadCondWaiter waiter,
@@ -1572,7 +1845,7 @@ public static class KernelPthreadCompatExports
waiter.TimeoutTimer?.Dispose();
waiter.TimeoutTimer = null;
lock (waiter.MutexState)
lock (waiter.MutexState.SyncRoot)
{
waiter.MutexWaiter = EnqueueMutexWaiterLocked(
waiter.MutexState,
@@ -1620,7 +1893,7 @@ public static class KernelPthreadCompatExports
return false;
}
lock (waiter.MutexState)
lock (waiter.MutexState.SyncRoot)
{
return TryGrantMutexWaiterLocked(waiter.MutexState, mutexWaiter);
}
@@ -860,6 +860,18 @@ public static class KernelPthreadExtendedCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
/// <summary>
/// The POSIX-named alias of <see cref="PthreadAttrGetschedparam"/>. libKernel
/// exports the same routine under two NIDs; middleware compiled against the
/// plain POSIX headers links this one rather than scePthreadAttrGetschedparam.
/// </summary>
[SysAbiExport(
Nid = "qlk9pSLsUmM",
ExportName = "pthread_attr_getschedparam",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrGetschedparamPOSIX(CpuContext ctx) => PthreadAttrGetschedparam(ctx);
[SysAbiExport(
Nid = "FXPWHNk8Of0",
ExportName = "scePthreadAttrGetschedparam",
@@ -1133,6 +1145,90 @@ public static class KernelPthreadExtendedCompatExports
LibraryName = "libKernel")]
public static int PosixPthreadRwlockWrlock(CpuContext ctx) => PthreadRwlockWrlock(ctx);
[SysAbiExport(
Nid = "SFxTMOfuCkE",
ExportName = "pthread_rwlock_tryrdlock",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixPthreadRwlockTryrdlock(CpuContext ctx) =>
PthreadRwlockTryLockCore(ctx, ctx[CpuRegister.Rdi], write: false);
[SysAbiExport(
Nid = "XhWHn6P5R7U",
ExportName = "pthread_rwlock_trywrlock",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixPthreadRwlockTrywrlock(CpuContext ctx) =>
PthreadRwlockTryLockCore(ctx, ctx[CpuRegister.Rdi], write: true);
/// <summary>
/// Non-blocking counterpart of <see cref="PthreadRwlockLockCore"/>: acquires
/// only if the lock is free right now, otherwise reports BUSY.
/// </summary>
/// <remarks>
/// Deliberately not routed through TryAcquireBlockedRwlock. That helper exists
/// for the scheduler resume path and decrements WaitingWriters on success,
/// which is correct only for a thread that previously incremented it. A fresh
/// try never did, so reusing it would silently consume another thread's
/// waiter count and let a queued writer be skipped.
/// </remarks>
private static int PthreadRwlockTryLockCore(CpuContext ctx, ulong rwlockAddress, bool write)
{
if (rwlockAddress == 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
if (!TryResolveRwlockState(ctx, rwlockAddress, createIfZero: true, out var resolvedAddress, out var rwlock))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
var currentThreadId = KernelPthreadState.GetCurrentThreadHandle();
lock (rwlock.SyncRoot)
{
if (write)
{
if (rwlock.WriterThreadId == currentThreadId || rwlock.GetReaderCount(currentThreadId) > 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
}
// Mirrors the blocking path's re-entrant compat-writer grant so the
// two agree on what counts as already owning the lock.
if (rwlock.CompatWriterCounts.GetValueOrDefault(currentThreadId) > 0)
{
rwlock.AddCompatWriter(currentThreadId);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (rwlock.WriterThreadId != 0 ||
rwlock.ReaderTotalCount != 0 ||
rwlock.CompatWriterTotalCount != 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
DetectRwlockWriterConflict(resolvedAddress, rwlock, currentThreadId, "trywrlock");
rwlock.WriterThreadId = currentThreadId;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
if (rwlock.WriterThreadId == currentThreadId)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK;
}
if (ReaderMustWaitForRwlock(rwlock, currentThreadId))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY;
}
rwlock.AddReader(currentThreadId);
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
}
[SysAbiExport(
Nid = "+L98PIbGttk",
ExportName = "scePthreadRwlockUnlock",
@@ -1819,4 +1915,94 @@ public static class KernelPthreadExtendedCompatExports
BinaryPrimitives.WriteInt32LittleEndian(bytes, value);
return ctx.Memory.TryWrite(address, bytes);
}
// POSIX-named aliases. libKernel exports each of these routines under two
// NIDs -- a scePthread* name and the plain POSIX name -- and middleware
// compiled against POSIX headers links the latter. Both take identical
// arguments and, per the convention already used by scePthreadOnce's alias,
// return the same OrbisGen2Result rather than translating to errno.
[SysAbiExport(
Nid = "a2P9wYGeZvc",
ExportName = "pthread_setprio",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSetprioPOSIX(CpuContext ctx) => PthreadSetprio(ctx);
[SysAbiExport(
Nid = "FIs3-UQT9sg",
ExportName = "pthread_getschedparam",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadGetschedparamPOSIX(CpuContext ctx) => PthreadGetschedparam(ctx);
[SysAbiExport(
Nid = "vQm4fDEsWi8",
ExportName = "pthread_attr_getstack",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrGetstackPOSIX(CpuContext ctx) => PthreadAttrGetstack(ctx);
[SysAbiExport(
Nid = "Ucsu-OK+els",
ExportName = "pthread_attr_get_np",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrGetNpPOSIX(CpuContext ctx) => PthreadAttrGet(ctx);
[SysAbiExport(
Nid = "JarMIy8kKEY",
ExportName = "pthread_attr_setschedpolicy",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrSetschedpolicyPOSIX(CpuContext ctx) => PthreadAttrSetschedpolicy(ctx);
[SysAbiExport(
Nid = "E+tyo3lp5Lw",
ExportName = "pthread_attr_setdetachstate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrSetdetachstatePOSIX(CpuContext ctx) => PthreadAttrSetdetachstate(ctx);
[SysAbiExport(
Nid = "euKRgm0Vn2M",
ExportName = "pthread_attr_setschedparam",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrSetschedparamPOSIX(CpuContext ctx) => PthreadAttrSetschedparam(ctx);
[SysAbiExport(
Nid = "7ZlAakEf0Qg",
ExportName = "pthread_attr_setinheritsched",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrSetinheritschedPOSIX(CpuContext ctx) => PthreadAttrSetinheritsched(ctx);
[SysAbiExport(
Nid = "0qOtCR-ZHck",
ExportName = "pthread_attr_getstacksize",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrGetstacksizePOSIX(CpuContext ctx) => PthreadAttrGetstacksize(ctx);
[SysAbiExport(
Nid = "VUT1ZSrHT0I",
ExportName = "pthread_attr_getdetachstate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrGetdetachstatePOSIX(CpuContext ctx) => PthreadAttrGetdetachstate(ctx);
[SysAbiExport(
Nid = "JKyG3SWyA10",
ExportName = "pthread_attr_setguardsize",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrSetguardsizePOSIX(CpuContext ctx) => PthreadAttrSetguardsize(ctx);
[SysAbiExport(
Nid = "JNkVVsVDmOk",
ExportName = "pthread_attr_getguardsize",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadAttrGetguardsizePOSIX(CpuContext ctx) => PthreadAttrGetguardsize(ctx);
}
@@ -2058,6 +2058,13 @@ public static class KernelRuntimeCompatExports
LibraryName = "libKernel")]
public static int KernelNanosleep(CpuContext ctx) => NanosleepCore(ctx, posix: false);
[SysAbiExport(
Nid = "NhpspxdjEKU",
ExportName = "_nanosleep",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixNanosleepUnderscore(CpuContext ctx) => NanosleepCore(ctx, posix: true);
[SysAbiExport(
Nid = "yS8U2TGCe1A",
ExportName = "nanosleep",
@@ -428,6 +428,22 @@ public static class KernelSemaphoreCompatExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "GEnUkDZoUwY",
ExportName = "scePthreadSemInit",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemInit(CpuContext ctx)
{
// scePthreadSemInit(sem, flag, value, name) seems to only support private semaphores
if (ctx[CpuRegister.Rsi] != 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
return PosixSemInit(ctx);
}
[SysAbiExport(
Nid = "YCV5dGGBcCo",
ExportName = "sem_wait",
@@ -446,6 +462,13 @@ public static class KernelSemaphoreCompatExports
return KernelWaitSema(ctx);
}
[SysAbiExport(
Nid = "C36iRE0F5sE",
ExportName = "scePthreadSemWait",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemWait(CpuContext ctx) => PosixSemWait(ctx);
[SysAbiExport(
Nid = "WBWzsRifCEA",
ExportName = "sem_trywait",
@@ -463,6 +486,19 @@ public static class KernelSemaphoreCompatExports
return KernelPollSema(ctx, handle, 1);
}
[SysAbiExport(
Nid = "H2a+IN9TP0E",
ExportName = "scePthreadSemTrywait",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemTryWait(CpuContext ctx)
{
var result = PosixSemTryWait(ctx);
return result == (int)OrbisGen2Result.ORBIS_GEN2_ERROR_BUSY
? SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN)
: result;
}
[SysAbiExport(
Nid = "w5IHyvahg-o",
ExportName = "sem_timedwait",
@@ -499,6 +535,13 @@ public static class KernelSemaphoreCompatExports
return KernelSignalSema(ctx, handle, 1);
}
[SysAbiExport(
Nid = "aishVAiFaYM",
ExportName = "scePthreadSemPost",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemPost(CpuContext ctx) => PosixSemPost(ctx);
[SysAbiExport(
Nid = "Bq+LRV-N6Hk",
ExportName = "sem_getvalue",
@@ -549,6 +592,13 @@ public static class KernelSemaphoreCompatExports
return result;
}
[SysAbiExport(
Nid = "Vwc+L05e6oE",
ExportName = "scePthreadSemDestroy",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PthreadSemDestroy(CpuContext ctx) => PosixSemDestroy(ctx);
private static bool TryGetPosixSemaphoreHandle(CpuContext ctx, ulong semaphoreAddress, out uint handle)
{
handle = 0;
@@ -18,7 +18,8 @@ internal static class KernelVirtualRangeAllocator
bool allowSearch,
bool allowAllocateAtAlternative,
string traceName,
out ulong mappedAddress)
out ulong mappedAddress,
bool backPartialOverlap = false)
{
mappedAddress = 0;
if (length == 0)
@@ -42,6 +43,18 @@ internal static class KernelVirtualRangeAllocator
return true;
}
// Fixed mappings must cover the whole requested window even when part of
// it is already backed by another allocation. The single-call AllocateAt
// below is all-or-nothing and fails outright on partial overlap, leaving
// the untouched pages unmapped for the guest to fault into. Fill the free
// pages directly instead.
if (backPartialOverlap &&
addressSpace.TryBackFixedRange(desiredAddress, length, executable))
{
mappedAddress = desiredAddress;
return true;
}
var allocated = addressSpace.AllocateAt(desiredAddress, length, executable, allowAllocateAtAlternative);
if (allocated == 0)
{
+206
View File
@@ -184,6 +184,212 @@ public static class NetExports
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
/// <summary>
/// POSIX alias of <see cref="NetSetsockopt"/>; identical
/// (fd, level, option, value, length) argument order.
/// </summary>
[SysAbiExport(
Nid = "fFxGkxF2bVo",
ExportName = "setsockopt",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixSetsockopt(CpuContext ctx) => NetSetsockopt(ctx);
/// <summary>
/// Reads back the socket options this backend actually tracks: SO_NBIO,
/// SO_REUSEADDR and SO_ERROR.
/// </summary>
/// <remarks>
/// Anything else returns EINVAL rather than a zero-filled buffer. A caller
/// that receives success for an option nobody stored would treat whatever
/// happens to be in its output buffer as the real setting, which is a harder
/// failure to trace than an explicit rejection.
/// </remarks>
[SysAbiExport(
Nid = "6O8EwYOgH9Y",
ExportName = "getsockopt",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixGetsockopt(CpuContext ctx)
{
var id = unchecked((int)ctx[CpuRegister.Rdi]);
var level = unchecked((int)ctx[CpuRegister.Rsi]);
var option = unchecked((int)ctx[CpuRegister.Rdx]);
var valueAddress = ctx[CpuRegister.Rcx];
var lengthAddress = ctx[CpuRegister.R8];
if (!_sockets.TryGetValue(id, out var socket))
{
return SetNetError(ctx, NetErrorBadFileDescriptor, NetErrnoBadFileDescriptor);
}
if (valueAddress == 0 || lengthAddress == 0 || level != 0xFFFF)
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
Span<byte> lengthBytes = stackalloc byte[sizeof(int)];
if (!ctx.Memory.TryRead(lengthAddress, lengthBytes))
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
if (BinaryPrimitives.ReadInt32LittleEndian(lengthBytes) < sizeof(int))
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
int value;
switch (option)
{
// ORBIS_NET_SO_NBIO: mirrors what sceNetSetsockopt stored.
case 0x1200:
value = socket.Blocking ? 0 : 1;
break;
case 0x0004:
value = (int)socket.GetSocketOption(
SocketOptionLevel.Socket,
SocketOptionName.ReuseAddress)! != 0 ? 1 : 0;
break;
// ORBIS_NET_SO_ERROR: nothing here records per-socket async errors,
// so report "no pending error" rather than inventing one.
case 0x1007:
value = 0;
break;
default:
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
Span<byte> valueBytes = stackalloc byte[sizeof(int)];
BinaryPrimitives.WriteInt32LittleEndian(valueBytes, value);
BinaryPrimitives.WriteInt32LittleEndian(lengthBytes, sizeof(int));
if (!ctx.Memory.TryWrite(valueAddress, valueBytes) ||
!ctx.Memory.TryWrite(lengthAddress, lengthBytes))
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
TraceNet("socket.getsockopt", id, unchecked((uint)option), unchecked((uint)value), 0);
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "fZOeZIOEmLw",
ExportName = "send",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixSend(CpuContext ctx)
{
var id = unchecked((int)ctx[CpuRegister.Rdi]);
var bufferAddress = ctx[CpuRegister.Rsi];
var length = unchecked((int)ctx[CpuRegister.Rdx]);
if (!_sockets.TryGetValue(id, out var socket))
{
return SetNetError(ctx, NetErrorBadFileDescriptor, NetErrnoBadFileDescriptor);
}
if (length < 0 || (length != 0 && bufferAddress == 0))
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
if (length == 0)
{
return ctx.SetReturn(0);
}
var payload = new byte[length];
if (!ctx.Memory.TryRead(bufferAddress, payload))
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
try
{
var sent = socket.Send(payload, SocketFlags.None);
TraceNet("socket.send", id, unchecked((uint)length), unchecked((uint)sent), 0);
return ctx.SetReturn(sent);
}
catch (SocketException exception)
when (exception.SocketErrorCode == SocketError.WouldBlock)
{
return SetNetError(ctx, NetErrorWouldBlock, NetErrnoWouldBlock);
}
catch (SocketException)
{
return SetNetError(ctx, NetErrorInvalidArgument, NetErrnoInvalidArgument);
}
catch (ObjectDisposedException)
{
return SetNetError(ctx, NetErrorBadFileDescriptor, NetErrnoBadFileDescriptor);
}
}
/// <summary>
/// Formats a binary address as text. Pure conversion with no socket state,
/// so it behaves identically to the console version for AF_INET/AF_INET6.
/// </summary>
[SysAbiExport(
Nid = "5jRCs2axtr4",
ExportName = "inet_ntop",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int PosixInetNtop(CpuContext ctx)
{
var family = unchecked((int)ctx[CpuRegister.Rdi]);
var sourceAddress = ctx[CpuRegister.Rsi];
var destinationAddress = ctx[CpuRegister.Rdx];
var destinationSize = unchecked((int)ctx[CpuRegister.Rcx]);
if (sourceAddress == 0 || destinationAddress == 0 || destinationSize <= 0)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
// ORBIS_NET_AF_INET / ORBIS_NET_AF_INET6, matching TryMapAddressFamily.
var addressLength = family switch
{
2 => 4,
28 => 16,
_ => 0,
};
if (addressLength == 0)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
var rawAddress = new byte[addressLength];
if (!ctx.Memory.TryRead(sourceAddress, rawAddress))
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
var text = new IPAddress(rawAddress).ToString();
var encoded = Encoding.ASCII.GetBytes(text);
// POSIX requires the terminator to fit as well; a truncated address string
// is worse than a reported failure because the caller cannot detect it.
if (encoded.Length + 1 > destinationSize)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
var buffer = new byte[encoded.Length + 1];
encoded.CopyTo(buffer, 0);
if (!ctx.Memory.TryWrite(destinationAddress, buffer))
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
// inet_ntop returns the destination pointer on success.
ctx[CpuRegister.Rax] = destinationAddress;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "bErx49PgxyY",
ExportName = "sceNetBind",
+17
View File
@@ -69,6 +69,23 @@ public static class NpManagerExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
/// <summary>
/// Accepts the reachability callback and never invokes it. Reachability
/// transitions only ever fire on a real PSN connection, which an offline
/// session does not have, so registering successfully and staying silent is
/// the accurate emulation of a signed-out console rather than a stub.
/// </summary>
[SysAbiExport(
Nid = "hw5KNqAAels",
ExportName = "sceNpRegisterNpReachabilityStateCallback",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNpManager")]
public static int NpRegisterNpReachabilityStateCallback(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "qQJfO8HAiaY",
ExportName = "sceNpRegisterStateCallbackA",
+19
View File
@@ -80,6 +80,25 @@ public static class NpTrophy2Exports
LibraryName = "libSceNpTrophy2")]
public static int NpTrophy2ShowTrophyList(CpuContext ctx) => ReturnOk(ctx);
/// <summary>
/// Gen5 ABI: context, handle, trophy id, then SceNpTrophy2Details and
/// SceNpTrophy2Data output pointers.
/// </summary>
/// <remarks>
/// Reports "no such trophy" rather than succeeding. Succeeding would require
/// filling both output structures, and their exact layouts are not confirmed
/// here — a title that trusted zeroed details would read an empty name and a
/// grade of zero as real data. NOT_FOUND is a documented outcome that callers
/// must already handle, so it degrades along a path the game tests.
/// </remarks>
[SysAbiExport(
Nid = "EwNylPdWUTM",
ExportName = "sceNpTrophy2GetTrophyInfo",
Target = Generation.Gen5,
LibraryName = "libSceNpTrophy2")]
public static int NpTrophy2GetTrophyInfo(CpuContext ctx) =>
SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND);
private static int WriteIdAndReturn(CpuContext ctx, ulong outAddress, ref int nextId)
{
if (outAddress == 0)
+50 -7
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
@@ -698,14 +698,22 @@ public static class PlayGoExports
var hasMetadata = File.Exists(playGoDat) || File.Exists(scenarioJson) || File.Exists(chunkDefsXml);
if (!hasMetadata)
{
// 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");
// No PlayGo sidecar: derive the installed chunk set from the pak files
// actually present on disk. A locally dumped title has all of its data
// installed, and a package that splits content across chunks names them
// pakchunk<N>-<platform>.pak, so those N are exactly the chunks that
// exist. Reporting only chunk 0 told such a title its remaining content
// was missing: The Invincible (PPSA06426) ships pakchunk0..8 and spun
// forever re-querying scePlayGoGetLocus for a chunk that never became
// available. Available must stay true or scePlayGoOpen fails with
// NotSupportPlayGo (fatal PS5-component init failure for UE titles).
// Ids outside the discovered set still return BAD_CHUNK_ID, so
// title-side chunk enumeration still terminates.
var installedChunkIds = DiscoverInstalledChunkIds(app0Root);
TracePlayGo($"metadata_missing; fully-installed chunks=[{string.Join(',', installedChunkIds)}]");
return new PlayGoMetadata(
true,
[(ushort)0],
installedChunkIds,
PlayGoChunkIdKnowledge.Authoritative);
}
@@ -718,6 +726,41 @@ public static class PlayGoExports
: PlayGoChunkIdKnowledge.Authoritative);
}
// Chunk ids for a title that ships no PlayGo sidecar, taken from the
// pakchunk<N>-<platform>.pak files on disk. Chunk 0 is always included: it
// is the base chunk and must resolve even for a title with no pak files at
// all (which keeps the single-chunk behaviour for such titles).
private static ushort[] DiscoverInstalledChunkIds(string app0Root)
{
var ids = new SortedSet<ushort> { 0 };
try
{
foreach (var pakFile in Directory.EnumerateFiles(app0Root, "pakchunk*.pak", SearchOption.AllDirectories))
{
var name = Path.GetFileNameWithoutExtension(pakFile);
var digits = name.AsSpan("pakchunk".Length);
var length = 0;
while (length < digits.Length && char.IsAsciiDigit(digits[length]))
{
length++;
}
if (length > 0 && ushort.TryParse(digits[..length], out var chunkId))
{
ids.Add(chunkId);
}
}
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
return ids.ToArray();
}
private static ushort[] LoadChunkIds(string chunkDefsXml)
{
if (!File.Exists(chunkDefsXml))
+17 -20
View File
@@ -792,28 +792,15 @@ public static class SaveDataExports
var id = (uint)Interlocked.Increment(ref _nextTransactionResource);
// The resource-out pointer's argument slot varies by SDK revision: some
// callers pass it in rdx, others in rcx (a 4-arg form where rdx holds a
// count/flag). Void Terrarium passes rdx=0x1 (not a pointer) and the
// real out-pointer in rcx. Probe the plausible candidates and write the
// handle to the first writable one instead of faulting on a bad rdx.
// This is a stub-level create (matches shadPS4's return-OK semantics);
// never return MEMORY_FAULT for it, or the guest treats savedata init as
// failed and never advances.
// A small RDX value is a flag, and RCX contains the output address.
// A larger RDX value is the output address for the older ABI.
var resourceAddress = 0UL;
foreach (var candidate in new[]
{
ctx[CpuRegister.Rdx],
ctx[CpuRegister.Rcx],
ctx[CpuRegister.R8],
ctx[CpuRegister.R9],
})
var selectedAddress = SelectTransactionResourceAddress(
ctx[CpuRegister.Rdx],
ctx[CpuRegister.Rcx]);
if (selectedAddress != 0 && TryWriteUInt32(ctx, selectedAddress, id))
{
if (candidate != 0 && TryWriteUInt32(ctx, candidate, id))
{
resourceAddress = candidate;
break;
}
resourceAddress = selectedAddress;
}
TraceSaveData(
@@ -822,6 +809,16 @@ public static class SaveDataExports
return SetReturn(ctx, 0);
}
internal static ulong SelectTransactionResourceAddress(ulong rdx, ulong rcx)
{
if (rdx == 0)
{
return 0;
}
return rdx <= ushort.MaxValue ? rcx : rdx;
}
[SysAbiExport(
Nid = "lJUQuaKqoKY",
ExportName = "sceSaveDataDeleteTransactionResource",
@@ -472,6 +472,11 @@ internal static unsafe class VulkanVideoPresenter
private static readonly Queue<Presentation> _pendingGuestImagePresentations = new();
private static readonly Dictionary<ulong, long> _guestImageWorkSequences = new();
private static readonly Dictionary<ulong, uint> _availableGuestImages = new();
// Write-tracker generation last uploaded for a CPU-backed guest image.
// A newer generation in the tracker means the guest CPU rewrote the
// memory (video frames, streamed atlases) and the upload-known skip in
// draw translation must ship fresh texels instead of reusing the image.
private static readonly Dictionary<ulong, long> _cpuBackedUploadGenerations = new();
private static readonly Dictionary<(int Handle, int BufferIndex), long>
_lastOrderedGuestFlipVersions = new();
private static long _orderedGuestFlipVersionSequence;
@@ -811,6 +816,7 @@ internal static unsafe class VulkanVideoPresenter
_pendingGuestImagePresentations.Clear();
_guestImageWorkSequences.Clear();
_availableGuestImages.Clear();
_cpuBackedUploadGenerations.Clear();
_lastOrderedGuestFlipVersions.Clear();
_orderedGuestFlipVersionSequence = 0;
_pendingGuestImageUploads.Clear();
@@ -1684,6 +1690,21 @@ internal static unsafe class VulkanVideoPresenter
return checked((ulong)width * height * bytesPerPixel);
}
// Maps a UNORM swapchain format to the sRGB view of the same bit layout,
// or Undefined when no counterpart exists. Used to encode linear-float
// guest flips on their way into a UNORM swapchain.
internal static Format GetSrgbCounterpart(Format format) => format switch
{
Format.B8G8R8A8Unorm => Format.B8G8R8A8Srgb,
Format.R8G8B8A8Unorm => Format.R8G8B8A8Srgb,
_ => Format.Undefined,
};
// Float VideoOut flip buffers hold linear scRGB light; presenting them
// requires a linear->sRGB encode that a plain blit does not perform.
internal static bool IsLinearFloatPresentSource(Format format) =>
format is Format.R16G16B16A16Sfloat or Format.R32G32B32A32Sfloat;
private static byte[]? TakeGuestImageInitialData(ulong address)
{
lock (_gate)
@@ -1790,9 +1811,30 @@ internal static unsafe class VulkanVideoPresenter
lock (_gate)
{
return _availableGuestImages.TryGetValue(address, out var availableFormat) &&
var known =
_availableGuestImages.TryGetValue(address, out var availableFormat) &&
availableFormat == guestFormat ||
_pendingGuestImageUploads.ContainsKey((address, guestFormat));
if (!known)
{
return false;
}
// CPU-backed images (video frames, streamed atlases) go stale when
// the guest CPU rewrites the memory after the recorded upload; a
// changed write-tracker generation forces a fresh texel copy so
// the refresh path can re-upload. GPU-rendered images have no
// generation entry and keep the plain availability answer.
if (_cpuBackedUploadGenerations.TryGetValue(address, out var uploadedGeneration) &&
SharpEmu.HLE.GuestImageWriteTracker.TryGetWriteGeneration(
address,
out var currentGeneration) &&
currentGeneration != uploadedGeneration)
{
return false;
}
return true;
}
}
@@ -2980,6 +3022,7 @@ internal static unsafe class VulkanVideoPresenter
public uint Height;
public uint RowLength;
public uint DstSelect;
public uint Layers = 1;
public bool NeedsUpload;
public bool OwnsStorage;
public bool IsStorage;
@@ -2990,6 +3033,10 @@ internal static unsafe class VulkanVideoPresenter
public Sampler Sampler;
public GuestImageResource? GuestImage;
public GuestDepthResource? GuestDepth;
// Write-tracker generation of the guest memory the staged pixels
// were read from; -1 when unknown. Recorded on the guest image
// after upload so stale-content skips can be detected.
public long WriteGeneration = -1;
// A sampled render-target alias cannot remain bound to the same
// image while that image is a color attachment. The per-draw
// snapshot uses this source to copy the target's pre-draw contents
@@ -3042,6 +3089,9 @@ internal static unsafe class VulkanVideoPresenter
public ulong WriteAddress;
public uint Width;
public uint Height;
// Unscaled guest-requested size; Width/Height are the physical (scaled) backing size.
public uint LogicalWidth;
public uint LogicalHeight;
public uint GuestFormat;
public uint SwizzleMode;
public Image Image;
@@ -3071,6 +3121,9 @@ internal static unsafe class VulkanVideoPresenter
public long FlipVersion;
public uint Width;
public uint Height;
// Unscaled guest-requested size; Width/Height are the physical (scaled) backing size.
public uint LogicalWidth;
public uint LogicalHeight;
public uint MipLevels;
public uint GuestFormat;
public Format Format;
@@ -5404,6 +5457,8 @@ internal static unsafe class VulkanVideoPresenter
FlipVersion = version,
Width = source.Width,
Height = source.Height,
LogicalWidth = source.LogicalWidth,
LogicalHeight = source.LogicalHeight,
MipLevels = 1,
GuestFormat = source.GuestFormat,
Format = source.Format,
@@ -6887,6 +6942,7 @@ internal static unsafe class VulkanVideoPresenter
var vkFormat = GetTextureFormat(texture.Format, texture.NumberType);
if (texture.Address != 0 &&
!(texture.ArrayedView && texture.ArrayLayers > 1) &&
TryResolveGuestImageAlias(texture, vkFormat, out var guestImage) &&
TryGetOrCreateGuestImageView(
guestImage,
@@ -6894,7 +6950,8 @@ internal static unsafe class VulkanVideoPresenter
mipLevel: texture.BaseMipLevel,
levelCount: texture.MipLevels,
dstSelect: texture.DstSelect,
out var view))
out var view,
arrayedView: texture.ArrayedView))
{
if (ShouldTraceVulkanResources() &&
_tracedTextureCacheHits.Add(
@@ -7004,6 +7061,18 @@ internal static unsafe class VulkanVideoPresenter
if ((guestImage.Initialized || guestImage.InitialUploadPending) &&
guestImage.CpuContentFingerprint == fingerprint)
{
// Content unchanged despite a newer write generation: advance
// the recorded generation so later draws can skip the copy
// again instead of restaging identical texels every draw.
if (texture.WriteGeneration >= 0)
{
lock (_gate)
{
_cpuBackedUploadGenerations[texture.Address] =
texture.WriteGeneration;
}
}
return false;
}
@@ -7033,6 +7102,7 @@ internal static unsafe class VulkanVideoPresenter
GuestImage = guestImage,
CpuContentFingerprint = fingerprint,
UpdatesCpuContent = true,
WriteGeneration = texture.WriteGeneration,
};
return true;
}
@@ -7057,8 +7127,8 @@ internal static unsafe class VulkanVideoPresenter
// but it must not make a differently sized alias outrank the image
// that the texture descriptor actually names.
var score = 0;
if (candidate.Width == texture.Width &&
candidate.Height == texture.Height)
if (candidate.LogicalWidth == texture.Width &&
candidate.LogicalHeight == texture.Height)
{
score += 32;
}
@@ -7143,7 +7213,7 @@ internal static unsafe class VulkanVideoPresenter
continue;
}
if (texture.Width > depth.Width || texture.Height > depth.Height)
if (texture.Width > depth.LogicalWidth || texture.Height > depth.LogicalHeight)
{
continue;
}
@@ -7228,7 +7298,9 @@ internal static unsafe class VulkanVideoPresenter
texture.DstSelect,
texture.TileMode,
texture.Pitch,
texture.Sampler);
texture.Sampler,
texture.ArrayedView,
Math.Max(texture.ArrayLayers, 1));
if (_textureCache.TryGetValue(key, out var cached))
{
return cached;
@@ -7353,8 +7425,8 @@ internal static unsafe class VulkanVideoPresenter
GuestDrawTexture texture,
GuestImageResource guestImage)
{
if (guestImage.Width == texture.Width &&
guestImage.Height == texture.Height)
if (guestImage.LogicalWidth == texture.Width &&
guestImage.LogicalHeight == texture.Height)
{
return true;
}
@@ -7366,8 +7438,8 @@ internal static unsafe class VulkanVideoPresenter
return false;
}
return texture.Width <= guestImage.Width &&
texture.Height <= guestImage.Height;
return texture.Width <= guestImage.LogicalWidth &&
texture.Height <= guestImage.LogicalHeight;
}
[MethodImpl(MethodImplOptions.NoInlining)]
@@ -7534,6 +7606,8 @@ internal static unsafe class VulkanVideoPresenter
Address = 0,
Width = width,
Height = height,
LogicalWidth = width,
LogicalHeight = height,
MipLevels = 1,
GuestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType),
Format = vkFormat,
@@ -7616,6 +7690,7 @@ internal static unsafe class VulkanVideoPresenter
: width;
var vkFormat = GetTextureFormat(texture.Format, texture.NumberType);
var layers = Math.Max(texture.ArrayLayers, 1);
var expectedSize = GetTextureByteCount(texture.Format, rowLength, height);
if (ShouldTraceVulkanResources() &&
_tracedTextureUploads.Add((texture.Address, width, height, vkFormat)))
@@ -7624,12 +7699,16 @@ internal static unsafe class VulkanVideoPresenter
$"[LOADER][TRACE] vk.texture addr=0x{texture.Address:X16} " +
$"fmt={texture.Format} num={texture.NumberType} vk={vkFormat} " +
$"size={width}x{height} row={rowLength} tile={texture.TileMode} " +
$"dst=0x{texture.DstSelect:X3} " +
$"layers={layers} dst=0x{texture.DstSelect:X3} " +
$"bytes={texture.RgbaPixels.Length} expected={expectedSize}");
}
var pixels = texture.RgbaPixels.Length == (int)expectedSize
var pixels = texture.RgbaPixels.Length == (int)(expectedSize * layers)
? texture.RgbaPixels
: CreateFallbackTexturePixels(texture.Format, rowLength, height, expectedSize);
if (!ReferenceEquals(pixels, texture.RgbaPixels))
{
layers = 1;
}
if (AddressListContains("SHARPEMU_FORCE_WHITE_TEXTURE_TARGETS", texture.Address))
{
pixels = pixels.ToArray();
@@ -7662,7 +7741,7 @@ internal static unsafe class VulkanVideoPresenter
Format = vkFormat,
Extent = new Extent3D(width, height, 1),
MipLevels = 1,
ArrayLayers = 1,
ArrayLayers = layers,
Samples = SampleCountFlags.Count1Bit,
Tiling = ImageTiling.Optimal,
Usage = supportsAttachmentUsage
@@ -7692,10 +7771,12 @@ internal static unsafe class VulkanVideoPresenter
{
SType = StructureType.ImageViewCreateInfo,
Image = image,
ViewType = ImageViewType.Type2D,
ViewType = texture.ArrayedView
? ImageViewType.Type2DArray
: ImageViewType.Type2D,
Format = vkFormat,
Components = ToVkComponentMapping(texture.DstSelect),
SubresourceRange = ColorSubresourceRange(),
SubresourceRange = ColorSubresourceRange(layerCount: layers),
};
Check(_vk.CreateImageView(_device, &viewInfo, null, out var view), "vkCreateImageView(texture)");
var debugName = TextureDebugName(texture, vkFormat);
@@ -7713,14 +7794,18 @@ internal static unsafe class VulkanVideoPresenter
Height = height,
RowLength = rowLength,
DstSelect = texture.DstSelect,
Layers = layers,
NeedsUpload = true,
OwnsStorage = true,
SamplerState = texture.Sampler,
CpuContentFingerprint = contentFingerprint,
UpdatesCpuContent = texture.Address != 0,
WriteGeneration = texture.WriteGeneration,
};
if (texture.Address != 0 &&
!texture.ArrayedView &&
layers == 1 &&
!_guestImages.ContainsKey(texture.Address))
{
var guestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType);
@@ -7729,6 +7814,8 @@ internal static unsafe class VulkanVideoPresenter
Address = texture.Address,
Width = width,
Height = height,
LogicalWidth = width,
LogicalHeight = height,
MipLevels = 1,
GuestFormat = guestFormat,
Format = vkFormat,
@@ -7750,6 +7837,12 @@ internal static unsafe class VulkanVideoPresenter
_availableGuestImages[texture.Address] = guestFormat;
}
if (texture.WriteGeneration >= 0)
{
_cpuBackedUploadGenerations[texture.Address] =
texture.WriteGeneration;
}
_guestImageExtents[texture.Address] =
(width, height, expectedSize);
}
@@ -9095,11 +9188,19 @@ internal static unsafe class VulkanVideoPresenter
private static GuestRect ClampScissor(GuestRect? scissor, Extent2D extent)
{
if (scissor is not { } rect)
if (scissor is not { } guestRect)
{
return new GuestRect(0, 0, extent.Width, extent.Height);
}
var rect = _renderResolutionScale == 1.0
? guestRect
: new GuestRect(
(int)Math.Round(guestRect.X * _renderResolutionScale),
(int)Math.Round(guestRect.Y * _renderResolutionScale),
Math.Max(1u, (uint)Math.Round(guestRect.Width * _renderResolutionScale)),
Math.Max(1u, (uint)Math.Round(guestRect.Height * _renderResolutionScale)));
var left = Math.Clamp(rect.X, 0, checked((int)extent.Width));
var top = Math.Clamp(rect.Y, 0, checked((int)extent.Height));
var right = Math.Clamp(
@@ -9127,11 +9228,22 @@ internal static unsafe class VulkanVideoPresenter
private static Viewport ClampViewport(GuestViewport? viewport, Extent2D extent)
{
if (viewport is not { } rect)
if (viewport is not { } guestRect)
{
return new Viewport(0, 0, extent.Width, extent.Height, 0, 1);
}
var scale = (float)_renderResolutionScale;
var rect = scale == 1f
? guestRect
: guestRect with
{
X = guestRect.X * scale,
Y = guestRect.Y * scale,
Width = guestRect.Width * scale,
Height = guestRect.Height * scale,
};
// Do NOT trim the rectangle to the render target: Vulkan allows
// viewports that extend beyond the framebuffer (rendering is
// confined by the scissor), and trimming changes the guest's
@@ -10447,10 +10559,11 @@ internal static unsafe class VulkanVideoPresenter
draw.RenderState.Depth) &&
work.DepthTarget is { } depthTarget)
{
// Logical dims: GetOrCreateGuestDepth below scales itself.
var resolution = GuestDepthExtentResolver.Resolve(
depthTarget,
firstTarget.Width,
firstTarget.Height,
firstTarget.LogicalWidth,
firstTarget.LogicalHeight,
draw.Textures);
var effectiveDepthTarget = resolution.IsUsable &&
(resolution.Width != depthTarget.Width ||
@@ -11150,7 +11263,14 @@ internal static unsafe class VulkanVideoPresenter
$"address 0x{target.Address:X16}.");
}
var mipLevels = ClampMipLevels(target.Width, target.Height, target.MipLevels);
// Storage/UAV images keep native guest dimensions (compute shaders index them directly).
var physicalWidth = requiresStorage
? target.Width
: ScaleGuestDimension(target.Width);
var physicalHeight = requiresStorage
? target.Height
: ScaleGuestDimension(target.Height);
var mipLevels = ClampMipLevels(physicalWidth, physicalHeight, target.MipLevels);
var guestFormat = GetGuestTextureFormat(target.Format, target.NumberType);
var requestedKey = new GuestImageVariantKey(
target.Address,
@@ -11161,8 +11281,8 @@ internal static unsafe class VulkanVideoPresenter
format);
if (_guestImages.TryGetValue(target.Address, out var existing))
{
if (existing.Width == target.Width &&
existing.Height == target.Height &&
if (existing.LogicalWidth == target.Width &&
existing.LogicalHeight == target.Height &&
existing.MipLevels == mipLevels &&
existing.GuestFormat == guestFormat &&
existing.Format == format)
@@ -11212,8 +11332,8 @@ internal static unsafe class VulkanVideoPresenter
_guestImageVariants.Add(
new GuestImageVariantKey(
existing.Address,
existing.Width,
existing.Height,
existing.LogicalWidth,
existing.LogicalHeight,
existing.MipLevels,
existing.GuestFormat,
existing.Format),
@@ -11222,6 +11342,7 @@ internal static unsafe class VulkanVideoPresenter
lock (_gate)
{
_availableGuestImages.Remove(target.Address);
_cpuBackedUploadGenerations.Remove(target.Address);
_guestImageExtents.Remove(target.Address);
}
@@ -11246,6 +11367,7 @@ internal static unsafe class VulkanVideoPresenter
_guestImages.Add(target.Address, retained);
lock (_gate)
{
_cpuBackedUploadGenerations.Remove(target.Address);
_guestImageExtents[target.Address] = (
target.Width,
target.Height,
@@ -11280,7 +11402,7 @@ internal static unsafe class VulkanVideoPresenter
ImageCreateFlags.CreateExtendedUsageBit,
ImageType = ImageType.Type2D,
Format = format,
Extent = new Extent3D(target.Width, target.Height, 1),
Extent = new Extent3D(physicalWidth, physicalHeight, 1),
MipLevels = mipLevels,
ArrayLayers = 1,
Samples = SampleCountFlags.Count1Bit,
@@ -11353,15 +11475,17 @@ internal static unsafe class VulkanVideoPresenter
CreateRenderPassAndFramebuffer(
format,
mipViews[0],
target.Width,
target.Height);
physicalWidth,
physicalHeight);
}
var resource = new GuestImageResource
{
Address = target.Address,
Width = target.Width,
Height = target.Height,
Width = physicalWidth,
Height = physicalHeight,
LogicalWidth = target.Width,
LogicalHeight = target.Height,
MipLevels = mipLevels,
GuestFormat = guestFormat,
Format = format,
@@ -11617,15 +11741,19 @@ internal static unsafe class VulkanVideoPresenter
return existing;
}
var (image, memory, view) = CreateDepthAttachment(target.Width, target.Height);
var physicalWidth = ScaleGuestDimension(target.Width);
var physicalHeight = ScaleGuestDimension(target.Height);
var (image, memory, view) = CreateDepthAttachment(physicalWidth, physicalHeight);
var resource = new GuestDepthResource
{
Key = key,
Address = target.Address,
ReadAddress = target.ReadAddress,
WriteAddress = target.WriteAddress,
Width = target.Width,
Height = target.Height,
Width = physicalWidth,
Height = physicalHeight,
LogicalWidth = target.Width,
LogicalHeight = target.Height,
GuestFormat = target.GuestFormat,
SwizzleMode = target.SwizzleMode,
Image = image,
@@ -11998,11 +12126,12 @@ internal static unsafe class VulkanVideoPresenter
uint mipLevel,
uint levelCount,
uint dstSelect,
out ImageView view)
out ImageView view,
bool arrayedView = false)
{
try
{
view = GetOrCreateGuestImageView(resource, format, mipLevel, levelCount, dstSelect);
view = GetOrCreateGuestImageView(resource, format, mipLevel, levelCount, dstSelect, arrayedView);
return true;
}
catch (Exception exception)
@@ -12020,7 +12149,8 @@ internal static unsafe class VulkanVideoPresenter
Format format,
uint mipLevel,
uint levelCount,
uint dstSelect = 0xFAC)
uint dstSelect = 0xFAC,
bool arrayedView = false)
{
if (mipLevel >= resource.MipLevels)
{
@@ -12030,7 +12160,7 @@ internal static unsafe class VulkanVideoPresenter
levelCount = Math.Max(levelCount, 1);
levelCount = Math.Min(levelCount, resource.MipLevels - mipLevel);
if (format == resource.Format && dstSelect == 0xFAC)
if (format == resource.Format && dstSelect == 0xFAC && !arrayedView)
{
if (mipLevel == 0 && levelCount == resource.MipLevels)
{
@@ -12049,7 +12179,7 @@ internal static unsafe class VulkanVideoPresenter
$"Incompatible image view format {format} for image {resource.Format}.");
}
var key = (format, mipLevel, levelCount, dstSelect);
var key = (format, mipLevel + (arrayedView ? 0x100u : 0), levelCount, dstSelect);
if (resource.FormatViews.TryGetValue(key, out var existing))
{
return existing;
@@ -12059,7 +12189,7 @@ internal static unsafe class VulkanVideoPresenter
{
SType = StructureType.ImageViewCreateInfo,
Image = resource.Image,
ViewType = ImageViewType.Type2D,
ViewType = arrayedView ? ImageViewType.Type2DArray : ImageViewType.Type2D,
Format = format,
Components = ToVkComponentMapping(dstSelect),
SubresourceRange = ColorSubresourceRange(mipLevel, levelCount),
@@ -13165,7 +13295,7 @@ internal static unsafe class VulkanVideoPresenter
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = texture.Image,
SubresourceRange = ColorSubresourceRange(),
SubresourceRange = ColorSubresourceRange(layerCount: texture.Layers),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
@@ -13187,7 +13317,7 @@ internal static unsafe class VulkanVideoPresenter
ImageSubresource = new ImageSubresourceLayers
{
AspectMask = ImageAspectFlags.ColorBit,
LayerCount = 1,
LayerCount = texture.Layers,
},
ImageExtent = new Extent3D(texture.Width, texture.Height, 1),
};
@@ -13209,7 +13339,7 @@ internal static unsafe class VulkanVideoPresenter
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = texture.Image,
SubresourceRange = ColorSubresourceRange(),
SubresourceRange = ColorSubresourceRange(layerCount: texture.Layers),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
@@ -13873,6 +14003,11 @@ internal static unsafe class VulkanVideoPresenter
if (texture.UpdatesCpuContent)
{
guestImage.CpuContentFingerprint = texture.CpuContentFingerprint;
if (texture.WriteGeneration >= 0)
{
_cpuBackedUploadGenerations[guestImage.Address] =
texture.WriteGeneration;
}
}
}
}
@@ -14080,6 +14215,22 @@ internal static unsafe class VulkanVideoPresenter
!string.IsNullOrWhiteSpace(
Environment.GetEnvironmentVariable(
"SHARPEMU_TRACE_GUEST_IMAGE_ADDRS"));
private static readonly double _renderResolutionScale =
double.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_RENDER_SCALE"),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var renderResolutionScale) &&
renderResolutionScale > 0 &&
renderResolutionScale <= 2.0
? renderResolutionScale
: 1.0;
private static uint ScaleGuestDimension(uint value) =>
_renderResolutionScale == 1.0
? value
: Math.Max(1u, (uint)Math.Round(value * _renderResolutionScale));
private static readonly bool _forceFullscreenPipeline =
Environment.GetEnvironmentVariable("SHARPEMU_FORCE_FULLSCREEN_PIPELINE") == "1";
private static readonly bool _forceFullscreenVertex =
@@ -14594,6 +14745,99 @@ internal static unsafe class VulkanVideoPresenter
&toPresent);
}
// PS5 float VideoOut buffers (A16B16G16R16F flips) hold linear scRGB
// light where 1.0 is SDR white; hardware scan-out applies the display
// transfer. vkCmdBlitImage converts numerically only, so presenting a
// linear-float guest frame into a UNORM swapchain shows near-black
// for any dim scene. Encode linear->sRGB by blitting through an sRGB
// intermediate (sRGB stores encode), then raw-copying the encoded
// bytes into the same-class UNORM swapchain image.
private Image _presentEncodeImage;
private DeviceMemory _presentEncodeMemory;
private Extent2D _presentEncodeExtent;
private bool TryGetPresentEncodeImage(out Image encodeImage)
{
encodeImage = default;
var encodeFormat = GetSrgbCounterpart(_swapchainFormat);
if (encodeFormat == Format.Undefined)
{
return false;
}
if (_presentEncodeImage.Handle != 0 &&
(_presentEncodeExtent.Width != _extent.Width ||
_presentEncodeExtent.Height != _extent.Height))
{
DestroyPresentEncodeImage();
}
if (_presentEncodeImage.Handle == 0)
{
var imageInfo = new ImageCreateInfo
{
SType = StructureType.ImageCreateInfo,
ImageType = ImageType.Type2D,
Format = encodeFormat,
Extent = new Extent3D(_extent.Width, _extent.Height, 1),
MipLevels = 1,
ArrayLayers = 1,
Samples = SampleCountFlags.Count1Bit,
Tiling = ImageTiling.Optimal,
Usage = ImageUsageFlags.TransferDstBit |
ImageUsageFlags.TransferSrcBit,
SharingMode = SharingMode.Exclusive,
InitialLayout = ImageLayout.Undefined,
};
Check(
_vk.CreateImage(_device, &imageInfo, null, out _presentEncodeImage),
"vkCreateImage(present encode)");
_vk.GetImageMemoryRequirements(
_device,
_presentEncodeImage,
out var requirements);
var allocationInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
MemoryTypeIndex = FindMemoryType(
requirements.MemoryTypeBits,
MemoryPropertyFlags.DeviceLocalBit),
};
Check(
_vk.AllocateMemory(_device, &allocationInfo, null, out _presentEncodeMemory),
"vkAllocateMemory(present encode)");
Check(
_vk.BindImageMemory(_device, _presentEncodeImage, _presentEncodeMemory, 0),
"vkBindImageMemory(present encode)");
_presentEncodeExtent = new Extent2D(_extent.Width, _extent.Height);
SetDebugName(
ObjectType.Image,
_presentEncodeImage.Handle,
"SharpEmu present sRGB-encode image");
}
encodeImage = _presentEncodeImage;
return true;
}
private void DestroyPresentEncodeImage()
{
if (_presentEncodeImage.Handle != 0)
{
_vk.DestroyImage(_device, _presentEncodeImage, null);
_presentEncodeImage = default;
}
if (_presentEncodeMemory.Handle != 0)
{
_vk.FreeMemory(_device, _presentEncodeMemory, null);
_presentEncodeMemory = default;
}
_presentEncodeExtent = default;
}
private void RecordGuestImageBlit(
uint imageIndex,
GuestImageResource source)
@@ -14645,9 +14889,33 @@ internal static unsafe class VulkanVideoPresenter
Image = _swapchainImages[imageIndex],
SubresourceRange = ColorSubresourceRange(),
};
var barriers = stackalloc ImageMemoryBarrier[2];
// Linear-float flips need a linear->sRGB encode on the way to a
// UNORM swapchain; sRGB (or unknown-counterpart) swapchains keep
// the direct blit.
var encodeForPresent = false;
Image encodeImage = default;
if (IsLinearFloatPresentSource(source.Format) &&
GetSrgbCounterpart(_swapchainFormat) != Format.Undefined)
{
encodeForPresent = TryGetPresentEncodeImage(out encodeImage);
}
var encodeToTransferDst = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferReadBit,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = ImageLayout.Undefined,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = _presentEncodeImage,
SubresourceRange = ColorSubresourceRange(),
};
var barriers = stackalloc ImageMemoryBarrier[3];
barriers[0] = sourceToTransfer;
barriers[1] = destinationToTransfer;
barriers[2] = encodeToTransferDst;
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.AllCommandsBit,
@@ -14657,7 +14925,7 @@ internal static unsafe class VulkanVideoPresenter
null,
0,
null,
2,
encodeForPresent ? 3u : 2u,
barriers);
var sourceX = 0u;
@@ -14726,12 +14994,58 @@ internal static unsafe class VulkanVideoPresenter
_commandBuffer,
source.Image,
ImageLayout.TransferSrcOptimal,
_swapchainImages[imageIndex],
encodeForPresent ? encodeImage : _swapchainImages[imageIndex],
ImageLayout.TransferDstOptimal,
1,
&region,
isIntegerUpscale ? Filter.Nearest : Filter.Linear);
if (encodeForPresent)
{
var encodeToTransferSrc = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.TransferWriteBit,
DstAccessMask = AccessFlags.TransferReadBit,
OldLayout = ImageLayout.TransferDstOptimal,
NewLayout = ImageLayout.TransferSrcOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
Image = encodeImage,
SubresourceRange = ColorSubresourceRange(),
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TransferBit,
PipelineStageFlags.TransferBit,
0,
0,
null,
0,
null,
1,
&encodeToTransferSrc);
// Raw same-class copy keeps the sRGB-encoded bytes unchanged
// while landing them in the UNORM swapchain image.
var encodedCopy = new ImageCopy
{
SrcSubresource = new ImageSubresourceLayers(
ImageAspectFlags.ColorBit, 0, 0, 1),
DstSubresource = new ImageSubresourceLayers(
ImageAspectFlags.ColorBit, 0, 0, 1),
Extent = new Extent3D(_extent.Width, _extent.Height, 1),
};
_vk.CmdCopyImage(
_commandBuffer,
encodeImage,
ImageLayout.TransferSrcOptimal,
_swapchainImages[imageIndex],
ImageLayout.TransferDstOptimal,
1,
&encodedCopy);
}
if (traceDestination)
{
var destinationToReadback = new ImageMemoryBarrier
@@ -14996,13 +15310,14 @@ internal static unsafe class VulkanVideoPresenter
private static ImageSubresourceRange ColorSubresourceRange(
uint baseMipLevel = 0,
uint levelCount = 1) =>
uint levelCount = 1,
uint layerCount = 1) =>
new()
{
AspectMask = ImageAspectFlags.ColorBit,
BaseMipLevel = baseMipLevel,
LevelCount = levelCount,
LayerCount = 1,
LayerCount = layerCount,
};
private static byte[] ScaleBgra(byte[] source, uint sourceWidth, uint sourceHeight, uint width, uint height)
@@ -15190,6 +15505,7 @@ internal static unsafe class VulkanVideoPresenter
lock (_gate)
{
_availableGuestImages.Clear();
_cpuBackedUploadGenerations.Clear();
_lastOrderedGuestFlipVersions.Clear();
}
DestroySwapchainResources();
@@ -15266,6 +15582,7 @@ internal static unsafe class VulkanVideoPresenter
private void DestroySwapchainResources()
{
DestroyPresentEncodeImage();
if (_stagingBuffer.Handle != 0)
{
_vk.DestroyBuffer(_device, _stagingBuffer, null);
@@ -959,6 +959,15 @@ public static partial class Gen5SpirvTranslator
return false;
}
break;
case "VFmaMixF32":
case "VFmaMixloF16":
case "VFmaMixhiF16":
if (!TryEmitFmaMix(instruction, destination, out result, out error))
{
return false;
}
break;
default:
error = $"unsupported vector opcode {instruction.Opcode}";
@@ -1015,12 +1024,6 @@ public static partial class Gen5SpirvTranslator
return false;
}
if (control.Clamp)
{
error = $"unsupported vop3p modifiers (clamp) for {instruction.Opcode}";
return false;
}
var sourceCount = instruction.Opcode == "VPkFmaF16" ? 3 : 2;
for (var index = 0; index < sourceCount; index++)
{
@@ -1039,7 +1042,112 @@ public static partial class Gen5SpirvTranslator
return true;
}
// V_FMA_MIX_F32 / _MIXLO_F16 / _MIXHI_F16 (VOP3P opcodes 0x20 / 0x21 /
// 0x22). Unlike the packed v_pk_* ops these compute a single f32
// fma(a, b, c): each of the three sources is *independently* read as
// either a full f32 register/constant or one f16 half widened to f32,
// selected per operand by op_sel_hi (read as f16 when set) and op_sel
// (which half feeds the f32). For the mix ops the VOP3P neg_hi field is
// the absolute-value modifier and neg negates, applied abs-then-neg to
// match the hardware and shadPS4's GetSrcMix. _MIXLO / _MIXHI round the
// f32 result back to f16 and write it into the low / high 16 bits of
// vdst, leaving the other half intact.
private bool TryEmitFmaMix(
Gen5ShaderInstruction instruction,
uint destination,
out uint result,
out string error)
{
result = 0;
error = string.Empty;
if (instruction.Control is not Gen5Vop3pControl control)
{
error = $"missing vop3p control for {instruction.Opcode}";
return false;
}
var product = Bitcast(
_uintType,
Ext(
50,
_floatType,
EmitFmaMixOperand(instruction, control, 0),
EmitFmaMixOperand(instruction, control, 1),
EmitFmaMixOperand(instruction, control, 2)));
if (control.Clamp)
{
product = EmitClampToUnitInterval(product);
}
if (instruction.Opcode == "VFmaMixF32")
{
result = product;
return true;
}
// _MIXLO / _MIXHI: narrow to f16 and merge into one half of vdst.
var half = EmitFloatToHalf(product);
var existing = LoadV(destination);
result = instruction.Opcode == "VFmaMixloF16"
? BitwiseOr(BitwiseAnd(existing, UInt(0xFFFF_0000)), half)
: BitwiseOr(
BitwiseAnd(existing, UInt(0x0000_FFFF)),
ShiftLeftLogical(half, UInt(16)));
return true;
}
// Reads one V_FMA_MIX source as an f32. op_sel_hi selects whether a
// register operand is taken as an f16 (the half picked by op_sel, widened
// exactly to f32) or as a full f32; inline constants are always f32. The
// per-operand neg_hi bit takes the absolute value and neg negates, in that
// order (abs-then-neg), reusing the VOP3P modifier fields the way the mix
// ops define them rather than the packed low/high-lane meaning.
private uint EmitFmaMixOperand(
Gen5ShaderInstruction instruction,
Gen5Vop3pControl control,
int index)
{
var source = instruction.Sources[index];
var readAsHalf =
((control.OpSelHiMask >> index) & 1) != 0 &&
source.Kind is Gen5OperandKind.VectorRegister or Gen5OperandKind.ScalarRegister;
uint value;
if (readAsHalf)
{
var raw = GetRawSource(instruction, index);
var half = ((control.OpSelMask >> index) & 1) != 0
? ShiftRightLogical(raw, UInt(16))
: raw;
value = Bitcast(_floatType, EmitHalfToFloat(half));
}
else
{
value = GetFloatSource(instruction, index);
}
if (((control.NegHiMask >> index) & 1) != 0)
{
value = Ext(4, _floatType, value);
}
if (((control.NegLoMask >> index) & 1) != 0)
{
value = _module.AddInstruction(SpirvOp.FNegate, _floatType, value);
}
return value;
}
// Computes one result lane (low or high) as a packed 16-bit f16 value.
// The op runs in f32 and its result is narrowed back to f16 exactly (see
// EmitFloatToHalf). When the clamp modifier is set the pre-narrowing f32
// value is saturated to [0, 1] first; because 0.0 and 1.0 are exact in both
// f32 and f16 and the clamp is monotonic, clamping before the narrowing
// gives the same f16 the hardware produces by clamping the f16 result. For
// the fused multiply-add the pre-narrowing value is the round-to-odd f32
// from EmitPackedF16FusedMultiplyAdd, and round-to-odd preserves that
// equivalence through the final round-to-nearest-even.
private uint EmitPackedF16Lane(
Gen5ShaderInstruction instruction,
Gen5Vop3pControl control,
@@ -1047,21 +1155,44 @@ public static partial class Gen5SpirvTranslator
{
var left = EmitPackedF16Operand(instruction, control, 0, highLane);
var right = EmitPackedF16Operand(instruction, control, 1, highLane);
uint value;
if (instruction.Opcode == "VPkFmaF16")
{
var addend = EmitPackedF16Operand(instruction, control, 2, highLane);
return EmitFloatToHalf(EmitPackedF16FusedMultiplyAdd(left, right, addend));
value = EmitPackedF16FusedMultiplyAdd(left, right, addend);
}
else
{
value = Bitcast(_uintType, instruction.Opcode switch
{
"VPkAddF16" => _module.AddInstruction(SpirvOp.FAdd, _floatType, left, right),
"VPkMulF16" => _module.AddInstruction(SpirvOp.FMul, _floatType, left, right),
"VPkMinF16" => EmitPackedF16MinMax(left, right, isMax: false),
"VPkMaxF16" => EmitPackedF16MinMax(left, right, isMax: true),
_ => left,
});
}
var value = instruction.Opcode switch
if (control.Clamp)
{
"VPkAddF16" => _module.AddInstruction(SpirvOp.FAdd, _floatType, left, right),
"VPkMulF16" => _module.AddInstruction(SpirvOp.FMul, _floatType, left, right),
"VPkMinF16" => EmitPackedF16MinMax(left, right, isMax: false),
"VPkMaxF16" => EmitPackedF16MinMax(left, right, isMax: true),
_ => left,
};
return EmitFloatToHalf(Bitcast(_uintType, value));
value = EmitClampToUnitInterval(value);
}
return EmitFloatToHalf(value);
}
// Saturates an f32 bit pattern to [0, 1] the way the VOP3P clamp modifier
// does: below 0 (and NaN, since the ordered compare is false for it) becomes
// 0, above 1 becomes 1. Ordered compares match the hardware's NaN-to-zero
// behaviour without a separate IsNan test.
private uint EmitClampToUnitInterval(uint valueBits)
{
var value = Bitcast(_floatType, valueBits);
var aboveZero = _module.AddInstruction(SpirvOp.FOrdGreaterThan, _boolType, value, Float(0));
var lowerBounded = _module.AddInstruction(SpirvOp.Select, _floatType, aboveZero, value, Float(0));
var belowOne = _module.AddInstruction(SpirvOp.FOrdLessThan, _boolType, lowerBounded, Float(1));
var clamped = _module.AddInstruction(SpirvOp.Select, _floatType, belowOne, lowerBounded, Float(1));
return Bitcast(_uintType, clamped);
}
// Fused f16 multiply-add with a single rounding, emulated in f32 without the
@@ -313,7 +313,8 @@ public static partial class Gen5SpirvTranslator
uint ComponentType,
uint VectorType,
ImageComponentKind ComponentKind,
bool IsStorage);
bool IsStorage,
bool Arrayed);
private readonly record struct SpirvVertexInput(
uint Variable,
@@ -1000,11 +1001,13 @@ public static partial class Gen5SpirvTranslator
SpirvCapability.StorageImageExtendedFormats);
}
var isArrayed = !isStorage &&
Gen5ShaderTranslator.IsArrayedImageBinding(binding);
var imageType = _module.TypeImage(
componentType,
SpirvImageDim.Dim2D,
depth: false,
arrayed: false,
arrayed: isArrayed,
multisampled: false,
sampled: isStorage ? 2u : 1u,
isStorage ? format : SpirvImageFormat.Unknown);
@@ -1031,7 +1034,8 @@ public static partial class Gen5SpirvTranslator
componentType,
_module.TypeVector(componentType, 4),
componentKind,
isStorage));
isStorage,
isArrayed));
_interfaces.Add(variable);
}
}
@@ -3529,12 +3533,16 @@ public static partial class Gen5SpirvTranslator
addressCursor += 4;
}
var coordinates = BuildFloatCoordinates(image, addressCursor);
var coordinates = resource.Arrayed
? BuildFloatArrayCoordinates(image, addressCursor)
: BuildFloatCoordinates(image, addressCursor);
var explicitLod = hasGradients || hasZeroLod || hasLod;
var lod = hasZeroLod
? Float(0)
: hasLod
? LoadImageFloatAddress(image, addressCursor + 2)
? LoadImageFloatAddress(
image,
addressCursor + (resource.Arrayed ? 3 : 2))
: lodOrBias;
if (hasOffset)
{
@@ -3619,7 +3627,9 @@ public static partial class Gen5SpirvTranslator
addressCursor += ImageFullAddressSlots(image);
}
var coordinates = BuildFloatCoordinates(image, addressCursor);
var coordinates = resource.Arrayed
? BuildFloatArrayCoordinates(image, addressCursor)
: BuildFloatCoordinates(image, addressCursor);
var operands = new List<uint>
{
imageObject,
@@ -3823,6 +3833,19 @@ public static partial class Gen5SpirvTranslator
y);
}
private uint BuildFloatArrayCoordinates(Gen5ImageControl image, int start)
{
var x = LoadImageFloatAddress(image, start);
var y = LoadImageFloatAddress(image, start + 1);
var slice = LoadImageFloatAddress(image, start + 2);
return _module.AddInstruction(
SpirvOp.CompositeConstruct,
_vec3Type,
x,
y,
slice);
}
private static int ImageAddressRegister(
Gen5ImageControl image,
int component) => image.A16 ? component / 2 : component;
@@ -4140,9 +4163,20 @@ public static partial class Gen5SpirvTranslator
signedLod);
var size = _module.AddInstruction(
SpirvOp.ImageQuerySizeLod,
ivec2,
resource.Arrayed ? _module.TypeVector(_intType, 3) : ivec2,
image,
clampedLod);
if (resource.Arrayed)
{
size = _module.AddInstruction(
SpirvOp.VectorShuffle,
ivec2,
size,
size,
0u,
1u);
}
var sizeFloat = _module.AddInstruction(
SpirvOp.ConvertSToF,
_vec2Type,
@@ -4156,11 +4190,34 @@ public static partial class Gen5SpirvTranslator
_vec2Type,
offsetFloat,
sizeFloat);
if (!resource.Arrayed)
{
return _module.AddInstruction(
SpirvOp.FAdd,
_vec2Type,
coordinates,
normalizedOffset);
}
var offsetVec3 = _module.AddInstruction(
SpirvOp.CompositeConstruct,
_vec3Type,
_module.AddInstruction(
SpirvOp.CompositeExtract,
_floatType,
normalizedOffset,
0u),
_module.AddInstruction(
SpirvOp.CompositeExtract,
_floatType,
normalizedOffset,
1u),
Float(0));
return _module.AddInstruction(
SpirvOp.FAdd,
_vec2Type,
_vec3Type,
coordinates,
normalizedOffset);
offsetVec3);
}
private bool TryEmitExport(
@@ -5290,10 +5347,20 @@ public static partial class Gen5SpirvTranslator
UInt(0x108));
}
// A wave-mask SGPR (VCC/EXEC) consumed as a per-lane predicate — the
// condition of VCndmask, a VCC/EXEC branch, or the derived _vcc/_exec
// bool — must be tested at the CURRENT lane's bit, exactly as the
// hardware does, not as "the 64-bit value is non-zero". The two coincide
// for comparison results (only the lane's own bit is ever set), so the
// single-lane path historically used a cheaper whole-word non-zero test.
// But bitwise-complement wave-mask idioms (S_NOT/S_ORN2/S_ANDN2/S_NAND/
// S_NOR on a 64-bit mask) set the unused upper 63 bits; a whole-word test
// then reports "lane active" even when this lane's bit is clear. Unity's
// PostProcessing NaN killer does exactly this (`anyNaN | ~allFinite`),
// which made every valid pixel read as NaN and get replaced with 0 —
// zeroing the whole scene before tonemap. Extract the lane bit always.
private uint IsWaveMaskActive(uint mask) =>
_subgroupInvocationIdInput == 0
? IsNotZero64(mask)
: IsCurrentLaneSet(mask);
IsCurrentLaneSet(mask);
private uint IsCurrentLaneSet(uint mask) =>
IsNotZero64(
@@ -1192,8 +1192,10 @@ public static class Gen5ShaderTranslator
// Opcode numbers taken from LLVM's AMDGPU VOP3PInstructions.td and the
// gfx9/gfx10 MC test encodings; they are unchanged across gfx9 and gfx10.
// Unhandled packed opcodes (integer, fma_mix, ...) stay opaque here and
// fail loudly at emission rather than being silently mis-emitted.
// The mix ops (0x20/0x21/0x22) are V_MAD_MIX_* on gfx9 and V_FMA_MIX_*
// (fused) on the gfx10 the PS5 targets; both share these opcodes. Any
// remaining packed opcode (integer, ...) stays opaque here and fails
// loudly at emission rather than being silently mis-emitted.
name = opcode switch
{
0x0E => "VPkFmaF16",
@@ -1201,6 +1203,9 @@ public static class Gen5ShaderTranslator
0x10 => "VPkMulF16",
0x11 => "VPkMinF16",
0x12 => "VPkMaxF16",
0x20 => "VFmaMixF32",
0x21 => "VFmaMixloF16",
0x22 => "VFmaMixhiF16",
_ => $"Vop3pRaw{opcode:X2}",
};
@@ -1607,6 +1612,11 @@ public static class Gen5ShaderTranslator
binding.ResourceDescriptor.SequenceEqual(candidate.ResourceDescriptor));
}
public static bool IsArrayedImageBinding(Gen5ImageBinding binding) =>
binding.Control.IsArray &&
(binding.Opcode.StartsWith("ImageSample", StringComparison.Ordinal) ||
binding.Opcode.StartsWith("ImageGather4", StringComparison.Ordinal));
public static bool IsDataShareAtomic(string name) => name switch
{
"DsAddU32" or "DsSubU32" or "DsIncU32" or "DsDecU32" or
@@ -0,0 +1,135 @@
// 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;
// Regression tests for the VOP3P mix ops V_FMA_MIX_F32 / _MIXLO_F16 / _MIXHI_F16
// (opcodes 0x20 / 0x21 / 0x22). The decoder leaves any unlowered VOP3P opcode
// opaque (Vop3pRaw20/21/22); before these were lowered they hit the vector-ALU
// switch default and failed emission ("unsupported vector opcode"), which drops
// the whole shader. Unity HDR / tone-mapping / auto-exposure shaders use
// V_FMA_MIX_F32 and so failed to translate entirely.
//
// Each mix op computes a single f32 fma(a, b, c) where every source is read
// *independently* as either a full f32 register or one f16 half widened to f32,
// selected per operand by op_sel_hi (f16 when set) and op_sel (which half). The
// mix ops also repurpose the VOP3P neg_hi field as an absolute-value modifier.
public sealed class Gen5FmaMixSpirvTests
{
private const ulong ShaderAddress = 0x1_0000_0000;
// GLSL.std.450 extended-instruction numbers used by the lowering.
private const uint GlslFma = 50;
private const uint GlslFAbs = 4;
[Fact]
public void FmaMixF32_TranslatesToFmaAndDoesNotDropShader()
{
// V_FMA_MIX_F32 v3, v0, v1, v2
// op_sel_hi = 0b011 -> src0/src1 read as f16, src2 as full f32
// op_sel = 0b010 -> src1 takes its high f16 half (src0 low half)
// neg_hi = 0b001 -> abs(src0)
// neg = 0b100 -> -src2
// Reaching TryCompileComputeShader == true already proves the shader is no
// longer dropped at the VOP3P default error path.
var spirv = Compile([0xCC201103u, 0x9C0A0300u]);
Assert.True(
ContainsExtInst(spirv, GlslFma),
"V_FMA_MIX_F32 must lower to a GLSL.std.450 Fma");
Assert.True(
ContainsExtInst(spirv, GlslFAbs),
"the neg_hi modifier on a mix source must lower to an FAbs (abs-then-neg)");
}
[Fact]
public void FmaMixLoF16_TranslatesWithoutDroppingShader()
{
// V_FMA_MIXLO_F16 v3, v0, v1, v2 with op_sel_hi = 0b111 (all sources read
// as f16 low halves). The f32 fma result is narrowed to f16 and merged
// into the low 16 bits of vdst; the fma itself is still emitted.
var spirv = Compile([0xCC214003u, 0x1C0A0300u]);
Assert.True(
ContainsExtInst(spirv, GlslFma),
"V_FMA_MIXLO_F16 must still lower its multiply-add to a GLSL.std.450 Fma");
}
// True when the module contains an OpExtInst selecting the given GLSL.std.450
// instruction number.
private static bool ContainsExtInst(byte[] spirv, uint instruction)
{
foreach (var (op, wordCount, offset) in EnumerateInstructions(spirv))
{
// OpExtInst = 12: (opcode, resultType, resultId, set, instruction, ...).
if (op != 12 || wordCount < 5)
{
continue;
}
if (ReadWord(spirv, offset + 16) == instruction)
{
return true;
}
}
return false;
}
private static IEnumerable<(ushort Op, int WordCount, int Offset)> EnumerateInstructions(
byte[] spirv)
{
// 5-word SPIR-V header, then (wordCount << 16 | opcode) packed instructions.
for (var offset = 5 * sizeof(uint); offset + sizeof(uint) <= spirv.Length;)
{
var word = ReadWord(spirv, offset);
var wordCount = (int)(word >> 16);
if (wordCount <= 0)
{
yield break;
}
yield return ((ushort)word, wordCount, offset);
offset += wordCount * sizeof(uint);
}
}
private static uint ReadWord(byte[] spirv, int offset) =>
BinaryPrimitives.ReadUInt32LittleEndian(spirv.AsSpan(offset, sizeof(uint)));
private static byte[] Compile(uint[] programWords)
{
var memory = new FakeCpuMemory(ShaderAddress, 0x2000);
var ctx = new CpuContext(memory, Generation.Gen5);
Gen5ShaderAtomicDecodeTests.WriteProgram(memory, ShaderAddress, programWords);
var shaderRegisters = new Dictionary<uint, uint>
{
[Gen5ShaderAtomicDecodeTests.ComputePgmRsrc2Register] = 16u << 1,
};
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 shader.Spirv;
}
}
@@ -0,0 +1,140 @@
// 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;
// Regression tests for how a VCC/EXEC wave mask consumed as a per-lane predicate
// is lowered to SPIR-V. A wave mask must be tested at the current lane's bit
// (mask & lane_bit) — exactly as the hardware evaluates the VCndmask condition or
// a VCC/EXEC branch — not with a whole-word "the 64-bit value is non-zero" test.
//
// The two agree for comparison results (only the lane's own bit is ever set), but
// diverge for the bitwise-complement wave-mask idioms (S_NOT / S_ORN2 / S_ANDN2 /
// S_NAND / S_NOR), which set the unused upper 63 bits. A whole-word test then
// reports the lane active even when its bit is clear. Unity's PostProcessing NaN
// killer combines its channels as `anyNaN | ~allFinite` (S_ORN2_B64); under the
// whole-word test every valid pixel read as NaN and was replaced with 0, zeroing
// the whole HDR scene before tone-mapping.
public sealed class Gen5WaveMaskSpirvTests
{
private const ulong ShaderAddress = 0x1_0000_0000;
[Fact]
public void WaveMaskPredicate_IsTestedAtCurrentLaneBit()
{
// V_CMP_EQ_F32 vcc, v0, v1 writes VCC at run time, which re-materialises
// the per-lane _vcc predicate from the wave mask via IsWaveMaskActive.
var spirv = Compile([0x7C04_0300u]);
// The lane's bit in single-lane emulation is the 64-bit constant 1, so the
// predicate is `(mask & 1) != 0`. The whole-word bug emitted `mask != 0`
// with no such mask. Require the lane-bit AND to be present.
Assert.True(
ContainsLaneBitMaskedWaveTest(spirv),
"wave-mask predicate must be tested at the current lane bit "
+ "(mask & lane_bit), not as a whole-word non-zero test");
}
// True when the module contains an OpBitwiseAnd whose operand is a 64-bit
// constant of value 1 — the current-lane bit that IsCurrentLaneSet masks the
// wave mask with before the non-zero test.
private static bool ContainsLaneBitMaskedWaveTest(byte[] spirv)
{
var laneBitConstIds = new HashSet<uint>();
// Pass 1: collect 64-bit OpConstant result-ids whose value is 1.
foreach (var (op, wordCount, offset) in EnumerateInstructions(spirv))
{
// OpConstant = 43; a 64-bit constant occupies 5 words
// (opcode, resultType, resultId, valueLow, valueHigh).
if (op != 43 || wordCount != 5)
{
continue;
}
var resultId = ReadWord(spirv, offset + 8);
var low = ReadWord(spirv, offset + 12);
var high = ReadWord(spirv, offset + 16);
if (low == 1 && high == 0)
{
laneBitConstIds.Add(resultId);
}
}
// Pass 2: look for an OpBitwiseAnd that consumes one of those constants.
foreach (var (op, wordCount, offset) in EnumerateInstructions(spirv))
{
// OpBitwiseAnd = 199 (opcode, resultType, resultId, operand0, operand1).
if (op != 199 || wordCount != 5)
{
continue;
}
var operand0 = ReadWord(spirv, offset + 12);
var operand1 = ReadWord(spirv, offset + 16);
if (laneBitConstIds.Contains(operand0) || laneBitConstIds.Contains(operand1))
{
return true;
}
}
return false;
}
private static IEnumerable<(ushort Op, int WordCount, int Offset)> EnumerateInstructions(
byte[] spirv)
{
// 5-word SPIR-V header, then (wordCount << 16 | opcode) packed instructions.
for (var offset = 5 * sizeof(uint); offset + sizeof(uint) <= spirv.Length;)
{
var word = ReadWord(spirv, offset);
var wordCount = (int)(word >> 16);
if (wordCount <= 0)
{
yield break;
}
yield return ((ushort)word, wordCount, offset);
offset += wordCount * sizeof(uint);
}
}
private static uint ReadWord(byte[] spirv, int offset) =>
BinaryPrimitives.ReadUInt32LittleEndian(spirv.AsSpan(offset, sizeof(uint)));
private static byte[] Compile(uint[] programWords)
{
var memory = new FakeCpuMemory(ShaderAddress, 0x2000);
var ctx = new CpuContext(memory, Generation.Gen5);
Gen5ShaderAtomicDecodeTests.WriteProgram(memory, ShaderAddress, programWords);
var shaderRegisters = new Dictionary<uint, uint>
{
[Gen5ShaderAtomicDecodeTests.ComputePgmRsrc2Register] = 16u << 1,
};
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 shader.Spirv;
}
}
@@ -90,6 +90,133 @@ public sealed class AprStreamingContractTests
}
}
[Fact]
public void ResolveFilepathsToIdsAndFileSizes_MissingFile_FailsFastWithErrorIndex()
{
const ulong memoryBase = 0x1_0000_0000;
const ulong pathListAddress = memoryBase + 0x100;
const ulong pathAddress = memoryBase + 0x200;
const ulong idsAddress = memoryBase + 0x800;
const ulong sizesAddress = memoryBase + 0x880;
const ulong errorIndexAddress = memoryBase + 0x8F0;
var memory = new FakeCpuMemory(memoryBase, 0x4000);
var context = new CpuContext(memory, Generation.Gen5);
var missingHostPath = Path.Combine(
Path.GetTempPath(),
$"sharpemu-apr-missing-{Guid.NewGuid():N}.bin");
memory.WriteCString(pathAddress, missingHostPath);
WriteUInt64(memory, pathListAddress, pathAddress);
context[CpuRegister.Rdi] = pathListAddress;
context[CpuRegister.Rsi] = 1;
context[CpuRegister.Rdx] = idsAddress;
context[CpuRegister.Rcx] = sizesAddress;
context[CpuRegister.R8] = errorIndexAddress;
Assert.Equal(-1, KernelMemoryCompatExports.KernelAprResolveFilepathsToIdsAndFileSizes(context));
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
Assert.Equal(uint.MaxValue, ReadUInt32(memory, idsAddress));
Assert.Equal(0ul, ReadUInt64(memory, sizesAddress));
Assert.Equal(0u, ReadUInt32(memory, errorIndexAddress));
}
[Fact]
public void ResolveFilepathsToIdsAndFileSizes_InvalidErrorIndex_ReturnsMemoryFault()
{
const ulong memoryBase = 0x1_0000_0000;
const ulong pathListAddress = memoryBase + 0x100;
const ulong pathAddress = memoryBase + 0x200;
const ulong idsAddress = memoryBase + 0x800;
const ulong sizesAddress = memoryBase + 0x880;
var memory = new FakeCpuMemory(memoryBase, 0x4000);
var context = new CpuContext(memory, Generation.Gen5);
var missingHostPath = Path.Combine(
Path.GetTempPath(),
$"sharpemu-apr-missing-{Guid.NewGuid():N}.bin");
memory.WriteCString(pathAddress, missingHostPath);
WriteUInt64(memory, pathListAddress, pathAddress);
context[CpuRegister.Rdi] = pathListAddress;
context[CpuRegister.Rsi] = 1;
context[CpuRegister.Rdx] = idsAddress;
context[CpuRegister.Rcx] = sizesAddress;
context[CpuRegister.R8] = memoryBase + 0x5000;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT,
KernelMemoryCompatExports.KernelAprResolveFilepathsToIdsAndFileSizes(context));
}
[Fact]
public void ResolveFilepathsToIdsAndFileSizes_MissingMidBatch_StopsAtFailingEntry()
{
const ulong memoryBase = 0x1_0000_0000;
const ulong pathListAddress = memoryBase + 0x100;
const ulong idsAddress = memoryBase + 0x800;
const ulong sizesAddress = memoryBase + 0x880;
const ulong errorIndexAddress = memoryBase + 0x8F0;
byte[] fileContents = [1, 2, 3, 4, 5];
var hostPath = Path.GetTempFileName();
var missingHostPath = Path.Combine(
Path.GetTempPath(),
$"sharpemu-apr-missing-{Guid.NewGuid():N}.bin");
try
{
File.WriteAllBytes(hostPath, fileContents);
var memory = new FakeCpuMemory(memoryBase, 0x4000);
var context = new CpuContext(memory, Generation.Gen5);
memory.WriteCString(memoryBase + 0x200, hostPath);
memory.WriteCString(memoryBase + 0x400, missingHostPath);
memory.WriteCString(memoryBase + 0x600, hostPath);
WriteUInt64(memory, pathListAddress, memoryBase + 0x200);
WriteUInt64(memory, pathListAddress + 8, memoryBase + 0x400);
WriteUInt64(memory, pathListAddress + 16, memoryBase + 0x600);
WriteUInt32(memory, idsAddress + 8, 0x1234_5678); // sentinel: entry 2 untouched
WriteUInt64(memory, sizesAddress + 16, 0xDEAD);
context[CpuRegister.Rdi] = pathListAddress;
context[CpuRegister.Rsi] = 3;
context[CpuRegister.Rdx] = idsAddress;
context[CpuRegister.Rcx] = sizesAddress;
context[CpuRegister.R8] = errorIndexAddress;
Assert.Equal(-1, KernelMemoryCompatExports.KernelAprResolveFilepathsToIdsAndFileSizes(context));
Assert.NotEqual(uint.MaxValue, ReadUInt32(memory, idsAddress));
Assert.Equal((ulong)fileContents.Length, ReadUInt64(memory, sizesAddress));
Assert.Equal(uint.MaxValue, ReadUInt32(memory, idsAddress + 4));
Assert.Equal(0ul, ReadUInt64(memory, sizesAddress + 8));
Assert.Equal(1u, ReadUInt32(memory, errorIndexAddress));
Assert.Equal(0x1234_5678u, ReadUInt32(memory, idsAddress + 8));
Assert.Equal(0xDEADul, ReadUInt64(memory, sizesAddress + 16));
}
finally
{
File.Delete(hostPath);
}
}
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
{
Span<byte> bytes = stackalloc byte[sizeof(uint)];
Assert.True(memory.TryRead(address, bytes));
return BinaryPrimitives.ReadUInt32LittleEndian(bytes);
}
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
{
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
Assert.True(memory.TryRead(address, bytes));
return BinaryPrimitives.ReadUInt64LittleEndian(bytes);
}
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
{
Span<byte> bytes = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(bytes, value);
Assert.True(memory.TryWrite(address, bytes));
}
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
{
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
@@ -40,6 +40,65 @@ public sealed class AvPlayerPathTests : IDisposable
AssertPathIsInsideApp0(resolved);
}
[Fact]
public void UnrealRelativeRawPathAnchorsAtApp0AndResolvesMedia()
{
var mediaPath = CreateFile("SampleProject/Content/Movies/Startup.mp4");
var resolved = AvPlayerExports.ResolveGuestPath(
"../../../SampleProject/Content/Movies/Startup.mp4");
Assert.NotNull(resolved);
Assert.Equal(File.ReadAllBytes(mediaPath), File.ReadAllBytes(resolved));
AssertPathIsInsideApp0(resolved);
}
[Fact]
public void UnrealRelativeRawPathCannotEscapeApp0()
{
var outsidePath = Path.Combine(_tempRoot, "outside.mp4");
File.WriteAllBytes(outsidePath, [0x7F]);
CreateFile("outside.mp4");
Assert.Null(AvPlayerExports.ResolveGuestPath("../../../outside.mp4"));
}
[Fact]
public void CurrentDirectoryRawPathResolvesInsideApp0()
{
var mediaPath = CreateFile("Movies/Intro.mp4");
var resolved = AvPlayerExports.ResolveGuestPath("./Movies/Intro.mp4");
Assert.NotNull(resolved);
Assert.Equal(Path.GetFullPath(mediaPath), resolved);
AssertPathIsInsideApp0(resolved);
}
[Theory]
[InlineData(false, "ffmpeg", "ffprobe")]
[InlineData(true, "ffmpeg.exe", "ffprobe.exe")]
public void MediaToolLookupUsesPlatformNames(
bool isWindows,
string ffmpegName,
string ffprobeName)
{
var toolDirectory = Path.Combine(_tempRoot, "Media Tools");
Directory.CreateDirectory(toolDirectory);
var ffmpeg = Path.Combine(toolDirectory, ffmpegName);
File.WriteAllBytes(ffmpeg, []);
var resolved = AvPlayerExports.FindFfmpeg(
configured: null,
searchPath: $"\"{toolDirectory}\"",
isWindows);
Assert.Equal(ffmpeg, resolved);
Assert.Equal(
Path.Combine(toolDirectory, ffprobeName),
AvPlayerExports.GetFfprobePath(ffmpeg, isWindows));
}
[Fact]
public void RelativeFileUriCannotEscapeApp0()
{
@@ -143,12 +202,14 @@ public sealed class AvPlayerPathTests : IDisposable
private void AssertPathIsInsideApp0(string resolved)
{
var rootWithSeparator =
Path.TrimEndingDirectorySeparator(Path.GetFullPath(_app0Root)) +
Path.DirectorySeparatorChar;
Assert.StartsWith(
rootWithSeparator,
Path.GetFullPath(resolved),
StringComparison.OrdinalIgnoreCase);
var relative = Path.GetRelativePath(
Path.GetFullPath(_app0Root),
Path.GetFullPath(resolved));
Assert.False(Path.IsPathFullyQualified(relative));
Assert.NotEqual("..", relative);
Assert.False(
relative.StartsWith(
".." + Path.DirectorySeparatorChar,
StringComparison.Ordinal));
}
}
@@ -0,0 +1,126 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.AvPlayer;
using Xunit;
namespace SharpEmu.Libs.Tests.AvPlayer;
public sealed class AvPlayerStreamInfoTests
{
private const string StreamInfoExNid = "ctTAcF5DiKQ";
private const ulong BaseAddress = 0x1_0000_0000;
private const int MemorySize = 0x2000;
private const ulong InfoAddress = BaseAddress + 0x100;
private const ulong Handle = 0xA0_0000_0001;
private const ulong DurationMilliseconds = 0x0102_0304_0506_0708;
private const byte Sentinel = 0xAB;
[Theory]
[InlineData(false, 0u)]
[InlineData(true, 0u)]
[InlineData(false, 1u)]
[InlineData(true, 1u)]
public void GetStreamInfoFunctionsDoNotWritePastThe32ByteStructure(
bool useExtendedFunction,
uint streamIndex)
{
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var context = new CpuContext(memory, Generation.Gen5);
AvPlayerExports.RegisterPlayerForTest(Handle, 1280, 720, DurationMilliseconds);
try
{
Span<byte> window = stackalloc byte[40];
window.Fill(Sentinel);
Assert.True(memory.TryWrite(InfoAddress, window));
context[CpuRegister.Rdi] = Handle;
context[CpuRegister.Rsi] = streamIndex;
context[CpuRegister.Rdx] = InfoAddress;
var resultCode = useExtendedFunction
? AvPlayerExports.AvPlayerGetStreamInfoEx(context)
: AvPlayerExports.AvPlayerGetStreamInfo(context);
Assert.Equal(0, resultCode);
Span<byte> result = stackalloc byte[40];
Assert.True(memory.TryRead(InfoAddress, result));
Assert.Equal(streamIndex, BinaryPrimitives.ReadUInt32LittleEndian(result));
if (streamIndex == 0)
{
Assert.Equal(1280u, BinaryPrimitives.ReadUInt32LittleEndian(result[8..]));
Assert.Equal(720u, BinaryPrimitives.ReadUInt32LittleEndian(result[12..]));
}
else
{
Assert.Equal(2, BinaryPrimitives.ReadUInt16LittleEndian(result[8..]));
Assert.Equal(48_000u, BinaryPrimitives.ReadUInt32LittleEndian(result[12..]));
}
Assert.Equal(DurationMilliseconds, BinaryPrimitives.ReadUInt64LittleEndian(result[24..]));
for (var index = 32; index < result.Length; index++)
{
Assert.Equal(Sentinel, result[index]);
}
}
finally
{
AvPlayerExports.RemovePlayerForTest(Handle);
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void GetStreamInfoFunctionsRejectInvalidArguments(bool useExtendedFunction)
{
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var context = new CpuContext(memory, Generation.Gen5);
AvPlayerExports.RegisterPlayerForTest(Handle, 1280, 720, DurationMilliseconds);
try
{
context[CpuRegister.Rdi] = Handle;
context[CpuRegister.Rsi] = 2;
context[CpuRegister.Rdx] = InfoAddress;
Assert.NotEqual(0, InvokeGetStreamInfo(context, useExtendedFunction));
context[CpuRegister.Rsi] = 0;
context[CpuRegister.Rdx] = 0;
Assert.NotEqual(0, InvokeGetStreamInfo(context, useExtendedFunction));
context[CpuRegister.Rdi] = Handle + 1;
context[CpuRegister.Rdx] = InfoAddress;
Assert.NotEqual(0, InvokeGetStreamInfo(context, useExtendedFunction));
}
finally
{
AvPlayerExports.RemovePlayerForTest(Handle);
}
}
[Fact]
public void StreamInfoExExportIsRegisteredForGen5Only()
{
var gen4Manager = new ModuleManager();
gen4Manager.RegisterExports(
SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen4));
Assert.False(gen4Manager.TryGetExport(StreamInfoExNid, out _));
var gen5Manager = new ModuleManager();
gen5Manager.RegisterExports(
SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen5));
Assert.True(gen5Manager.TryGetExport(StreamInfoExNid, out var export));
Assert.Equal("sceAvPlayerGetStreamInfoEx", export.Name);
Assert.Equal("libSceAvPlayer", export.LibraryName);
Assert.Equal(Generation.Gen5, export.Target);
}
private static int InvokeGetStreamInfo(CpuContext context, bool useExtendedFunction) =>
useExtendedFunction
? AvPlayerExports.AvPlayerGetStreamInfoEx(context)
: AvPlayerExports.AvPlayerGetStreamInfo(context);
}
@@ -0,0 +1,147 @@
// 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 EXTRQ/INSERTQ bit-field semantics used by the general SSE4a
// illegal-instruction software fallback (DirectExecutionBackend.Amd64Compat.cs). Expected values
// were computed from the AMD64 Architecture Programmer's Manual definitions and cross-checked
// with an independent Python re-implementation before being written here, so a regression in the
// ported bit math fails in this file without needing a live guest or a Windows host.
public sealed class Sse4aBitFieldEmulatorTests
{
[Fact]
public void ExtractBitField_ExtractsLowByte()
{
var result = Sse4aBitFieldEmulator.ExtractBitField(0x1234_5678_9ABC_DEF0, length: 8, index: 0);
Assert.Equal(0xF0UL, result);
}
[Fact]
public void ExtractBitField_ExtractsMidFieldAtNonZeroIndex()
{
// bits [31:16] of 0x1234_5678_9ABC_DEF0 == 0x9ABC
var result = Sse4aBitFieldEmulator.ExtractBitField(0x1234_5678_9ABC_DEF0, length: 16, index: 16);
Assert.Equal(0x9ABCUL, result);
}
[Fact]
public void ExtractBitField_LengthZeroMeansSixtyFour()
{
var result = Sse4aBitFieldEmulator.ExtractBitField(0xFFFF_FFFF_FFFF_FFFF, length: 0, index: 0);
Assert.Equal(0xFFFF_FFFF_FFFF_FFFFUL, result);
}
[Fact]
public void ExtractBitField_MasksImmediatesToLowSixBits()
{
// length=0x28 (40) and index=0 is exactly the idiom SharpEmu's load-time
// Sse4aExtrqBlendPatch already recognizes; the general emulator must agree with it.
var result = Sse4aBitFieldEmulator.ExtractBitField(0x0000_0000_0000_00FF, length: 0x28, index: 0);
Assert.Equal(0xFFUL, result);
}
[Theory]
[InlineData(0x1234_5678_9ABC_DEF0UL)]
[InlineData(0x0000_0000_0000_0000UL)]
[InlineData(0xFFFF_FFFF_FFFF_FFFFUL)]
[InlineData(0x00FF_00FF_00FF_00FFUL)]
public void ExtractBitField_AgreesWithSse4aExtrqBlendPatchsByteFourRule(ulong value)
{
// Sse4aExtrqBlendPatch's own comment states that after "EXTRQ xmmN, 0x28, 0x00", dword
// lane 1 (bits 63:32) of the result equals byte 4 of the source zero-extended. The
// general emulator (used for every other EXTRQ occurrence) must produce a result
// consistent with that independently-reverse-engineered rule for the one idiom both
// paths can be checked against.
var extractedLow64 = Sse4aBitFieldEmulator.ExtractBitField(value, length: 0x28, index: 0);
var dword1 = (uint)(extractedLow64 >> 32);
var byteFourZeroExtended = (uint)((value >> 32) & 0xFF);
Assert.Equal(byteFourZeroExtended, dword1);
}
[Fact]
public void ExtractBitField_RejectsUndefinedFieldPastRegisterEnd()
{
Assert.False(Sse4aBitFieldEmulator.IsValidBitField(length: 8, index: 60));
Assert.Equal(0UL, Sse4aBitFieldEmulator.ExtractBitField(
0xFFFF_FFFF_FFFF_FFFF,
length: 8,
index: 60));
}
[Fact]
public void ExtractBitField_RejectsZeroLengthAtNonZeroIndex()
{
Assert.False(Sse4aBitFieldEmulator.IsValidBitField(length: 0, index: 1));
}
[Fact]
public void InsertBitField_InsertsFieldAtNonZeroIndexWithoutDisturbingOtherBits()
{
var result = Sse4aBitFieldEmulator.InsertBitField(
destination: 0x0000_0000_0000_0000,
source: 0xFFFF_FFFF_FFFF_FFFF,
length: 8,
index: 8);
Assert.Equal(0x0000_0000_0000_FF00UL, result);
}
[Fact]
public void InsertBitField_ClearsExactlyTheDestinationWindowBeforeInserting()
{
var result = Sse4aBitFieldEmulator.InsertBitField(
destination: 0xFFFF_FFFF_FFFF_FFFF,
source: 0x0000_0000_0000_0000,
length: 16,
index: 16);
Assert.Equal(0xFFFF_FFFF_0000_FFFFUL, result);
}
[Fact]
public void InsertBitField_InsertsLowByteAtIndexZero()
{
var result = Sse4aBitFieldEmulator.InsertBitField(
destination: 0x1122_3344_5566_7788,
source: 0xAABB_CCDD_EEFF_0011,
length: 8,
index: 0);
Assert.Equal(0x1122_3344_5566_7711UL, result);
}
[Fact]
public void InsertBitField_LengthZeroMeansSixtyFourAndOverwritesEverything()
{
var result = Sse4aBitFieldEmulator.InsertBitField(
destination: 0x1111_1111_1111_1111,
source: 0xFFFF_FFFF_FFFF_FFFF,
length: 0,
index: 0);
Assert.Equal(0xFFFF_FFFF_FFFF_FFFFUL, result);
}
[Fact]
public void InsertBitField_ZeroSourceFieldClearsOnlyItsOwnWindow()
{
// A zero-valued 12-bit field inserted at index 20 clears exactly bits [31:20]
// (0x234 -> 0x000) and leaves every bit outside that window untouched.
var result = Sse4aBitFieldEmulator.InsertBitField(
destination: 0xABCD_EF01_2345_6789,
source: 0,
length: 12,
index: 20);
Assert.Equal(0xABCD_EF01_0005_6789UL, result);
}
}
@@ -0,0 +1,332 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Fiber;
using Xunit;
namespace SharpEmu.Libs.Tests.Fiber;
/// <summary>
/// Contract tests for the libSceFiber HLE exports. These pin the current
/// validation and layout behaviour of <see cref="FiberExports"/>; they do not
/// exercise a live guest thread scheduler.
/// </summary>
public sealed class FiberExportsTests
{
private const ulong Base = 0x3_0000_0000UL;
private const int RegionSize = 0x2000;
private const int ErrorNull = unchecked((int)0x80590001);
private const int ErrorAlignment = unchecked((int)0x80590002);
private const int ErrorRange = unchecked((int)0x80590003);
private const int ErrorInvalid = unchecked((int)0x80590004);
private const int ErrorPermission = unchecked((int)0x80590005);
private const uint SignatureStart = 0xDEF1649Cu;
private const uint SignatureEnd = 0xB37592A0u;
private const ulong StackSignature = 0x7149F2CA7149F2CAUL;
private const uint StateIdle = 2;
private const ulong FiberAddress = Base;
private const ulong NameAddress = Base + 0x200;
private const ulong ContextAddress = Base + 0x400;
private const ulong EntryAddress = 0x4_0000_1000UL;
private const ulong InfoAddress = Base + 0x800;
public FiberExportsTests()
{
FiberExports.ResetRuntimeState();
}
[Fact]
public void OptParamInitialize_NullParam_ReturnsNullError()
{
var memory = new FakeCpuMemory(Base, RegionSize);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = 0;
var result = FiberExports.FiberOptParamInitialize(context);
Assert.Equal(ErrorNull, result);
}
[Fact]
public void GetSelf_NullOutAddress_ReturnsNullError()
{
var memory = new FakeCpuMemory(Base, RegionSize);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = 0;
var result = FiberExports.FiberGetSelf(context);
Assert.Equal(ErrorNull, result);
}
[Fact]
public void GetSelf_OutsideFiberContext_ReturnsPermissionError()
{
var memory = new FakeCpuMemory(Base, RegionSize);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = Base + 0x100;
var result = FiberExports.FiberGetSelf(context);
Assert.Equal(ErrorPermission, result);
}
[Fact]
public void GetInfo_NullInfo_ReturnsNullError()
{
var memory = new FakeCpuMemory(Base, RegionSize);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = FiberAddress;
context[CpuRegister.Rsi] = 0;
var result = FiberExports.FiberGetInfo(context);
Assert.Equal(ErrorNull, result);
}
[Fact]
public void Initialize_NullFiber_ReturnsNullError()
{
var memory = new FakeCpuMemory(Base, RegionSize);
var context = new CpuContext(memory, Generation.Gen5);
WriteCString(memory, NameAddress, "F");
context[CpuRegister.Rdi] = 0;
context[CpuRegister.Rsi] = NameAddress;
context[CpuRegister.Rdx] = EntryAddress;
var result = FiberExports.FiberInitialize(context);
Assert.Equal(ErrorNull, result);
}
[Fact]
public void Initialize_NullName_ReturnsNullError()
{
var memory = new FakeCpuMemory(Base, RegionSize);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = FiberAddress;
context[CpuRegister.Rsi] = 0;
context[CpuRegister.Rdx] = EntryAddress;
var result = FiberExports.FiberInitialize(context);
Assert.Equal(ErrorNull, result);
}
[Fact]
public void Initialize_NullEntry_ReturnsNullError()
{
var memory = new FakeCpuMemory(Base, RegionSize);
var context = new CpuContext(memory, Generation.Gen5);
WriteCString(memory, NameAddress, "F");
context[CpuRegister.Rdi] = FiberAddress;
context[CpuRegister.Rsi] = NameAddress;
context[CpuRegister.Rdx] = 0;
var result = FiberExports.FiberInitialize(context);
Assert.Equal(ErrorNull, result);
}
[Fact]
public void Initialize_MisalignedFiber_ReturnsAlignmentError()
{
var memory = new FakeCpuMemory(Base, RegionSize);
var context = new CpuContext(memory, Generation.Gen5);
WriteCString(memory, NameAddress, "F");
context[CpuRegister.Rdi] = FiberAddress + 4;
context[CpuRegister.Rsi] = NameAddress;
context[CpuRegister.Rdx] = EntryAddress;
var result = FiberExports.FiberInitialize(context);
Assert.Equal(ErrorAlignment, result);
}
[Fact]
public void Initialize_TooSmallContextSize_ReturnsRangeError()
{
var memory = new FakeCpuMemory(Base, RegionSize);
var context = new CpuContext(memory, Generation.Gen5);
WriteCString(memory, NameAddress, "F");
context[CpuRegister.Rdi] = FiberAddress;
context[CpuRegister.Rsi] = NameAddress;
context[CpuRegister.Rdx] = EntryAddress;
context[CpuRegister.R8] = ContextAddress;
context[CpuRegister.R9] = 256;
var result = FiberExports.FiberInitialize(context);
Assert.Equal(ErrorRange, result);
}
[Fact]
public void Initialize_ContextAddressWithoutSize_ReturnsInvalidError()
{
var memory = new FakeCpuMemory(Base, RegionSize);
var context = new CpuContext(memory, Generation.Gen5);
WriteCString(memory, NameAddress, "F");
context[CpuRegister.Rdi] = FiberAddress;
context[CpuRegister.Rsi] = NameAddress;
context[CpuRegister.Rdx] = EntryAddress;
context[CpuRegister.R8] = ContextAddress;
context[CpuRegister.R9] = 0;
var result = FiberExports.FiberInitialize(context);
Assert.Equal(ErrorInvalid, result);
}
[Fact]
public void Initialize_Valid_WritesExpectedLayout()
{
var memory = new FakeCpuMemory(Base, RegionSize);
var context = new CpuContext(memory, Generation.Gen5);
const string name = "TestFiber";
WriteCString(memory, NameAddress, name);
const ulong argOnInitialize = 0xDEADUL;
const ulong contextSize = 512UL;
context[CpuRegister.Rdi] = FiberAddress;
context[CpuRegister.Rsi] = NameAddress;
context[CpuRegister.Rdx] = EntryAddress;
context[CpuRegister.Rcx] = argOnInitialize;
context[CpuRegister.R8] = ContextAddress;
context[CpuRegister.R9] = contextSize;
// RSP defaults to 0 (unmapped); ReadStackArg64 falls back to 0 ->
// optParam = 0, buildVersion = 0. ApplyInitializationFlags(0, 0, false) == 0.
var result = FiberExports.FiberInitialize(context);
Assert.Equal(0, result);
Assert.Equal(0UL, context[CpuRegister.Rax]);
Assert.Equal(SignatureStart, ReadUInt32(memory, FiberAddress + 0));
Assert.Equal(StateIdle, ReadUInt32(memory, FiberAddress + 4));
Assert.Equal(EntryAddress, ReadUInt64(memory, FiberAddress + 8));
Assert.Equal(argOnInitialize, ReadUInt64(memory, FiberAddress + 16));
Assert.Equal(ContextAddress, ReadUInt64(memory, FiberAddress + 24));
Assert.Equal(contextSize, ReadUInt64(memory, FiberAddress + 32));
AssertInlineName(memory, FiberAddress + 40, name);
Assert.Equal(0UL, ReadUInt64(memory, FiberAddress + 72));
Assert.Equal(0u, ReadUInt32(memory, FiberAddress + 80));
Assert.Equal(ContextAddress, ReadUInt64(memory, FiberAddress + 88));
Assert.Equal(ContextAddress + contextSize, ReadUInt64(memory, FiberAddress + 96));
Assert.Equal(SignatureEnd, ReadUInt32(memory, FiberAddress + 104));
Assert.Equal(StackSignature, ReadUInt64(memory, ContextAddress));
}
[Fact]
public void GetInfo_AfterInitialize_RoundTripsFields()
{
var memory = new FakeCpuMemory(Base, RegionSize);
var context = new CpuContext(memory, Generation.Gen5);
const string name = "RoundTrip";
WriteCString(memory, NameAddress, name);
const ulong argOnInitialize = 0xCAFEUL;
const ulong contextSize = 512UL;
context[CpuRegister.Rdi] = FiberAddress;
context[CpuRegister.Rsi] = NameAddress;
context[CpuRegister.Rdx] = EntryAddress;
context[CpuRegister.Rcx] = argOnInitialize;
context[CpuRegister.R8] = ContextAddress;
context[CpuRegister.R9] = contextSize;
Assert.Equal(0, FiberExports.FiberInitialize(context));
WriteUInt64(memory, InfoAddress, 128);
context[CpuRegister.Rdi] = FiberAddress;
context[CpuRegister.Rsi] = InfoAddress;
var result = FiberExports.FiberGetInfo(context);
Assert.Equal(0, result);
Assert.Equal(EntryAddress, ReadUInt64(memory, InfoAddress + 8));
Assert.Equal(argOnInitialize, ReadUInt64(memory, InfoAddress + 16));
Assert.Equal(ContextAddress, ReadUInt64(memory, InfoAddress + 24));
Assert.Equal(contextSize, ReadUInt64(memory, InfoAddress + 32));
AssertInlineName(memory, InfoAddress + 40, name);
Assert.Equal(ulong.MaxValue, ReadUInt64(memory, InfoAddress + 72));
}
[Fact]
public void GetInfo_WrongSize_ReturnsInvalidError()
{
var memory = new FakeCpuMemory(Base, RegionSize);
var context = new CpuContext(memory, Generation.Gen5);
WriteCString(memory, NameAddress, "F");
context[CpuRegister.Rdi] = FiberAddress;
context[CpuRegister.Rsi] = NameAddress;
context[CpuRegister.Rdx] = EntryAddress;
context[CpuRegister.R8] = ContextAddress;
context[CpuRegister.R9] = 512;
Assert.Equal(0, FiberExports.FiberInitialize(context));
WriteUInt64(memory, InfoAddress, 64);
context[CpuRegister.Rdi] = FiberAddress;
context[CpuRegister.Rsi] = InfoAddress;
var result = FiberExports.FiberGetInfo(context);
Assert.Equal(ErrorInvalid, result);
}
private static void WriteCString(FakeCpuMemory memory, ulong address, string text)
{
memory.WriteCString(address, text);
}
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
{
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
Assert.True(memory.TryWrite(address, buffer));
}
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[sizeof(uint)];
Assert.True(memory.TryRead(address, buffer));
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
}
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
Assert.True(memory.TryRead(address, buffer));
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
}
private static void AssertInlineName(FakeCpuMemory memory, ulong address, string expected)
{
Span<byte> buffer = stackalloc byte[32];
Assert.True(memory.TryRead(address, buffer));
var length = buffer.IndexOf((byte)0);
if (length < 0)
{
length = buffer.Length;
}
Assert.Equal(expected, System.Text.Encoding.UTF8.GetString(buffer[..length]));
}
}
@@ -0,0 +1,100 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.GUI;
using Xunit;
namespace SharpEmu.Libs.Tests.GUI;
public sealed class GuiSettingsTests
{
[Fact]
public void NormalizeFromJson_AllPropertiesNull_FallsBackToDefaults()
{
const string json = """
{
"LogLevel": null,
"GameFolders": null,
"ExcludedGames": null,
"EnvironmentToggles": null,
"Language": null,
"DiscordClientId": null
}
""";
var settings = GuiSettings.NormalizeFromJson(json);
Assert.Equal("Info", settings.LogLevel);
Assert.Equal("en", settings.Language);
Assert.Equal("1525606762248540221", settings.DiscordClientId);
Assert.Empty(settings.GameFolders);
Assert.Empty(settings.ExcludedGames);
Assert.Empty(settings.EnvironmentToggles);
}
[Fact]
public void NormalizeFromJson_ValidValues_ArePreserved()
{
const string json = """
{
"LogLevel": "Debug",
"GameFolders": ["C:\\Games"],
"ExcludedGames": ["C:\\Games\\skip.bin"],
"EnvironmentToggles": ["SHARPEMU_TRACE"],
"Language": "pt-BR",
"DiscordClientId": "999"
}
""";
var settings = GuiSettings.NormalizeFromJson(json);
Assert.Equal("Debug", settings.LogLevel);
Assert.Equal("pt-BR", settings.Language);
Assert.Equal("999", settings.DiscordClientId);
Assert.Equal(["C:\\Games"], settings.GameFolders);
Assert.Equal(["C:\\Games\\skip.bin"], settings.ExcludedGames);
Assert.Equal(["SHARPEMU_TRACE"], settings.EnvironmentToggles);
}
// An empty Discord client ID intentionally disables Rich Presence.
[Fact]
public void NormalizeFromJson_EmptyDiscordClientId_IsPreservedNotNormalized()
{
const string json = """{ "DiscordClientId": "" }""";
var settings = GuiSettings.NormalizeFromJson(json);
Assert.Equal(string.Empty, settings.DiscordClientId);
}
[Fact]
public void NormalizeFromJson_NullOrEmptyListEntries_AreFilteredOut()
{
const string json = """
{
"GameFolders": ["C:\\Games", null, ""],
"ExcludedGames": [null],
"EnvironmentToggles": [null, "SHARPEMU_TRACE", ""]
}
""";
var settings = GuiSettings.NormalizeFromJson(json);
Assert.Equal(["C:\\Games"], settings.GameFolders);
Assert.Empty(settings.ExcludedGames);
Assert.Equal(["SHARPEMU_TRACE"], settings.EnvironmentToggles);
}
[Fact]
public void NormalizeFromJson_EmptyObject_UsesConstructorDefaults()
{
var settings = GuiSettings.NormalizeFromJson("{}");
Assert.Equal("Info", settings.LogLevel);
Assert.Equal("en", settings.Language);
Assert.Equal("1525606762248540221", settings.DiscordClientId);
Assert.Empty(settings.GameFolders);
Assert.Empty(settings.ExcludedGames);
Assert.Empty(settings.EnvironmentToggles);
}
}
@@ -0,0 +1,60 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.GUI;
using Xunit;
namespace SharpEmu.Libs.Tests.GUI;
public sealed class PerGameSettingsTests
{
// Invalid entries must not reach Environment.SetEnvironmentVariable.
[Fact]
public void NormalizeFromJson_NullOrEmptyToggleEntries_AreFilteredOut()
{
const string json = """
{ "EnvironmentToggles": [null, "SHARPEMU_TRACE", ""] }
""";
var settings = PerGameSettings.NormalizeFromJson(json);
Assert.NotNull(settings);
Assert.Equal(["SHARPEMU_TRACE"], settings.EnvironmentToggles);
}
// A null list means that the global setting should be inherited.
[Fact]
public void NormalizeFromJson_NullToggleList_StaysNull()
{
const string json = """{ "EnvironmentToggles": null }""";
var settings = PerGameSettings.NormalizeFromJson(json);
Assert.NotNull(settings);
Assert.Null(settings.EnvironmentToggles);
}
[Fact]
public void NormalizeFromJson_EmptyToggleList_StaysEmpty()
{
const string json = """{ "EnvironmentToggles": [] }""";
var settings = PerGameSettings.NormalizeFromJson(json);
Assert.NotNull(settings);
Assert.Empty(Assert.IsType<List<string>>(settings.EnvironmentToggles));
}
[Fact]
public void NormalizeFromJson_ValidToggles_ArePreserved()
{
const string json = """
{ "EnvironmentToggles": ["SHARPEMU_TRACE", "SHARPEMU_NO_JIT"] }
""";
var settings = PerGameSettings.NormalizeFromJson(json);
Assert.NotNull(settings);
Assert.Equal(["SHARPEMU_TRACE", "SHARPEMU_NO_JIT"], settings.EnvironmentToggles);
}
}
@@ -0,0 +1,77 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using Xunit;
namespace SharpEmu.Libs.Tests.HLE;
public sealed class GuestWriteWatchTests
{
[Theory]
[InlineData(0x0000001000000001, "torn")]
[InlineData(0x0000FFFF00000001, "torn")]
[InlineData(0x0000000008000000, "shift")]
[InlineData(0x0000000080015F00, "shift")]
[InlineData(0x0000000007FFFFFF, null)]
[InlineData(0x0000000009000000, null)]
[InlineData(0x000000003F800000, null)]
[InlineData(0x0000000080015F01, null)]
[InlineData(0x0001000000000001, null)]
public void ClassifyBulkValue_RecognizesCorruptionSignatures(ulong value, string? expected)
{
Assert.Equal(expected, GuestWriteWatch.ClassifyBulkValue(value));
}
[Theory]
[InlineData(0, 0)]
[InlineData(1, 7)]
[InlineData(3, 5)]
[InlineData(7, 1)]
[InlineData(8, 0)]
public void FirstAlignedOffset_ReturnsTheNextEightByteBoundary(ulong address, int expected)
{
Assert.Equal(expected, GuestWriteWatch.FirstAlignedOffset(address));
}
[Theory]
[InlineData(0x1000, 8, 0x1000, true)]
[InlineData(0x0FFF, 1, 0x1000, false)]
[InlineData(0x0FFF, 2, 0x1000, true)]
[InlineData(0x1008, 1, 0x1000, false)]
[InlineData(ulong.MaxValue - 3, 4, ulong.MaxValue - 1, true)]
[InlineData(ulong.MaxValue, 1, ulong.MaxValue, true)]
[InlineData(0x1000, 0, 0x1000, false)]
public void Overlaps_HandlesBoundariesWithoutOverflow(
ulong address,
int length,
ulong slot,
bool expected)
{
Assert.Equal(expected, GuestWriteWatch.Overlaps(address, length, slot));
}
[Theory]
[InlineData(0x10000, 0xF2, true)]
[InlineData(0x10001, 0xF2, false)]
[InlineData(0x10000, 0xF1, false)]
public void IsPoolMapping_RequiresTheExpectedSizeAndProtection(
ulong length,
int protection,
bool expected)
{
Assert.Equal(expected, GuestWriteWatch.IsPoolMapping(length, protection));
}
[Theory]
[InlineData(null, 0)]
[InlineData("", 0)]
[InlineData("not-hex", 0)]
[InlineData("80", 0x80)]
[InlineData("0x80", 0x80)]
[InlineData(" 0X801DB3BBB ", 0x801DB3BBB)]
public void Parse_HandlesHexadecimalWatchValues(string? text, ulong expected)
{
Assert.Equal(expected, GuestWriteWatch.Parse(text));
}
}
@@ -41,6 +41,90 @@ public sealed class KernelMemoryCompatExportsTests
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
}
[Fact]
public void PosixOpen_MissingFileReturnsMinusOne()
{
const ulong memoryBase = 0x1_0000_0000;
const ulong pathAddress = memoryBase + 0x100;
var memory = new FakeCpuMemory(memoryBase, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
memory.WriteCString(pathAddress, "/__sharpemu_test_missing__/il2cpp.usym");
context[CpuRegister.Rdi] = pathAddress;
context[CpuRegister.Rsi] = 0; // O_RDONLY
var result = KernelMemoryCompatExports.PosixOpen(context);
// A libc open() failure must be -1, not the raw 0x8002xxxx sentinel the
// guest would otherwise store as a valid fd and later dereference.
Assert.Equal(-1, result);
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
}
[Fact]
public void PosixFstat_BadDescriptorReturnsMinusOne()
{
const ulong memoryBase = 0x1_0000_0000;
const ulong statAddress = memoryBase + 0x400;
var memory = new FakeCpuMemory(memoryBase, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = 0x80020002; // the not-found sentinel misused as an fd
context[CpuRegister.Rsi] = statAddress;
var result = KernelMemoryCompatExports.PosixFstat(context);
Assert.Equal(-1, result);
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
}
[Fact]
public void PosixClose_BadDescriptorReturnsMinusOne()
{
const ulong memoryBase = 0x1_0000_0000;
var memory = new FakeCpuMemory(memoryBase, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = 0x80020002; // never-opened / sentinel fd
var result = KernelMemoryCompatExports.PosixClose(context);
Assert.Equal(-1, result);
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
}
[Fact]
public void PosixRead_BadDescriptorReturnsMinusOne()
{
const ulong memoryBase = 0x1_0000_0000;
const ulong bufferAddress = memoryBase + 0x200;
var memory = new FakeCpuMemory(memoryBase, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = 0x80020002; // never-opened / sentinel fd
context[CpuRegister.Rsi] = bufferAddress;
context[CpuRegister.Rdx] = 0x40;
var result = KernelMemoryCompatExports.PosixRead(context);
Assert.Equal(-1, result);
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
}
[Fact]
public void PosixWrite_BadDescriptorReturnsMinusOne()
{
const ulong memoryBase = 0x1_0000_0000;
const ulong bufferAddress = memoryBase + 0x200;
var memory = new FakeCpuMemory(memoryBase, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
memory.WriteCString(bufferAddress, "payload");
context[CpuRegister.Rdi] = 0x80020002; // never-opened / sentinel fd
context[CpuRegister.Rsi] = bufferAddress;
context[CpuRegister.Rdx] = 0x7;
var result = KernelMemoryCompatExports.PosixWrite(context);
Assert.Equal(-1, result);
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
}
[Fact]
public void Sprintf_ReadsVariadicDoubleFromXmmRegister()
{
@@ -37,10 +37,11 @@ public sealed class KernelSocketCompatExportsTests
KernelMemoryCompatExports.PosixClose(context));
Assert.Equal(0UL, context[CpuRegister.Rax]);
// A second close of an already-closed fd fails per the POSIX ABI:
// -1 with errno set, not the raw Orbis NOT_FOUND sentinel.
context[CpuRegister.Rdi] = unchecked((ulong)guestFd);
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND,
KernelMemoryCompatExports.PosixClose(context));
Assert.Equal(-1, KernelMemoryCompatExports.PosixClose(context));
Assert.Equal(ulong.MaxValue, context[CpuRegister.Rax]);
}
finally
{
@@ -0,0 +1,135 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
using SharpEmu.HLE;
using Xunit;
namespace SharpEmu.Libs.Tests.Memory;
/// <summary>
/// The write generation lets GPU caches detect guest CPU rewrites even after
/// another cache owner consumed the (single) dirty flag: the generation is
/// monotonic and survives consume/re-arm cycles and range replacement. These
/// invariants back the presenter's stale-upload detection for CPU-rewritten
/// images (video planes, streamed font atlases).
/// </summary>
public sealed unsafe class GuestImageWriteTrackerTests
{
// The tracker aligns to the guest's 4 KiB pages; the mprotect underneath
// operates on host pages, which are 16 KiB on Apple Silicon (the emulator
// itself always runs with 4 KiB host pages under Rosetta, but this test
// host may not). Align the allocation to the largest host page size so
// the kernel's rounding stays inside memory this test owns instead of
// spilling onto neighbouring heap pages.
private const nuint TrackedByteCount = 4096;
private const nuint HostPageAlignment = 16384;
private static ulong AllocateTrackedPages(out void* allocation)
{
allocation = NativeMemory.AlignedAlloc(2 * HostPageAlignment, HostPageAlignment);
return (ulong)allocation;
}
[Fact]
public void GenerationSurvivesDirtyConsume()
{
if (!GuestImageWriteTracker.Enabled)
{
return;
}
var address = AllocateTrackedPages(out var allocation);
try
{
GuestImageWriteTracker.Track(address, TrackedByteCount);
Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out var generation));
Assert.Equal(0, generation);
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address));
Assert.True(GuestImageWriteTracker.ConsumeDirty(address));
// Consuming the dirty flag must not roll back the generation:
// that is exactly what lets a second cache owner still observe
// the rewrite after the first owner consumed the flag.
Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out generation));
Assert.Equal(1, generation);
}
finally
{
GuestImageWriteTracker.Untrack(address);
NativeMemory.Free(allocation);
}
}
[Fact]
public void GenerationIncrementsOncePerArmedLifetime()
{
if (!GuestImageWriteTracker.Enabled)
{
return;
}
var address = AllocateTrackedPages(out var allocation);
try
{
GuestImageWriteTracker.Track(address, TrackedByteCount);
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address));
// The first fault disarmed the range; later writes are free-running
// and must not inflate the generation until the owner re-arms.
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address));
Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out var generation));
Assert.Equal(1, generation);
GuestImageWriteTracker.Rearm(address);
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address));
Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out generation));
Assert.Equal(2, generation);
}
finally
{
GuestImageWriteTracker.Untrack(address);
NativeMemory.Free(allocation);
}
}
[Fact]
public void GenerationCarriesAcrossRangeReplacement()
{
if (!GuestImageWriteTracker.Enabled)
{
return;
}
var address = AllocateTrackedPages(out var allocation);
try
{
GuestImageWriteTracker.Track(address, TrackedByteCount);
Assert.True(GuestImageWriteTracker.TryHandleWriteFault(address));
// Re-registering the same allocation with a different size retires
// the range object (the signal handler may still see the old
// snapshot) but must carry the generation, otherwise a resize
// would hide the rewrite from cache owners.
GuestImageWriteTracker.Track(address, 2 * TrackedByteCount);
Assert.True(GuestImageWriteTracker.TryGetWriteGeneration(address, out var generation));
Assert.Equal(1, generation);
}
finally
{
GuestImageWriteTracker.Untrack(address);
NativeMemory.Free(allocation);
}
}
[Fact]
public void UntrackedAddressHasNoGeneration()
{
if (!GuestImageWriteTracker.Enabled)
{
return;
}
Assert.False(GuestImageWriteTracker.TryGetWriteGeneration(0xDEAD_0000_0000UL, out _));
}
}
@@ -121,6 +121,113 @@ public sealed class GuestMemoryAllocatorTests
Assert.Equal([alignedAddress + 0x1000], host.FreedAddresses);
}
[Fact]
public void TryBackFixedRangeFillsOnlyTheFreePagesOfAPartiallyOccupiedRange()
{
const ulong rangeBase = 0x0000_0020_2F00_0000;
const ulong rangeSize = 0x40_0000;
const ulong occupiedSize = 0x4_0000;
using var host = new PartialOverlapHostMemory(rangeBase, occupiedSize, rangeSize);
using var memory = new PhysicalVirtualMemory(host);
Assert.True(memory.TryBackFixedRange(rangeBase, rangeSize, executable: false));
// Only the free tail is allocated; the already-occupied head is untouched.
Assert.Equal(
[(rangeBase + occupiedSize, rangeSize - occupiedSize)],
host.AllocationCalls);
}
[Fact]
public void TryBackFixedRangeReturnsFalseWhenRangeIsFullyOccupied()
{
const ulong rangeBase = 0x0000_0020_2F00_0000;
const ulong rangeSize = 0x40_0000;
using var host = new PartialOverlapHostMemory(rangeBase, rangeSize, rangeSize);
using var memory = new PhysicalVirtualMemory(host);
Assert.False(memory.TryBackFixedRange(rangeBase, rangeSize, executable: false));
Assert.Empty(host.AllocationCalls);
}
private sealed class PartialOverlapHostMemory : IHostMemory, IDisposable
{
private readonly ulong _rangeBase;
private readonly ulong _occupiedEnd;
private readonly ulong _rangeEnd;
public PartialOverlapHostMemory(ulong rangeBase, ulong occupiedSize, ulong rangeSize)
{
_rangeBase = rangeBase;
_occupiedEnd = rangeBase + occupiedSize;
_rangeEnd = rangeBase + rangeSize;
}
public List<(ulong Address, ulong Size)> AllocationCalls { get; } = [];
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection)
{
AllocationCalls.Add((desiredAddress, size));
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) => 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)
{
// Report contiguous runs of same-state pages, exactly as VirtualQuery
// does: the occupied head first, then the free tail.
if (address < _occupiedEnd)
{
info = new HostRegionInfo(
address,
_rangeBase,
_occupiedEnd - address,
HostRegionState.Committed,
RawState: 0x1000,
HostPageProtection.ReadWrite,
RawProtection: 0x04,
RawAllocationProtection: 0x04);
return true;
}
info = new HostRegionInfo(
address,
AllocationBase: 0,
_rangeEnd - address,
HostRegionState.Free,
RawState: 0x10000,
HostPageProtection.NoAccess,
RawProtection: 0x01,
RawAllocationProtection: 0);
return true;
}
public void FlushInstructionCache(ulong address, ulong size)
{
}
public void Dispose()
{
}
}
private sealed class FakeHostMemory : IHostMemory
{
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) =>
@@ -39,6 +39,60 @@ public sealed class PhysicalVirtualMemoryTests
Assert.Equal([(page, 0x1000UL, HostPageProtection.ReadWrite)], host.CommitCalls);
}
[Fact]
public void RepeatedLazyReadUsesCommittedRangeCache()
{
using var host = new LazyZeroedHostMemory();
using var memory = new PhysicalVirtualMemory(host);
var address = memory.AllocateAt(0, (4UL << 30) + 0x1000, executable: false);
host.CommitCalls.Clear();
Span<byte> buffer = stackalloc byte[16];
Assert.True(memory.TryRead(address + 0x100, buffer));
var queryCallsAfterFirstRead = host.QueryCalls;
Assert.True(memory.TryRead(address + 0x108, buffer[..8]));
Assert.Equal(queryCallsAfterFirstRead, host.QueryCalls);
Assert.Single(host.CommitCalls);
}
[Fact]
public void TryCopyHandlesOverlappingIdentityMappedRanges()
{
using var host = new LazyZeroedHostMemory();
using var memory = new PhysicalVirtualMemory(host);
var address = memory.AllocateAt(0, (4UL << 30) + 0x1000, executable: false);
Assert.True(memory.TryWrite(address, new byte[] { 1, 2, 3, 4, 5, 6 }));
Assert.True(memory.TryCopy(address + 2, address, 4));
Span<byte> result = stackalloc byte[6];
Assert.True(memory.TryRead(address, result));
Assert.Equal(new byte[] { 1, 2, 1, 2, 3, 4 }, result.ToArray());
}
[Fact]
public void RepeatedTryCopyKeepsSourceAndDestinationCommitRangesCached()
{
using var host = new LazyZeroedHostMemory();
using var memory = new PhysicalVirtualMemory(host);
var address = memory.AllocateAt(0, (4UL << 30) + 0x1000, executable: false);
var source = address + 0x100;
var destination = address + 0x1100;
Assert.True(memory.TryWrite(source, new byte[] { 1, 2, 3, 4 }));
Assert.True(memory.TryWrite(destination, new byte[4]));
host.CommitCalls.Clear();
Assert.True(memory.TryCopy(destination, source, 4));
var queryCallsAfterFirstCopy = host.QueryCalls;
Assert.True(memory.TryCopy(destination, source, 4));
Assert.Equal(queryCallsAfterFirstCopy, host.QueryCalls);
}
// 2. Reserve-only region: GetPointer commits the page before returning it,
// so callers receive a valid (non-null) pointer. An unmapped address yields null.
[Fact]
@@ -125,12 +179,14 @@ public sealed class PhysicalVirtualMemoryTests
public LazyZeroedHostMemory()
{
_allocation = System.Runtime.InteropServices.NativeMemory.AllocZeroed(0x2000);
_allocation = System.Runtime.InteropServices.NativeMemory.AllocZeroed(0x3000);
_address = ((ulong)_allocation + 0xFFF) & ~0xFFFUL;
}
public List<(ulong Address, ulong Size, HostPageProtection Protection)> CommitCalls { get; } = [];
public int QueryCalls { get; private set; }
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) => _address;
public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection) => _address;
@@ -162,6 +218,7 @@ public sealed class PhysicalVirtualMemoryTests
public bool Query(ulong address, out HostRegionInfo info)
{
QueryCalls++;
var pageAddress = address & ~0xFFFUL;
info = new HostRegionInfo(
pageAddress,
@@ -10,7 +10,7 @@ namespace SharpEmu.Libs.Tests.Pthread;
public sealed class PthreadMutexSemanticsTests
{
[Fact]
public void AdaptiveMutex_SelfLockUsesCompatibilityRecursion()
public void AdaptiveMutex_SelfLockIsIdempotent()
{
const ulong memoryBase = 0x1_0000_0000;
const ulong mutexAddress = memoryBase + 0x100;
@@ -22,7 +22,180 @@ public sealed class PthreadMutexSemanticsTests
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
Assert.NotEqual(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
}
[Fact]
public void AdaptiveMutex_GuestTrackedSelfLockReturnsDeadlockAndSingleUnlockReleases()
{
const ulong memoryBase = 0x1_0001_0000;
const ulong mutexAddress = memoryBase + 0x100;
var memory = new AllocatingCpuMemory(memoryBase, 0x4000);
var context = new CpuContext(memory, Generation.Gen5);
Assert.True(context.TryWriteUInt64(mutexAddress, 1)); // Static adaptive initializer.
context[CpuRegister.Rdi] = mutexAddress;
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
var currentThreadHandle = KernelPthreadState.GetCurrentThreadHandle();
Assert.True(context.TryWriteUInt64(mutexAddress + 8, currentThreadHandle));
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_DEADLOCK,
KernelPthreadCompatExports.PthreadMutexLock(context));
Assert.True(context.TryWriteUInt64(mutexAddress + 8, 0));
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexTrylock(context));
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
}
[Fact]
public void RecursiveMutex_GuestTrackedSelfLockKeepsRecursiveSemantics()
{
const ulong memoryBase = 0x1_0002_0000;
const ulong attrAddress = memoryBase + 0x100;
const ulong mutexAddress = memoryBase + 0x200;
var memory = new AllocatingCpuMemory(memoryBase, 0x4000);
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = attrAddress;
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexattrInit(context));
context[CpuRegister.Rsi] = 2;
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexattrSettype(context));
context[CpuRegister.Rdi] = mutexAddress;
context[CpuRegister.Rsi] = attrAddress;
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexInit(context));
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
var currentThreadHandle = KernelPthreadState.GetCurrentThreadHandle();
Assert.True(context.TryWriteUInt64(mutexAddress + 8, currentThreadHandle));
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(context));
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
Assert.NotEqual(0, KernelPthreadCompatExports.PthreadMutexUnlock(context));
}
[Fact]
public async Task ContendedMutex_HandsOffOneHostWaiterAtATime()
{
const ulong memoryBase = 0x2_0000_0000;
const ulong mutexAddress = memoryBase + 0x100;
var memory = new AllocatingCpuMemory(memoryBase, 0x4000);
var ownerContext = new CpuContext(memory, Generation.Gen5);
Assert.True(ownerContext.TryWriteUInt64(mutexAddress, 1));
ownerContext[CpuRegister.Rdi] = mutexAddress;
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(ownerContext));
using var waitersStarted = new CountdownEvent(2);
using var firstAcquired = new ManualResetEventSlim(false);
using var secondAcquired = new ManualResetEventSlim(false);
using var releaseFirst = new ManualResetEventSlim(false);
var acquisitionCount = 0;
Task<(int LockResult, int UnlockResult)> StartWaiter() =>
Task.Factory.StartNew(
() =>
{
var waiterContext = new CpuContext(memory, Generation.Gen5);
waiterContext[CpuRegister.Rdi] = mutexAddress;
waitersStarted.Signal();
var lockResult = KernelPthreadCompatExports.PthreadMutexLock(waiterContext);
if (lockResult != 0)
{
return (lockResult, int.MinValue);
}
if (Interlocked.Increment(ref acquisitionCount) == 1)
{
firstAcquired.Set();
releaseFirst.Wait(TimeSpan.FromSeconds(5));
}
else
{
secondAcquired.Set();
}
var unlockResult = KernelPthreadCompatExports.PthreadMutexUnlock(waiterContext);
return (lockResult, unlockResult);
},
CancellationToken.None,
TaskCreationOptions.LongRunning,
TaskScheduler.Default);
var firstWaiter = StartWaiter();
var secondWaiter = StartWaiter();
Assert.True(waitersStarted.Wait(TimeSpan.FromSeconds(5)));
Thread.Sleep(50);
Assert.Equal(0, Volatile.Read(ref acquisitionCount));
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(ownerContext));
Assert.True(firstAcquired.Wait(TimeSpan.FromSeconds(5)));
Assert.Equal(1, Volatile.Read(ref acquisitionCount));
releaseFirst.Set();
Assert.True(secondAcquired.Wait(TimeSpan.FromSeconds(5)));
var results = await Task.WhenAll(firstWaiter, secondWaiter).WaitAsync(TimeSpan.FromSeconds(5));
Assert.All(results, result => Assert.Equal((0, 0), result));
Assert.Equal(2, Volatile.Read(ref acquisitionCount));
}
[Fact]
public async Task ContendedMutex_PreservesMutualExclusionUnderLoad()
{
const ulong memoryBase = 0x3_0000_0000;
const ulong mutexAddress = memoryBase + 0x100;
const int workerCount = 4;
const int iterationsPerWorker = 250;
var memory = new AllocatingCpuMemory(memoryBase, 0x4000);
var initializationContext = new CpuContext(memory, Generation.Gen5);
Assert.True(initializationContext.TryWriteUInt64(mutexAddress, 1));
initializationContext[CpuRegister.Rdi] = mutexAddress;
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexLock(initializationContext));
Assert.Equal(0, KernelPthreadCompatExports.PthreadMutexUnlock(initializationContext));
using var start = new ManualResetEventSlim(false);
var insideCriticalSection = 0;
var mutualExclusionViolations = 0;
var protectedCounter = 0;
var workers = Enumerable.Range(0, workerCount)
.Select(_ => Task.Factory.StartNew(
() =>
{
var context = new CpuContext(memory, Generation.Gen5);
context[CpuRegister.Rdi] = mutexAddress;
start.Wait();
for (var iteration = 0; iteration < iterationsPerWorker; iteration++)
{
if (KernelPthreadCompatExports.PthreadMutexLock(context) != 0)
{
throw new InvalidOperationException("pthread mutex lock failed during contention stress.");
}
if (Interlocked.Increment(ref insideCriticalSection) != 1)
{
Interlocked.Increment(ref mutualExclusionViolations);
}
protectedCounter++;
Thread.SpinWait(20);
Interlocked.Decrement(ref insideCriticalSection);
if (KernelPthreadCompatExports.PthreadMutexUnlock(context) != 0)
{
throw new InvalidOperationException("pthread mutex unlock failed during contention stress.");
}
}
},
CancellationToken.None,
TaskCreationOptions.LongRunning,
TaskScheduler.Default))
.ToArray();
start.Set();
await Task.WhenAll(workers).WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal(0, Volatile.Read(ref mutualExclusionViolations));
Assert.Equal(workerCount * iterationsPerWorker, protectedCounter);
}
private sealed class AllocatingCpuMemory : ICpuMemory, IGuestMemoryAllocator
@@ -0,0 +1,144 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
using Xunit;
namespace SharpEmu.Libs.Tests.Pthread;
public sealed class PthreadSemaphoreSemanticsTests
{
private const ulong MemoryBase = 0x1_0000_0000;
private const ulong SemaphoreAddress = MemoryBase + 0x100;
private const ulong ValueAddress = MemoryBase + 0x200;
[Theory]
[InlineData("C36iRE0F5sE", "scePthreadSemWait")]
[InlineData("aishVAiFaYM", "scePthreadSemPost")]
[InlineData("H2a+IN9TP0E", "scePthreadSemTrywait")]
[InlineData("GEnUkDZoUwY", "scePthreadSemInit")]
[InlineData("Vwc+L05e6oE", "scePthreadSemDestroy")]
public void RegistryResolvesPthreadSemaphoreExports(string nid, string exportName)
{
var manager = new ModuleManager();
manager.RegisterExports(SharpEmu.Generated.SysAbiExportRegistry.CreateExports(
Generation.Gen4 | Generation.Gen5));
Assert.True(manager.TryGetExport(nid, out var export));
Assert.Equal(exportName, export.Name);
Assert.Equal("libKernel", export.LibraryName);
}
[Fact]
public void PthreadSemPostAndWaitShareSemaphoreState()
{
var context = CreateContext();
InitializeSemaphore(context, initialCount: 0);
context[CpuRegister.Rdi] = SemaphoreAddress;
Assert.Equal(0, KernelSemaphoreCompatExports.PthreadSemPost(context));
Assert.Equal(0UL, context[CpuRegister.Rax]);
Assert.Equal(1, ReadSemaphoreValue(context));
context[CpuRegister.Rdi] = SemaphoreAddress;
Assert.Equal(0, KernelSemaphoreCompatExports.PthreadSemWait(context));
Assert.Equal(0UL, context[CpuRegister.Rax]);
Assert.Equal(0, ReadSemaphoreValue(context));
DestroySemaphore(context);
}
[Fact]
public void PthreadSemTryWaitConsumesAvailableTokenAndReturnsTryAgainWhenEmpty()
{
var context = CreateContext();
InitializeSemaphore(context, initialCount: 1);
context[CpuRegister.Rdi] = SemaphoreAddress;
Assert.Equal(0, KernelSemaphoreCompatExports.PthreadSemTryWait(context));
Assert.Equal(0, ReadSemaphoreValue(context));
context[CpuRegister.Rdi] = SemaphoreAddress;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN,
KernelSemaphoreCompatExports.PthreadSemTryWait(context));
Assert.Equal(unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_TRY_AGAIN),
context[CpuRegister.Rax]);
DestroySemaphore(context);
}
[Fact]
public void PthreadSemInitRejectsNonPrivateFlag()
{
var context = CreateContext();
context[CpuRegister.Rdi] = SemaphoreAddress;
context[CpuRegister.Rsi] = 1;
context[CpuRegister.Rdx] = 0;
context[CpuRegister.Rcx] = 0;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
KernelSemaphoreCompatExports.PthreadSemInit(context));
Assert.Equal(unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT),
context[CpuRegister.Rax]);
}
[Fact]
public void PthreadSemDestroyClearsSemaphoreAndRejectsSecondDestroy()
{
var context = CreateContext();
InitializeSemaphore(context, initialCount: 0);
context[CpuRegister.Rdi] = SemaphoreAddress;
Assert.Equal(0, KernelSemaphoreCompatExports.PthreadSemDestroy(context));
Assert.True(context.TryReadUInt32(SemaphoreAddress, out var handle));
Assert.Equal(0U, handle);
context[CpuRegister.Rdi] = SemaphoreAddress;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
KernelSemaphoreCompatExports.PthreadSemDestroy(context));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void PthreadSemOperationRejectsInvalidSemaphore(bool post)
{
var context = CreateContext();
context[CpuRegister.Rdi] = SemaphoreAddress;
var result = post
? KernelSemaphoreCompatExports.PthreadSemPost(context)
: KernelSemaphoreCompatExports.PthreadSemWait(context);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT, result);
Assert.Equal(unchecked((ulong)(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT),
context[CpuRegister.Rax]);
}
private static CpuContext CreateContext() => new(new FakeCpuMemory(MemoryBase, 0x1000), Generation.Gen5);
private static void InitializeSemaphore(CpuContext context, uint initialCount)
{
context[CpuRegister.Rdi] = SemaphoreAddress;
context[CpuRegister.Rsi] = 0;
context[CpuRegister.Rdx] = initialCount;
context[CpuRegister.Rcx] = 0;
Assert.Equal(0, KernelSemaphoreCompatExports.PthreadSemInit(context));
}
private static int ReadSemaphoreValue(CpuContext context)
{
context[CpuRegister.Rdi] = SemaphoreAddress;
context[CpuRegister.Rsi] = ValueAddress;
Assert.Equal(0, KernelSemaphoreCompatExports.PosixSemGetValue(context));
Assert.True(context.TryReadUInt32(ValueAddress, out var value));
return unchecked((int)value);
}
private static void DestroySemaphore(CpuContext context)
{
context[CpuRegister.Rdi] = SemaphoreAddress;
Assert.Equal(0, KernelSemaphoreCompatExports.PthreadSemDestroy(context));
}
}
@@ -31,6 +31,9 @@ public sealed class SaveDataExportsTests : IDisposable
private const ulong SyncParam = Base + 0xC80;
private const ulong SetupParam = Base + 0xD00;
private const ulong SetupResult = Base + 0xD80;
private const ulong TransactionOut = Base + 0xE00;
private const ulong StaleR8 = Base + 0xE08;
private const ulong StaleR9 = Base + 0xE10;
private const int NoEvent = unchecked((int)0x809F0008);
private const int ParameterError = unchecked((int)0x809F0000);
@@ -225,4 +228,64 @@ public sealed class SaveDataExportsTests : IDisposable
unchecked((int)0x809F0008),
SaveDataExports.SaveDataDelete(Reg(rdi: DeleteParam)));
}
[Fact]
public void CreateTransactionResource_WithoutOutPointer_DoesNotProbeStaleRegisters()
{
const uint sentinel = 0xA5A5A5A5;
Assert.True(_ctx.TryWriteUInt32(TransactionOut, sentinel));
Assert.True(_ctx.TryWriteUInt32(StaleR8, sentinel));
Assert.True(_ctx.TryWriteUInt32(StaleR9, sentinel));
var ctx = Reg(rdi: UserId, rdx: 0, rcx: TransactionOut);
ctx[CpuRegister.R8] = StaleR8;
ctx[CpuRegister.R9] = StaleR9;
Assert.Equal(0, SaveDataExports.SaveDataCreateTransactionResource(ctx));
Assert.True(_ctx.TryReadUInt32(TransactionOut, out var rcxValue));
Assert.True(_ctx.TryReadUInt32(StaleR8, out var r8Value));
Assert.True(_ctx.TryReadUInt32(StaleR9, out var r9Value));
Assert.Equal(sentinel, rcxValue);
Assert.Equal(sentinel, r8Value);
Assert.Equal(sentinel, r9Value);
}
[Fact]
public void CreateTransactionResource_WithOutPointerFlag_WritesOnlyRcx()
{
const uint sentinel = 0xA5A5A5A5;
Assert.True(_ctx.TryWriteUInt32(TransactionOut, 0));
Assert.True(_ctx.TryWriteUInt32(StaleR8, sentinel));
Assert.True(_ctx.TryWriteUInt32(StaleR9, sentinel));
var ctx = Reg(rdi: UserId, rdx: 1, rcx: TransactionOut);
ctx[CpuRegister.R8] = StaleR8;
ctx[CpuRegister.R9] = StaleR9;
Assert.Equal(0, SaveDataExports.SaveDataCreateTransactionResource(ctx));
Assert.True(_ctx.TryReadUInt32(TransactionOut, out var resource));
Assert.True(_ctx.TryReadUInt32(StaleR8, out var r8Value));
Assert.True(_ctx.TryReadUInt32(StaleR9, out var r9Value));
Assert.NotEqual(0u, resource);
Assert.Equal(sentinel, r8Value);
Assert.Equal(sentinel, r9Value);
}
[Fact]
public void CreateTransactionResource_WithLegacyOutPointer_WritesOnlyRdx()
{
const uint sentinel = 0xA5A5A5A5;
Assert.True(_ctx.TryWriteUInt32(TransactionOut, 0));
Assert.True(_ctx.TryWriteUInt32(StaleR8, sentinel));
Assert.Equal(
0,
SaveDataExports.SaveDataCreateTransactionResource(
Reg(rdi: UserId, rdx: TransactionOut, rcx: StaleR8)));
Assert.True(_ctx.TryReadUInt32(TransactionOut, out var resource));
Assert.True(_ctx.TryReadUInt32(StaleR8, out var rcxValue));
Assert.NotEqual(0u, resource);
Assert.Equal(sentinel, rcxValue);
}
}
@@ -14,6 +14,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<ProjectReference Include="..\..\src\SharpEmu.Core\SharpEmu.Core.csproj" />
<ProjectReference Include="..\..\src\SharpEmu.Libs\SharpEmu.Libs.csproj" />
<ProjectReference Include="..\..\src\SharpEmu.HLE\SharpEmu.HLE.csproj" />
<ProjectReference Include="..\..\src\SharpEmu.GUI\SharpEmu.GUI.csproj" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,59 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.VideoOut;
using Silk.NET.Vulkan;
using Xunit;
namespace SharpEmu.Libs.Tests.VideoOut;
public sealed class VulkanPresentEncodeFormatTests
{
[Theory]
[InlineData(Format.B8G8R8A8Unorm, Format.B8G8R8A8Srgb)]
[InlineData(Format.R8G8B8A8Unorm, Format.R8G8B8A8Srgb)]
public void UnormSwapchainFormatsHaveSrgbCounterparts(
Format swapchainFormat,
Format expected)
{
Assert.Equal(
expected,
VulkanVideoPresenter.GetSrgbCounterpart(swapchainFormat));
}
[Theory]
// Already-sRGB swapchains encode on store; the direct blit stays.
[InlineData(Format.B8G8R8A8Srgb)]
[InlineData(Format.R8G8B8A8Srgb)]
// No same-class sRGB counterpart exists; the raw blit must remain.
[InlineData(Format.A2B10G10R10UnormPack32)]
[InlineData(Format.R16G16B16A16Sfloat)]
[InlineData(Format.R5G6B5UnormPack16)]
[InlineData(Format.Undefined)]
public void OtherSwapchainFormatsKeepTheDirectBlit(Format swapchainFormat)
{
Assert.Equal(
Format.Undefined,
VulkanVideoPresenter.GetSrgbCounterpart(swapchainFormat));
}
[Theory]
[InlineData(Format.R16G16B16A16Sfloat)]
[InlineData(Format.R32G32B32A32Sfloat)]
public void FloatFlipSourcesNeedLinearToSrgbEncode(Format sourceFormat)
{
Assert.True(VulkanVideoPresenter.IsLinearFloatPresentSource(sourceFormat));
}
[Theory]
[InlineData(Format.B8G8R8A8Unorm)]
[InlineData(Format.R8G8B8A8Unorm)]
[InlineData(Format.B8G8R8A8Srgb)]
[InlineData(Format.A2B10G10R10UnormPack32)]
[InlineData(Format.B10G11R11UfloatPack32)]
[InlineData(Format.Undefined)]
public void NonFloatFlipSourcesKeepTheDirectBlit(Format sourceFormat)
{
Assert.False(VulkanVideoPresenter.IsLinearFloatPresentSource(sourceFormat));
}
}
@@ -17,6 +17,8 @@
// multiply-add would give 0x7A6A instead
// [6] the same fma with the addend negated -> 0x7A6A packed (just below the
// same midpoint), pinning the opposite rounding direction
// [7] the pinned fma with the clamp modifier -> 0x3C00 packed (both lanes
// exceed 1.0, so each saturates to 1.0)
// Every other word of the buffer must still hold the sentinel afterwards.
//
// Creating the compute pipeline doubles as a driver-acceptance check for the
@@ -45,6 +47,9 @@ var expectedRestored = BitConverter.SingleToUInt32Bits(1.5f);
// addend decides the rounding direction under a single fused rounding.
const uint ExpectedPkFma = 0x7A6B_7A6B;
const uint ExpectedPkFmaNeg = 0x7A6A_7A6A;
// Both lanes of the pinned fma are ~52560, far above 1.0, so the clamp modifier
// saturates each to 1.0 (0x3C00 in f16).
const uint ExpectedPkFmaClamp = 0x3C00_3C00;
unsafe
{
@@ -365,6 +370,7 @@ unsafe
("store after exec restore (offset 16)", words[4], expectedRestored),
("v_pk_fma_f16 fused rounds up at midpoint", words[5], ExpectedPkFma),
("v_pk_fma_f16 neg addend rounds down", words[6], ExpectedPkFmaNeg),
("v_pk_fma_f16 clamp saturates to 1.0", words[7], ExpectedPkFmaClamp),
};
var failures = 0;
foreach (var (name, actual, expected) in results)
@@ -57,6 +57,8 @@ const ulong ProgramAddress = 0x100000;
0xCC114006, 0x18020300, // v_pk_min_f16 v6, v0, v1
0xCC124007, 0x18020300, // v_pk_max_f16 v7, v0, v1
0xCC0E4408, 0x9C0A0300, // v_pk_fma_f16 v8, v0, v1, neg_lo:[0,0,1] neg_hi:[0,0,1] v2
0xCC0FC009, 0x18020000, // v_pk_add_f16 v9, v0, v0 clamp (2.5+2.5=5 -> saturates to 1.0)
0xCC0EC40A, 0x1C0A0300, // v_pk_fma_f16 v10, v0, v1, v2 clamp
0xBF810000, // s_endpgm
]),
("mrt", true, [
@@ -158,8 +160,10 @@ const ulong ProgramAddress = 0x100000;
0x7E1202FF, 0x04EA04EA, // v_mov_b32 v9, 0x04EA04EA (~7.496e-5 packed)
0xCC0E400A, 0x1C261107, // v_pk_fma_f16 v10, v7, v8, v9
0xCC0E440B, 0x9C261107, // v_pk_fma_f16 v11, v7, v8, neg_lo:[0,0,1] neg_hi:[0,0,1] v9
0xCC0EC00C, 0x1C261107, // v_pk_fma_f16 v12, v7, v8, v9 clamp (>=1 -> saturates to 1.0)
0xE0700014, 0x80020A00, // buffer_store_dword v10, off, s[8:11], 0 offset:20
0xE0700018, 0x80020B00, // buffer_store_dword v11, off, s[8:11], 0 offset:24
0xE070001C, 0x80020C00, // buffer_store_dword v12, off, s[8:11], 0 offset:28
0xBF810000, // s_endpgm
]),
];