Linux and macOS support (#47)

* [macos/linux] Cross-platform host memory, TLS, and ABI layer for POSIX

Introduces the foundation for running SharpEmu on macOS (osx-x64 under
Rosetta 2) and Linux (linux-x64). The CPU backend executes guest x86-64
code natively, so these targets run the whole process as x86-64; this
commit replaces the Windows-only host primitives with platform-dispatched
equivalents so the guest boots and services HLE calls off Windows.

Memory (HostMemory.cs, new): a Win32-semantics facade over
mmap/mprotect/munmap with a shadow region table answering VirtualQuery.
PhysicalVirtualMemory, DirectExecutionBackend, StubManager, and the two
Kernel*CompatExports now go through it instead of kernel32 P/Invokes.
Exact-address requests use MAP_FIXED_NOREPLACE (Linux) / guarded
MAP_FIXED (macOS) so they match Win32 "map there or fail" semantics.

TLS + host helpers (PosixHostStubs.cs, new): pthread-backed TLS and
Win64-ABI-compatible stubs for the kernel32 helpers the backend embeds
into emitted x86-64 code (TlsGetValue, QueryPerformanceCounter,
SwitchToThread, Sleep). A Win64->SysV thunk wraps managed callbacks,
since .NET on POSIX compiles them for the SysV ABI while the emitted
call sites use Win64.

Guest address layout: the 0x7FFx window is Windows-only (dyld shared
cache / Rosetta runtime live there on POSIX), so stack/TLS/stub regions
relocate to 0x6FFx off Windows.

Vectored exception handling is gated off on POSIX for now (guest faults
are not yet recovered) — the signal-based bridge is the next step. Also
adds osx-x64 to the RID list and a Docker-based Linux smoke-test script.

Status: on both macOS (Rosetta) and Linux (amd64), the guest now boots,
runs native x86-64 code, and dispatches HLE imports. macOS stops at a
Rosetta translation-cache issue; Linux runs ~252 imports through C++
static-init before hitting the missing fault handler (SIGSEGV).

* [posix] Bridge the vectored exception handler to sigaction(SIGSEGV/SIGBUS/SIGILL)

Guest faults on macOS/Linux previously terminated the process because the
recovery logic in DirectExecutionBackend.Exceptions.cs was Windows-only.
This adds a POSIX front-end that reuses the existing handler bodies:

- DirectExecutionBackend.PosixSignals.cs installs SA_SIGINFO handlers via
  an [UnmanagedCallersOnly] entry, rebuilds the Win64 EXCEPTION_POINTERS /
  CONTEXT view from the platform mcontext (Darwin __ss thread state via
  the mcontext pointer at ucontext+48, Linux glibc gregs at ucontext+40 --
  offsets verified against the headers on both platforms), runs the same
  chain as the VEH path (TryRecoverUnresolvedSentinel trap-sentinel
  recovery, TryHandleLazyCommittedPage demand paging, VectoredHandler
  diagnostics incl. FS/GS TLS-fault detection), and writes register
  changes back into the mcontext so sigreturn resumes the repaired guest.
  Unrecovered faults chain to the previously installed handler so the
  .NET runtime keeps mapping its own faults to managed exceptions.

- The whole recovery path is warmed up with fabricated inputs before the
  handlers are installed. This is required under Rosetta 2: the signal
  trampoline cannot enter x86 code that has never been executed (and so
  never translated) -- a cold handler is silently never invoked and the
  faulting instruction retries forever (reproduced and verified in an
  isolated .NET test under Rosetta for Linux). It also keeps first-fault
  JIT work out of the signal frame.

- Handlers run without SA_ONSTACK: the runtime's alternate stacks are too
  small for the diagnostic path, while guest (2MB) and host thread stacks
  match where Windows dispatches exceptions anyway.

- The raw reads in the shared fault diagnostics (stack qwords, RBP walk,
  code bytes at RIP) now probe the region table on POSIX before touching
  memory, since a nested SIGSEGV inside the handler would kill the
  process before diagnostics finish. Windows keeps its try/catch reads.

- Escape hatches: SHARPEMU_DISABLE_POSIX_SIGNALS=1 skips installation,
  SHARPEMU_DISABLE_RAW_HANDLER=1 disables sentinel recovery (parity with
  Windows), SHARPEMU_LOG_POSIX_SIGNALS=1 traces every delivery (first 16
  and every 1024th are always traced).

Verified with the test game: Linux (amd64 container) previously died with
SIGSEGV right after import #252; it now recovers/diagnoses signals and the
run proceeds to the real next blocker, an unpatched negative-offset guest
TLS read (fault at TLS base - 0x1708), which gets the full NATIVE
EXCEPTION dump before terminating. macOS is unchanged: the bridge installs
and the game still stops at the known Rosetta translation-cache error at
import 12, which is the next work item.

* [posix] Fix guest memory layout faults: TLS prefix, exact mmap, map search base

Three fixes that take the test game from dying during libc init to running
its full main loop on macOS and Linux:

- Static TLS blocks live below the TCB (FreeBSD amd64 variant II) and
  libc.prx reaches past -0x1700, but only a 4KB prefix was mapped below
  the TLS base. The prefix is now 64KB on POSIX (Windows keeps 4KB); the
  fault was a read at TLS base - 0x1708 during libc init.

- HostMemory exact allocation on macOS used MAP_FIXED, which silently
  maps over untracked host memory. The direct-memory allocator's address
  scan walked into the .NET runtime's JIT heap and replaced live code,
  which under Rosetta 2 surfaced as "no code fragment associated with
  the given arm pc". Exact placement now passes the address as a hint
  and fails on relocation, like MAP_FIXED_NOREPLACE does on Linux.

- sceKernelMapDirectMemory/MapFlexibleMemory searched for free space
  starting at 4GB, which is the Mach-O image base on macOS. The default
  search base is 0x20_0000_0000 on POSIX, and TryAllocateAtOrAbove now
  asks the kernel for a placement instead of page-stepping through host-
  owned address space (Rosetta ignores mmap hints for whole VA windows),
  over-allocating when the caller needs more than page alignment.

Windows behavior is unchanged; all divergences are platform-guarded.

* [macos] Video presenter on the main thread, MoltenVK support, window keyboard input

Gets the test game from a headless loop to a playable window on macOS:

- AppKit traps with SIGILL ("NSUpdateCycleInitialize() is called off the
  main thread") when GLFW runs on a worker thread. The CLI now moves
  emulation onto a worker thread on macOS and parks the real main thread
  in HostMainThread.Pump(); the presenter posts its whole window loop
  there instead of spawning a thread, and a shutdown handler asks the
  render loop to close the window so the pump unwinds on guest exit.

- MoltenVK: enable VK_KHR_portability_enumeration (+ the portability
  instance flag) and VK_KHR_portability_subset when advertised, and gate
  robustBufferAccess2 on the device actually supporting it (Metal does
  not; the old code keyed it off robustImageAccess2 and vkCreateDevice
  failed with ErrorFeatureNotPresent).

- Input: pad exports polled user32 GetAsyncKeyState, so POSIX hosts threw
  DllNotFoundException per scePadReadState call. The presenter now
  attaches the window's keyboard via Silk.NET.Input into HostWindowInput,
  and the pad exports map the existing VK-code layout onto it off
  Windows. Headless hosts (Linux containers) report a disconnected
  keyboard and fall back to neutral pad data silently.

GLFW needs an x86-64 Vulkan loader under Rosetta: place a universal
libMoltenVK.dylib next to SharpEmu named libvulkan.1.dylib (Homebrew's
arm64-only copy cannot load into the x86-64 process) and export
DYLD_LIBRARY_PATH to that directory.

Verified: Dreaming Sarah boots to a MoltenVK-backed 2560x1440 window on
macOS (Apple M4, Rosetta 2), renders the intro, title, and menus, and
keyboard input drives it into gameplay. Linux (amd64 container) runs the
same build headless through millions of imports with no faults. Windows
paths unchanged; arm64 and x64 builds clean.

* [posix] CoreAudio playback, self-contained MoltenVK loading, input/log polish

- Audio: sceAudioOut ports now play through an AudioQueue backend on macOS
  (stereo PCM16 with the same 32KB backpressure pacing as the WinMM path).
  The WinMM port and the new CoreAudio port share an IHostAudioPort
  interface and sample converter; hosts without a backend (Linux
  containers) keep the silent fallback.

- MoltenVK: GLFW resolves Vulkan with dlopen("libvulkan.1.dylib"), which
  cannot see the app-local universal MoltenVK build, so the presenter now
  feeds vkGetInstanceProcAddr straight into glfwInitVulkanLoader (GLFW
  3.4) before creating the window. No DYLD_LIBRARY_PATH needed; the CLI
  also preloads the dylib for Silk.NET and prints setup hints when it is
  missing. scripts/fetch-macos-moltenvk.sh stages the official universal
  dylib next to a build.

- The virtual-range allocator's failure trace now names the address and
  length instead of "AllocateAt invocation threw".

Investigated and documented (not port defects): the savedata transaction
failure is identical on Linux and macOS (HLE argument-register mapping for
sceSaveDataCreateTransactionResource), and the in-game tile speckling has
no platform-specific code in its path - the one macOS-only delta is that
MoltenVK lacks robustBufferAccess2, so out-of-bounds shader reads return
garbage instead of zeros.

Verified on macOS: window, audio backend, and keyboard input all come up
with zero environment configuration; the game runs to gameplay. Linux
headless run unchanged (silent audio, no faults). Windows paths untouched;
arm64 and x64 builds clean.

* [cpu] Preserve guest registers and flags across patched TLS accesses

The TLS patch handler replaces guest `mov reg, fs:[...]` instructions,
which preserve every other register and the flags - but the handler
loaded the TLS index into ecx and called TlsGetValue (Win64: clobbers
rcx/rdx/r8-r11) with `sub/add rsp` trashing the arithmetic flags. Guest
code that keeps live values or comparison results across a TLS access
computed garbage deterministically. The handler now saves rcx, rdx,
r8-r11, and the flags around the call, keeping the same inner stack
alignment. This applies to the load patches and both store-helper stubs,
on every platform.

Also in this change, from the rendering-artifact investigation:

- The present blit picks linear filtering for any fractional scale
  (nearest only for integer upscales): a 3840x2160 guest frame blitted
  into a 2560x1440 swapchain with nearest silently dropped every third
  row/column.
- ClampViewport no longer trims the guest viewport rectangle to the
  render target; trimming changed the guest's scale/offset and skewed
  texel addressing. Vulkan permits viewports beyond the framebuffer
  (the scissor confines rendering), so only spec bounds are enforced.
- Env-gated diagnostics grown during the investigation: guest texture
  dumps (SHARPEMU_TEXTURE_DUMP_DIR), aliased guest-image readback dumps
  (SHARPEMU_TRACE_GUEST_IMAGES=alias), small-render-target write movies
  (SHARPEMU_TRACE_GUEST_WRITES=small), unattended input injection
  (SHARPEMU_AUTO_CROSS=secs,...), viewport nudging
  (SHARPEMU_VIEWPORT_EPSILON), chunked-draw toggle
  (SHARPEMU_DISABLE_CHUNKED_DRAWS), and rect-list/draw vertex traces.

Known remaining issue (root cause narrowed, not yet fixed): the game's
terrain texture pages are corrupted in guest memory before any GPU work
- the mound's solid-fill 32x32 tiles decode to fully transparent texels
and the grass page has deterministic gaps, byte-identical across runs.
Ruled out: memcpy/memmove/memset/realloc HLE semantics, sampler wrap
modes, texel-boundary rounding, chunked draws, viewport handling. Next
step is auditing the Chowdren asset decode path (custom compressed
images) against the emulator's import surface.

* [linux] ALSA playback backend for sceAudioOut

sceAudioOut ports on Linux now play through libasound instead of the
silent fallback. The PCM device opens in blocking mode with ~170ms of
device buffer (the time-equivalent of the 32KB queue the WinMM and
CoreAudio ports keep), so snd_pcm_writei provides the same backpressure
pacing without a managed queue. Underruns and suspend/resume go through
snd_pcm_recover with one retry per submit; anything else drops the
buffer rather than stalling the guest.

The "default" device routes through PulseAudio/PipeWire on desktops
and straight to hardware on bare ALSA; SHARPEMU_ALSA_DEVICE overrides
it (the null device makes the path testable in containers). A missing
libasound or device fails port creation and lands in the existing
silent fallback.

Verified in an amd64 container: the test game opens the port
(backend=alsa, 48kHz stereo float32) and streams sceAudioOutOutput
through the null device for a full run; without a usable device the
port logs a warning and falls back to silent. Playback on real Linux
audio hardware has not been tested.

* [fixes] Address review feedback: commit bounds, CoreAudio shutdown, dump errors

- HostMemory: a MEM_COMMIT that runs past its reservation now fails like
  Win32 instead of committing a prefix and reporting success. All current
  callers already clamp their ranges to the region, so this only guards
  future callers.

- CoreAudioPort: Dispose wakes a submitter waiting on backpressure and
  the wait treats ObjectDisposedException as a timed-out wait, so closing
  a port during playback can no longer throw. A failed AudioQueueStart
  tears the queue down and fails fast instead of leaving an undrainable
  queue that stalls every later submit on its timeout.

- AgcExports: texture dumping catches all write failures (bad path,
  permissions), logging a warning instead of crashing when
  SHARPEMU_TEXTURE_DUMP_DIR points somewhere unusable.

Verified with the Linux container run: game boots and streams audio with
the stricter commit check, and a dump dir under /proc produces warnings
instead of taking the process down.

* [ci] Build linux-x64 and osx-x64 archives

Adds a build-posix matrix job (ubuntu-latest / macos-latest) mirroring
the Windows build: locked restore, Release build, self-contained CLI
publish, and a tar.gz artifact per RID (tar keeps the executable bit).
The macOS archive also stages the universal MoltenVK dylib via
scripts/fetch-macos-moltenvk.sh so the artifact runs without any manual
Vulkan setup. The release job still only ships the Windows archive.

* [cli] Keep POSIX glfw natives outside the single-file bundle

The KeepGlfwOutsideSingleFile target only matched filenames starting
with 'glfw', which covers Windows (glfw3.dll) but not libglfw.3.dylib /
libglfw.so.3. Those got embedded into the single-file bundle, and
Silk.NET's library loader does not probe the bundle extraction
directory, so a published build died with "Couldn't find a suitable
window platform" (and the glfwInitVulkanLoader wiring, which loads the
library from AppContext.BaseDirectory, could not run either). Keeping
the POSIX names loose next to the executable fixes both, the same way
the Windows build already handled it.

Found by running the CI-built osx-x64 archive: video failed while local
loose-file builds worked. With the fix the published single-file build
opens the MoltenVK window, wires the loader, and reaches gameplay.

* [ci] Publish linux-x64 and osx-x64 release archives

The build-posix artifacts now ship as per-RID GitHub releases on main
pushes and manual dispatches, tagged the same way as the win64 ones
(<rid>-<ref>-<sha>). Archives stay tar.gz so the executable bit
survives extraction.

* [cli] Fail early on non-x86-64 host processes

The CPU backend executes guest x86-64 code natively, so the process
must be x86-64 (win-x64/linux-x64 on x64 hardware, osx-x64 under
Rosetta 2 on Apple Silicon). An arm64 process previously failed deep
inside emulation startup, indistinguishable from MoltenVK, signal
handler, or guest memory problems. CLI mode now checks the process
architecture up front and exits with a message naming the supported
execution model (and the Rosetta install command on macOS). The
GUI-only path stays usable on arm64.

* [video] Log the selected Vulkan device name and API version

The presenter never named the GPU it picked, so a 'no video' report
could not be told apart from a real windowing failure without guessing.
It now logs the device name, type, and API version right after
selection. A software rasterizer (llvmpipe/lavapipe/SwiftShader) shows
up here and typically lacks the device features the translated shaders
need, which is the likely cause when a window opens and presents frames
but nothing draws.

* [video] Steer GLFW to XWayland on Wayland sessions

GLFW's native Wayland backend does not reliably map the Vulkan window
with some drivers (NVIDIA in particular): frames present but the window
never becomes visible, so the game runs with audio and no picture. A
report on an RTX 5080 showed exactly this — all device features present,
frames presenting, but the log had 'libdecor-gtk.so failed to init' and
a 1.4x-scaled window, both Wayland tells.

On a Wayland session that also exposes an X server (DISPLAY set), the
presenter now clears WAYLAND_DISPLAY for its own process before GLFW
initializes, so GLFW selects its dependable X11/XWayland backend.
SHARPEMU_ENABLE_WAYLAND=1 opts back into native Wayland. Headless
(no DISPLAY) and non-Linux hosts are unaffected.

* [video] Force GLFW X11 backend via the platform init hint, log the platform

The previous attempt cleared WAYLAND_DISPLAY to steer GLFW off Wayland,
but a reporter still hit the native-Wayland path (the Wayland-only
libdecor error persisted), so that env trick doesn't switch GLFW.

Use GLFW's supported mechanism instead: glfwInitHint(GLFW_PLATFORM,
GLFW_PLATFORM_X11) before GLFW initializes, called into the same libglfw
GLFW itself loads (the pattern InitializeMacVulkanLoader already uses).
Still gated on a Wayland session with an X server present (DISPLAY set)
so we never force X11 where XWayland can't catch it, and still
overridable with SHARPEMU_ENABLE_WAYLAND=1.

