From f704586a8d7c338503d4c47a17fbd99bdfa40949 Mon Sep 17 00:00:00 2001 From: Berk Date: Wed, 22 Jul 2026 21:41:41 +0300 Subject: [PATCH] [VideoOut] Add Bink2 support via FFMPEG bridge (#527) * [VideoOut] Add Bink2 support via FFMPEG bridge * [CMake] update commit * [CMake] update commit --- README.md | 15 +- docs/bink2-bridge.md | 46 +- native/bink2-bridge/CMakeLists.txt | 128 ++++ native/bink2-bridge/sharpemu_bink2_bridge.c | 324 +++++++-- src/SharpEmu.CLI/SharpEmu.CLI.csproj | 39 + src/SharpEmu.Libs/Agc/AgcExports.cs | 255 +++++-- src/SharpEmu.Libs/Agc/GnmTiling.cs | 207 ++++-- src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs | 39 + src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs | 23 +- src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs | 622 +++++++++++++--- src/SharpEmu.Libs/Bink/BinkFramePlayback.cs | 246 +++++++ .../Bink/FfmpegBinkFrameSource.cs | 166 +++++ .../Kernel/KernelMemoryCompatExports.cs | 75 +- .../VideoOut/VulkanVideoPresenter.cs | 680 ++++++++++++++++-- .../Agc/AgcPredicationTests.cs | 93 +++ .../Agc/AgcResourceOwnerTests.cs | 61 ++ .../Agc/AgcWaitRegMemTests.cs | 133 ++++ .../Agc/GnmTilingDetileTests.cs | 10 +- .../AvPlayer/AvPlayerPathTests.cs | 19 + .../Bink/Bink2MovieBridgeTests.cs | 79 ++ .../Bink/BinkFramePlaybackTests.cs | 105 +++ .../Kernel/KernelMemoryCompatExportsTests.cs | 32 + 22 files changed, 3087 insertions(+), 310 deletions(-) create mode 100644 native/bink2-bridge/CMakeLists.txt create mode 100644 src/SharpEmu.Libs/Bink/BinkFramePlayback.cs create mode 100644 src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs create mode 100644 tests/SharpEmu.Libs.Tests/Agc/AgcPredicationTests.cs create mode 100644 tests/SharpEmu.Libs.Tests/Agc/AgcResourceOwnerTests.cs create mode 100644 tests/SharpEmu.Libs.Tests/Agc/AgcWaitRegMemTests.cs create mode 100644 tests/SharpEmu.Libs.Tests/Bink/Bink2MovieBridgeTests.cs create mode 100644 tests/SharpEmu.Libs.Tests/Bink/BinkFramePlaybackTests.cs diff --git a/README.md b/README.md index bec4c8f4..96d6f1f2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/bink2-bridge.md b/docs/bink2-bridge.md index e2ea7369..5dbf25f2 100644 --- a/docs/bink2-bridge.md +++ b/docs/bink2-bridge.md @@ -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. diff --git a/native/bink2-bridge/CMakeLists.txt b/native/bink2-bridge/CMakeLists.txt new file mode 100644 index 00000000..424b0b73 --- /dev/null +++ b/native/bink2-bridge/CMakeLists.txt @@ -0,0 +1,128 @@ +# 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 "6a6861e") +set(SHARPEMU_FFMPEG_COMMIT "6a6861e357a263edee51d2f4894941f50aed59f5") +# Keyed by tag so bumping SHARPEMU_FFMPEG_TAG always fetches fresh instead of +# silently reusing a previous tag's cached download. +set(SHARPEMU_FFMPEG_ROOT "${CMAKE_BINARY_DIR}/ffmpeg-core-${SHARPEMU_FFMPEG_TAG}") + +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$<$: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() diff --git a/native/bink2-bridge/sharpemu_bink2_bridge.c b/native/bink2-bridge/sharpemu_bink2_bridge.c index 43cb6ef8..1c503600 100644 --- a/native/bink2-bridge/sharpemu_bink2_bridge.c +++ b/native/bink2-bridge/sharpemu_bink2_bridge.c @@ -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 #include -#include "bink.h" +#include +#include + +#include +#include +#include +#include + +#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); } diff --git a/src/SharpEmu.CLI/SharpEmu.CLI.csproj b/src/SharpEmu.CLI/SharpEmu.CLI.csproj index 7b272ca5..8a6165fe 100644 --- a/src/SharpEmu.CLI/SharpEmu.CLI.csproj +++ b/src/SharpEmu.CLI/SharpEmu.CLI.csproj @@ -87,4 +87,43 @@ SPDX-License-Identifier: GPL-2.0-or-later + + + $(BaseIntermediateOutputPath)bink2-bridge/$(RuntimeIdentifier) + sharpemu_bink2_bridge.dll + libsharpemu_bink2_bridge.so + libsharpemu_bink2_bridge.dylib + $(Bink2BridgeBuildDir)/out/$(Bink2BridgeFileName) + + + + + + + + + + + + + $(Bink2BridgeFileName) + + + + diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs index fbd07ced..1cc6ed8c 100644 --- a/src/SharpEmu.Libs/Agc/AgcExports.cs +++ b/src/SharpEmu.Libs/Agc/AgcExports.cs @@ -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 _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 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) { diff --git a/src/SharpEmu.Libs/Agc/GnmTiling.cs b/src/SharpEmu.Libs/Agc/GnmTiling.cs index 6f93bb2e..d0d1beef 100644 --- a/src/SharpEmu.Libs/Agc/GnmTiling.cs +++ b/src/SharpEmu.Libs/Agc/GnmTiling.cs @@ -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; /// @@ -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. /// -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 _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(source)); + break; + case 4: + Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned(source)); + break; + case 8: + Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned(source)); + break; + case 16: + Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned(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); diff --git a/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs b/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs index abbb3efc..33ef58c7 100644 --- a/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs +++ b/src/SharpEmu.Libs/Agc/GpuWaitRegistry.cs @@ -343,6 +343,45 @@ internal static class GpuWaitRegistry return expired; } + public static List? CollectAllForMemory(object memory) + { + List? collected = null; + lock (_gate) + { + List? 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(); + collected.Add(list[index]); + list.RemoveAt(index); + } + + if (list.Count == 0) + { + emptied ??= new List(); + emptied.Add(address); + } + } + + if (emptied is not null) + { + foreach (var address in emptied) + { + _waiters.Remove(address); + } + } + } + + return collected; + } + /// Records the value a label producer wrote, for the deadlock /// breaker. Also latches any already-waiting waiter it satisfies. public static bool RecordProduced(object memory, ulong address, ulong value) diff --git a/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs b/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs index 8672031e..a92983df 100644 --- a/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs +++ b/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs @@ -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)) { diff --git a/src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs b/src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs index 21699847..c185af2b 100644 --- a/src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs +++ b/src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs @@ -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); + } + } /// /// 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) + /// + /// Starts or queues host decoding. Decoded frames are only exposed as a + /// sampled guest texture; presentation and UI composition remain guest-owned. + /// + 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(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(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 header = stackalloc byte[32]; + Span 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 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(scaledOpen); + } + adapter = new NativeAdapter( Marshal.GetDelegateForFunctionPointer(open), + openScaled, Marshal.GetDelegateForFunctionPointer(decode), Marshal.GetDelegateForFunctionPointer(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 PendingMoviePaths = new(); + private static readonly HashSet 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; + /// + /// Blocks the calling (guest I/O) thread until the host has actually + /// finished presenting — 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. + /// + 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 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 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; + } + + /// + /// 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. + /// + internal bool Patch(long fileOffset, Span 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 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; + } + } } diff --git a/src/SharpEmu.Libs/Bink/BinkFramePlayback.cs b/src/SharpEmu.Libs/Bink/BinkFramePlayback.cs new file mode 100644 index 00000000..aa0600f4 --- /dev/null +++ b/src/SharpEmu.Libs/Bink/BinkFramePlayback.cs @@ -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 destination); +} + +/// +/// Keeps blocking codec work away from the Vulkan presentation thread and +/// releases decoded frames according to the movie time base. +/// +internal sealed class BinkFramePlayback : IDisposable +{ + private const int BufferCount = 5; + + private readonly object _gate = new(); + private readonly IBinkFrameDecoder _decoder; + private readonly Queue _freeBuffers = new(); + private readonly Queue _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(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); +} diff --git a/src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs b/src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs new file mode 100644 index 00000000..f2aae6d9 --- /dev/null +++ b/src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs @@ -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 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(); + } + } +} diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs index e948d43a..2b1461d6 100644 --- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs +++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs @@ -97,6 +97,9 @@ public static partial class KernelMemoryCompatExports private static readonly object _fdGate = new(); private static readonly Dictionary _openFiles = new(); + private static readonly Dictionary + _binkGuestCompletionShims = new(); + private static readonly Dictionary _observedBinkGuestFiles = new(); private static readonly Dictionary _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(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)) diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs index 4ebcc487..71e2d359 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs @@ -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 _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 _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 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 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(width * height); + } + if (_hostMovieChromaPixels?.Length != chromaWidth * chromaHeight * 2) + { + _hostMovieChromaPixels = + GC.AllocateUninitializedArray(chromaWidth * chromaHeight * 2); + } + + ConvertBgraToYuv420( + bgra, + width, + height, + _hostMovieLumaPixels, + _hostMovieChromaPixels); + _hostMovieConvertedFrameSerial = _hostMovieFrameSerial; + } + + internal static void ConvertBgraToYuv420( + ReadOnlySpan bgra, + int width, + int height, + Span luma, + Span 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? 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); diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcPredicationTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcPredicationTests.cs new file mode 100644 index 00000000..89bb93bc --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Agc/AgcPredicationTests.cs @@ -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 buffer = stackalloc byte[4]; + Assert.True(memory.TryRead(address, buffer)); + return BinaryPrimitives.ReadUInt32LittleEndian(buffer); + } + + private static ulong ReadUInt64(FakeCpuMemory memory, ulong address) + { + Span 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 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 buffer = stackalloc byte[8]; + BinaryPrimitives.WriteUInt64LittleEndian(buffer, value); + Assert.True(memory.TryWrite(address, buffer)); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcResourceOwnerTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcResourceOwnerTests.cs new file mode 100644 index 00000000..aac94fcb --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Agc/AgcResourceOwnerTests.cs @@ -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 buffer = stackalloc byte[4]; + Assert.True(memory.TryRead(address, buffer)); + return BinaryPrimitives.ReadUInt32LittleEndian(buffer); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Agc/AgcWaitRegMemTests.cs b/tests/SharpEmu.Libs.Tests/Agc/AgcWaitRegMemTests.cs new file mode 100644 index 00000000..07efa6c4 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Agc/AgcWaitRegMemTests.cs @@ -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 buffer = stackalloc byte[4]; + Assert.True(memory.TryRead(address, buffer)); + return BinaryPrimitives.ReadUInt32LittleEndian(buffer); + } + + private static ulong ReadUInt64(FakeCpuMemory memory, ulong address) + { + Span 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 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 buffer = stackalloc byte[8]; + BinaryPrimitives.WriteUInt64LittleEndian(buffer, value); + Assert.True(memory.TryWrite(address, buffer)); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Agc/GnmTilingDetileTests.cs b/tests/SharpEmu.Libs.Tests/Agc/GnmTilingDetileTests.cs index 0e694fd1..b517f53c 100644 --- a/tests/SharpEmu.Libs.Tests/Agc/GnmTilingDetileTests.cs +++ b/tests/SharpEmu.Libs.Tests/Agc/GnmTilingDetileTests.cs @@ -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; diff --git a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerPathTests.cs b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerPathTests.cs index 30cb0f01..8c55abff 100644 --- a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerPathTests.cs +++ b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerPathTests.cs @@ -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() { diff --git a/tests/SharpEmu.Libs.Tests/Bink/Bink2MovieBridgeTests.cs b/tests/SharpEmu.Libs.Tests/Bink/Bink2MovieBridgeTests.cs new file mode 100644 index 00000000..ca51f9bc --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Bink/Bink2MovieBridgeTests.cs @@ -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 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); + } +} diff --git a/tests/SharpEmu.Libs.Tests/Bink/BinkFramePlaybackTests.cs b/tests/SharpEmu.Libs.Tests/Bink/BinkFramePlaybackTests.cs new file mode 100644 index 00000000..271a5c87 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/Bink/BinkFramePlaybackTests.cs @@ -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 destination) + { + if (_index >= values.Length) + { + return false; + } + + destination.Fill(values[_index++]); + return true; + } + + public void Dispose() + { + } + } +} diff --git a/tests/SharpEmu.Libs.Tests/Kernel/KernelMemoryCompatExportsTests.cs b/tests/SharpEmu.Libs.Tests/Kernel/KernelMemoryCompatExportsTests.cs index 203b9702..f1662443 100644 --- a/tests/SharpEmu.Libs.Tests/Kernel/KernelMemoryCompatExportsTests.cs +++ b/tests/SharpEmu.Libs.Tests/Kernel/KernelMemoryCompatExportsTests.cs @@ -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() {