From fa2616d2246aa5f98bde5d4988b8d0cbec62942a Mon Sep 17 00:00:00 2001 From: kuba Date: Wed, 15 Jul 2026 14:36:20 +0200 Subject: [PATCH] Linux and macOS support (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [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 (--). 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: ' 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. --- .github/workflows/workflow.yml | 104 +++ Directory.Packages.props | 3 +- README.md | 46 +- scripts/fetch-macos-moltenvk.sh | 37 + scripts/test-linux-docker.sh | 32 + src/SharpEmu.CLI/Program.cs | 115 +++ src/SharpEmu.CLI/SharpEmu.CLI.csproj | 16 +- src/SharpEmu.CLI/packages.lock.json | 81 +++ src/SharpEmu.Core/Cpu/CpuDispatcher.cs | 21 +- .../DirectExecutionBackend.Exceptions.cs | 102 ++- .../DirectExecutionBackend.NativeWorker.cs | 94 ++- .../DirectExecutionBackend.PosixSignals.cs | 374 ++++++++++ .../Cpu/Native/DirectExecutionBackend.cs | 242 ++++++- .../Cpu/Native/NullHostFaultHandling.cs | 49 ++ .../Cpu/Native/Windows/Win64ContextOffsets.cs | 1 + .../Memory/PhysicalVirtualMemory.cs | 87 +++ src/SharpEmu.Core/packages.lock.json | 28 + src/SharpEmu.HLE/Host/HostPlatform.cs | 10 +- .../Host/Posix/PosixAlsaAudioStream.cs | 173 +++++ .../Host/Posix/PosixCoreAudioStream.cs | 261 +++++++ src/SharpEmu.HLE/Host/Posix/PosixHostAudio.cs | 21 + src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs | 79 +++ .../Host/Posix/PosixHostMemory.cs | 508 ++++++++++++++ .../Host/Posix/PosixHostPlatform.cs | 17 + src/SharpEmu.HLE/Host/Posix/PosixHostStubs.cs | 657 ++++++++++++++++++ .../Host/Posix/PosixHostSymbolResolver.cs | 19 + .../Host/Posix/PosixHostThreading.cs | 57 ++ src/SharpEmu.HLE/HostMainThread.cs | 79 +++ src/SharpEmu.HLE/SharpEmu.HLE.csproj | 4 + src/SharpEmu.Libs/Agc/AgcExports.cs | 174 ++++- .../Kernel/KernelMemoryCompatExports.cs | 62 +- .../Kernel/KernelVirtualRangeAllocator.cs | 5 +- src/SharpEmu.Libs/Pad/HostWindowInput.cs | 275 ++++++++ src/SharpEmu.Libs/Pad/PadExports.cs | 50 ++ src/SharpEmu.Libs/SharpEmu.Libs.csproj | 1 + .../VideoOut/VulkanVideoPresenter.cs | 526 ++++++++++++-- src/SharpEmu.Libs/packages.lock.json | 27 + tests/SharpEmu.Libs.Tests/packages.lock.json | 28 + 38 files changed, 4289 insertions(+), 176 deletions(-) create mode 100755 scripts/fetch-macos-moltenvk.sh create mode 100755 scripts/test-linux-docker.sh create mode 100644 src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.PosixSignals.cs create mode 100644 src/SharpEmu.Core/Cpu/Native/NullHostFaultHandling.cs create mode 100644 src/SharpEmu.HLE/Host/Posix/PosixAlsaAudioStream.cs create mode 100644 src/SharpEmu.HLE/Host/Posix/PosixCoreAudioStream.cs create mode 100644 src/SharpEmu.HLE/Host/Posix/PosixHostAudio.cs create mode 100644 src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs create mode 100644 src/SharpEmu.HLE/Host/Posix/PosixHostMemory.cs create mode 100644 src/SharpEmu.HLE/Host/Posix/PosixHostPlatform.cs create mode 100644 src/SharpEmu.HLE/Host/Posix/PosixHostStubs.cs create mode 100644 src/SharpEmu.HLE/Host/Posix/PosixHostSymbolResolver.cs create mode 100644 src/SharpEmu.HLE/Host/Posix/PosixHostThreading.cs create mode 100644 src/SharpEmu.HLE/HostMainThread.cs create mode 100644 src/SharpEmu.Libs/Pad/HostWindowInput.cs diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 4eb7b47..a98cb18 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -38,6 +38,7 @@ jobs: artifact-name: ${{ steps.vars.outputs.artifact-name }} release-name: ${{ steps.vars.outputs.release-name }} release-tag: ${{ steps.vars.outputs.release-tag }} + safe-ref: ${{ steps.vars.outputs.safe-ref }} short-sha: ${{ steps.vars.outputs.short-sha }} steps: - name: Compute workflow variables @@ -53,6 +54,7 @@ jobs: { echo "short-sha=${short_sha}" + echo "safe-ref=${safe_ref}" echo "archive-name=${archive_name}" echo "artifact-name=${artifact_name}" echo "release-tag=${release_tag}" @@ -124,6 +126,65 @@ jobs: path: ${{ env.RELEASE_DIR }}\${{ needs.init.outputs.archive-name }} if-no-files-found: error + build-posix: + name: Build ${{ matrix.rid }} + needs: + - init + - reuse + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + rid: linux-x64 + - os: macos-latest + rid: osx-x64 + env: + DOTNET_NOLOGO: true + NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages + PUBLISH_DIR: ${{ github.workspace }}/artifacts/publish/${{ matrix.rid }} + RELEASE_DIR: ${{ github.workspace }}/artifacts/release + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.103 + cache: true + cache-dependency-path: | + Directory.Packages.props + src/**/packages.lock.json + + - name: Restore solution + run: dotnet restore SharpEmu.slnx --locked-mode + + - name: Build solution + run: dotnet build SharpEmu.slnx -c Release --no-restore + + - name: Publish ${{ matrix.rid }} CLI + run: dotnet publish src/SharpEmu.CLI/SharpEmu.CLI.csproj -c Release -r ${{ matrix.rid }} --self-contained true --no-restore -p:PublishDir="$PUBLISH_DIR" + + - name: Stage MoltenVK next to the build + 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-${{ matrix.rid }}-${{ needs.init.outputs.short-sha }}.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-${{ matrix.rid }}-${{ needs.init.outputs.short-sha }}.tar.gz + if-no-files-found: error + release: name: Publish GitHub Release needs: @@ -161,3 +222,46 @@ jobs: --notes "${notes}" \ --target "${GITHUB_SHA}" fi + + release-posix: + name: Publish GitHub Release (${{ matrix.rid }}) + needs: + - init + - build-posix + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') + runs-on: ubuntu-latest + permissions: + contents: write + strategy: + fail-fast: false + matrix: + rid: [linux-x64, osx-x64] + steps: + - name: Download build artifact + uses: actions/download-artifact@v8 + with: + name: sharpemu-${{ matrix.rid }}-${{ needs.init.outputs.short-sha }} + path: release + + - name: Create or update release + shell: bash + env: + ARCHIVE_NAME: sharpemu-${{ matrix.rid }}-${{ needs.init.outputs.short-sha }}.tar.gz + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_NAME: SharpEmu ${{ matrix.rid }} ${{ needs.init.outputs.short-sha }} + RELEASE_TAG: ${{ matrix.rid }}-${{ needs.init.outputs.safe-ref }}-${{ needs.init.outputs.short-sha }} + RID: ${{ matrix.rid }} + run: | + asset_path="release/${ARCHIVE_NAME}" + notes="Automated ${RID} build for commit ${GITHUB_SHA}." + + if gh release view "${RELEASE_TAG}" >/dev/null 2>&1; then + gh release upload "${RELEASE_TAG}" "${asset_path}" --clobber + gh release edit "${RELEASE_TAG}" --title "${RELEASE_NAME}" --notes "${notes}" + else + gh release create "${RELEASE_TAG}" "${asset_path}" \ + --title "${RELEASE_NAME}" \ + --notes "${notes}" \ + --target "${GITHUB_SHA}" + fi diff --git a/Directory.Packages.props b/Directory.Packages.props index 4a05189..901e196 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,6 +13,7 @@ SPDX-License-Identifier: GPL-2.0-or-later + @@ -22,4 +23,4 @@ SPDX-License-Identifier: GPL-2.0-or-later - \ No newline at end of file + diff --git a/README.md b/README.md index 9ed2486..c08400a 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,11 @@ SPDX-License-Identifier: GPL-2.0-or-later Join our Discord for development updates, compatibility discussions, support, and community chat.

---- - -> [!WARNING] -> Currently the primary development target is Windows. +--- + +> [!NOTE] +> SharpEmu supports Windows x64, Linux x64, and macOS x64. Apple Silicon Macs +> can run the macOS x64 build through Rosetta 2. > [!WARNING] > SharpEmu is an experimental PS5 emulator developed from scratch in C#. The current focus is on accuracy and infrastructure setup rather than game-specific compatibility. @@ -59,14 +60,33 @@ Current capabilities include: Some games have reached like `sceVideoOut` and AGC stages. -Currently the project primarily targets Windows. Cross-platform support (Linux and macOS) is planned, but development is currently focused on Windows to simplify early-stage debugging and iteration. - -## Using - -* Build or Publish project or download in release tab. -* Open Powershell. - * Run Emulator GUI. - * Or command: `.\SharpEmu "eboot.bin" 2>&1 | Tee-Object -FilePath "log.txt"` +SharpEmu supports Windows, Linux, and macOS hosts. Video output uses Vulkan on +Windows and Linux, and MoltenVK on macOS. Platform support is still experimental, +so compatibility and performance vary by game, operating system, and GPU driver. + +## Using + +Download the release archive for your operating system, extract it, and launch +SharpEmu with the path to a legally obtained game's `eboot.bin`. + +Windows PowerShell: + +```powershell +.\SharpEmu.exe "C:\path\to\game\eboot.bin" 2>&1 | + Tee-Object -FilePath "SharpEmu.log" +``` + +Linux and macOS: + +```bash +chmod +x ./SharpEmu + +./SharpEmu "/path/to/game/eboot.bin" 2>&1 | + tee SharpEmu.log +``` + +A Vulkan-capable GPU and current graphics driver are required. The macOS +release includes the MoltenVK Vulkan implementation. ## Games Tested @@ -94,7 +114,7 @@ Currently the project primarily targets Windows. Cross-platform support (Linux a ## Build -1. Install the **.NET SDK**. +1. Install the .NET SDK version specified in [`global.json`](./global.json). 2. Clone the repository: `git clone https://github.com/par274/sharpemu.git` 3. Open the solution file (`SharpEmu.slnx`) in **VSCode**. 4. Build the project: `dotnet build` or `dotnet publish` diff --git a/scripts/fetch-macos-moltenvk.sh b/scripts/fetch-macos-moltenvk.sh new file mode 100755 index 0000000..c84067d --- /dev/null +++ b/scripts/fetch-macos-moltenvk.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Copyright (C) 2026 SharpEmu Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Downloads the official (universal x86_64+arm64) MoltenVK dylib and stages +# it next to a SharpEmu build as libvulkan.1.dylib. The macOS build runs as +# an x86-64 process under Rosetta 2, so Homebrew's arm64-only Vulkan +# libraries cannot be used; the presenter looks for this app-local copy. +# +# Usage: scripts/fetch-macos-moltenvk.sh [output-dir] +# (default output: artifacts/bin/Debug/net10.0/osx-x64) +set -euo pipefail + +MVK_VERSION="${MVK_VERSION:-v1.4.0}" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +OUT_DIR="${1:-$REPO_ROOT/artifacts/bin/Debug/net10.0/osx-x64}" + +if [[ ! -d "$OUT_DIR" ]]; then + echo "output directory does not exist: $OUT_DIR (build first?)" >&2 + exit 2 +fi + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +echo ">> Downloading MoltenVK $MVK_VERSION..." +curl -sL -o "$WORK_DIR/mvk.tar" \ + "https://github.com/KhronosGroup/MoltenVK/releases/download/$MVK_VERSION/MoltenVK-macos.tar" +tar -xf "$WORK_DIR/mvk.tar" -C "$WORK_DIR" \ + MoltenVK/MoltenVK/dynamic/dylib/macOS/libMoltenVK.dylib + +DYLIB="$WORK_DIR/MoltenVK/MoltenVK/dynamic/dylib/macOS/libMoltenVK.dylib" +file "$DYLIB" | grep -q x86_64 || { echo "downloaded dylib lacks x86_64 slice" >&2; exit 3; } + +cp "$DYLIB" "$OUT_DIR/libMoltenVK.dylib" +cp "$DYLIB" "$OUT_DIR/libvulkan.1.dylib" +echo ">> Staged libMoltenVK.dylib + libvulkan.1.dylib in $OUT_DIR" diff --git a/scripts/test-linux-docker.sh b/scripts/test-linux-docker.sh new file mode 100755 index 0000000..e8f871d --- /dev/null +++ b/scripts/test-linux-docker.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Copyright (C) 2026 SharpEmu Emulator Project +# SPDX-License-Identifier: GPL-2.0-or-later +# +# Smoke-tests the linux-x64 build inside an amd64 container. Useful from any +# host (including Apple Silicon, where Docker runs the amd64 image under +# emulation) to confirm the cross-platform layer keeps working on Linux. +# +# Usage: scripts/test-linux-docker.sh /path/to/eboot.bin +set -euo pipefail + +GAME_PATH="${1:-}" +if [[ -z "$GAME_PATH" || ! -f "$GAME_PATH" ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +GAME_DIR="$(cd "$(dirname "$GAME_PATH")" && pwd)" +GAME_FILE="$(basename "$GAME_PATH")" +PUBLISH_DIR="$REPO_ROOT/artifacts/publish/SharpEmu.CLI/Debug/net10.0/linux-x64" + +echo ">> Publishing linux-x64 self-contained build..." +dotnet publish "$REPO_ROOT/src/SharpEmu.CLI" \ + -c Debug -r linux-x64 --self-contained -p:PublishSingleFile=false + +echo ">> Running inside linux/amd64 container..." +docker run --rm --platform linux/amd64 \ + -v "$PUBLISH_DIR":/app:ro \ + -v "$GAME_DIR":/game:ro \ + mcr.microsoft.com/dotnet/runtime-deps:10.0 \ + /app/SharpEmu --log-level=info "/game/$GAME_FILE" diff --git a/src/SharpEmu.CLI/Program.cs b/src/SharpEmu.CLI/Program.cs index d47ca78..26e56b3 100644 --- a/src/SharpEmu.CLI/Program.cs +++ b/src/SharpEmu.CLI/Program.cs @@ -75,6 +75,121 @@ internal static partial class Program TryEnableConsoleFileMirror(earlyLogFilePath); } + if (!CheckHostArchitecture()) + { + return 5; + } + + if (OperatingSystem.IsMacOS() || OperatingSystem.IsLinux()) + { + if (OperatingSystem.IsMacOS()) + { + PreloadMacVulkanLoader(); + } + + // GLFW requires window creation and event processing on the + // process main thread: AppKit demands it on macOS, and X11 has a + // single event queue that must be serviced from the main thread + // (a window created and polled off it may never map, which showed + // as a running game with no visible window on Linux). Emulation + // moves to a worker thread and the main thread services the window + // work the video presenter posts. Windows keeps a per-thread event + // queue, so its window stays on the presenter's own thread. + var exitCode = 0; + HostMainThread.Enable(); + var emulation = new Thread(() => + { + try + { + exitCode = RunEmulator(args, isMitigatedChild); + } + finally + { + HostMainThread.Shutdown(); + } + }, 32 * 1024 * 1024) + { + Name = "SharpEmu Emulation", + }; + emulation.Start(); + HostMainThread.Pump(); + emulation.Join(); + return exitCode; + } + + return RunEmulator(args, isMitigatedChild); + } + + /// + /// The supported host execution model, checked before any emulation + /// starts: the CPU backend executes guest x86-64 code natively, so the + /// host process must be x86-64 — win-x64/linux-x64 on x64 hardware, or + /// osx-x64 under Rosetta 2 on Apple Silicon (Rosetta translates the + /// whole process, so it still reports as X64 here). An arm64 process + /// (e.g. the osx-arm64 build) can browse the GUI but cannot run games; + /// failing up front distinguishes that from MoltenVK, signal-handler, + /// or guest-memory startup problems. + /// + private static bool CheckHostArchitecture() + { + if (RuntimeInformation.ProcessArchitecture == Architecture.X64) + { + return true; + } + + Console.Error.WriteLine( + $"[LOADER][ERROR] Unsupported process architecture " + + $"{RuntimeInformation.ProcessArchitecture}: guest code executes " + + "natively, so SharpEmu must run as an x86-64 process."); + if (OperatingSystem.IsMacOS()) + { + Console.Error.WriteLine( + "[LOADER][ERROR] On Apple Silicon, use the osx-x64 build under " + + "Rosetta 2 (install with: softwareupdate --install-rosetta)."); + } + + return false; + } + + /// + /// Makes a Vulkan loader visible to GLFW's dlopen("libvulkan.1.dylib"). + /// Homebrew's Vulkan libraries are arm64-only and cannot load into this + /// x86-64 (Rosetta 2) process, so a universal libMoltenVK.dylib placed + /// next to the executable (named libvulkan.1.dylib) is preloaded here; + /// dyld then resolves GLFW's bare-name dlopen to the loaded image. + /// + private static void PreloadMacVulkanLoader() + { + var candidates = new[] + { + Path.Combine(AppContext.BaseDirectory, "libvulkan.1.dylib"), + Path.Combine(AppContext.BaseDirectory, "libMoltenVK.dylib"), + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".sharpemu", "x64lib", "libvulkan.1.dylib"), + }; + foreach (var candidate in candidates) + { + if (File.Exists(candidate) && NativeLibrary.TryLoad(candidate, out _)) + { + Console.Error.WriteLine($"[LOADER][INFO] Vulkan loader preloaded: {candidate}"); + return; + } + } + + if (NativeLibrary.TryLoad("libvulkan.1.dylib", out _)) + { + return; + } + + Console.Error.WriteLine( + "[LOADER][WARN] No x86-64 Vulkan loader found; video output will be unavailable. " + + "Place a universal libMoltenVK.dylib (from the MoltenVK releases) next to SharpEmu " + + "as libvulkan.1.dylib."); + } + + private static int RunEmulator(string[] args, bool isMitigatedChild) + { Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args"); if (!isMitigatedChild && TryRunMitigatedChild(args, out var childExitCode)) diff --git a/src/SharpEmu.CLI/SharpEmu.CLI.csproj b/src/SharpEmu.CLI/SharpEmu.CLI.csproj index 3621327..20b9985 100644 --- a/src/SharpEmu.CLI/SharpEmu.CLI.csproj +++ b/src/SharpEmu.CLI/SharpEmu.CLI.csproj @@ -16,7 +16,9 @@ SPDX-License-Identifier: GPL-2.0-or-later console window; CLI mode re-attaches to the parent terminal's console. --> WinExe SharpEmu - win-x64;linux-x64;osx-arm64 + + win-x64;linux-x64;osx-x64;osx-arm64 true true true @@ -29,6 +31,16 @@ SPDX-License-Identifier: GPL-2.0-or-later true + + + false + + false none @@ -63,7 +75,7 @@ SPDX-License-Identifier: GPL-2.0-or-later <_GlfwPublishFiles Include="@(ResolvedFileToPublish)" - Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw'))" /> + Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" /> true diff --git a/src/SharpEmu.CLI/packages.lock.json b/src/SharpEmu.CLI/packages.lock.json index 95a7cc2..fb58102 100644 --- a/src/SharpEmu.CLI/packages.lock.json +++ b/src/SharpEmu.CLI/packages.lock.json @@ -135,6 +135,23 @@ "Ultz.Native.GLFW": "3.4.0" } }, + "Silk.NET.Input.Common": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "QbJVV7kFBHEByayXCYdJtXXI9Sp4a+QAf0IdGV6uCWkFYcEmqBYW3aaNGvFOdSwTBDbHL5T/OtOCrGh4qYhk7A==", + "dependencies": { + "Silk.NET.Windowing.Common": "2.23.0" + } + }, + "Silk.NET.Input.Glfw": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "KGHYqsv/IQRJtD6dloYh2tN4CkaM40vxM2kj0cGKBoCQiBDYHHhJiyDTyMPx0W7Fz5IgnhnG42ELmIAa0DH69A==", + "dependencies": { + "Silk.NET.Input.Common": "2.23.0", + "Silk.NET.Windowing.Glfw": "2.23.0" + } + }, "Silk.NET.Maths": { "type": "Transitive", "resolved": "2.23.0", @@ -225,6 +242,7 @@ "type": "Project", "dependencies": { "SharpEmu.HLE": "[1.0.0, )", + "Silk.NET.Input": "[2.23.0, )", "Silk.NET.Vulkan": "[2.23.0, )", "Silk.NET.Vulkan.Extensions.EXT": "[2.23.0, )", "Silk.NET.Vulkan.Extensions.KHR": "[2.23.0, )", @@ -282,6 +300,16 @@ "resolved": "1.21.0", "contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg==" }, + "Silk.NET.Input": { + "type": "CentralTransitive", + "requested": "[2.23.0, )", + "resolved": "2.23.0", + "contentHash": "Xzl+tVwAp2eEd8blmGQjmJrsZPnp3PWG0KJjiAQHaY2Zr/ELVWeAROKXmZdCAvexzmte2JVGEy/dxnMycbxlpg==", + "dependencies": { + "Silk.NET.Input.Common": "2.23.0", + "Silk.NET.Input.Glfw": "2.23.0" + } + }, "Silk.NET.Vulkan": { "type": "CentralTransitive", "requested": "[2.23.0, )", @@ -434,6 +462,59 @@ "contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA==" } }, + "net10.0/osx-x64": { + "Avalonia.Angle.Windows.Natives": { + "type": "Transitive", + "resolved": "2.1.25547.20250602", + "contentHash": "ZL0VLc4s9rvNNFt19Pxm5UNAkmKNylugAwJPX9ulXZ6JWs/l6XZihPWWTyezaoNOVyEPU8YbURtW7XMAtqXH5A==" + }, + "Avalonia.Native": { + "type": "Transitive", + "resolved": "11.3.18", + "contentHash": "8g53DROFW6wVJAnTsE1Iu5bdZO5r0oqxbdbMQODs1QDYCK2IU/y/x/vQVKCYOvqxl4tXkzU9tExv8XqSGPWthQ==", + "dependencies": { + "Avalonia": "11.3.18" + } + }, + "HarfBuzzSharp.NativeAssets.Linux": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "3EZ1mpIiKWRLL5hUYA82ZHteeDIVaEA/Z0rA/wU6tjx6crcAkJnBPwDXZugBSfo8+J3EznvRJf49uMsqYfKrHg==" + }, + "HarfBuzzSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "jbtCsgftcaFLCA13tVKo5iWdElJScrulLTKJre36O4YQTIlwDtPPqhRZNk+Y0vv4D1gxbscasGRucUDfS44ofQ==" + }, + "HarfBuzzSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "8.3.1.1", + "contentHash": "UsJtQsfAJoFDZrXc4hCUfRPMqccfKZ0iumJ/upcUjz/cmsTgVFGNEL5yaJWmkqsuFYdMWbj/En5/kS4PFl9hBA==" + }, + "SkiaSharp.NativeAssets.Linux": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "cWSaJKVPWAaT/WIn9c8T5uT/l4ETwHxNJTkEOtNKjphNo8AW6TF9O32aRkxqw3l8GUdUo66Bu7EiqtFh/XG0Zg==", + "dependencies": { + "SkiaSharp": "2.88.9" + } + }, + "SkiaSharp.NativeAssets.macOS": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "Nv5spmKc4505Ep7oUoJ5vp3KweFpeNqxpyGDWyeEPTX2uR6S6syXIm3gj75dM0YJz7NPvcix48mR5laqs8dPuA==" + }, + "SkiaSharp.NativeAssets.Win32": { + "type": "Transitive", + "resolved": "2.88.9", + "contentHash": "wb2kYgU7iy84nQLYZwMeJXixvK++GoIuECjU4ECaUKNuflyRlJKyiRhN1MAHswvlvzuvkrjRWlK0Za6+kYQK7w==" + }, + "Ultz.Native.GLFW": { + "type": "Transitive", + "resolved": "3.4.0", + "contentHash": "Iy22JopynbOJ32vA0lBhFEzGi65GQJBuJHYBYRBpydrDpNoTiHnjIXfA65Gu+8qsOr/ZEoIF8r9aHCgAXuO6DA==" + } + }, "net10.0/win-x64": { "Avalonia.Angle.Windows.Natives": { "type": "Transitive", diff --git a/src/SharpEmu.Core/Cpu/CpuDispatcher.cs b/src/SharpEmu.Core/Cpu/CpuDispatcher.cs index f09d49d..28e1cb1 100644 --- a/src/SharpEmu.Core/Cpu/CpuDispatcher.cs +++ b/src/SharpEmu.Core/Cpu/CpuDispatcher.cs @@ -22,15 +22,22 @@ public sealed class CpuDispatcher : ICpuDispatcher, IDisposable ModuleInitializer, } - private const ulong StackBaseAddress = 0x7FFF_F000_0000UL; + // The top of the x86-64 user address space (0x7FFD..0x7FFF) is only + // freely mappable on Windows; on macOS/Linux it hosts the dyld shared + // cache / vdso and (under Rosetta 2) the translator runtime, so POSIX + // hosts use the equivalent layout one slot lower at 0x6FFx. + private static readonly ulong StackBaseAddress = OperatingSystem.IsWindows() ? 0x7FFF_F000_0000UL : 0x6FFF_F000_0000UL; private const ulong StackSize = 0x0020_0000UL; - private const ulong TlsBaseAddress = 0x7FFE_0000_0000UL; + private static readonly ulong TlsBaseAddress = OperatingSystem.IsWindows() ? 0x7FFE_0000_0000UL : 0x6FFE_0000_0000UL; private const ulong TlsSize = 0x0001_0000UL; - private const ulong TlsPrefixSize = 0x0000_1000UL; - private const ulong BootstrapStubBaseAddress = 0x7FFD_F000_0000UL; - private const ulong BootstrapPayloadBaseAddress = 0x7FFD_E000_0000UL; - private const ulong DynlibFallbackStubBaseAddress = 0x7FFD_D000_0000UL; - private const ulong ReturnToHostStubBaseAddress = 0x7FFD_C000_0000UL; + // The static TLS blocks live at negative offsets from the TCB (FreeBSD + // amd64 variant II); libc.prx alone reaches beyond -0x1700, so give the + // prefix a full 64KB on POSIX. Windows keeps its historical 4KB prefix. + private static readonly ulong TlsPrefixSize = OperatingSystem.IsWindows() ? 0x0000_1000UL : 0x0001_0000UL; + private static readonly ulong BootstrapStubBaseAddress = OperatingSystem.IsWindows() ? 0x7FFD_F000_0000UL : 0x6FFD_F000_0000UL; + private static readonly ulong BootstrapPayloadBaseAddress = OperatingSystem.IsWindows() ? 0x7FFD_E000_0000UL : 0x6FFD_E000_0000UL; + private static readonly ulong DynlibFallbackStubBaseAddress = OperatingSystem.IsWindows() ? 0x7FFD_D000_0000UL : 0x6FFD_D000_0000UL; + private static readonly ulong ReturnToHostStubBaseAddress = OperatingSystem.IsWindows() ? 0x7FFD_C000_0000UL : 0x6FFD_C000_0000UL; private const ulong BootstrapRegionSize = 0x0000_1000UL; private const ulong ReturnToHostStubStride = 0x0100_0000UL; private const ulong BootstrapPayloadResultOffset = 0x28UL; diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs index a16de8d..7c96cab 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.Exceptions.cs @@ -22,6 +22,12 @@ public sealed partial class DirectExecutionBackend private unsafe void SetupExceptionHandler() { + if (!OperatingSystem.IsWindows()) + { + SetupPosixExceptionHandler(); + return; + } + if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_RAW_HANDLER"), "1", StringComparison.Ordinal)) { _rawExceptionHandlerStub = _faultHandling.CreateHandlerThunk(RawVectoredHandlerPtrManaged, _hostRspSlotTlsIndex, _tlsGetValueAddress); @@ -207,19 +213,16 @@ public sealed partial class DirectExecutionBackend } - try + Console.Error.WriteLine("[LOADER][INFO] Stack qwords (RSP..):"); + for (int i = 0; i < 16; i++) { - Console.Error.WriteLine("[LOADER][INFO] Stack qwords (RSP..):"); - for (int i = 0; i < 16; i++) + ulong stackAddr = rsp + (ulong)(i * 8); + if (!TryReadHostQword(stackAddr, out ulong value)) { - ulong stackAddr = rsp + (ulong)(i * 8); - ulong value = (ulong)Marshal.ReadInt64((nint)stackAddr); - Console.Error.WriteLine($"[LOADER][INFO] [rsp+0x{i * 8:X2}] @0x{stackAddr:X16} = 0x{value:X16}"); + Console.Error.WriteLine("[LOADER][WARNING] Could not read stack qwords."); + break; } - } - catch - { - Console.Error.WriteLine("[LOADER][WARNING] Could not read stack qwords."); + Console.Error.WriteLine($"[LOADER][INFO] [rsp+0x{i * 8:X2}] @0x{stackAddr:X16} = 0x{value:X16}"); } try @@ -232,8 +235,11 @@ public sealed partial class DirectExecutionBackend { break; } - ulong next = (ulong)Marshal.ReadInt64((nint)frame); - ulong ret = (ulong)Marshal.ReadInt64((nint)(frame + 8)); + if (!TryReadHostQword(frame, out ulong next) || !TryReadHostQword(frame + 8, out ulong ret)) + { + Console.Error.WriteLine("[LOADER][WARNING] Could not walk RBP frame chain."); + break; + } string extra = TryFormatNearestRuntimeSymbol(ret, out string retSym) ? $" [{retSym}]" : string.Empty; Console.Error.WriteLine($"[LOADER][INFO] frame#{i}: rbp=0x{frame:X16} ret=0x{ret:X16}{extra} next=0x{next:X16}"); if (next <= frame) @@ -256,10 +262,9 @@ public sealed partial class DirectExecutionBackend Console.Error.WriteLine("[LOADER][ERROR] - Guest code called an unmapped import"); Console.Error.WriteLine("[LOADER][ERROR] - Guest code accessed unmapped memory"); Console.Error.WriteLine("[LOADER][ERROR] - Need to implement HLE for this NID"); - try + byte[] code = new byte[16]; + if (TryReadHostBytes(rip, code)) { - byte[] code = new byte[16]; - Marshal.Copy((nint)rip, code, 0, code.Length); Console.Error.WriteLine("[LOADER][INFO] Code at RIP: " + BitConverter.ToString(code).Replace("-", " ")); if (code[0] == 100) { @@ -275,20 +280,18 @@ public sealed partial class DirectExecutionBackend Console.Error.WriteLine($"[LOADER][INFO] RBP: 0x{rbp:X16} (mod 16 = {rbp % 16})"); Console.Error.WriteLine($"[LOADER][INFO] RSP: 0x{rsp:X16} (mod 16 = {rsp % 16})"); } - if (rip > 16) + byte[] before = new byte[16]; + if (rip > 16 && TryReadHostBytes(rip - 16, before)) { - byte[] before = new byte[16]; - Marshal.Copy((nint)(rip - 16), before, 0, before.Length); Console.Error.WriteLine("[LOADER][INFO] Code before RIP: " + BitConverter.ToString(before).Replace("-", " ")); } - if (rip > 32) + byte[] window = new byte[64]; + if (rip > 32 && TryReadHostBytes(rip - 32, window)) { - byte[] window = new byte[64]; - Marshal.Copy((nint)(rip - 32), window, 0, window.Length); Console.Error.WriteLine("[LOADER][INFO] Code window [RIP-0x20..]: " + BitConverter.ToString(window).Replace("-", " ")); } } - catch + else { Console.Error.WriteLine("[LOADER][ERROR] Could not read code at RIP"); } @@ -823,6 +826,61 @@ public sealed partial class DirectExecutionBackend } } + private static bool TryReadHostQword(ulong address, out ulong value) + { + if (!OperatingSystem.IsWindows()) + { + // A stray read inside the signal handler would raise a nested + // SIGSEGV and kill the process before diagnostics finish, so + // probe the region table instead of relying on try/catch. + return TryReadStackU64(address, out value); + } + + value = 0; + try + { + value = (ulong)Marshal.ReadInt64((nint)address); + return true; + } + catch + { + return false; + } + } + + private unsafe bool TryReadHostBytes(ulong address, byte[] buffer) + { + if (address < 65536) + { + return false; + } + + if (!OperatingSystem.IsWindows()) + { + // See TryReadHostQword: probe every touched page before reading. + ulong end = address + (ulong)buffer.Length; + for (ulong page = address & 0xFFFFFFFFFFFFF000uL; page < end; page += 4096) + { + if (!_hostMemory.Query(page, out var mbi) || + mbi.State != HostRegionState.Committed || + !IsReadableProtection(mbi.RawProtection)) + { + return false; + } + } + } + + try + { + Marshal.Copy((nint)address, buffer, 0, buffer.Length); + return true; + } + catch + { + return false; + } + } + private string FormatPointerWithNearestSymbol(ulong value) { string text = $"0x{value:X16}"; diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.NativeWorker.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.NativeWorker.cs index c5c50db..43e8414 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.NativeWorker.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.NativeWorker.cs @@ -7,6 +7,7 @@ using System.Runtime.InteropServices; using System.Threading; using SharpEmu.HLE; using SharpEmu.HLE.Host; +using SharpEmu.HLE.Host.Posix; namespace SharpEmu.Core.Cpu.Native; @@ -172,8 +173,20 @@ public sealed partial class DirectExecutionBackend private static nint _exitThreadAddress; private readonly DirectExecutionBackend _backend; - private readonly AutoResetEvent _workAvailable = new(false); - private readonly AutoResetEvent _workCompleted = new(false); + // Windows uses AutoResetEvent (its SafeWaitHandle is a real kernel + // event the emitted loop can wait on); POSIX uses worker-event + // semaphores shared the same way via PosixHostStubs. + private readonly AutoResetEvent? _workAvailable; + private readonly AutoResetEvent? _workCompleted; + private nint _workSemaphore; + private nint _doneSemaphore; + + // RunPrologue/RunEpilogue compile to the host ABI (SysV on POSIX); the + // emitted loop calls them with Win64 registers, so POSIX routes the + // calls through register-shuffling thunks (shared by all workers). + private static nint _posixPrologueThunk; + private static nint _posixEpilogueThunk; + private static readonly object PosixThunkGate = new(); private GCHandle _selfHandle; private void* _controlBlock; private void* _loopStub; @@ -212,6 +225,11 @@ public sealed partial class DirectExecutionBackend private NativeGuestExecutor(DirectExecutionBackend backend) { _backend = backend; + if (OperatingSystem.IsWindows()) + { + _workAvailable = new AutoResetEvent(false); + _workCompleted = new AutoResetEvent(false); + } } public static NativeGuestExecutor? TryCreate(DirectExecutionBackend backend) @@ -258,8 +276,34 @@ public sealed partial class DirectExecutionBackend var prologuePtr = (nint)(delegate* unmanaged)&RunPrologue; var epiloguePtr = (nint)(delegate* unmanaged)&RunEpilogue; var executorHandle = GCHandle.ToIntPtr(_selfHandle); - var workHandle = _workAvailable.SafeWaitHandle.DangerousGetHandle(); - var doneHandle = _workCompleted.SafeWaitHandle.DangerousGetHandle(); + nint workHandle; + nint doneHandle; + if (OperatingSystem.IsWindows()) + { + workHandle = _workAvailable!.SafeWaitHandle.DangerousGetHandle(); + doneHandle = _workCompleted!.SafeWaitHandle.DangerousGetHandle(); + } + else + { + lock (PosixThunkGate) + { + if (_posixPrologueThunk == 0) + { + _posixPrologueThunk = PosixHostStubs.CreateWin64ToSysVThunk(prologuePtr); + _posixEpilogueThunk = PosixHostStubs.CreateWin64ToSysVThunk(epiloguePtr); + } + } + prologuePtr = _posixPrologueThunk; + epiloguePtr = _posixEpilogueThunk; + _workSemaphore = PosixHostStubs.CreateWorkerEvent(); + _doneSemaphore = PosixHostStubs.CreateWorkerEvent(); + if (_workSemaphore == 0 || _doneSemaphore == 0) + { + return false; + } + workHandle = _workSemaphore; + doneHandle = _doneSemaphore; + } byte* code = (byte*)_loopStub; int offset = 0; @@ -377,8 +421,8 @@ public sealed partial class DirectExecutionBackend _runYieldRequested = false; _runYieldReason = null; _runForcedExit = false; - _workAvailable.Set(); - _workCompleted.WaitOne(); + SignalWorkAvailable(); + WaitWorkCompleted(); _runContext = null; _runState = null; yieldRequested = _runYieldRequested; @@ -391,6 +435,28 @@ public sealed partial class DirectExecutionBackend return _runNativeResult; } + private void SignalWorkAvailable() + { + if (_workAvailable is not null) + { + _workAvailable.Set(); + return; + } + + _ = PosixHostStubs.SignalWorkerEvent(_workSemaphore); + } + + private void WaitWorkCompleted() + { + if (_workCompleted is not null) + { + _workCompleted.WaitOne(); + return; + } + + _ = PosixHostStubs.WaitWorkerEvent(_doneSemaphore, -1); + } + [UnmanagedCallersOnly] private static nint RunPrologue(nint executorHandle) { @@ -520,7 +586,7 @@ public sealed partial class DirectExecutionBackend } try { - _workAvailable.Set(); + SignalWorkAvailable(); } catch (ObjectDisposedException) { @@ -555,8 +621,18 @@ public sealed partial class DirectExecutionBackend { _selfHandle.Free(); } - _workAvailable.Dispose(); - _workCompleted.Dispose(); + _workAvailable?.Dispose(); + _workCompleted?.Dispose(); + if (_workSemaphore != 0) + { + PosixHostStubs.DestroyWorkerEvent(_workSemaphore); + _workSemaphore = 0; + } + if (_doneSemaphore != 0) + { + PosixHostStubs.DestroyWorkerEvent(_doneSemaphore); + _doneSemaphore = 0; + } } } } diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.PosixSignals.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.PosixSignals.cs new file mode 100644 index 0000000..d9dbf82 --- /dev/null +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.PosixSignals.cs @@ -0,0 +1,374 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System; +using System.Runtime.InteropServices; +using System.Threading; +using SharpEmu.Core.Cpu.Native.Windows; + +namespace SharpEmu.Core.Cpu.Native; + +public sealed unsafe partial class DirectExecutionBackend +{ + // POSIX bridge for the Windows vectored-exception-handler logic. A + // sigaction(SIGSEGV/SIGBUS/SIGILL) handler rebuilds the EXCEPTION_POINTERS + // view the shared handlers expect (Win64 CONTEXT register offsets) from + // the signal's mcontext, runs the same recovery chain the VEH path uses + // (unresolved-import trap sentinels, demand-paging of lazily-committed + // guest pages, fault diagnostics), and writes register changes back into + // the mcontext so sigreturn resumes the repaired guest. Unrecovered + // faults are forwarded to the previously installed handler so the .NET + // runtime keeps turning its own faults into managed exceptions. + + private const int PosixSigIll = 4; + private const int PosixSigSegv = 11; + private static readonly int PosixSigBus = OperatingSystem.IsMacOS() ? 10 : 7; + + // struct sigaction: the handler pointer leads on both platforms; Darwin + // packs { handler(8), mask(4), flags(4) }, Linux glibc/musl packs + // { handler(8), mask(128), flags(4), restorer(8) }. + private static readonly int PosixSigactionSize = OperatingSystem.IsMacOS() ? 16 : 152; + private static readonly int PosixSigactionFlagsOffset = OperatingSystem.IsMacOS() ? 12 : 136; + + private static readonly int PosixSaSigInfo = OperatingSystem.IsMacOS() ? 0x0040 : 0x0004; + private static readonly int PosixSaNoDefer = OperatingSystem.IsMacOS() ? 0x0010 : 0x40000000; + + // siginfo_t.si_addr: Darwin { signo, errno, code, pid, uid, status, addr }, + // Linux { signo, errno, code, pad32, addr }. + private static readonly int PosixSigInfoAddressOffset = OperatingSystem.IsMacOS() ? 24 : 16; + + // Darwin ucontext_t stores a pointer to __darwin_mcontext64 at +48; the + // general registers live in its __ss thread state after the 16-byte + // exception state. Linux glibc embeds mcontext_t inline at +40 with the + // registers in gregs[23]. Rosetta 2 delivers the regular x86-64 layout + // to translated processes. + private const int DarwinUcontextMcontextOffset = 48; + private const int DarwinMcontextErrOffset = 4; + private const int DarwinMcontextFaultAddressOffset = 8; + private const int LinuxUcontextGregsOffset = 40; + private const int LinuxGregsErrOffset = 19 * 8; + + // Byte offsets of the general registers relative to GetPosixRegisterBase, + // ordered to match the contiguous Win64 CONTEXT block CTX_RAX..CTX_RIP + // (rax, rcx, rdx, rbx, rsp, rbp, rsi, rdi, r8..r15, rip). Verified + // against the x86-64 platform headers. + private static readonly int[] PosixRegisterOffsets = OperatingSystem.IsMacOS() + ? new[] { 16, 32, 40, 24, 72, 64, 56, 48, 80, 88, 96, 104, 112, 120, 128, 136, 144 } + : new[] { 104, 112, 96, 88, 120, 80, 72, 64, 0, 8, 16, 24, 32, 40, 48, 56, 128 }; + + private static DirectExecutionBackend? _posixSignalBackend; + private static bool _posixSignalHandlersInstalled; + private static bool _posixRawRecoveryEnabled; + private static bool _posixSignalWarmup; + private static readonly nint[] _posixPreviousActions = new nint[32]; + private static int _posixSignalTraceCount; + + [ThreadStatic] + private static int _posixSignalHandlerDepth; + + private void SetupPosixExceptionHandler() + { + if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_POSIX_SIGNALS"), "1", StringComparison.Ordinal)) + { + Console.Error.WriteLine("[LOADER][WARN] POSIX signal exception bridge disabled by SHARPEMU_DISABLE_POSIX_SIGNALS=1; guest faults will not be recovered."); + return; + } + + _posixSignalBackend = this; + if (_posixSignalHandlersInstalled) + { + return; + } + + _posixRawRecoveryEnabled = !string.Equals( + Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_RAW_HANDLER"), "1", StringComparison.Ordinal); + if (!_posixRawRecoveryEnabled) + { + Console.Error.WriteLine("[LOADER][INFO] Raw sentinel recovery disabled by SHARPEMU_DISABLE_RAW_HANDLER=1"); + } + + WarmUpPosixSignalPath(); + + if (!InstallPosixSignalHandler(PosixSigSegv) || + !InstallPosixSignalHandler(PosixSigBus) || + !InstallPosixSignalHandler(PosixSigIll)) + { + throw new InvalidOperationException("Failed to install POSIX fault signal handlers"); + } + + _posixSignalHandlersInstalled = true; + Console.Error.WriteLine("[LOADER][INFO] POSIX signal exception bridge installed (SIGSEGV/SIGBUS/SIGILL)"); + } + + /// + /// Runs the signal-recovery path once with fabricated inputs before the + /// handlers are installed. The first entry into the handler must not + /// require JIT compilation (a fault can interrupt arbitrary runtime + /// states), and under Rosetta 2 the signal trampoline cannot enter x86 + /// code that has never been executed (and therefore never translated): a + /// cold handler is silently never invoked and the faulting instruction + /// retries forever. + /// + private void WarmUpPosixSignalPath() + { + byte* fakeUcontext = stackalloc byte[512]; + new Span(fakeUcontext, 512).Clear(); + byte* fakeMcontext = stackalloc byte[512]; + new Span(fakeMcontext, 512).Clear(); + if (OperatingSystem.IsMacOS()) + { + *(byte**)(fakeUcontext + DarwinUcontextMcontextOffset) = fakeMcontext; + } + + _posixSignalWarmup = true; + try + { + ((delegate* unmanaged)&HandlePosixSignal)(PosixSigSegv, 0, (nint)fakeUcontext); + + // Warm the branches the fabricated fault above skips without + // spamming diagnostics: the benign-exception path through + // VectoredHandler, the lazy-commit probe (fault address 0 bails + // out immediately), and the chain helper (signal 0 has no saved + // action and sigaction(0, ...) fails with EINVAL). + EXCEPTION_RECORD record = default; + record.ExceptionCode = DBG_PRINTEXCEPTION_C; + byte* contextRecord = stackalloc byte[Win64ContextOffsets.Size]; + new Span(contextRecord, Win64ContextOffsets.Size).Clear(); + EXCEPTION_POINTERS pointers; + pointers.ExceptionRecord = &record; + pointers.ContextRecord = contextRecord; + _ = VectoredHandler(&pointers); + + record.ExceptionCode = 3221225477u; + record.NumberParameters = 2; + // 0x70000 is never guest-owned, so this walks the vmem region + // scan and the PRT range check, then bails out silently. + record.ExceptionInformation[1] = 0x70000; + _ = TryHandleLazyCommittedPage(&record, 0, 0); + ChainPreviousPosixAction(0, 0, 0); + } + finally + { + _posixSignalWarmup = false; + } + } + + private static bool InstallPosixSignalHandler(int signal) + { + byte* action = stackalloc byte[PosixSigactionSize]; + new Span(action, PosixSigactionSize).Clear(); + *(nint*)action = (nint)(delegate* unmanaged)&HandlePosixSignal; + // No SA_ONSTACK: the runtime's alternate stacks are far too small for + // the recovery/diagnostic path (JIT compilation of cold handler code + // can run inside the signal frame). Guest faults deliver onto the 2MB + // guest stack, host faults onto the regular thread stack — the same + // stacks Windows dispatches exceptions on. + *(int*)(action + PosixSigactionFlagsOffset) = PosixSaSigInfo | PosixSaNoDefer; + + var previous = (byte*)NativeMemory.AllocZeroed((nuint)PosixSigactionSize); + if (sigaction(signal, action, previous) != 0) + { + NativeMemory.Free(previous); + Console.Error.WriteLine($"[LOADER][ERROR] sigaction({signal}) failed: errno={Marshal.GetLastPInvokeError()}"); + return false; + } + + _posixPreviousActions[signal] = (nint)previous; + return true; + } + + [UnmanagedCallersOnly] + private static void HandlePosixSignal(int signal, nint siginfo, nint ucontext) + { + if (_posixSignalHandlerDepth > 0) + { + // A fault inside our own fault handler (diagnostics touched an + // unmapped address): restore the default action and return so the + // re-executed instruction terminates the process. + RestoreDefaultPosixAction(signal); + return; + } + + _posixSignalHandlerDepth++; + try + { + if (TryHandlePosixFault(signal, siginfo, ucontext)) + { + return; + } + } + catch + { + // A managed exception must never unwind out of a signal frame. + } + finally + { + _posixSignalHandlerDepth--; + } + + ChainPreviousPosixAction(signal, siginfo, ucontext); + } + + private static bool TryHandlePosixFault(int signal, nint siginfo, nint ucontext) + { + byte* registers = GetPosixRegisterBase(ucontext); + if (registers == null) + { + return false; + } + + byte* contextRecord = stackalloc byte[Win64ContextOffsets.Size]; + new Span(contextRecord, Win64ContextOffsets.Size).Clear(); + int[] offsets = PosixRegisterOffsets; + for (int i = 0; i < offsets.Length; i++) + { + WriteCtxU64(contextRecord, CTX_RAX + i * 8, *(ulong*)(registers + offsets[i])); + } + + EXCEPTION_RECORD record = default; + record.ExceptionAddress = (void*)ReadCtxU64(contextRecord, CTX_RIP); + if (signal == PosixSigIll) + { + record.ExceptionCode = 3221225501u; + } + else + { + ulong faultAddress = GetPosixFaultAddress(siginfo, registers); + record.ExceptionCode = 3221225477u; + record.NumberParameters = 2; + record.ExceptionInformation[0] = GetPosixAccessType(registers, faultAddress, ReadCtxU64(contextRecord, CTX_RIP)); + record.ExceptionInformation[1] = faultAddress; + } + + EXCEPTION_POINTERS pointers; + pointers.ExceptionRecord = &record; + pointers.ContextRecord = contextRecord; + + int traceIndex = _posixSignalWarmup ? 0 : Interlocked.Increment(ref _posixSignalTraceCount); + bool traceSignal = traceIndex > 0 && (traceIndex <= 16 || traceIndex % 1024 == 0 || + string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_POSIX_SIGNALS"), "1", StringComparison.Ordinal)); + if (traceSignal) + { + Console.Error.WriteLine( + $"[LOADER][TRACE] posix-signal#{traceIndex}: sig={signal} rip=0x{ReadCtxU64(contextRecord, CTX_RIP):X16} " + + $"fault=0x{record.ExceptionInformation[1]:X16} access={record.ExceptionInformation[0]} rsp=0x{ReadCtxU64(contextRecord, CTX_RSP):X16}"); + Console.Error.Flush(); + } + + // Sentinel recovery runs first: on Windows both vectored handlers see + // every fault anyway, and recovering here avoids dumping the full + // VectoredHandler diagnostics for each recoverable trap. + int disposition = 0; + if (_posixRawRecoveryEnabled) + { + disposition = TryRecoverUnresolvedSentinel(&pointers); + } + if (disposition != -1 && !_posixSignalWarmup && _posixSignalBackend is { } backend) + { + disposition = backend.VectoredHandler(&pointers); + } + if (traceSignal) + { + Console.Error.WriteLine( + $"[LOADER][TRACE] posix-signal#{traceIndex}: recovered={disposition == -1} new_rip=0x{ReadCtxU64(contextRecord, CTX_RIP):X16}"); + Console.Error.Flush(); + } + if (disposition != -1 && !_posixSignalWarmup) + { + return false; + } + + for (int i = 0; i < offsets.Length; i++) + { + *(ulong*)(registers + offsets[i]) = ReadCtxU64(contextRecord, CTX_RAX + i * 8); + } + return true; + } + + private static byte* GetPosixRegisterBase(nint ucontext) + { + if (ucontext == 0) + { + return null; + } + + if (OperatingSystem.IsMacOS()) + { + return *(byte**)((byte*)ucontext + DarwinUcontextMcontextOffset); + } + + return (byte*)ucontext + LinuxUcontextGregsOffset; + } + + private static ulong GetPosixFaultAddress(nint siginfo, byte* registers) + { + ulong address = siginfo != 0 ? *(ulong*)((byte*)siginfo + PosixSigInfoAddressOffset) : 0; + if (address == 0 && OperatingSystem.IsMacOS()) + { + address = *(ulong*)(registers + DarwinMcontextFaultAddressOffset); + } + + return address; + } + + private static ulong GetPosixAccessType(byte* registers, ulong faultAddress, ulong rip) + { + // x86 page-fault error code: bit 1 = write access, bit 4 = instruction + // fetch. Fall back to comparing the fault address against RIP when + // the error code is not populated (e.g. under Rosetta 2 translation). + ulong error = OperatingSystem.IsMacOS() + ? *(uint*)(registers + DarwinMcontextErrOffset) + : *(ulong*)(registers + LinuxGregsErrOffset); + if ((error & 0x10) != 0) + { + return 8; + } + if ((error & 0x2) != 0) + { + return 1; + } + + return faultAddress != 0 && faultAddress == rip ? 8u : 0u; + } + + private static void RestoreDefaultPosixAction(int signal) + { + byte* action = stackalloc byte[PosixSigactionSize]; + new Span(action, PosixSigactionSize).Clear(); + _ = sigaction(signal, action, null); + } + + private static void ChainPreviousPosixAction(int signal, nint siginfo, nint ucontext) + { + byte* previous = (uint)signal < (uint)_posixPreviousActions.Length + ? (byte*)_posixPreviousActions[signal] + : null; + nint handler = previous != null ? *(nint*)previous : 0; + if (handler == 0) + { + // SIG_DFL (or nothing saved): reinstate the default action and + // return, so re-executing the faulting instruction terminates the + // process with the original fault context intact. + RestoreDefaultPosixAction(signal); + return; + } + if (handler == 1) + { + // SIG_IGN + return; + } + + int flags = *(int*)(previous + PosixSigactionFlagsOffset); + if ((flags & PosixSaSigInfo) != 0) + { + ((delegate* unmanaged)handler)(signal, siginfo, ucontext); + } + else + { + ((delegate* unmanaged)handler)(signal); + } + } + + [DllImport("libc", SetLastError = true)] + private static extern int sigaction(int signum, void* act, void* oldact); +} diff --git a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs index eb51bd6..d8d27a1 100644 --- a/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs +++ b/src/SharpEmu.Core/Cpu/Native/DirectExecutionBackend.cs @@ -14,6 +14,7 @@ using SharpEmu.Core.Loader; using SharpEmu.Core.Memory; using SharpEmu.HLE; using SharpEmu.HLE.Host; +using SharpEmu.HLE.Host.Posix; using SharpEmu.Logging; namespace SharpEmu.Core.Cpu.Native; @@ -138,15 +139,25 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I private const ulong GuestImageScanEnd = 36507222016uL; - private const ulong GuestThreadStackBaseAddress = 0x7FFF_E000_0000UL; + // The 0x7FFx window is Windows-specific; dyld and Rosetta reserve that + // range on macOS, so POSIX guest threads use the lower 0x6FFx window. + // The POSIX stack base sits a further 1GB down: the import-stub region + // descends from 0x7000_0000_0000 on the same 16MB grid and reaches + // 0x6FFF_C000_0000 at its 64-module limit, which would otherwise consume + // the top stack slots (on Windows the two bands are ~15TB apart). + private static readonly ulong GuestThreadStackBaseAddress = + OperatingSystem.IsWindows() ? 0x7FFF_E000_0000UL : 0x6FFF_A000_0000UL; - private const ulong GuestThreadTlsBaseAddress = 0x7FFE_0000_0000UL; + private static readonly ulong GuestThreadTlsBaseAddress = + OperatingSystem.IsWindows() ? 0x7FFE_0000_0000UL : 0x6FFE_0000_0000UL; private const ulong GuestThreadStackSize = 0x0020_0000UL; private const ulong GuestThreadTlsSize = 0x0001_0000UL; - private const ulong GuestThreadTlsPrefixSize = 0x0000_1000UL; + // Matches CpuDispatcher.TlsPrefixSize: static TLS blocks sit below the + // TCB, and libc.prx already reaches beyond -0x1700 on POSIX. + private static readonly ulong GuestThreadTlsPrefixSize = OperatingSystem.IsWindows() ? 0x0000_1000UL : 0x0001_0000UL; private const ulong GuestThreadRegionStride = 0x0100_0000UL; @@ -546,6 +557,38 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I private readonly object _guestThreadGate = new object(); + // Diagnostic owner tracking for _guestThreadGate; written only while the + // gate is held, read lock-free by the stall watchdog's periodic snapshot. + private volatile string? _gateOwnerSite; + private int _gateOwnerManagedThreadId; + private long _gateAcquireTimestamp; + + private GateHolder LockGate(string site) + { + Monitor.Enter(_guestThreadGate); + _gateOwnerSite = site; + Volatile.Write(ref _gateOwnerManagedThreadId, Environment.CurrentManagedThreadId); + Volatile.Write(ref _gateAcquireTimestamp, Stopwatch.GetTimestamp()); + return new GateHolder(this); + } + + private readonly struct GateHolder : IDisposable + { + private readonly DirectExecutionBackend _owner; + + public GateHolder(DirectExecutionBackend owner) + { + _owner = owner; + } + + public void Dispose() + { + _owner._gateOwnerSite = null; + Volatile.Write(ref _owner._gateOwnerManagedThreadId, 0); + Monitor.Exit(_owner._guestThreadGate); + } + } + private readonly Queue _readyGuestThreads = new Queue(); // Once set, guest worker threads are unwound to the host at their next import @@ -610,8 +653,17 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I private static readonly RawExceptionHandlerDelegate RawVectoredHandlerDelegateInstance = RawVectoredHandlerManaged; private static readonly RawExceptionHandlerDelegate RawUnhandledFilterDelegateInstance = RawUnhandledFilterManaged; - private static readonly nint ImportGatewayPtr = - Marshal.GetFunctionPointerForDelegate(ImportGatewayDelegateInstance); + private static readonly nint ImportGatewayPtr = ResolveImportGatewayPtr(); + + // Emitted import trampolines use the Win64 ABI. Managed callbacks use the + // host ABI, so POSIX needs a small Win64-to-SysV register-shuffling thunk. + private static nint ResolveImportGatewayPtr() + { + var managedPtr = Marshal.GetFunctionPointerForDelegate(ImportGatewayDelegateInstance); + return OperatingSystem.IsWindows() + ? managedPtr + : PosixHostStubs.CreateWin64ToSysVThunk(managedPtr); + } private static readonly nint RawVectoredHandlerPtrManaged = Marshal.GetFunctionPointerForDelegate(RawVectoredHandlerDelegateInstance); @@ -865,7 +917,9 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I _hostThreading = _hostPlatform.Threading; _hostSymbols = _hostPlatform.Symbols; _hostMemory = _hostPlatform.Memory; - _faultHandling = faultHandling ?? new WindowsFaultHandling(_hostMemory); + _faultHandling = faultHandling ?? (OperatingSystem.IsWindows() + ? new WindowsFaultHandling(_hostMemory) + : NullHostFaultHandling.Instance); _selfHandle = GCHandle.Alloc(this); _selfHandlePtr = GCHandle.ToIntPtr(_selfHandle); _guestTlsBaseTlsIndex = _hostThreading.AllocateTlsSlot(); @@ -1976,26 +2030,54 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I { throw new OutOfMemoryException("Failed to allocate TLS handler"); } + // The handler runs in place of a patched guest `mov reg, fs:[0]`, + // which preserves every register and the flags. TlsGetValue (and the + // Win64 ABI in general) clobbers rcx/rdx/r8-r11 and the arithmetic + // flags, so save them all: guest code legitimately keeps live values + // and comparison results across TLS reads, and losing them corrupted + // deterministic computations (e.g. procedural texture generation). byte* tlsHandlerAddress = (byte*)_tlsHandlerAddress; int num = 0; - tlsHandlerAddress[num++] = 72; - tlsHandlerAddress[num++] = 131; - tlsHandlerAddress[num++] = 236; - tlsHandlerAddress[num++] = 40; - tlsHandlerAddress[num++] = 185; + tlsHandlerAddress[num++] = 0x9C; // pushfq + tlsHandlerAddress[num++] = 0x51; // push rcx + tlsHandlerAddress[num++] = 0x52; // push rdx + tlsHandlerAddress[num++] = 0x41; // push r8 + tlsHandlerAddress[num++] = 0x50; + tlsHandlerAddress[num++] = 0x41; // push r9 + tlsHandlerAddress[num++] = 0x51; + tlsHandlerAddress[num++] = 0x41; // push r10 + tlsHandlerAddress[num++] = 0x52; + tlsHandlerAddress[num++] = 0x41; // push r11 + tlsHandlerAddress[num++] = 0x53; + tlsHandlerAddress[num++] = 0x48; // sub rsp, 0x20 + tlsHandlerAddress[num++] = 0x83; + tlsHandlerAddress[num++] = 0xEC; + tlsHandlerAddress[num++] = 0x20; + tlsHandlerAddress[num++] = 0xB9; // mov ecx, index *(uint*)(tlsHandlerAddress + num) = _guestTlsBaseTlsIndex; num += 4; - tlsHandlerAddress[num++] = 72; - tlsHandlerAddress[num++] = 184; + tlsHandlerAddress[num++] = 0x48; // mov rax, TlsGetValue + tlsHandlerAddress[num++] = 0xB8; *(long*)(tlsHandlerAddress + num) = _tlsGetValueAddress; num += 8; - tlsHandlerAddress[num++] = byte.MaxValue; - tlsHandlerAddress[num++] = 208; - tlsHandlerAddress[num++] = 72; - tlsHandlerAddress[num++] = 131; - tlsHandlerAddress[num++] = 196; - tlsHandlerAddress[num++] = 40; - tlsHandlerAddress[num++] = 195; + tlsHandlerAddress[num++] = 0xFF; // call rax + tlsHandlerAddress[num++] = 0xD0; + tlsHandlerAddress[num++] = 0x48; // add rsp, 0x20 + tlsHandlerAddress[num++] = 0x83; + tlsHandlerAddress[num++] = 0xC4; + tlsHandlerAddress[num++] = 0x20; + tlsHandlerAddress[num++] = 0x41; // pop r11 + tlsHandlerAddress[num++] = 0x5B; + tlsHandlerAddress[num++] = 0x41; // pop r10 + tlsHandlerAddress[num++] = 0x5A; + tlsHandlerAddress[num++] = 0x41; // pop r9 + tlsHandlerAddress[num++] = 0x59; + tlsHandlerAddress[num++] = 0x41; // pop r8 + tlsHandlerAddress[num++] = 0x58; + tlsHandlerAddress[num++] = 0x5A; // pop rdx + tlsHandlerAddress[num++] = 0x59; // pop rcx + tlsHandlerAddress[num++] = 0x9D; // popfq + tlsHandlerAddress[num++] = 0xC3; // ret _tlsPatchStubOffset = (num + 15) & ~15; uint num2 = default(uint); if (!_hostMemory.Protect((ulong)(void*)_tlsHandlerAddress, TlsHandlerRegionSize, HostPageProtection.ReadExecute, out num2)) @@ -2613,7 +2695,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I { return false; } - lock (_guestThreadGate) + using (LockGate("TryStartThread")) { _guestThreads[request.ThreadHandle] = thread; _readyGuestThreads.Enqueue(thread); @@ -2649,10 +2731,15 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I return false; } + // Joins regularly park here for minutes (a game main thread joining a + // streamer); polling at a fixed 1ms burns half a host core for the + // whole wait, so back off toward a 10ms cadence once the join is + // clearly long-lived. + var joinPollMilliseconds = 1; while (!ActiveForcedGuestExit) { Thread? hostThread; - lock (_guestThreadGate) + using (LockGate("TryJoinThread")) { if (!_guestThreads.TryGetValue(threadHandle, out var thread)) { @@ -2689,16 +2776,21 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I try { - hostThread.Join(1); + hostThread.Join(joinPollMilliseconds); } catch (ThreadStateException) { - Thread.Sleep(1); + Thread.Sleep(joinPollMilliseconds); } } else { - Thread.Sleep(1); + Thread.Sleep(joinPollMilliseconds); + } + + if (joinPollMilliseconds < 10) + { + joinPollMilliseconds++; } } @@ -2728,7 +2820,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I for (int i = 0; i < 8; i++) { GuestThreadState? thread = null; - lock (_guestThreadGate) + using (LockGate("Pump.dequeue")) { while (_readyGuestThreads.Count > 0) { @@ -2759,7 +2851,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I Name = $"SharpEmu-{thread.Name}", Priority = MapGuestThreadPriority(thread.Priority), }; - lock (_guestThreadGate) + using (LockGate("Pump.bind_host")) { thread.HostThread = hostThread; } @@ -2780,7 +2872,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I } var wakeCount = 0; - lock (_guestThreadGate) + using (LockGate("WakeBlockedThreads")) { foreach (var thread in _guestThreads.Values) { @@ -2829,7 +2921,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I public IReadOnlyList SnapshotThreads() { - lock (_guestThreadGate) + using (LockGate("SnapshotThreads")) { var snapshots = new GuestThreadSnapshot[_guestThreads.Count]; var index = 0; @@ -2861,7 +2953,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I return; } - lock (_guestThreadGate) + using (LockGate("RegisterBlockedContinuation")) { if (!_guestThreads.TryGetValue(guestThreadHandle, out var thread)) { @@ -2880,7 +2972,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I { var now = Stopwatch.GetTimestamp(); var wakeCount = 0; - lock (_guestThreadGate) + using (LockGate("WakeExpiredBlockedGuestThreads")) { foreach (var thread in _guestThreads.Values) { @@ -2971,7 +3063,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I private GuestThreadState[] SnapshotGuestThreads() { - lock (_guestThreadGate) + using (LockGate("SnapshotGuestThreads")) { return _guestThreads.Values.ToArray(); } @@ -3161,7 +3253,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I if (currentGuestThreadHandle != 0) { GuestContinuationRunner? runner; - lock (_guestThreadGate) + using (LockGate("TryCallGuestContinuation")) { if (_guestThreads.TryGetValue(currentGuestThreadHandle, out var guestThread)) { @@ -3253,7 +3345,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I { _guestTeardownRequested = true; Thread[] hostThreads; - lock (_guestThreadGate) + using (LockGate("RequestGuestThreadTeardown")) { _readyGuestThreads.Clear(); Interlocked.Exchange(ref _readyGuestThreadCount, 0); @@ -3283,7 +3375,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I private void ClearGuestThreads() { GuestContinuationRunner[] runners; - lock (_guestThreadGate) + using (LockGate("ClearGuestThreads")) { runners = _guestThreads.Values .Select(static thread => thread.ContinuationRunner) @@ -3572,7 +3664,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I GuestCpuContinuation continuation = default; IGuestThreadBlockWaiter? blockWaiter = null; var resumeContinuation = false; - lock (_guestThreadGate) + using (LockGate("RunGuestThread.block")) { if (thread.HasBlockedContinuation) { @@ -3602,7 +3694,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I var exitReason = resumeContinuation ? ExecuteBlockedGuestThreadContinuation(thread.Context, continuation, thread.Name, out var blockReason) : ExecuteGuestThreadEntry(thread.Context, thread.EntryPoint, thread.Name, out blockReason); - lock (_guestThreadGate) + using (LockGate("RunGuestThread.exit")) { switch (exitReason) { @@ -4355,6 +4447,18 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I return 20; } + // SHARPEMU_PERIODIC_SNAPSHOT_SECONDS=N: dump the stall snapshot every N + // seconds regardless of progress, for diagnosing soft stalls where imports + // keep flowing but the game stops advancing. + private static int GetPeriodicSnapshotSeconds() + { + if (int.TryParse(Environment.GetEnvironmentVariable("SHARPEMU_PERIODIC_SNAPSHOT_SECONDS"), out var result)) + { + return Math.Max(0, result); + } + return 0; + } + private void StartStallWatchdog() { int stallWatchdogSeconds = GetStallWatchdogSeconds(); @@ -4384,6 +4488,8 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I dispatcherThread.Start(); long num = (long)((double)stallWatchdogSeconds * Stopwatch.Frequency); + var periodicSnapshotTicks = (long)((double)GetPeriodicSnapshotSeconds() * Stopwatch.Frequency); + var lastPeriodicSnapshot = Stopwatch.GetTimestamp(); _stallWatchdogThread = new Thread(new ThreadStart(delegate { while (!_stallWatchdogStop) @@ -4393,6 +4499,58 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I { break; } + if (periodicSnapshotTicks > 0 && + Stopwatch.GetTimestamp() - lastPeriodicSnapshot >= periodicSnapshotTicks) + { + lastPeriodicSnapshot = Stopwatch.GetTimestamp(); + var gateOwnerSite = _gateOwnerSite; + var gateOwnerTid = Volatile.Read(ref _gateOwnerManagedThreadId); + var gateHeldMs = gateOwnerSite is null + ? 0.0 + : Stopwatch.GetElapsedTime(Volatile.Read(ref _gateAcquireTimestamp)).TotalMilliseconds; + var snapshotText = new System.Text.StringBuilder(); + snapshotText.AppendLine( + $"[LOADER][DIAG] Periodic snapshot: gate_owner={gateOwnerSite ?? "none"} " + + $"gate_tid={gateOwnerTid} gate_held_ms={gateHeldMs:0}"); + // Never touch the gate here: the periodic snapshot must keep + // reporting even (especially) when the gate is wedged. + // Dump guest threads without the lock; tolerate torn reads. + try + { + foreach (var thread in _guestThreads.Values) + { + snapshotText.AppendLine( + $"[LOADER][DIAG] gateless guest-thread: handle=0x{thread.ThreadHandle:X16} name='{thread.Name}' " + + $"state={thread.State} imports={Interlocked.Read(ref thread.ImportCount)} " + + $"nid={Volatile.Read(ref thread.LastImportNid) ?? "none"} ret=0x{Volatile.Read(ref thread.LastReturnRip):X16} " + + $"block={thread.BlockReason ?? "none"} wake={thread.BlockWakeKey ?? "none"}"); + } + } + catch (Exception snapshotError) + { + snapshotText.AppendLine($"[LOADER][DIAG] gateless snapshot failed: {snapshotError.Message}"); + } + + // Console can be wedged by whatever is being diagnosed, so + // write to a side file when one is configured and fall back + // to stderr otherwise. + var snapshotPath = Environment.GetEnvironmentVariable("SHARPEMU_PERIODIC_SNAPSHOT_FILE"); + if (!string.IsNullOrWhiteSpace(snapshotPath)) + { + try + { + System.IO.File.AppendAllText(snapshotPath, snapshotText.ToString()); + } + catch + { + } + } + else + { + Console.Error.Write(snapshotText.ToString()); + Console.Error.Flush(); + } + } long num2 = Stopwatch.GetTimestamp() - Volatile.Read(ref _lastProgressTimestamp); if (num2 < num) { @@ -4441,7 +4599,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I private bool HasReadyGuestThread() { WakeExpiredBlockedGuestThreads(); - lock (_guestThreadGate) + using (LockGate("HasReadyGuestThread")) { foreach (var thread in _guestThreads.Values) { @@ -4630,6 +4788,14 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I // Native guest workers park idle once every guest thread has unwound; stop // them before any executable stub or TLS index they reference is freed. DisposeNativeGuestExecutors(); + + if (ReferenceEquals(_posixSignalBackend, this)) + { + // The signal handlers stay installed (they chain to the previous + // action when no backend is active), but must stop dispatching + // into a disposed backend. + _posixSignalBackend = null; + } ClearImportHandlerTrampolines(); _importEntries = Array.Empty(); _runtimeSymbolsByName.Clear(); diff --git a/src/SharpEmu.Core/Cpu/Native/NullHostFaultHandling.cs b/src/SharpEmu.Core/Cpu/Native/NullHostFaultHandling.cs new file mode 100644 index 0000000..011cb67 --- /dev/null +++ b/src/SharpEmu.Core/Cpu/Native/NullHostFaultHandling.cs @@ -0,0 +1,49 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE.Host; + +namespace SharpEmu.Core.Cpu.Native; + +/// +/// Placeholder for hosts whose fault bridge is installed directly by the +/// execution backend. POSIX uses its sigaction bridge and never calls these +/// Windows-shaped registration methods. +/// +internal sealed class NullHostFaultHandling : IHostFaultHandling +{ + public static NullHostFaultHandling Instance { get; } = new(); + + private NullHostFaultHandling() + { + } + + public nint CreateHandlerThunk(nint managedCallback, uint hostRspSwitchTlsSlot, nint tlsGetValueAddress) + { + _ = managedCallback; + _ = hostRspSwitchTlsSlot; + _ = tlsGetValueAddress; + return 0; + } + + public void FreeThunk(nint thunk) + { + _ = thunk; + } + + public nint AddFirstChanceHandler(nint thunk) + { + _ = thunk; + return 0; + } + + public void RemoveHandler(nint handle) + { + _ = handle; + } + + public void SetUnhandledFilter(nint thunk) + { + _ = thunk; + } +} diff --git a/src/SharpEmu.Core/Cpu/Native/Windows/Win64ContextOffsets.cs b/src/SharpEmu.Core/Cpu/Native/Windows/Win64ContextOffsets.cs index 10e22e8..79db3a9 100644 --- a/src/SharpEmu.Core/Cpu/Native/Windows/Win64ContextOffsets.cs +++ b/src/SharpEmu.Core/Cpu/Native/Windows/Win64ContextOffsets.cs @@ -11,6 +11,7 @@ namespace SharpEmu.Core.Cpu.Native.Windows; /// internal static class Win64ContextOffsets { + public const int Size = 0x4D0; public const int Mxcsr = 52; public const int Rax = 120; public const int Rcx = 128; diff --git a/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs b/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs index 8ac6ca8..7add040 100644 --- a/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs +++ b/src/SharpEmu.Core/Memory/PhysicalVirtualMemory.cs @@ -249,6 +249,71 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA var requestedCursor = AlignUp(desiredAddress, effectiveAlignment); var cursor = GetAllocationSearchCursor(desiredAddress, requestedCursor, effectiveAlignment, executable); + // Under Rosetta 2 the kernel can ignore placement hints for whole + // windows, so page-stepped exact probes are pathological on macOS. + // Linux must keep using the exact-address search below: PS5 resource + // descriptors cannot represent ordinary 0x7F... host mappings. Linux + // HostMemory uses MAP_FIXED_NOREPLACE, making those low-address probes + // safe without clobbering existing host mappings. + if (OperatingSystem.IsMacOS()) + { + // Prefer the requested low address. Besides matching the guest + // address model, this keeps the allocation representable by every + // PS5 GPU descriptor (the strictest ones carry 40 address bits). + try + { + var exactAddress = AllocateAt( + cursor, + alignedSize, + executable, + allowAlternative: false); + if (exactAddress == cursor) + { + actualAddress = exactAddress; + UpdateAllocationSearchCursor( + desiredAddress, + effectiveAlignment, + executable, + exactAddress + alignedSize); + return true; + } + } + catch + { + } + + // Over-allocate by the alignment so a kernel-chosen placement + // always contains an aligned start; the unused head/tail stays + // part of the tracked region and is simply never handed out. + var reserveSize = effectiveAlignment > PageSize + ? alignedSize + effectiveAlignment + : alignedSize; + try + { + var posixAddress = AllocateAt(cursor, reserveSize, executable, allowAlternative: true); + if (posixAddress != 0) + { + var alignedBase = AlignUp(posixAddress, effectiveAlignment); + const ulong gpuAddressLimit = 1UL << 40; + if (alignedBase < gpuAddressLimit && + alignedSize <= gpuAddressLimit - alignedBase && + alignedBase + alignedSize <= posixAddress + reserveSize) + { + actualAddress = alignedBase; + UpdateAllocationSearchCursor(desiredAddress, effectiveAlignment, executable, alignedBase + alignedSize); + return true; + } + + ReleaseUntrackedAllocation(posixAddress); + } + } + catch + { + } + + return false; + } + for (var attempt = 0; attempt < 0x10000; attempt++) { if (cursor == 0 || ulong.MaxValue - cursor < alignedSize) @@ -283,6 +348,28 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA return false; } + private void ReleaseUntrackedAllocation(ulong address) + { + _gate.EnterWriteLock(); + try + { + for (var i = 0; i < _regions.Count; i++) + { + if (_regions[i].VirtualAddress == address) + { + _regions.RemoveAt(i); + break; + } + } + } + finally + { + _gate.ExitWriteLock(); + } + + _hostMemory.Free(address); + } + public bool TryAllocateGuestMemory(ulong size, ulong alignment, out ulong address) { address = 0; diff --git a/src/SharpEmu.Core/packages.lock.json b/src/SharpEmu.Core/packages.lock.json index 42a3d8e..fe059ca 100644 --- a/src/SharpEmu.Core/packages.lock.json +++ b/src/SharpEmu.Core/packages.lock.json @@ -36,6 +36,23 @@ "Ultz.Native.GLFW": "3.4.0" } }, + "Silk.NET.Input.Common": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "QbJVV7kFBHEByayXCYdJtXXI9Sp4a+QAf0IdGV6uCWkFYcEmqBYW3aaNGvFOdSwTBDbHL5T/OtOCrGh4qYhk7A==", + "dependencies": { + "Silk.NET.Windowing.Common": "2.23.0" + } + }, + "Silk.NET.Input.Glfw": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "KGHYqsv/IQRJtD6dloYh2tN4CkaM40vxM2kj0cGKBoCQiBDYHHhJiyDTyMPx0W7Fz5IgnhnG42ELmIAa0DH69A==", + "dependencies": { + "Silk.NET.Input.Common": "2.23.0", + "Silk.NET.Windowing.Glfw": "2.23.0" + } + }, "Silk.NET.Maths": { "type": "Transitive", "resolved": "2.23.0", @@ -74,6 +91,7 @@ "type": "Project", "dependencies": { "SharpEmu.HLE": "[1.0.0, )", + "Silk.NET.Input": "[2.23.0, )", "Silk.NET.Vulkan": "[2.23.0, )", "Silk.NET.Vulkan.Extensions.EXT": "[2.23.0, )", "Silk.NET.Vulkan.Extensions.KHR": "[2.23.0, )", @@ -83,6 +101,16 @@ "sharpemu.logging": { "type": "Project" }, + "Silk.NET.Input": { + "type": "CentralTransitive", + "requested": "[2.23.0, )", + "resolved": "2.23.0", + "contentHash": "Xzl+tVwAp2eEd8blmGQjmJrsZPnp3PWG0KJjiAQHaY2Zr/ELVWeAROKXmZdCAvexzmte2JVGEy/dxnMycbxlpg==", + "dependencies": { + "Silk.NET.Input.Common": "2.23.0", + "Silk.NET.Input.Glfw": "2.23.0" + } + }, "Silk.NET.Vulkan": { "type": "CentralTransitive", "requested": "[2.23.0, )", diff --git a/src/SharpEmu.HLE/Host/HostPlatform.cs b/src/SharpEmu.HLE/Host/HostPlatform.cs index 15a60f5..30503bb 100644 --- a/src/SharpEmu.HLE/Host/HostPlatform.cs +++ b/src/SharpEmu.HLE/Host/HostPlatform.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later using System.Runtime.InteropServices; +using SharpEmu.HLE.Host.Posix; using SharpEmu.HLE.Host.Windows; namespace SharpEmu.HLE.Host; @@ -28,7 +29,14 @@ public static class HostPlatform return new WindowsHostPlatform(); } + if ((OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) && + RuntimeInformation.ProcessArchitecture == Architecture.X64) + { + return new PosixHostPlatform(); + } + throw new PlatformNotSupportedException( - "SharpEmu native guest execution requires a host platform backend and none exists for this OS/architecture yet (currently Windows x64 only)."); + "SharpEmu native guest execution requires an x86-64 process on Windows, Linux, or macOS. " + + "On Apple Silicon, use the osx-x64 build under Rosetta 2."); } } diff --git a/src/SharpEmu.HLE/Host/Posix/PosixAlsaAudioStream.cs b/src/SharpEmu.HLE/Host/Posix/PosixAlsaAudioStream.cs new file mode 100644 index 0000000..afcae68 --- /dev/null +++ b/src/SharpEmu.HLE/Host/Posix/PosixAlsaAudioStream.cs @@ -0,0 +1,173 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Runtime.InteropServices; + +namespace SharpEmu.HLE.Host.Posix; + +/// +/// ALSA-based playback for Linux. The PCM device is opened in blocking mode +/// with a device buffer sized to match the 32KB queue the other backends +/// keep, so snd_pcm_writei itself provides the backpressure pacing. The +/// "default" device routes through PulseAudio/PipeWire on desktops and to +/// the hardware on bare ALSA setups; SHARPEMU_ALSA_DEVICE overrides it. +/// +internal sealed unsafe class PosixAlsaAudioStream : IHostAudioStream +{ + // 32KB of stereo PCM16 at 48kHz is ~170ms; keep the same device-side + // queue depth the WinMM/CoreAudio ports enforce in managed code. + private const uint DeviceLatencyMicroseconds = 170_000; + private const int StreamPlayback = 0; + private const int FormatS16LittleEndian = 2; + private const int AccessReadWriteInterleaved = 3; + private const int ErrorPipe = -32; // -EPIPE, underrun + private const int ErrorStreamPipe = -86; // -ESTRPIPE, suspended + + private readonly object _gate = new(); + private nint _pcm; + private bool _disposed; + + public PosixAlsaAudioStream(uint sampleRate) + { + if (!OperatingSystem.IsLinux()) + { + throw new PlatformNotSupportedException("ALSA audio is only available on Linux."); + } + + var device = Environment.GetEnvironmentVariable("SHARPEMU_ALSA_DEVICE"); + if (string.IsNullOrWhiteSpace(device)) + { + device = "default"; + } + + var status = snd_pcm_open(out _pcm, device, StreamPlayback, 0); + if (status != 0) + { + throw new InvalidOperationException( + $"snd_pcm_open(\"{device}\") failed: {DescribeError(status)}."); + } + + status = snd_pcm_set_params( + _pcm, + FormatS16LittleEndian, + AccessReadWriteInterleaved, + 2, + sampleRate, + 1, + DeviceLatencyMicroseconds); + if (status != 0) + { + _ = snd_pcm_close(_pcm); + _pcm = 0; + throw new InvalidOperationException( + $"snd_pcm_set_params({sampleRate} Hz) failed: {DescribeError(status)}."); + } + } + + public bool Submit(ReadOnlySpan stereoPcm16) + { + lock (_gate) + { + if (_disposed) + { + return false; + } + + return WritePcm(stereoPcm16, (uint)(stereoPcm16.Length / 4)); + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + if (_pcm != 0) + { + _ = snd_pcm_drop(_pcm); + _ = snd_pcm_close(_pcm); + _pcm = 0; + } + } + } + + private bool WritePcm(ReadOnlySpan pcm, uint frames) + { + var recovered = false; + fixed (byte* data = pcm) + { + var offset = 0L; + while (offset < frames) + { + var written = snd_pcm_writei( + _pcm, + data + (offset * 4), + (nuint)(frames - offset)); + if (written >= 0) + { + offset += written; + continue; + } + + // One recovery attempt per submit covers underruns (-EPIPE) + // and suspend/resume (-ESTRPIPE); anything else, or a second + // failure, drops the buffer rather than stalling the guest. + if (recovered || + (written != ErrorPipe && written != ErrorStreamPipe) || + snd_pcm_recover(_pcm, (int)written, 1) != 0) + { + return false; + } + + recovered = true; + } + } + + return true; + } + + private static string DescribeError(long status) + { + var message = Marshal.PtrToStringUTF8(snd_strerror((int)status)); + return $"{message ?? "unknown error"} ({status})"; + } + + private const string Alsa = "libasound.so.2"; + + [DllImport(Alsa)] + private static extern int snd_pcm_open( + out nint pcm, + [MarshalAs(UnmanagedType.LPUTF8Str)] string name, + int stream, + int mode); + + [DllImport(Alsa)] + private static extern int snd_pcm_set_params( + nint pcm, + int format, + int access, + uint channels, + uint rate, + int softResample, + uint latencyUs); + + [DllImport(Alsa)] + private static extern long snd_pcm_writei(nint pcm, byte* buffer, nuint frames); + + [DllImport(Alsa)] + private static extern int snd_pcm_recover(nint pcm, int error, int silent); + + [DllImport(Alsa)] + private static extern int snd_pcm_drop(nint pcm); + + [DllImport(Alsa)] + private static extern int snd_pcm_close(nint pcm); + + [DllImport(Alsa)] + private static extern nint snd_strerror(int error); +} diff --git a/src/SharpEmu.HLE/Host/Posix/PosixCoreAudioStream.cs b/src/SharpEmu.HLE/Host/Posix/PosixCoreAudioStream.cs new file mode 100644 index 0000000..f5ff329 --- /dev/null +++ b/src/SharpEmu.HLE/Host/Posix/PosixCoreAudioStream.cs @@ -0,0 +1,261 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Runtime.InteropServices; + +namespace SharpEmu.HLE.Host.Posix; + +/// +/// AudioQueue-based playback for macOS. Buffers are enqueued as stereo PCM16 +/// and returned by the queue's internal thread through the output callback; +/// Submit applies the same 32KB backpressure the WinMM backend uses so guest +/// pacing works identically. +/// +internal sealed unsafe class PosixCoreAudioStream : IHostAudioStream +{ + private const int MaximumQueuedPcmBytes = 32 * 1024; + private const uint FormatLinearPcm = 0x6C70636D; // 'lpcm' + private const uint FlagIsSignedInteger = 0x4; + private const uint FlagIsPacked = 0x8; + + private readonly object _gate = new(); + private readonly AutoResetEvent _completion = new(false); + private readonly Queue _freeBuffers = new(); + private GCHandle _selfHandle; + private nint _queue; + private int _queuedPcmBytes; + private bool _started; + private bool _disposed; + + public PosixCoreAudioStream(uint sampleRate) + { + if (!OperatingSystem.IsMacOS()) + { + throw new PlatformNotSupportedException("CoreAudio is only available on macOS."); + } + + var format = new AudioStreamBasicDescription + { + SampleRate = sampleRate, + FormatId = FormatLinearPcm, + FormatFlags = FlagIsSignedInteger | FlagIsPacked, + BytesPerPacket = 4, + FramesPerPacket = 1, + BytesPerFrame = 4, + ChannelsPerFrame = 2, + BitsPerChannel = 16, + }; + + _selfHandle = GCHandle.Alloc(this); + var status = AudioQueueNewOutput( + &format, + &OutputCallback, + GCHandle.ToIntPtr(_selfHandle), + 0, + 0, + 0, + out _queue); + if (status != 0) + { + _selfHandle.Free(); + throw new InvalidOperationException($"AudioQueueNewOutput failed with OSStatus {status}."); + } + } + + public bool Submit(ReadOnlySpan stereoPcm16) + { + lock (_gate) + { + if (_disposed || _queue == 0) + { + return false; + } + + var outputLength = stereoPcm16.Length; + while (_queuedPcmBytes != 0 && + _queuedPcmBytes + outputLength > MaximumQueuedPcmBytes) + { + Monitor.Exit(_gate); + try + { + // Dispose can free the event while this thread waits + // outside the gate; treat that like a timed-out wait. + if (!_completion.WaitOne(TimeSpan.FromSeconds(1))) + { + return false; + } + } + catch (ObjectDisposedException) + { + return false; + } + finally + { + Monitor.Enter(_gate); + } + + if (_disposed) + { + return false; + } + } + + if (!TryTakeBuffer(outputLength, out var buffer)) + { + return false; + } + + var audioData = ((AudioQueueBuffer*)buffer)->AudioData; + stereoPcm16.CopyTo(new Span(audioData, outputLength)); + + ((AudioQueueBuffer*)buffer)->AudioDataByteSize = (uint)outputLength; + if (AudioQueueEnqueueBuffer(_queue, buffer, 0, 0) != 0) + { + _freeBuffers.Enqueue(buffer); + return false; + } + + _queuedPcmBytes += outputLength; + if (!_started) + { + if (AudioQueueStart(_queue, 0) != 0) + { + // A queue that never starts never drains, so later + // submits would block on backpressure until their + // timeout. Tear the queue down and fail fast instead. + _ = AudioQueueDispose(_queue, true); + _queue = 0; + _queuedPcmBytes = 0; + _freeBuffers.Clear(); + return false; + } + + _started = true; + } + + return true; + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + if (_queue != 0) + { + // Synchronous dispose stops the queue, frees its buffers, and + // guarantees no further callbacks reference this instance. + _ = AudioQueueDispose(_queue, true); + _queue = 0; + } + + _freeBuffers.Clear(); + // Wake any submitter waiting on backpressure before the event + // goes away; a late waiter observes ObjectDisposedException and + // bails out in Submit. + _completion.Set(); + _completion.Dispose(); + if (_selfHandle.IsAllocated) + { + _selfHandle.Free(); + } + } + } + + private bool TryTakeBuffer(int length, out nint buffer) + { + while (_freeBuffers.TryDequeue(out buffer)) + { + if (((AudioQueueBuffer*)buffer)->AudioDataBytesCapacity >= (uint)length) + { + return true; + } + + _ = AudioQueueFreeBuffer(_queue, buffer); + } + + return AudioQueueAllocateBuffer(_queue, (uint)length, out buffer) == 0; + } + + [UnmanagedCallersOnly] + private static void OutputCallback(nint userData, nint queue, nint buffer) + { + if (GCHandle.FromIntPtr(userData).Target is not PosixCoreAudioStream port) + { + return; + } + + lock (port._gate) + { + if (port._disposed) + { + return; + } + + port._queuedPcmBytes -= checked((int)((AudioQueueBuffer*)buffer)->AudioDataByteSize); + port._freeBuffers.Enqueue(buffer); + } + + port._completion.Set(); + } + + [StructLayout(LayoutKind.Sequential)] + private struct AudioStreamBasicDescription + { + public double SampleRate; + public uint FormatId; + public uint FormatFlags; + public uint BytesPerPacket; + public uint FramesPerPacket; + public uint BytesPerFrame; + public uint ChannelsPerFrame; + public uint BitsPerChannel; + public uint Reserved; + } + + [StructLayout(LayoutKind.Sequential)] + private struct AudioQueueBuffer + { + public uint AudioDataBytesCapacity; + public void* AudioData; + public uint AudioDataByteSize; + public nint UserData; + public uint PacketDescriptionCapacity; + public nint PacketDescriptions; + public uint PacketDescriptionCount; + } + + private const string AudioToolbox = + "/System/Library/Frameworks/AudioToolbox.framework/AudioToolbox"; + + [DllImport(AudioToolbox)] + private static extern int AudioQueueNewOutput( + AudioStreamBasicDescription* format, + delegate* unmanaged callback, + nint userData, + nint callbackRunLoop, + nint runLoopMode, + uint flags, + out nint queue); + + [DllImport(AudioToolbox)] + private static extern int AudioQueueAllocateBuffer(nint queue, uint bufferByteSize, out nint buffer); + + [DllImport(AudioToolbox)] + private static extern int AudioQueueFreeBuffer(nint queue, nint buffer); + + [DllImport(AudioToolbox)] + private static extern int AudioQueueEnqueueBuffer(nint queue, nint buffer, uint packetDescriptionCount, nint packetDescriptions); + + [DllImport(AudioToolbox)] + private static extern int AudioQueueStart(nint queue, nint startTime); + + [DllImport(AudioToolbox)] + private static extern int AudioQueueDispose(nint queue, [MarshalAs(UnmanagedType.I1)] bool immediate); +} diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostAudio.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostAudio.cs new file mode 100644 index 0000000..133479e --- /dev/null +++ b/src/SharpEmu.HLE/Host/Posix/PosixHostAudio.cs @@ -0,0 +1,21 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.HLE.Host.Posix; + +/// +/// POSIX audio output: CoreAudio (AudioQueue) on macOS, ALSA on Linux. Both +/// streams accept the seam's interleaved stereo PCM16 and pace the guest via +/// device-queue backpressure. +/// +internal sealed class PosixHostAudio : IHostAudioOutput +{ + public string BackendName => OperatingSystem.IsMacOS() ? "coreaudio" : "alsa"; + + public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate) + { + return OperatingSystem.IsMacOS() + ? new PosixCoreAudioStream(sampleRate) + : new PosixAlsaAudioStream(sampleRate); + } +} diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs new file mode 100644 index 0000000..303d6c8 --- /dev/null +++ b/src/SharpEmu.HLE/Host/Posix/PosixHostInput.cs @@ -0,0 +1,79 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.HLE.Host.Posix; + +/// +/// Bridges a window-provided input source into the host input seam. POSIX +/// hosts have no user32/XInput/raw-HID readers; keyboard and gamepad state +/// come from the presenter's GLFW window instead, which registers itself via +/// once the window exists. Until then (and with no +/// window at all, e.g. headless runs) every query reports neutral input. +/// Rumble and lightbar are unsupported by the GLFW input layer and no-op. +/// +public interface IPosixWindowInputSource +{ + /// True while the window's keyboard is delivering events. + bool HasKeyboardFocus { get; } + + /// Windows virtual-key semantics; the source translates. + bool IsKeyDown(int virtualKey); + + /// Same contract as . + int GetGamepadStates(Span destination); + + string? DescribeConnectedGamepad(); +} + +// Public so the presenter's window layer (SharpEmu.Libs) can register its +// input source; the platform still constructs the singleton itself. +public sealed class PosixHostInput : IHostInput +{ + private static volatile IPosixWindowInputSource? _source; + + /// Called by the presenter's window layer when input is ready. + public static void SetSource(IPosixWindowInputSource source) + { + _source = source; + } + + public void EnsureStarted() + { + // Device readers are event-driven off the window thread; nothing to start. + } + + public int GetGamepadStates(Span destination) + { + return _source?.GetGamepadStates(destination) ?? 0; + } + + public string? DescribeConnectedGamepad() => _source?.DescribeConnectedGamepad(); + + public void SetRumble(byte largeMotor, byte smallMotor) + { + } + + public void SetTriggerRumble(byte? leftTrigger, byte? rightTrigger) + { + } + + public void SetLightbar(byte red, byte green, byte blue) + { + } + + public void ResetLightbar() + { + } + + public bool IsHostWindowFocused() + { + // GLFW only delivers key events to the focused window, so a + // delivering keyboard implies focus. + return _source?.HasKeyboardFocus ?? false; + } + + public bool IsKeyDown(int virtualKey) + { + return _source?.IsKeyDown(virtualKey) ?? false; + } +} diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostMemory.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostMemory.cs new file mode 100644 index 0000000..a463d8b --- /dev/null +++ b/src/SharpEmu.HLE/Host/Posix/PosixHostMemory.cs @@ -0,0 +1,508 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Runtime.InteropServices; + +namespace SharpEmu.HLE.Host.Posix; + +/// +/// POSIX virtual memory backend implemented over mmap/mprotect/munmap with a +/// shadow region table that answers VirtualQuery-style questions and tracks +/// page protections. +/// POSIX anonymous mappings are demand-paged by the kernel, so Win32 +/// "reserve-only" regions are mapped as committed memory directly and +/// commit requests become protection changes. +/// +internal sealed unsafe class PosixHostMemory : IHostMemory +{ + private const uint MEM_COMMIT = 0x1000; + private const uint MEM_RESERVE = 0x2000; + private const uint MEM_RELEASE = 0x8000; + private const uint MEM_FREE_STATE = 0x10000; + private const uint MEM_PRIVATE = 0x20000; + + private const uint PAGE_NOACCESS = 0x01; + private const uint PAGE_READONLY = 0x02; + private const uint PAGE_READWRITE = 0x04; + private const uint PAGE_EXECUTE = 0x10; + private const uint PAGE_EXECUTE_READ = 0x20; + private const uint PAGE_EXECUTE_READWRITE = 0x40; + + private const ulong PageSize = 0x1000; + + private struct BasicInfo + { + public ulong BaseAddress; + public ulong AllocationBase; + public uint AllocationProtect; + public ulong RegionSize; + public uint State; + public uint Protect; + public uint Type; + } + + public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) + { + return (ulong)Posix.Alloc( + (void*)desiredAddress, + (nuint)size, + MEM_COMMIT | MEM_RESERVE, + ToNativeProtection(protection)); + } + + public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection) + { + return (ulong)Posix.Alloc( + (void*)desiredAddress, + (nuint)size, + MEM_RESERVE, + ToNativeProtection(protection)); + } + + public bool Commit(ulong address, ulong size, HostPageProtection protection) + { + return Posix.Alloc( + (void*)address, + (nuint)size, + MEM_COMMIT, + ToNativeProtection(protection)) != null; + } + + public bool Free(ulong address) + { + return Posix.Free((void*)address, 0, MEM_RELEASE); + } + + public bool Protect( + ulong address, + ulong size, + HostPageProtection protection, + out uint rawOldProtection) + { + return Posix.Protect( + (void*)address, + (nuint)size, + ToNativeProtection(protection), + out rawOldProtection); + } + + public bool ProtectRaw( + ulong address, + ulong size, + uint rawProtection, + out uint rawOldProtection) + { + return Posix.Protect( + (void*)address, + (nuint)size, + rawProtection, + out rawOldProtection); + } + + public bool Query(ulong address, out HostRegionInfo info) + { + if (Posix.Query((void*)address, out var nativeInfo) == 0) + { + info = default; + return false; + } + + info = new HostRegionInfo( + nativeInfo.BaseAddress, + nativeInfo.AllocationBase, + nativeInfo.RegionSize, + nativeInfo.State switch + { + MEM_COMMIT => HostRegionState.Committed, + MEM_RESERVE => HostRegionState.Reserved, + _ => HostRegionState.Free, + }, + nativeInfo.State, + ToHostProtection(nativeInfo.Protect), + nativeInfo.Protect, + nativeInfo.AllocationProtect); + return true; + } + + public void FlushInstructionCache(ulong address, ulong size) + { + _ = address; + _ = size; + // The supported POSIX process is x86-64 (including Rosetta 2), whose + // instruction cache is coherent. A future arm64 backend must call the + // platform instruction-cache invalidation API here. + } + + private static uint ToNativeProtection(HostPageProtection protection) => protection switch + { + HostPageProtection.NoAccess => PAGE_NOACCESS, + HostPageProtection.ReadOnly => PAGE_READONLY, + HostPageProtection.ReadWrite => PAGE_READWRITE, + HostPageProtection.Execute => PAGE_EXECUTE, + HostPageProtection.ReadExecute => PAGE_EXECUTE_READ, + HostPageProtection.ReadWriteExecute => PAGE_EXECUTE_READWRITE, + HostPageProtection.ExecuteWriteCopy => PAGE_EXECUTE_READWRITE, + _ => throw new ArgumentOutOfRangeException(nameof(protection), protection, null), + }; + + private static HostPageProtection ToHostProtection(uint protection) => protection switch + { + PAGE_READONLY => HostPageProtection.ReadOnly, + PAGE_READWRITE => HostPageProtection.ReadWrite, + PAGE_EXECUTE => HostPageProtection.Execute, + PAGE_EXECUTE_READ => HostPageProtection.ReadExecute, + PAGE_EXECUTE_READWRITE => HostPageProtection.ReadWriteExecute, + _ => HostPageProtection.NoAccess, + }; + + private static class Posix + { + private const int PROT_NONE = 0x0; + private const int PROT_READ = 0x1; + private const int PROT_WRITE = 0x2; + private const int PROT_EXEC = 0x4; + + private const int MAP_PRIVATE = 0x02; + private const int MAP_FIXED = 0x10; + private static readonly int MAP_ANON = OperatingSystem.IsMacOS() ? 0x1000 : 0x20; + private static readonly int MAP_NORESERVE = OperatingSystem.IsMacOS() ? 0 : 0x4000; + + // Linux-only: fail instead of clobbering an existing mapping. + private const int MAP_FIXED_NOREPLACE = 0x100000; + + private static readonly nint MAP_FAILED = -1; + + private static readonly object Gate = new(); + private static readonly SortedList Regions = new(); + + private sealed class Region + { + public ulong Base; + public ulong Size; + public uint DefaultProtect; + public Dictionary? PageProtects; + + public ulong End => Base + Size; + + public uint ProtectAt(ulong pageAddress) + { + if (PageProtects is not null && PageProtects.TryGetValue(pageAddress, out var overriden)) + { + return overriden; + } + + return DefaultProtect; + } + } + + public static void* Alloc(void* address, nuint size, uint allocationType, uint protect) + { + if (size == 0) + { + return null; + } + + var alignedSize = AlignUp((ulong)size, PageSize); + + lock (Gate) + { + if (allocationType == MEM_COMMIT && address != null && + TryFindRegionLocked((ulong)address, out var existing)) + { + // Note: MEM_RESERVE requests that overlap an existing + // region must fail like Win32 does; only a pure commit + // may target pages inside a tracked mapping. + // Commit inside an existing mapping: the pages are already + // backed (demand paged), so only apply the protection. + var start = AlignDown((ulong)address, PageSize); + var end = AlignUp((ulong)address + alignedSize, PageSize); + if (end <= start || end > existing.End) + { + // Win32 fails a commit that runs past its reservation + // instead of committing a prefix; committing partially + // here would let callers believe the whole range is + // usable. + return null; + } + + if (mprotect((nint)start, (nuint)(end - start), ToPosixProtect(protect)) != 0) + { + return null; + } + + SetProtectRangeLocked(existing, start, end - start, protect); + return address; + } + + if ((allocationType & MEM_RESERVE) == 0) + { + // MEM_COMMIT alone outside any known region is invalid here. + return null; + } + + var posixProtect = ToPosixProtect(protect); + var flags = MAP_PRIVATE | MAP_ANON; + if ((allocationType & MEM_COMMIT) == 0) + { + // Reserve-only: keep the requested protection so the region + // is usable without a separate commit step, but tell the + // kernel not to account swap for it where supported. + flags |= MAP_NORESERVE; + } + + nint result; + if (address != null) + { + // Win32 maps at exactly the requested address or fails + // without touching existing mappings. Fail up front on + // any overlap we track, then place the mapping: Linux + // gets MAP_FIXED_NOREPLACE (fails cleanly on host + // mappings too). Darwin lacks NOREPLACE and plain + // MAP_FIXED would silently clobber untracked host + // memory (dyld, the runtime's JIT heap, Rosetta), so + // pass the address as a hint instead -- the kernel + // honors it when the range is free and relocates the + // mapping otherwise, which we treat as failure. + if (OverlapsTrackedRegionLocked((ulong)address, alignedSize)) + { + Trace($"exact overlap: addr=0x{(ulong)address:X16} size=0x{alignedSize:X}"); + return null; + } + + var exactFlags = OperatingSystem.IsMacOS() ? flags : flags | MAP_FIXED_NOREPLACE; + result = mmap((nint)address, (nuint)alignedSize, posixProtect, exactFlags, -1, 0); + if (result == MAP_FAILED || (ulong)result != (ulong)address) + { + Trace($"exact mmap failed: addr=0x{(ulong)address:X16} got=0x{(ulong)result:X16} size=0x{alignedSize:X} errno={Marshal.GetLastPInvokeError()}"); + if (result != MAP_FAILED) + { + munmap(result, (nuint)alignedSize); + } + + return null; + } + } + else + { + result = mmap(0, (nuint)alignedSize, posixProtect, flags, -1, 0); + if (result == MAP_FAILED) + { + Trace($"mmap failed: size=0x{alignedSize:X} errno={Marshal.GetLastPInvokeError()}"); + return null; + } + } + + Regions[(ulong)result] = new Region + { + Base = (ulong)result, + Size = alignedSize, + DefaultProtect = protect + }; + + return (void*)result; + } + } + + public static bool Free(void* address, nuint size, uint freeType) + { + _ = size; + _ = freeType; + + lock (Gate) + { + if (!Regions.TryGetValue((ulong)address, out var region)) + { + return false; + } + + Regions.Remove((ulong)address); + return munmap((nint)address, (nuint)region.Size) == 0; + } + } + + public static bool Protect(void* address, nuint size, uint newProtect, out uint oldProtect) + { + oldProtect = PAGE_NOACCESS; + if (size == 0) + { + return false; + } + + var start = AlignDown((ulong)address, PageSize); + var end = AlignUp((ulong)address + size, PageSize); + + lock (Gate) + { + if (!TryFindRegionLocked(start, out var region) || end > region.End) + { + return false; + } + + oldProtect = region.ProtectAt(start); + if (mprotect((nint)start, (nuint)(end - start), ToPosixProtect(newProtect)) != 0) + { + return false; + } + + SetProtectRangeLocked(region, start, end - start, newProtect); + return true; + } + } + + public static nuint Query(void* address, out BasicInfo info) + { + info = default; + var pageAddress = AlignDown((ulong)address, PageSize); + + lock (Gate) + { + if (TryFindRegionLocked(pageAddress, out var region)) + { + // Win32 VirtualQuery reports a run of pages sharing the + // same protection, so stop the run where it changes. + var protect = region.ProtectAt(pageAddress); + var runEnd = pageAddress + PageSize; + while (runEnd < region.End && region.ProtectAt(runEnd) == protect) + { + runEnd += PageSize; + } + + info.BaseAddress = pageAddress; + info.AllocationBase = region.Base; + info.AllocationProtect = region.DefaultProtect; + info.RegionSize = runEnd - pageAddress; + info.State = MEM_COMMIT; + info.Protect = protect; + info.Type = MEM_PRIVATE; + return (nuint)sizeof(BasicInfo); + } + + // Untracked host memory (runtime heaps, stacks, libraries) is + // reported as a free block reaching to the next tracked region + // so scanning callers keep advancing. + var nextBase = ulong.MaxValue; + foreach (var regionBase in Regions.Keys) + { + if (regionBase > pageAddress) + { + nextBase = regionBase; + break; + } + } + + info.BaseAddress = pageAddress; + info.AllocationBase = 0; + info.AllocationProtect = PAGE_NOACCESS; + info.RegionSize = (nextBase == ulong.MaxValue ? pageAddress + PageSize : nextBase) - pageAddress; + info.State = MEM_FREE_STATE; + info.Protect = PAGE_NOACCESS; + info.Type = 0; + return (nuint)sizeof(BasicInfo); + } + } + + private static bool OverlapsTrackedRegionLocked(ulong start, ulong size) + { + var end = start + size; + foreach (var region in Regions.Values) + { + if (region.Base < end && start < region.End) + { + return true; + } + } + + return false; + } + + private static bool TryFindRegionLocked(ulong address, out Region region) + { + region = null!; + var keys = Regions.Keys; + var low = 0; + var high = keys.Count - 1; + Region? candidate = null; + while (low <= high) + { + var middle = low + ((high - low) >> 1); + var entry = Regions.Values[middle]; + if (entry.Base <= address) + { + candidate = entry; + low = middle + 1; + } + else + { + high = middle - 1; + } + } + + if (candidate is null || address >= candidate.End) + { + return false; + } + + region = candidate; + return true; + } + + private static void SetProtectRangeLocked(Region region, ulong start, ulong size, uint protect) + { + if (start == region.Base && size >= region.Size) + { + region.DefaultProtect = protect; + region.PageProtects = null; + return; + } + + region.PageProtects ??= new Dictionary(); + var end = start + size; + for (var pageAddress = start; pageAddress < end; pageAddress += PageSize) + { + if (protect == region.DefaultProtect) + { + region.PageProtects.Remove(pageAddress); + } + else + { + region.PageProtects[pageAddress] = protect; + } + } + } + + private static int ToPosixProtect(uint win32Protect) + { + return win32Protect switch + { + PAGE_NOACCESS => PROT_NONE, + PAGE_READONLY => PROT_READ, + PAGE_READWRITE => PROT_READ | PROT_WRITE, + PAGE_EXECUTE => PROT_READ | PROT_EXEC, + PAGE_EXECUTE_READ => PROT_READ | PROT_EXEC, + PAGE_EXECUTE_READWRITE => PROT_READ | PROT_WRITE | PROT_EXEC, + _ => PROT_READ | PROT_WRITE + }; + } + + private static void Trace(string message) + { + if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_VMEM"), "1", StringComparison.Ordinal)) + { + Console.Error.WriteLine($"[HOSTMEM] {message}"); + } + } + + private static ulong AlignDown(ulong value, ulong alignment) => value & ~(alignment - 1); + + private static ulong AlignUp(ulong value, ulong alignment) => checked((value + alignment - 1) & ~(alignment - 1)); + + [DllImport("libc", SetLastError = true)] + private static extern nint mmap(nint addr, nuint length, int prot, int flags, int fd, long offset); + + [DllImport("libc", SetLastError = true)] + private static extern int munmap(nint addr, nuint length); + + [DllImport("libc", SetLastError = true)] + private static extern int mprotect(nint addr, nuint length, int prot); + } +} diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostPlatform.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostPlatform.cs new file mode 100644 index 0000000..fc452d3 --- /dev/null +++ b/src/SharpEmu.HLE/Host/Posix/PosixHostPlatform.cs @@ -0,0 +1,17 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.HLE.Host.Posix; + +internal sealed class PosixHostPlatform : IHostPlatform +{ + public IHostMemory Memory { get; } = new PosixHostMemory(); + + public IHostThreading Threading { get; } = new PosixHostThreading(); + + public IHostSymbolResolver Symbols { get; } = new PosixHostSymbolResolver(); + + public IHostAudioOutput Audio { get; } = new PosixHostAudio(); + + public IHostInput Input { get; } = new PosixHostInput(); +} diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostStubs.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostStubs.cs new file mode 100644 index 0000000..22ae8ec --- /dev/null +++ b/src/SharpEmu.HLE/Host/Posix/PosixHostStubs.cs @@ -0,0 +1,657 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Runtime.InteropServices; + +namespace SharpEmu.HLE.Host.Posix; + +/// +/// POSIX replacements for the kernel32 helpers the native backend embeds in +/// emitted x86-64 code. Every stub exposed here follows the Win64 calling +/// convention the emitted call sites were written for (first argument in +/// ECX, result in RAX, Win64 non-volatile registers preserved), so the +/// emission code stays identical across platforms. +/// +internal static unsafe class PosixHostStubs +{ + private static readonly object Gate = new(); + private static bool _initialized; + private static nint _tlsGetValueStub; + private static nint _queryPerformanceCounterStub; + private static nint _switchToThreadStub; + private static nint _sleepStub; + private static nint _waitForSingleObjectStub; + private static nint _setEventStub; + private static nint _exitThreadStub; + + public static nint TlsGetValueStubAddress + { + get { EnsureInitialized(); return _tlsGetValueStub; } + } + + public static nint QueryPerformanceCounterStubAddress + { + get { EnsureInitialized(); return _queryPerformanceCounterStub; } + } + + public static nint SwitchToThreadStubAddress + { + get { EnsureInitialized(); return _switchToThreadStub; } + } + + public static nint SleepStubAddress + { + get { EnsureInitialized(); return _sleepStub; } + } + + /// + /// Win64-convention replacements for the kernel32 event/thread helpers the + /// native guest worker loop embeds. The "handle" they take is a worker + /// event created by : a dispatch semaphore + /// on macOS, an unnamed POSIX semaphore on Linux. The wait stub always + /// waits forever (the worker loop passes INFINITE) and retries EINTR. + /// + public static nint WaitForSingleObjectStubAddress + { + get { EnsureInitialized(); return _waitForSingleObjectStub; } + } + + public static nint SetEventStubAddress + { + get { EnsureInitialized(); return _setEventStub; } + } + + public static nint ExitThreadStubAddress + { + get { EnsureInitialized(); return _exitThreadStub; } + } + + /// + /// Creates a binary-semaphore worker event signalable/waitable both from + /// managed code and from emitted native code (via the stub addresses + /// above). Returns 0 on failure. + /// + public static nint CreateWorkerEvent() + { + if (OperatingSystem.IsMacOS()) + { + return dispatch_semaphore_create(0); + } + + var semaphore = Marshal.AllocHGlobal(64); + if (sem_init(semaphore, 0, 0) != 0) + { + Marshal.FreeHGlobal(semaphore); + return 0; + } + + return semaphore; + } + + public static bool SignalWorkerEvent(nint handle) + { + if (OperatingSystem.IsMacOS()) + { + _ = dispatch_semaphore_signal(handle); + return true; + } + + return sem_post(handle) == 0; + } + + /// Waits for a worker event; a negative timeout waits forever. + public static bool WaitWorkerEvent(nint handle, int timeoutMilliseconds) + { + if (OperatingSystem.IsMacOS()) + { + if (timeoutMilliseconds < 0) + { + return dispatch_semaphore_wait(handle, ulong.MaxValue) == 0; + } + + var deadline = dispatch_time(0, timeoutMilliseconds * 1_000_000L); + return dispatch_semaphore_wait(handle, deadline) == 0; + } + + if (timeoutMilliseconds < 0) + { + while (sem_wait(handle) != 0) + { + // EINTR: retry. + } + + return true; + } + + var deadlineTicks = Environment.TickCount64 + timeoutMilliseconds; + while (sem_trywait(handle) != 0) + { + if (Environment.TickCount64 >= deadlineTicks) + { + return false; + } + + System.Threading.Thread.Sleep(1); + } + + return true; + } + + public static void DestroyWorkerEvent(nint handle) + { + if (handle == 0) + { + return; + } + + if (OperatingSystem.IsMacOS()) + { + dispatch_release(handle); + return; + } + + _ = sem_destroy(handle); + Marshal.FreeHGlobal(handle); + } + + /// + /// Starts a raw pthread at a native entry point (pthread entries take their + /// argument in RDI; the worker loop stub ignores it). Returns an opaque + /// handle for /, + /// or 0 on failure. + /// + public static nint CreateWorkerThread(nint entry, nint parameter, nuint stackReserveBytes, out uint threadId) + { + threadId = 0; + byte* attr = stackalloc byte[512]; + if (pthread_attr_init(attr) != 0) + { + return 0; + } + + try + { + if (stackReserveBytes != 0) + { + _ = pthread_attr_setstacksize(attr, nuint.Max(stackReserveBytes, 512 * 1024)); + } + + nint thread; + if (pthread_create(&thread, attr, entry, parameter) != 0) + { + return 0; + } + + if (OperatingSystem.IsMacOS()) + { + ulong numericId; + if (pthread_threadid_np(thread, &numericId) == 0) + { + threadId = unchecked((uint)numericId); + } + } + else + { + threadId = unchecked((uint)thread); + } + + var holder = (nint*)Marshal.AllocHGlobal(sizeof(nint) * 2); + holder[0] = thread; + holder[1] = 0; // joined flag + return (nint)holder; + } + finally + { + _ = pthread_attr_destroy(attr); + } + } + + /// + /// Waits for a worker thread to exit. Liveness is probed with + /// pthread_kill(thread, 0) (ESRCH once the thread has terminated) because + /// neither platform offers a portable timed join; the exited thread is then + /// joined so its resources are reclaimed. + /// + public static bool WaitForWorkerThreadExit(nint threadHandle, uint timeoutMilliseconds) + { + var holder = (nint*)threadHandle; + if (holder == null) + { + return false; + } + + if (holder[1] != 0) + { + return true; + } + + var thread = holder[0]; + var deadline = Environment.TickCount64 + timeoutMilliseconds; + while (pthread_kill(thread, 0) == 0) + { + if (Environment.TickCount64 >= deadline) + { + return false; + } + + System.Threading.Thread.Sleep(1); + } + + _ = pthread_join(thread, null); + holder[1] = 1; + return true; + } + + public static void CloseWorkerThreadHandle(nint threadHandle) + { + var holder = (nint*)threadHandle; + if (holder == null) + { + return; + } + + if (holder[1] == 0) + { + // Never observed exiting: detach so the thread does not leak a + // zombie join target when it eventually terminates. + _ = pthread_detach(holder[0]); + } + + Marshal.FreeHGlobal(threadHandle); + } + + /// Allocates a pthread TLS key, mirroring kernel32!TlsAlloc. + public static uint TlsAlloc() + { + if (OperatingSystem.IsMacOS()) + { + nuint key; + return pthread_key_create_mac(&key, 0) == 0 ? (uint)key : uint.MaxValue; + } + + uint key32; + return pthread_key_create_linux(&key32, 0) == 0 ? key32 : uint.MaxValue; + } + + public static bool TlsFree(uint key) + { + return OperatingSystem.IsMacOS() + ? pthread_key_delete_mac((nuint)key) == 0 + : pthread_key_delete_linux(key) == 0; + } + + public static bool TlsSetValue(uint key, nint value) + { + return OperatingSystem.IsMacOS() + ? pthread_setspecific_mac((nuint)key, value) == 0 + : pthread_setspecific_linux(key, value) == 0; + } + + public static nint TlsGetValue(uint key) + { + return OperatingSystem.IsMacOS() + ? pthread_getspecific_mac((nuint)key) + : pthread_getspecific_linux(key); + } + + /// Stable numeric id of the calling thread (kernel32!GetCurrentThreadId). + public static uint GetCurrentThreadId() + { + if (OperatingSystem.IsMacOS()) + { + ulong tid; + return pthread_threadid_np(0, &tid) == 0 ? unchecked((uint)tid) : 0u; + } + + return unchecked((uint)gettid()); + } + + /// + /// Wraps a managed callback (compiled for the SysV ABI on POSIX .NET) in a + /// thunk that accepts up to four integer arguments in the Win64 ABI the + /// emitted x86-64 call sites use. Win64 passes args in rcx/rdx/r8/r9 and + /// treats rdi/rsi as non-volatile; SysV expects rdi/rsi/rdx/rcx and + /// clobbers them, so the thunk saves rdi/rsi, shuffles the registers, keeps + /// the stack 16-byte aligned for the call, and forwards the rax result. + /// + public static nint CreateWin64ToSysVThunk(nint sysvTarget) + { + var memory = HostPlatform.Current.Memory; + var page = (byte*)memory.Allocate( + 0, + 4096, + HostPageProtection.ReadWriteExecute); + if (page == null) + { + throw new OutOfMemoryException("Failed to allocate Win64->SysV thunk page"); + } + + var offset = 0; + Emit(page, ref offset, 0x57); // push rdi + Emit(page, ref offset, 0x56); // push rsi + Emit(page, ref offset, 0x48, 0x89, 0xCF); // mov rdi, rcx + Emit(page, ref offset, 0x48, 0x89, 0xD6); // mov rsi, rdx + Emit(page, ref offset, 0x4C, 0x89, 0xC2); // mov rdx, r8 + Emit(page, ref offset, 0x4C, 0x89, 0xC9); // mov rcx, r9 + Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8 (realign to 16) + EmitMovRaxImm64(page, ref offset, sysvTarget); // mov rax, target + Emit(page, ref offset, 0xFF, 0xD0); // call rax + Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x08); // add rsp, 8 + Emit(page, ref offset, 0x5E); // pop rsi + Emit(page, ref offset, 0x5F); // pop rdi + Emit(page, ref offset, 0xC3); // ret + + if (!memory.Protect((ulong)page, 4096, HostPageProtection.ReadExecute, out _)) + { + throw new InvalidOperationException("Failed to protect Win64->SysV thunk page"); + } + + memory.FlushInstructionCache((ulong)page, (ulong)offset); + return (nint)page; + } + + private static void EnsureInitialized() + { + if (_initialized) + { + return; + } + + lock (Gate) + { + if (_initialized) + { + return; + } + + BuildStubs(); + _initialized = true; + } + } + + private static void BuildStubs() + { + var memory = HostPlatform.Current.Memory; + var page = (byte*)memory.Allocate( + 0, + 4096, + HostPageProtection.ReadWriteExecute); + if (page == null) + { + throw new OutOfMemoryException("Failed to allocate POSIX host helper stub page"); + } + + var offset = 0; + _tlsGetValueStub = EmitTlsGetValue(page, ref offset); + _queryPerformanceCounterStub = EmitQueryPerformanceCounter(page, ref offset); + _switchToThreadStub = EmitSwitchToThread(page, ref offset); + _sleepStub = EmitSleep(page, ref offset); + _waitForSingleObjectStub = EmitWaitForSingleObject(page, ref offset); + _setEventStub = EmitSetEvent(page, ref offset); + _exitThreadStub = EmitExitThread(page, ref offset); + + if (!memory.Protect((ulong)page, 4096, HostPageProtection.ReadExecute, out _)) + { + throw new InvalidOperationException("Failed to protect POSIX host helper stub page"); + } + + memory.FlushInstructionCache((ulong)page, (ulong)offset); + } + + private static nint EmitTlsGetValue(byte* page, ref int offset) + { + var start = (nint)(page + offset); + if (OperatingSystem.IsMacOS()) + { + // On macOS x86-64 pthread keys index the gs-based thread specific + // data array directly, so TlsGetValue(index in ecx) collapses to a + // single load that clobbers nothing but RAX. + Emit(page, ref offset, 0x89, 0xC8); // mov eax, ecx + Emit(page, ref offset, 0x65, 0x48, 0x8B, 0x04, 0xC5, 0, 0, 0, 0); // mov rax, gs:[rax*8] + Emit(page, ref offset, 0xC3); // ret + return start; + } + + // Linux: call pthread_getspecific, preserving the registers that are + // volatile in SysV but non-volatile in Win64 (rsi, rdi). + var pthreadGetSpecific = ResolveLibcExport("pthread_getspecific"); + Emit(page, ref offset, 0x56); // push rsi + Emit(page, ref offset, 0x57); // push rdi + Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8 + Emit(page, ref offset, 0x89, 0xCF); // mov edi, ecx + EmitMovRaxImm64(page, ref offset, pthreadGetSpecific); // mov rax, imm64 + Emit(page, ref offset, 0xFF, 0xD0); // call rax + Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x08); // add rsp, 8 + Emit(page, ref offset, 0x5F); // pop rdi + Emit(page, ref offset, 0x5E); // pop rsi + Emit(page, ref offset, 0xC3); // ret + return start; + } + + private static nint EmitQueryPerformanceCounter(byte* page, ref int offset) + { + // BOOL QueryPerformanceCounter(LARGE_INTEGER* out in rcx): the emitted + // consumers only need a monotonically increasing counter, which rdtsc + // provides without leaving Win64-safe registers. + var start = (nint)(page + offset); + Emit(page, ref offset, 0x0F, 0x31); // rdtsc + Emit(page, ref offset, 0x48, 0xC1, 0xE2, 0x20); // shl rdx, 32 + Emit(page, ref offset, 0x48, 0x09, 0xD0); // or rax, rdx + Emit(page, ref offset, 0x48, 0x89, 0x01); // mov [rcx], rax + Emit(page, ref offset, 0xB8, 0x01, 0x00, 0x00, 0x00); // mov eax, 1 + Emit(page, ref offset, 0xC3); // ret + return start; + } + + private static nint EmitSwitchToThread(byte* page, ref int offset) + { + var schedYield = ResolveLibcExport("sched_yield"); + var start = (nint)(page + offset); + Emit(page, ref offset, 0x56); // push rsi + Emit(page, ref offset, 0x57); // push rdi + Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8 + EmitMovRaxImm64(page, ref offset, schedYield); // mov rax, imm64 + Emit(page, ref offset, 0xFF, 0xD0); // call rax + Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x08); // add rsp, 8 + Emit(page, ref offset, 0x5F); // pop rdi + Emit(page, ref offset, 0x5E); // pop rsi + Emit(page, ref offset, 0xB8, 0x01, 0x00, 0x00, 0x00); // mov eax, 1 + Emit(page, ref offset, 0xC3); // ret + return start; + } + + private static nint EmitSleep(byte* page, ref int offset) + { + // void Sleep(DWORD milliseconds in ecx) -> usleep(microseconds in edi). + var usleep = ResolveLibcExport("usleep"); + var start = (nint)(page + offset); + Emit(page, ref offset, 0x56); // push rsi + Emit(page, ref offset, 0x57); // push rdi + Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8 + Emit(page, ref offset, 0x89, 0xCF); // mov edi, ecx + Emit(page, ref offset, 0x81, 0xFF, 0xFF, 0x0F, 0x00, 0x00); // cmp edi, 0xFFF + Emit(page, ref offset, 0x76, 0x05); // jbe +5 + Emit(page, ref offset, 0xBF, 0xFF, 0x0F, 0x00, 0x00); // mov edi, 0xFFF (cap at ~4s) + Emit(page, ref offset, 0x69, 0xFF, 0xE8, 0x03, 0x00, 0x00); // imul edi, edi, 1000 + EmitMovRaxImm64(page, ref offset, usleep); // mov rax, imm64 + Emit(page, ref offset, 0xFF, 0xD0); // call rax + Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x08); // add rsp, 8 + Emit(page, ref offset, 0x5F); // pop rdi + Emit(page, ref offset, 0x5E); // pop rsi + Emit(page, ref offset, 0xC3); // ret + return start; + } + + private static nint EmitWaitForSingleObject(byte* page, ref int offset) + { + // DWORD WaitForSingleObject(worker event in rcx, timeout in edx): the + // worker loop only ever waits forever, so the timeout is ignored. + // macOS waits on a dispatch semaphore (needs DISPATCH_TIME_FOREVER in + // rsi), Linux on a sem_t; both retry until the wait succeeds (EINTR). + var wait = ResolveLibcExport( + OperatingSystem.IsMacOS() ? "dispatch_semaphore_wait" : "sem_wait"); + var start = (nint)(page + offset); + Emit(page, ref offset, 0x56); // push rsi + Emit(page, ref offset, 0x57); // push rdi + Emit(page, ref offset, 0x53); // push rbx + Emit(page, ref offset, 0x48, 0x89, 0xCB); // mov rbx, rcx + var retry = offset; + Emit(page, ref offset, 0x48, 0x89, 0xDF); // mov rdi, rbx + if (OperatingSystem.IsMacOS()) + { + Emit(page, ref offset, 0x48, 0xC7, 0xC6, 0xFF, 0xFF, 0xFF, 0xFF); // mov rsi, DISPATCH_TIME_FOREVER + } + EmitMovRaxImm64(page, ref offset, wait); // mov rax, imm64 + Emit(page, ref offset, 0xFF, 0xD0); // call rax + Emit(page, ref offset, 0x85, 0xC0); // test eax, eax + Emit(page, ref offset, 0x75, unchecked((byte)(retry - (offset + 2)))); // jnz retry + Emit(page, ref offset, 0x31, 0xC0); // xor eax, eax (WAIT_OBJECT_0) + Emit(page, ref offset, 0x5B); // pop rbx + Emit(page, ref offset, 0x5F); // pop rdi + Emit(page, ref offset, 0x5E); // pop rsi + Emit(page, ref offset, 0xC3); // ret + return start; + } + + private static nint EmitSetEvent(byte* page, ref int offset) + { + // BOOL SetEvent(worker event in rcx) -> dispatch_semaphore_signal / + // sem_post. + var signal = ResolveLibcExport( + OperatingSystem.IsMacOS() ? "dispatch_semaphore_signal" : "sem_post"); + var start = (nint)(page + offset); + Emit(page, ref offset, 0x56); // push rsi + Emit(page, ref offset, 0x57); // push rdi + Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8 + Emit(page, ref offset, 0x48, 0x89, 0xCF); // mov rdi, rcx + EmitMovRaxImm64(page, ref offset, signal); // mov rax, imm64 + Emit(page, ref offset, 0xFF, 0xD0); // call rax + Emit(page, ref offset, 0x48, 0x83, 0xC4, 0x08); // add rsp, 8 + Emit(page, ref offset, 0x5F); // pop rdi + Emit(page, ref offset, 0x5E); // pop rsi + Emit(page, ref offset, 0xB8, 0x01, 0x00, 0x00, 0x00); // mov eax, 1 + Emit(page, ref offset, 0xC3); // ret + return start; + } + + private static nint EmitExitThread(byte* page, ref int offset) + { + // void ExitThread(code in ecx) -> pthread_exit(NULL); never returns, + // so no registers need preserving. pthread_exit runs the thread's TSD + // destructors, which detaches the CLR if the thread lazily attached. + var pthreadExit = ResolveLibcExport("pthread_exit"); + var start = (nint)(page + offset); + Emit(page, ref offset, 0x48, 0x83, 0xEC, 0x08); // sub rsp, 8 + Emit(page, ref offset, 0x31, 0xFF); // xor edi, edi + EmitMovRaxImm64(page, ref offset, pthreadExit); // mov rax, imm64 + Emit(page, ref offset, 0xFF, 0xD0); // call rax + Emit(page, ref offset, 0xCC); // int3 (never returns) + return start; + } + + private static nint ResolveLibcExport(string name) + { + var libc = NativeLibrary.Load(OperatingSystem.IsMacOS() ? "libSystem.dylib" : "libc.so.6"); + return NativeLibrary.GetExport(libc, name); + } + + private static void Emit(byte* page, ref int offset, params byte[] bytes) + { + foreach (var value in bytes) + { + page[offset++] = value; + } + } + + private static void EmitMovRaxImm64(byte* page, ref int offset, nint value) + { + Emit(page, ref offset, 0x48, 0xB8); + *(long*)(page + offset) = value; + offset += sizeof(long); + } + + [DllImport("libc", EntryPoint = "pthread_key_create", SetLastError = true)] + private static extern int pthread_key_create_mac(nuint* key, nint destructor); + + [DllImport("libc", EntryPoint = "pthread_key_create", SetLastError = true)] + private static extern int pthread_key_create_linux(uint* key, nint destructor); + + [DllImport("libc", EntryPoint = "pthread_key_delete")] + private static extern int pthread_key_delete_mac(nuint key); + + [DllImport("libc", EntryPoint = "pthread_key_delete")] + private static extern int pthread_key_delete_linux(uint key); + + [DllImport("libc", EntryPoint = "pthread_setspecific")] + private static extern int pthread_setspecific_mac(nuint key, nint value); + + [DllImport("libc", EntryPoint = "pthread_setspecific")] + private static extern int pthread_setspecific_linux(uint key, nint value); + + [DllImport("libc", EntryPoint = "pthread_getspecific")] + private static extern nint pthread_getspecific_mac(nuint key); + + [DllImport("libc", EntryPoint = "pthread_getspecific")] + private static extern nint pthread_getspecific_linux(uint key); + + [DllImport("libc")] + private static extern int pthread_threadid_np(nint thread, ulong* threadId); + + [DllImport("libc")] + private static extern int gettid(); + + [DllImport("libc")] + private static extern int pthread_attr_init(byte* attr); + + [DllImport("libc")] + private static extern int pthread_attr_destroy(byte* attr); + + [DllImport("libc")] + private static extern int pthread_attr_setstacksize(byte* attr, nuint stackSize); + + [DllImport("libc")] + private static extern int pthread_create(nint* thread, byte* attr, nint startRoutine, nint arg); + + [DllImport("libc")] + private static extern int pthread_join(nint thread, nint* returnValue); + + [DllImport("libc")] + private static extern int pthread_detach(nint thread); + + [DllImport("libc")] + private static extern int pthread_kill(nint thread, int signal); + + // macOS: dispatch semaphores back the worker events (unnamed sem_init is + // unsupported on Darwin). libSystem reexports libdispatch, so "libc" + // resolves these like the pthread imports above. + [DllImport("libc")] + private static extern nint dispatch_semaphore_create(long value); + + [DllImport("libc")] + private static extern nint dispatch_semaphore_signal(nint semaphore); + + [DllImport("libc")] + private static extern nint dispatch_semaphore_wait(nint semaphore, ulong timeout); + + [DllImport("libc")] + private static extern ulong dispatch_time(ulong when, long deltaNanoseconds); + + [DllImport("libc")] + private static extern void dispatch_release(nint handle); + + // Linux: unnamed POSIX semaphores. + [DllImport("libc")] + private static extern int sem_init(nint semaphore, int shared, uint value); + + [DllImport("libc")] + private static extern int sem_post(nint semaphore); + + [DllImport("libc")] + private static extern int sem_wait(nint semaphore); + + [DllImport("libc")] + private static extern int sem_trywait(nint semaphore); + + [DllImport("libc")] + private static extern int sem_destroy(nint semaphore); +} diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostSymbolResolver.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostSymbolResolver.cs new file mode 100644 index 0000000..0a0056e --- /dev/null +++ b/src/SharpEmu.HLE/Host/Posix/PosixHostSymbolResolver.cs @@ -0,0 +1,19 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.HLE.Host.Posix; + +internal sealed class PosixHostSymbolResolver : IHostSymbolResolver +{ + public nint GetAddress(HostRuntimeFunction function) => function switch + { + HostRuntimeFunction.TlsGetValue => PosixHostStubs.TlsGetValueStubAddress, + HostRuntimeFunction.QueryPerformanceCounter => PosixHostStubs.QueryPerformanceCounterStubAddress, + HostRuntimeFunction.SwitchToThread => PosixHostStubs.SwitchToThreadStubAddress, + HostRuntimeFunction.Sleep => PosixHostStubs.SleepStubAddress, + HostRuntimeFunction.WaitForSingleObject => PosixHostStubs.WaitForSingleObjectStubAddress, + HostRuntimeFunction.SetEvent => PosixHostStubs.SetEventStubAddress, + HostRuntimeFunction.ExitThread => PosixHostStubs.ExitThreadStubAddress, + _ => throw new ArgumentOutOfRangeException(nameof(function), function, null), + }; +} diff --git a/src/SharpEmu.HLE/Host/Posix/PosixHostThreading.cs b/src/SharpEmu.HLE/Host/Posix/PosixHostThreading.cs new file mode 100644 index 0000000..7bb11fb --- /dev/null +++ b/src/SharpEmu.HLE/Host/Posix/PosixHostThreading.cs @@ -0,0 +1,57 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +namespace SharpEmu.HLE.Host.Posix; + +internal sealed class PosixHostThreading : IHostThreading +{ + public uint AllocateTlsSlot() => PosixHostStubs.TlsAlloc(); + + public bool FreeTlsSlot(uint slot) => PosixHostStubs.TlsFree(slot); + + public bool SetTlsValue(uint slot, nint value) => PosixHostStubs.TlsSetValue(slot, value); + + public nint GetTlsValue(uint slot) => PosixHostStubs.TlsGetValue(slot); + + public uint CurrentThreadId => PosixHostStubs.GetCurrentThreadId(); + + public void RequestTimerResolution() + { + // POSIX sleep primitives are already high-resolution; there is no + // timeBeginPeriod equivalent to request. + } + + // Thread affinity is advisory on POSIX hosts (macOS offers no + // pthread-level affinity API); callers treat false as "not applied". + public bool TrySetCurrentThreadAffinity(nuint affinityMask) + { + _ = affinityMask; + return false; + } + + public nint CreateNativeThread( + nint entry, + nint parameter, + nuint stackReserveBytes, + out uint threadId) + { + return PosixHostStubs.CreateWorkerThread(entry, parameter, stackReserveBytes, out threadId); + } + + public bool WaitForThreadExit(nint threadHandle, uint timeoutMilliseconds) + { + return PosixHostStubs.WaitForWorkerThreadExit(threadHandle, timeoutMilliseconds); + } + + public void CloseThreadHandle(nint threadHandle) + { + PosixHostStubs.CloseWorkerThreadHandle(threadHandle); + } + + public bool TryCaptureThreadRegisters(uint threadId, out HostCapturedRegisters registers) + { + _ = threadId; + registers = default; + return false; + } +} diff --git a/src/SharpEmu.HLE/HostMainThread.cs b/src/SharpEmu.HLE/HostMainThread.cs new file mode 100644 index 0000000..78f27ca --- /dev/null +++ b/src/SharpEmu.HLE/HostMainThread.cs @@ -0,0 +1,79 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Collections.Concurrent; + +namespace SharpEmu.HLE; + +/// +/// Runs work on the real process main thread. GLFW windowing must live on +/// that thread on macOS (AppKit) and Linux (X11's single event queue), so the +/// CLI moves emulation onto a worker thread, parks the main thread in +/// , and the video presenter posts its window loop here. On +/// Windows stays false and the window keeps its own +/// thread. +/// +public static class HostMainThread +{ + private static readonly BlockingCollection _work = new(); + private static Action? _shutdownRequestHandler; + + public static bool IsAvailable { get; private set; } + + /// + /// Registers a callback invoked by so a + /// long-running posted work item (the presenter's window loop) can be + /// asked to return to the pump. + /// + public static void SetShutdownRequestHandler(Action handler) => + _shutdownRequestHandler = handler; + + /// Marks the pump as present. Call before guest code can run. + public static void Enable() => IsAvailable = true; + + public static void Post(Action work) + { + try + { + _work.Add(work); + } + catch (InvalidOperationException) + { + // Shutdown already requested; the process is exiting. + } + } + + /// + /// Services posted work on the calling (main) thread until + /// is called and the queue drains. + /// + public static void Pump() + { + foreach (var work in _work.GetConsumingEnumerable()) + { + try + { + work(); + } + catch (Exception exception) + { + Console.Error.WriteLine($"[LOADER][ERROR] Main-thread work failed: {exception}"); + } + } + } + + public static void Shutdown() + { + IsAvailable = false; + try + { + _shutdownRequestHandler?.Invoke(); + } + catch (Exception exception) + { + Console.Error.WriteLine($"[LOADER][WARN] Main-thread shutdown handler failed: {exception.Message}"); + } + + _work.CompleteAdding(); + } +} diff --git a/src/SharpEmu.HLE/SharpEmu.HLE.csproj b/src/SharpEmu.HLE/SharpEmu.HLE.csproj index 6b026db..b6845be 100644 --- a/src/SharpEmu.HLE/SharpEmu.HLE.csproj +++ b/src/SharpEmu.HLE/SharpEmu.HLE.csproj @@ -12,6 +12,10 @@ SPDX-License-Identifier: GPL-2.0-or-later $(NoWarn);1591 + + + + diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs index 109995e..7e10146 100644 --- a/src/SharpEmu.Libs/Agc/AgcExports.cs +++ b/src/SharpEmu.Libs/Agc/AgcExports.cs @@ -5,6 +5,7 @@ using SharpEmu.HLE; using SharpEmu.Libs.Kernel; using SharpEmu.Libs.VideoOut; using System.Buffers.Binary; +using System.Collections.Concurrent; using System.Runtime.CompilerServices; namespace SharpEmu.Libs.Agc; @@ -3379,6 +3380,7 @@ public static class AgcExports $"agc.rt_writer seq={drawSequence} target=0x{target.Address:X16} " + $"fmt={target.Format} tile={target.TileMode} " + $"size={target.Width}x{target.Height} vertices={vertexCount} " + + $"prim=0x{primitiveType:X} indexed={indexed} " + $"es=0x{(hasExportShader ? exportShaderAddress : 0):X16} " + $"ps=0x{(hasPixelShader ? pixelShaderAddress : 0):X16}"); } @@ -3415,6 +3417,8 @@ public static class AgcExports CreateVulkanGuestMemoryBuffers(translatedDraw.GlobalMemoryBindings); var vertexBuffers = CreateVulkanGuestVertexBuffers(translatedDraw.VertexInputs); + TraceRectListVertices(translatedDraw, vertexBuffers); + TraceGrassDrawVertices(translatedDraw, textures, vertexBuffers); VulkanVideoPresenter.SubmitOffscreenTranslatedDraw( translatedDraw.PixelSpirv, textures, @@ -4381,6 +4385,7 @@ public static class AgcExports descriptor.Width > 8192 || descriptor.Height > 8192) { + TraceTextureFallback(descriptor, "invalid-descriptor"); texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType); return true; } @@ -4398,6 +4403,9 @@ public static class AgcExports sourceByteCount > MaxPresentedTextureBytes || sourceByteCount > int.MaxValue) { + TraceTextureFallback( + descriptor, + $"invalid-byte-count:{sourceByteCount}"); texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType); return true; } @@ -4433,7 +4441,10 @@ public static class AgcExports if (descriptor.Address != 0) { var storageSource = new byte[(int)sourceByteCount]; - if (ctx.Memory.TryRead(descriptor.Address, storageSource) && + if ((ctx.Memory.TryRead(descriptor.Address, storageSource) || + KernelMemoryCompatExports.TryReadTrackedLibcHeapGpuAlias( + descriptor.Address, + storageSource)) && storageSource.AsSpan().IndexOfAnyExcept((byte)0) >= 0) { initialPixels = storageSource; @@ -4459,8 +4470,14 @@ public static class AgcExports } var source = new byte[(int)sourceByteCount]; - if (!ctx.Memory.TryRead(descriptor.Address, source)) + if (!ctx.Memory.TryRead(descriptor.Address, source) && + !KernelMemoryCompatExports.TryReadTrackedLibcHeapGpuAlias( + descriptor.Address, + source)) { + TraceTextureFallback( + descriptor, + $"guest-read-failed:{sourceByteCount}"); texture = CreateFallbackGuestDrawTexture(isStorage, descriptor.Format, descriptor.NumberType); return true; } @@ -4486,6 +4503,7 @@ public static class AgcExports $"size={descriptor.Width}x{descriptor.Height} pitch={descriptor.Pitch} " + $"dst=0x{descriptor.DstSelect:X3} " + $"bytes={source.Length} nonzero64={nonZero}"); + DumpTextureSourceIfRequested(descriptor, sourceWidth, source); var rgba = source; texture = new VulkanGuestDrawTexture( @@ -4506,6 +4524,158 @@ public static class AgcExports return true; } + private static int _textureFallbackTraceCount; + + private static void TraceTextureFallback( + TextureDescriptor descriptor, + string reason) + { + var mode = Environment.GetEnvironmentVariable("SHARPEMU_TRACE_GUEST_IMAGES"); + if ((!string.Equals(mode, "1", StringComparison.Ordinal) && + !string.Equals(mode, "present", StringComparison.OrdinalIgnoreCase)) || + Interlocked.Increment(ref _textureFallbackTraceCount) > 64) + { + return; + } + + Console.Error.WriteLine( + $"[LOADER][TRACE] agc.texture_fallback reason={reason} " + + $"addr=0x{descriptor.Address:X16} type={descriptor.Type} " + + $"size={descriptor.Width}x{descriptor.Height} pitch={descriptor.Pitch} " + + $"fmt={descriptor.Format} num={descriptor.NumberType} " + + $"tile={descriptor.TileMode} mip={descriptor.MipLevels} " + + $"dst=0x{descriptor.DstSelect:X3}"); + } + + + + private static int _grassTraceCount; + + private static void TraceGrassDrawVertices( + TranslatedGuestDraw draw, + IReadOnlyList textures, + IReadOnlyList vertexBuffers) + { + if (_grassTraceCount >= 6 || + !textures.Any(texture => texture.Width == 288 && texture.Height == 160) || + vertexBuffers.Count == 0 || + Interlocked.Increment(ref _grassTraceCount) > 6) + { + return; + } + + var text = new System.Text.StringBuilder(); + text.Append($"agc.grassdraw prim=0x{draw.PrimitiveType:X} verts={draw.VertexCount} "); + text.Append($"indexed={draw.IndexBuffer is not null} buffers={vertexBuffers.Count}"); + foreach (var buffer in vertexBuffers) + { + text.Append( + $"\n loc={buffer.Location} fmt={buffer.DataFormat}/{buffer.NumberFormat}x{buffer.ComponentCount} " + + $"stride={buffer.Stride} offset={buffer.OffsetBytes} bytes={buffer.Data.Length}"); + var stride = Math.Max(buffer.Stride, 4u); + var maxVerts = Math.Min(6, (int)((buffer.Data.Length - buffer.OffsetBytes) / stride)); + for (var vertex = 0; vertex < maxVerts; vertex++) + { + var baseOffset = (int)(buffer.OffsetBytes + vertex * stride); + var components = Math.Min(4, (int)((buffer.Data.Length - baseOffset) / 4)); + text.Append($"\n v{vertex}:"); + for (var c = 0; c < components; c++) + { + text.Append($" {BitConverter.ToSingle(buffer.Data, baseOffset + c * 4):0.#####}"); + } + } + } + + TraceAgcShader(text.ToString()); + } + + private static int _rectListTraceCount; + + private static void TraceRectListVertices( + TranslatedGuestDraw draw, + IReadOnlyList vertexBuffers) + { + if (draw.PrimitiveType != 0x11 || + draw.IndexBuffer is not null || + vertexBuffers.Count == 0 || + _rectListTraceCount >= 8 || + Interlocked.Increment(ref _rectListTraceCount) > 8) + { + return; + } + + var buffer = vertexBuffers[0]; + var stride = Math.Max(buffer.Stride, 4u); + var text = new System.Text.StringBuilder(); + for (var vertex = 0; vertex < 3; vertex++) + { + var baseOffset = (int)(buffer.OffsetBytes + vertex * stride); + if (baseOffset + 16 > buffer.Data.Length) + { + break; + } + + var x = BitConverter.ToSingle(buffer.Data, baseOffset); + var y = BitConverter.ToSingle(buffer.Data, baseOffset + 4); + var z = BitConverter.ToSingle(buffer.Data, baseOffset + 8); + var w = BitConverter.ToSingle(buffer.Data, baseOffset + 12); + text.Append($" v{vertex}=({x:0.###},{y:0.###},{z:0.###},{w:0.###})"); + } + + TraceAgcShader( + $"agc.rectlist verts={draw.VertexCount} stride={buffer.Stride} " + + $"fmt={buffer.DataFormat}/{buffer.NumberFormat}x{buffer.ComponentCount}{text}"); + } + + private static int _textureDumpCount; + private static readonly ConcurrentDictionary _textureDumpKeys = new(); + + /// + /// Writes raw sampled-texture bytes (as read from guest memory) when + /// SHARPEMU_TEXTURE_DUMP_DIR is set, so upload-time content can be + /// inspected offline. File name records size and effective pitch. + /// + private static void DumpTextureSourceIfRequested( + in TextureDescriptor descriptor, + uint sourcePitch, + byte[] source) + { + var directory = Environment.GetEnvironmentVariable("SHARPEMU_TEXTURE_DUMP_DIR"); + if (string.IsNullOrWhiteSpace(directory)) + { + return; + } + + var key = $"0x{descriptor.Address:X}-{descriptor.Width}x{descriptor.Height}"; + var occurrence = _textureDumpKeys.AddOrUpdate(key, 1, static (_, count) => count + 1); + // First uses plus periodic later snapshots (the game reuses the same + // allocation for successive full-screen images). + if ((occurrence > 3 && occurrence % 500 >= 3) || + Interlocked.Increment(ref _textureDumpCount) > 200) + { + return; + } + + var index = _textureDumpCount; + + try + { + Directory.CreateDirectory(directory); + var path = Path.Combine( + directory, + $"{index:D3}-0x{descriptor.Address:X}-{descriptor.Width}x{descriptor.Height}" + + $"-p{sourcePitch}-f{descriptor.Format}-t{descriptor.TileMode}.bin"); + File.WriteAllBytes(path, source); + } + catch (Exception exception) + { + // A bad SHARPEMU_TEXTURE_DUMP_DIR (permissions, invalid path) + // must not take the emulator down; the dump is a debug aid. + Console.Error.WriteLine( + $"[LOADER][WARN] Texture dump failed: {exception.Message}"); + } + } + private static VulkanGuestDrawTexture CreateFallbackGuestDrawTexture( bool isStorage, uint format, diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs index da9065d..9d5c90b 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs @@ -125,6 +125,13 @@ public static class KernelMemoryCompatExports private static ulong _nextPhysicalAddress; private static ulong _nextVirtualAddress; + // First guest virtual address handed out for direct/flexible mappings + // when the game does not request one. 4GB is free on Windows, but on + // POSIX hosts it belongs to the host image / runtime (the Mach-O image + // base is 0x100000000 on macOS), so search from a guest-owned window + // well clear of host mappings instead. + private static readonly ulong DefaultMapSearchBase = + OperatingSystem.IsWindows() ? 0x1_0000_0000UL : 0x20_0000_0000UL; private static ulong _mainDirectMemoryPoolBase = UnsetMainDirectMemoryPoolBase; private static ulong _allocatedFlexibleBytes; private static ulong _threadAtexitCountCallback; @@ -241,7 +248,7 @@ public static class KernelMemoryCompatExports lock (_memoryGate) { var desiredAddress = AlignUp( - _nextVirtualAddress == 0 ? 0x1_0000_0000UL : _nextVirtualAddress, + _nextVirtualAddress == 0 ? DefaultMapSearchBase : _nextVirtualAddress, effectiveAlignment); if (!TryReserveGuestVirtualRange(ctx, desiredAddress, mappedLength, protection, effectiveAlignment, out address) || address == 0) @@ -3185,7 +3192,7 @@ public static class KernelMemoryCompatExports ? requestedAddress : directMemoryStart != 0 ? AlignUp(directMemoryStart, effectiveAlignment) - : AlignUp(_nextVirtualAddress == 0 ? 0x1_0000_0000UL : _nextVirtualAddress, effectiveAlignment); + : AlignUp(_nextVirtualAddress == 0 ? DefaultMapSearchBase : _nextVirtualAddress, effectiveAlignment); var reserved = false; if (fixedMapping && requestedAddress != 0) @@ -3291,7 +3298,7 @@ public static class KernelMemoryCompatExports var fixedMapping = (flags & 0x10UL) != 0; var desiredAddress = requestedAddress != 0 ? requestedAddress - : AlignUp(_nextVirtualAddress == 0 ? 0x1_0000_0000UL : _nextVirtualAddress, 0x1000UL); + : AlignUp(_nextVirtualAddress == 0 ? DefaultMapSearchBase : _nextVirtualAddress, 0x1000UL); if (fixedMapping && requestedAddress != 0) { @@ -6191,6 +6198,55 @@ public static class KernelMemoryCompatExports return false; } + internal static bool TryReadTrackedLibcHeapGpuAlias( + ulong packedAddress, + Span destination) + { + if (destination.IsEmpty) + { + return true; + } + + // Gen5 texture descriptors retain 46 bits of the byte address. Host + // libc allocations can live at 0x7F... on Linux, so recover the full + // tracked allocation address when the descriptor contains its packed + // low-bit alias. + const ulong textureAddressMask = (1UL << 46) - 1; + var length = (ulong)destination.Length; + ulong resolvedAddress = 0; + lock (_libcAllocGate) + { + foreach (var (allocationAddress, allocation) in _libcAllocations) + { + var packedBase = allocationAddress & textureAddressMask; + if (packedAddress < packedBase) + { + continue; + } + + var offset = packedAddress - packedBase; + var allocationSize = (ulong)allocation.Size; + if (offset > allocationSize || length > allocationSize - offset) + { + continue; + } + + var candidate = allocationAddress + offset; + if (resolvedAddress != 0 && resolvedAddress != candidate) + { + // Do not guess if two live host allocations collide after + // descriptor address packing. + return false; + } + + resolvedAddress = candidate; + } + + return resolvedAddress != 0 && + TryReadHostMemory(resolvedAddress, destination); + } + } + private static bool TryAllocateLibcHeap(ulong requestedSize, nuint alignment, bool zeroFill, out ulong address) { address = 0; diff --git a/src/SharpEmu.Libs/Kernel/KernelVirtualRangeAllocator.cs b/src/SharpEmu.Libs/Kernel/KernelVirtualRangeAllocator.cs index 8971dc8..50c8868 100644 --- a/src/SharpEmu.Libs/Kernel/KernelVirtualRangeAllocator.cs +++ b/src/SharpEmu.Libs/Kernel/KernelVirtualRangeAllocator.cs @@ -54,7 +54,10 @@ internal static class KernelVirtualRangeAllocator } catch { - Console.Error.WriteLine($"[LOADER][TRACE] {traceName}: AllocateAt invocation threw"); + // Expected when a fixed-address request cannot be satisfied on + // this host; the caller falls back or reports the failure. + Console.Error.WriteLine( + $"[LOADER][TRACE] {traceName}: no host mapping at 0x{desiredAddress:X16} len=0x{length:X}"); return false; } } diff --git a/src/SharpEmu.Libs/Pad/HostWindowInput.cs b/src/SharpEmu.Libs/Pad/HostWindowInput.cs new file mode 100644 index 0000000..9a609b6 --- /dev/null +++ b/src/SharpEmu.Libs/Pad/HostWindowInput.cs @@ -0,0 +1,275 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using SharpEmu.HLE.Host; +using SharpEmu.HLE.Host.Posix; +using Silk.NET.Input; + +namespace SharpEmu.Libs.Pad; + +/// +/// Keyboard and gamepad state sampled from the presenter's window, feeding +/// the POSIX host input seam (macOS/Linux have no user32/XInput/raw-HID +/// readers). The presenter attaches the window's input context once the +/// window exists; input events arrive on the window thread and pad reads +/// happen on guest threads, so all state is guarded. +/// +public static class HostWindowInput +{ + private static readonly object Gate = new(); + private static readonly HashSet Pressed = new(); + private static volatile bool _connected; + + // Latest window-gamepad snapshot in the host seam's conventions. + private static bool _gamepadConnected; + private static string? _gamepadName; + private static HostGamepadButtons _gamepadButtons; + private static byte _gamepadLeftX = 128; + private static byte _gamepadLeftY = 128; + private static byte _gamepadRightX = 128; + private static byte _gamepadRightY = 128; + private static byte _gamepadL2; + private static byte _gamepadR2; + + /// True once a window keyboard is delivering events. + public static bool IsConnected => _connected; + + public static void Attach(IInputContext input) + { + foreach (var keyboard in input.Keyboards) + { + keyboard.KeyDown += (_, key, _) => + { + lock (Gate) + { + Pressed.Add(key); + } + }; + keyboard.KeyUp += (_, key, _) => + { + lock (Gate) + { + Pressed.Remove(key); + } + }; + } + + if (input.Keyboards.Count > 0) + { + _connected = true; + } + + foreach (var gamepad in input.Gamepads) + { + AttachGamepad(gamepad); + } + + input.ConnectionChanged += (device, connected) => + { + if (device is not IGamepad gamepad) + { + return; + } + + if (connected) + { + AttachGamepad(gamepad); + return; + } + + lock (Gate) + { + _gamepadConnected = false; + _gamepadName = null; + _gamepadButtons = HostGamepadButtons.None; + _gamepadLeftX = 128; + _gamepadLeftY = 128; + _gamepadRightX = 128; + _gamepadRightY = 128; + _gamepadL2 = 0; + _gamepadR2 = 0; + } + }; + + PosixHostInput.SetSource(new WindowInputSource()); + } + + public static bool IsKeyDown(Key key) + { + lock (Gate) + { + return Pressed.Contains(key); + } + } + + private sealed class WindowInputSource : IPosixWindowInputSource + { + public bool HasKeyboardFocus => _connected; + + public bool IsKeyDown(int virtualKey) + { + return TryMapVirtualKey(virtualKey, out var key) && HostWindowInput.IsKeyDown(key); + } + + public int GetGamepadStates(Span destination) + { + lock (Gate) + { + if (!_gamepadConnected || destination.Length == 0) + { + return 0; + } + + destination[0] = new HostGamepadState( + Connected: true, + Buttons: _gamepadButtons, + LeftX: _gamepadLeftX, + LeftY: _gamepadLeftY, + RightX: _gamepadRightX, + RightY: _gamepadRightY, + LeftTrigger: _gamepadL2, + RightTrigger: _gamepadR2); + return 1; + } + } + + public string? DescribeConnectedGamepad() + { + lock (Gate) + { + return _gamepadConnected ? _gamepadName ?? "GLFW gamepad" : null; + } + } + } + + private static bool TryMapVirtualKey(int vk, out Key key) + { + key = vk switch + { + 0x08 => Key.Backspace, + 0x09 => Key.Tab, + 0x0D => Key.Enter, + 0x1B => Key.Escape, + 0x25 => Key.Left, + 0x26 => Key.Up, + 0x27 => Key.Right, + 0x28 => Key.Down, + >= 0x41 and <= 0x5A => Key.A + (vk - 0x41), + _ => Key.Unknown, + }; + return key != Key.Unknown; + } + + private static void AttachGamepad(IGamepad gamepad) + { + lock (Gate) + { + _gamepadConnected = true; + _gamepadName = gamepad.Name; + } + + gamepad.ButtonDown += (_, button) => + { + var bit = MapButton(button.Name); + if (bit == HostGamepadButtons.None) + { + return; + } + + lock (Gate) + { + _gamepadButtons |= bit; + } + }; + gamepad.ButtonUp += (_, button) => + { + var bit = MapButton(button.Name); + if (bit == HostGamepadButtons.None) + { + return; + } + + lock (Gate) + { + _gamepadButtons &= ~bit; + } + }; + gamepad.ThumbstickMoved += (_, thumbstick) => + { + // Silk's GLFW backend reports sticks -1..1 with +Y pointing down, + // matching the seam's 0..255 down-growing convention after biasing. + var x = ToStickByte(thumbstick.X); + var y = ToStickByte(thumbstick.Y); + lock (Gate) + { + if (thumbstick.Index == 0) + { + _gamepadLeftX = x; + _gamepadLeftY = y; + } + else + { + _gamepadRightX = x; + _gamepadRightY = y; + } + } + }; + gamepad.TriggerMoved += (_, trigger) => + { + // GLFW gamepad triggers rest at -1 and saturate at +1. + var value = (byte)Math.Clamp((int)((trigger.Position + 1.0f) * 0.5f * 255.0f), 0, 255); + lock (Gate) + { + if (trigger.Index == 0) + { + _gamepadL2 = value; + if (value > 64) + { + _gamepadButtons |= HostGamepadButtons.L2; + } + else + { + _gamepadButtons &= ~HostGamepadButtons.L2; + } + } + else + { + _gamepadR2 = value; + if (value > 64) + { + _gamepadButtons |= HostGamepadButtons.R2; + } + else + { + _gamepadButtons &= ~HostGamepadButtons.R2; + } + } + } + }; + } + + private static byte ToStickByte(float value) + { + return (byte)Math.Clamp((int)(128.0f + value * 127.0f), 0, 255); + } + + private static HostGamepadButtons MapButton(ButtonName name) => name switch + { + // GLFW reports the Xbox layout: A=Cross, B=Circle, X=Square, Y=Triangle. + ButtonName.A => HostGamepadButtons.Cross, + ButtonName.B => HostGamepadButtons.Circle, + ButtonName.X => HostGamepadButtons.Square, + ButtonName.Y => HostGamepadButtons.Triangle, + ButtonName.LeftBumper => HostGamepadButtons.L1, + ButtonName.RightBumper => HostGamepadButtons.R1, + ButtonName.Back => HostGamepadButtons.TouchPad, + ButtonName.Start => HostGamepadButtons.Options, + ButtonName.LeftStick => HostGamepadButtons.L3, + ButtonName.RightStick => HostGamepadButtons.R3, + ButtonName.DPadUp => HostGamepadButtons.Up, + ButtonName.DPadRight => HostGamepadButtons.Right, + ButtonName.DPadDown => HostGamepadButtons.Down, + ButtonName.DPadLeft => HostGamepadButtons.Left, + _ => HostGamepadButtons.None, + }; +} diff --git a/src/SharpEmu.Libs/Pad/PadExports.cs b/src/SharpEmu.Libs/Pad/PadExports.cs index 72205c5..54a595f 100644 --- a/src/SharpEmu.Libs/Pad/PadExports.cs +++ b/src/SharpEmu.Libs/Pad/PadExports.cs @@ -442,6 +442,11 @@ public static class PadExports r2 = Math.Max(r2, pad.RightTrigger); } + if (IsAutoCrossActive()) + { + buttons |= 0x4000; + } + _cachedInputState = new PadState( Connected: true, Buttons: buttons, @@ -455,6 +460,51 @@ public static class PadExports return _cachedInputState; } + private static readonly long PadStartTimestamp = Stopwatch.GetTimestamp(); + private static readonly double[] AutoCrossTimes = ParseAutoCrossTimes(); + + private static double[] ParseAutoCrossTimes() + { + // SHARPEMU_AUTO_CROSS="40,52,64": presses Cross for 0.4s at each + // second offset from process start. Debug aid for unattended runs. + var raw = Environment.GetEnvironmentVariable("SHARPEMU_AUTO_CROSS"); + if (string.IsNullOrWhiteSpace(raw)) + { + return []; + } + + var values = new List(); + foreach (var token in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (double.TryParse(token, System.Globalization.CultureInfo.InvariantCulture, out var value)) + { + values.Add(value); + } + } + + return values.ToArray(); + } + + private static bool IsAutoCrossActive() + { + var times = AutoCrossTimes; + if (times.Length == 0) + { + return false; + } + + var elapsed = (Stopwatch.GetTimestamp() - PadStartTimestamp) / (double)Stopwatch.Frequency; + foreach (var time in times) + { + if (elapsed >= time && elapsed < time + 0.4) + { + return true; + } + } + + return false; + } + /// Maps the host seam's neutral button flags onto SCE_PAD_BUTTON bits. private static uint ToOrbisButtons(HostGamepadButtons buttons) { diff --git a/src/SharpEmu.Libs/SharpEmu.Libs.csproj b/src/SharpEmu.Libs/SharpEmu.Libs.csproj index 4d19ca1..70973f8 100644 --- a/src/SharpEmu.Libs/SharpEmu.Libs.csproj +++ b/src/SharpEmu.Libs/SharpEmu.Libs.csproj @@ -13,6 +13,7 @@ SPDX-License-Identifier: GPL-2.0-or-later + diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs index ee47c7a..1283705 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs @@ -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(); + } + + /// + /// Asks a running presenter to close its window; used at emulator + /// shutdown so a main-thread-hosted window loop returns to the pump. + /// + public static void RequestClose() + { + Volatile.Write(ref _presenterCloseRequested, true); + } + + /// + /// 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. + /// + 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) + 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; + + /// + /// 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. + /// + 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) + 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}"); + } + } + + /// Logs the backend GLFW actually selected, so a "no window" + /// report shows Wayland vs X11 at a glance. + private static unsafe void LogGlfwPlatformInUse() + { + if (OperatingSystem.IsWindows() || !TryLoadGlfw(out var glfw)) + { + return; + } + + try + { + var getPlatform = (delegate* unmanaged) + 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 _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, ®ion, - Filter.Nearest); + isIntegerUpscale ? Filter.Nearest : Filter.Linear); if (traceDestination) { diff --git a/src/SharpEmu.Libs/packages.lock.json b/src/SharpEmu.Libs/packages.lock.json index 4a4f120..efa61e0 100644 --- a/src/SharpEmu.Libs/packages.lock.json +++ b/src/SharpEmu.Libs/packages.lock.json @@ -2,6 +2,16 @@ "version": 2, "dependencies": { "net10.0": { + "Silk.NET.Input": { + "type": "Direct", + "requested": "[2.23.0, )", + "resolved": "2.23.0", + "contentHash": "Xzl+tVwAp2eEd8blmGQjmJrsZPnp3PWG0KJjiAQHaY2Zr/ELVWeAROKXmZdCAvexzmte2JVGEy/dxnMycbxlpg==", + "dependencies": { + "Silk.NET.Input.Common": "2.23.0", + "Silk.NET.Input.Glfw": "2.23.0" + } + }, "Silk.NET.Vulkan": { "type": "Direct", "requested": "[2.23.0, )", @@ -69,6 +79,23 @@ "Ultz.Native.GLFW": "3.4.0" } }, + "Silk.NET.Input.Common": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "QbJVV7kFBHEByayXCYdJtXXI9Sp4a+QAf0IdGV6uCWkFYcEmqBYW3aaNGvFOdSwTBDbHL5T/OtOCrGh4qYhk7A==", + "dependencies": { + "Silk.NET.Windowing.Common": "2.23.0" + } + }, + "Silk.NET.Input.Glfw": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "KGHYqsv/IQRJtD6dloYh2tN4CkaM40vxM2kj0cGKBoCQiBDYHHhJiyDTyMPx0W7Fz5IgnhnG42ELmIAa0DH69A==", + "dependencies": { + "Silk.NET.Input.Common": "2.23.0", + "Silk.NET.Windowing.Glfw": "2.23.0" + } + }, "Silk.NET.Maths": { "type": "Transitive", "resolved": "2.23.0", diff --git a/tests/SharpEmu.Libs.Tests/packages.lock.json b/tests/SharpEmu.Libs.Tests/packages.lock.json index 6114506..82aca41 100644 --- a/tests/SharpEmu.Libs.Tests/packages.lock.json +++ b/tests/SharpEmu.Libs.Tests/packages.lock.json @@ -81,6 +81,23 @@ "Ultz.Native.GLFW": "3.4.0" } }, + "Silk.NET.Input.Common": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "QbJVV7kFBHEByayXCYdJtXXI9Sp4a+QAf0IdGV6uCWkFYcEmqBYW3aaNGvFOdSwTBDbHL5T/OtOCrGh4qYhk7A==", + "dependencies": { + "Silk.NET.Windowing.Common": "2.23.0" + } + }, + "Silk.NET.Input.Glfw": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "KGHYqsv/IQRJtD6dloYh2tN4CkaM40vxM2kj0cGKBoCQiBDYHHhJiyDTyMPx0W7Fz5IgnhnG42ELmIAa0DH69A==", + "dependencies": { + "Silk.NET.Input.Common": "2.23.0", + "Silk.NET.Windowing.Glfw": "2.23.0" + } + }, "Silk.NET.Maths": { "type": "Transitive", "resolved": "2.23.0", @@ -168,6 +185,7 @@ "type": "Project", "dependencies": { "SharpEmu.HLE": "[1.0.0, )", + "Silk.NET.Input": "[2.23.0, )", "Silk.NET.Vulkan": "[2.23.0, )", "Silk.NET.Vulkan.Extensions.EXT": "[2.23.0, )", "Silk.NET.Vulkan.Extensions.KHR": "[2.23.0, )", @@ -183,6 +201,16 @@ "resolved": "1.21.0", "contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg==" }, + "Silk.NET.Input": { + "type": "CentralTransitive", + "requested": "[2.23.0, )", + "resolved": "2.23.0", + "contentHash": "Xzl+tVwAp2eEd8blmGQjmJrsZPnp3PWG0KJjiAQHaY2Zr/ELVWeAROKXmZdCAvexzmte2JVGEy/dxnMycbxlpg==", + "dependencies": { + "Silk.NET.Input.Common": "2.23.0", + "Silk.NET.Input.Glfw": "2.23.0" + } + }, "Silk.NET.Vulkan": { "type": "CentralTransitive", "requested": "[2.23.0, )",