[VideoOut] Add Bink2 support via FFMPEG bridge

This commit is contained in:
ParantezTech
2026-07-22 18:31:29 +03:00
parent d3600c9255
commit c9c0793059
22 changed files with 3085 additions and 310 deletions
+11 -4
View File
@@ -108,10 +108,17 @@ release includes the MoltenVK Vulkan implementation.
## Build
1. Install the .NET SDK version specified in [`global.json`](./global.json).
2. Clone the repository: `git clone https://github.com/sharpemu/sharpemu.git`
3. Open the solution file (`SharpEmu.slnx`) in **VSCode**.
4. Build the project: `dotnet build` or `dotnet publish`
5. Build artifacts will be located in the `artifacts` directory.
2. `dotnet publish` also builds the Bink 2 bridge
(`native/bink2-bridge/sharpemu_bink2_bridge.c`) from source, so also
install:
* **Windows:** [CMake](https://cmake.org/download/), [Ninja](https://github.com/ninja-build/ninja/releases), and [LLVM](https://github.com/llvm/llvm-project/releases) (for `clang-cl`)
* **Linux/macOS:** CMake and a C compiler toolchain (e.g. `build-essential` on Linux, Xcode Command Line Tools on macOS)
`dotnet build` alone doesn't need these; it skips the bridge.
3. Clone the repository: `git clone https://github.com/sharpemu/sharpemu.git`
4. Open the solution file (`SharpEmu.slnx`) in **VSCode**.
5. Build the project: `dotnet build` or `dotnet publish`
6. Build artifacts will be located in the `artifacts` directory.
## Disclaimer
+32 -14
View File
@@ -14,31 +14,49 @@ 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.
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.
The default path decodes through the bundled FFmpeg-backed native bridge
(`native/bink2-bridge/sharpemu_bink2_bridge.c`); see "Supplying the adapter"
below for where that binary comes from. 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.
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. Set
SHARPEMU_BINK_MODE=native to force native bridge mode.
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.
The experimental `SHARPEMU_BINK_MODE=ffmpeg` override forces a host FFmpeg
source. SharpEmu searches
`SHARPEMU_FFMPEG_PATH`, the executable directory, its `ffmpeg` subdirectory,
and then `PATH`. The FFmpeg build must contain the Bink 2 decoder; stock FFmpeg
builds that only recognize the Bink container are not sufficient.
## Supplying the adapter
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.
The adapter (`native/bink2-bridge/sharpemu_bink2_bridge.c`) links against a
custom FFmpeg build (`github.com/sharpemu/ffmpeg-core`, LGPL-2.1) that adds a
Bink 2 decoder to FFmpeg 7.1.2; no proprietary RAD SDK is needed to build or
run SharpEmu.
Place the resulting libsharpemu_bink2_bridge.dylib next to the SharpEmu
executable, or point to it explicitly:
`dotnet publish` builds it from source with CMake + Ninja + clang-cl (Windows)
or the platform's default C compiler (Linux/macOS), targeting win-x64,
linux-x64, osx-x64, or osx-arm64, then embeds the result in the published
single-file executable. Publishing SharpEmu therefore requires that
toolchain locally (the same one the CI runners already ship with); there is
no prebuilt/download fallback. A downloaded release needs no such setup: the
compiled adapter is already inside `SharpEmu.exe`.
SHARPEMU_BINK2_BRIDGE=/absolute/path/libsharpemu_bink2_bridge.dylib \
To use a different build of the adapter, point to it explicitly:
SHARPEMU_BINK2_BRIDGE=/absolute/path/sharpemu_bink2_bridge.dll \
./SharpEmu /path/to/eboot.bin
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
sharpemu_bink2_open_scaled_utf8, sharpemu_bink2_decode_next_bgra, and
sharpemu_bink2_close. The adapter opens one movie, optionally scaling it down
to a maximum size, exposes BGRA pixels, and advances after each decoded
frame. The managed side validates dimensions and retains ownership of the
destination buffer.
+126
View File
@@ -0,0 +1,126 @@
# Copyright (C) 2026 SharpEmu Emulator Project
# SPDX-License-Identifier: GPL-2.0-or-later
cmake_minimum_required(VERSION 3.21)
if(NOT DEFINED SHARPEMU_TARGET_RID)
message(FATAL_ERROR "SHARPEMU_TARGET_RID is required")
endif()
if(SHARPEMU_TARGET_RID STREQUAL "osx-x64")
set(CMAKE_OSX_ARCHITECTURES x86_64 CACHE STRING "" FORCE)
elseif(SHARPEMU_TARGET_RID STREQUAL "osx-arm64")
set(CMAKE_OSX_ARCHITECTURES arm64 CACHE STRING "" FORCE)
endif()
project(sharpemu_bink2_bridge LANGUAGES C)
set(SHARPEMU_FFMPEG_TAG "4b286ab")
set(SHARPEMU_FFMPEG_COMMIT "4b286ab5f7db41b116693e414185560630ae69e0")
set(SHARPEMU_FFMPEG_ROOT "${CMAKE_BINARY_DIR}/ffmpeg-core")
if(SHARPEMU_TARGET_RID STREQUAL "win-x64")
set(SHARPEMU_FFMPEG_PACKAGE "ffmpeg-windows-x64.zip")
elseif(SHARPEMU_TARGET_RID STREQUAL "linux-x64")
set(SHARPEMU_FFMPEG_PACKAGE "ffmpeg-linux-x64.zip")
elseif(SHARPEMU_TARGET_RID STREQUAL "osx-x64")
set(SHARPEMU_FFMPEG_PACKAGE "ffmpeg-macos-x64.zip")
elseif(SHARPEMU_TARGET_RID STREQUAL "osx-arm64")
set(SHARPEMU_FFMPEG_PACKAGE "ffmpeg-macos-arm64.zip")
else()
message(FATAL_ERROR "Unsupported Bink2 bridge RID: ${SHARPEMU_TARGET_RID}")
endif()
set(SHARPEMU_FFMPEG_ARCHIVE "${SHARPEMU_FFMPEG_ROOT}/${SHARPEMU_FFMPEG_PACKAGE}")
set(SHARPEMU_FFMPEG_LIB_DIR "${SHARPEMU_FFMPEG_ROOT}/lib")
set(SHARPEMU_FFMPEG_SOURCE_ARCHIVE "${SHARPEMU_FFMPEG_ROOT}/source.tar.gz")
set(SHARPEMU_FFMPEG_SOURCE_DIR
"${SHARPEMU_FFMPEG_ROOT}/source/ffmpeg-core-${SHARPEMU_FFMPEG_COMMIT}")
if(NOT EXISTS "${SHARPEMU_FFMPEG_ARCHIVE}")
file(MAKE_DIRECTORY "${SHARPEMU_FFMPEG_ROOT}")
file(DOWNLOAD
"https://github.com/sharpemu/ffmpeg-core/releases/download/${SHARPEMU_FFMPEG_TAG}/${SHARPEMU_FFMPEG_PACKAGE}"
"${SHARPEMU_FFMPEG_ARCHIVE}"
SHOW_PROGRESS
STATUS SHARPEMU_DOWNLOAD_STATUS)
list(GET SHARPEMU_DOWNLOAD_STATUS 0 SHARPEMU_DOWNLOAD_CODE)
if(NOT SHARPEMU_DOWNLOAD_CODE EQUAL 0)
message(FATAL_ERROR "Failed to download ${SHARPEMU_FFMPEG_PACKAGE}: ${SHARPEMU_DOWNLOAD_STATUS}")
endif()
endif()
if(NOT EXISTS "${SHARPEMU_FFMPEG_LIB_DIR}")
file(MAKE_DIRECTORY "${SHARPEMU_FFMPEG_LIB_DIR}")
file(ARCHIVE_EXTRACT
INPUT "${SHARPEMU_FFMPEG_ARCHIVE}"
DESTINATION "${SHARPEMU_FFMPEG_LIB_DIR}")
endif()
if(NOT EXISTS "${SHARPEMU_FFMPEG_SOURCE_DIR}/include/libavcodec/avcodec.h")
file(MAKE_DIRECTORY "${SHARPEMU_FFMPEG_ROOT}/source")
file(DOWNLOAD
"https://github.com/sharpemu/ffmpeg-core/archive/${SHARPEMU_FFMPEG_COMMIT}.tar.gz"
"${SHARPEMU_FFMPEG_SOURCE_ARCHIVE}"
SHOW_PROGRESS
STATUS SHARPEMU_SOURCE_STATUS)
list(GET SHARPEMU_SOURCE_STATUS 0 SHARPEMU_SOURCE_CODE)
if(NOT SHARPEMU_SOURCE_CODE EQUAL 0)
message(FATAL_ERROR "Failed to download FFmpeg headers: ${SHARPEMU_SOURCE_STATUS}")
endif()
file(ARCHIVE_EXTRACT
INPUT "${SHARPEMU_FFMPEG_SOURCE_ARCHIVE}"
DESTINATION "${SHARPEMU_FFMPEG_ROOT}/source")
endif()
add_library(sharpemu_bink2_bridge SHARED sharpemu_bink2_bridge.c)
target_compile_features(sharpemu_bink2_bridge PRIVATE c_std_11)
target_include_directories(sharpemu_bink2_bridge PRIVATE
"${SHARPEMU_FFMPEG_SOURCE_DIR}/include")
function(sharpemu_link_ffmpeg_library target library_name)
find_library(SHARPEMU_${library_name}_LIBRARY
NAMES "${library_name}" "lib${library_name}"
PATHS "${SHARPEMU_FFMPEG_LIB_DIR}"
NO_DEFAULT_PATH
REQUIRED)
target_link_libraries(${target} PRIVATE "${SHARPEMU_${library_name}_LIBRARY}")
endfunction()
sharpemu_link_ffmpeg_library(sharpemu_bink2_bridge avformat)
sharpemu_link_ffmpeg_library(sharpemu_bink2_bridge avcodec)
sharpemu_link_ffmpeg_library(sharpemu_bink2_bridge swscale)
sharpemu_link_ffmpeg_library(sharpemu_bink2_bridge avutil)
if(WIN32)
set_property(TARGET sharpemu_bink2_bridge PROPERTY
MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
target_link_libraries(sharpemu_bink2_bridge PRIVATE
bcrypt ole32 oleaut32 psapi shlwapi strmiids user32 uuid ws2_32)
elseif(APPLE)
target_link_libraries(sharpemu_bink2_bridge PRIVATE
"-framework AppKit"
"-framework AudioToolbox"
"-framework CoreAudio"
"-framework CoreFoundation"
"-framework CoreMedia"
"-framework CoreServices"
"-framework CoreVideo"
"-framework Security"
"-framework VideoToolbox")
else()
target_link_libraries(sharpemu_bink2_bridge PRIVATE dl m pthread)
endif()
set_target_properties(sharpemu_bink2_bridge PROPERTIES
C_VISIBILITY_PRESET hidden
VISIBILITY_INLINES_HIDDEN YES
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/out"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/out")
foreach(configuration Debug Release RelWithDebInfo MinSizeRel)
string(TOUPPER "${configuration}" configuration_upper)
set_target_properties(sharpemu_bink2_bridge PROPERTIES
LIBRARY_OUTPUT_DIRECTORY_${configuration_upper} "${CMAKE_BINARY_DIR}/out"
RUNTIME_OUTPUT_DIRECTORY_${configuration_upper} "${CMAKE_BINARY_DIR}/out")
endforeach()
+279 -45
View File
@@ -1,66 +1,300 @@
/*
* 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 <errno.h>
#include <stdint.h>
#include "bink.h"
#include <stdio.h>
#include <stdlib.h>
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/error.h>
#include <libswscale/swscale.h>
#if defined(_WIN32)
#define SHARPEMU_EXPORT __declspec(dllexport)
#else
#define SHARPEMU_EXPORT __attribute__((visibility("default")))
#endif
typedef struct sharpemu_bink2_info {
uint32_t width;
uint32_t height;
uint32_t frames_per_second_numerator;
uint32_t frames_per_second_denominator;
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;
typedef struct sharpemu_bink2_movie {
AVFormatContext *format;
AVCodecContext *codec;
struct SwsContext *converter;
AVFrame *frame;
AVPacket *packet;
int video_stream;
uint32_t output_width;
uint32_t output_height;
int draining;
} sharpemu_bink2_movie;
*movie = NULL;
static void sharpemu_bink2_log_error(const char *operation, int error) {
char message[AV_ERROR_MAX_STRING_SIZE];
if (av_strerror(error, message, sizeof(message)) < 0) {
snprintf(message, sizeof(message), "FFmpeg error %d", error);
}
fprintf(stderr, "[BINK2][ERROR] %s: %s\n", operation, message);
}
bink = BinkOpen(path, 0);
if (!bink) return 0;
static void sharpemu_bink2_destroy(sharpemu_bink2_movie *movie) {
if (!movie) {
return;
}
if (bink->Width == 0 || bink->Height == 0) {
BinkClose(bink);
sws_freeContext(movie->converter);
av_packet_free(&movie->packet);
av_frame_free(&movie->frame);
avcodec_free_context(&movie->codec);
avformat_close_input(&movie->format);
free(movie);
}
static AVRational sharpemu_bink2_frame_rate(AVFormatContext *format, AVStream *stream) {
AVRational rate = av_guess_frame_rate(format, stream, NULL);
if (rate.num <= 0 || rate.den <= 0) {
rate = stream->avg_frame_rate;
}
if (rate.num <= 0 || rate.den <= 0) {
rate = stream->r_frame_rate;
}
if (rate.num <= 0 || rate.den <= 0) {
rate = (AVRational){30, 1};
}
return rate;
}
static int sharpemu_bink2_open_internal(
const char *path,
uint32_t maximum_width,
uint32_t maximum_height,
void **movie_out,
sharpemu_bink2_info *info) {
sharpemu_bink2_movie *movie;
const AVCodec *decoder = NULL;
AVStream *stream;
AVRational frame_rate;
int result;
if (!path || !movie_out || !info) {
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;
*movie_out = NULL;
movie = (sharpemu_bink2_movie *)calloc(1, sizeof(*movie));
if (!movie) {
return 0;
}
result = avformat_open_input(&movie->format, path, NULL, NULL);
if (result < 0) {
sharpemu_bink2_log_error("open", result);
sharpemu_bink2_destroy(movie);
return 0;
}
result = avformat_find_stream_info(movie->format, NULL);
if (result < 0) {
sharpemu_bink2_log_error("stream info", result);
sharpemu_bink2_destroy(movie);
return 0;
}
result = av_find_best_stream(
movie->format, AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0);
if (result < 0 || !decoder) {
sharpemu_bink2_log_error("video stream", result);
sharpemu_bink2_destroy(movie);
return 0;
}
movie->video_stream = result;
stream = movie->format->streams[movie->video_stream];
movie->codec = avcodec_alloc_context3(decoder);
if (!movie->codec) {
sharpemu_bink2_destroy(movie);
return 0;
}
result = avcodec_parameters_to_context(movie->codec, stream->codecpar);
if (result < 0) {
sharpemu_bink2_log_error("codec parameters", result);
sharpemu_bink2_destroy(movie);
return 0;
}
movie->codec->thread_count = 0;
movie->codec->thread_type = FF_THREAD_FRAME | FF_THREAD_SLICE;
result = avcodec_open2(movie->codec, decoder, NULL);
if (result < 0) {
sharpemu_bink2_log_error("codec open", result);
sharpemu_bink2_destroy(movie);
return 0;
}
movie->frame = av_frame_alloc();
movie->packet = av_packet_alloc();
if (!movie->frame || !movie->packet ||
movie->codec->width <= 0 || movie->codec->height <= 0) {
sharpemu_bink2_destroy(movie);
return 0;
}
frame_rate = sharpemu_bink2_frame_rate(movie->format, stream);
movie->output_width = (uint32_t)movie->codec->width;
movie->output_height = (uint32_t)movie->codec->height;
if (maximum_width > 0 && maximum_height > 0 &&
(movie->output_width > maximum_width ||
movie->output_height > maximum_height)) {
if ((uint64_t)movie->output_width * maximum_height >
(uint64_t)movie->output_height * maximum_width) {
movie->output_height = (uint32_t)((uint64_t)movie->output_height *
maximum_width /
movie->output_width);
movie->output_width = maximum_width;
} else {
movie->output_width = (uint32_t)((uint64_t)movie->output_width *
maximum_height /
movie->output_height);
movie->output_height = maximum_height;
}
if (movie->output_width == 0) {
movie->output_width = 1;
}
if (movie->output_height == 0) {
movie->output_height = 1;
}
}
info->width = movie->output_width;
info->height = movie->output_height;
info->frames_per_second_numerator = (uint32_t)frame_rate.num;
info->frames_per_second_denominator = (uint32_t)frame_rate.den;
*movie_out = movie;
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;
SHARPEMU_EXPORT int sharpemu_bink2_open_utf8(
const char *path,
void **movie_out,
sharpemu_bink2_info *info) {
return sharpemu_bink2_open_internal(path, 0, 0, movie_out, info);
}
void sharpemu_bink2_close(HBINK movie) {
if (movie) BinkClose(movie);
SHARPEMU_EXPORT int sharpemu_bink2_open_scaled_utf8(
const char *path,
uint32_t maximum_width,
uint32_t maximum_height,
void **movie_out,
sharpemu_bink2_info *info) {
return sharpemu_bink2_open_internal(
path, maximum_width, maximum_height, movie_out, info);
}
static int sharpemu_bink2_receive_frame(sharpemu_bink2_movie *movie) {
int result;
for (;;) {
result = avcodec_receive_frame(movie->codec, movie->frame);
if (result >= 0) {
return 1;
}
if (result == AVERROR_EOF) {
return 0;
}
if (result != AVERROR(EAGAIN)) {
sharpemu_bink2_log_error("decode", result);
return 0;
}
if (movie->draining) {
return 0;
}
for (;;) {
result = av_read_frame(movie->format, movie->packet);
if (result < 0) {
movie->draining = 1;
result = avcodec_send_packet(movie->codec, NULL);
if (result < 0 && result != AVERROR_EOF) {
sharpemu_bink2_log_error("decoder drain", result);
return 0;
}
break;
}
if (movie->packet->stream_index != movie->video_stream) {
av_packet_unref(movie->packet);
continue;
}
result = avcodec_send_packet(movie->codec, movie->packet);
av_packet_unref(movie->packet);
if (result < 0 && result != AVERROR(EAGAIN)) {
sharpemu_bink2_log_error("packet submit", result);
return 0;
}
break;
}
}
}
SHARPEMU_EXPORT int sharpemu_bink2_decode_next_bgra(
void *handle,
uint8_t *destination,
uint32_t stride,
uint32_t destination_bytes) {
sharpemu_bink2_movie *movie = (sharpemu_bink2_movie *)handle;
uint8_t *destination_planes[4] = {destination, NULL, NULL, NULL};
int destination_strides[4] = {(int)stride, 0, 0, 0};
uint64_t required_bytes;
int converted_rows;
if (!movie || !destination || stride < movie->output_width * 4) {
return 0;
}
required_bytes = (uint64_t)stride * movie->output_height;
if (required_bytes > destination_bytes || !sharpemu_bink2_receive_frame(movie)) {
return 0;
}
movie->converter = sws_getCachedContext(
movie->converter,
movie->frame->width,
movie->frame->height,
(enum AVPixelFormat)movie->frame->format,
(int)movie->output_width,
(int)movie->output_height,
AV_PIX_FMT_BGRA,
SWS_FAST_BILINEAR,
NULL,
NULL,
NULL);
if (!movie->converter) {
av_frame_unref(movie->frame);
return 0;
}
converted_rows = sws_scale(
movie->converter,
(const uint8_t *const *)movie->frame->data,
movie->frame->linesize,
0,
movie->frame->height,
destination_planes,
destination_strides);
av_frame_unref(movie->frame);
return converted_rows == (int)movie->output_height;
}
SHARPEMU_EXPORT void sharpemu_bink2_close(void *movie) {
sharpemu_bink2_destroy((sharpemu_bink2_movie *)movie);
}
+39
View File
@@ -87,4 +87,43 @@ SPDX-License-Identifier: GPL-2.0-or-later
</ItemGroup>
</Target>
<!-- Building the Bink2 bridge (native/bink2-bridge/sharpemu_bink2_bridge.c)
requires cmake, ninja, and clang-cl (Windows) or a C compiler
(Linux/macOS); the same tools the CI runners already ship with. No
prebuilt/download fallback: anyone publishing SharpEmu is expected to
have the same toolchain, matching every other native dependency in
this repo. -->
<PropertyGroup>
<Bink2BridgeBuildDir>$(BaseIntermediateOutputPath)bink2-bridge/$(RuntimeIdentifier)</Bink2BridgeBuildDir>
<Bink2BridgeFileName Condition="$([MSBuild]::IsOSPlatform('Windows'))">sharpemu_bink2_bridge.dll</Bink2BridgeFileName>
<Bink2BridgeFileName Condition="$([MSBuild]::IsOSPlatform('Linux'))">libsharpemu_bink2_bridge.so</Bink2BridgeFileName>
<Bink2BridgeFileName Condition="$([MSBuild]::IsOSPlatform('OSX'))">libsharpemu_bink2_bridge.dylib</Bink2BridgeFileName>
<Bink2BridgeOutput>$(Bink2BridgeBuildDir)/out/$(Bink2BridgeFileName)</Bink2BridgeOutput>
</PropertyGroup>
<Target Name="BuildBink2Bridge"
BeforeTargets="ComputeResolvedFilesToPublishList"
Condition="'$(RuntimeIdentifier)' != '' And '$(Bink2BridgeFileName)' != ''">
<Exec Condition="$([MSBuild]::IsOSPlatform('Windows'))"
Command="cmake -S &quot;$(RepoRoot)native/bink2-bridge&quot; -B &quot;$(Bink2BridgeBuildDir)&quot; -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang-cl -DSHARPEMU_TARGET_RID=$(RuntimeIdentifier)" />
<Exec Condition="!$([MSBuild]::IsOSPlatform('Windows'))"
Command="cmake -S &quot;$(RepoRoot)native/bink2-bridge&quot; -B &quot;$(Bink2BridgeBuildDir)&quot; -DCMAKE_BUILD_TYPE=Release -DSHARPEMU_TARGET_RID=$(RuntimeIdentifier)" />
<Exec Command="cmake --build &quot;$(Bink2BridgeBuildDir)&quot; --config Release" />
</Target>
<!-- Embeds the bridge into the single-file bundle (self-extracted at first
load, see Bink2MovieBridge.NativeAdapter) instead of leaving it as a
loose file next to the executable, so a downloaded release needs no
setup: the DLL is already inside SharpEmu.exe. -->
<Target Name="EmbedBink2Bridge"
AfterTargets="ComputeResolvedFilesToPublishList"
DependsOnTargets="BuildBink2Bridge"
Condition="'$(RuntimeIdentifier)' != '' And Exists('$(Bink2BridgeOutput)')">
<ItemGroup>
<ResolvedFileToPublish Include="$(Bink2BridgeOutput)">
<RelativePath>$(Bink2BridgeFileName)</RelativePath>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
</Project>
+204 -51
View File
@@ -3,6 +3,7 @@
using System.Collections.Concurrent;
using SharpEmu.HLE;
using SharpEmu.Libs.Bink;
using SharpEmu.Libs.Gpu;
using SharpEmu.ShaderCompiler;
using SharpEmu.Libs.Kernel;
@@ -48,6 +49,7 @@ 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;
@@ -277,7 +279,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, ulong SubmissionId)>
private static readonly HashSet<(object Memory, ulong Address)>
_tracedProducerlessWaits = new();
private static long _shaderTranslationMissTraceCount;
private static long _translatedDrawTraceCount;
@@ -543,6 +545,7 @@ 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();
@@ -1183,12 +1186,12 @@ public static partial class AgcExports
return ReturnPointer(ctx, 0);
}
var packetDwords = size == 0 ? 6u : 9u;
var packetDwords = size == 0 ? 7u : 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) ||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address & (size == 0 ? ~0x3u : ~0x7u)) ||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32) & 0x3FFFFu) ||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)mask))
{
return ReturnPointer(ctx, 0);
@@ -1196,8 +1199,9 @@ public static partial class AgcExports
if (size == 0)
{
if (!TryWriteUInt32(ctx, commandAddress + 16, compareFunction) ||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference))
if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)reference) ||
!TryWriteUInt32(ctx, commandAddress + 20, EncodeWaitRegMem32Control(compareFunction, 0, cachePolicy)) ||
!TryWriteUInt32(ctx, commandAddress + 24, EncodeWaitRegMemPoll(pollCycles)))
{
return ReturnPointer(ctx, 0);
}
@@ -1205,8 +1209,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, compareFunction) ||
!TryWriteUInt32(ctx, commandAddress + 32, pollCycles / 40))
!TryWriteUInt32(ctx, commandAddress + 28, EncodeWaitRegMem64Control(compareFunction, 0, cachePolicy)) ||
!TryWriteUInt32(ctx, commandAddress + 32, EncodeWaitRegMemPoll(pollCycles)))
{
return ReturnPointer(ctx, 0);
}
@@ -1826,38 +1830,21 @@ public static partial class AgcExports
return ReturnPointer(ctx, 0);
}
var standardWait = operation is 2 or 3;
var packetDwords = standardWait ? 7u : size == 0 ? 6u : 9u;
var packetDwords = size == 0 ? 7u : 9u;
var packetRegister = size == 0 ? RWaitMem32 : RWaitMem64;
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)) ||
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 + 12, (uint)mask))
{
return ReturnPointer(ctx, 0);
}
else if (size == 0)
{
if (!TryWriteUInt32(ctx, commandAddress + 16, compareFunction | (operation << 8)) ||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference))
if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)reference) ||
!TryWriteUInt32(ctx, commandAddress + 20, EncodeWaitRegMem32Control(compareFunction, operation, cachePolicy)) ||
!TryWriteUInt32(ctx, commandAddress + 24, EncodeWaitRegMemPoll(pollCycles)))
{
return ReturnPointer(ctx, 0);
}
@@ -1865,8 +1852,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, compareFunction | (operation << 8)) ||
!TryWriteUInt32(ctx, commandAddress + 32, pollCycles / 40))
!TryWriteUInt32(ctx, commandAddress + 28, EncodeWaitRegMem64Control(compareFunction, operation, cachePolicy)) ||
!TryWriteUInt32(ctx, commandAddress + 32, EncodeWaitRegMemPoll(pollCycles)))
{
return ReturnPointer(ctx, 0);
}
@@ -2305,7 +2292,14 @@ public static partial class AgcExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
return ctx.TryWriteUInt64(commandAddress + fieldOffset, address)
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
? SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK)
: SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
@@ -2328,7 +2322,7 @@ public static partial class AgcExports
var fieldOffset = op == ItWaitRegMem
? 4UL
: op == ItNop && register == RWaitMem32
? 16UL
? 20UL
: op == ItNop && register == RWaitMem64
? 28UL
: 0;
@@ -2357,7 +2351,7 @@ public static partial class AgcExports
var wrote = op == ItWaitRegMem
? TryWriteUInt32(ctx, commandAddress + 16, (uint)reference)
: op == ItNop && register == RWaitMem32
? TryWriteUInt32(ctx, commandAddress + 20, (uint)reference)
? TryWriteUInt32(ctx, commandAddress + 16, (uint)reference)
: op == ItNop && register == RWaitMem64 &&
ctx.TryWriteUInt64(commandAddress + 20, reference);
return wrote
@@ -3105,6 +3099,26 @@ 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)
@@ -3823,7 +3837,7 @@ public static partial class AgcExports
if (!stale && producer is null &&
!_tracedProducerlessWaits.Add(
(memory, waiter.WaitAddress, waiter.SubmissionId)))
(memory, waiter.WaitAddress)))
{
return;
}
@@ -4019,6 +4033,111 @@ 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,
@@ -4561,6 +4680,7 @@ public static partial class AgcExports
private static bool TryParseSubmittedWait(
CpuContext ctx,
ulong packetAddress,
uint packetLength,
bool is64Bit,
bool isStandard,
out ulong waitAddress,
@@ -4591,8 +4711,10 @@ 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 + (is64Bit ? 28u : 16u), out var control))
!TryReadUInt32(ctx, packetAddress + controlOffset, out var control))
{
return false;
}
@@ -4605,8 +4727,9 @@ 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 + 20, out var reference32))
!TryReadUInt32(ctx, packetAddress + referenceOffset, out var reference32))
{
return false;
}
@@ -4711,7 +4834,7 @@ public static partial class AgcExports
bool tracePacket)
{
if (!TryParseSubmittedWait(
ctx, packetAddress, is64Bit, isStandard,
ctx, packetAddress, length, is64Bit, isStandard,
out var waitAddress, out var reference, out var mask, out var compareFunction,
out var controlValue))
{
@@ -10910,6 +11033,23 @@ 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;
@@ -11364,16 +11504,21 @@ public static partial class AgcExports
public static int DcbSetPredication(CpuContext ctx)
{
var dcb = ctx[CpuRegister.Rdi];
var address = ctx[CpuRegister.Rsi];
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];
if (dcb == 0)
{
return ReturnPointer(ctx, 0);
}
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)))
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)))
{
return ReturnPointer(ctx, 0);
}
@@ -11388,9 +11533,17 @@ public static partial class AgcExports
LibraryName = "libSceAgc")]
public static int SetPacketPredication(CpuContext ctx)
{
// Global predication toggle on a packet; a no-op is safe for rendering.
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
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);
}
// ABI (reversed from Quake): rdi = array of DCB base addresses (u64 each),
@@ -11565,7 +11718,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)
{
+157 -50
View File
@@ -1,6 +1,9 @@
// 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>
@@ -16,8 +19,11 @@ 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 class GnmTiling
internal static unsafe 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.
@@ -118,6 +124,14 @@ internal static 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;
@@ -404,64 +418,83 @@ internal static class GnmTiling
return false;
}
// 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.
// Address tables depend only on the swizzle equation and element size,
// so retain them across textures instead of rebuilding them per upload.
var hasExactXorPattern = TryGetExactXorPattern(swizzleMode, bppLog2, out var xorPattern);
var blockTable = hasExactXorPattern ? [] : new int[blockWidth * blockHeight];
for (var by = 0; !hasExactXorPattern && by < blockHeight; by++)
{
for (var bx = 0; bx < blockWidth; bx++)
{
blockTable[by * blockWidth + bx] = (int)(kind == SwizzleKind.ZOrder
? MortonInterleave((uint)bx, (uint)by, blockWidth, blockHeight)
: StandardSwizzleOffset((uint)bx, (uint)by, blockWidth, blockHeight));
}
}
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).
// Precompute the per-column X term once (reused across every row) so the
// inner loop drops from a 16-bit interleave with 32 PopCounts per element
// to a single array load and one XOR.
var xTermByColumn = hasExactXorPattern ? new int[elementsWide] : [];
for (var x = 0; hasExactXorPattern && x < elementsWide; x++)
// 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)
{
xTermByColumn[x] = (int)PatternAxisTerm((uint)x, xorPattern, useX: true);
}
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;
// Y term is constant across the row; hoist it out of the inner loop.
var yTerm = hasExactXorPattern ? (int)PatternAxisTerm((uint)y, xorPattern, useX: false) : 0;
for (var x = 0; x < elementsWide; x++)
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) =>
{
var blockX = x / blockWidth;
var inBlockX = x % blockWidth;
var blockIndex = rowBlockBase + blockX;
var sourceByte = hasExactXorPattern
? blockIndex * blockBytes + (xTermByColumn[x] ^ yTerm)
: (blockIndex * blockElements + blockTable[tableRowBase + inBlockX]) *
(long)bytesPerElement;
var destByte = destRowBase + (long)x * bytesPerElement;
if (sourceByte + bytesPerElement > tiled.Length ||
destByte + bytesPerElement > linear.Length)
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++)
{
continue;
}
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;
}
tiled.Slice((int)sourceByte, bytesPerElement)
.CopyTo(linear.Slice((int)destByte, bytesPerElement));
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);
}
else
{
for (var y = 0; y < elementsHigh; y++)
{
detileRow(y);
}
}
}
@@ -474,6 +507,80 @@ internal static 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);
+39
View File
@@ -343,6 +343,45 @@ 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)
+20 -3
View File
@@ -1131,16 +1131,18 @@ public static class AvPlayerExports
}
}
private static string? FindFfmpeg() =>
internal static string? FindFfmpeg() =>
FindFfmpeg(
Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH"),
Environment.GetEnvironmentVariable("PATH"),
OperatingSystem.IsWindows());
OperatingSystem.IsWindows(),
AppContext.BaseDirectory);
internal static string? FindFfmpeg(
string? configured,
string? searchPath,
bool isWindows)
bool isWindows,
string? baseDirectory = null)
{
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
{
@@ -1148,6 +1150,21 @@ 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))
{
+536 -86
View File
@@ -18,15 +18,46 @@ 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 _usingDummyMovie;
private static bool _frameBufferPresented;
private static BinkFramePlayback? _playback;
private static long _frameSerial;
private static bool _loadAttempted;
private static bool _availabilityReported;
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);
}
}
/// <summary>
/// Returns true only when movie skipping was explicitly requested. Without
@@ -37,109 +68,106 @@ internal static class Bink2MovieBridge
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
ResolveMode() == MovieMode.Skip;
internal static void ObserveGuestMovie(string hostPath)
/// <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)
{
if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
!File.Exists(hostPath))
{
return;
return false;
}
lock (Gate)
{
if (string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
{
return;
return _playback is not null || _frameBuffer is not null;
}
var mode = ResolveMode();
if (mode == MovieMode.Dummy)
if (mode is MovieMode.Guest or MovieMode.Skip)
{
AttachDummyMovieLocked(hostPath);
return;
return false;
}
if (mode != MovieMode.Native)
if (_playback is not null || _frameBuffer is not null)
{
return;
if (PendingMoviePathSet.Add(hostPath))
{
PendingMoviePaths.Enqueue(hostPath);
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge queued: " +
Path.GetFileName(hostPath));
}
return PendingMoviePathSet.Contains(hostPath);
}
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.");
AttachMovieLocked(hostPath, mode);
return string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase) &&
(_playback is not null || _frameBuffer is not null);
}
}
internal static bool TryDecodeNextFrame(
bool advanceClock,
out byte[] pixels,
out uint width,
out uint height)
out uint height,
out bool advanced,
out long frameSerial,
out string hostPath)
{
lock (Gate)
{
pixels = [];
width = 0;
height = 0;
if (_adapter is null || _activeMovie == IntPtr.Zero || _frameBuffer is null)
advanced = false;
frameSerial = _frameSerial;
hostPath = _activePath ?? string.Empty;
if (_playback is not null)
{
if (_usingDummyMovie && _frameBuffer is not null)
if (!_playback.TryGetFrame(advanceClock, out pixels, out advanced))
{
pixels = _frameBuffer;
width = _activeInfo.Width;
height = _activeInfo.Height;
return true;
if (_playback.IsFinished)
{
var completedPath = _activePath;
CloseActiveLocked();
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge completed: " +
Path.GetFileName(completedPath));
AttachNextQueuedMovieLocked();
}
return false;
}
return false;
width = _activeInfo.Width;
height = _activeInfo.Height;
if (advanced)
{
frameSerial = ++_frameSerial;
}
return true;
}
unsafe
if (_frameBuffer is null)
{
fixed (byte* destination = _frameBuffer)
{
if (!_adapter.DecodeNextBgra(
_activeMovie,
(IntPtr)destination,
_activeInfo.Width * 4,
(uint)_frameBuffer.Length))
{
return false;
}
}
return false;
}
pixels = _frameBuffer;
width = _activeInfo.Width;
height = _activeInfo.Height;
advanced = !_frameBufferPresented;
_frameBufferPresented = true;
if (advanced)
{
frameSerial = ++_frameSerial;
}
return true;
}
}
@@ -152,6 +180,63 @@ 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)
{
var adapter = GetAdapterLocked();
if (adapter is null)
{
return;
}
CloseActiveLocked();
if (!adapter.TryOpen(
hostPath,
_presentationWidth,
_presentationHeight,
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;
}
AttachPlaybackLocked(
hostPath,
info,
new NativeFrameDecoder(adapter, movie, info));
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");
@@ -170,15 +255,23 @@ internal static class Bink2MovieBridge
return MovieMode.Skip;
}
// 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))
if (string.Equals(configured, "guest", StringComparison.OrdinalIgnoreCase))
{
return MovieMode.Native;
return MovieMode.Guest;
}
return MovieMode.Guest;
if (string.Equals(configured, "ffmpeg", StringComparison.OrdinalIgnoreCase))
{
return MovieMode.Ffmpeg;
}
// Native is the default: the bridge ships embedded in the published
// single-file executable (see SharpEmu.CLI.csproj), so it isn't a
// loose file next to the exe to probe for with File.Exists here.
// GetAdapterLocked() degrades gracefully (falls back to the guest's
// own decode, logging one informational line) if it's genuinely
// unavailable, so defaulting to Native unconditionally is safe.
return MovieMode.Native;
}
private static void AttachDummyMovieLocked(string hostPath)
@@ -195,22 +288,61 @@ 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 bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
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)
{
info = default;
Span<byte> header = stackalloc byte[32];
Span<byte> header = stackalloc byte[36];
try
{
using var stream = File.OpenRead(path);
if (stream.Read(header) != header.Length ||
!header[..4].SequenceEqual("KB2j"u8))
stream.ReadExactly(header);
if (!header[..3].SequenceEqual("KB2"u8))
{
return false;
}
@@ -219,10 +351,11 @@ internal static class Bink2MovieBridge
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x14, 4)),
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x18, 4)),
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x1C, 4)),
1);
return true;
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x20, 4)));
return info.FramesPerSecondNumerator != 0 &&
info.FramesPerSecondDenominator != 0;
}
catch (IOException)
catch (Exception exception) when (exception is IOException or EndOfStreamException)
{
return false;
}
@@ -252,6 +385,26 @@ internal static class Bink2MovieBridge
}
_loadAttempted = true;
// Assembly-relative resolution participates in the single-file
// bundle's native-library extraction, so it finds the bridge whether
// it was embedded in the publish or sits as a loose file next to the
// executable, without us needing to know which. Skipped when the env
// override is set so that override still takes priority below.
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE")) &&
NativeLibrary.TryLoad(
"sharpemu_bink2_bridge", typeof(Bink2MovieBridge).Assembly, null, out var bundledLibrary))
{
if (NativeAdapter.TryCreate(bundledLibrary, out var bundledAdapter))
{
_adapter = bundledAdapter;
Console.Error.WriteLine("[LOADER][INFO] Bink2 bridge loaded (bundled).");
return bundledAdapter;
}
NativeLibrary.Free(bundledLibrary);
}
foreach (var candidate in EnumerateAdapterCandidates())
{
if (!NativeLibrary.TryLoad(candidate, out var library))
@@ -304,20 +457,20 @@ internal static class Bink2MovieBridge
private static void CloseActiveLocked()
{
if (_activeMovie != IntPtr.Zero)
{
_adapter?.Close(_activeMovie);
}
_playback?.Dispose();
_playback = null;
_activePath = null;
_activeMovie = IntPtr.Zero;
_activeInfo = default;
_frameBuffer = null;
_usingDummyMovie = false;
_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);
}
[StructLayout(LayoutKind.Sequential)]
private readonly struct Bink2MovieInfo
internal readonly struct Bink2MovieInfo
{
public readonly uint Width;
public readonly uint Height;
@@ -343,13 +496,68 @@ internal static class Bink2MovieBridge
Skip,
Dummy,
Native,
Ffmpeg,
}
private sealed class NativeFrameDecoder : IBinkFrameDecoder
{
private readonly NativeAdapter _adapter;
private readonly IntPtr _movie;
private int _disposed;
internal NativeFrameDecoder(NativeAdapter adapter, IntPtr movie, Bink2MovieInfo info)
{
_adapter = adapter;
_movie = movie;
Width = info.Width;
Height = info.Height;
FramesPerSecondNumerator = info.FramesPerSecondNumerator;
FramesPerSecondDenominator = info.FramesPerSecondDenominator;
}
public uint Width { get; }
public uint Height { get; }
public uint FramesPerSecondNumerator { get; }
public uint FramesPerSecondDenominator { get; }
public unsafe bool TryDecodeNextFrame(Span<byte> destination)
{
fixed (byte* pointer = destination)
{
return _adapter.DecodeNextBgra(
_movie,
(IntPtr)pointer,
Width * 4,
checked((uint)destination.Length));
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0)
{
_adapter.Close(_movie);
}
}
}
private sealed class NativeAdapter
{
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int OpenUtf8Delegate(IntPtr pathUtf8, out IntPtr movie, out Bink2MovieInfo info);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int OpenScaledUtf8Delegate(
IntPtr pathUtf8,
uint maximumWidth,
uint maximumHeight,
out IntPtr movie,
out Bink2MovieInfo info);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int DecodeNextBgraDelegate(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes);
@@ -357,15 +565,18 @@ internal static class Bink2MovieBridge
private delegate void CloseDelegate(IntPtr movie);
private readonly OpenUtf8Delegate _openUtf8;
private readonly OpenScaledUtf8Delegate? _openScaledUtf8;
private readonly DecodeNextBgraDelegate _decodeNextBgra;
private readonly CloseDelegate _close;
private NativeAdapter(
OpenUtf8Delegate openUtf8,
OpenScaledUtf8Delegate? openScaledUtf8,
DecodeNextBgraDelegate decodeNextBgra,
CloseDelegate close)
{
_openUtf8 = openUtf8;
_openScaledUtf8 = openScaledUtf8;
_decodeNextBgra = decodeNextBgra;
_close = close;
}
@@ -380,19 +591,42 @@ internal static class Bink2MovieBridge
return false;
}
OpenScaledUtf8Delegate? openScaled = null;
if (NativeLibrary.TryGetExport(
library,
"sharpemu_bink2_open_scaled_utf8",
out var scaledOpen))
{
openScaled = Marshal.GetDelegateForFunctionPointer<OpenScaledUtf8Delegate>(scaledOpen);
}
adapter = new NativeAdapter(
Marshal.GetDelegateForFunctionPointer<OpenUtf8Delegate>(open),
openScaled,
Marshal.GetDelegateForFunctionPointer<DecodeNextBgraDelegate>(decode),
Marshal.GetDelegateForFunctionPointer<CloseDelegate>(close));
return true;
}
internal bool TryOpen(string path, out IntPtr movie, out Bink2MovieInfo info)
internal bool TryOpen(
string path,
uint maximumWidth,
uint maximumHeight,
out IntPtr movie,
out Bink2MovieInfo info)
{
var utf8 = Marshal.StringToCoTaskMemUTF8(path);
try
{
return _openUtf8(utf8, out movie, out info) != 0 && movie != IntPtr.Zero;
var result = _openScaledUtf8 is not null
? _openScaledUtf8(
utf8,
maximumWidth,
maximumHeight,
out movie,
out info)
: _openUtf8(utf8, out movie, out info);
return result != 0 && movie != IntPtr.Zero;
}
finally
{
@@ -405,4 +639,220 @@ internal static class Bink2MovieBridge
internal void Close(IntPtr movie) => _close(movie);
}
private static readonly Queue<string> PendingMoviePaths = new();
private static readonly HashSet<string> PendingMoviePathSet =
new(StringComparer.OrdinalIgnoreCase);
private static void AttachNextQueuedMovieLocked()
{
while (PendingMoviePaths.Count > 0)
{
var path = PendingMoviePaths.Dequeue();
PendingMoviePathSet.Remove(path);
if (!File.Exists(path))
{
continue;
}
AttachMovieLocked(path, ResolveMode());
if (_playback is not null || _frameBuffer is not null)
{
return;
}
}
}
// 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)
{
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))
{
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);
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)
{
_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)
{
return false;
}
BinaryPrimitives.WriteUInt32LittleEndian(
bytes.Slice((int)relativeOffset, sizeof(uint)),
value);
return true;
}
}
}
+246
View File
@@ -0,0 +1,246 @@
// 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);
}
@@ -0,0 +1,166 @@
// 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();
}
}
}
@@ -97,6 +97,9 @@ 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();
@@ -268,13 +271,13 @@ public static partial class KernelMemoryCompatExports
}
_nextVirtualAddress = Math.Max(_nextVirtualAddress, address + mappedLength);
_mappedRegions[address] = new MappedRegion(
ReplaceMappedRegionRangeLocked(new MappedRegion(
address,
mappedLength,
OrbisProtCpuReadWrite,
IsFlexible: false,
IsDirect: false,
DirectStart: 0);
DirectStart: 0));
}
for (ulong offset = 0; offset < mappedLength;)
@@ -300,13 +303,13 @@ public static partial class KernelMemoryCompatExports
lock (_memoryGate)
{
_mappedRegions[address] = new MappedRegion(
ReplaceMappedRegionRangeLocked(new MappedRegion(
address,
length,
Protection: 0,
IsFlexible: false,
IsDirect: false,
DirectStart: 0);
DirectStart: 0));
}
}
@@ -1468,6 +1471,14 @@ 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}");
@@ -1516,13 +1527,22 @@ public static partial class KernelMemoryCompatExports
lock (_fdGate)
{
_openFiles[fd] = stream;
if (useBinkCompletionShim)
{
_binkGuestCompletionShims[fd] = binkCompletionShim;
}
if (observedBinkMovie)
{
_observedBinkGuestFiles[fd] = hostPath;
}
}
// 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 (useBinkCompletionShim)
{
LogOpenTrace(
"_open bink-host-shim path='" + guestPath + "' host='" + hostPath +
"' flags=0x" + flags.ToString("X8") + " fd=" + fd);
}
if (IsMutatingOpen(flags))
{
@@ -2053,10 +2073,21 @@ 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))
{
@@ -2069,6 +2100,10 @@ public static partial class KernelMemoryCompatExports
}
}
if (notifyBinkClose)
{
Bink2MovieBridge.NotifyGuestMovieClosed(observedBinkPath!);
}
stream.Dispose();
ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
@@ -2096,9 +2131,12 @@ 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)
@@ -2118,6 +2156,17 @@ 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;
@@ -3091,13 +3140,13 @@ public static partial class KernelMemoryCompatExports
}
_nextVirtualAddress = Math.Max(_nextVirtualAddress, mappedAddress + length);
_mappedRegions[mappedAddress] = new MappedRegion(
ReplaceMappedRegionRangeLocked(new MappedRegion(
mappedAddress,
length,
protection,
IsFlexible: false,
IsDirect: true,
DirectStart: directMemoryStart);
DirectStart: directMemoryStart));
}
if (!ctx.TryWriteUInt64(inOutAddressPointer, mappedAddress))
@@ -3183,13 +3232,13 @@ public static partial class KernelMemoryCompatExports
_nextVirtualAddress = Math.Max(_nextVirtualAddress, mappedAddress + length);
_allocatedFlexibleBytes = Math.Min(FlexibleMemorySizeBytes, _allocatedFlexibleBytes + length);
_mappedRegions[mappedAddress] = new MappedRegion(
ReplaceMappedRegionRangeLocked(new MappedRegion(
mappedAddress,
length,
protection,
IsFlexible: true,
IsDirect: false,
DirectStart: 0);
DirectStart: 0));
}
if (!ctx.TryWriteUInt64(inOutAddressPointer, mappedAddress))
@@ -2230,7 +2230,6 @@ internal static unsafe class VulkanVideoPresenter
if (IsGuestWorkCompletedLocked(pending.RequiredGuestWorkSequence))
{
presentation = _pendingGuestImagePresentations.Dequeue();
TryReplaceWithBinkFrame(ref presentation);
return true;
}
@@ -2263,29 +2262,10 @@ 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)
@@ -2835,6 +2815,40 @@ 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;
@@ -3027,6 +3041,9 @@ 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;
@@ -4328,6 +4345,7 @@ 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)
@@ -4474,6 +4492,7 @@ internal static unsafe class VulkanVideoPresenter
_presentationCommandBuffer = _commandBuffer;
CreateStagingBuffer((ulong)_extent.Width * _extent.Height * 4);
CreateFrameUploadBuffers(_stagingSize);
CreateOverlayResources();
}
@@ -6076,10 +6095,15 @@ 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 = ResolveTextureResource(texture);
var resolved = index == hostMovieTextures.Luma
? CreateHostMovieTextureResource(texture, plane: 0)
: index == hostMovieTextures.Chroma
? CreateHostMovieTextureResource(texture, plane: 1)
: ResolveTextureResource(texture);
var feedbackTarget = !texture.IsStorage
? feedbackTargets?.FirstOrDefault(target =>
ReferenceEquals(resolved.GuestImage, target))
@@ -6934,6 +6958,343 @@ 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)
{
@@ -9539,6 +9900,26 @@ 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);
@@ -9590,6 +9971,8 @@ internal static unsafe class VulkanVideoPresenter
return;
}
PumpHostMovieFrame();
if (_skipAllCompute ||
AddressListContains("SHARPEMU_SKIP_COMPUTE_CS", work.ShaderAddress) ||
(_skipTallComputeZ > 0 && work.GroupCountZ >= _skipTallComputeZ))
@@ -12476,10 +12859,12 @@ internal static unsafe class VulkanVideoPresenter
EvictDirtyCachedTextures();
var completedWork = 0;
HashSet<string>? deferredOrderedQueues = null;
var renderWorkDeadline = _renderWorkBudgetTicks > 0
? System.Diagnostics.Stopwatch.GetTimestamp() + _renderWorkBudgetTicks
var workBudgetTicks = _renderWorkBudgetTicks;
var renderWorkDeadline = workBudgetTicks > 0
? System.Diagnostics.Stopwatch.GetTimestamp() + workBudgetTicks
: long.MaxValue;
while (completedWork < _maxGuestWorkPerRender)
var workLimit = _maxGuestWorkPerRender;
while (completedWork < workLimit)
{
// Never block the macOS main thread waiting for in-flight GPU
// work to drain. If submission is at capacity (a slow-compute
@@ -12536,6 +12921,14 @@ 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:
@@ -12613,7 +13006,8 @@ internal static unsafe class VulkanVideoPresenter
FlushBatchedGuestCommands();
CollectAbandonedGuestImageVersions();
if (!TryTakePresentation(_presentedSequence, out var presentation))
Presentation presentation;
if (!TryTakePresentation(_presentedSequence, out presentation))
{
if (_pendingHostSplashReplay is { } splash)
{
@@ -12637,7 +13031,6 @@ internal static unsafe class VulkanVideoPresenter
return;
}
}
if (_hostSurface is not null)
{
if (presentation.IsSplash && presentation.Pixels is not null)
@@ -12690,6 +13083,7 @@ internal static unsafe class VulkanVideoPresenter
{
return;
}
}
TranslatedDrawResources? translatedResources = null;
@@ -12817,19 +13211,11 @@ internal static unsafe class VulkanVideoPresenter
if (pixels is not null)
{
// 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");
var mapped = (void*)_frameUploadMapped[frameSlot];
fixed (byte* source = pixels)
{
System.Buffer.MemoryCopy(source, mapped, pixels.Length, pixels.Length);
}
_vk.UnmapMemory(_device, _stagingMemory);
}
Check(_vk.ResetCommandBuffer(_commandBuffer, 0), "vkResetCommandBuffer");
@@ -12843,7 +13229,7 @@ internal static unsafe class VulkanVideoPresenter
PipelineStageFlags waitStage;
if (pixels is not null)
{
RecordUpload(imageIndex);
RecordUpload(imageIndex, frameSlot);
waitStage = PipelineStageFlags.TransferBit;
}
else if (presentation.DrawKind == GuestDrawKind.FullscreenBarycentric)
@@ -13365,11 +13751,22 @@ 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 = ImageLayout.Undefined,
OldLayout = texture.IsHostMovie && hostMovieImageInitialized
? ImageLayout.ShaderReadOnlyOptimal
: ImageLayout.Undefined,
NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
@@ -13378,7 +13775,9 @@ internal static unsafe class VulkanVideoPresenter
};
_vk.CmdPipelineBarrier(
_commandBuffer,
PipelineStageFlags.TopOfPipeBit,
texture.IsHostMovie && hostMovieImageInitialized
? PipelineStageFlags.AllCommandsBit
: PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.TransferBit,
0,
0,
@@ -13438,6 +13837,19 @@ 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;
}
}
}
}
@@ -14751,7 +15163,176 @@ internal static unsafe class VulkanVideoPresenter
}
}
private void RecordUpload(uint imageIndex)
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)
{
var oldLayout = _imageInitialized[imageIndex]
? ImageLayout.PresentSrcKhr
@@ -14793,7 +15374,7 @@ internal static unsafe class VulkanVideoPresenter
};
_vk.CmdCopyBufferToImage(
_commandBuffer,
_stagingBuffer,
_frameUploadBuffers[frameSlot],
_swapchainImages[imageIndex],
ImageLayout.TransferDstOptimal,
1,
@@ -15661,7 +16242,26 @@ 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);
@@ -0,0 +1,93 @@
// 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));
}
}
@@ -0,0 +1,61 @@
// 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);
}
}
@@ -0,0 +1,133 @@
// 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));
}
}
@@ -37,8 +37,12 @@ public sealed class GnmTilingDetileTests
return offset;
}
[Fact]
public void TryDetile_ExactXorMode27_MatchesReferenceAddressEquation()
[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;
@@ -46,8 +50,6 @@ public sealed class GnmTilingDetileTests
// SquareBlockDimensions(32768 elements): 15 bits split 8/7, x favored.
const int blockWidth = 256;
const int blockHeight = 128;
const int elementsWide = 384; // spans two block columns and a partial third
const int elementsHigh = 200;
var blocksPerRow = (elementsWide + blockWidth - 1) / blockWidth;
var blocksPerColumn = (elementsHigh + blockHeight - 1) / blockHeight;
@@ -99,6 +99,25 @@ 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()
{
@@ -0,0 +1,79 @@
// 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);
}
}
@@ -0,0 +1,105 @@
// 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()
{
}
}
}
@@ -301,6 +301,38 @@ 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()
{