Also logs 'GLFW windowing platform in use: <backend>' after init via
glfwGetPlatform, so a 'no window' report shows X11 vs Wayland outright.
Verified on macOS: the readback correctly reports Cocoa and the
presenter is unaffected (the fix is a no-op off Linux).

* [video] Run the GLFW window on the main thread on Linux too

GLFW requires window creation and event processing on the process main
thread on every platform: initialization, window creation, and
glfwPollEvents are main-thread-only, and X11 in particular has a single
event queue that must be serviced there. A window created and polled on
another thread may never map — which is why the game ran (audio, imports,
even Vulkan present) with no visible window on Linux.

macOS already routed the window loop to the main-thread pump (AppKit
needs it); Windows is fine because it has a per-thread event queue. Linux
was the gap: it spawned a background thread for the presenter. Extend the
existing HostMainThread pattern to Linux — emulation runs on a worker,
the main thread pumps the window work the presenter posts.

Refs GLFW intro guide (thread-safety): init, window creation, and event
processing are restricted to the main thread.

Verified: macOS still boots to its window unchanged; the Linux headless
container runs to millions of imports with no deadlock or regression.
On-screen confirmation on a real Linux desktop is still pending, but this
is the documented root cause for a windowless-but-running Linux session.

* [posix] Skip Win32 native guest workers

