Compare commits

..

1 Commits

Author SHA1 Message Date
ParantezTech 0945b2c000 [VideoPresenter] Fix logical width/height calculation 2026-07-20 16:48:36 +03:00
58 changed files with 533 additions and 5162 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 82 KiB

-19
View File
@@ -26,25 +26,6 @@ Before opening a pull request, please keep the following in mind:
If you're unsure about a design decision, feel free to open a discussion or draft PR first.
## Pull Request Expectations
Pull requests should provide real, observable emulator behavior rather than only suppressing errors or unresolved imports.
Changes that only return success, zero, or fabricated handles without implementing the expected state, output, or side effects will generally not be accepted. Functions that create resources, write output structures, register callbacks, or expose runtime state should model the behavior required by the guest.
When applicable, PRs should include:
- The affected game or application.
- Relevant logs or failing imports.
- Behavior before and after the change.
- Real game testing and known limitations.
Avoid submitting large collections of speculative NIDs or unrelated exports. Keep each PR focused on one problem or a closely related set of changes.
Large architectural changes should be discussed with the maintainers before implementation. Contributors are encouraged to ask first when they are uncertain whether a proposed direction fits the project.
Opening a PR does not guarantee that it will be merged. Maintainers evaluate changes based on correctness, evidence, testing, scope, maintenance cost, and the long-term direction of the project.
## AI-Assisted Contributions
AI-assisted development is welcome and may be used for research, reverse engineering, code generation, or documentation.
-7
View File
@@ -14,13 +14,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And $([MSBuild]::IsOSPlatform('Windows'))">win</_HostRidOSPrefix>
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And '$(_HostRidOSPrefix)' == '' And $([MSBuild]::IsOSPlatform('Linux'))">linux</_HostRidOSPrefix>
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And '$(_HostRidOSPrefix)' == '' And $([MSBuild]::IsOSPlatform('OSX'))">osx</_HostRidOSPrefix>
<_HostRidArch Condition="'$(_HostRidOSPrefix)' != '' And '$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)' == 'Arm64'">arm64</_HostRidArch>
<_HostRidArch Condition="'$(_HostRidOSPrefix)' != '' And '$(_HostRidArch)' == ''">x64</_HostRidArch>
<RuntimeIdentifier Condition="'$(_HostRidOSPrefix)' != ''">$(_HostRidOSPrefix)-$(_HostRidArch)</RuntimeIdentifier>
<BaseIntermediateOutputPath>$(RepoRoot)artifacts/obj/$(MSBuildProjectName)/</BaseIntermediateOutputPath>
<BaseOutputPath>$(RepoRoot)artifacts/bin/</BaseOutputPath>
-1
View File
@@ -11,7 +11,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<PackageVersion Include="Avalonia.Desktop" Version="11.3.18" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.18" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.18" />
<PackageVersion Include="FFmpeg.AutoGen" Version="7.1.1" />
<PackageVersion Include="Iced" Version="1.21.0" />
<PackageVersion Include="Microsoft.Build.Framework" Version="17.14.8" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
+22 -51
View File
@@ -9,67 +9,38 @@ Demon's Souls plays Bink 2 (.bk2) files through a Bink implementation linked
directly into eboot.bin. It does not use libSceVideodec, therefore an HLE video
decoder cannot observe or replace those frames.
SharpEmu observes successful guest .bk2 opens and, when a Bink decoder is
SharpEmu observes successful guest .bk2 opens and, when a Bink bridge is
available, presents its decoded BGRA frames at the normal guest-flip boundary.
This preserves the game's own timing and lets the host Vulkan presenter display
the movie without trying to execute the PS5-specific Bink GPU decode path.
The default path decodes by calling FFmpeg's own C API directly from managed
code (`src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs`, via the
[FFmpeg.AutoGen](https://github.com/Ruslan-B/FFmpeg.AutoGen) P/Invoke
bindings) against a custom FFmpeg build
(`github.com/sharpemu/ffmpeg-core`, LGPL-2.1) that adds a Bink 2 decoder to
FFmpeg 7.1.2; see "Supplying the FFmpeg libraries" below for where those
libraries come from. No proprietary RAD SDK is needed to build or run
SharpEmu, and there is no C/C++ code of SharpEmu's own involved in decoding
-- SharpEmu.CLI.csproj only downloads a prebuilt release archive.
Set `SHARPEMU_BINK_MODE=guest` to leave decoding to the Bink implementation
statically linked into the game instead. Set `skip` only when explicitly
testing a title whose cinematics are optional.
Without an adapter, Bink files remain visible to the guest and the game's
statically linked decoder runs normally. Set SHARPEMU_BINK_MODE=skip only when
explicitly testing a title whose cinematics are optional.
Set SHARPEMU_BINK_MODE=dummy to retain the open and show a built-in,
non-decoded placeholder frame. This requires no SDK, but is a visual diagnostic
only; it does not decode the movie or alter its game logic.
SHARPEMU_BINK_MODE=native is equivalent to the default and mainly useful for
being explicit about it.
only; it does not decode the movie or alter its game logic. Set
SHARPEMU_BINK_MODE=native to force native bridge mode.
The experimental `SHARPEMU_BINK_MODE=ffmpeg` override is unrelated to the
default path above: instead of calling into FFmpeg in-process, it spawns a
standalone `ffmpeg` executable and reads raw frames from its stdout
(`src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs`). SharpEmu searches
`SHARPEMU_FFMPEG_PATH`, the executable directory, its `ffmpeg` subdirectory,
and then `PATH` (plus a couple of common Homebrew paths on macOS). That
`ffmpeg` build must contain a Bink 2 decoder itself; a stock FFmpeg build that
only recognizes the Bink container is not sufficient. Most users want the
default `native` mode instead, which always has Bink 2 support since it's
built against `ffmpeg-core` specifically.
## Supplying the adapter
## Supplying the FFmpeg libraries
Bink 2 is proprietary. Obtain a compatible Mac Bink 2 SDK from RAD Game Tools,
then compile sharpemu_bink2_bridge.c against the SDK's bink.h and Mac library.
The adapter deliberately contains only a three-function C ABI so the managed
emulator never depends on RAD's private binary ABI.
`dotnet publish` fetches a prebuilt release of `github.com/sharpemu/ffmpeg-core`
(the tag is pinned in `SharpEmu.CLI.csproj`'s `FfmpegRuntimeTag`, matched to
the `FFmpeg.AutoGen` package version in `Directory.Packages.props` -- both
need to agree on the same FFmpeg ABI) and copies its dynamically linked
libraries into a `plugins` folder next to the published executable. No C
toolchain is required to build SharpEmu; publishing just downloads a zip.
`plugins` is a loose, unpacked folder rather than something embedded in the
single-file bundle, so the OS loader can resolve the libraries' own
inter-dependencies (`avcodec` depends on `avutil`, etc.) itself.
Place the resulting libsharpemu_bink2_bridge.dylib next to the SharpEmu
executable, or point to it explicitly:
A plain `dotnet publish` with no `-r` still works: it defaults to the host
machine's own RID (see `Directory.Build.props`), so it fetches the matching
`ffmpeg-core` archive and populates `plugins` without any extra flags.
Passing an explicit `-r <rid>` (e.g. to cross-publish `linux-x64` from
Windows) still overrides that default normally.
SHARPEMU_BINK2_BRIDGE=/absolute/path/libsharpemu_bink2_bridge.dylib \
./SharpEmu /path/to/eboot.bin
To use a different set of FFmpeg libraries, drop them into the published
`plugins` folder yourself (matching FFmpeg's own file-naming and versioning
conventions, e.g. `avformat-61.dll` / `libavformat.so.61` / matching
`.dylib`) -- `FfmpegNativeBinkFrameSource` points `ffmpeg.RootPath` at that
folder and does not otherwise care where the files came from.
The expected exports are sharpemu_bink2_open_utf8,
sharpemu_bink2_decode_next_bgra, and sharpemu_bink2_close. The supplied
adapter opens one movie, exposes BGRA pixels, and advances after each decoded
frame. The managed side validates dimensions and retains ownership of the
destination buffer.
If the libraries are absent or fail to load, `FfmpegNativeBinkFrameSource.TryOpen`
degrades gracefully: SharpEmu logs one informational line ("Bink2 bridge
could not open movie ...") and leaves the guest's own rendering path
untouched, rather than crashing.
If the bridge is absent in native mode, SharpEmu logs one informational line
and retains the existing guest rendering path.
@@ -0,0 +1,66 @@
/*
* Copyright (C) 2026 SharpEmu Emulator Project
* SPDX-License-Identifier: GPL-2.0-or-later
*
* Build this small adapter with a licensed RAD Bink 2 SDK. The SDK and its
* headers are not distributed by SharpEmu. See docs/bink2-bridge.md.
*/
#include <stdint.h>
#include "bink.h"
typedef struct sharpemu_bink2_info {
uint32_t width;
uint32_t height;
uint32_t frames_per_second_numerator;
uint32_t frames_per_second_denominator;
} sharpemu_bink2_info;
int sharpemu_bink2_open_utf8(const char *path, HBINK *movie, sharpemu_bink2_info *info) {
HBINK bink;
if (!path || !movie || !info) return 0;
*movie = NULL;
bink = BinkOpen(path, 0);
if (!bink) return 0;
if (bink->Width == 0 || bink->Height == 0) {
BinkClose(bink);
return 0;
}
*movie = bink;
info->width = bink->Width;
info->height = bink->Height;
info->frames_per_second_numerator = bink->FrameRate;
info->frames_per_second_denominator = bink->FrameRateDiv;
return 1;
}
int sharpemu_bink2_decode_next_bgra(HBINK movie, uint8_t *destination,
uint32_t stride, uint32_t destination_bytes) {
uint64_t needed;
uint64_t min_stride;
if (!movie || !destination) return 0;
min_stride = (uint64_t)movie->Width * 4;
if ((uint64_t)stride < min_stride) return 0;
needed = (uint64_t)stride * movie->Height;
if (needed > destination_bytes) return 0;
/* Async Bink I/O has not filled the next frame yet; retry on the next host present. */
if (BinkWait(movie)) return 0;
if (!BinkDoFrame(movie)) return 0;
if (!BinkCopyToBuffer(movie, destination, stride, movie->Height, 0, 0, BINKSURFACE32RA)) return 0;
BinkNextFrame(movie);
return 1;
}
void sharpemu_bink2_close(HBINK movie) {
if (movie) BinkClose(movie);
}
+6 -23
View File
@@ -64,7 +64,6 @@ internal static partial class Program
}
args = NormalizeInternalArguments(args, out var isMitigatedChild);
PreloadGlfw();
if (args.Length == 0)
{
@@ -214,27 +213,6 @@ internal static partial class Program
"as libvulkan.1.dylib.");
}
/// <summary>
/// SharpEmu.CLI.csproj publishes glfw into a "plugins" subfolder rather
/// than flat next to the executable, which falls outside the default OS
/// DLL/dlopen search path. Preloading it here by full path first means
/// any later bare-name lookup (however Silk.NET/GLFW itself resolves the
/// library) finds it already loaded in the process and reuses it -- the
/// same technique <see cref="PreloadMacVulkanLoader"/> already relies on
/// for the Vulkan loader.
/// </summary>
private static void PreloadGlfw()
{
var fileName = OperatingSystem.IsWindows() ? "glfw3.dll"
: OperatingSystem.IsMacOS() ? "libglfw.3.dylib"
: "libglfw.so.3";
var candidate = Path.Combine(AppContext.BaseDirectory, "plugins", fileName);
if (File.Exists(candidate))
{
NativeLibrary.TryLoad(candidate, out _);
}
}
private static int RunEmulator(string[] args, bool isMitigatedChild)
{
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
@@ -573,7 +551,12 @@ internal static partial class Program
return false;
}
string[] childArgs = [MitigatedChildFlag, .. args];
var childArgs = new string[args.Length + 1];
childArgs[0] = MitigatedChildFlag;
for (var i = 0; i < args.Length; i++)
{
childArgs[i + 1] = args[i];
}
var commandLine = BuildCommandLine(processPath, childArgs);
var startupInfoEx = new STARTUPINFOEX();
+3 -66
View File
@@ -20,11 +20,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
<!-- osx-x64 is the macOS target: the CPU backend executes guest x86-64
natively, so on Apple Silicon it runs under Rosetta 2. -->
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
<!-- A plain "dotnet publish" with no -r defaults $(RuntimeIdentifier) to
the host's own RID; see Directory.Build.props, which is where that
default actually has to live (PublishDir's RID suffix is decided
there, evaluated before this file, so a default set only here would
be too late for it). -->
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
@@ -65,7 +60,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
<PropertyGroup>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<Content Include="..\..\LICENSE.txt">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
@@ -79,75 +74,17 @@ SPDX-License-Identifier: GPL-2.0-or-later
</Content>
</ItemGroup>
<!-- Native libraries (glfw, FFmpeg) publish into a subfolder next to the
executable instead of sitting loose beside it, so the publish
directory stays uncluttered as more native deps get added. The folder
name is a fixed constant, not derived from the RID/architecture: each
publish output only ever holds one architecture's binaries anyway, so
varying the name added a class of bugs (RID resolution timing, host-OS
vs. target-RID mixups) for no benefit. Runtime code (Program.cs's
PreloadGlfw, FfmpegNativeBinkFrameSource's RootPath) uses the same
literal "plugins" folder name. -->
<PropertyGroup>
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
</PropertyGroup>
<!-- Keep glfw as a loose file in the native subfolder; every other native
<!-- Keep glfw as a loose file next to the executable; every other native
library is embedded into the single-file bundle. -->
<Target Name="KeepGlfwOutsideSingleFile" AfterTargets="ComputeResolvedFilesToPublishList">
<ItemGroup>
<_GlfwPublishFiles Include="@(ResolvedFileToPublish)"
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
<ResolvedFileToPublish Remove="@(_GlfwPublishFiles)" />
<ResolvedFileToPublish Include="@(_GlfwPublishFiles)">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<RelativePath>$(NativeLibraryFolderName)/%(Filename)%(Extension)</RelativePath>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
<PropertyGroup>
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
<FfmpegRuntimeDir>
$(BaseIntermediateOutputPath)ffmpeg-runtime/$(FfmpegRuntimeTag)/$(RuntimeIdentifier)</FfmpegRuntimeDir>
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'win-x64'">ffmpeg-windows-x64.zip</FfmpegRuntimePackage>
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'linux-x64'">ffmpeg-linux-x64.zip</FfmpegRuntimePackage>
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'osx-x64'">ffmpeg-macos-x64.zip</FfmpegRuntimePackage>
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'osx-arm64'">ffmpeg-macos-arm64.zip</FfmpegRuntimePackage>
<FfmpegRuntimeArchive>$(FfmpegRuntimeDir)/$(FfmpegRuntimePackage)</FfmpegRuntimeArchive>
<FfmpegRuntimeExtractDir>$(FfmpegRuntimeDir)/extracted</FfmpegRuntimeExtractDir>
</PropertyGroup>
<Target Name="FetchFfmpegRuntime"
BeforeTargets="Publish"
Condition="'$(RuntimeIdentifier)' != '' And '$(FfmpegRuntimePackage)' != ''">
<DownloadFile
SourceUrl="https://github.com/sharpemu/ffmpeg-core/releases/download/$(FfmpegRuntimeTag)/$(FfmpegRuntimePackage)"
DestinationFolder="$(FfmpegRuntimeDir)"
Condition="!Exists('$(FfmpegRuntimeArchive)')" />
<Unzip
SourceFiles="$(FfmpegRuntimeArchive)"
DestinationFolder="$(FfmpegRuntimeExtractDir)"
Condition="!Exists('$(FfmpegRuntimeExtractDir)')" />
</Target>
<Target Name="PublishFfmpegRuntime"
AfterTargets="Publish"
DependsOnTargets="FetchFfmpegRuntime"
Condition="'$(RuntimeIdentifier)' != '' And '$(FfmpegRuntimePackage)' != ''">
<!-- Keyed off the target $(RuntimeIdentifier), not the host OS: publishing
e.g. linux-x64 from a Windows machine is a supported cross-publish,
and the extracted archive's own layout (bin/*.dll vs lib/*.so*) only
depends on which platform's ffmpeg-core package was fetched. -->
<ItemGroup>
<_FfmpegRuntimeFiles Condition="$(RuntimeIdentifier.StartsWith('win'))"
Include="$(FfmpegRuntimeExtractDir)/bin/*.dll" />
<_FfmpegRuntimeFiles Condition="!$(RuntimeIdentifier.StartsWith('win'))"
Include="$(FfmpegRuntimeExtractDir)/lib/*.so;$(FfmpegRuntimeExtractDir)/lib/*.so.*;$(FfmpegRuntimeExtractDir)/lib/*.dylib" />
</ItemGroup>
<Copy SourceFiles="@(_FfmpegRuntimeFiles)"
DestinationFolder="$(PublishDir)$(NativeLibraryFolderName)"
SkipUnchangedFiles="true" />
</Target>
</Project>
@@ -51,13 +51,12 @@ public sealed partial class DirectExecutionBackend
// round-trips through the real ucontext, so it works on every supported OS. EXTRQ/
// INSERTQ additionally read and write an XMM register: on Windows contextRecord is the
// live CONTEXT the OS resumes the thread from, so touching the Xmm0.. slots is visible
// to the guest, and on Linux the bridge copies the mcontext's FXSAVE image into the
// Xmm0.. slots and writes them back through sigreturn (_posixXmmContextBridged). On
// Darwin the XMM area is still a zeroed scratch buffer - running this there would
// silently compute a result from stale bytes and then discard whatever it "wrote", so
// the recovery declines until that bridge exists.
return (OperatingSystem.IsWindows() || _posixXmmContextBridged) &&
TryRecoverSse4aExtractInsert(contextRecord, rip);
// to the guest, but on POSIX contextRecord is a CONTEXT-shaped scratch buffer that the
// bridge only populates with the 17 general-purpose registers - the XMM region is never
// read from or written back to the real mcontext/ucontext. Running this on POSIX would
// silently compute a result from stale/zeroed XMM bytes and then discard whatever it
// "wrote", so keep it Windows-only, matching Kyty's own scope for the identical fix.
return OperatingSystem.IsWindows() && TryRecoverSse4aExtractInsert(contextRecord, rip);
}
private unsafe bool TryRecoverMonitorxMwaitx(void* contextRecord, ulong rip)
@@ -98,8 +97,7 @@ public sealed partial class DirectExecutionBackend
private unsafe bool TryRecoverSse4aExtractInsert(void* contextRecord, ulong rip)
{
if (!OperatingSystem.IsWindows() && !_posixXmmContextBridged ||
!TryReadFaultingInstruction(rip, out var instruction))
if (!OperatingSystem.IsWindows() || !TryReadFaultingInstruction(rip, out var instruction))
{
return false;
}
@@ -483,7 +483,7 @@ public sealed partial class DirectExecutionBackend
if (count <= 16 || count % 65536 == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (default-on; set SHARPEMU_IGNORE_INT41=0 to disable)");
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (SHARPEMU_IGNORE_INT41=1)");
Console.Error.Flush();
}
return true;
@@ -1404,13 +1404,11 @@ public sealed partial class DirectExecutionBackend
"vWU-odnS+fU" or // sceAmprMeasureCommandSizeReadFile
"sSAUCCU1dv4" or // sceAmprMeasureCommandSizeWriteKernelEventQueue_04_00
"C+IEj+BsAFM" or // sceAmprMeasureCommandSizeWriteAddressOnCompletion
"4fgtGfXDrFc" or // sceAmprMeasureCommandSizeWriteAddress_04_00
"tZDDEo2tE5k" or // sceAmprCommandBufferGetSize
"GnxKOHEawhk" or // sceAmprCommandBufferGetCurrentOffset
"gzndltBEzWc" or // sceAmprCommandBufferGetNumCommands
"H896Pt-yB4I" or // sceAmprCommandBufferWriteKernelEventQueue_04_00
"sJXyWHjP-F8" or // sceAmprCommandBufferWriteAddressOnCompletion
"j0+3uJMxYJY" or // sceAmprCommandBufferWriteAddress_04_00
"mPpPxv5CZt4" or // sceSystemServiceGetHdrToneMapLuminance
"1FZBKy8HeNU" or // sceVideoOutGetVblankStatus
"ASoW5WE-UPo" or // sceKernelAprSubmitCommandBufferAndGetResult
@@ -1556,13 +1554,11 @@ public sealed partial class DirectExecutionBackend
"vWU-odnS+fU" or
"sSAUCCU1dv4" or
"C+IEj+BsAFM" or
"4fgtGfXDrFc" or
"tZDDEo2tE5k" or
"GnxKOHEawhk" or
"gzndltBEzWc" or
"H896Pt-yB4I" or
"sJXyWHjP-F8" or
"j0+3uJMxYJY" or
"mPpPxv5CZt4" or
"1FZBKy8HeNU" or
"ASoW5WE-UPo" or
@@ -50,19 +50,6 @@ public sealed unsafe partial class DirectExecutionBackend
private const int LinuxUcontextGregsOffset = 40;
private const int LinuxGregsErrOffset = 19 * 8;
// The kernel's x86-64 sigcontext places the FXSAVE-image pointer right
// after the general registers it hands to the handler: err(152)
// trapno(160) oldmask(168) cr2(176) fpstate(184), all relative to
// GetPosixRegisterBase. glibc and musl both overlay this kernel layout
// verbatim (glibc's mcontext_t.fpregs is the same slot), so the offset
// is libc-independent. Inside the FXSAVE image the XMM registers start
// at +160 (32-byte header + 8 legacy x87/MMX slots x 16 bytes) - the
// same relative position they occupy in the Win64 CONTEXT's FltSave
// area (Win64ContextXmm0Offset = 256 + 160).
private const int LinuxGregsFpstateOffset = 184;
private const int FxsaveXmmOffset = 160;
private const int XmmBlockSize = 16 * 16;
// 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
@@ -84,15 +71,6 @@ public sealed unsafe partial class DirectExecutionBackend
[ThreadStatic]
private static int _posixSignalHandlerDepth;
// True while the current thread's in-flight POSIX fault carries the real
// XMM registers in the CONTEXT scratch buffer and writes to them will
// reach the mcontext on resume. Gates recovery paths (SSE4a EXTRQ/
// INSERTQ) that would otherwise compute results from a zeroed XMM area
// and silently discard what they "wrote". Darwin is not bridged yet, so
// the flag stays false there.
[ThreadStatic]
private static bool _posixXmmContextBridged;
private void SetupPosixExceptionHandler()
{
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_POSIX_SIGNALS"), "1", StringComparison.Ordinal))
@@ -274,26 +252,6 @@ public sealed unsafe partial class DirectExecutionBackend
WriteCtxU64(contextRecord, CTX_RAX + i * 8, *(ulong*)(registers + offsets[i]));
}
// Bridge the XMM registers alongside the GPRs where the layout is
// known: on Linux the fpstate pointer and FXSAVE image are kernel
// ABI, so recovery paths that read or write XMM state (SSE4a
// EXTRQ/INSERTQ) see the live registers and their writes reach the
// guest through sigreturn.
byte* fpstate = null;
if (OperatingSystem.IsLinux())
{
fpstate = *(byte**)(registers + LinuxGregsFpstateOffset);
if (fpstate != null)
{
Buffer.MemoryCopy(
fpstate + FxsaveXmmOffset,
contextRecord + Win64ContextXmm0Offset,
XmmBlockSize,
XmmBlockSize);
}
}
_posixXmmContextBridged = fpstate != null;
EXCEPTION_RECORD record = default;
record.ExceptionAddress = (void*)ReadCtxU64(contextRecord, CTX_RIP);
if (signal == PosixSigIll)
@@ -359,14 +317,6 @@ public sealed unsafe partial class DirectExecutionBackend
{
*(ulong*)(registers + offsets[i]) = ReadCtxU64(contextRecord, CTX_RAX + i * 8);
}
if (fpstate != null)
{
Buffer.MemoryCopy(
contextRecord + Win64ContextXmm0Offset,
fpstate + FxsaveXmmOffset,
XmmBlockSize,
XmmBlockSize);
}
return true;
}
@@ -1123,9 +1123,7 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
_logStrlenBursts = _logStrlenImports ||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_STRLEN_BURSTS"), "1", StringComparison.Ordinal);
_logGuestContext = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_CONTEXT"), "1", StringComparison.Ordinal);
var ignoreGuestInt41Env = Environment.GetEnvironmentVariable("SHARPEMU_IGNORE_INT41");
_ignoreGuestInt41 = !string.Equals(ignoreGuestInt41Env, "0", StringComparison.Ordinal) &&
!string.Equals(ignoreGuestInt41Env, "false", StringComparison.OrdinalIgnoreCase);
_ignoreGuestInt41 = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_IGNORE_INT41"), "1", StringComparison.Ordinal);
_ignoredGuestInt41Count = 0;
_logGuestThreads = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_GUEST_THREADS"), "1", StringComparison.Ordinal);
_logUsleep = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_USLEEP"), "1", StringComparison.Ordinal);
+4 -70
View File
@@ -199,32 +199,9 @@ public sealed class SelfLoader : ISelfLoader
{
if (!physicalVm.TryAllocateAtExact(imageBase, totalImageSize, executable: true, out var allocatedBase))
{
// Exact allocation failed — the host may have already claimed
// part of this range (ASLR, Rosetta 2, or another process).
// Try backing the fixed range page by page to claim whatever
// free gaps exist. If the whole range is occupied the backfill
// returns false and we surface the original failure reason.
Console.Error.WriteLine(
$"[LOADER] Exact allocation at main image base 0x{imageBase:X16} " +
$"(size=0x{totalImageSize:X}) failed; attempting fixed-range backfill.");
if (!physicalVm.TryBackFixedRange(imageBase, totalImageSize, executable: true))
{
// TryBackFixedRange may have partially backed pages before
// failing. The earlier Clear() already reset all regions, so
// this second Clear() is idempotent for everything except the
// partial backfill — it frees only those orphaned pages.
physicalVm.Clear();
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
throw new InvalidOperationException(
$"Could not allocate main image at required base 0x{imageBase:X16} " +
$"(size=0x{totalImageSize:X}): {reason}. " +
"Try closing other applications, rebooting, or " +
(OperatingSystem.IsWindows()
? "setting SHARPEMU_DISABLE_MITIGATION_RELAUNCH=1."
: "ensuring no other process maps into this address range."));
}
allocatedBase = imageBase;
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
throw new InvalidOperationException(
$"Could not allocate main image at required base 0x{imageBase:X16} (size=0x{totalImageSize:X}): {reason}.");
}
imageBase = allocatedBase;
@@ -737,9 +714,8 @@ public sealed class SelfLoader : ISelfLoader
importedRelocations = BuildImportedRelocations(descriptors);
var stubEligibleNids = CollectStubEligibleNids(descriptors, moduleManager);
var stubImportNids = orderedImportNids
.Where(stubEligibleNids.Contains)
.Where(nid => ShouldCreateImportStub(nid, descriptors, moduleManager))
.ToArray();
var stubsByAddress = CreateImportStubMapping(virtualMemory, stubImportNids);
Console.WriteLine($"[LOADER] Created {stubsByAddress.Count} import stubs");
@@ -1184,35 +1160,6 @@ public sealed class SelfLoader : ISelfLoader
isWeak);
}
// Collects every NID that needs a trap import stub in a single pass over the
// descriptors. This mirrors ShouldCreateImportStub applied per NID, but avoids
// the O(nids * descriptors) rescan that filtering each unique NID against the
// full descriptor list would incur on large modules. A NID qualifies as soon as
// one of its descriptors is non-weak, or is weak but resolvable via the module
// manager.
private static HashSet<string> CollectStubEligibleNids(
IReadOnlyList<RelocationDescriptor> descriptors,
IModuleManager? moduleManager)
{
var eligible = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < descriptors.Count; i++)
{
var descriptor = descriptors[i];
var nid = descriptor.ImportNid;
if (nid is null || eligible.Contains(nid))
{
continue;
}
if (!descriptor.IsWeak || moduleManager?.TryGetExport(nid, out _) == true)
{
eligible.Add(nid);
}
}
return eligible;
}
private static bool ShouldCreateImportStub(
string nid,
IReadOnlyList<RelocationDescriptor> descriptors,
@@ -2484,19 +2431,6 @@ public sealed class SelfLoader : ISelfLoader
Debug.Assert(
!ShouldCreateImportStub("weak", [weak], moduleManager: null),
"An unresolved weak symbol incorrectly received a trap import stub.");
var strong = new RelocationDescriptor(
TargetAddress: 0x3000,
Addend: 0,
ImportNid: "strong",
SymbolValue: 0,
RelocationValueKind.Pointer,
IsDataImport: false);
var mixed = new List<RelocationDescriptor> { weak, strong };
var eligible = CollectStubEligibleNids(mixed, moduleManager: null);
Debug.Assert(
eligible.Contains("strong") && !eligible.Contains("weak"),
"CollectStubEligibleNids disagreed with the per-NID stub eligibility rule.");
}
private static ulong AlignUp(ulong value, ulong alignment)
@@ -446,20 +446,13 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
// us over whole free or occupied stretches. Only free stretches get backed;
// stretches already reserved or committed by another allocation are left as
// they are, which is exactly what a fixed mapping does on hardware.
//
// Because backing may span several disjoint free runs, allocations are
// staged: host pages are reserved/committed first, and the corresponding
// MemoryRegions are inserted only once every gap in the range has been
// backed. If any gap fails to back, every earlier host allocation is freed
// and no region is inserted, so the address space is left untouched.
var stagedAllocations = new List<(ulong Address, ulong Size)>();
var cursor = start;
var backedAny = false;
while (cursor < end)
{
if (!_hostMemory.Query(cursor, out var info))
{
goto Rollback;
return false;
}
var queriedEnd = info.RegionSize > ulong.MaxValue - info.BaseAddress
@@ -468,7 +461,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
var runEnd = Math.Min(end, queriedEnd);
if (runEnd <= cursor)
{
goto Rollback;
return false;
}
if (info.State == HostRegionState.Free)
@@ -482,52 +475,35 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
_hostMemory.Free(allocated);
}
goto Rollback;
return false;
}
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
_gate.EnterWriteLock();
try
{
InsertRegionSorted(new MemoryRegion
{
VirtualAddress = cursor,
Size = runSize,
IsExecutable = executable,
IsReservedOnly = false,
Protection = protection
});
}
finally
{
_gate.ExitWriteLock();
}
stagedAllocations.Add((cursor, runSize));
TraceVmem($"Backed fixed range gap: 0x{cursor:X16} - 0x{runEnd:X16} ({runSize} bytes)");
backedAny = true;
}
cursor = runEnd;
}
if (stagedAllocations.Count == 0)
{
return false;
}
// All gaps backed successfully — insert regions in one batch.
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
_gate.EnterWriteLock();
try
{
foreach (var (gapAddress, gapSize) in stagedAllocations)
{
InsertRegionSorted(new MemoryRegion
{
VirtualAddress = gapAddress,
Size = gapSize,
IsExecutable = executable,
IsReservedOnly = false,
Protection = protection
});
}
}
finally
{
_gate.ExitWriteLock();
}
return true;
Rollback:
foreach (var (gapAddress, _) in stagedAllocations)
{
_hostMemory.Free(gapAddress);
}
return false;
return backedAny;
}
public bool TryAllocateAtOrAbove(
-44
View File
@@ -1,44 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
namespace SharpEmu.Libs.Acm;
public static class AcmExports
{
private static int _nextContextHandle;
[SysAbiExport(
Nid = "ZIXln2K3XMk",
ExportName = "sceAcmContextCreate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmContextCreate(CpuContext ctx)
{
var outContextAddress = ctx[CpuRegister.Rdi];
if (outContextAddress == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var handle = (ulong)Interlocked.Increment(ref _nextContextHandle);
Span<byte> handleBytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(handleBytes, handle);
return ctx.Memory.TryWrite(outContextAddress, handleBytes)
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK)
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
[SysAbiExport(
Nid = "jBgBjAj02R8",
ExportName = "sceAcmContextDestroy",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmContextDestroy(CpuContext ctx)
{
_ = ctx;
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
}
+52 -289
View File
@@ -3,7 +3,6 @@
using System.Collections.Concurrent;
using SharpEmu.HLE;
using SharpEmu.Libs.Bink;
using SharpEmu.Libs.Gpu;
using SharpEmu.ShaderCompiler;
using SharpEmu.Libs.Kernel;
@@ -49,7 +48,6 @@ public static partial class AgcExports
private const uint ItWriteData = 0x37;
private const uint ItDispatchDirect = 0x15;
private const uint ItDispatchIndirect = 0x16;
private const uint ItSetPredication = 0x20;
private const uint ItWaitRegMem = 0x3C;
private const uint ItIndirectBuffer = 0x3F;
private const uint ItEventWrite = 0x46;
@@ -223,7 +221,6 @@ public static partial class AgcExports
(ulong Es, ulong State, ulong AliasAlignment),
IGuestCompiledShader> _depthOnlyVertexShaderCache = new();
private static readonly Dictionary<ulong, ulong> _shaderHeadersByCode = new();
private static readonly ConcurrentDictionary<ulong, byte> _arrayUploadUnsupported = new();
private static readonly bool _traceAgc = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_AGC"),
"1",
@@ -279,7 +276,7 @@ public static partial class AgcExports
private static long _labelProducerSequence;
private static readonly object _labelProducerGate = new();
private static readonly List<LabelProducerTrace> _labelProducers = [];
private static readonly HashSet<(object Memory, ulong Address)>
private static readonly HashSet<(object Memory, ulong Address, ulong SubmissionId)>
_tracedProducerlessWaits = new();
private static long _shaderTranslationMissTraceCount;
private static long _translatedDrawTraceCount;
@@ -545,7 +542,6 @@ public static partial class AgcExports
public uint IndexSize { get; set; }
public uint InstanceCount { get; set; } = 1;
public uint DrawIndexOffset { get; set; }
public bool PredicateSkip { get; set; }
public string QueueName { get; set; } = "graphics";
public ulong ActiveSubmissionId { get; set; }
public Queue<PendingSubmission> PendingSubmissions { get; } = new();
@@ -965,20 +961,6 @@ public static partial class AgcExports
return ReturnPointer(ctx, commandAddress);
}
// RenderThread/Subrender probe this before writing a NOP. Unresolved
// GetSize returns NOT_FOUND and leaves command-buffer sizing broken.
// CbNop rejects dwordCount < 2, so report that floor.
[SysAbiExport(
Nid = "t7PlZ9nt5Lc",
ExportName = "sceAgcCbNopGetSize",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int CbNopGetSize(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 2u * sizeof(uint);
return (int)ctx[CpuRegister.Rax];
}
[SysAbiExport(
Nid = "k3GhuSNmBLU",
ExportName = "sceAgcCbDispatch",
@@ -1176,18 +1158,6 @@ public static partial class AgcExports
return ReturnPointer(ctx, commandAddress);
}
// Matches the fixed 8-dword ACQUIRE_MEM packet AcbAcquireMem writes above.
[SysAbiExport(
Nid = "ewobAQeMo5k",
ExportName = "sceAgcAcbAcquireMemGetSize",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int AcbAcquireMemGetSize(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 8u * sizeof(uint);
return (int)ctx[CpuRegister.Rax];
}
[SysAbiExport(
Nid = "htn36gPnBk4",
ExportName = "sceAgcAcbWaitRegMem",
@@ -1212,12 +1182,12 @@ public static partial class AgcExports
return ReturnPointer(ctx, 0);
}
var packetDwords = size == 0 ? 7u : 9u;
var packetDwords = size == 0 ? 6u : 9u;
var packetRegister = size == 0 ? RWaitMem32 : RWaitMem64;
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItNop, packetRegister)) ||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address & (size == 0 ? ~0x3u : ~0x7u)) ||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32) & 0x3FFFFu) ||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address) ||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)mask))
{
return ReturnPointer(ctx, 0);
@@ -1225,9 +1195,8 @@ public static partial class AgcExports
if (size == 0)
{
if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)reference) ||
!TryWriteUInt32(ctx, commandAddress + 20, EncodeWaitRegMem32Control(compareFunction, 0, cachePolicy)) ||
!TryWriteUInt32(ctx, commandAddress + 24, EncodeWaitRegMemPoll(pollCycles)))
if (!TryWriteUInt32(ctx, commandAddress + 16, compareFunction) ||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference))
{
return ReturnPointer(ctx, 0);
}
@@ -1235,8 +1204,8 @@ public static partial class AgcExports
else if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)(mask >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference) ||
!TryWriteUInt32(ctx, commandAddress + 24, (uint)(reference >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 28, EncodeWaitRegMem64Control(compareFunction, 0, cachePolicy)) ||
!TryWriteUInt32(ctx, commandAddress + 32, EncodeWaitRegMemPoll(pollCycles)))
!TryWriteUInt32(ctx, commandAddress + 28, compareFunction) ||
!TryWriteUInt32(ctx, commandAddress + 32, pollCycles / 40))
{
return ReturnPointer(ctx, 0);
}
@@ -1763,18 +1732,6 @@ public static partial class AgcExports
return ReturnPointer(ctx, commandAddress);
}
// Matches the fixed 8-dword ACQUIRE_MEM packet DcbAcquireMem writes above.
[SysAbiExport(
Nid = "-vnlTPPXPrw",
ExportName = "sceAgcDcbAcquireMemGetSize",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int DcbAcquireMemGetSize(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 8u * sizeof(uint);
return (int)ctx[CpuRegister.Rax];
}
[SysAbiExport(
Nid = "i1jyy49AjXU",
ExportName = "sceAgcDcbWriteData",
@@ -1868,21 +1825,38 @@ public static partial class AgcExports
return ReturnPointer(ctx, 0);
}
var packetDwords = size == 0 ? 7u : 9u;
var standardWait = operation is 2 or 3;
var packetDwords = standardWait ? 7u : size == 0 ? 6u : 9u;
var packetRegister = size == 0 ? RWaitMem32 : RWaitMem64;
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItNop, packetRegister)) ||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address & (size == 0 ? ~0x3u : ~0x7u)) ||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32) & 0x3FFFFu) ||
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress))
{
return ReturnPointer(ctx, 0);
}
if (standardWait)
{
if (!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItWaitRegMem, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 4, compareFunction | ((operation & 1) << 8)) ||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)address) ||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)(address >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 16, (uint)reference) ||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)mask) ||
!TryWriteUInt32(ctx, commandAddress + 24, pollCycles / 40))
{
return ReturnPointer(ctx, 0);
}
}
else if (!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItNop, packetRegister)) ||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address) ||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)mask))
{
return ReturnPointer(ctx, 0);
}
else if (size == 0)
{
if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)reference) ||
!TryWriteUInt32(ctx, commandAddress + 20, EncodeWaitRegMem32Control(compareFunction, operation, cachePolicy)) ||
!TryWriteUInt32(ctx, commandAddress + 24, EncodeWaitRegMemPoll(pollCycles)))
if (!TryWriteUInt32(ctx, commandAddress + 16, compareFunction | (operation << 8)) ||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference))
{
return ReturnPointer(ctx, 0);
}
@@ -1890,8 +1864,8 @@ public static partial class AgcExports
else if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)(mask >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference) ||
!TryWriteUInt32(ctx, commandAddress + 24, (uint)(reference >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 28, EncodeWaitRegMem64Control(compareFunction, operation, cachePolicy)) ||
!TryWriteUInt32(ctx, commandAddress + 32, EncodeWaitRegMemPoll(pollCycles)))
!TryWriteUInt32(ctx, commandAddress + 28, compareFunction | (operation << 8)) ||
!TryWriteUInt32(ctx, commandAddress + 32, pollCycles / 40))
{
return ReturnPointer(ctx, 0);
}
@@ -2330,14 +2304,7 @@ public static partial class AgcExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
var wrote = op == ItNop && register is RWaitMem32 or RWaitMem64
? TryWriteUInt32(
ctx,
commandAddress + fieldOffset,
(uint)address & (register == RWaitMem32 ? ~0x3u : ~0x7u)) &&
TryWriteUInt32(ctx, commandAddress + fieldOffset + 4, (uint)(address >> 32) & 0x3FFFFu)
: ctx.TryWriteUInt64(commandAddress + fieldOffset, address);
return wrote
return ctx.TryWriteUInt64(commandAddress + fieldOffset, address)
? SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK)
: SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
@@ -2360,7 +2327,7 @@ public static partial class AgcExports
var fieldOffset = op == ItWaitRegMem
? 4UL
: op == ItNop && register == RWaitMem32
? 20UL
? 16UL
: op == ItNop && register == RWaitMem64
? 28UL
: 0;
@@ -2389,7 +2356,7 @@ public static partial class AgcExports
var wrote = op == ItWaitRegMem
? TryWriteUInt32(ctx, commandAddress + 16, (uint)reference)
: op == ItNop && register == RWaitMem32
? TryWriteUInt32(ctx, commandAddress + 16, (uint)reference)
? TryWriteUInt32(ctx, commandAddress + 20, (uint)reference)
: op == ItNop && register == RWaitMem64 &&
ctx.TryWriteUInt64(commandAddress + 20, reference);
return wrote
@@ -2428,20 +2395,6 @@ public static partial class AgcExports
: OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
// PatchAddress/PatchData touch UInt64 fields at +12/+20 of an RReleaseMem
// packet, so the packet is at least 7 dwords; use the 8-dword RELEASE_MEM
// family size already used elsewhere in this file.
[SysAbiExport(
Nid = "hL7C0IRpWZI",
ExportName = "sceAgcCbQueueEndOfPipeActionGetSize",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int CbQueueEndOfPipeActionGetSize(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 8u * sizeof(uint);
return (int)ctx[CpuRegister.Rax];
}
[SysAbiExport(
Nid = "0fWWK5uG9rQ",
ExportName = "sceAgcQueueEndOfPipeActionPatchAddress",
@@ -3151,26 +3104,6 @@ public static partial class AgcExports
CountSubmittedOpcode(op, register);
}
if ((header & 1u) != 0 && state.PredicateSkip)
{
if (tracePackets)
{
TraceAgc(
$"agc.dcb.predicated_skip queue={state.QueueName} " +
$"packet=0x{currentAddress:X16} op=0x{op:X2} len={length}");
}
offset += length;
continue;
}
if (op == ItSetPredication)
{
ApplySubmittedPredication(ctx, state, currentAddress, length, tracePackets);
offset += length;
continue;
}
if (op == ItNop &&
register is RDrawReset or RAcbReset &&
length >= 2)
@@ -3889,7 +3822,7 @@ public static partial class AgcExports
if (!stale && producer is null &&
!_tracedProducerlessWaits.Add(
(memory, waiter.WaitAddress)))
(memory, waiter.WaitAddress, waiter.SubmissionId)))
{
return;
}
@@ -4085,111 +4018,6 @@ public static partial class AgcExports
state.DrawIndexOffset = 0;
}
private static void ApplySubmittedPredication(
CpuContext ctx,
SubmittedDcbState state,
ulong packetAddress,
uint packetLength,
bool tracePacket)
{
if (packetLength < 3 ||
!TryReadUInt32(ctx, packetAddress + 4, out var first) ||
!TryReadUInt32(ctx, packetAddress + 8, out var second))
{
return;
}
const uint flagsMask = 0x0007_1100u;
uint flags;
ulong predicateAddress;
if (packetLength >= 4 &&
(first & ~flagsMask) == 0 &&
TryReadUInt32(ctx, packetAddress + 12, out var third) &&
third <= 0xFFFFu)
{
flags = first;
predicateAddress = ((ulong)third << 32) | (second & 0xFFFF_FFF0u);
}
else
{
flags = second;
predicateAddress = (first & 0xFFFF_FFF0u) | ((ulong)(second & 0xFFu) << 32);
}
var operation = (flags >> 16) & 0x7u;
if (operation == 0)
{
state.PredicateSkip = false;
return;
}
if (operation != 3)
{
if (tracePacket)
{
TraceAgc(
$"agc.dcb.predication_unsupported packet=0x{packetAddress:X16} " +
$"op={operation} addr=0x{predicateAddress:X16}");
}
return;
}
var waitOperation = (flags >> 12) & 1u;
var value = 0UL;
var readSucceeded = false;
void ReadPredicate() =>
readSucceeded = ctx.TryReadUInt64(predicateAddress, out value);
if (waitOperation != 0)
{
var sequence = GuestGpu.Current.SubmitOrderedGuestAction(
ReadPredicate,
$"set_predication read 0x{predicateAddress:X16}");
if (sequence == 0)
{
ReadPredicate();
}
else if (!GuestGpu.Current.WaitForGuestWork(sequence))
{
if (tracePacket)
{
TraceAgc(
$"agc.dcb.predication_wait_failed packet=0x{packetAddress:X16} " +
$"addr=0x{predicateAddress:X16} sequence={sequence}");
}
return;
}
}
else
{
ReadPredicate();
}
if (!readSucceeded)
{
if (tracePacket)
{
TraceAgc(
$"agc.dcb.predication_read_failed packet=0x{packetAddress:X16} " +
$"addr=0x{predicateAddress:X16}");
}
return;
}
var condition = (flags >> 8) & 1u;
state.PredicateSkip = condition == 0 ? value != 0 : value == 0;
if (tracePacket)
{
TraceAgc(
$"agc.dcb.predication packet=0x{packetAddress:X16} " +
$"addr=0x{predicateAddress:X16} value=0x{value:X16} " +
$"condition={condition} wait={waitOperation} skip={state.PredicateSkip}");
}
}
private static bool RangesOverlap(
ulong leftAddress,
ulong leftLength,
@@ -4732,7 +4560,6 @@ public static partial class AgcExports
private static bool TryParseSubmittedWait(
CpuContext ctx,
ulong packetAddress,
uint packetLength,
bool is64Bit,
bool isStandard,
out ulong waitAddress,
@@ -4763,10 +4590,8 @@ public static partial class AgcExports
return true;
}
var legacyWait32 = !is64Bit && packetLength == 6;
var controlOffset = is64Bit ? 28u : legacyWait32 ? 16u : 20u;
if (!TryReadUInt64(ctx, packetAddress + 4, out waitAddress) ||
!TryReadUInt32(ctx, packetAddress + controlOffset, out var control))
!TryReadUInt32(ctx, packetAddress + (is64Bit ? 28u : 16u), out var control))
{
return false;
}
@@ -4779,9 +4604,8 @@ public static partial class AgcExports
TryReadUInt64(ctx, packetAddress + 20, out reference);
}
var referenceOffset = legacyWait32 ? 20u : 16u;
if (!TryReadUInt32(ctx, packetAddress + 12, out var mask32) ||
!TryReadUInt32(ctx, packetAddress + referenceOffset, out var reference32))
!TryReadUInt32(ctx, packetAddress + 20, out var reference32))
{
return false;
}
@@ -4886,7 +4710,7 @@ public static partial class AgcExports
bool tracePacket)
{
if (!TryParseSubmittedWait(
ctx, packetAddress, length, is64Bit, isStandard,
ctx, packetAddress, is64Bit, isStandard,
out var waitAddress, out var reference, out var mask, out var compareFunction,
out var controlValue))
{
@@ -8160,8 +7984,7 @@ public static partial class AgcExports
descriptor.Address != 0 &&
(descriptor.Type == Gen5TextureType2DArray ||
descriptor.Type == Gen5TextureType1DArray) &&
descriptor.Depth > 1 &&
!_arrayUploadUnsupported.ContainsKey(descriptor.Address);
descriptor.Depth > 1;
var arrayUploadLayers = wantsArrayUpload ? descriptor.Depth : 1u;
// Upload-known (not plain availability): the presenter's answer goes
@@ -8390,8 +8213,6 @@ public static partial class AgcExports
return true;
}
}
_arrayUploadUnsupported.TryAdd(descriptor.Address, 0);
}
var source = new byte[(int)physicalSourceByteCount];
@@ -11085,23 +10906,6 @@ public static partial class AgcExports
((op & 0xFFu) << 8) |
((register & 0x3Fu) << 2);
private static uint EncodeWaitRegMemPoll(uint pollCycles) =>
Math.Min(pollCycles >> 4, 0xFFFFu);
private static uint EncodeWaitRegMem32Control(uint compareFunction, uint operation, uint cachePolicy) =>
0x10u |
(compareFunction & 0x7u) |
((operation & 0x3u) << 8) |
((operation & 0xCu) << 4) |
((cachePolicy & 0x3u) << 25);
private static uint EncodeWaitRegMem64Control(uint compareFunction, uint operation, uint cachePolicy) =>
0x10u |
(compareFunction & 0x7u) |
((operation & 0x1u) << 8) |
((operation & 0x6u) << 5) |
((cachePolicy & 0x3u) << 25);
private static uint Pm4Length(uint header) =>
((header >> 16) & 0x3FFFu) + 2u;
@@ -11521,34 +11325,6 @@ public static partial class AgcExports
$"[LOADER][TRACE] agc.create_shader dst=0x{destinationAddress:X16} header=0x{headerAddress:X16} code=0x{codeAddress:X16} {detail}");
}
// Hardware REWIND is a fixed 2-dword header + valid-bit packet (same floor
// as CbNopGetSize). No Rewind writer is implemented yet; size-only is enough
// for callers that allocate the packet before filling it.
[SysAbiExport(
Nid = "QIXCsbipds0",
ExportName = "sceAgcDcbRewindGetSize",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int DcbRewindGetSize(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 2u * sizeof(uint);
return (int)ctx[CpuRegister.Rax];
}
// Matches the 4-dword INDIRECT_BUFFER packet DcbJump writes below.
// Returning NOT_FOUND here left callers with a null packet pointer and an
// immediate write AV on RenderThread.
[SysAbiExport(
Nid = "VEGu4dixjUg",
ExportName = "sceAgcDcbJumpGetSize",
Target = Generation.Gen5,
LibraryName = "libSceAgc")]
public static int DcbJumpGetSize(CpuContext ctx)
{
ctx[CpuRegister.Rax] = 4u * sizeof(uint);
return (int)ctx[CpuRegister.Rax];
}
[SysAbiExport(
Nid = "xSAR0LTcRKM",
ExportName = "sceAgcDcbJump",
@@ -11584,21 +11360,16 @@ public static partial class AgcExports
public static int DcbSetPredication(CpuContext ctx)
{
var dcb = ctx[CpuRegister.Rdi];
var condition = (uint)(ctx[CpuRegister.Rsi] & 1u);
var operation = (uint)(ctx[CpuRegister.Rdx] & 0x7u);
var waitOperation = (uint)(ctx[CpuRegister.Rcx] & 1u);
var address = ctx[CpuRegister.R8];
var address = ctx[CpuRegister.Rsi];
if (dcb == 0)
{
return ReturnPointer(ctx, 0);
}
var flags = (condition << 8) | (waitOperation << 12) | (operation << 16);
if (!TryAllocateCommandDwords(ctx, dcb, 4, out var cmd) ||
!ctx.TryWriteUInt32(cmd, Pm4(4, ItSetPredication, RZero)) ||
!ctx.TryWriteUInt32(cmd + 4, flags) ||
!ctx.TryWriteUInt32(cmd + 8, (uint)address & 0xFFFF_FFF0u) ||
!ctx.TryWriteUInt32(cmd + 12, (uint)(address >> 32)))
if (!TryAllocateCommandDwords(ctx, dcb, 3, out var cmd) ||
!ctx.TryWriteUInt32(cmd, Pm4(3, ItNop, RZero)) ||
!ctx.TryWriteUInt32(cmd + 4, (uint)(address & 0xFFFF_FFFFUL)) ||
!ctx.TryWriteUInt32(cmd + 8, (uint)(address >> 32)))
{
return ReturnPointer(ctx, 0);
}
@@ -11613,17 +11384,9 @@ public static partial class AgcExports
LibraryName = "libSceAgc")]
public static int SetPacketPredication(CpuContext ctx)
{
var packetAddress = ctx[CpuRegister.Rdi];
var predication = ctx[CpuRegister.Rsi];
if (packetAddress == 0 || !TryReadUInt32(ctx, packetAddress, out var header))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
header = (header & ~1u) | (predication == 1 ? 1u : 0u);
return !ctx.TryWriteUInt32(packetAddress, header)
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT)
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
// Global predication toggle on a packet; a no-op is safe for rendering.
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
// ABI (reversed from Quake): rdi = array of DCB base addresses (u64 each),
@@ -11798,7 +11561,7 @@ public static partial class AgcExports
uint owner;
lock (state.Gate)
{
if (state.ResourceRegistrationInitialized &&
if (!state.ResourceRegistrationInitialized ||
state.ResourceRegistrationMaxOwners != 0 &&
state.ResourceOwners.Count >= state.ResourceRegistrationMaxOwners)
{
+42 -169
View File
@@ -1,9 +1,6 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
namespace SharpEmu.Libs.Agc;
/// <summary>
@@ -19,11 +16,8 @@ namespace SharpEmu.Libs.Agc;
/// other D/R and pipe/bank-XOR modes stay opt-in while their complete AddrLib
/// equations are being ported.
/// </summary>
internal static unsafe class GnmTiling
internal static class GnmTiling
{
private const int ParallelDetileElementThreshold = 512 * 512;
private const int MaxDetileWorkers = 4;
// Oberon uses the 16-pipe / 8-pixel-packer RB+ topology. These are the
// single-sample 64 KiB equations generated by AMD AddrLib for that exact
// topology. Each entry describes one address bit as an XOR of X/Y bits.
@@ -124,14 +118,6 @@ internal static unsafe class GnmTiling
StringComparison.Ordinal);
private static readonly HashSet<uint> _reportedModes = new();
private static readonly ConcurrentDictionary<(uint SwizzleMode, int BppLog2), PatternTerms>
_patternTermCache = new();
private static readonly ConcurrentDictionary<(SwizzleKind Kind, int Width, int Height), int[]>
_blockTableCache = new();
private static readonly ParallelOptions _parallelDetileOptions = new()
{
MaxDegreeOfParallelism = Math.Min(MaxDetileWorkers, Environment.ProcessorCount),
};
public static bool Enabled => _enabled || !_disabled;
@@ -418,83 +404,50 @@ internal static unsafe class GnmTiling
return false;
}
// Address tables depend only on the swizzle equation and element size,
// so retain them across textures instead of rebuilding them per upload.
// Precompute the within-block element offset for each (x, y) inside a
// single block. The swizzle equation only depends on the in-block
// coordinates, so this table is reused for every block — turning the
// per-pixel bit-interleave (a loop + calls) into a single array lookup.
// Detiling a 2048x2048 texture is millions of elements; without this the
// per-pixel math makes DETILE unusably slow during asset streaming.
var hasExactXorPattern = TryGetExactXorPattern(swizzleMode, bppLog2, out var xorPattern);
var patternTerms = hasExactXorPattern
? _patternTermCache.GetOrAdd(
(swizzleMode, bppLog2),
_ => CreatePatternTerms(xorPattern))
: default;
var blockTable = hasExactXorPattern
? []
: _blockTableCache.GetOrAdd(
(kind, blockWidth, blockHeight),
static key => CreateBlockTable(key.Kind, key.Width, key.Height));
// The XOR equation offset factors cleanly into independent X and Y
// fields — each output bit is parity(x & XMask) XOR parity(y & YMask),
// and parity distributes over XOR, so offset(x, y) == xTerm(x) ^ yTerm(y).
// Exact equations repeat at a small power-of-two period. Cached axis
// terms reduce the inner loop to two array loads and one XOR.
fixed (byte* tiledPointer = tiled)
fixed (byte* linearPointer = linear)
var blockTable = hasExactXorPattern ? [] : new int[blockWidth * blockHeight];
for (var by = 0; !hasExactXorPattern && by < blockHeight; by++)
{
var sourceAddress = (nint)tiledPointer;
var destinationAddress = (nint)linearPointer;
var sourceLength = tiled.Length;
var destinationLength = linear.Length;
var blockWidthShift = BitLog2((uint)blockWidth);
var blockWidthMask = blockWidth - 1;
var detileRow = (int y) =>
for (var bx = 0; bx < blockWidth; bx++)
{
var blockY = y / blockHeight;
var inBlockY = y & (blockHeight - 1);
var rowBlockBase = (long)blockY * blocksPerRow;
var tableRowBase = inBlockY * blockWidth;
var destRowBase = (long)y * elementsWide * bytesPerElement;
var yTerm = hasExactXorPattern
? patternTerms.Y[y & patternTerms.YMask]
: 0;
for (var x = 0; x < elementsWide; x++)
{
var blockX = x >> blockWidthShift;
var inBlockX = x & blockWidthMask;
var blockIndex = rowBlockBase + blockX;
var sourceByte = hasExactXorPattern
? blockIndex * blockBytes + (patternTerms.X[x & patternTerms.XMask] ^ yTerm)
: (blockIndex * blockElements + blockTable[tableRowBase + inBlockX]) *
(long)bytesPerElement;
var destByte = destRowBase + (long)x * bytesPerElement;
if (sourceByte < 0 ||
sourceByte + bytesPerElement > sourceLength ||
destByte + bytesPerElement > destinationLength)
{
continue;
}
CopyElement(
(byte*)sourceAddress + sourceByte,
(byte*)destinationAddress + destByte,
bytesPerElement);
}
};
var elementCount = (long)elementsWide * elementsHigh;
if (elementCount >= ParallelDetileElementThreshold && Environment.ProcessorCount > 1)
{
Parallel.For(
0,
elementsHigh,
_parallelDetileOptions,
detileRow);
blockTable[by * blockWidth + bx] = (int)(kind == SwizzleKind.ZOrder
? MortonInterleave((uint)bx, (uint)by, blockWidth, blockHeight)
: StandardSwizzleOffset((uint)bx, (uint)by, blockWidth, blockHeight));
}
else
}
for (var y = 0; y < elementsHigh; y++)
{
var blockY = y / blockHeight;
var inBlockY = y % blockHeight;
var rowBlockBase = (long)blockY * blocksPerRow;
var tableRowBase = inBlockY * blockWidth;
var destRowBase = (long)y * elementsWide * bytesPerElement;
for (var x = 0; x < elementsWide; x++)
{
for (var y = 0; y < elementsHigh; y++)
var blockX = x / blockWidth;
var inBlockX = x % blockWidth;
var blockIndex = rowBlockBase + blockX;
var sourceByte = hasExactXorPattern
? blockIndex * blockBytes + ComputePatternOffset((uint)x, (uint)y, xorPattern)
: (blockIndex * blockElements + blockTable[tableRowBase + inBlockX]) *
(long)bytesPerElement;
var destByte = destRowBase + (long)x * bytesPerElement;
if (sourceByte + bytesPerElement > tiled.Length ||
destByte + bytesPerElement > linear.Length)
{
detileRow(y);
continue;
}
tiled.Slice((int)sourceByte, bytesPerElement)
.CopyTo(linear.Slice((int)destByte, bytesPerElement));
}
}
@@ -507,80 +460,6 @@ internal static unsafe class GnmTiling
ZOrder,
}
private readonly record struct PatternTerms(int[] X, int XMask, int[] Y, int YMask);
private static PatternTerms CreatePatternTerms(AddressBit[] pattern)
{
uint xMask = 0;
uint yMask = 0;
foreach (var bit in pattern)
{
xMask |= bit.XMask;
yMask |= bit.YMask;
}
var xLength = AxisTermPeriod(xMask);
var yLength = AxisTermPeriod(yMask);
var xTerms = new int[xLength];
var yTerms = new int[yLength];
for (var x = 0; x < xTerms.Length; x++)
{
xTerms[x] = (int)PatternAxisTerm((uint)x, pattern, useX: true);
}
for (var y = 0; y < yTerms.Length; y++)
{
yTerms[y] = (int)PatternAxisTerm((uint)y, pattern, useX: false);
}
return new PatternTerms(xTerms, xLength - 1, yTerms, yLength - 1);
}
private static int AxisTermPeriod(uint mask) =>
mask == 0 ? 1 : 1 << (32 - System.Numerics.BitOperations.LeadingZeroCount(mask));
private static int[] CreateBlockTable(SwizzleKind kind, int blockWidth, int blockHeight)
{
var table = new int[blockWidth * blockHeight];
for (var y = 0; y < blockHeight; y++)
{
for (var x = 0; x < blockWidth; x++)
{
table[y * blockWidth + x] = (int)(kind == SwizzleKind.ZOrder
? MortonInterleave((uint)x, (uint)y, blockWidth, blockHeight)
: StandardSwizzleOffset((uint)x, (uint)y, blockWidth, blockHeight));
}
}
return table;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void CopyElement(byte* source, byte* destination, int bytesPerElement)
{
switch (bytesPerElement)
{
case 1:
*destination = *source;
break;
case 2:
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<ushort>(source));
break;
case 4:
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<uint>(source));
break;
case 8:
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<ulong>(source));
break;
case 16:
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<UInt128>(source));
break;
default:
Unsafe.CopyBlockUnaligned(destination, source, (uint)bytesPerElement);
break;
}
}
private static readonly AddressBit Zero = new(0, 0);
private static AddressBit X(int bit) => new(1u << bit, 0);
@@ -614,20 +493,14 @@ internal static unsafe class GnmTiling
return pattern.Length != 0;
}
// The AddrLib within-block byte offset is a per-bit XOR equation:
// offset = OR over bits of ( parity(x & XMask) XOR parity(y & YMask) ) << bit
// Because parity distributes over XOR, that whole offset factors into two
// independent axis terms: PatternAxisTerm(x, useX: true) ^
// PatternAxisTerm(y, useX: false). Splitting the axes lets TryDetile cache
// the X term per column and hoist the Y term per row instead of recomputing
// the full 16-bit interleave (32 PopCounts) for every element.
private static uint PatternAxisTerm(uint coordinate, AddressBit[] pattern, bool useX)
private static long ComputePatternOffset(uint x, uint y, AddressBit[] pattern)
{
uint offset = 0;
for (var bit = 0; bit < pattern.Length; bit++)
{
var mask = useX ? pattern[bit].XMask : pattern[bit].YMask;
var parity = System.Numerics.BitOperations.PopCount(coordinate & mask) & 1;
var equation = pattern[bit];
var parity = (System.Numerics.BitOperations.PopCount(x & equation.XMask) +
System.Numerics.BitOperations.PopCount(y & equation.YMask)) & 1;
offset |= (uint)parity << bit;
}
-39
View File
@@ -343,45 +343,6 @@ internal static class GpuWaitRegistry
return expired;
}
public static List<WaitingDcb>? CollectAllForMemory(object memory)
{
List<WaitingDcb>? collected = null;
lock (_gate)
{
List<ulong>? emptied = null;
foreach (var (address, list) in _waiters)
{
for (var index = list.Count - 1; index >= 0; index--)
{
if (!ReferenceEquals(list[index].Memory, memory))
{
continue;
}
collected ??= new List<WaitingDcb>();
collected.Add(list[index]);
list.RemoveAt(index);
}
if (list.Count == 0)
{
emptied ??= new List<ulong>();
emptied.Add(address);
}
}
if (emptied is not null)
{
foreach (var address in emptied)
{
_waiters.Remove(address);
}
}
}
return collected;
}
/// <summary>Records the value a label producer wrote, for the deadlock
/// breaker. Also latches any already-waiting waiter it satisfies.</summary>
public static bool RecordProduced(object memory, ulong address, ulong value)
-38
View File
@@ -340,18 +340,6 @@ public static class AmprExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "4fgtGfXDrFc",
ExportName = "sceAmprMeasureCommandSizeWriteAddress_04_00",
Target = Generation.Gen5,
LibraryName = "libSceAmpr")]
public static int MeasureCommandSizeWriteAddress0400(CpuContext ctx)
{
TraceAmpr(ctx, "measure_write_address", 0, WriteAddressRecordSize, 0);
ctx[CpuRegister.Rax] = WriteAddressRecordSize;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "tZDDEo2tE5k",
ExportName = "sceAmprCommandBufferGetSize",
@@ -521,32 +509,6 @@ public static class AmprExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "j0+3uJMxYJY",
ExportName = "sceAmprCommandBufferWriteAddress_04_00",
Target = Generation.Gen5,
LibraryName = "libSceAmpr")]
public static int CommandBufferWriteAddress0400(CpuContext ctx)
{
var commandBuffer = ctx[CpuRegister.Rdi];
var address = ctx[CpuRegister.Rsi];
var value = ctx[CpuRegister.Rdx];
if (commandBuffer == 0 || address == 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
if (!AppendWriteAddressRecord(ctx, commandBuffer, address, value))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
TraceAmpr(ctx, "write_address", commandBuffer, address, value);
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
public static int CompleteCommandBuffer(CpuContext ctx, ulong commandBuffer)
{
if (commandBuffer == 0)
+1 -242
View File
@@ -17,11 +17,10 @@ public static class AjmExports
private const int OrbisAjmErrorCodecAlreadyRegistered = unchecked((int)0x80930009);
private const int OrbisAjmErrorCodecNotRegistered = unchecked((int)0x8093000A);
private const int OrbisAjmErrorWrongRevisionFlag = unchecked((int)0x8093000B);
private const uint MaxCodecType = 25;
private const uint MaxCodecType = 23;
private const int MaxInstanceIndex = 0x2FFF;
private static readonly ConcurrentDictionary<uint, AjmContextState> Contexts = new();
private static int _nextContextId;
private static int _nextBatchId;
private sealed class AjmContextState
{
@@ -228,250 +227,10 @@ public static class AjmExports
return 0;
}
/// <summary>
/// Enqueues a decode job on a batch. Titles call this on the Bink/AJM hot
/// path; leaving it unresolved floods Import WARN spam. This is a silence
/// stub, not a codec: advance the batch cursor and report the input as
/// consumed with silence produced so the title does not spin on the same
/// packet.
/// </summary>
[SysAbiExport(
Nid = "39WxhR-ePew",
ExportName = "sceAjmBatchJobDecode",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAjm")]
public static int AjmBatchJobDecode(CpuContext ctx)
{
var infoAddress = ctx[CpuRegister.Rdi];
var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]);
var inputAddress = ctx[CpuRegister.Rdx];
var inputSize = ctx[CpuRegister.Rcx];
var outputAddress = ctx[CpuRegister.R8];
var outputSize = ctx[CpuRegister.R9];
var resultAddress = ReadStackArg64(ctx, 0);
if (infoAddress == 0)
{
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
}
// Best-effort: bump the batch cursor when the guest filled AjmBatchInfo.
// Still succeed without it — the unresolved stub returned 0 and titles
// keep calling; failing here would reintroduce hot-path spam via retries.
_ = TryAppendBatchJob(ctx, infoAddress, AjmJobRunSize);
// Silence: clear PCM out and claim full input consumed so the guest
// advances its bitstream cursor instead of re-submitting forever.
if (outputAddress != 0 && outputSize != 0 && outputSize <= MaxSilentPcmBytes)
{
ClearGuestMemory(ctx, outputAddress, outputSize);
}
WriteDecodeStreamResult(
ctx,
resultAddress,
inputConsumed: inputSize > int.MaxValue ? int.MaxValue : (int)inputSize,
outputWritten: 0,
totalDecodedSamples: 0,
frames: inputSize != 0 || outputSize != 0 ? 1u : 0u);
Trace(
$"batch_job_decode info=0x{infoAddress:X16} instance=0x{instanceId:X8} " +
$"in=0x{inputAddress:X16}+0x{inputSize:X} out=0x{outputAddress:X16}+0x{outputSize:X} " +
$"result=0x{resultAddress:X16}");
return ctx.SetReturn(0);
}
/// <summary>
/// Submits a built batch. Instant-complete silence stub: publish a batch id
/// and clear any error out. Decode sidebands were already filled at
/// job-enqueue time.
/// </summary>
[SysAbiExport(
Nid = "5tOfnaClcqM",
ExportName = "sceAjmBatchStart",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAjm")]
public static int AjmBatchStart(CpuContext ctx)
{
var contextId = unchecked((uint)ctx[CpuRegister.Rdi]);
var infoAddress = ctx[CpuRegister.Rsi];
var priority = unchecked((int)ctx[CpuRegister.Rdx]);
var errorAddress = ctx[CpuRegister.Rcx];
var batchOutAddress = ctx[CpuRegister.R8];
if (infoAddress == 0 || batchOutAddress == 0)
{
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
}
ClearAjmBatchError(ctx, errorAddress);
var batchId = unchecked((uint)Interlocked.Increment(ref _nextBatchId));
Span<byte> batchValue = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(batchValue, batchId);
if (!ctx.Memory.TryWrite(batchOutAddress, batchValue))
{
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
}
Trace(
$"batch_start context={contextId} info=0x{infoAddress:X16} " +
$"priority={priority} batch={batchId} error=0x{errorAddress:X16}");
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "-qLsfDAywIY",
ExportName = "sceAjmBatchWait",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAjm")]
public static int AjmBatchWait(CpuContext ctx)
{
// Batches complete synchronously in Start; Wait is a no-op success.
var errorAddress = ctx[CpuRegister.Rcx];
ClearAjmBatchError(ctx, errorAddress);
Trace(
$"batch_wait context={unchecked((uint)ctx[CpuRegister.Rdi])} " +
$"batch={unchecked((uint)ctx[CpuRegister.Rsi])} " +
$"timeout={unchecked((uint)ctx[CpuRegister.Rdx])}");
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "NVDXiUesSbA",
ExportName = "sceAjmBatchCancel",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAjm")]
public static int AjmBatchCancel(CpuContext ctx)
{
Trace(
$"batch_cancel context={unchecked((uint)ctx[CpuRegister.Rdi])} " +
$"batch={unchecked((uint)ctx[CpuRegister.Rsi])}");
return ctx.SetReturn(0);
}
internal static void ResetForTests()
{
Contexts.Clear();
Interlocked.Exchange(ref _nextContextId, 0);
Interlocked.Exchange(ref _nextBatchId, 0);
}
// AjmBatchInfo: buffer, offset, size, last_good_job, last_good_job_ra (5× u64).
private const ulong AjmBatchInfoOffsetField = 8;
private const ulong AjmBatchInfoSizeField = 16;
private const ulong AjmBatchInfoLastGoodJobField = 24;
private const ulong AjmJobRunSize = 64;
private const ulong MaxSilentPcmBytes = 1 << 20;
// AjmSidebandResult (8) + AjmSidebandStream (16) + AjmSidebandMFrame (8).
private const int DecodeSidebandBytes = 32;
private static bool TryAppendBatchJob(CpuContext ctx, ulong infoAddress, ulong jobSize)
{
if (!TryReadUInt64(ctx, infoAddress, out var buffer) ||
!TryReadUInt64(ctx, infoAddress + AjmBatchInfoOffsetField, out var offset) ||
!TryReadUInt64(ctx, infoAddress + AjmBatchInfoSizeField, out var size))
{
return false;
}
if (buffer == 0 || jobSize == 0 || offset > size || size - offset < jobSize)
{
return false;
}
var jobAddress = buffer + offset;
ClearGuestMemory(ctx, jobAddress, jobSize);
return TryWriteUInt64(ctx, infoAddress + AjmBatchInfoLastGoodJobField, jobAddress) &&
TryWriteUInt64(ctx, infoAddress + AjmBatchInfoOffsetField, offset + jobSize);
}
// AjmBatchError: int error_code; const void* job_addr; uint32_t cmd_offset; const void* job_ra;
private const int AjmBatchErrorBytes = 24;
private static void ClearAjmBatchError(CpuContext ctx, ulong errorAddress)
{
if (errorAddress == 0)
{
return;
}
Span<byte> error = stackalloc byte[AjmBatchErrorBytes];
error.Clear();
_ = ctx.Memory.TryWrite(errorAddress, error);
}
private static void WriteDecodeStreamResult(
CpuContext ctx,
ulong resultAddress,
int inputConsumed,
int outputWritten,
ulong totalDecodedSamples,
uint frames)
{
if (resultAddress == 0)
{
return;
}
Span<byte> sideband = stackalloc byte[DecodeSidebandBytes];
sideband.Clear();
// AjmSidebandResult.result / internal_result = 0 (OK)
BinaryPrimitives.WriteInt32LittleEndian(sideband.Slice(8, 4), inputConsumed);
BinaryPrimitives.WriteInt32LittleEndian(sideband.Slice(12, 4), outputWritten);
BinaryPrimitives.WriteUInt64LittleEndian(sideband.Slice(16, 8), totalDecodedSamples);
BinaryPrimitives.WriteUInt32LittleEndian(sideband.Slice(24, 4), frames);
_ = ctx.Memory.TryWrite(resultAddress, sideband);
}
private static void ClearGuestMemory(CpuContext ctx, ulong address, ulong byteCount)
{
if (address == 0 || byteCount == 0)
{
return;
}
var remaining = byteCount;
var cursor = address;
Span<byte> zero = stackalloc byte[256];
while (remaining > 0)
{
var chunk = (int)Math.Min(remaining, (ulong)zero.Length);
if (!ctx.Memory.TryWrite(cursor, zero[..chunk]))
{
return;
}
cursor += (ulong)chunk;
remaining -= (ulong)chunk;
}
}
private static ulong ReadStackArg64(CpuContext ctx, int index)
{
var address = ctx[CpuRegister.Rsp] + sizeof(ulong) + ((ulong)index * sizeof(ulong));
return TryReadUInt64(ctx, address, out var value) ? value : 0;
}
private static bool TryReadUInt64(CpuContext ctx, ulong address, out ulong value)
{
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
if (!ctx.Memory.TryRead(address, buffer))
{
value = 0;
return false;
}
value = BinaryPrimitives.ReadUInt64LittleEndian(buffer);
return true;
}
private static bool TryWriteUInt64(CpuContext ctx, ulong address, ulong value)
{
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
return ctx.Memory.TryWrite(address, buffer);
}
private static void Trace(string message)
@@ -25,10 +25,6 @@ internal static class AudioPcmConversion
float volume)
{
var sourceFrameSize = checked(channels * bytesPerSample);
// Volume is constant for the whole submission, so clamp it once here
// rather than per sample inside the loop (this runs on every real-time
// audio buffer, hundreds of frames at a time).
var clampedVolume = Math.Clamp(volume, 0.0f, 1.0f);
for (var frame = 0; frame < frames; frame++)
{
var sourceFrame = source.Slice(frame * sourceFrameSize, sourceFrameSize);
@@ -36,8 +32,8 @@ internal static class AudioPcmConversion
var right = channels == 1
? left
: ReadSample(sourceFrame, 1, bytesPerSample, isFloat);
left = ApplyVolume(left, clampedVolume);
right = ApplyVolume(right, clampedVolume);
left = ApplyVolume(left, volume);
right = ApplyVolume(right, volume);
BinaryPrimitives.WriteInt16LittleEndian(destination[(frame * OutputFrameSize)..], left);
BinaryPrimitives.WriteInt16LittleEndian(destination[((frame * OutputFrameSize) + 2)..], right);
}
@@ -71,10 +67,9 @@ internal static class AudioPcmConversion
return checked((short)MathF.Round(value * scale));
}
// <paramref name="volume"/> is expected pre-clamped to [0, 1] by the caller.
private static short ApplyVolume(short sample, float volume)
{
var scaled = MathF.Round(sample * volume);
var scaled = MathF.Round(sample * Math.Clamp(volume, 0.0f, 1.0f));
return (short)Math.Clamp(scaled, short.MinValue, short.MaxValue);
}
}
+3 -20
View File
@@ -1131,18 +1131,16 @@ public static class AvPlayerExports
}
}
internal static string? FindFfmpeg() =>
private static string? FindFfmpeg() =>
FindFfmpeg(
Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH"),
Environment.GetEnvironmentVariable("PATH"),
OperatingSystem.IsWindows(),
AppContext.BaseDirectory);
OperatingSystem.IsWindows());
internal static string? FindFfmpeg(
string? configured,
string? searchPath,
bool isWindows,
string? baseDirectory = null)
bool isWindows)
{
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
{
@@ -1150,21 +1148,6 @@ public static class AvPlayerExports
}
var executable = isWindows ? "ffmpeg.exe" : "ffmpeg";
if (!string.IsNullOrWhiteSpace(baseDirectory))
{
foreach (var candidate in new[]
{
Path.Combine(baseDirectory, executable),
Path.Combine(baseDirectory, "ffmpeg", executable),
})
{
if (File.Exists(candidate))
{
return candidate;
}
}
}
foreach (var directory in (searchPath ?? string.Empty)
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
+188 -405
View File
@@ -18,43 +18,15 @@ namespace SharpEmu.Libs.Bink;
internal static class Bink2MovieBridge
{
private const uint MaxDimension = 16384;
private const uint MaxHostVideoWidth = 1920;
private const uint MaxHostVideoHeight = 1080;
private static readonly object Gate = new();
private static NativeAdapter? _adapter;
private static string? _activePath;
private static IntPtr _activeMovie;
private static Bink2MovieInfo _activeInfo;
private static byte[]? _frameBuffer;
private static bool _frameBufferPresented;
private static BinkFramePlayback? _playback;
private static long _frameSerial;
private static uint _presentationWidth = MaxHostVideoWidth;
private static uint _presentationHeight = MaxHostVideoHeight;
internal static bool IsHostPlaybackActive
{
get
{
lock (Gate)
{
return _playback is not null || _frameBuffer is not null;
}
}
}
internal static void SetPresentationSize(uint width, uint height)
{
if (width == 0 || height == 0)
{
return;
}
lock (Gate)
{
_presentationWidth = Math.Min(width, MaxHostVideoWidth);
_presentationHeight = Math.Min(height, MaxHostVideoHeight);
}
}
private static bool _usingDummyMovie;
private static bool _loadAttempted;
private static bool _availabilityReported;
/// <summary>
/// Returns true only when movie skipping was explicitly requested. Without
@@ -65,106 +37,109 @@ internal static class Bink2MovieBridge
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
ResolveMode() == MovieMode.Skip;
/// <summary>
/// Starts or queues host decoding. Decoded frames are only exposed as a
/// sampled guest texture; presentation and UI composition remain guest-owned.
/// </summary>
internal static bool ObserveGuestMovie(string hostPath)
internal static void ObserveGuestMovie(string hostPath)
{
if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
!File.Exists(hostPath))
{
return false;
return;
}
lock (Gate)
{
if (string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
{
return _playback is not null || _frameBuffer is not null;
return;
}
var mode = ResolveMode();
if (mode is MovieMode.Guest or MovieMode.Skip)
if (mode == MovieMode.Dummy)
{
return false;
AttachDummyMovieLocked(hostPath);
return;
}
if (_playback is not null || _frameBuffer is not null)
if (mode != MovieMode.Native)
{
if (PendingMoviePathSet.Add(hostPath))
{
PendingMoviePaths.Enqueue(hostPath);
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge queued: " +
Path.GetFileName(hostPath));
}
return PendingMoviePathSet.Contains(hostPath);
return;
}
AttachMovieLocked(hostPath, mode);
return string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase) &&
(_playback is not null || _frameBuffer is not null);
var adapter = GetAdapterLocked();
if (adapter is null)
{
return;
}
CloseActiveLocked();
if (!adapter.TryOpen(hostPath, out var movie, out var info))
{
Console.Error.WriteLine(
"[LOADER][WARN] Bink2 bridge could not open movie '" +
Path.GetFileName(hostPath) + "'.");
return;
}
if (!IsValid(info))
{
adapter.Close(movie);
Console.Error.WriteLine(
"[LOADER][WARN] Bink2 bridge rejected invalid movie dimensions for '" +
Path.GetFileName(hostPath) + "'.");
return;
}
_activePath = hostPath;
_activeMovie = movie;
_activeInfo = info;
_frameBuffer = GC.AllocateUninitializedArray<byte>(GetFrameBufferLength(info));
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge attached: " + Path.GetFileName(hostPath) + " " +
info.Width + "x" + info.Height + " @ " +
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
}
}
internal static bool TryDecodeNextFrame(
bool advanceClock,
out byte[] pixels,
out uint width,
out uint height,
out bool advanced,
out long frameSerial,
out string hostPath)
out uint height)
{
lock (Gate)
{
pixels = [];
width = 0;
height = 0;
advanced = false;
frameSerial = _frameSerial;
hostPath = _activePath ?? string.Empty;
if (_playback is not null)
if (_adapter is null || _activeMovie == IntPtr.Zero || _frameBuffer is null)
{
if (!_playback.TryGetFrame(advanceClock, out pixels, out advanced))
if (_usingDummyMovie && _frameBuffer is not null)
{
if (_playback.IsFinished)
{
var completedPath = _activePath;
CloseActiveLocked();
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge completed: " +
Path.GetFileName(completedPath));
AttachNextQueuedMovieLocked();
}
return false;
pixels = _frameBuffer;
width = _activeInfo.Width;
height = _activeInfo.Height;
return true;
}
width = _activeInfo.Width;
height = _activeInfo.Height;
if (advanced)
{
frameSerial = ++_frameSerial;
}
return true;
return false;
}
if (_frameBuffer is null)
unsafe
{
return false;
fixed (byte* destination = _frameBuffer)
{
if (!_adapter.DecodeNextBgra(
_activeMovie,
(IntPtr)destination,
_activeInfo.Width * 4,
(uint)_frameBuffer.Length))
{
return false;
}
}
}
pixels = _frameBuffer;
width = _activeInfo.Width;
height = _activeInfo.Height;
advanced = !_frameBufferPresented;
_frameBufferPresented = true;
if (advanced)
{
frameSerial = ++_frameSerial;
}
return true;
}
}
@@ -177,52 +152,6 @@ internal static class Bink2MovieBridge
private static int GetFrameBufferLength(Bink2MovieInfo info) =>
checked((int)((ulong)info.Width * info.Height * 4));
private static void AttachMovieLocked(string hostPath, MovieMode mode)
{
switch (mode)
{
case MovieMode.Dummy:
AttachDummyMovieLocked(hostPath);
return;
case MovieMode.Ffmpeg:
AttachFfmpegMovieLocked(hostPath);
return;
case MovieMode.Native:
AttachNativeMovieLocked(hostPath);
return;
}
}
private static void AttachNativeMovieLocked(string hostPath)
{
if (!FfmpegNativeBinkFrameSource.TryOpen(
hostPath, _presentationWidth, _presentationHeight, out var source) ||
source is null)
{
Console.Error.WriteLine(
"[LOADER][WARN] Bink2 bridge could not open movie '" +
Path.GetFileName(hostPath) + "'.");
return;
}
var info = new Bink2MovieInfo(
source.Width, source.Height, source.FramesPerSecondNumerator, source.FramesPerSecondDenominator);
if (!IsValid(info))
{
source.Dispose();
Console.Error.WriteLine(
"[LOADER][WARN] Bink2 bridge rejected invalid movie dimensions for '" +
Path.GetFileName(hostPath) + "'.");
return;
}
AttachPlaybackLocked(hostPath, info, source);
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge attached: " + Path.GetFileName(hostPath) + " " +
info.Width + "x" + info.Height + " @ " +
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
}
private static MovieMode ResolveMode()
{
var configured = Environment.GetEnvironmentVariable("SHARPEMU_BINK_MODE");
@@ -241,22 +170,15 @@ internal static class Bink2MovieBridge
return MovieMode.Skip;
}
if (string.Equals(configured, "guest", StringComparison.OrdinalIgnoreCase))
// Prefer the optional host adapter when one is supplied. Otherwise let
// the game's statically linked Bink implementation consume the file.
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE")) ||
EnumerateAdapterCandidates().Any(File.Exists))
{
return MovieMode.Guest;
return MovieMode.Native;
}
if (string.Equals(configured, "ffmpeg", StringComparison.OrdinalIgnoreCase))
{
return MovieMode.Ffmpeg;
}
// Native is the default: FfmpegNativeBinkFrameSource.TryOpen degrades
// gracefully (falls back to the guest's own decode, logging one
// informational line) if the FFmpeg libraries SharpEmu.CLI.csproj
// downloads next to the executable are genuinely unavailable, so
// defaulting to Native unconditionally is safe.
return MovieMode.Native;
return MovieMode.Guest;
}
private static void AttachDummyMovieLocked(string hostPath)
@@ -273,61 +195,22 @@ internal static class Bink2MovieBridge
_activePath = hostPath;
_activeInfo = info;
_frameBuffer = GC.AllocateUninitializedArray<byte>(GetFrameBufferLength(info));
_frameBufferPresented = false;
FillDummyFrame(_frameBuffer, info.Width, info.Height);
_usingDummyMovie = true;
Console.Error.WriteLine(
"[LOADER][INFO] Bink dummy attached: " + Path.GetFileName(hostPath) + " " +
info.Width + "x" + info.Height + ".");
}
private static void AttachFfmpegMovieLocked(string hostPath)
{
if (!TryReadBinkInfo(hostPath, out var info) || !IsValid(info))
{
Console.Error.WriteLine(
"[LOADER][WARN] Bink FFmpeg source has an invalid header: " +
Path.GetFileName(hostPath));
return;
}
if (!FfmpegBinkFrameSource.TryOpen(
hostPath,
info.Width,
info.Height,
info.FramesPerSecondNumerator,
info.FramesPerSecondDenominator,
out var source) || source is null)
{
return;
}
AttachPlaybackLocked(hostPath, info, source);
Console.Error.WriteLine(
"[LOADER][INFO] Bink FFmpeg source attached: " +
Path.GetFileName(hostPath) + " " + info.Width + "x" + info.Height + " @ " +
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
}
private static void AttachPlaybackLocked(
string hostPath,
Bink2MovieInfo info,
IBinkFrameDecoder decoder)
{
CloseActiveLocked();
_activePath = hostPath;
_activeInfo = info;
_playback = new BinkFramePlayback(decoder);
}
internal static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
private static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
{
info = default;
Span<byte> header = stackalloc byte[36];
Span<byte> header = stackalloc byte[32];
try
{
using var stream = File.OpenRead(path);
stream.ReadExactly(header);
if (!header[..3].SequenceEqual("KB2"u8))
if (stream.Read(header) != header.Length ||
!header[..4].SequenceEqual("KB2j"u8))
{
return false;
}
@@ -336,11 +219,10 @@ internal static class Bink2MovieBridge
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x14, 4)),
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x18, 4)),
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x1C, 4)),
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x20, 4)));
return info.FramesPerSecondNumerator != 0 &&
info.FramesPerSecondDenominator != 0;
1);
return true;
}
catch (Exception exception) when (exception is IOException or EndOfStreamException)
catch (IOException)
{
return false;
}
@@ -362,23 +244,80 @@ internal static class Bink2MovieBridge
}
}
private static NativeAdapter? GetAdapterLocked()
{
if (_loadAttempted)
{
return _adapter;
}
_loadAttempted = true;
foreach (var candidate in EnumerateAdapterCandidates())
{
if (!NativeLibrary.TryLoad(candidate, out var library))
{
continue;
}
if (NativeAdapter.TryCreate(library, out var adapter))
{
_adapter = adapter;
Console.Error.WriteLine("[LOADER][INFO] Bink2 bridge loaded: " + candidate);
return adapter;
}
NativeLibrary.Free(library);
}
if (!_availabilityReported)
{
_availabilityReported = true;
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge unavailable; install the licensed adapter and set SHARPEMU_BINK2_BRIDGE.");
}
return null;
}
private static IEnumerable<string> EnumerateAdapterCandidates()
{
var configured = Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE");
if (!string.IsNullOrWhiteSpace(configured))
{
yield return configured;
}
var baseDirectory = AppContext.BaseDirectory;
if (OperatingSystem.IsMacOS())
{
yield return Path.Combine(baseDirectory, "libsharpemu_bink2_bridge.dylib");
}
else if (OperatingSystem.IsWindows())
{
yield return Path.Combine(baseDirectory, "sharpemu_bink2_bridge.dll");
}
else
{
yield return Path.Combine(baseDirectory, "libsharpemu_bink2_bridge.so");
}
}
private static void CloseActiveLocked()
{
_playback?.Dispose();
_playback = null;
if (_activeMovie != IntPtr.Zero)
{
_adapter?.Close(_activeMovie);
}
_activePath = null;
_activeMovie = IntPtr.Zero;
_activeInfo = default;
_frameBuffer = null;
_frameBufferPresented = false;
// Wake any guest _read() blocked in WaitForHostPlaybackToFinish: its
// movie either just finished or is being pre-empted by a new attach.
Monitor.PulseAll(Gate);
_usingDummyMovie = false;
}
[StructLayout(LayoutKind.Sequential)]
internal readonly struct Bink2MovieInfo
private readonly struct Bink2MovieInfo
{
public readonly uint Width;
public readonly uint Height;
@@ -404,222 +343,66 @@ internal static class Bink2MovieBridge
Skip,
Dummy,
Native,
Ffmpeg,
}
private static readonly Queue<string> PendingMoviePaths = new();
private static readonly HashSet<string> PendingMoviePathSet =
new(StringComparer.OrdinalIgnoreCase);
private static void AttachNextQueuedMovieLocked()
private sealed class NativeAdapter
{
while (PendingMoviePaths.Count > 0)
{
var path = PendingMoviePaths.Dequeue();
PendingMoviePathSet.Remove(path);
if (!File.Exists(path))
{
continue;
}
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int OpenUtf8Delegate(IntPtr pathUtf8, out IntPtr movie, out Bink2MovieInfo info);
AttachMovieLocked(path, ResolveMode());
if (_playback is not null || _frameBuffer is not null)
{
return;
}
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int DecodeNextBgraDelegate(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void CloseDelegate(IntPtr movie);
private readonly OpenUtf8Delegate _openUtf8;
private readonly DecodeNextBgraDelegate _decodeNextBgra;
private readonly CloseDelegate _close;
private NativeAdapter(
OpenUtf8Delegate openUtf8,
DecodeNextBgraDelegate decodeNextBgra,
CloseDelegate close)
{
_openUtf8 = openUtf8;
_decodeNextBgra = decodeNextBgra;
_close = close;
}
}
// Longest a guest _read() will block waiting for real host playback to
// finish. A safety net, not a target: real movies finish well under
// this. Bounds the damage if a movie fails to attach/decode after being
// queued, so the guest thread doesn't hang forever.
private const long MaxCompletionWaitMilliseconds = 5 * 60 * 1000;
/// <summary>
/// Blocks the calling (guest I/O) thread until the host has actually
/// finished presenting <paramref name="hostPath"/> — either because it
/// played through, or because something else took over the timeline.
///
/// The completion shim tells the guest's own Bink header parse "this
/// movie is one frame and already done" so its native decoder never
/// blocks the guest on real per-frame work. Without this wait, that lie
/// lands the instant the guest reads the header, so guest-side game
/// logic races far ahead of whatever the host is still showing on
/// screen: pressing a button lands on the (already-advanced) guest
/// state, but the video visibly keeps playing, and any real-time-gated
/// trigger later in the guest's own flow can fire against a clock that
/// no longer matches wall time. Gating the "done" read on real host
/// completion keeps guest pacing and on-screen playback in lockstep.
/// </summary>
internal static void WaitForHostPlaybackToFinish(string hostPath)
{
var deadline = Environment.TickCount64 + MaxCompletionWaitMilliseconds;
lock (Gate)
internal static bool TryCreate(IntPtr library, out NativeAdapter? adapter)
{
while (IsTrackedLocked(hostPath))
{
var remaining = deadline - Environment.TickCount64;
if (remaining <= 0)
{
Console.Error.WriteLine(
"[LOADER][WARN] Bink2 bridge completion wait timed out for '" +
Path.GetFileName(hostPath) + "'.");
return;
}
Monitor.Wait(Gate, (int)Math.Min(remaining, 200));
}
}
}
private static bool IsTrackedLocked(string hostPath) =>
string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase) ||
PendingMoviePathSet.Contains(hostPath);
internal static bool TryTakeOverGuestMovie(
string hostPath,
out BinkGuestCompletionShim completionShim,
out bool observed)
{
completionShim = default;
observed = ObserveGuestMovie(hostPath);
// Keep the real header visible so the guest creates its movie surface
// and draw. Host-decoded pixels replace that sampled image later; a
// one-frame completion shim would finish before the descriptor exists.
return false;
}
internal static void NotifyGuestMovieClosed(string hostPath)
{
lock (Gate)
{
if (PendingMoviePathSet.Remove(hostPath))
{
var retained = PendingMoviePaths
.Where(path => !string.Equals(
path,
hostPath,
StringComparison.OrdinalIgnoreCase))
.ToArray();
PendingMoviePaths.Clear();
foreach (var path in retained)
{
PendingMoviePaths.Enqueue(path);
}
}
if (!string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
{
Monitor.PulseAll(Gate);
return;
}
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge stopped by guest close: " +
Path.GetFileName(hostPath));
CloseActiveLocked();
AttachNextQueuedMovieLocked();
}
}
internal static bool TryReadGuestCompletionShim(
string hostPath,
out BinkGuestCompletionShim completionShim)
{
completionShim = default;
Span<byte> header = stackalloc byte[48];
try
{
using var stream = File.OpenRead(hostPath);
stream.ReadExactly(header);
if (!header[..3].SequenceEqual("KB2"u8))
adapter = null;
if (!NativeLibrary.TryGetExport(library, "sharpemu_bink2_open_utf8", out var open) ||
!NativeLibrary.TryGetExport(library, "sharpemu_bink2_decode_next_bgra", out var decode) ||
!NativeLibrary.TryGetExport(library, "sharpemu_bink2_close", out var close))
{
return false;
}
var frameCount = BinaryPrimitives.ReadUInt32LittleEndian(header[8..12]);
var audioTrackCount = BinaryPrimitives.ReadUInt32LittleEndian(header[40..44]);
if (frameCount < 2 || audioTrackCount > 256)
{
return false;
}
var revision = header[3];
var frameIndexOffset = 44L + checked(12L * audioTrackCount);
if (revision == (byte)'m')
{
frameIndexOffset += 16;
}
else if (revision is (byte)'i' or (byte)'j' or (byte)'k' or (byte)'n')
{
frameIndexOffset += 4;
}
Span<byte> frameOffsets = stackalloc byte[8];
stream.Position = frameIndexOffset;
stream.ReadExactly(frameOffsets);
var firstFrameOffset = BinaryPrimitives.ReadUInt32LittleEndian(frameOffsets[..4]) & ~1u;
var secondFrameOffset = BinaryPrimitives.ReadUInt32LittleEndian(frameOffsets[4..]) & ~1u;
if (firstFrameOffset < frameIndexOffset + 8 ||
secondFrameOffset <= firstFrameOffset ||
secondFrameOffset > stream.Length)
{
return false;
}
completionShim = new BinkGuestCompletionShim(
secondFrameOffset - 8,
secondFrameOffset - firstFrameOffset);
adapter = new NativeAdapter(
Marshal.GetDelegateForFunctionPointer<OpenUtf8Delegate>(open),
Marshal.GetDelegateForFunctionPointer<DecodeNextBgraDelegate>(decode),
Marshal.GetDelegateForFunctionPointer<CloseDelegate>(close));
return true;
}
catch (Exception exception) when (
exception is IOException or EndOfStreamException or OverflowException)
{
return false;
}
}
internal readonly struct BinkGuestCompletionShim
{
private readonly uint _fileSizeMinusHeader;
private readonly uint _largestFrameSize;
internal BinkGuestCompletionShim(uint fileSizeMinusHeader, uint largestFrameSize)
internal bool TryOpen(string path, out IntPtr movie, out Bink2MovieInfo info)
{
_fileSizeMinusHeader = fileSizeMinusHeader;
_largestFrameSize = largestFrameSize;
}
/// <summary>
/// Rewrites the frame-count/size fields the guest's own Bink header
/// parse reads, if this read covers them. Returns true when the
/// NumFrames field (the field that tells the guest "this movie is
/// done") was in range, so the caller can gate that specific read on
/// the host's real playback actually finishing first.
/// </summary>
internal bool Patch(long fileOffset, Span<byte> bytes)
{
PatchUInt32(fileOffset, bytes, 4, _fileSizeMinusHeader);
var touchedCompletionField = PatchUInt32(fileOffset, bytes, 8, 1);
PatchUInt32(fileOffset, bytes, 12, _largestFrameSize);
return touchedCompletionField;
}
private static bool PatchUInt32(
long fileOffset,
Span<byte> bytes,
long fieldOffset,
uint value)
{
var relativeOffset = fieldOffset - fileOffset;
if (relativeOffset < 0 || relativeOffset + sizeof(uint) > bytes.Length)
var utf8 = Marshal.StringToCoTaskMemUTF8(path);
try
{
return false;
return _openUtf8(utf8, out movie, out info) != 0 && movie != IntPtr.Zero;
}
finally
{
Marshal.FreeCoTaskMem(utf8);
}
BinaryPrimitives.WriteUInt32LittleEndian(
bytes.Slice((int)relativeOffset, sizeof(uint)),
value);
return true;
}
internal bool DecodeNextBgra(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes) =>
_decodeNextBgra(movie, destination, stride, destinationBytes) != 0;
internal void Close(IntPtr movie) => _close(movie);
}
}
-246
View File
@@ -1,246 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
namespace SharpEmu.Libs.Bink;
internal interface IBinkFrameDecoder : IDisposable
{
uint Width { get; }
uint Height { get; }
uint FramesPerSecondNumerator { get; }
uint FramesPerSecondDenominator { get; }
bool TryDecodeNextFrame(Span<byte> destination);
}
/// <summary>
/// Keeps blocking codec work away from the Vulkan presentation thread and
/// releases decoded frames according to the movie time base.
/// </summary>
internal sealed class BinkFramePlayback : IDisposable
{
private const int BufferCount = 5;
private readonly object _gate = new();
private readonly IBinkFrameDecoder _decoder;
private readonly Queue<byte[]> _freeBuffers = new();
private readonly Queue<DecodedFrame> _decodedFrames = new();
private readonly Thread _decoderThread;
private byte[]? _currentFrame;
private byte[]? _retiredFrame;
private long _currentFrameIndex = -1;
private long _nextDecodedFrameIndex;
private long _playbackStartTimestamp;
private bool _playbackClockStarted;
private bool _decoderCompleted;
private bool _stopRequested;
private bool _finished;
private int _disposed;
internal BinkFramePlayback(IBinkFrameDecoder decoder)
{
_decoder = decoder;
Width = decoder.Width;
Height = decoder.Height;
FramesPerSecondNumerator = decoder.FramesPerSecondNumerator;
FramesPerSecondDenominator = decoder.FramesPerSecondDenominator;
var frameBytes = checked((int)((ulong)Width * Height * 4));
for (var index = 0; index < BufferCount; index++)
{
_freeBuffers.Enqueue(GC.AllocateUninitializedArray<byte>(frameBytes));
}
_decoderThread = new Thread(DecodeLoop)
{
IsBackground = true,
Name = "SharpEmu Bink video decoder",
};
_decoderThread.Start();
}
internal uint Width { get; }
internal uint Height { get; }
internal uint FramesPerSecondNumerator { get; }
internal uint FramesPerSecondDenominator { get; }
internal bool IsFinished
{
get
{
lock (_gate)
{
return _finished;
}
}
}
internal bool TryGetFrame(
bool advanceClock,
out byte[] pixels,
out bool advanced)
{
lock (_gate)
{
pixels = [];
advanced = false;
if (_finished)
{
return false;
}
if (_currentFrame is null)
{
if (_decodedFrames.Count == 0)
{
if (_decoderCompleted)
{
_finished = true;
}
return false;
}
var first = _decodedFrames.Dequeue();
_currentFrame = first.Pixels;
_currentFrameIndex = first.Index;
advanced = true;
Monitor.PulseAll(_gate);
}
if (advanceClock && !_playbackClockStarted)
{
_playbackStartTimestamp = Stopwatch.GetTimestamp();
_playbackClockStarted = true;
}
var elapsedSeconds = _playbackClockStarted
? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds
: 0;
var targetFrameIndex = (long)Math.Floor(
elapsedSeconds * FramesPerSecondNumerator / FramesPerSecondDenominator);
DecodedFrame? replacement = null;
while (_decodedFrames.Count > 0 &&
_decodedFrames.Peek().Index <= targetFrameIndex)
{
if (replacement is { } skipped)
{
_freeBuffers.Enqueue(skipped.Pixels);
}
replacement = _decodedFrames.Dequeue();
}
if (replacement is { } next)
{
if (_retiredFrame is not null)
{
_freeBuffers.Enqueue(_retiredFrame);
}
_retiredFrame = _currentFrame;
_currentFrame = next.Pixels;
_currentFrameIndex = next.Index;
advanced = true;
Monitor.PulseAll(_gate);
}
var frameDurationSeconds =
(double)FramesPerSecondDenominator / FramesPerSecondNumerator;
if (_playbackClockStarted &&
_decoderCompleted &&
_decodedFrames.Count == 0 &&
elapsedSeconds >= (_currentFrameIndex + 1) * frameDurationSeconds)
{
_finished = true;
return false;
}
pixels = _currentFrame;
return true;
}
}
private void DecodeLoop()
{
try
{
while (true)
{
byte[] destination;
lock (_gate)
{
while (!_stopRequested && _freeBuffers.Count == 0)
{
Monitor.Wait(_gate);
}
if (_stopRequested)
{
return;
}
destination = _freeBuffers.Dequeue();
}
if (!_decoder.TryDecodeNextFrame(destination))
{
lock (_gate)
{
_freeBuffers.Enqueue(destination);
_decoderCompleted = true;
Monitor.PulseAll(_gate);
}
return;
}
lock (_gate)
{
_decodedFrames.Enqueue(new DecodedFrame(
_nextDecodedFrameIndex++, destination));
Monitor.PulseAll(_gate);
}
}
}
catch (Exception exception) when (exception is IOException or
InvalidOperationException)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Bink decoder stopped: {exception.Message}");
lock (_gate)
{
_decoderCompleted = true;
Monitor.PulseAll(_gate);
}
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
lock (_gate)
{
_stopRequested = true;
Monitor.PulseAll(_gate);
}
if (Thread.CurrentThread != _decoderThread &&
!_decoderThread.Join(TimeSpan.FromMilliseconds(100)))
{
_decoder.Dispose();
_decoderThread.Join(TimeSpan.FromSeconds(2));
}
else
{
_decoder.Dispose();
}
}
private readonly record struct DecodedFrame(long Index, byte[] Pixels);
}
@@ -1,166 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
using SharpEmu.Libs.AvPlayer;
namespace SharpEmu.Libs.Bink;
internal sealed class FfmpegBinkFrameSource : IBinkFrameDecoder
{
private readonly Process _process;
private readonly Stream _output;
private int _errorLines;
private int _disposed;
private FfmpegBinkFrameSource(
Process process,
uint width,
uint height,
uint framesPerSecondNumerator,
uint framesPerSecondDenominator)
{
_process = process;
_output = process.StandardOutput.BaseStream;
Width = width;
Height = height;
FramesPerSecondNumerator = framesPerSecondNumerator;
FramesPerSecondDenominator = framesPerSecondDenominator;
}
public uint Width { get; }
public uint Height { get; }
public uint FramesPerSecondNumerator { get; }
public uint FramesPerSecondDenominator { get; }
internal static bool IsAvailable => AvPlayerExports.FindFfmpeg() is not null;
internal static bool TryOpen(
string path,
uint width,
uint height,
uint framesPerSecondNumerator,
uint framesPerSecondDenominator,
out FfmpegBinkFrameSource? source)
{
source = null;
var ffmpeg = AvPlayerExports.FindFfmpeg();
if (ffmpeg is null)
{
return false;
}
var startInfo = new ProcessStartInfo(ffmpeg)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-nostdin");
startInfo.ArgumentList.Add("-hide_banner");
startInfo.ArgumentList.Add("-loglevel");
startInfo.ArgumentList.Add("error");
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(path);
startInfo.ArgumentList.Add("-map");
startInfo.ArgumentList.Add("0:v:0");
startInfo.ArgumentList.Add("-an");
startInfo.ArgumentList.Add("-pix_fmt");
startInfo.ArgumentList.Add("bgra");
startInfo.ArgumentList.Add("-f");
startInfo.ArgumentList.Add("rawvideo");
startInfo.ArgumentList.Add("pipe:1");
try
{
var process = Process.Start(startInfo);
if (process is null)
{
return false;
}
source = new FfmpegBinkFrameSource(
process,
width,
height,
framesPerSecondNumerator,
framesPerSecondDenominator);
process.ErrorDataReceived += source.OnErrorData;
process.BeginErrorReadLine();
return true;
}
catch (Exception exception) when (exception is IOException or
InvalidOperationException or
System.ComponentModel.Win32Exception)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Bink FFmpeg decoder could not start: {exception.Message}");
return false;
}
}
public bool TryDecodeNextFrame(Span<byte> destination)
{
try
{
var offset = 0;
while (offset < destination.Length)
{
var read = _output.Read(destination[offset..]);
if (read == 0)
{
return false;
}
offset += read;
}
return true;
}
catch (Exception exception) when (exception is IOException or ObjectDisposedException)
{
if (Volatile.Read(ref _disposed) == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Bink FFmpeg stream failed: {exception.Message}");
}
return false;
}
}
private void OnErrorData(object sender, DataReceivedEventArgs eventArgs)
{
if (string.IsNullOrWhiteSpace(eventArgs.Data) ||
Interlocked.Increment(ref _errorLines) > 20)
{
return;
}
Console.Error.WriteLine($"[LOADER][FFMPEG-BINK] {eventArgs.Data}");
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_output.Dispose();
try
{
if (!_process.HasExited)
{
_process.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
}
finally
{
_process.Dispose();
}
}
}
@@ -1,355 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using FFmpeg.AutoGen;
namespace SharpEmu.Libs.Bink;
/// <summary>
/// Decodes a .bk2 (or any FFmpeg-readable movie) directly via FFmpeg's C API
/// through FFmpeg.AutoGen P/Invoke bindings against the dynamically linked
/// libraries published by github.com/sharpemu/ffmpeg-core -- no native C
/// bridge of our own to build. See docs/bink2-bridge.md.
/// </summary>
internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
{
private AVFormatContext* _formatContext;
private AVCodecContext* _codecContext;
private SwsContext* _swsContext;
private AVFrame* _frame;
private AVPacket* _packet;
private readonly int _videoStreamIndex;
private bool _draining;
private int _disposed;
public uint Width { get; }
public uint Height { get; }
public uint FramesPerSecondNumerator { get; }
public uint FramesPerSecondDenominator { get; }
private FfmpegNativeBinkFrameSource(
AVFormatContext* formatContext,
AVCodecContext* codecContext,
int videoStreamIndex,
uint width,
uint height,
uint framesPerSecondNumerator,
uint framesPerSecondDenominator)
{
_formatContext = formatContext;
_codecContext = codecContext;
_videoStreamIndex = videoStreamIndex;
Width = width;
Height = height;
FramesPerSecondNumerator = framesPerSecondNumerator;
FramesPerSecondDenominator = framesPerSecondDenominator;
_frame = ffmpeg.av_frame_alloc();
_packet = ffmpeg.av_packet_alloc();
}
private static bool _rootPathInitialized;
/// <summary>
/// Points FFmpeg.AutoGen at the FFmpeg shared libraries SharpEmu.CLI
/// downloads next to the executable (see SharpEmu.CLI.csproj's
/// FetchFfmpegRuntime target); kept as loose files rather than embedded
/// in the single-file bundle so the OS loader can resolve the normal
/// inter-library dependencies (avcodec depends on avutil, etc.) itself.
/// </summary>
private static void EnsureRootPathInitialized()
{
if (_rootPathInitialized)
{
return;
}
_rootPathInitialized = true;
// SharpEmu.CLI.csproj publishes FFmpeg's shared libraries into a
// "plugins" subfolder next to the executable rather than flat beside
// it (see NativeLibraryFolderName in SharpEmu.CLI.csproj).
ffmpeg.RootPath = Path.Combine(AppContext.BaseDirectory, "plugins");
// ffmpeg's static constructor runs DynamicallyLoadedBindings.Initialize()
// itself, but that constructor fires on first touch of the ffmpeg type --
// which is the RootPath assignment above -- so it binds against the
// default (empty) RootPath before the assignment's own setter body runs.
// Every function resolved during that first pass permanently throws
// NotSupportedException. Re-running Initialize() now, with RootPath
// actually set, rebinds everything against the real search path.
DynamicallyLoadedBindings.Initialize();
}
internal static bool TryOpen(
string path,
uint maximumWidth,
uint maximumHeight,
out FfmpegNativeBinkFrameSource? source)
{
source = null;
EnsureRootPathInitialized();
AVFormatContext* formatContext = null;
AVCodecContext* codecContext = null;
try
{
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
{
return false;
}
if (ffmpeg.avformat_find_stream_info(formatContext, null) < 0)
{
return false;
}
AVCodec* decoder = null;
var videoStreamIndex = ffmpeg.av_find_best_stream(
formatContext, AVMediaType.AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0);
if (videoStreamIndex < 0 || decoder is null)
{
return false;
}
var stream = formatContext->streams[videoStreamIndex];
codecContext = ffmpeg.avcodec_alloc_context3(decoder);
if (codecContext is null)
{
return false;
}
if (ffmpeg.avcodec_parameters_to_context(codecContext, stream->codecpar) < 0)
{
return false;
}
codecContext->thread_count = 0;
codecContext->thread_type = ffmpeg.FF_THREAD_FRAME | ffmpeg.FF_THREAD_SLICE;
if (ffmpeg.avcodec_open2(codecContext, decoder, null) < 0)
{
return false;
}
if (codecContext->width <= 0 || codecContext->height <= 0)
{
return false;
}
var frameRate = ffmpeg.av_guess_frame_rate(formatContext, stream, null);
if (frameRate.num <= 0 || frameRate.den <= 0)
{
frameRate = stream->avg_frame_rate;
}
if (frameRate.num <= 0 || frameRate.den <= 0)
{
frameRate = stream->r_frame_rate;
}
if (frameRate.num <= 0 || frameRate.den <= 0)
{
frameRate = new AVRational { num = 30, den = 1 };
}
var outputWidth = (uint)codecContext->width;
var outputHeight = (uint)codecContext->height;
if (maximumWidth > 0 && maximumHeight > 0 &&
(outputWidth > maximumWidth || outputHeight > maximumHeight))
{
if ((ulong)outputWidth * maximumHeight > (ulong)outputHeight * maximumWidth)
{
outputHeight = (uint)((ulong)outputHeight * maximumWidth / outputWidth);
outputWidth = maximumWidth;
}
else
{
outputWidth = (uint)((ulong)outputWidth * maximumHeight / outputHeight);
outputHeight = maximumHeight;
}
outputWidth = Math.Max(1, outputWidth);
outputHeight = Math.Max(1, outputHeight);
}
source = new FfmpegNativeBinkFrameSource(
formatContext,
codecContext,
videoStreamIndex,
outputWidth,
outputHeight,
(uint)frameRate.num,
(uint)frameRate.den);
formatContext = null;
codecContext = null;
return true;
}
catch (DllNotFoundException)
{
return false;
}
finally
{
if (codecContext is not null)
{
ffmpeg.avcodec_free_context(&codecContext);
}
if (formatContext is not null)
{
ffmpeg.avformat_close_input(&formatContext);
}
}
}
public bool TryDecodeNextFrame(Span<byte> destination)
{
var stride = checked((int)(Width * 4));
var required = (long)stride * Height;
if (destination.Length < required)
{
return false;
}
if (!TryReceiveFrame())
{
return false;
}
_swsContext = ffmpeg.sws_getCachedContext(
_swsContext,
_frame->width,
_frame->height,
(AVPixelFormat)_frame->format,
(int)Width,
(int)Height,
AVPixelFormat.AV_PIX_FMT_BGRA,
ffmpeg.SWS_FAST_BILINEAR,
null,
null,
null);
if (_swsContext is null)
{
ffmpeg.av_frame_unref(_frame);
return false;
}
fixed (byte* destinationPointer = destination)
{
var destinationPlanes = new byte*[4] { destinationPointer, null, null, null };
var destinationStrides = new int[4] { stride, 0, 0, 0 };
var convertedRows = ffmpeg.sws_scale(
_swsContext,
_frame->data,
_frame->linesize,
0,
_frame->height,
destinationPlanes,
destinationStrides);
ffmpeg.av_frame_unref(_frame);
return convertedRows == (int)Height;
}
}
private bool TryReceiveFrame()
{
while (true)
{
var receiveResult = ffmpeg.avcodec_receive_frame(_codecContext, _frame);
if (receiveResult >= 0)
{
return true;
}
if (receiveResult == ffmpeg.AVERROR_EOF)
{
return false;
}
if (receiveResult != ffmpeg.AVERROR(ffmpeg.EAGAIN))
{
return false;
}
if (_draining)
{
return false;
}
if (!TryFeedPacket())
{
return false;
}
}
}
private bool TryFeedPacket()
{
while (true)
{
var readResult = ffmpeg.av_read_frame(_formatContext, _packet);
if (readResult < 0)
{
_draining = true;
ffmpeg.avcodec_send_packet(_codecContext, null);
return true;
}
if (_packet->stream_index != _videoStreamIndex)
{
ffmpeg.av_packet_unref(_packet);
continue;
}
var sendResult = ffmpeg.avcodec_send_packet(_codecContext, _packet);
ffmpeg.av_packet_unref(_packet);
if (sendResult < 0 && sendResult != ffmpeg.AVERROR(ffmpeg.EAGAIN))
{
return false;
}
return true;
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
if (_swsContext is not null)
{
ffmpeg.sws_freeContext(_swsContext);
_swsContext = null;
}
if (_packet is not null)
{
var packet = _packet;
ffmpeg.av_packet_free(&packet);
_packet = null;
}
if (_frame is not null)
{
var frame = _frame;
ffmpeg.av_frame_free(&frame);
_frame = null;
}
if (_codecContext is not null)
{
var codecContext = _codecContext;
ffmpeg.avcodec_free_context(&codecContext);
_codecContext = null;
}
if (_formatContext is not null)
{
var formatContext = _formatContext;
ffmpeg.avformat_close_input(&formatContext);
_formatContext = null;
}
}
}
-31
View File
@@ -153,37 +153,6 @@ public static class FontExports
return SetSuccess(ctx);
}
[SysAbiExport(
Nid = "3BrWWFU+4ts",
ExportName = "sceFontGetVerticalLayout",
Target = Generation.Gen5,
LibraryName = "libSceFont")]
public static int GetVerticalLayout(CpuContext ctx)
{
var layoutAddress = ctx[CpuRegister.Rsi];
if (layoutAddress == 0)
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
// Baseline (horizontal offset), line advance, decoration extent.
// Mirrors the same three-float layout as GetHorizontalLayout, but
// interpreted for vertical writing (e.g. CJK text rendered top-to-bottom).
var values = new[] { 8.0f, 16.0f, 0.0f };
for (var index = 0; index < values.Length; index++)
{
if (!TryWriteUInt32(
ctx,
layoutAddress + (ulong)(index * sizeof(float)),
BitConverter.SingleToUInt32Bits(values[index])))
{
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
return SetSuccess(ctx);
}
[SysAbiExport(
Nid = "cKYtVmeSTcw",
ExportName = "sceFontOpenFontSet",
-11
View File
@@ -119,17 +119,6 @@ public static class JsonExports
return SetReturn(ctx, 0);
}
// Catalog alias NID for the same callback setter.
#pragma warning disable SHEM004
[SysAbiExport(
Nid = "00oCq0RwSAY",
ExportName = "_ZN3sce4Json11Initializer27setGlobalNullAccessCallbackEPFRKNS0_5ValueENS0_9ValueTypeEPS3_PvES7_",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceJson")]
public static int InitializerSetGlobalNullAccessCallbackAlt(CpuContext ctx) =>
InitializerSetGlobalNullAccessCallback(ctx);
#pragma warning restore SHEM004
[SysAbiExport(
Nid = "WSOuge5IsCg",
ExportName = "_ZN3sce4Json14InitParameter2C1Ev",
@@ -267,11 +267,6 @@ public static partial class KernelMemoryCompatExports
}
var hostPath = ResolveGuestPath(guestPath);
if (string.IsNullOrEmpty(hostPath))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
try
{
using var stream = new FileStream(hostPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite);
@@ -315,11 +310,6 @@ public static partial class KernelMemoryCompatExports
var fromHost = ResolveGuestPath(fromGuest);
var toHost = ResolveGuestPath(toGuest);
if (string.IsNullOrEmpty(fromHost) || string.IsNullOrEmpty(toHost))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
try
{
if (Directory.Exists(fromHost))
@@ -97,9 +97,6 @@ public static partial class KernelMemoryCompatExports
private static readonly object _fdGate = new();
private static readonly Dictionary<int, FileStream> _openFiles = new();
private static readonly Dictionary<int, Bink2MovieBridge.BinkGuestCompletionShim>
_binkGuestCompletionShims = new();
private static readonly Dictionary<int, string> _observedBinkGuestFiles = new();
private static readonly Dictionary<int, OpenDirectory> _openDirectories = new();
private static readonly object _libcAllocGate = new();
private static readonly object _memoryGate = new();
@@ -271,13 +268,13 @@ public static partial class KernelMemoryCompatExports
}
_nextVirtualAddress = Math.Max(_nextVirtualAddress, address + mappedLength);
ReplaceMappedRegionRangeLocked(new MappedRegion(
_mappedRegions[address] = new MappedRegion(
address,
mappedLength,
OrbisProtCpuReadWrite,
IsFlexible: false,
IsDirect: false,
DirectStart: 0));
DirectStart: 0);
}
for (ulong offset = 0; offset < mappedLength;)
@@ -303,13 +300,13 @@ public static partial class KernelMemoryCompatExports
lock (_memoryGate)
{
ReplaceMappedRegionRangeLocked(new MappedRegion(
_mappedRegions[address] = new MappedRegion(
address,
length,
Protection: 0,
IsFlexible: false,
IsDirect: false,
DirectStart: 0));
DirectStart: 0);
}
}
@@ -1451,13 +1448,6 @@ public static partial class KernelMemoryCompatExports
var hostPath = ResolveGuestPath(guestPath);
var access = ResolveOpenAccess(flags);
var mode = ResolveOpenMode(flags, access);
// A denied path (empty host path) must not reach FileStream, which would
// throw an ArgumentException the catch below does not cover.
if (string.IsNullOrEmpty(hostPath))
{
LogOpenTrace($"_open denied path='{guestPath}' flags=0x{flags:X8}");
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
try
{
if (Bink2MovieBridge.ShouldSkipGuestMovie(hostPath))
@@ -1471,14 +1461,6 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
Bink2MovieBridge.BinkGuestCompletionShim binkCompletionShim = default;
var observedBinkMovie = false;
var useBinkCompletionShim = access == FileAccess.Read &&
Bink2MovieBridge.TryTakeOverGuestMovie(
hostPath,
out binkCompletionShim,
out observedBinkMovie);
if (IsMutatingOpen(flags) && IsReadOnlyGuestMutationPath(guestPath))
{
LogOpenTrace($"_open readonly path='{guestPath}' host='{hostPath}' flags=0x{flags:X8}");
@@ -1527,22 +1509,13 @@ public static partial class KernelMemoryCompatExports
lock (_fdGate)
{
_openFiles[fd] = stream;
if (useBinkCompletionShim)
{
_binkGuestCompletionShims[fd] = binkCompletionShim;
}
if (observedBinkMovie)
{
_observedBinkGuestFiles[fd] = hostPath;
}
}
if (useBinkCompletionShim)
{
LogOpenTrace(
"_open bink-host-shim path='" + guestPath + "' host='" + hostPath +
"' flags=0x" + flags.ToString("X8") + " fd=" + fd);
}
// Bink is linked directly into some games, so there is no media
// import for the HLE codec layer to intercept. The successful
// guest file open is the stable boundary at which the optional
// host Bink bridge can attach to the same movie.
Bink2MovieBridge.ObserveGuestMovie(hostPath);
if (IsMutatingOpen(flags))
{
@@ -2073,21 +2046,10 @@ public static partial class KernelMemoryCompatExports
}
FileStream? stream;
var notifyBinkClose = false;
string? observedBinkPath = null;
lock (_fdGate)
{
if (_openFiles.Remove(fd, out stream))
{
_binkGuestCompletionShims.Remove(fd);
if (_observedBinkGuestFiles.Remove(fd, out observedBinkPath))
{
notifyBinkClose = !_observedBinkGuestFiles.Values.Any(path =>
string.Equals(
path,
observedBinkPath,
StringComparison.OrdinalIgnoreCase));
}
}
else if (_openDirectories.Remove(fd))
{
@@ -2100,10 +2062,6 @@ public static partial class KernelMemoryCompatExports
}
}
if (notifyBinkClose)
{
Bink2MovieBridge.NotifyGuestMovieClosed(observedBinkPath!);
}
stream.Dispose();
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
@@ -2131,12 +2089,9 @@ public static partial class KernelMemoryCompatExports
}
FileStream? stream;
Bink2MovieBridge.BinkGuestCompletionShim completionShim = default;
var useBinkCompletionShim = false;
lock (_fdGate)
{
_openFiles.TryGetValue(fd, out stream);
useBinkCompletionShim = _binkGuestCompletionShims.TryGetValue(fd, out completionShim);
}
if (stream is null)
@@ -2156,17 +2111,6 @@ public static partial class KernelMemoryCompatExports
var buffer = GC.AllocateUninitializedArray<byte>(requested);
var read = stream.Read(buffer, 0, requested);
if (read > 0 && useBinkCompletionShim)
{
// The patched NumFrames field is what tells the guest "this
// movie is fully consumed" - hold that specific read until the
// host has actually finished showing it, so guest-side game
// logic can't race ahead of what's still on screen.
if (completionShim.Patch(positionBefore, buffer.AsSpan(0, read)))
{
Bink2MovieBridge.WaitForHostPlaybackToFinish(stream.Name);
}
}
if (read > 0 && !ctx.Memory.TryWrite(bufferAddress, buffer.AsSpan(0, read)))
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
@@ -2710,26 +2654,6 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
}
[SysAbiExport(
Nid = "n1-v6FgU7MQ",
ExportName = "sceKernelConfiguredFlexibleMemorySize",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libKernel")]
public static int KernelConfiguredFlexibleMemorySize(CpuContext ctx)
{
var outSizeAddress = ctx[CpuRegister.Rdi];
if (outSizeAddress == 0)
{
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
}
Span<byte> sizeBytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(sizeBytes, FlexibleMemorySizeBytes);
return ctx.Memory.TryWrite(outSizeAddress, sizeBytes)
? (int)OrbisGen2Result.ORBIS_GEN2_OK
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
}
[SysAbiExport(
Nid = "rTXw65xmLIA",
ExportName = "sceKernelAllocateDirectMemory",
@@ -3140,13 +3064,13 @@ public static partial class KernelMemoryCompatExports
}
_nextVirtualAddress = Math.Max(_nextVirtualAddress, mappedAddress + length);
ReplaceMappedRegionRangeLocked(new MappedRegion(
_mappedRegions[mappedAddress] = new MappedRegion(
mappedAddress,
length,
protection,
IsFlexible: false,
IsDirect: true,
DirectStart: directMemoryStart));
DirectStart: directMemoryStart);
}
if (!ctx.TryWriteUInt64(inOutAddressPointer, mappedAddress))
@@ -3232,13 +3156,13 @@ public static partial class KernelMemoryCompatExports
_nextVirtualAddress = Math.Max(_nextVirtualAddress, mappedAddress + length);
_allocatedFlexibleBytes = Math.Min(FlexibleMemorySizeBytes, _allocatedFlexibleBytes + length);
ReplaceMappedRegionRangeLocked(new MappedRegion(
_mappedRegions[mappedAddress] = new MappedRegion(
mappedAddress,
length,
protection,
IsFlexible: true,
IsDirect: false,
DirectStart: 0));
DirectStart: 0);
}
if (!ctx.TryWriteUInt64(inOutAddressPointer, mappedAddress))
@@ -4854,32 +4778,21 @@ public static partial class KernelMemoryCompatExports
return guestPath;
}
if (TryResolveRegisteredGuestMount(guestPath, out var mountedPath, out var mountPrefixMatched))
if (TryResolveRegisteredGuestMount(guestPath, out var mountedPath))
{
return mountedPath;
}
// A registered mount claimed this path by prefix but denied it (failed
// containment or a reparse point inside the mount). That denial is
// authoritative: do NOT fall through to the built-in branches below,
// which could re-resolve an overlapping prefix (e.g. a registered
// "/app0" vs the built-in SHARPEMU_APP0_DIR branch) against a different
// root and turn the denial back into a resolution.
if (mountPrefixMatched)
{
return string.Empty;
}
if (guestPath.StartsWith("/devlog/app/", StringComparison.OrdinalIgnoreCase))
{
var relative = NormalizeMountRelativePath(guestPath["/devlog/app/".Length..]);
return CombineWithinMount(ResolveDevlogAppRoot(), relative);
return Path.Combine(ResolveDevlogAppRoot(), relative);
}
if (guestPath.StartsWith("devlog/app/", StringComparison.OrdinalIgnoreCase))
{
var relative = NormalizeMountRelativePath(guestPath["devlog/app/".Length..]);
return CombineWithinMount(ResolveDevlogAppRoot(), relative);
return Path.Combine(ResolveDevlogAppRoot(), relative);
}
if (string.Equals(guestPath, "/devlog/app", StringComparison.OrdinalIgnoreCase) ||
@@ -4891,7 +4804,7 @@ public static partial class KernelMemoryCompatExports
if (guestPath.StartsWith("/temp0/", StringComparison.OrdinalIgnoreCase))
{
var relative = NormalizeMountRelativePath(guestPath["/temp0/".Length..]);
return CombineWithinMount(ResolveTemp0Root(), relative);
return Path.Combine(ResolveTemp0Root(), relative);
}
if (string.Equals(guestPath, "/temp0", StringComparison.OrdinalIgnoreCase))
@@ -4902,13 +4815,13 @@ public static partial class KernelMemoryCompatExports
if (guestPath.StartsWith("/download0/", StringComparison.OrdinalIgnoreCase))
{
var relative = NormalizeMountRelativePath(guestPath["/download0/".Length..]);
return CombineWithinMount(ResolveDownload0Root(), relative);
return Path.Combine(ResolveDownload0Root(), relative);
}
if (guestPath.StartsWith("download0/", StringComparison.OrdinalIgnoreCase))
{
var relative = NormalizeMountRelativePath(guestPath["download0/".Length..]);
return CombineWithinMount(ResolveDownload0Root(), relative);
return Path.Combine(ResolveDownload0Root(), relative);
}
if (string.Equals(guestPath, "/download0", StringComparison.OrdinalIgnoreCase) ||
@@ -4920,13 +4833,13 @@ public static partial class KernelMemoryCompatExports
if (guestPath.StartsWith("/hostapp/", StringComparison.OrdinalIgnoreCase))
{
var relative = NormalizeMountRelativePath(guestPath["/hostapp/".Length..]);
return CombineWithinMount(ResolveHostappRoot(), relative);
return Path.Combine(ResolveHostappRoot(), relative);
}
if (guestPath.StartsWith("hostapp/", StringComparison.OrdinalIgnoreCase))
{
var relative = NormalizeMountRelativePath(guestPath["hostapp/".Length..]);
return CombineWithinMount(ResolveHostappRoot(), relative);
return Path.Combine(ResolveHostappRoot(), relative);
}
if (string.Equals(guestPath, "/hostapp", StringComparison.OrdinalIgnoreCase) ||
@@ -4949,7 +4862,7 @@ public static partial class KernelMemoryCompatExports
guestPath.StartsWith("$\\", StringComparison.Ordinal))
{
var relative = NormalizeMountRelativePath(guestPath[2..]);
return CombineWithinMount(app0Root, relative);
return Path.Combine(app0Root, relative);
}
if (string.Equals(guestPath, "/app0", StringComparison.OrdinalIgnoreCase) ||
@@ -4961,13 +4874,13 @@ public static partial class KernelMemoryCompatExports
if (guestPath.StartsWith("/app0/", StringComparison.OrdinalIgnoreCase))
{
var relative = NormalizeMountRelativePath(guestPath["/app0/".Length..]);
return CombineWithinMount(app0Root, relative);
return Path.Combine(app0Root, relative);
}
if (guestPath.StartsWith("app0/", StringComparison.OrdinalIgnoreCase))
{
var relative = NormalizeMountRelativePath(guestPath["app0/".Length..]);
return CombineWithinMount(app0Root, relative);
return Path.Combine(app0Root, relative);
}
if (!Path.IsPathFullyQualified(guestPath) &&
@@ -4975,26 +4888,16 @@ public static partial class KernelMemoryCompatExports
!guestPath.StartsWith("\\", StringComparison.Ordinal))
{
var relative = NormalizeMountRelativePath(guestPath);
return CombineWithinMount(app0Root, relative);
return Path.Combine(app0Root, relative);
}
}
// Default-deny: a guest path that matched no mount prefix must NOT be
// handed back verbatim as a host path. Returning it raw let any absolute
// guest path address the host filesystem directly ("/etc/passwd",
// "C:\\Windows\\...") because it is already fully qualified and skips the
// relative-path app0 fallback above. Callers treat an empty host path as
// "resolves to nothing" and fail the syscall with NOT_FOUND.
return string.Empty;
return guestPath;
}
private static bool TryResolveRegisteredGuestMount(
string guestPath,
out string hostPath,
out bool mountPrefixMatched)
private static bool TryResolveRegisteredGuestMount(string guestPath, out string hostPath)
{
hostPath = string.Empty;
mountPrefixMatched = false;
var normalizedGuestPath = NormalizeGuestStatCachePath(guestPath);
if (normalizedGuestPath is null)
{
@@ -5022,30 +4925,10 @@ public static partial class KernelMemoryCompatExports
return false;
}
// A registered mount owns this prefix. Whatever the outcome below
// (containment or reparse denial), the caller must NOT fall through to a
// built-in branch for the same prefix, or a denied path could be
// re-resolved against a different root.
mountPrefixMatched = true;
var relativePath = normalizedGuestPath[matchedMountPoint.Length..].TrimStart('/');
string candidate;
try
{
candidate = Path.GetFullPath(Path.Combine(
matchedHostRoot,
NormalizeMountRelativePath(relativePath)));
}
catch (Exception ex) when (
ex is IOException or ArgumentException or NotSupportedException)
{
// The relative part comes from an untrusted guest path; a crafted
// over-long or invalid path can make GetFullPath throw. Fail closed
// rather than propagate out of ResolveGuestPath (which callers invoke
// outside their try blocks).
return false;
}
var candidate = Path.GetFullPath(Path.Combine(
matchedHostRoot,
NormalizeMountRelativePath(relativePath)));
var rootWithSeparator = Path.TrimEndingDirectorySeparator(matchedHostRoot) + Path.DirectorySeparatorChar;
// Host-semantics comparison: an ignore-case check on a case-sensitive
// host would let a relative path escape into a sibling directory that
@@ -5057,14 +4940,6 @@ public static partial class KernelMemoryCompatExports
return false;
}
// Textual containment does not follow symlinks/junctions; refuse a
// reparse point planted inside the mount that would redirect onto the
// host filesystem. See EscapesMountViaReparsePoint.
if (EscapesMountViaReparsePoint(Path.GetFullPath(matchedHostRoot), candidate))
{
return false;
}
hostPath = candidate;
return true;
}
@@ -5123,116 +4998,6 @@ public static partial class KernelMemoryCompatExports
return string.Join(Path.DirectorySeparatorChar, resolved);
}
// Combines a mount-relative guest path onto a built-in mount root and
// re-verifies the result stays under that root. NormalizeMountRelativePath
// strips "." / ".." but splits only on separators, so a drive-qualified
// token like "C:" survives as a segment; Path.Combine then DISCARDS the
// mount root because its second argument is drive-rooted, yielding a raw
// host path such as "C:\Windows\...". Re-resolving with Path.GetFullPath and
// checking containment (the same guard TryResolveRegisteredGuestMount uses)
// rejects that escape. Returns string.Empty on denial, which callers treat
// as an unresolved path.
private static string CombineWithinMount(string mountRoot, string relative)
{
string fullRoot;
string candidate;
try
{
fullRoot = Path.GetFullPath(mountRoot);
candidate = Path.GetFullPath(Path.Combine(fullRoot, relative));
}
catch (Exception ex) when (
ex is IOException or ArgumentException or NotSupportedException)
{
// The relative part comes from an untrusted guest path; a crafted
// over-long or invalid path can make GetFullPath throw. Fail closed
// rather than propagate out of ResolveGuestPath (which callers invoke
// outside their try blocks).
return string.Empty;
}
var rootWithSeparator =
Path.TrimEndingDirectorySeparator(fullRoot) + Path.DirectorySeparatorChar;
if (!string.Equals(candidate, fullRoot, HostFsPathComparison) &&
!candidate.StartsWith(rootWithSeparator, HostFsPathComparison))
{
return string.Empty;
}
if (EscapesMountViaReparsePoint(fullRoot, candidate))
{
return string.Empty;
}
return candidate;
}
// Lexical containment (Path.GetFullPath + StartsWith) proves the TEXTUAL
// path stays under the mount root, but it does not follow symlinks or
// Windows junctions. A malicious game dump can plant a reparse point inside
// an otherwise-contained mount (e.g. "/app0/link" -> "/") so that
// "/app0/link/etc/passwd" passes the textual check yet resolves onto the
// host filesystem. Walk each already-existing component from the mount root
// down to the candidate and refuse if any is a reparse point. Components
// that do not yet exist (e.g. an O_CREAT target and its parents) carry no
// link to follow and are simply skipped. Mirrors the reparse rejection in
// AvPlayerExports.TryResolveSandboxedFile.
private static bool EscapesMountViaReparsePoint(string mountRoot, string candidate)
{
var rootTrimmed = Path.TrimEndingDirectorySeparator(mountRoot);
if (string.Equals(candidate, rootTrimmed, HostFsPathComparison))
{
return false;
}
var relative = Path.GetRelativePath(rootTrimmed, candidate);
// A leading ".." segment means the candidate is not under the root. Match
// the segment precisely: bare ".." or a "../" prefix, NOT a legitimate
// file merely named "..foo". This branch is a defensive fallback (lexical
// containment already passed before it runs), so it fails closed.
if (relative == "." || relative == ".." || Path.IsPathRooted(relative) ||
relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal))
{
// Not actually under the root (should have been caught lexically);
// treat as an escape rather than walk outside it.
return true;
}
var current = rootTrimmed;
foreach (var segment in relative.Split(
Path.DirectorySeparatorChar,
StringSplitOptions.RemoveEmptyEntries))
{
current = Path.Combine(current, segment);
try
{
if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
{
return true;
}
}
catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException)
{
// Component does not exist yet (create path); nothing to follow.
break;
}
catch (Exception ex) when (
ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
{
// The path comes from an untrusted dump, so a crafted over-long,
// invalid-char, or unreadable intermediate component can make
// GetAttributes throw. Fail closed: if containment cannot be
// verified, treat it as an escape rather than let the exception
// crash the syscall (ResolveGuestPath runs outside the callers'
// try blocks).
return true;
}
}
return false;
}
private static string ResolveDevlogAppRoot()
{
var configuredRoot = Environment.GetEnvironmentVariable("SHARPEMU_DEVLOG_APP_DIR");
@@ -191,27 +191,6 @@ public static class KernelSemaphoreCompatExports
WakePredicate,
deadline))
{
// A signal may have arrived between releasing the semaphore gate
// (after incrementing WaitingThreads) and the scheduler registering
// this block. When that happens WakeBlockedThreads cannot find the
// waiter yet and the exit-handler re-check runs later; a re-check
// here keeps the thread from yielding to the scheduler at all when
// the count is already sufficient.
lock (semaphore.Gate)
{
if (semaphore.Count >= needCount)
{
semaphore.Count -= needCount;
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
GuestThreadExecution.TryConsumeCurrentThreadBlock(out _);
if (_traceSema)
{
TraceSemaphore($"wait-recheck handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
}
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
}
}
if (_traceSema)
{
TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
+8 -98
View File
@@ -10,11 +10,6 @@ public static class NpWebApi2Exports
private const int NpWebApi2ErrorInvalidArgument = unchecked((int)0x80553402);
private static int _initialized;
private static int _nextLibraryContextHandle;
private static int _nextPushEventHandle;
private static int _nextUserContextHandle = 1000;
private static readonly object _contextGate = new();
private static readonly HashSet<int> _libraryContexts = [];
[SysAbiExport(
Nid = "+o9816YQhqQ",
@@ -31,28 +26,9 @@ public static class NpWebApi2Exports
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
}
var libraryContextId = CreateLibraryContextId();
Interlocked.Exchange(ref _initialized, 1);
TraceNpWebApi2("init", httpContextId, poolSize);
return ctx.SetReturn(libraryContextId);
}
[SysAbiExport(
Nid = "MsaFhR+lPE4",
ExportName = "sceNpWebApi2PushEventCreateFilter",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNpWebApi2")]
public static int NpWebApi2PushEventCreateFilter(CpuContext ctx)
{
var libraryContextId = unchecked((int)ctx[CpuRegister.Rdi]);
if (!IsValidLibraryContextId(libraryContextId))
{
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
}
var filterHandle = Interlocked.Increment(ref _nextPushEventHandle);
TraceNpWebApi2("push-event-create-filter", libraryContextId, (ulong)filterHandle);
return ctx.SetReturn(filterHandle);
return ctx.SetReturn(0);
}
[SysAbiExport(
@@ -62,16 +38,9 @@ public static class NpWebApi2Exports
LibraryName = "libSceNpWebApi2")]
public static int NpWebApi2InitializeAlt(CpuContext ctx)
{
var libraryContextId = unchecked((int)ctx[CpuRegister.Rdi]);
if (!IsValidLibraryContextId(libraryContextId))
{
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
}
var handle = CreatePushEventHandle();
Interlocked.Exchange(ref _initialized, 1);
TraceNpWebApi2("init-alt", libraryContextId, 0);
return ctx.SetReturn(handle);
TraceNpWebApi2("init-alt", unchecked((int)ctx[CpuRegister.Rdi]), ctx[CpuRegister.Rsi]);
return ctx.SetReturn(0);
}
[SysAbiExport(
@@ -81,23 +50,10 @@ public static class NpWebApi2Exports
LibraryName = "libSceNpWebApi2")]
public static int NpWebApi2CreateUserContext(CpuContext ctx)
{
var libraryContextId = unchecked((int)ctx[CpuRegister.Rdi]);
var userId = unchecked((int)ctx[CpuRegister.Rsi]);
TraceNpWebApi2(
"create-user-context",
libraryContextId,
unchecked((uint)userId));
if (Volatile.Read(ref _initialized) == 0 ||
!IsValidLibraryContextId(libraryContextId) ||
userId == -1)
{
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
}
var userContextId = Interlocked.Increment(ref _nextUserContextHandle);
return ctx.SetReturn(userContextId);
// No PSN backend: refuse user-context creation so the title's online
// layer backs off instead of driving a half-created context handle.
TraceNpWebApi2("create-user-context", unchecked((int)ctx[CpuRegister.Rdi]), ctx[CpuRegister.Rsi]);
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
}
[SysAbiExport(
@@ -108,57 +64,11 @@ public static class NpWebApi2Exports
public static int NpWebApi2Terminate(CpuContext ctx)
{
var libraryContextId = unchecked((int)ctx[CpuRegister.Rdi]);
if (!IsValidLibraryContextId(libraryContextId))
{
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
}
RemoveLibraryContextId(libraryContextId);
Interlocked.Exchange(ref _initialized, 0);
TraceNpWebApi2("term", libraryContextId, 0);
return ctx.SetReturn(0);
}
private static int CreateLibraryContextId()
{
var handle = Interlocked.Increment(ref _nextLibraryContextHandle);
lock (_contextGate)
{
_libraryContexts.Add(handle);
}
return handle;
}
private static int CreatePushEventHandle()
{
return Interlocked.Increment(ref _nextPushEventHandle);
}
private static bool IsValidLibraryContextId(int libraryContextId)
{
if (libraryContextId <= 0 || libraryContextId >= 0x8000)
{
return false;
}
lock (_contextGate)
{
return _libraryContexts.Contains(libraryContextId);
}
}
private static void RemoveLibraryContextId(int libraryContextId)
{
lock (_contextGate)
{
_libraryContexts.Remove(libraryContextId);
if (_libraryContexts.Count == 0)
{
Interlocked.Exchange(ref _initialized, 0);
}
}
}
private static void TraceNpWebApi2(string operation, int id, ulong arg0)
{
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NP_WEB_API2"), "1", StringComparison.Ordinal))
+4 -106
View File
@@ -28,7 +28,6 @@ public static class SaveDataExports
private const ulong ResultInfosOffset = 0x20;
private const uint SortKeyFreeBlocks = 5;
private const uint SortOrderDescent = 1;
private const uint MountModeReadOnly = 1u << 0;
private const uint MountModeCreate = 1u << 2;
private const uint MountModeCreate2 = 1u << 5;
private const int MountResultSize = 0x40;
@@ -714,43 +713,16 @@ public static class SaveDataExports
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
return MountSaveData(
ctx,
"mount3",
userId,
ResolveConfiguredTitleId(),
dirName,
blocks,
systemBlocks,
mountMode,
resource,
mode,
resultAddress);
}
private static int MountSaveData(
CpuContext ctx,
string operation,
int userId,
string titleId,
string dirName,
ulong blocks,
ulong systemBlocks,
uint mountMode,
uint resource,
uint mode,
ulong resultAddress)
{
if (userId < 0 || string.IsNullOrWhiteSpace(titleId) || string.IsNullOrWhiteSpace(dirName))
if (userId < 0 || string.IsNullOrWhiteSpace(dirName))
{
return SetReturn(ctx, OrbisSaveDataErrorParameter);
}
try
{
var sanitizedTitleId = SanitizePathSegment(titleId.Trim());
var titleId = ResolveConfiguredTitleId();
var savePath = Path.Combine(
ResolveTitleSaveRoot(userId, sanitizedTitleId),
ResolveTitleSaveRoot(userId, titleId),
SanitizePathSegment(dirName));
var existed = Directory.Exists(savePath);
var create = (mountMode & MountModeCreate) != 0;
@@ -788,7 +760,7 @@ public static class SaveDataExports
}
TraceSaveData(
$"{operation} user={userId} title={sanitizedTitleId} dir={dirName} blocks={blocks} " +
$"mount3 user={userId} title={titleId} dir={dirName} blocks={blocks} " +
$"system_blocks={systemBlocks} mount_mode=0x{mountMode:X} resource={resource} mode={mode} " +
$"mount_point={mountPoint} created={!existed} root='{savePath}'");
return SetReturn(ctx, 0);
@@ -807,52 +779,6 @@ public static class SaveDataExports
}
}
[SysAbiExport(
Nid = "WAzWTZm1H+I",
ExportName = "sceSaveDataTransferringMount",
Target = Generation.Gen5,
LibraryName = "libSceSaveData")]
public static int SaveDataTransferringMount(CpuContext ctx)
{
var mountAddress = ctx[CpuRegister.Rdi];
var resultAddress = ctx[CpuRegister.Rsi];
if (mountAddress == 0 || resultAddress == 0)
{
return SetReturn(ctx, OrbisSaveDataErrorParameter);
}
if (!TryReadInt32(ctx, mountAddress, out var userId) ||
!ctx.TryReadUInt64(mountAddress + 0x08, out var titleIdAddress) ||
!ctx.TryReadUInt64(mountAddress + 0x10, out var dirNameAddress) ||
titleIdAddress == 0 ||
dirNameAddress == 0 ||
!TryReadFixedAscii(ctx, titleIdAddress, SaveDataTitleIdSize, out var titleId) ||
!TryReadFixedAscii(ctx, dirNameAddress, SaveDataDirNameSize, out var dirName))
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
return MountSaveData(
ctx,
"transferring_mount",
userId,
titleId,
dirName,
0,
0,
MountModeReadOnly,
0,
0,
resultAddress);
}
[SysAbiExport(
Nid = "RjMlsR8EXrw",
ExportName = "sceSaveDataTransferringMountPs4",
Target = Generation.Gen5,
LibraryName = "libSceSaveData")]
public static int SaveDataTransferringMountPs4(CpuContext ctx) => SaveDataTransferringMount(ctx);
private static int _nextTransactionResource;
[SysAbiExport(
Nid = "gjRZNnw0JPE",
@@ -861,34 +787,6 @@ public static class SaveDataExports
LibraryName = "libSceSaveData")]
public static int SaveDataCreateTransactionResource(CpuContext ctx)
{
// Demon's Souls first-run call:
// RDI = 0xC0000, RSI = RDX + 8, RDX = resource output.
// Writing integer handle 1 makes the title dereference [1 + 8],
// causing the repeatable access violation at guest address 0x9.
var desWorkSize = ctx[CpuRegister.Rdi];
var desWorkAddress = ctx[CpuRegister.Rsi];
var desResourceAddress = ctx[CpuRegister.Rdx];
if (desWorkSize == 0xC0000 &&
desResourceAddress != 0 &&
desResourceAddress <= ulong.MaxValue - sizeof(ulong) &&
desWorkAddress == desResourceAddress + sizeof(ulong))
{
if (!ctx.TryWriteUInt64(desResourceAddress, 0))
{
return SetReturn(
ctx,
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
TraceSaveData(
$"create_transaction_resource_des_guard " +
$"work_size=0x{desWorkSize:X} " +
$"work=0x{desWorkAddress:X} " +
$"resource_addr=0x{desResourceAddress:X} resource=0x0");
return SetReturn(ctx, 0);
}
var userId = unchecked((int)ctx[CpuRegister.Rdi]);
var reserved = ctx[CpuRegister.Rsi];
-53
View File
@@ -12,9 +12,6 @@ public static class ShareExports
private static int _initialized;
private static string _contentParam = string.Empty;
private static readonly object _callbackGate = new();
private static ulong _contentEventCallback;
private static ulong _contentEventCallbackArgument;
[SysAbiExport(
Nid = "nBDD66kiFW8",
@@ -65,56 +62,6 @@ public static class ShareExports
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "Sygnk9dr5WQ",
ExportName = "sceShareRegisterContentEventCallback",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceShareUtility")]
public static int ShareRegisterContentEventCallback(CpuContext ctx)
{
var callback = ctx[CpuRegister.Rdi];
var argument = ctx[CpuRegister.Rsi];
if (callback == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
lock (_callbackGate)
{
_contentEventCallback = callback;
_contentEventCallbackArgument = argument;
}
TraceShare($"register_content_event_callback fn=0x{callback:X16} arg=0x{argument:X16}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
[SysAbiExport(
Nid = "KnsfHKmZqFA",
ExportName = "sceShareUnregisterContentEventCallback",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceShareUtility")]
public static int ShareUnregisterContentEventCallback(CpuContext ctx)
{
var callback = ctx[CpuRegister.Rdi];
if (callback == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
lock (_callbackGate)
{
if (_contentEventCallback == callback)
{
_contentEventCallback = 0;
_contentEventCallbackArgument = 0;
}
}
TraceShare($"unregister_content_event_callback fn=0x{callback:X16}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
private static bool TryReadNullTerminatedUtf8(CpuContext ctx, ulong address, int maxLength, out string value)
{
Span<byte> bytes = stackalloc byte[maxLength];
-1
View File
@@ -25,7 +25,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
</ItemGroup>
<ItemGroup>
<PackageReference Include="FFmpeg.AutoGen" />
<PackageReference Include="Silk.NET.Input" />
<PackageReference Include="Silk.NET.Vulkan" />
<PackageReference Include="Silk.NET.Vulkan.Extensions.EXT" />
@@ -45,25 +45,6 @@ public static class SystemServiceExports
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
[SysAbiExport(
Nid = "8Lo6Zv94aho",
ExportName = "sceSystemServiceDisableNoticeScreenSkipFlagAutoSet",
Target = Generation.Gen5,
LibraryName = "libSceSystemService")]
public static int SystemServiceDisableNoticeScreenSkipFlagAutoSet(CpuContext ctx) =>
ctx.SetReturn(0);
// Settings entry calls this immediately before spawning SaveModTime/Load
// threads. An unresolved stub returns NOT_FOUND and the title can stall in
// that path; accept the write and report success.
[SysAbiExport(
Nid = "Q3utJvma4Mo",
ExportName = "sceSystemServiceSetNoticeScreenSkipFlag",
Target = Generation.Gen5,
LibraryName = "libSceSystemService")]
public static int SystemServiceSetNoticeScreenSkipFlag(CpuContext ctx) =>
ctx.SetReturn(0);
[SysAbiExport(
Nid = "4veE0XiIugA",
ExportName = "sceSystemServiceGetMainAppTitleId",
@@ -118,7 +118,7 @@ public static class UserServiceExports
var userId = unchecked((int)ctx[CpuRegister.Rdi]);
var nameAddress = ctx[CpuRegister.Rsi];
var capacity = ctx[CpuRegister.Rdx];
if (userId != PrimaryUserId && userId != 1)
if (userId != PrimaryUserId)
{
return SetReturn(ctx, OrbisUserServiceErrorInvalidParameter);
}
@@ -144,16 +144,6 @@ public static class UserServiceExports
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
// Title-captured alias NID for the same username query.
#pragma warning disable SHEM004
[SysAbiExport(
Nid = "znaWI0gpuo8",
ExportName = "sceUserServiceGetUserName",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceUserService")]
public static int UserServiceGetUserNameAlt(CpuContext ctx) => UserServiceGetUserName(ctx);
#pragma warning restore SHEM004
// Name not yet in ps5_names.txt and the NID was captured from titles; revisit when the symbol is catalogued.
#pragma warning disable SHEM006
[SysAbiExport(
@@ -2230,6 +2230,7 @@ internal static unsafe class VulkanVideoPresenter
if (IsGuestWorkCompletedLocked(pending.RequiredGuestWorkSequence))
{
presentation = _pendingGuestImagePresentations.Dequeue();
TryReplaceWithBinkFrame(ref presentation);
return true;
}
@@ -2262,10 +2263,29 @@ internal static unsafe class VulkanVideoPresenter
}
presentation = latest;
TryReplaceWithBinkFrame(ref presentation);
return true;
}
}
private static void TryReplaceWithBinkFrame(ref Presentation presentation)
{
if (!Bink2MovieBridge.TryDecodeNextFrame(out var pixels, out var width, out var height))
{
return;
}
presentation = new Presentation(
pixels,
width,
height,
presentation.Sequence,
GuestDrawKind.None,
TranslatedDraw: null,
presentation.RequiredGuestWorkSequence,
IsSplash: false);
}
private static readonly HashSet<long> _tracedGuestImagePresentRejections = new();
private static bool HasPendingGuestPresentation(long presentedSequence)
@@ -2815,40 +2835,6 @@ internal static unsafe class VulkanVideoPresenter
private VkBuffer _stagingBuffer;
private DeviceMemory _stagingMemory;
private ulong _stagingSize;
private VkBuffer[] _frameUploadBuffers = [];
private DeviceMemory[] _frameUploadMemory = [];
private nint[] _frameUploadMapped = [];
private Image _hostMovieImage;
private DeviceMemory _hostMovieImageMemory;
private ImageView _hostMovieImageView;
private uint _hostMovieImageWidth;
private uint _hostMovieImageHeight;
private Format _hostMovieImageFormat;
private uint _hostMovieImageDstSelect;
private bool _hostMovieImageInitialized;
private Image _hostMovieChromaImage;
private DeviceMemory _hostMovieChromaImageMemory;
private ImageView _hostMovieChromaImageView;
private uint _hostMovieChromaImageWidth;
private uint _hostMovieChromaImageHeight;
private uint _hostMovieChromaImageDstSelect;
private bool _hostMovieChromaImageInitialized;
private byte[]? _hostMovieFramePixels;
private byte[]? _hostMovieLumaPixels;
private byte[]? _hostMovieChromaPixels;
private uint _hostMovieFrameWidth;
private uint _hostMovieFrameHeight;
private long _hostMovieFrameSerial;
private long _hostMovieConvertedFrameSerial = -1;
private long _hostMovieLumaUploadedFrameSerial = -1;
private long _hostMovieChromaUploadedFrameSerial = -1;
private string? _hostMovieFramePath;
private ulong _hostMovieLumaTextureAddress;
private ulong _hostMovieChromaTextureAddress;
private uint _hostMovieLumaDstSelect;
private uint _hostMovieChromaDstSelect;
private readonly HashSet<string> _tracedHostMovieTextureBindings =
new(StringComparer.Ordinal);
// Perf overlay: CPU-rasterized panel copied through per-slot staging
// buffers into one image, then blitted onto the swapchain.
private Image _overlayImage;
@@ -3041,9 +3027,6 @@ internal static unsafe class VulkanVideoPresenter
public bool OwnsStorage;
public bool IsStorage;
public bool Cached;
public bool IsHostMovie;
public int HostMoviePlane = -1;
public long HostMovieFrameSerial;
public ulong CpuContentFingerprint;
public bool UpdatesCpuContent;
public GuestSampler SamplerState;
@@ -3268,27 +3251,19 @@ internal static unsafe class VulkanVideoPresenter
}
}
try
{
WaitForRenderDocAttachIfRequested();
_vk = Vk.GetApi();
CreateInstance();
CreateSurface();
SelectPhysicalDevice();
CreateDevice();
CreatePipelineCache();
CreateSwapchain();
CreateCommandResources();
CreateGuestDrawResources();
_vulkanReady = true;
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan VideoOut ready: {_extent.Width}x{_extent.Height}, format={_swapchainFormat}");
}
catch (Exception exception)
{
_vulkanReady = false;
Console.Error.WriteLine($"[LOADER][WARN] Vulkan VideoOut disabled: {exception.Message}");
}
WaitForRenderDocAttachIfRequested();
_vk = Vk.GetApi();
CreateInstance();
CreateSurface();
SelectPhysicalDevice();
CreateDevice();
CreatePipelineCache();
CreateSwapchain();
CreateCommandResources();
CreateGuestDrawResources();
_vulkanReady = true;
Console.Error.WriteLine(
$"[LOADER][INFO] Vulkan VideoOut ready: {_extent.Width}x{_extent.Height}, format={_swapchainFormat}");
}
private static void WaitForRenderDocAttachIfRequested()
@@ -4345,7 +4320,6 @@ internal static unsafe class VulkanVideoPresenter
var surfaceFormat = ChooseSurfaceFormat(formats);
_swapchainFormat = surfaceFormat.Format;
_extent = ChooseExtent(capabilities);
Bink2MovieBridge.SetPresentationSize(_extent.Width, _extent.Height);
var presentMode = ChoosePresentMode();
var imageCount = capabilities.MinImageCount + 1;
if (capabilities.MaxImageCount != 0)
@@ -4492,7 +4466,6 @@ internal static unsafe class VulkanVideoPresenter
_presentationCommandBuffer = _commandBuffer;
CreateStagingBuffer((ulong)_extent.Width * _extent.Height * 4);
CreateFrameUploadBuffers(_stagingSize);
CreateOverlayResources();
}
@@ -6095,15 +6068,10 @@ internal static unsafe class VulkanVideoPresenter
}
}
var hostMovieTextures = FindHostMovieTextureBindings(draw.Textures);
for (var index = 0; index < draw.Textures.Count; index++)
{
var texture = draw.Textures[index];
var resolved = index == hostMovieTextures.Luma
? CreateHostMovieTextureResource(texture, plane: 0)
: index == hostMovieTextures.Chroma
? CreateHostMovieTextureResource(texture, plane: 1)
: ResolveTextureResource(texture);
var resolved = ResolveTextureResource(texture);
var feedbackTarget = !texture.IsStorage
? feedbackTargets?.FirstOrDefault(target =>
ReferenceEquals(resolved.GuestImage, target))
@@ -6958,343 +6926,6 @@ internal static unsafe class VulkanVideoPresenter
}
}
private void PumpHostMovieFrame()
{
if (!Bink2MovieBridge.TryDecodeNextFrame(
advanceClock: _hostMovieLumaTextureAddress != 0 &&
_hostMovieChromaTextureAddress != 0,
out var pixels,
out var width,
out var height,
out var advanced,
out var frameSerial,
out var hostPath))
{
// Keep the last decoded image until a replacement arrives.
// Movie sessions are queued asynchronously; clearing here
// exposes the guest decoder's neutral surfaces between the
// final frame of one movie and the first frame of the next.
return;
}
if (!string.Equals(
_hostMovieFramePath,
hostPath,
StringComparison.OrdinalIgnoreCase))
{
_hostMovieFramePath = hostPath;
_hostMovieLumaTextureAddress = 0;
_hostMovieChromaTextureAddress = 0;
_hostMovieLumaDstSelect = 0;
_hostMovieChromaDstSelect = 0;
_hostMovieConvertedFrameSerial = -1;
_hostMovieLumaUploadedFrameSerial = -1;
_hostMovieChromaUploadedFrameSerial = -1;
}
if (!advanced && _hostMovieFramePixels is not null)
{
return;
}
_hostMovieFramePixels = pixels;
_hostMovieFrameWidth = width;
_hostMovieFrameHeight = height;
_hostMovieFrameSerial = frameSerial;
}
private readonly record struct HostMovieTextureBindings(int Luma, int Chroma)
{
public static HostMovieTextureBindings None { get; } = new(-1, -1);
}
private HostMovieTextureBindings FindHostMovieTextureBindings(
IReadOnlyList<GuestDrawTexture> textures)
{
if (_hostMovieFramePixels is null ||
_hostMovieFrameWidth == 0 ||
_hostMovieFrameHeight == 0)
{
return HostMovieTextureBindings.None;
}
if (_hostMovieLumaTextureAddress != 0 &&
_hostMovieChromaTextureAddress != 0)
{
var lumaIndex = -1;
var chromaIndex = -1;
for (var index = 0; index < textures.Count; index++)
{
if (textures[index].Address == _hostMovieLumaTextureAddress)
{
lumaIndex = index;
}
else if (textures[index].Address == _hostMovieChromaTextureAddress)
{
chromaIndex = index;
}
}
if (lumaIndex >= 0 && chromaIndex >= 0)
{
return RememberHostMovieTextureMappings(
textures,
lumaIndex,
chromaIndex);
}
// Bluepoint alternates decoder output between multiple Y/UV
// surface pairs. Fall through and discover the active pair
// instead of sampling the stale guest surface on every other
// movie draw.
}
var bestLumaIndex = -1;
var bestChromaIndex = -1;
ulong bestArea = 0;
for (var lumaIndex = 0; lumaIndex < textures.Count; lumaIndex++)
{
var luma = textures[lumaIndex];
if (!IsHostMovieLumaCandidate(luma))
{
continue;
}
for (var chromaIndex = 0; chromaIndex < textures.Count; chromaIndex++)
{
var chroma = textures[chromaIndex];
if (!IsHostMovieChromaCandidate(luma, chroma))
{
continue;
}
var area = (ulong)luma.Width * luma.Height;
if (bestLumaIndex < 0 || area > bestArea)
{
bestLumaIndex = lumaIndex;
bestChromaIndex = chromaIndex;
bestArea = area;
}
}
}
if (bestLumaIndex < 0 || bestChromaIndex < 0)
{
return HostMovieTextureBindings.None;
}
var lumaTexture = textures[bestLumaIndex];
var chromaTexture = textures[bestChromaIndex];
_hostMovieLumaTextureAddress = lumaTexture.Address;
_hostMovieChromaTextureAddress = chromaTexture.Address;
_hostMovieLumaDstSelect = lumaTexture.DstSelect;
_hostMovieChromaDstSelect = chromaTexture.DstSelect;
var traceKey =
$"{_hostMovieFramePath}|{lumaTexture.Address:X16}|{chromaTexture.Address:X16}";
if (_tracedHostMovieTextureBindings.Add(traceKey))
{
Console.Error.WriteLine(
$"[LOADER][INFO] Bink2 YUV textures bound: " +
$"{Path.GetFileName(_hostMovieFramePath)} " +
$"y={bestLumaIndex}:0x{lumaTexture.Address:X16}:" +
$"{lumaTexture.Width}x{lumaTexture.Height}:dst=0x{lumaTexture.DstSelect:X} " +
$"uv={bestChromaIndex}:0x{chromaTexture.Address:X16}:" +
$"{chromaTexture.Width}x{chromaTexture.Height}:dst=0x{chromaTexture.DstSelect:X} " +
$"host={_hostMovieFrameWidth}x{_hostMovieFrameHeight}.");
}
return new HostMovieTextureBindings(bestLumaIndex, bestChromaIndex);
}
private HostMovieTextureBindings RememberHostMovieTextureMappings(
IReadOnlyList<GuestDrawTexture> textures,
int lumaIndex,
int chromaIndex)
{
_hostMovieLumaDstSelect = textures[lumaIndex].DstSelect;
_hostMovieChromaDstSelect = textures[chromaIndex].DstSelect;
return new HostMovieTextureBindings(lumaIndex, chromaIndex);
}
private bool IsHostMovieLumaCandidate(GuestDrawTexture texture)
{
if (texture.Address == 0 ||
texture.IsStorage ||
texture.IsFallback ||
texture.ArrayedView ||
texture.ArrayLayers > 1 ||
texture.Format != 1 ||
texture.NumberType != 0 ||
texture.Width < 1280 ||
texture.Height < 720)
{
return false;
}
var guestAspect = (ulong)texture.Width * _hostMovieFrameHeight;
var hostAspect = (ulong)texture.Height * _hostMovieFrameWidth;
var difference = guestAspect > hostAspect
? guestAspect - hostAspect
: hostAspect - guestAspect;
return difference * 100 <= Math.Max(guestAspect, hostAspect) * 2;
}
private static bool IsHostMovieChromaCandidate(
GuestDrawTexture luma,
GuestDrawTexture chroma)
{
return chroma.Address != 0 &&
chroma.Address != luma.Address &&
!chroma.IsStorage &&
!chroma.IsFallback &&
!chroma.ArrayedView &&
chroma.ArrayLayers <= 1 &&
chroma.Format == 3 &&
chroma.NumberType == 0 &&
luma.Width == chroma.Width * 2 &&
luma.Height == chroma.Height * 2;
}
private TextureResource CreateHostMovieTextureResource(
GuestDrawTexture texture,
int plane)
{
EnsureHostMovieYuvFrame();
var isLuma = plane == 0;
var pixels = isLuma ? _hostMovieLumaPixels! : _hostMovieChromaPixels!;
var width = isLuma
? _hostMovieFrameWidth
: (_hostMovieFrameWidth + 1) / 2;
var height = isLuma
? _hostMovieFrameHeight
: (_hostMovieFrameHeight + 1) / 2;
EnsureHostMovieImages(
_hostMovieFrameWidth,
_hostMovieFrameHeight,
_hostMovieLumaDstSelect,
(_hostMovieFrameWidth + 1) / 2,
(_hostMovieFrameHeight + 1) / 2,
_hostMovieChromaDstSelect);
var uploadedFrameSerial = isLuma
? _hostMovieLumaUploadedFrameSerial
: _hostMovieChromaUploadedFrameSerial;
var needsUpload = uploadedFrameSerial != _hostMovieFrameSerial;
VkBuffer stagingBuffer = default;
DeviceMemory stagingMemory = default;
if (needsUpload)
{
(stagingBuffer, stagingMemory) = CreateTextureStagingBuffer(
pixels,
$"Bink2 frame {_hostMovieFrameSerial} plane {plane} staging");
}
return new TextureResource
{
Address = texture.Address,
StagingBuffer = stagingBuffer,
StagingMemory = stagingMemory,
Image = isLuma ? _hostMovieImage : _hostMovieChromaImage,
View = isLuma ? _hostMovieImageView : _hostMovieChromaImageView,
Width = width,
Height = height,
RowLength = width,
DstSelect = texture.DstSelect,
NeedsUpload = needsUpload,
IsHostMovie = true,
HostMoviePlane = plane,
HostMovieFrameSerial = _hostMovieFrameSerial,
SamplerState = texture.Sampler,
};
}
private void EnsureHostMovieYuvFrame()
{
if (_hostMovieConvertedFrameSerial == _hostMovieFrameSerial)
{
return;
}
var bgra = _hostMovieFramePixels ??
throw new InvalidOperationException("Host movie frame is unavailable.");
var width = checked((int)_hostMovieFrameWidth);
var height = checked((int)_hostMovieFrameHeight);
var chromaWidth = (width + 1) / 2;
var chromaHeight = (height + 1) / 2;
if (_hostMovieLumaPixels?.Length != width * height)
{
_hostMovieLumaPixels = GC.AllocateUninitializedArray<byte>(width * height);
}
if (_hostMovieChromaPixels?.Length != chromaWidth * chromaHeight * 2)
{
_hostMovieChromaPixels =
GC.AllocateUninitializedArray<byte>(chromaWidth * chromaHeight * 2);
}
ConvertBgraToYuv420(
bgra,
width,
height,
_hostMovieLumaPixels,
_hostMovieChromaPixels);
_hostMovieConvertedFrameSerial = _hostMovieFrameSerial;
}
internal static void ConvertBgraToYuv420(
ReadOnlySpan<byte> bgra,
int width,
int height,
Span<byte> luma,
Span<byte> chroma)
{
var chromaWidth = (width + 1) / 2;
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
var source = (y * width + x) * 4;
var b = bgra[source];
var g = bgra[source + 1];
var r = bgra[source + 2];
luma[y * width + x] = ClampByte(
(54 * r + 183 * g + 19 * b + 128) >> 8);
}
}
for (var y = 0; y < height; y += 2)
{
for (var x = 0; x < width; x += 2)
{
var red = 0;
var green = 0;
var blue = 0;
var samples = 0;
for (var sampleY = y; sampleY < Math.Min(y + 2, height); sampleY++)
{
for (var sampleX = x; sampleX < Math.Min(x + 2, width); sampleX++)
{
var source = (sampleY * width + sampleX) * 4;
blue += bgra[source];
green += bgra[source + 1];
red += bgra[source + 2];
samples++;
}
}
red /= samples;
green /= samples;
blue /= samples;
var destination = ((y / 2) * chromaWidth + x / 2) * 2;
chroma[destination] = ClampByte(
((128 * red - 116 * green - 12 * blue + 128) >> 8) + 128);
chroma[destination + 1] = ClampByte(
((-29 * red - 99 * green + 128 * blue + 128) >> 8) + 128);
}
}
}
private static byte ClampByte(int value) => (byte)Math.Clamp(value, 0, 255);
[MethodImpl(MethodImplOptions.NoInlining)]
private TextureResource ResolveTextureResource(GuestDrawTexture texture)
{
@@ -9900,26 +9531,6 @@ internal static unsafe class VulkanVideoPresenter
_stagingSize = size;
}
private void CreateFrameUploadBuffers(ulong size)
{
_frameUploadBuffers = new VkBuffer[MaxFramesInFlight];
_frameUploadMemory = new DeviceMemory[MaxFramesInFlight];
_frameUploadMapped = new nint[MaxFramesInFlight];
for (var slot = 0; slot < MaxFramesInFlight; slot++)
{
_frameUploadBuffers[slot] = CreateBuffer(
size,
BufferUsageFlags.TransferSrcBit,
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
out _frameUploadMemory[slot]);
void* mapped;
Check(
_vk.MapMemory(_device, _frameUploadMemory[slot], 0, size, 0, &mapped),
"vkMapMemory(frame upload)");
_frameUploadMapped[slot] = (nint)mapped;
}
}
private uint FindMemoryType(uint typeBits, MemoryPropertyFlags requiredFlags)
{
_vk.GetPhysicalDeviceMemoryProperties(_physicalDevice, out var properties);
@@ -9971,8 +9582,6 @@ internal static unsafe class VulkanVideoPresenter
return;
}
PumpHostMovieFrame();
if (_skipAllCompute ||
AddressListContains("SHARPEMU_SKIP_COMPUTE_CS", work.ShaderAddress) ||
(_skipTallComputeZ > 0 && work.GroupCountZ >= _skipTallComputeZ))
@@ -11488,65 +11097,6 @@ internal static unsafe class VulkanVideoPresenter
target.Initialized = true;
}
// Returns the source row length in texels when the upload is a linear
// image whose rows are padded to a wider hardware pitch, or 0 when the
// data is an exact tightly packed match or not a recognisable padded
// layout (in which case the caller keeps rejecting it). Only a pitch
// equal to the width rounded up to a common alignment is accepted, so an
// oversized buffer that still carries mip data is left rejected rather
// than mis-copied.
private static uint TryGetPaddedUploadRowLength(
GuestImageResource target,
ulong uploadByteCount,
ulong expectedByteCount)
{
if (expectedByteCount == 0
|| target.Width == 0
|| target.Height == 0
|| uploadByteCount <= expectedByteCount)
{
return 0;
}
var texelCount = (ulong)target.Width * target.Height;
if (texelCount == 0 || expectedByteCount % texelCount != 0)
{
// Block-compressed or otherwise non-linear: no per-texel pitch.
return 0;
}
var bytesPerTexel = expectedByteCount / texelCount;
if (bytesPerTexel == 0 || uploadByteCount % target.Height != 0)
{
return 0;
}
var rowBytes = uploadByteCount / target.Height;
if (rowBytes % bytesPerTexel != 0)
{
return 0;
}
var rowTexels = rowBytes / bytesPerTexel;
if (rowTexels < target.Width || rowTexels > uint.MaxValue)
{
return 0;
}
foreach (var alignment in (ReadOnlySpan<uint>)[8, 16, 32, 64, 128, 256])
{
if (AlignUp(target.Width, alignment) == rowTexels)
{
return (uint)rowTexels;
}
}
return 0;
}
private static uint AlignUp(uint value, uint alignment) =>
(value + alignment - 1) / alignment * alignment;
private void UploadGuestImageInitialData(GuestImageResource target, byte[] pixels)
{
var guestDataFormat = (target.GuestFormat & 0x8000_0000u) != 0
@@ -11559,19 +11109,7 @@ internal static unsafe class VulkanVideoPresenter
target.Format,
target.Width,
target.Height);
// The guest can hand us linear pixel data whose rows are padded out
// to a hardware pitch wider than the image, so the byte count runs
// past the tightly packed width*height*bpp we compute. Recover the
// real source row length and copy with it instead of dropping the
// upload, which would otherwise leave the texture blank.
var uploadRowLengthTexels = TryGetPaddedUploadRowLength(
target,
(ulong)uploadPixels.Length,
expectedByteCount);
if (expectedByteCount == 0
|| ((ulong)uploadPixels.Length != expectedByteCount
&& uploadRowLengthTexels == 0))
if (expectedByteCount == 0 || (ulong)uploadPixels.Length != expectedByteCount)
{
if (_rejectedGuestImageUploads.Add(
(target.Address, uploadPixels.Length, expectedByteCount, target.Format)))
@@ -11645,7 +11183,7 @@ internal static unsafe class VulkanVideoPresenter
var copyRegion = new BufferImageCopy
{
BufferOffset = 0,
BufferRowLength = uploadRowLengthTexels,
BufferRowLength = 0,
BufferImageHeight = 0,
ImageSubresource = new ImageSubresourceLayers(
ImageAspectFlags.ColorBit,
@@ -12859,12 +12397,10 @@ internal static unsafe class VulkanVideoPresenter
EvictDirtyCachedTextures();
var completedWork = 0;
HashSet<string>? deferredOrderedQueues = null;
var workBudgetTicks = _renderWorkBudgetTicks;
var renderWorkDeadline = workBudgetTicks > 0
? System.Diagnostics.Stopwatch.GetTimestamp() + workBudgetTicks
var renderWorkDeadline = _renderWorkBudgetTicks > 0
? System.Diagnostics.Stopwatch.GetTimestamp() + _renderWorkBudgetTicks
: long.MaxValue;
var workLimit = _maxGuestWorkPerRender;
while (completedWork < workLimit)
while (completedWork < _maxGuestWorkPerRender)
{
// Never block the macOS main thread waiting for in-flight GPU
// work to drain. If submission is at capacity (a slow-compute
@@ -12921,14 +12457,6 @@ internal static unsafe class VulkanVideoPresenter
}
try
{
// A host-decoded movie only overrides which image gets
// presented (see the presentation selection below); the
// guest's own command stream keeps draining normally.
// Silently discarding these instead of executing them
// leaves guest-visible completion state (labels, buffers,
// job results) permanently unwritten, which desyncs the
// engine's own job system and previously crashed it
// shortly after the movie finished.
switch (work)
{
case VulkanOffscreenGuestDraw offscreenDraw:
@@ -13006,8 +12534,7 @@ internal static unsafe class VulkanVideoPresenter
FlushBatchedGuestCommands();
CollectAbandonedGuestImageVersions();
Presentation presentation;
if (!TryTakePresentation(_presentedSequence, out presentation))
if (!TryTakePresentation(_presentedSequence, out var presentation))
{
if (_pendingHostSplashReplay is { } splash)
{
@@ -13031,6 +12558,7 @@ internal static unsafe class VulkanVideoPresenter
return;
}
}
if (_hostSurface is not null)
{
if (presentation.IsSplash && presentation.Pixels is not null)
@@ -13083,7 +12611,6 @@ internal static unsafe class VulkanVideoPresenter
{
return;
}
}
TranslatedDrawResources? translatedResources = null;
@@ -13211,11 +12738,19 @@ internal static unsafe class VulkanVideoPresenter
if (pixels is not null)
{
var mapped = (void*)_frameUploadMapped[frameSlot];
// The staging buffer is shared across frame slots; a CPU
// pixel upload (splash / host frames) degrades to serial
// presentation rather than corrupting an in-flight copy.
WaitAllFrameSlots();
void* mapped;
Check(
_vk.MapMemory(_device, _stagingMemory, 0, (ulong)pixels.Length, 0, &mapped),
"vkMapMemory");
fixed (byte* source = pixels)
{
System.Buffer.MemoryCopy(source, mapped, pixels.Length, pixels.Length);
}
_vk.UnmapMemory(_device, _stagingMemory);
}
Check(_vk.ResetCommandBuffer(_commandBuffer, 0), "vkResetCommandBuffer");
@@ -13229,7 +12764,7 @@ internal static unsafe class VulkanVideoPresenter
PipelineStageFlags waitStage;
if (pixels is not null)
{
RecordUpload(imageIndex, frameSlot);
RecordUpload(imageIndex);
waitStage = PipelineStageFlags.TransferBit;
}
else if (presentation.DrawKind == GuestDrawKind.FullscreenBarycentric)
@@ -13751,22 +13286,11 @@ internal static unsafe class VulkanVideoPresenter
continue;
}
var hostMovieImageInitialized = texture.HostMoviePlane switch
{
0 => _hostMovieImageInitialized,
1 => _hostMovieChromaImageInitialized,
_ => false,
};
var toTransfer = new ImageMemoryBarrier
{
SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = texture.IsHostMovie && hostMovieImageInitialized
? AccessFlags.ShaderReadBit
: 0,
DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = texture.IsHostMovie && hostMovieImageInitialized
? ImageLayout.ShaderReadOnlyOptimal
: ImageLayout.Undefined,
OldLayout = ImageLayout.Undefined,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
@@ -13775,9 +13299,7 @@ internal static unsafe class VulkanVideoPresenter
};
_vk.CmdPipelineBarrier(
_commandBuffer,
texture.IsHostMovie && hostMovieImageInitialized
? PipelineStageFlags.AllCommandsBit
: PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.TransferBit,
0,
0,
@@ -13837,19 +13359,6 @@ internal static unsafe class VulkanVideoPresenter
// reuse the image without restaging it.
texture.NeedsUpload = false;
}
if (texture.IsHostMovie)
{
if (texture.HostMoviePlane == 0)
{
_hostMovieImageInitialized = true;
_hostMovieLumaUploadedFrameSerial = texture.HostMovieFrameSerial;
}
else if (texture.HostMoviePlane == 1)
{
_hostMovieChromaImageInitialized = true;
_hostMovieChromaUploadedFrameSerial = texture.HostMovieFrameSerial;
}
}
}
}
@@ -15163,176 +14672,7 @@ internal static unsafe class VulkanVideoPresenter
}
}
private void EnsureHostMovieImages(
uint lumaWidth,
uint lumaHeight,
uint lumaDstSelect,
uint chromaWidth,
uint chromaHeight,
uint chromaDstSelect)
{
if (_hostMovieImage.Handle != 0 &&
_hostMovieImageWidth == lumaWidth &&
_hostMovieImageHeight == lumaHeight &&
_hostMovieImageFormat == Format.R8Unorm &&
_hostMovieImageDstSelect == lumaDstSelect &&
_hostMovieChromaImage.Handle != 0 &&
_hostMovieChromaImageWidth == chromaWidth &&
_hostMovieChromaImageHeight == chromaHeight &&
_hostMovieChromaImageDstSelect == chromaDstSelect)
{
return;
}
if (_hostMovieImage.Handle != 0 || _hostMovieChromaImage.Handle != 0)
{
FlushBatchedGuestCommands();
WaitForAllGuestSubmissions();
DrainFrameSlots();
DestroyHostMovieImage();
}
CreateHostMoviePlaneImage(
lumaWidth,
lumaHeight,
Format.R8Unorm,
lumaDstSelect,
"luma",
out _hostMovieImage,
out _hostMovieImageMemory,
out _hostMovieImageView);
CreateHostMoviePlaneImage(
chromaWidth,
chromaHeight,
Format.R8G8Unorm,
chromaDstSelect,
"chroma",
out _hostMovieChromaImage,
out _hostMovieChromaImageMemory,
out _hostMovieChromaImageView);
_hostMovieImageWidth = lumaWidth;
_hostMovieImageHeight = lumaHeight;
_hostMovieImageFormat = Format.R8Unorm;
_hostMovieImageDstSelect = lumaDstSelect;
_hostMovieChromaImageWidth = chromaWidth;
_hostMovieChromaImageHeight = chromaHeight;
_hostMovieChromaImageDstSelect = chromaDstSelect;
_hostMovieImageInitialized = false;
_hostMovieChromaImageInitialized = false;
_hostMovieLumaUploadedFrameSerial = -1;
_hostMovieChromaUploadedFrameSerial = -1;
}
private void CreateHostMoviePlaneImage(
uint width,
uint height,
Format format,
uint dstSelect,
string planeName,
out Image image,
out DeviceMemory memory,
out ImageView view)
{
var imageInfo = new ImageCreateInfo
{
SType = StructureType.ImageCreateInfo,
ImageType = ImageType.Type2D,
Format = format,
Extent = new Extent3D(width, height, 1),
MipLevels = 1,
ArrayLayers = 1,
Samples = SampleCountFlags.Count1Bit,
Tiling = ImageTiling.Optimal,
Usage = ImageUsageFlags.TransferDstBit | ImageUsageFlags.SampledBit,
SharingMode = SharingMode.Exclusive,
InitialLayout = ImageLayout.Undefined,
};
Check(
_vk.CreateImage(_device, &imageInfo, null, out image),
$"vkCreateImage(host movie {planeName})");
_vk.GetImageMemoryRequirements(_device, image, out var requirements);
var allocationInfo = new MemoryAllocateInfo
{
SType = StructureType.MemoryAllocateInfo,
AllocationSize = requirements.Size,
MemoryTypeIndex = FindMemoryType(
requirements.MemoryTypeBits,
MemoryPropertyFlags.DeviceLocalBit),
};
Check(
_vk.AllocateMemory(
_device,
&allocationInfo,
null,
out memory),
$"vkAllocateMemory(host movie {planeName})");
Check(
_vk.BindImageMemory(_device, image, memory, 0),
$"vkBindImageMemory(host movie {planeName})");
var viewInfo = new ImageViewCreateInfo
{
SType = StructureType.ImageViewCreateInfo,
Image = image,
ViewType = ImageViewType.Type2D,
Format = format,
Components = ToVkComponentMapping(dstSelect),
SubresourceRange = ColorSubresourceRange(),
};
Check(
_vk.CreateImageView(
_device,
&viewInfo,
null,
out view),
$"vkCreateImageView(host movie {planeName})");
SetDebugName(ObjectType.Image, image.Handle, $"SharpEmu Bink2 {planeName} image");
SetDebugName(ObjectType.ImageView, view.Handle, $"SharpEmu Bink2 {planeName} view");
}
private void DestroyHostMovieImage()
{
if (_hostMovieImageView.Handle != 0)
{
_vk.DestroyImageView(_device, _hostMovieImageView, null);
_hostMovieImageView = default;
}
if (_hostMovieImage.Handle != 0)
{
_vk.DestroyImage(_device, _hostMovieImage, null);
_hostMovieImage = default;
}
if (_hostMovieImageMemory.Handle != 0)
{
_vk.FreeMemory(_device, _hostMovieImageMemory, null);
_hostMovieImageMemory = default;
}
if (_hostMovieChromaImageView.Handle != 0)
{
_vk.DestroyImageView(_device, _hostMovieChromaImageView, null);
_hostMovieChromaImageView = default;
}
if (_hostMovieChromaImage.Handle != 0)
{
_vk.DestroyImage(_device, _hostMovieChromaImage, null);
_hostMovieChromaImage = default;
}
if (_hostMovieChromaImageMemory.Handle != 0)
{
_vk.FreeMemory(_device, _hostMovieChromaImageMemory, null);
_hostMovieChromaImageMemory = default;
}
_hostMovieImageWidth = 0;
_hostMovieImageHeight = 0;
_hostMovieImageFormat = Format.Undefined;
_hostMovieChromaImageWidth = 0;
_hostMovieChromaImageHeight = 0;
_hostMovieImageInitialized = false;
_hostMovieChromaImageInitialized = false;
_hostMovieLumaUploadedFrameSerial = -1;
_hostMovieChromaUploadedFrameSerial = -1;
}
private void RecordUpload(uint imageIndex, int frameSlot)
private void RecordUpload(uint imageIndex)
{
var oldLayout = _imageInitialized[imageIndex]
? ImageLayout.PresentSrcKhr
@@ -15374,7 +14714,7 @@ internal static unsafe class VulkanVideoPresenter
};
_vk.CmdCopyBufferToImage(
_commandBuffer,
_frameUploadBuffers[frameSlot],
_stagingBuffer,
_swapchainImages[imageIndex],
ImageLayout.TransferDstOptimal,
1,
@@ -16242,26 +15582,7 @@ internal static unsafe class VulkanVideoPresenter
private void DestroySwapchainResources()
{
DestroyHostMovieImage();
DestroyPresentEncodeImage();
for (var slot = 0; slot < _frameUploadBuffers.Length; slot++)
{
if (_frameUploadMapped.Length > slot && _frameUploadMapped[slot] != 0)
{
_vk.UnmapMemory(_device, _frameUploadMemory[slot]);
}
if (_frameUploadBuffers[slot].Handle != 0)
{
_vk.DestroyBuffer(_device, _frameUploadBuffers[slot], null);
}
if (_frameUploadMemory[slot].Handle != 0)
{
_vk.FreeMemory(_device, _frameUploadMemory[slot], null);
}
}
_frameUploadBuffers = [];
_frameUploadMemory = [];
_frameUploadMapped = [];
if (_stagingBuffer.Handle != 0)
{
_vk.DestroyBuffer(_device, _stagingBuffer, null);
@@ -16,37 +16,4 @@ public static class VoiceQoSExports
{
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "Trpt2QBZHCI",
ExportName = "sceVoiceQoSGetStatus",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceVoiceQoS")]
public static int VoiceQoSGetStatus(CpuContext ctx)
{
// Returns 0 to indicate connected state (voice available)
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "FuXenJLkk-c",
ExportName = "sceVoiceQoSTerminate",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceVoiceQoS")]
public static int VoiceQoSTerminate(CpuContext ctx)
{
// No-op: cleanup is handled by emulator shutdown
return ctx.SetReturn(0);
}
[SysAbiExport(
Nid = "+0lOiPZjnBI",
ExportName = "sceVoiceQoSSetMode",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceVoiceQoS")]
public static int VoiceQoSSetMode(CpuContext ctx)
{
// No-op: mode configuration is not emulated
return ctx.SetReturn(0);
}
}
@@ -80,7 +80,7 @@ public static class Gen5ShaderTranslator
public static bool IsScalarConsumed(ulong[] mask, uint register) =>
register < 256 && (mask[register >> 6] & (1UL << (int)(register & 63))) != 0;
private const int MaxInstructions = 16384;
private const int MaxInstructions = 4096;
private const uint PsUserDataRegister = 0x0C;
private const uint VsUserDataRegister = 0x4C;
private const uint GsUserDataRegister = 0x8C;
@@ -1,93 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
public sealed class AgcPredicationTests
{
private const ulong BaseAddress = 0x1_0000_0000;
private const ulong CommandBufferAddress = BaseAddress + 0x100;
private const ulong PacketAddress = BaseAddress + 0x400;
private const ulong PredicateAddress = BaseAddress + 0x800;
[Fact]
public void DcbSetPredication_EmitsGen5Packet()
{
var memory = new FakeCpuMemory(BaseAddress, 0x2000);
var ctx = new CpuContext(memory, Generation.Gen5);
WriteUInt64(memory, CommandBufferAddress + 0x10, PacketAddress);
WriteUInt64(memory, CommandBufferAddress + 0x18, PacketAddress + 0x100);
ctx[CpuRegister.Rdi] = CommandBufferAddress;
ctx[CpuRegister.Rsi] = 1;
ctx[CpuRegister.Rdx] = 3;
ctx[CpuRegister.Rcx] = 1;
ctx[CpuRegister.R8] = PredicateAddress + 7;
ctx[CpuRegister.R9] = 2;
var result = AgcExports.DcbSetPredication(ctx);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
Assert.Equal(PacketAddress, ctx[CpuRegister.Rax]);
Assert.Equal(0xC002_2000u, ReadUInt32(memory, PacketAddress));
Assert.Equal(0x0003_1100u, ReadUInt32(memory, PacketAddress + 4));
Assert.Equal(unchecked((uint)PredicateAddress), ReadUInt32(memory, PacketAddress + 8));
Assert.Equal((uint)(PredicateAddress >> 32), ReadUInt32(memory, PacketAddress + 12));
Assert.Equal(PacketAddress + 16, ReadUInt64(memory, CommandBufferAddress + 0x10));
}
[Fact]
public void SetPacketPredication_TogglesPacketHeaderBit()
{
var memory = new FakeCpuMemory(BaseAddress, 0x1000);
var ctx = new CpuContext(memory, Generation.Gen5);
const uint header = 0xC003_1500;
WriteUInt32(memory, PacketAddress, header);
ctx[CpuRegister.Rdi] = PacketAddress;
ctx[CpuRegister.Rsi] = 1;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
AgcExports.SetPacketPredication(ctx));
Assert.Equal(header | 1u, ReadUInt32(memory, PacketAddress));
ctx[CpuRegister.Rsi] = 0;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
AgcExports.SetPacketPredication(ctx));
Assert.Equal(header, ReadUInt32(memory, PacketAddress));
}
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[4];
Assert.True(memory.TryRead(address, buffer));
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
}
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[8];
Assert.True(memory.TryRead(address, buffer));
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
}
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
{
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
Assert.True(memory.TryWrite(address, buffer));
}
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
{
Span<byte> buffer = stackalloc byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
Assert.True(memory.TryWrite(address, buffer));
}
}
@@ -1,61 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
public sealed class AgcResourceOwnerTests
{
private const ulong BaseAddress = 0x1_0000_0000;
private const ulong OwnerAddress = BaseAddress + 0x100;
private const ulong NameAddress = BaseAddress + 0x200;
private const ulong RegistrationMemoryAddress = BaseAddress + 0x400;
[Fact]
public void RegisterOwner_DoesNotRequireOptionalResourceRegistryMemory()
{
var memory = new FakeCpuMemory(BaseAddress, 0x2000);
var ctx = new CpuContext(memory, Generation.Gen5);
memory.WriteCString(NameAddress, "GIRender");
ctx[CpuRegister.Rdi] = OwnerAddress;
ctx[CpuRegister.Rsi] = NameAddress;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DriverRegisterOwner(ctx));
Assert.NotEqual(0u, ReadUInt32(memory, OwnerAddress));
}
[Fact]
public void RegisterOwner_RespectsExplicitRegistryCapacity()
{
var memory = new FakeCpuMemory(BaseAddress, 0x2000);
var ctx = new CpuContext(memory, Generation.Gen5);
ctx[CpuRegister.Rdi] = RegistrationMemoryAddress;
ctx[CpuRegister.Rsi] = 0x1000;
ctx[CpuRegister.Rdx] = 1;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
AgcExports.DriverInitResourceRegistration(ctx));
memory.WriteCString(NameAddress, "First");
ctx[CpuRegister.Rdi] = OwnerAddress;
ctx[CpuRegister.Rsi] = NameAddress;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DriverRegisterOwner(ctx));
memory.WriteCString(NameAddress, "Second");
ctx[CpuRegister.Rdi] = OwnerAddress + 4;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
AgcExports.DriverRegisterOwner(ctx));
}
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[4];
Assert.True(memory.TryRead(address, buffer));
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
}
}
@@ -1,133 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
public sealed class AgcWaitRegMemTests
{
private const ulong BaseAddress = 0x1_0000_0000;
private const ulong CommandBufferAddress = BaseAddress + 0x100;
private const ulong PacketAddress = BaseAddress + 0x400;
private const ulong StackAddress = BaseAddress + 0x800;
[Fact]
public void DcbWaitRegMem32_EmitsGen5PacketLayout()
{
var memory = CreateMemory(out var ctx);
var waitAddress = BaseAddress + 0xC03;
ctx[CpuRegister.Rdi] = CommandBufferAddress;
ctx[CpuRegister.Rsi] = 0;
ctx[CpuRegister.Rdx] = 3;
ctx[CpuRegister.Rcx] = 4;
ctx[CpuRegister.R8] = 2;
ctx[CpuRegister.R9] = waitAddress;
WriteUInt64(memory, StackAddress + 8, 0x1122_3344_5566_7788);
WriteUInt64(memory, StackAddress + 16, 0xAABB_CCDD_EEFF_0011);
WriteUInt32(memory, StackAddress + 24, 0x123456);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DcbWaitRegMem(ctx));
Assert.Equal(PacketAddress, ctx[CpuRegister.Rax]);
Assert.Equal(0xC005_1028u, ReadUInt32(memory, PacketAddress));
Assert.Equal(0x0000_0C00u, ReadUInt32(memory, PacketAddress + 4));
Assert.Equal(1u, ReadUInt32(memory, PacketAddress + 8));
Assert.Equal(0xEEFF_0011u, ReadUInt32(memory, PacketAddress + 12));
Assert.Equal(0x5566_7788u, ReadUInt32(memory, PacketAddress + 16));
Assert.Equal(0x0400_0053u, ReadUInt32(memory, PacketAddress + 20));
Assert.Equal(0xFFFFu, ReadUInt32(memory, PacketAddress + 24));
Assert.Equal(PacketAddress + 28, ReadUInt64(memory, CommandBufferAddress + 0x10));
}
[Fact]
public void DcbWaitRegMem64_EmitsGen5PacketLayout()
{
var memory = CreateMemory(out var ctx);
var waitAddress = BaseAddress + 0xC07;
ctx[CpuRegister.Rdi] = CommandBufferAddress;
ctx[CpuRegister.Rsi] = 1;
ctx[CpuRegister.Rdx] = 6;
ctx[CpuRegister.Rcx] = 3;
ctx[CpuRegister.R8] = 1;
ctx[CpuRegister.R9] = waitAddress;
WriteUInt64(memory, StackAddress + 8, 0x1122_3344_5566_7788);
WriteUInt64(memory, StackAddress + 16, 0xAABB_CCDD_EEFF_0011);
WriteUInt32(memory, StackAddress + 24, 0x320);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DcbWaitRegMem(ctx));
Assert.Equal(0xC007_1058u, ReadUInt32(memory, PacketAddress));
Assert.Equal(0x0000_0C00u, ReadUInt32(memory, PacketAddress + 4));
Assert.Equal(1u, ReadUInt32(memory, PacketAddress + 8));
Assert.Equal(0xEEFF_0011u, ReadUInt32(memory, PacketAddress + 12));
Assert.Equal(0xAABB_CCDDu, ReadUInt32(memory, PacketAddress + 16));
Assert.Equal(0x5566_7788u, ReadUInt32(memory, PacketAddress + 20));
Assert.Equal(0x1122_3344u, ReadUInt32(memory, PacketAddress + 24));
Assert.Equal(0x0200_0156u, ReadUInt32(memory, PacketAddress + 28));
Assert.Equal(0x32u, ReadUInt32(memory, PacketAddress + 32));
}
[Fact]
public void WaitRegMemPatchFunctions_UseGen5Fields()
{
var memory = CreateMemory(out var ctx);
WriteUInt32(memory, PacketAddress, 0xC005_1028);
WriteUInt32(memory, PacketAddress + 20, 0x0400_0153);
ctx[CpuRegister.Rdi] = PacketAddress;
ctx[CpuRegister.Rsi] = BaseAddress + 0xD07;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.WaitRegMemPatchAddress(ctx));
Assert.Equal(0x0000_0D04u, ReadUInt32(memory, PacketAddress + 4));
Assert.Equal(1u, ReadUInt32(memory, PacketAddress + 8));
ctx[CpuRegister.Rsi] = 5;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.WaitRegMemPatchCompareFunction(ctx));
Assert.Equal(0x0400_0155u, ReadUInt32(memory, PacketAddress + 20));
ctx[CpuRegister.Rsi] = 0xDEAD_BEEF;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.WaitRegMemPatchReference(ctx));
Assert.Equal(0xDEAD_BEEFu, ReadUInt32(memory, PacketAddress + 16));
}
private static FakeCpuMemory CreateMemory(out CpuContext ctx)
{
var memory = new FakeCpuMemory(BaseAddress, 0x2000);
ctx = new CpuContext(memory, Generation.Gen5);
ctx[CpuRegister.Rsp] = StackAddress;
WriteUInt64(memory, CommandBufferAddress + 0x10, PacketAddress);
WriteUInt64(memory, CommandBufferAddress + 0x18, PacketAddress + 0x100);
return memory;
}
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[4];
Assert.True(memory.TryRead(address, buffer));
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
}
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[8];
Assert.True(memory.TryRead(address, buffer));
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
}
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
{
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
Assert.True(memory.TryWrite(address, buffer));
}
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
{
Span<byte> buffer = stackalloc byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
Assert.True(memory.TryWrite(address, buffer));
}
}
@@ -8,20 +8,6 @@ using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
// Gen5ShaderScalarEvaluator.FallbackMemoryReader is a process-global static. This
// test swaps it, but the SharpEmu.Libs [ModuleInitializer] (AgcShaderCompilerHooks)
// reassigns the same static the first time any Libs type is touched. Under xUnit's
// default cross-class parallelism a Libs test running concurrently can fire that
// initializer mid-test and clobber the swapped-in reader (observed as all-zero
// reads on CI). A DisableParallelization collection runs alone in the non-parallel
// phase, so nothing else can mutate the static while this test holds it.
[CollectionDefinition(Gen5ScalarEvaluatorStateCollection.Name, DisableParallelization = true)]
public sealed class Gen5ScalarEvaluatorStateCollection
{
public const string Name = "Gen5ScalarEvaluatorState";
}
[Collection(Gen5ScalarEvaluatorStateCollection.Name)]
public sealed class Gen5ScalarMemoryFallbackTests
{
private const ulong ScalarTableAddress = 0x4_4665_4FD0;
@@ -13,8 +13,7 @@ public sealed class Gen5ShaderDecoderBoundaryTests
private const ulong ShaderAddress = 0x1_0000_0000;
private const uint Export = 0xF8000000;
private const uint Nop = 0xBF800000;
private const uint EndPgm = 0xBF810000;
private const int MaximumInstructionCount = 16384;
private const int MaximumInstructionCount = 4096;
[Fact]
public void MissingAddress_IsRejectedWithoutReadingGuestMemory()
@@ -100,23 +99,6 @@ public sealed class Gen5ShaderDecoderBoundaryTests
memory.Reads[^1]);
}
[Fact]
public void ProgramMayEndAfterPreviousDecoderLimit()
{
const int previousDecoderLimit = 4096;
var words = new uint[previousDecoderLimit + 1];
Array.Fill(words, Nop);
words[^1] = EndPgm;
var memory = RecordingCpuMemory.FromWords(ShaderAddress, words);
var decoded = Decode(memory, ShaderAddress, out var program, out var error);
Assert.True(decoded, error);
Assert.Equal(words.Length, program.Instructions.Count);
Assert.Equal("SEndpgm", program.Instructions[^1].Opcode);
Assert.Equal(words.Length, memory.Reads.Count);
}
private static bool Decode(
RecordingCpuMemory memory,
ulong address,
@@ -1,86 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Agc;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
// TryDetile's exact-XOR fast path (PS5 swizzle modes 5/9/24/27) factors the
// AddrLib bit-interleave into independent per-column X and per-row Y terms so
// the inner loop is one array load and one XOR instead of a 16-bit interleave.
// These tests pin that the factored output stays byte-identical to the direct
// AddrLib address equation.
public sealed class GnmTilingDetileTests
{
// Independent re-derivation of the 64 KiB RB+ R_X equation (swizzle mode 27,
// 2 bytes/element) straight from the address-bit table, so the tiled source
// layout does not depend on TryDetile's own internal factoring.
private static readonly (uint XMask, uint YMask)[] RbPlus64KRenderX2Bpp =
[
(0, 0), (1u << 0, 0), (1u << 1, 0), (1u << 2, 0),
(0, 1u << 0), (0, 1u << 1), (0, 1u << 2), (1u << 3, 0),
(1u << 7, (1u << 4) | (1u << 7)), (1u << 4, 1u << 4), (1u << 6, 1u << 5), (1u << 5, 1u << 6),
(0, 1u << 3), (1u << 6, 0), (1u << 7, 1u << 7), (1u << 8, 1u << 6),
];
private static uint ReferenceOffset(uint x, uint y, (uint XMask, uint YMask)[] pattern)
{
uint offset = 0;
for (var bit = 0; bit < pattern.Length; bit++)
{
var parity = (System.Numerics.BitOperations.PopCount(x & pattern[bit].XMask) +
System.Numerics.BitOperations.PopCount(y & pattern[bit].YMask)) & 1;
offset |= (uint)parity << bit;
}
return offset;
}
[Theory]
[InlineData(384, 200)]
[InlineData(768, 512)]
public void TryDetile_ExactXorMode27_MatchesReferenceAddressEquation(
int elementsWide,
int elementsHigh)
{
const uint swizzleMode = 27; // 64 KiB RB+ R_X
const int bytesPerElement = 2;
const int blockBytes = 65536;
// SquareBlockDimensions(32768 elements): 15 bits split 8/7, x favored.
const int blockWidth = 256;
const int blockHeight = 128;
var blocksPerRow = (elementsWide + blockWidth - 1) / blockWidth;
var blocksPerColumn = (elementsHigh + blockHeight - 1) / blockHeight;
// Lay out a tiled source where each element stores its own linear index,
// placed at the byte address the AddrLib equation dictates. The tiled
// buffer is sized by padded whole blocks (block addressing overshoots the
// linear extent). A correct detile must recover ascending linear indices.
var tiled = new byte[blocksPerRow * blocksPerColumn * blockBytes];
for (var y = 0; y < elementsHigh; y++)
{
for (var x = 0; x < elementsWide; x++)
{
var blockIndex = (long)(y / blockHeight) * blocksPerRow + (x / blockWidth);
// The equation yields a byte offset within the block (bit 0 is
// Zero at 2bpp, keeping element writes 2-byte aligned).
var sourceByte = (int)(blockIndex * blockBytes +
ReferenceOffset((uint)x, (uint)y, RbPlus64KRenderX2Bpp));
var linearIndex = (ushort)(y * elementsWide + x);
tiled[sourceByte] = (byte)linearIndex;
tiled[sourceByte + 1] = (byte)(linearIndex >> 8);
}
}
var linear = new byte[elementsWide * elementsHigh * bytesPerElement];
var ok = GnmTiling.TryDetile(tiled, linear, swizzleMode, elementsWide, elementsHigh, bytesPerElement);
Assert.True(ok);
for (var i = 0; i < elementsWide * elementsHigh; i++)
{
var value = (ushort)(linear[i * 2] | (linear[i * 2 + 1] << 8));
Assert.Equal((ushort)i, value);
}
}
}
@@ -1,80 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.Libs.Ampr;
using System.Buffers.Binary;
using Xunit;
namespace SharpEmu.Libs.Tests.Ampr;
public sealed class AmprWriteAddressTests
{
[Fact]
public void MeasureCommandSizeWriteAddress0400_MatchesOnCompletionVariant()
{
const string nid = "4fgtGfXDrFc";
const ulong memoryBase = 0x1_0000_0000;
var memory = new FakeCpuMemory(memoryBase, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
var manager = CreateManagerWithExport(
nid,
"sceAmprMeasureCommandSizeWriteAddress_04_00");
Assert.Equal(OrbisGen2Result.ORBIS_GEN2_OK, manager.Dispatch(nid, context));
var measured = context[CpuRegister.Rax];
Assert.Equal(0, AmprExports.MeasureCommandSizeWriteAddressOnCompletion(context));
Assert.Equal(context[CpuRegister.Rax], measured);
}
[Fact]
public void CommandBufferWriteAddress0400_WritesValueOnCompletion()
{
const string nid = "j0+3uJMxYJY";
const ulong memoryBase = 0x1_0000_0000;
const ulong commandBufferAddress = memoryBase + 0x100;
const ulong recordBufferAddress = memoryBase + 0x200;
const ulong watcherAddress = memoryBase + 0x800;
const ulong watcherValue = 1;
var memory = new FakeCpuMemory(memoryBase, 0x1000);
var context = new CpuContext(memory, Generation.Gen5);
var manager = CreateManagerWithExport(
nid,
"sceAmprCommandBufferWriteAddress_04_00");
context[CpuRegister.Rdi] = commandBufferAddress;
context[CpuRegister.Rsi] = recordBufferAddress;
context[CpuRegister.Rdx] = 0x100;
Assert.Equal(0, AmprExports.CommandBufferConstructor(context));
context[CpuRegister.Rdi] = commandBufferAddress;
context[CpuRegister.Rsi] = watcherAddress;
context[CpuRegister.Rdx] = watcherValue;
Assert.Equal(OrbisGen2Result.ORBIS_GEN2_OK, manager.Dispatch(nid, context));
Span<byte> watcher = stackalloc byte[sizeof(ulong)];
Assert.True(memory.TryRead(watcherAddress, watcher));
Assert.Equal(0UL, BinaryPrimitives.ReadUInt64LittleEndian(watcher));
Assert.Equal(0, AmprExports.CompleteCommandBuffer(context, commandBufferAddress));
Assert.True(memory.TryRead(watcherAddress, watcher));
Assert.Equal(watcherValue, BinaryPrimitives.ReadUInt64LittleEndian(watcher));
}
private static ModuleManager CreateManagerWithExport(string nid, string exportName)
{
var manager = new ModuleManager();
manager.RegisterExports(
SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen5));
Assert.True(manager.TryGetExport(nid, out var export), $"NID {nid} did not register.");
Assert.Equal(exportName, export.Name);
Assert.Equal("libSceAmpr", export.LibraryName);
Assert.Equal(Generation.Gen5, export.Target);
return manager;
}
}
@@ -24,25 +24,14 @@ public sealed class AprStreamingContractTests
const ulong destinationAddress = memoryBase + 0x2000;
const ulong stackAddress = memoryBase + 0x3000;
byte[] fileContents = [10, 11, 12, 13, 14, 15, 16, 17];
// The kernel FS resolver default-denies raw absolute host paths, so the
// guest addresses the file through a registered mount instead of handing
// in a bare host temp path.
var mountRoot = Path.Combine(
Path.GetTempPath(),
$"sharpemu-apr-{Guid.NewGuid():N}");
Directory.CreateDirectory(mountRoot);
var mountPoint = $"/sharpemu_apr_mnt_{Guid.NewGuid():N}";
const string fileName = "asset.bin";
var hostPath = Path.Combine(mountRoot, fileName);
var guestPath = $"{mountPoint}/{fileName}";
var hostPath = Path.GetTempFileName();
try
{
File.WriteAllBytes(hostPath, fileContents);
KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, mountRoot);
var memory = new FakeCpuMemory(memoryBase, 0x4000);
var context = new CpuContext(memory, Generation.Gen5);
memory.WriteCString(pathAddress, guestPath);
memory.WriteCString(pathAddress, hostPath);
WriteUInt64(memory, pathListAddress, pathAddress);
context[CpuRegister.Rdi] = pathListAddress;
@@ -97,11 +86,7 @@ public sealed class AprStreamingContractTests
}
finally
{
KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint);
if (Directory.Exists(mountRoot))
{
Directory.Delete(mountRoot, recursive: true);
}
File.Delete(hostPath);
}
}
@@ -171,29 +156,19 @@ public sealed class AprStreamingContractTests
const ulong sizesAddress = memoryBase + 0x880;
const ulong errorIndexAddress = memoryBase + 0x8F0;
byte[] fileContents = [1, 2, 3, 4, 5];
// Entries 0 and 2 must resolve to a real file; the kernel FS resolver
// default-denies raw absolute host paths, so the present file is reached
// through a registered mount. The missing entry stays an unresolvable
// path so the batch fails mid-way at index 1.
var mountRoot = Path.Combine(
var hostPath = Path.GetTempFileName();
var missingHostPath = Path.Combine(
Path.GetTempPath(),
$"sharpemu-apr-{Guid.NewGuid():N}");
Directory.CreateDirectory(mountRoot);
var mountPoint = $"/sharpemu_apr_mnt_{Guid.NewGuid():N}";
const string fileName = "asset.bin";
var hostPath = Path.Combine(mountRoot, fileName);
var guestPath = $"{mountPoint}/{fileName}";
var missingGuestPath = $"{mountPoint}/missing-{Guid.NewGuid():N}.bin";
$"sharpemu-apr-missing-{Guid.NewGuid():N}.bin");
try
{
File.WriteAllBytes(hostPath, fileContents);
KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, mountRoot);
var memory = new FakeCpuMemory(memoryBase, 0x4000);
var context = new CpuContext(memory, Generation.Gen5);
memory.WriteCString(memoryBase + 0x200, guestPath);
memory.WriteCString(memoryBase + 0x400, missingGuestPath);
memory.WriteCString(memoryBase + 0x600, guestPath);
memory.WriteCString(memoryBase + 0x200, hostPath);
memory.WriteCString(memoryBase + 0x400, missingHostPath);
memory.WriteCString(memoryBase + 0x600, hostPath);
WriteUInt64(memory, pathListAddress, memoryBase + 0x200);
WriteUInt64(memory, pathListAddress + 8, memoryBase + 0x400);
WriteUInt64(memory, pathListAddress + 16, memoryBase + 0x600);
@@ -217,11 +192,7 @@ public sealed class AprStreamingContractTests
}
finally
{
KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint);
if (Directory.Exists(mountRoot))
{
Directory.Delete(mountRoot, recursive: true);
}
File.Delete(hostPath);
}
}
@@ -80,19 +80,6 @@ public sealed class AjmExportsTests : IDisposable
Assert.Equal(InvalidContext, RegisterCodec(contextId + 1, 1));
}
[Theory]
[InlineData(23u)]
[InlineData(24u)]
public void Gen5CodecTypesCanRegisterAndCreateInstances(uint codecType)
{
var contextId = Initialize();
Assert.Equal(0, RegisterCodec(contextId, codecType));
Assert.Equal(
0,
CreateInstance(contextId, codecType, 0x401, InstanceAddress));
}
[Fact]
public void InstanceDestroy_RejectsUnknownContextAndSlot()
{
@@ -99,25 +99,6 @@ public sealed class AvPlayerPathTests : IDisposable
AvPlayerExports.GetFfprobePath(ffmpeg, isWindows));
}
[Theory]
[InlineData(false, "ffmpeg")]
[InlineData(true, "ffmpeg.exe")]
public void MediaToolLookupFindsPackagedBinary(bool isWindows, string executable)
{
var publishDirectory = Path.Combine(_tempRoot, "publish");
Directory.CreateDirectory(Path.Combine(publishDirectory, "ffmpeg"));
var ffmpeg = Path.Combine(publishDirectory, "ffmpeg", executable);
File.WriteAllBytes(ffmpeg, []);
Assert.Equal(
ffmpeg,
AvPlayerExports.FindFfmpeg(
configured: null,
searchPath: null,
isWindows,
publishDirectory));
}
[Fact]
public void RelativeFileUriCannotEscapeApp0()
{
@@ -1,79 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.Libs.Bink;
using Xunit;
namespace SharpEmu.Libs.Tests.Bink;
public sealed class Bink2MovieBridgeTests : IDisposable
{
private readonly string _tempDirectory = Path.Combine(
Path.GetTempPath(),
$"sharpemu-bink-{Guid.NewGuid():N}");
public Bink2MovieBridgeTests()
{
Directory.CreateDirectory(_tempDirectory);
}
[Fact]
public void HeaderPreservesFractionalFrameRate()
{
var path = WriteHeader("KB2j"u8, 3840, 2160, 30_000, 1_001);
Assert.True(Bink2MovieBridge.TryReadBinkInfo(path, out var info));
Assert.Equal(3840u, info.Width);
Assert.Equal(2160u, info.Height);
Assert.Equal(30_000u, info.FramesPerSecondNumerator);
Assert.Equal(1_001u, info.FramesPerSecondDenominator);
}
[Theory]
[InlineData("KB2g")]
[InlineData("KB2i")]
[InlineData("KB2j")]
public void HeaderAcceptsBink2Revisions(string signature)
{
var path = WriteHeader(
System.Text.Encoding.ASCII.GetBytes(signature),
1920,
1080,
60,
1);
Assert.True(Bink2MovieBridge.TryReadBinkInfo(path, out _));
}
[Fact]
public void HeaderRejectsMissingFrameRateDenominator()
{
var path = WriteHeader("KB2j"u8, 1920, 1080, 60, 0);
Assert.False(Bink2MovieBridge.TryReadBinkInfo(path, out _));
}
private string WriteHeader(
ReadOnlySpan<byte> signature,
uint width,
uint height,
uint fpsNumerator,
uint fpsDenominator)
{
var header = new byte[36];
signature.CopyTo(header);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x14), width);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x18), height);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x1C), fpsNumerator);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x20), fpsDenominator);
var path = Path.Combine(_tempDirectory, $"{Guid.NewGuid():N}.bk2");
File.WriteAllBytes(path, header);
return path;
}
public void Dispose()
{
Directory.Delete(_tempDirectory, recursive: true);
}
}
@@ -1,105 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Bink;
using Xunit;
namespace SharpEmu.Libs.Tests.Bink;
public sealed class BinkFramePlaybackTests
{
[Fact]
public void FramesAdvanceAccordingToMovieClock()
{
using var playback = new BinkFramePlayback(new SequenceDecoder(1, 2, 3));
Assert.Equal(1, WaitForAdvancedFrame(playback)[0]);
Assert.True(playback.TryGetFrame(true, out var heldFrame, out var advanced));
Assert.False(advanced);
Assert.Equal(1, heldFrame[0]);
Assert.Equal(2, WaitForAdvancedFrame(playback)[0]);
Assert.Equal(3, WaitForAdvancedFrame(playback)[0]);
}
private static byte[] WaitForAdvancedFrame(BinkFramePlayback playback)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
while (DateTime.UtcNow < deadline)
{
if (playback.TryGetFrame(true, out var frame, out var advanced) && advanced)
{
return frame;
}
Thread.Sleep(1);
}
throw new TimeoutException("The decoder did not produce a frame.");
}
[Fact]
public void FirstFrameWaitsUntilPresentationStarts()
{
using var playback = new BinkFramePlayback(new SequenceDecoder(1, 2));
var first = WaitForFrame(playback, advanceClock: false);
Assert.Equal(1, first[0]);
Thread.Sleep(100);
Assert.True(playback.TryGetFrame(false, out var held, out var advanced));
Assert.False(advanced);
Assert.Equal(1, held[0]);
Assert.True(playback.TryGetFrame(true, out held, out advanced));
Assert.False(advanced);
Assert.Equal(1, held[0]);
Assert.Equal(2, WaitForAdvancedFrame(playback)[0]);
}
private static byte[] WaitForFrame(
BinkFramePlayback playback,
bool advanceClock)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
while (DateTime.UtcNow < deadline)
{
if (playback.TryGetFrame(advanceClock, out var frame, out _))
{
return frame;
}
Thread.Sleep(1);
}
throw new TimeoutException("The decoder did not produce a frame.");
}
private sealed class SequenceDecoder(params byte[] values) : IBinkFrameDecoder
{
private int _index;
public uint Width => 1;
public uint Height => 1;
public uint FramesPerSecondNumerator => 20;
public uint FramesPerSecondDenominator => 1;
public bool TryDecodeNextFrame(Span<byte> destination)
{
if (_index >= values.Length)
{
return false;
}
destination.Fill(values[_index++]);
return true;
}
public void Dispose()
{
}
}
}
@@ -1,265 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using SharpEmu.Core.Cpu.Emulation;
using SharpEmu.Core.Cpu.Native;
using SharpEmu.HLE;
using Xunit;
namespace SharpEmu.Libs.Tests.Cpu;
/// <summary>
/// Coverage for the SSE4a EXTRQ/INSERTQ fault recovery through the POSIX signal bridge on
/// Linux. Each test fabricates the exact frame the kernel hands the SIGILL handler - gregs
/// whose RIP points at a real EXTRQ/INSERTQ encoding in probe-visible host memory, plus an
/// FXSAVE image carrying the XMM registers - and drives the production entry point
/// (TryHandlePosixFault) over it. The bridge must capture the XMM state into the CONTEXT
/// scratch buffer, the recovery must decode and emulate the instruction, and the write-back
/// must land the result in the FXSAVE image and advance RIP, because that is precisely what
/// sigreturn restores on a live fault.
/// </summary>
public sealed unsafe class Sse4aPosixSignalRecoveryTests
{
private const int PosixSigIll = 4;
private const int LinuxUcontextGregsOffset = 40;
private const int LinuxGregsRipOffset = 16 * 8;
private const int LinuxGregsFpstateOffset = 184;
private const int FxsaveXmm0Offset = 160;
private const int FxsaveXmm1Offset = 176;
private static readonly MethodInfo TryHandlePosixFault = typeof(DirectExecutionBackend).GetMethod(
"TryHandlePosixFault",
BindingFlags.Static | BindingFlags.NonPublic)!;
private static readonly FieldInfo PosixSignalBackend = typeof(DirectExecutionBackend).GetField(
"_posixSignalBackend",
BindingFlags.Static | BindingFlags.NonPublic)!;
private static readonly FieldInfo EmulatedCounter = typeof(DirectExecutionBackend).GetField(
"_sse4aInstructionsEmulated",
BindingFlags.Static | BindingFlags.NonPublic)!;
private static readonly FieldInfo XmmBridgedFlag = typeof(DirectExecutionBackend).GetField(
"_posixXmmContextBridged",
BindingFlags.Static | BindingFlags.NonPublic)!;
private static readonly MethodInfo TryRecoverAmdCompat = typeof(DirectExecutionBackend).GetMethod(
"TryRecoverAmdCompatInstruction",
BindingFlags.Instance | BindingFlags.NonPublic)!;
[Fact]
public void ExtrqSigillRoundTripsXmmThroughTheBridge()
{
if (!OperatingSystem.IsLinux() ||
RuntimeInformation.ProcessArchitecture != Architecture.X64)
{
return;
}
// extrq xmm0, 0x10, 0x08
var code = AllocateProbeVisibleCode([0x66, 0x0F, 0x78, 0xC0, 0x10, 0x08]);
try
{
const ulong value = 0x1234_5678_9ABC_DEF0UL;
var frame = new FakeSignalFrame((ulong)code);
frame.SetXmmLow(FxsaveXmm0Offset, value);
var emulatedBefore = (long)EmulatedCounter.GetValue(null)!;
Assert.True(frame.Dispatch());
Assert.Equal(
Sse4aBitFieldEmulator.ExtractBitField(value, length: 0x10, index: 0x08),
frame.XmmLow(FxsaveXmm0Offset));
Assert.Equal(0UL, frame.XmmHigh(FxsaveXmm0Offset));
Assert.Equal((ulong)code + 6, frame.Rip);
Assert.True((long)EmulatedCounter.GetValue(null)! > emulatedBefore);
}
finally
{
FreeProbeVisibleCode(code);
}
}
[Fact]
public void InsertqSigillReadsSourceXmmThroughTheBridge()
{
if (!OperatingSystem.IsLinux() ||
RuntimeInformation.ProcessArchitecture != Architecture.X64)
{
return;
}
// insertq xmm0, xmm1, 0x10, 0x08
var code = AllocateProbeVisibleCode([0xF2, 0x0F, 0x78, 0xC1, 0x10, 0x08]);
try
{
const ulong destination = 0x1111_2222_3333_4444UL;
const ulong source = 0xAAAA_BBBB_CCCC_DDDDUL;
var frame = new FakeSignalFrame((ulong)code);
frame.SetXmmLow(FxsaveXmm0Offset, destination);
frame.SetXmmLow(FxsaveXmm1Offset, source);
Assert.True(frame.Dispatch());
Assert.Equal(
Sse4aBitFieldEmulator.InsertBitField(destination, source, length: 0x10, index: 0x08),
frame.XmmLow(FxsaveXmm0Offset));
Assert.Equal((ulong)code + 6, frame.Rip);
}
finally
{
FreeProbeVisibleCode(code);
}
}
[Fact]
public void RecoveryDeclinesWhenNoXmmStateWasBridged()
{
if (!OperatingSystem.IsLinux() ||
RuntimeInformation.ProcessArchitecture != Architecture.X64)
{
return;
}
// extrq xmm0, 0x10, 0x08 - valid and recoverable, but without bridged XMM state
// (fpstate missing from the frame) the recovery must decline rather than emulate
// over the zeroed scratch bytes. Drive the recovery entry directly: earlier tests
// on this thread leave the thread-static bridge flag set, so clear it the way a
// fpstate-less capture would.
var code = AllocateProbeVisibleCode([0x66, 0x0F, 0x78, 0xC0, 0x10, 0x08]);
try
{
XmmBridgedFlag.SetValue(null, false);
var backend = RuntimeHelpers.GetUninitializedObject(typeof(DirectExecutionBackend));
var contextRecord = stackalloc byte[0x4D0];
var recovered = (bool)TryRecoverAmdCompat.Invoke(
backend,
[Pointer.Box(contextRecord, typeof(void*)), (ulong)code])!;
Assert.False(recovered);
}
finally
{
FreeProbeVisibleCode(code);
}
}
/// <summary>
/// The Linux x86-64 signal frame as TryHandlePosixFault consumes it: a ucontext whose
/// mcontext gregs sit at +40 (kernel sigcontext layout) with the fpstate pointer at
/// gregs+184 aiming at a 512-byte FXSAVE image.
/// </summary>
private sealed class FakeSignalFrame
{
private readonly byte[] _ucontext = new byte[512];
private readonly byte[] _fpstate = new byte[512];
private readonly bool _wireFpstate;
public FakeSignalFrame(ulong rip, bool wireFpstate = true)
{
_wireFpstate = wireFpstate;
fixed (byte* ucontext = _ucontext)
{
*(ulong*)(ucontext + LinuxUcontextGregsOffset + LinuxGregsRipOffset) = rip;
}
}
public ulong Rip
{
get
{
fixed (byte* ucontext = _ucontext)
{
return *(ulong*)(ucontext + LinuxUcontextGregsOffset + LinuxGregsRipOffset);
}
}
}
public void SetXmmLow(int fxsaveOffset, ulong value)
{
fixed (byte* fpstate = _fpstate)
{
*(ulong*)(fpstate + fxsaveOffset) = value;
}
}
public ulong XmmLow(int fxsaveOffset)
{
fixed (byte* fpstate = _fpstate)
{
return *(ulong*)(fpstate + fxsaveOffset);
}
}
public ulong XmmHigh(int fxsaveOffset)
{
fixed (byte* fpstate = _fpstate)
{
return *(ulong*)(fpstate + fxsaveOffset + 8);
}
}
public bool Dispatch()
{
EnsureBridgeBackend();
fixed (byte* ucontext = _ucontext)
fixed (byte* fpstate = _fpstate)
{
if (_wireFpstate)
{
*(byte**)(ucontext + LinuxUcontextGregsOffset + LinuxGregsFpstateOffset) = fpstate;
}
return (bool)TryHandlePosixFault.Invoke(
null,
[PosixSigIll, (nint)0, (nint)ucontext])!;
}
}
}
/// <summary>
/// TryHandlePosixFault only runs the recovery chain when a backend instance is
/// registered. The tests do not need any of the constructor's state (and must not run
/// it: it installs process-wide signal handlers), so register an uninitialized
/// instance - the SIGILL recovery path only touches static state.
/// </summary>
private static void EnsureBridgeBackend()
{
if (PosixSignalBackend.GetValue(null) == null)
{
PosixSignalBackend.SetValue(
null,
RuntimeHelpers.GetUninitializedObject(typeof(DirectExecutionBackend)));
}
}
/// <summary>
/// The instruction bytes must live in memory the fault-time page probe
/// (TryReadHostBytes -> VirtualQuery) can see; on POSIX that is HostMemory's shadow
/// region table, the same allocator guest code pages come from. A raw libc mmap or a
/// pinned managed array would be invisible and the recovery would decline before
/// decoding.
/// </summary>
private static nint AllocateProbeVisibleCode(ReadOnlySpan<byte> instructions)
{
var size = checked((nuint)Environment.SystemPageSize);
var mapping = (nint)HostMemory.Alloc(
null,
size,
HostMemory.MEM_COMMIT | HostMemory.MEM_RESERVE,
HostMemory.PAGE_READWRITE);
Assert.NotEqual((nint)0, mapping);
instructions.CopyTo(new Span<byte>((void*)mapping, checked((int)size)));
return mapping;
}
private static void FreeProbeVisibleCode(nint mapping)
{
Assert.True(HostMemory.Free((void*)mapping, 0, HostMemory.MEM_RELEASE));
}
}
@@ -41,32 +41,4 @@ public sealed class FontExportsTests
Assert.Equal(0.0f, BinaryPrimitives.ReadSingleLittleEndian(layout[8..]));
Assert.Equal(Sentinel, BinaryPrimitives.ReadUInt32LittleEndian(layout[12..]));
}
[Fact]
public void GetVerticalLayout_WritesExactlyThreeFloats()
{
const uint Sentinel = 0xDEADBEEF;
Span<byte> sentinelBytes = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(sentinelBytes, Sentinel);
Assert.True(_ctx.Memory.TryWrite(LayoutAddress + 12, sentinelBytes));
_ctx[CpuRegister.Rsi] = LayoutAddress;
Assert.Equal(0, FontExports.GetVerticalLayout(_ctx));
Span<byte> layout = stackalloc byte[16];
Assert.True(_ctx.Memory.TryRead(LayoutAddress, layout));
Assert.Equal(8.0f, BinaryPrimitives.ReadSingleLittleEndian(layout));
Assert.Equal(16.0f, BinaryPrimitives.ReadSingleLittleEndian(layout[4..]));
Assert.Equal(0.0f, BinaryPrimitives.ReadSingleLittleEndian(layout[8..]));
Assert.Equal(Sentinel, BinaryPrimitives.ReadUInt32LittleEndian(layout[12..]));
}
[Fact]
public void GetVerticalLayout_NullBuffer_ReturnsInvalidArgument()
{
_ctx[CpuRegister.Rsi] = 0;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
FontExports.GetVerticalLayout(_ctx));
}
}
@@ -301,38 +301,6 @@ public sealed class KernelMemoryCompatExportsTests
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT, result);
}
[Fact]
public void VirtualQuery_PreservesReservationPastFixedCommitAtSameBase()
{
const ulong memoryBase = 0x12_0000_0000;
const ulong reservedLength = 0x1_0000;
const ulong committedLength = 0x2000;
const ulong inOutAddress = memoryBase + 0x1_7000;
const ulong infoAddress = memoryBase + 0x1_8000;
var memory = new FakeCpuMemory(memoryBase, 0x2_0000);
var context = new CpuContext(memory, Generation.Gen5);
KernelMemoryCompatExports.RegisterReservedVirtualRange(memoryBase, reservedLength);
Assert.True(memory.TryWrite(inOutAddress, BitConverter.GetBytes(memoryBase)));
context[CpuRegister.Rdi] = inOutAddress;
context[CpuRegister.Rsi] = committedLength;
context[CpuRegister.Rdx] = 0x03;
context[CpuRegister.Rcx] = 0x10; // fixed mapping
Assert.Equal(0, KernelMemoryCompatExports.KernelMapNamedFlexibleMemory(context));
context[CpuRegister.Rdi] = memoryBase + 0x8000;
context[CpuRegister.Rsi] = 0;
context[CpuRegister.Rdx] = infoAddress;
context[CpuRegister.Rcx] = 0x48;
Assert.Equal(0, KernelMemoryCompatExports.KernelVirtualQuery(context));
Assert.True(context.TryReadUInt64(infoAddress, out var regionStart));
Assert.True(context.TryReadUInt64(infoAddress + 8, out var regionEnd));
Assert.Equal(memoryBase + committedLength, regionStart);
Assert.Equal(memoryBase + reservedLength, regionEnd);
}
[Fact]
public void Mprotect_ZeroAddressReturnsInvalidArgument()
{
@@ -1,206 +0,0 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Kernel;
using Xunit;
namespace SharpEmu.Libs.Tests.Kernel;
// The kernel path resolver is the guest->host sandbox boundary: every real
// file syscall (open/stat/unlink/mkdir/rmdir/rename/chmod) maps a guest path
// to a host path through KernelMemoryCompatExports.ResolveGuestPath and then
// hands the result straight to the host filesystem. These tests pin the
// containment guarantees: an unmapped or absolute guest path must resolve to
// nothing (default-deny), and a mount-relative path must never escape its root.
[Collection(KernelMemoryCompatStateCollection.Name)]
public sealed class KernelSandboxEscapeTests : IDisposable
{
private readonly string? _originalApp0;
private readonly string _tempRoot;
private readonly string _app0Root;
public KernelSandboxEscapeTests()
{
_originalApp0 = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
_tempRoot = Path.Combine(
Path.GetTempPath(),
$"sharpemu-sandbox-{Guid.NewGuid():N}");
_app0Root = Path.Combine(_tempRoot, "app0");
Directory.CreateDirectory(_app0Root);
Environment.SetEnvironmentVariable("SHARPEMU_APP0_DIR", _app0Root);
// ResolveApp0Root caches _cachedApp0Root once, so a per-test env var is
// ignored after an earlier test populates it. Registering an explicit
// mount routes /app0 through the updatable mount table instead, which is
// the pattern KernelPathCaseSensitivityTests uses for the same reason.
KernelMemoryCompatExports.RegisterGuestPathMount("/app0", _app0Root);
}
public void Dispose()
{
KernelMemoryCompatExports.UnregisterGuestPathMount("/app0");
Environment.SetEnvironmentVariable("SHARPEMU_APP0_DIR", _originalApp0);
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
}
// Finding #1: an absolute guest path that matches no mount prefix used to be
// returned verbatim, letting the guest open arbitrary host files. These forms
// are absolute (or a UNC/backslash root) on every host, so they are denied
// everywhere.
[Theory]
[InlineData("/etc/passwd")]
[InlineData("/etc/shadow")]
[InlineData("/root/.ssh/id_rsa")]
[InlineData("\\\\server\\share\\secret")]
[InlineData("/proc/self/mem")]
public void ResolveGuestPath_UnmappedAbsolutePathIsDenied(string guestPath)
{
Assert.Equal(string.Empty, KernelMemoryCompatExports.ResolveGuestPath(guestPath));
}
// A Windows drive-qualified path (e.g. "C:\Windows\...") is only absolute on
// Windows. On Unix "C:" is an ordinary relative filename, so the resolver
// legitimately contains it under app0 rather than denying it; this escape is
// therefore Windows-specific.
[Fact]
public void ResolveGuestPath_UnmappedWindowsDrivePathIsDenied()
{
if (!OperatingSystem.IsWindows())
{
return;
}
Assert.Equal(
string.Empty,
KernelMemoryCompatExports.ResolveGuestPath("C:\\Windows\\System32\\drivers\\etc\\hosts"));
}
// A recognized mount prefix must still resolve to a path under its root, so
// the default-deny does not regress legitimate access.
[Fact]
public void ResolveGuestPath_App0PathResolvesUnderApp0Root()
{
var resolved = KernelMemoryCompatExports.ResolveGuestPath("/app0/game.bin");
Assert.False(string.IsNullOrEmpty(resolved));
var rootWithSep = Path.TrimEndingDirectorySeparator(_app0Root) + Path.DirectorySeparatorChar;
Assert.StartsWith(rootWithSep, Path.GetFullPath(resolved));
}
// Drive-letter injection: NormalizeMountRelativePath clamps "." / ".." but
// splits only on separators, so a "C:" token survives as a segment.
// Path.Combine then discards the mount root because the tail is drive-rooted,
// yielding a raw host path. A resolved path outside the mount root must be
// denied. On non-Windows hosts "C:" is an ordinary directory name and stays
// contained, so this specifically pins the Windows escape.
[Theory]
[InlineData("app0/C:/Windows/Temp/evil.dll")]
[InlineData("/app0/C:/Windows/Temp/evil.dll")]
[InlineData("download0/C:/Windows/Temp/evil.dll")]
[InlineData("/temp0/C:/Windows/Temp/evil.dll")]
public void ResolveGuestPath_DriveLetterInjectionCannotEscapeMount(string guestPath)
{
if (!OperatingSystem.IsWindows())
{
return;
}
var resolved = KernelMemoryCompatExports.ResolveGuestPath(guestPath);
// Either denied outright, or (defensively) still under a SharpEmu mount
// root — never a bare "C:\Windows\..." host path.
if (!string.IsNullOrEmpty(resolved))
{
Assert.DoesNotContain("Windows", Path.GetFullPath(resolved), StringComparison.OrdinalIgnoreCase);
}
}
// A "C:"-style token under app0 must resolve inside the app0 root, not to the
// host drive root.
[Fact]
public void ResolveGuestPath_DriveTokenStaysUnderApp0OnNonWindows()
{
if (OperatingSystem.IsWindows())
{
return;
}
var resolved = KernelMemoryCompatExports.ResolveGuestPath("/app0/C:/data.bin");
Assert.False(string.IsNullOrEmpty(resolved));
var rootWithSep = Path.TrimEndingDirectorySeparator(_app0Root) + Path.DirectorySeparatorChar;
Assert.StartsWith(rootWithSep, Path.GetFullPath(resolved));
}
// Finding #3: lexical containment does not follow symlinks/junctions. A dump
// that plants a reparse point inside app0 pointing outside it must not let a
// guest path through it escape onto the host filesystem. Creating a symlink
// can require privilege (Windows without Developer Mode); skip if it fails.
[Fact]
public void ResolveGuestPath_ReparsePointInsideMountIsDenied()
{
var outsideDir = Path.Combine(_tempRoot, "outside");
Directory.CreateDirectory(outsideDir);
File.WriteAllBytes(Path.Combine(outsideDir, "secret.bin"), [1, 2, 3]);
var linkPath = Path.Combine(_app0Root, "link");
try
{
Directory.CreateSymbolicLink(linkPath, outsideDir);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Unprivileged host cannot create the link; nothing to assert.
return;
}
// Sanity: the link genuinely redirects outside the mount, so a resolver
// that followed it would reach the planted secret.
Assert.True(File.Exists(Path.Combine(linkPath, "secret.bin")));
var resolved = KernelMemoryCompatExports.ResolveGuestPath("/app0/link/secret.bin");
Assert.Equal(string.Empty, resolved);
}
// The reparse defense must not break a legitimate real (non-link) file that
// happens to sit deep under the mount root.
[Fact]
public void ResolveGuestPath_RealNestedFileUnderMountResolves()
{
var nested = Path.Combine(_app0Root, "a", "b");
Directory.CreateDirectory(nested);
File.WriteAllBytes(Path.Combine(nested, "c.bin"), [9]);
var resolved = KernelMemoryCompatExports.ResolveGuestPath("/app0/a/b/c.bin");
Assert.False(string.IsNullOrEmpty(resolved));
var rootWithSep = Path.TrimEndingDirectorySeparator(_app0Root) + Path.DirectorySeparatorChar;
Assert.StartsWith(rootWithSep, Path.GetFullPath(resolved));
}
// The containment guard resolves paths via Path.GetFullPath / File.GetAttributes,
// which throw on a crafted over-long or invalid path. Because ResolveGuestPath
// runs outside the callers' try blocks, an uncaught throw would crash the
// syscall on untrusted input. The resolver must instead fail closed: return an
// empty host path (which callers map to NOT_FOUND), never throw.
[Theory]
[InlineData("/app0/")] // filled with a long segment below
[InlineData("/app0/bad\0name")]
public void ResolveGuestPath_MalformedPathUnderMountFailsClosed(string prefix)
{
var guestPath = prefix.EndsWith('/')
? prefix + new string('a', 40_000)
: prefix;
// Fail closed: the resolver must return an empty host path (never throw,
// never a non-empty resolution). A 40k-char segment exceeds NAME_MAX on
// Linux and a NUL trips GetFullPath on Windows; both must deny.
var resolved = KernelMemoryCompatExports.ResolveGuestPath(guestPath);
Assert.Equal(string.Empty, resolved);
}
}
@@ -121,27 +121,6 @@ public sealed class GuestMemoryAllocatorTests
Assert.Equal([alignedAddress + 0x1000], host.FreedAddresses);
}
[Fact]
public void TryBackFixedRangeRollsBackEarlierGapsWhenLaterGapCannotBeBacked()
{
// Layout: committed | free | committed | free
// First free gap allocates successfully, second fails.
// The first allocation must be freed — nothing should leak.
const ulong rangeBase = 0x0000_0020_2F00_0000;
const ulong rangeSize = 0x4000;
using var host = new GappedHostMemory(rangeBase);
using var memory = new PhysicalVirtualMemory(host);
Assert.False(memory.TryBackFixedRange(rangeBase, rangeSize, executable: false));
// First gap allocates successfully; second gap is attempted and fails.
Assert.Equal(
[(rangeBase + 0x1000, 0x1000), (rangeBase + 0x3000, 0x1000)],
host.AllocationCalls);
// First gap must have been freed during rollback — nothing leaks.
Assert.Equal([rangeBase + 0x1000], host.FreedAddresses);
}
[Fact]
public void TryBackFixedRangeFillsOnlyTheFreePagesOfAPartiallyOccupiedRange()
{
@@ -249,108 +228,6 @@ public sealed class GuestMemoryAllocatorTests
}
}
private sealed class GappedHostMemory : IHostMemory, IDisposable
{
// Layout (4 × 0x1000 pages):
// [committed] [free] [committed] [free]
// Allocate succeeds for the first free gap, fails for the second.
private readonly ulong _base;
private readonly ulong _firstGapStart;
private readonly ulong _firstGapEnd;
private readonly ulong _secondGapStart;
private readonly ulong _secondGapEnd;
private readonly ulong _end;
public GappedHostMemory(ulong @base)
{
_base = @base;
_firstGapStart = @base + 0x1000;
_firstGapEnd = @base + 0x2000;
_secondGapStart = @base + 0x3000;
_secondGapEnd = @base + 0x4000;
_end = @base + 0x4000;
}
public List<(ulong Address, ulong Size)> AllocationCalls { get; } = [];
public List<ulong> FreedAddresses { get; } = [];
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection)
{
AllocationCalls.Add((desiredAddress, size));
// First free gap — succeed.
if (desiredAddress == _firstGapStart && size == 0x1000)
{
return desiredAddress;
}
// Second free gap — fail to trigger rollback.
return 0;
}
public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection) => 0;
public bool Commit(ulong address, ulong size, HostPageProtection protection) => true;
public bool Free(ulong address)
{
FreedAddresses.Add(address);
return true;
}
public bool Protect(ulong address, ulong size, HostPageProtection protection, out uint rawOldProtection)
{
rawOldProtection = 0;
return true;
}
public bool ProtectRaw(ulong address, ulong size, uint rawProtection, out uint rawOldProtection)
{
rawOldProtection = 0;
return true;
}
public bool Query(ulong address, out HostRegionInfo info)
{
if (address < _firstGapStart)
{
// First block: committed.
info = new HostRegionInfo(address, _base, _firstGapStart - address,
HostRegionState.Committed, RawState: 0x1000,
HostPageProtection.ReadWrite, RawProtection: 0x04, RawAllocationProtection: 0x04);
return true;
}
if (address < _firstGapEnd)
{
// Second block: free (first gap).
info = new HostRegionInfo(address, AllocationBase: 0, _firstGapEnd - address,
HostRegionState.Free, RawState: 0x10000,
HostPageProtection.NoAccess, RawProtection: 0x01, RawAllocationProtection: 0);
return true;
}
if (address < _secondGapStart)
{
// Third block: committed.
info = new HostRegionInfo(address, _base + 0x2000, _secondGapStart - address,
HostRegionState.Committed, RawState: 0x1000,
HostPageProtection.ReadWrite, RawProtection: 0x04, RawAllocationProtection: 0x04);
return true;
}
// Fourth block: free (second gap — this one will fail to allocate).
info = new HostRegionInfo(address, AllocationBase: 0, _end - address,
HostRegionState.Free, RawState: 0x10000,
HostPageProtection.NoAccess, RawProtection: 0x01, RawAllocationProtection: 0);
return true;
}
public void FlushInstructionCache(ulong address, ulong size) { }
public void Dispose() { }
}
private sealed class FakeHostMemory : IHostMemory
{
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) =>