* [vulkan] Synchronize offscreen targets before present

* [vulkan] Transition fresh textures from undefined layout

* [vulkan] Report swapchain pixels before source readback

* [vulkan] Emit requested guest image diagnostics

* [agc] Diagnose guest texture fallbacks

* [linux] Keep guest GPU mappings in low address space

* [video] Reduce diagnostic stalls and drain complete frames

* [memory] Harden packed GPU address handling

* [readme] Document Linux and macOS release support

* [posix] Integrate the host platform abstraction

* [posix] Restore guest thread address window

* [video] Run the performance HUD on POSIX hosts

The FPS/CPU/work HUD bailed out unless the host was Windows; only the
per-thread hottest-thread scan actually needs Windows APIs. Keep that
scan Windows-only (POSIX reports 'idle') and let the rest of the HUD
run everywhere — the title is already set from the render thread, which
owns the window on macOS and Linux.

* [posix] Implement native guest worker threads

Guest entry stubs must not run above CLR-managed frames on CLR-created
threads (see the NativeWorker preamble); the PR previously fell back to
the inline calli path on POSIX, which reproduced the documented
'attempted to call a UnmanagedCallersOnly method from managed code'
fail-fast (observed after Dreaming Sarah's menu select) and left the
runtime's suspension machinery walking guest frames.

Provide the missing POSIX half of the worker loop:
- PosixHostStubs grows Win64-convention WaitForSingleObject/SetEvent/
  ExitThread stubs backed by dispatch semaphores (macOS) / unnamed POSIX
  semaphores (Linux) plus pthread_exit, with EINTR retry in the wait.
- Worker events are creatable/signalable/waitable from managed code too,
  so NativeGuestExecutor.Run keeps its handshake (AutoResetEvent stays
  on Windows byte-for-byte).
- PosixHostThreading implements CreateNativeThread/WaitForThreadExit/
  CloseThreadHandle over pthreads (liveness probed with
  pthread_kill(0), then joined).
- RunPrologue/RunEpilogue are routed through the existing Win64->SysV
  thunks, so the emitted loop stays identical across platforms.

* [macos] Disable concurrent GC under Rosetta's write-watch hazard

Background GC's write-watch revisit (SoftwareWriteWatch::GetDirty ->
FlushProcessWriteBuffers) calls thread_get_register_pointer_values on
every thread; under Rosetta 2 that Mach call stalls indefinitely on
threads executing translated guest code. The background mark phase then
never finishes and every allocating or Monitor-taking thread wedges
behind it — observed as Dreaming Sarah freezing at the menu/loading
screen with FPS 0 in 5 of 7 runs, dispatcher/watchdog parked in
Monitor.Enter and all BGC threads waiting in t_join.

Non-concurrent GC never takes that path; a 5-minute soak now holds
22-31 fps in-game with zero stalls. Windows and Linux keep concurrent
GC.

* [diag] Periodic guest-thread snapshots with gate-owner tracking

SHARPEMU_PERIODIC_SNAPSHOT_SECONDS=N dumps the stall snapshot every N
seconds even while imports are progressing, for soft stalls where the
game stops advancing but threads keep spinning. The periodic dump never
touches the guest-thread gate (it must keep reporting when the gate is
what's wedged): it reads a lock-free owner record — every gate
acquisition now goes through LockGate(site), which notes site/thread —
and walks the thread table without the lock, tolerating torn reads.
SHARPEMU_PERIODIC_SNAPSHOT_FILE redirects the dump to a side file for
the case where the console itself is wedged (frozen log mirror was one
of the observed failure modes).

* [nuget] Add osx-x64 RID targets to lock files

* [cpu] Back off the guest join poll

TryJoinThread polled the host thread at a fixed 1ms; a game main thread
joining a long-lived worker (Dreaming Sarah parks there for the whole
session) burned ~5% of managed CPU in Join/Sleep syscalls. Ramp the
poll interval to 10ms once the join is clearly long-lived — exit
detection latency for long joins moves from ~1ms to at most 10ms, and
short-lived joins still resolve on the first 1ms polls.

* [nuget] Add linux-x64/win-x64 RID targets to lock files

* [posix] Keep guest stacks clear of the import-stub descent

The import-stub region descends from 0x7000_0000_0000 on the same 16MB
grid as the guest thread windows; moving stacks to 0x6FFF_E000_0000 put
them inside the stub region's 64-module descent range (floor
0x6FFF_C000_0000), silently consuming the top ~32 stack slots on hosts
with many loaded modules. Drop the POSIX stack base to 0x6FFF_A000_0000:
512MB below the stub floor, still 2.5GB above the TLS window. Windows
keeps 0x7FFF_E000_0000 (its bands are ~15TB apart).

* [pad] Read window gamepads on POSIX hosts

XInput and the DualSense hid reader are Windows-only, which left
macOS/Linux with keyboard input only. The presenter's Silk/GLFW input
context already enumerates gamepads on both platforms, so track their
state in HostWindowInput (event-driven on the window thread, snapshot
guarded like the key set) translated to ORBIS conventions: GLFW's Xbox
layout maps A/B/X/Y to Cross/Circle/Square/Triangle, sticks bias from
-1..1 to 0..255 with Y growing down, and triggers rescale from GLFW's
-1..1 resting-at--1 range with digital L2/R2 bits past 25%.

The merge into ReadHostInputState is gated to non-Windows so a physical
pad is never sampled twice through both a native reader and GLFW.
Hotplug is handled via ConnectionChanged; with no pad connected the
path is inert.

Untested against a physical controller (none attached to the dev host);
axis conventions follow the GLFW gamepad-mapping contract.

* [nuget] Refresh lock files after cross-RID restores

* [posix] Adopt the host audio/input seams from main

Main's #192 abstracted audio output and pad/keyboard input behind
IHostAudioOutput/IHostInput; re-express the POSIX backends behind them:

- CoreAudioPort/AlsaAudioPort move to Host/Posix as
  PosixCoreAudioStream/PosixAlsaAudioStream implementing
  IHostAudioStream. The seam converts to stereo PCM16 before Submit, so
  the ports' own conversion (and IHostAudioPort/AudioSampleConverter)
  is gone; queueing and backpressure are unchanged.
- PosixHostAudio selects CoreAudio (macOS) / ALSA (Linux) as the
  platform's IHostAudioOutput.
- PosixHostInput implements IHostInput over an
  IPosixWindowInputSource that HostWindowInput registers when the
  presenter attaches the window's GLFW input context: keyboard with
  virtual-key translation, the window gamepad snapshot (now in seam
  HostGamepadState/HostGamepadButtons terms), and keyboard-connected as
  the focus signal. Rumble/lightbar no-op (GLFW has no such API).
- PadExports drops its direct HostWindowInput gamepad merge — pads now
  flow through IHostInput.GetGamepadStates like every platform.
- PosixHostThreading.RequestTimerResolution is a documented no-op.

All three RIDs build; SharpEmu.Libs.Tests pass (26/26).

* [nuget] Regenerate GUI lock file for RID-less locked restore

Local cross-RID builds stamped a win-x64 runtimes section into
SharpEmu.GUI's lock file; the project declares no RuntimeIdentifiers,
so CI's 'dotnet restore --locked-mode' failed with NU1004 on every
platform. Regenerated via a plain solution restore (--force-evaluate),
matching what the workflow validates.
This commit is contained in:
kuba
2026-07-15 14:36:20 +02:00
committed by GitHub
parent 7b86a91dfa
commit fa2616d224
38 changed files with 4289 additions and 176 deletions
@@ -4,7 +4,9 @@
using Silk.NET.Core;
using Silk.NET.Core.Native;
using Silk.NET.Maths;
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using Silk.NET.Input;
using Silk.NET.Vulkan;
using Silk.NET.Vulkan.Extensions.KHR;
using Silk.NET.Vulkan.Extensions.EXT;
@@ -164,7 +166,10 @@ internal static unsafe class VulkanVideoPresenter
private const uint DefaultWindowWidth = 1280;
private const uint DefaultWindowHeight = 720;
private const int MaxPendingGuestWork = 16;
private const int MaxGuestWorkPerRender = 16;
// A single guest frame commonly contains 30-50 translated draws. Limiting
// this to 16 split one frame across several 60 Hz window callbacks and
// unnecessarily throttled the producer behind the bounded work queue.
private const int MaxGuestWorkPerRender = 128;
private const uint GuestPrimitiveRectList = 0x11;
private const uint GuestFormatR32Uint = 0x10004;
private const uint GuestFormatR32Sint = 0x20004;
@@ -189,8 +194,11 @@ internal static unsafe class VulkanVideoPresenter
private static uint _windowWidth;
private static uint _windowHeight;
private static bool _closed;
private static bool _presenterCloseRequested;
private const string DebugUtilsExtensionName = "VK_EXT_debug_utils";
private const uint NvidiaVendorId = 0x10DE;
private const string PortabilityEnumerationExtensionName = "VK_KHR_portability_enumeration";
private const string PortabilitySubsetExtensionName = "VK_KHR_portability_subset";
private static bool _splashHidden;
private static long _enqueuedGuestWorkSequence;
private static long _completedGuestWorkSequence;
@@ -202,6 +210,29 @@ internal static unsafe class VulkanVideoPresenter
string.Equals(mode, "present", StringComparison.OrdinalIgnoreCase);
}
private static bool ShouldTraceGuestImageSubmissionsForDiagnostics()
{
return string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES"),
"1",
StringComparison.Ordinal);
}
private static bool ShouldSamplePresentedGuestImageForDiagnostics(long frame)
{
var mode = Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES");
if (string.Equals(mode, "present", StringComparison.OrdinalIgnoreCase))
{
// A 4K Vulkan readback is deliberately synchronous and can take
// several seconds on Linux. The lightweight "present" mode only
// needs one proof that the final image is non-black.
return frame == 1;
}
return string.Equals(mode, "1", StringComparison.Ordinal) &&
(frame is 1 or 30 or 120 || frame % 600 == 0);
}
public static void EnsureStarted(uint width, uint height)
{
if (width == 0 || height == 0)
@@ -259,12 +290,7 @@ internal static unsafe class VulkanVideoPresenter
TranslatedDraw: null,
RequiredGuestWorkSequence: _enqueuedGuestWorkSequence,
IsSplash: false);
_thread = new Thread(Run)
{
IsBackground = true,
Name = "SharpEmu Vulkan VideoOut",
};
_thread.Start();
StartPresenterLocked();
}
}
@@ -300,7 +326,7 @@ internal static unsafe class VulkanVideoPresenter
return;
}
if (ShouldTracePresentedGuestImageContentsForDiagnostics())
if (ShouldTraceGuestImageSubmissionsForDiagnostics())
{
Console.Error.WriteLine($"[LOADER][TRACE] vk.submit_call kind=Submit {width}x{height}");
}
@@ -330,12 +356,7 @@ internal static unsafe class VulkanVideoPresenter
_windowWidth = width;
_windowHeight = height;
_thread = new Thread(Run)
{
IsBackground = true,
Name = "SharpEmu Vulkan VideoOut",
};
_thread.Start();
StartPresenterLocked();
}
}
@@ -346,7 +367,7 @@ internal static unsafe class VulkanVideoPresenter
return;
}
if (ShouldTracePresentedGuestImageContentsForDiagnostics())
if (ShouldTraceGuestImageSubmissionsForDiagnostics())
{
Console.Error.WriteLine($"[LOADER][TRACE] vk.submit_call kind=SubmitGuestDraw({drawKind}) {width}x{height}");
}
@@ -380,12 +401,7 @@ internal static unsafe class VulkanVideoPresenter
_windowWidth = width;
_windowHeight = height;
_thread = new Thread(Run)
{
IsBackground = true,
Name = "SharpEmu Vulkan VideoOut",
};
_thread.Start();
StartPresenterLocked();
}
}
@@ -409,7 +425,7 @@ internal static unsafe class VulkanVideoPresenter
return;
}
if (ShouldTracePresentedGuestImageContentsForDiagnostics())
if (ShouldTraceGuestImageSubmissionsForDiagnostics())
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.submit_call kind=SubmitTranslatedDraw {width}x{height} textures={textures.Count}");
@@ -451,12 +467,7 @@ internal static unsafe class VulkanVideoPresenter
_windowWidth = width;
_windowHeight = height;
_thread = new Thread(Run)
{
IsBackground = true,
Name = "SharpEmu Vulkan VideoOut",
};
_thread.Start();
StartPresenterLocked();
}
}
@@ -522,7 +533,7 @@ internal static unsafe class VulkanVideoPresenter
return;
}
if (ShouldTracePresentedGuestImageContentsForDiagnostics())
if (ShouldTraceGuestImageSubmissionsForDiagnostics())
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.submit_call kind=SubmitOffscreenTranslatedDraw " +
@@ -671,7 +682,7 @@ internal static unsafe class VulkanVideoPresenter
{
// VideoOut registration does not imply a rendered Vulkan image.
var known = _gpuGuestImages.ContainsKey(address);
if (ShouldTracePresentedGuestImageContentsForDiagnostics())
if (ShouldTraceGuestImageSubmissionsForDiagnostics())
{
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.submit_call kind=TrySubmitGuestImage addr=0x{address:X16} " +
@@ -708,7 +719,9 @@ internal static unsafe class VulkanVideoPresenter
sequence,
GuestDrawKind.None,
TranslatedDraw: null,
RequiredGuestWorkSequence: 0,
// A flip targets the image produced by all work already queued
// for this frame. Do not expose it until those draws finish.
RequiredGuestWorkSequence: _enqueuedGuestWorkSequence,
IsSplash: false,
GuestImageAddress: address);
System.Threading.Monitor.PulseAll(_gate);
@@ -964,6 +977,200 @@ internal static unsafe class VulkanVideoPresenter
return pixels;
}
private static void StartPresenterLocked()
{
if (HostMainThread.IsAvailable)
{
// GLFW windowing must run on the process main thread (AppKit on
// macOS, X11's single event queue on Linux), so hand the whole
// window loop to the main-thread pump the CLI parked for us.
// _thread only marks the presenter as running; Run() clears it on
// exit either way.
_thread = Thread.CurrentThread;
HostMainThread.SetShutdownRequestHandler(RequestClose);
HostMainThread.Post(Run);
return;
}
_thread = new Thread(Run)
{
IsBackground = true,
Name = "SharpEmu Vulkan VideoOut",
};
_thread.Start();
}
/// <summary>
/// Asks a running presenter to close its window; used at emulator
/// shutdown so a main-thread-hosted window loop returns to the pump.
/// </summary>
public static void RequestClose()
{
Volatile.Write(ref _presenterCloseRequested, true);
}
/// <summary>
/// GLFW resolves Vulkan with dlopen("libvulkan.1.dylib"), which cannot
/// find the app-local MoltenVK on macOS (Homebrew's Vulkan libraries are
/// arm64-only and this is an x86-64 process). GLFW 3.4 accepts the
/// loader entry point directly instead, so hand it MoltenVK's
/// vkGetInstanceProcAddr before any window exists.
/// </summary>
private static unsafe void InitializeMacVulkanLoader()
{
if (!OperatingSystem.IsMacOS())
{
return;
}
try
{
nint vulkan = 0;
foreach (var candidate in new[]
{
Path.Combine(AppContext.BaseDirectory, "libvulkan.1.dylib"),
Path.Combine(AppContext.BaseDirectory, "libMoltenVK.dylib"),
"libvulkan.1.dylib",
"libMoltenVK.dylib",
})
{
if (System.Runtime.InteropServices.NativeLibrary.TryLoad(candidate, out vulkan))
{
break;
}
}
if (vulkan == 0 ||
!System.Runtime.InteropServices.NativeLibrary.TryGetExport(
vulkan, "vkGetInstanceProcAddr", out var procAddr))
{
Console.Error.WriteLine(
"[LOADER][WARN] No Vulkan loader for GLFW; place a universal libMoltenVK.dylib " +
"next to SharpEmu as libvulkan.1.dylib.");
return;
}
var glfw = System.Runtime.InteropServices.NativeLibrary.Load(
Path.Combine(AppContext.BaseDirectory, "libglfw.3.dylib"));
var initVulkanLoader = (delegate* unmanaged<nint, void>)
System.Runtime.InteropServices.NativeLibrary.GetExport(glfw, "glfwInitVulkanLoader");
initVulkanLoader(procAddr);
Console.Error.WriteLine("[LOADER][INFO] GLFW Vulkan loader wired to MoltenVK.");
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][WARN] GLFW Vulkan loader setup failed: {exception.Message}");
}
}
// GLFW platform enum (GLFW 3.4): glfwInitHint(GLFW_PLATFORM, ...) selects a
// backend, glfwGetPlatform() reports the one in use.
private const int GlfwPlatformHint = 0x00050003;
private const int GlfwPlatformWin32 = 0x00060001;
private const int GlfwPlatformCocoa = 0x00060002;
private const int GlfwPlatformWayland = 0x00060003;
private const int GlfwPlatformX11 = 0x00060004;
private const int GlfwPlatformNull = 0x00060005;
/// <summary>
/// GLFW's native Wayland backend does not reliably map the Vulkan window
/// with some drivers (notably NVIDIA): the surface presents frames but the
/// window never becomes visible, so the game runs with no picture while
/// audio works. XWayland is dependable, so on a Wayland session that also
/// exposes an X server (DISPLAY set) we force GLFW's X11 backend through
/// its GLFW_PLATFORM init hint before GLFW initializes — the supported way
/// to pick a backend, applied by calling into the same libglfw GLFW loads.
/// Opt back into native Wayland with SHARPEMU_ENABLE_WAYLAND=1.
/// </summary>
private static unsafe void PreferX11OnLinuxWayland()
{
if (!OperatingSystem.IsLinux() ||
string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_ENABLE_WAYLAND"),
"1",
StringComparison.Ordinal))
{
return;
}
// Only steer on a Wayland session (WAYLAND_DISPLAY set). Forcing X11
// needs an X server to fall back to (XWayland, DISPLAY set); without
// one, forcing it would make glfwInit fail outright, so leave GLFW
// alone and say why the window may not appear.
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY")))
{
return;
}
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY")))
{
Console.Error.WriteLine(
"[LOADER][WARN] Wayland session without an X server (DISPLAY unset); " +
"cannot steer GLFW to XWayland. If the window does not appear, install " +
"XWayland, or run natively with SHARPEMU_ENABLE_WAYLAND=1.");
return;
}
if (!TryLoadGlfw(out var glfw))
{
return;
}
try
{
var initHint = (delegate* unmanaged<int, int, void>)
System.Runtime.InteropServices.NativeLibrary.GetExport(glfw, "glfwInitHint");
initHint(GlfwPlatformHint, GlfwPlatformX11);
Console.Error.WriteLine(
"[LOADER][INFO] Wayland session detected; requested GLFW X11/XWayland " +
"backend (set SHARPEMU_ENABLE_WAYLAND=1 to force native Wayland).");
}
catch (Exception exception)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Could not set GLFW X11 platform hint: {exception.Message}");
}
}
/// <summary>Logs the backend GLFW actually selected, so a "no window"
/// report shows Wayland vs X11 at a glance.</summary>
private static unsafe void LogGlfwPlatformInUse()
{
if (OperatingSystem.IsWindows() || !TryLoadGlfw(out var glfw))
{
return;
}
try
{
var getPlatform = (delegate* unmanaged<int>)
System.Runtime.InteropServices.NativeLibrary.GetExport(glfw, "glfwGetPlatform");
var platform = getPlatform();
var label = platform switch
{
GlfwPlatformWin32 => "Win32",
GlfwPlatformCocoa => "Cocoa",
GlfwPlatformWayland => "Wayland",
GlfwPlatformX11 => "X11",
GlfwPlatformNull => "Null",
_ => $"0x{platform:X}",
};
Console.Error.WriteLine($"[LOADER][INFO] GLFW windowing platform in use: {label}");
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][WARN] Could not query GLFW platform: {exception.Message}");
}
}
private static bool TryLoadGlfw(out nint handle)
{
var name = OperatingSystem.IsMacOS() ? "libglfw.3.dylib" : "libglfw.so.3";
return System.Runtime.InteropServices.NativeLibrary.TryLoad(
Path.Combine(AppContext.BaseDirectory, name), out handle) ||
System.Runtime.InteropServices.NativeLibrary.TryLoad(name, out handle);
}
private static void Run()
{
uint width;
@@ -974,6 +1181,9 @@ internal static unsafe class VulkanVideoPresenter
height = _windowHeight == 0 ? _latestPresentation?.Height ?? 720 : _windowHeight;
}
InitializeMacVulkanLoader();
PreferX11OnLinuxWayland();
try
{
using var presenter = new Presenter(width, height);
@@ -1133,6 +1343,8 @@ internal static unsafe class VulkanVideoPresenter
private bool _swapchainRecreateDeferred;
private bool _tracedPresentedSwapchain;
private bool _swapchainReadbackPending;
private static int _guestImageDumpSequence;
private readonly System.Collections.Concurrent.ConcurrentQueue<GuestImageResource> _pendingAliasImageDumps = new();
private bool _deviceLost;
private bool _deviceLostLogged;
private int _directPresentationCount;
@@ -1330,6 +1542,20 @@ internal static unsafe class VulkanVideoPresenter
_window.SetWindowIcon(ref icon);
}
LogGlfwPlatformInUse();
if (!OperatingSystem.IsWindows())
{
try
{
Pad.HostWindowInput.Attach(_window.CreateInput());
Console.Error.WriteLine("[LOADER][INFO] Window keyboard input attached for pad emulation.");
}
catch (Exception exception)
{
Console.Error.WriteLine($"[LOADER][WARN] Window keyboard input unavailable: {exception.Message}");
}
}
WaitForRenderDocAttachIfRequested();
_vk = Vk.GetApi();
CreateInstance();
@@ -1542,8 +1768,10 @@ internal static unsafe class VulkanVideoPresenter
var extensions = _window.VkSurface!.GetRequiredExtensions(out var extensionCount);
byte* debugUtilsExtension = null;
byte* portabilityExtension = null;
var instanceCreateFlags = InstanceCreateFlags.None;
var enabledExtensionCount = (int)extensionCount;
var enabledExtensions = stackalloc byte*[(int)extensionCount + 1];
var enabledExtensions = stackalloc byte*[(int)extensionCount + 2];
for (var index = 0; index < (int)extensionCount; index++)
{
enabledExtensions[index] = extensions[index];
@@ -1555,6 +1783,15 @@ internal static unsafe class VulkanVideoPresenter
enabledExtensions[enabledExtensionCount++] = debugUtilsExtension;
}
if (IsInstanceExtensionAvailable(PortabilityEnumerationExtensionName))
{
// MoltenVK is a portability (non-conformant) implementation;
// without this flag + extension the loader hides it.
portabilityExtension = (byte*)SilkMarshal.StringToPtr(PortabilityEnumerationExtensionName);
enabledExtensions[enabledExtensionCount++] = portabilityExtension;
instanceCreateFlags |= InstanceCreateFlags.EnumeratePortabilityBitKhr;
}
if (enableValidation && IsInstanceLayerAvailable("VK_LAYER_KHRONOS_validation"))
{
validationLayerName = (byte*)SilkMarshal.StringToPtr("VK_LAYER_KHRONOS_validation");
@@ -1573,6 +1810,7 @@ internal static unsafe class VulkanVideoPresenter
var createInfo = new InstanceCreateInfo
{
SType = StructureType.InstanceCreateInfo,
Flags = instanceCreateFlags,
PApplicationInfo = &applicationInfo,
EnabledExtensionCount = (uint)enabledExtensionCount,
PpEnabledExtensionNames = enabledExtensions,
@@ -1601,6 +1839,10 @@ internal static unsafe class VulkanVideoPresenter
{
SilkMarshal.Free((nint)debugUtilsExtension);
}
if (portabilityExtension is not null)
{
SilkMarshal.Free((nint)portabilityExtension);
}
}
}
finally
@@ -1613,6 +1855,40 @@ internal static unsafe class VulkanVideoPresenter
}
}
private bool IsDeviceExtensionAvailable(string extensionName)
{
uint extensionCount = 0;
if (_vk.EnumerateDeviceExtensionProperties(_physicalDevice, (byte*)null, &extensionCount, null) != Result.Success ||
extensionCount == 0)
{
return false;
}
var properties = new ExtensionProperties[extensionCount];
fixed (ExtensionProperties* propertyPointer = properties)
{
if (_vk.EnumerateDeviceExtensionProperties(
_physicalDevice,
(byte*)null,
&extensionCount,
propertyPointer) != Result.Success)
{
return false;
}
var expected = Encoding.UTF8.GetBytes(extensionName);
for (var index = 0; index < extensionCount; index++)
{
if (Utf8NullTerminatedEquals(propertyPointer[index].ExtensionName, expected))
{
return true;
}
}
}
return false;
}
private bool IsInstanceLayerAvailable(string layerName)
{
uint layerCount = 0;
@@ -1748,8 +2024,12 @@ internal static unsafe class VulkanVideoPresenter
_vk.GetPhysicalDeviceProperties(_physicalDevice, out var selected);
_maxColorAttachments = selected.Limits.MaxColorAttachments;
var selectedName = SilkMarshal.PtrToString((nint)selected.DeviceName) ?? "unknown";
var apiMajor = (selected.ApiVersion >> 22) & 0x7F;
var apiMinor = (selected.ApiVersion >> 12) & 0x3FF;
var apiPatch = selected.ApiVersion & 0xFFF;
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan device: {selectedName} ({selected.DeviceType})");
$"[LOADER][INFO] Vulkan device: {selectedName} " +
$"(type={selected.DeviceType}, api={apiMajor}.{apiMinor}.{apiPatch})");
VideoOutExports.SetSelectedGpuName(selectedName);
_window.Title = VideoOutExports.GetWindowTitle();
}
@@ -1859,6 +2139,7 @@ internal static unsafe class VulkanVideoPresenter
};
_vk.GetPhysicalDeviceFeatures2(_physicalDevice, &featuresQuery);
var supportsMaintenance8 = maintenance8Features.Maintenance8;
var supportsRobustBufferAccess2 = robustness2Features.RobustBufferAccess2;
var supportsRobustImageAccess2 = robustness2Features.RobustImageAccess2;
var supportsNullDescriptor = robustness2Features.NullDescriptor;
var supportsRobustness2 = supportsRobustImageAccess2 || supportsNullDescriptor;
@@ -1879,9 +2160,10 @@ internal static unsafe class VulkanVideoPresenter
var swapchainExtension = (byte*)SilkMarshal.StringToPtr("VK_KHR_swapchain");
var maintenance8Extension = (byte*)SilkMarshal.StringToPtr("VK_KHR_maintenance8");
var robustness2Extension = (byte*)SilkMarshal.StringToPtr("VK_EXT_robustness2");
var portabilitySubsetExtension = (byte*)SilkMarshal.StringToPtr(PortabilitySubsetExtensionName);
try
{
var extensions = stackalloc byte*[3];
var extensions = stackalloc byte*[4];
var extensionCount = 0u;
extensions[extensionCount++] = swapchainExtension;
if (supportsMaintenance8)
@@ -1894,10 +2176,17 @@ internal static unsafe class VulkanVideoPresenter
extensions[extensionCount++] = robustness2Extension;
}
if (IsDeviceExtensionAvailable(PortabilitySubsetExtensionName))
{
// The spec requires enabling this when the (MoltenVK)
// device advertises it.
extensions[extensionCount++] = portabilitySubsetExtension;
}
maintenance8Features.Maintenance8 = supportsMaintenance8;
maintenance8Features.PNext = null;
robustness2Features.RobustBufferAccess2 =
supportsRobustImageAccess2 && supportedFeatures.RobustBufferAccess;
supportsRobustBufferAccess2 && supportedFeatures.RobustBufferAccess;
robustness2Features.RobustImageAccess2 = supportsRobustImageAccess2;
robustness2Features.NullDescriptor = supportsNullDescriptor;
robustness2Features.PNext = supportsMaintenance8 ? &maintenance8Features : null;
@@ -1926,6 +2215,7 @@ internal static unsafe class VulkanVideoPresenter
SilkMarshal.Free((nint)swapchainExtension);
SilkMarshal.Free((nint)maintenance8Extension);
SilkMarshal.Free((nint)robustness2Extension);
SilkMarshal.Free((nint)portabilitySubsetExtension);
}
_vk.GetDeviceQueue(_device, _queueFamilyIndex, 0, out _queue);
@@ -3371,6 +3661,17 @@ internal static unsafe class VulkanVideoPresenter
$"tile={texture.TileMode} format={vkFormat}");
}
if (string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES"),
"alias",
StringComparison.OrdinalIgnoreCase) &&
_tracedGuestImageContents.Add(guestImage.Address))
{
// Deferred: reading back here would clobber the command
// buffer mid-recording; drained after the next present.
_pendingAliasImageDumps.Enqueue(guestImage);
}
if (TryCreateCpuTextureRefreshResource(texture, guestImage, view, out var refresh))
{
return refresh;
@@ -4464,6 +4765,14 @@ internal static unsafe class VulkanVideoPresenter
checked((uint)(bottom - top)));
}
private static readonly float ViewportDebugEpsilon = float.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_VIEWPORT_EPSILON"),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture,
out var viewportEpsilon)
? viewportEpsilon
: 0f;
private static Viewport ClampViewport(VulkanGuestViewport? viewport, Extent2D extent)
{
if (viewport is not { } rect)
@@ -4471,21 +4780,26 @@ internal static unsafe class VulkanVideoPresenter
return new Viewport(0, 0, extent.Width, extent.Height, 0, 1);
}
var maxX = (float)extent.Width;
var maxY = (float)extent.Height;
var left = Math.Clamp(rect.X, 0f, maxX);
var right = Math.Clamp(rect.X + rect.Width, left, maxX);
var yOrigin = Math.Clamp(rect.Y, 0f, maxY);
var yEnd = Math.Clamp(rect.Y + rect.Height, 0f, maxY);
// 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
// scale and offset. That skews texel addressing on 1:1 draws -
// source rows get skipped or duplicated - which shredded the
// game's pre-composed tile surfaces. Only guard what the spec
// requires: a positive width and hardware viewport bounds.
const float bound = 32767f;
var x = Math.Clamp(rect.X, -bound, bound);
var y = Math.Clamp(rect.Y, -bound, bound);
var width = Math.Clamp(rect.Width, 1e-3f, bound);
var height = Math.Clamp(rect.Height, -bound, bound);
if (height == 0f)
{
height = extent.Height;
}
var minDepth = Math.Clamp(rect.MinDepth, 0f, 1f);
var maxDepth = Math.Clamp(rect.MaxDepth, minDepth, 1f);
return new Viewport(
left,
yOrigin,
right - left,
yEnd - yOrigin,
minDepth,
maxDepth);
return new Viewport(x, y, width, height, minDepth, maxDepth);
}
private static byte[] CreateFallbackTexturePixels(uint format, uint width, uint height, ulong expectedSize)
@@ -5075,10 +5389,12 @@ internal static unsafe class VulkanVideoPresenter
}
}
}
foreach (var target in targets)
{
if (ShouldTraceGuestImageWriteForDiagnostics(target.Address))
var traceSmallWrites =
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_WRITES") == "small" &&
target.Width <= 512 && target.Height <= 256;
if (ShouldTraceGuestImageWriteForDiagnostics(target.Address) || traceSmallWrites)
{
var writeCount = _tracedGuestWriteCounts.TryGetValue(
target.Address,
@@ -5086,7 +5402,7 @@ internal static unsafe class VulkanVideoPresenter
? previousCount + 1
: 1;
_tracedGuestWriteCounts[target.Address] = writeCount;
if (writeCount <= 3)
if (writeCount <= (traceSmallWrites ? 48 : 3))
{
_commandBuffer = _presentationCommandBuffer;
Check(
@@ -5599,7 +5915,7 @@ internal static unsafe class VulkanVideoPresenter
private void UpdatePerformanceHud()
{
if (!_performanceHudEnabled || !OperatingSystem.IsWindows())
if (!_performanceHudEnabled)
{
return;
}
@@ -5621,28 +5937,34 @@ internal static unsafe class VulkanVideoPresenter
var hottestThreadId = 0;
var hottestThreadCpuSeconds = 0.0;
foreach (ProcessThread thread in process.Threads)
// Per-thread CPU times and thread names come from Windows-only
// APIs; on POSIX the HUD reports process totals with an "idle"
// hottest-thread slot.
if (OperatingSystem.IsWindows())
{
using (thread)
foreach (ProcessThread thread in process.Threads)
{
try
using (thread)
{
var threadId = thread.Id;
var cpu = thread.TotalProcessorTime;
currentThreadIds.Add(threadId);
currentThreadCpu[threadId] = cpu;
if (_performanceHudThreadCpu.TryGetValue(threadId, out var previousCpu))
try
{
var deltaSeconds = Math.Max(0.0, (cpu - previousCpu).TotalSeconds);
if (deltaSeconds > hottestThreadCpuSeconds)
var threadId = thread.Id;
var cpu = thread.TotalProcessorTime;
currentThreadIds.Add(threadId);
currentThreadCpu[threadId] = cpu;
if (_performanceHudThreadCpu.TryGetValue(threadId, out var previousCpu))
{
hottestThreadCpuSeconds = deltaSeconds;
hottestThreadId = threadId;
var deltaSeconds = Math.Max(0.0, (cpu - previousCpu).TotalSeconds);
if (deltaSeconds > hottestThreadCpuSeconds)
{
hottestThreadCpuSeconds = deltaSeconds;
hottestThreadId = threadId;
}
}
}
}
catch (InvalidOperationException)
{
catch (InvalidOperationException)
{
}
}
}
}
@@ -5812,6 +6134,12 @@ internal static unsafe class VulkanVideoPresenter
private void Render(double _)
{
if (Volatile.Read(ref _presenterCloseRequested))
{
_window.Close();
return;
}
if (!_vulkanReady)
{
return;
@@ -5884,6 +6212,7 @@ internal static unsafe class VulkanVideoPresenter
TranslatedDrawResources? translatedResources = null;
GuestImageResource? presentedGuestImage = null;
var tracePresentedGuestImage = false;
if (presentation.GuestImageAddress != 0 &&
(!_guestImages.TryGetValue(
presentation.GuestImageAddress,
@@ -5895,13 +6224,13 @@ internal static unsafe class VulkanVideoPresenter
if (presentedGuestImage is not null)
{
_directPresentationCount++;
if (ShouldTracePresentedGuestImageContentsForDiagnostics() &&
_directPresentationCount is 1 or 30 or 120)
if (ShouldSamplePresentedGuestImageForDiagnostics(
_directPresentationCount))
{
tracePresentedGuestImage = true;
Console.Error.WriteLine(
$"[LOADER][TRACE] vk.present_sample frame={_directPresentationCount} " +
$"addr=0x{presentedGuestImage.Address:X16}");
TraceGuestImageContents(presentedGuestImage);
}
}
@@ -6089,6 +6418,19 @@ internal static unsafe class VulkanVideoPresenter
CompletePendingPresentation(wait: true);
TraceSwapchainReadback();
}
// Report the actual presented pixels before starting the larger
// source-image readback. If a guest draw wedges the GPU, the
// source probe can block in vkQueueWaitIdle; doing it first used
// to hide whether the swapchain itself was black and made the
// diagnostic run stop immediately after vk.present_sample.
if (tracePresentedGuestImage && presentedGuestImage is not null)
{
TraceGuestImageContents(presentedGuestImage);
}
while (_pendingAliasImageDumps.TryDequeue(out var aliasImage))
{
TraceGuestImageContents(aliasImage);
}
CollectCompletedGuestSubmissions(waitForOldest: false);
_imageInitialized[imageIndex] = true;
@@ -6132,7 +6474,8 @@ internal static unsafe class VulkanVideoPresenter
var bytesPerPixel = GetReadbackBytesPerPixel(image.Format);
if (bytesPerPixel == 0)
{
TraceVulkanShader(
Console.Error.WriteLine(
"[LOADER][TRACE] " +
$"vk.guest_image addr=0x{image.Address:X16} " +
$"format={image.Format} readback=unsupported");
return;
@@ -6267,7 +6610,8 @@ internal static unsafe class VulkanVideoPresenter
(int)bytesPerPixel);
var center = Convert.ToHexString(
bytes.Slice(centerOffset, (int)bytesPerPixel));
TraceVulkanShader(
Console.Error.WriteLine(
"[LOADER][TRACE] " +
$"vk.guest_image addr=0x{image.Address:X16} " +
$"size={image.Width}x{image.Height} format={image.Format} " +
$"nonzero_bytes={nonzeroBytes}/{byteCount} " +
@@ -6299,9 +6643,10 @@ internal static unsafe class VulkanVideoPresenter
}
Directory.CreateDirectory(directory);
var sequence = Interlocked.Increment(ref _guestImageDumpSequence);
var path = Path.Combine(
directory,
$"0x{image.Address:X16}-{image.Width}x{image.Height}-{image.Format}.rgba");
$"{sequence:D4}-0x{image.Address:X16}-{image.Width}x{image.Height}-{image.Format}.rgba");
File.WriteAllBytes(path, bytes.ToArray());
}
@@ -6387,8 +6732,14 @@ internal static unsafe class VulkanVideoPresenter
continue;
}
var hasPriorContents = texture.GuestImage is { } guestImage &&
(guestImage.Initialized || guestImage.InitialUploadPending);
// InitialUploadPending means this upload still has to perform
// the image's first layout transition. Treating it as prior
// contents records ShaderReadOnlyOptimal as oldLayout even
// though a freshly created image is still Undefined. Linux
// validation reports VUID-vkCmdDraw-None-09600 and NVIDIA
// samples the uninitialized (black) image in that case.
var hasPriorContents =
texture.GuestImage is { Initialized: true };
var toTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
@@ -6754,6 +7105,11 @@ internal static unsafe class VulkanVideoPresenter
}
var drawViewport = ClampViewport(resources.Viewport, extent);
if (ViewportDebugEpsilon != 0f)
{
drawViewport.X += ViewportDebugEpsilon;
drawViewport.Y += ViewportDebugEpsilon;
}
_vk.CmdSetViewport(_commandBuffer, 0, 1, &drawViewport);
if (resources.VertexBuffers.Length != 0)
{
@@ -6989,7 +7345,15 @@ internal static unsafe class VulkanVideoPresenter
var sourceToTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = AccessFlags.ShaderReadBit,
// An offscreen target is last written as a color attachment,
// then put in ShaderReadOnlyOptimal for later sampling. A
// layout-only handoff to ShaderRead does not make that write
// visible to this transfer when no shader sample occurs in
// between. NVIDIA's Linux driver exposed the resulting stale
// (usually black) image while Windows drivers happened to
// tolerate it. Include all preceding writes before blitting
// the image into the swapchain.
SrcAccessMask = AccessFlags.MemoryWriteBit | AccessFlags.ShaderReadBit,
DstAccessMask = AccessFlags.TransferReadBit,
OldLayout = ImageLayout.ShaderReadOnlyOptimal,
NewLayout = ImageLayout.TransferSrcOptimal,
@@ -7060,6 +7424,14 @@ internal static unsafe class VulkanVideoPresenter
1),
DstOffsets = destinationOffsets,
};
// Nearest keeps integer upscales pixel-crisp, but any fractional
// scale (e.g. a 3840x2160 guest frame into a 2560x1440 swapchain)
// must blend neighbours or it silently drops every Nth source
// row/column, which shreds 1-2px features in the guest frame.
var isIntegerUpscale =
source.Width != 0 && source.Height != 0 &&
_extent.Width >= source.Width && _extent.Height >= source.Height &&
_extent.Width % source.Width == 0 && _extent.Height % source.Height == 0;
_vk.CmdBlitImage(
_commandBuffer,
source.Image,
@@ -7068,7 +7440,7 @@ internal static unsafe class VulkanVideoPresenter
ImageLayout.TransferDstOptimal,
1,
&region,
Filter.Nearest);
isIntegerUpscale ? Filter.Nearest : Filter.Linear);
if (traceDestination)
{