[VideoOut] Add Bink2 support via FFMPEG bridge (#527)

* [VideoOut] Add Bink2 support via FFMPEG bridge

* [CMake] update commit

* [CMake] update commit
This commit is contained in:
Berk
2026-07-22 21:41:41 +03:00
committed by GitHub
parent d3600c9255
commit f704586a8d
22 changed files with 3087 additions and 310 deletions
+11 -4
View File
@@ -108,10 +108,17 @@ release includes the MoltenVK Vulkan implementation.
## Build ## Build
1. Install the .NET SDK version specified in [`global.json`](./global.json). 1. Install the .NET SDK version specified in [`global.json`](./global.json).
2. Clone the repository: `git clone https://github.com/sharpemu/sharpemu.git` 2. `dotnet publish` also builds the Bink 2 bridge
3. Open the solution file (`SharpEmu.slnx`) in **VSCode**. (`native/bink2-bridge/sharpemu_bink2_bridge.c`) from source, so also
4. Build the project: `dotnet build` or `dotnet publish` install:
5. Build artifacts will be located in the `artifacts` directory. * **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 ## Disclaimer
+32 -14
View File
@@ -14,31 +14,49 @@ available, presents its decoded BGRA frames at the normal guest-flip boundary.
This preserves the game's own timing and lets the host Vulkan presenter display This preserves the game's own timing and lets the host Vulkan presenter display
the movie without trying to execute the PS5-specific Bink GPU decode path. the 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 The default path decodes through the bundled FFmpeg-backed native bridge
statically linked decoder runs normally. Set SHARPEMU_BINK_MODE=skip only when (`native/bink2-bridge/sharpemu_bink2_bridge.c`); see "Supplying the adapter"
explicitly testing a title whose cinematics are optional. 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, 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 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 only; it does not decode the movie or alter its game logic.
SHARPEMU_BINK_MODE=native to force native bridge mode. 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 ## Supplying the adapter
Bink 2 is proprietary. Obtain a compatible Mac Bink 2 SDK from RAD Game Tools, The adapter (`native/bink2-bridge/sharpemu_bink2_bridge.c`) links against a
then compile sharpemu_bink2_bridge.c against the SDK's bink.h and Mac library. custom FFmpeg build (`github.com/sharpemu/ffmpeg-core`, LGPL-2.1) that adds a
The adapter deliberately contains only a three-function C ABI so the managed Bink 2 decoder to FFmpeg 7.1.2; no proprietary RAD SDK is needed to build or
emulator never depends on RAD's private binary ABI. run SharpEmu.
Place the resulting libsharpemu_bink2_bridge.dylib next to the SharpEmu `dotnet publish` builds it from source with CMake + Ninja + clang-cl (Windows)
executable, or point to it explicitly: 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 ./SharpEmu /path/to/eboot.bin
The expected exports are sharpemu_bink2_open_utf8, The expected exports are sharpemu_bink2_open_utf8,
sharpemu_bink2_decode_next_bgra, and sharpemu_bink2_close. The supplied sharpemu_bink2_open_scaled_utf8, sharpemu_bink2_decode_next_bgra, and
adapter opens one movie, exposes BGRA pixels, and advances after each decoded 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 frame. The managed side validates dimensions and retains ownership of the
destination buffer. destination buffer.
+128
View File
@@ -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$<$<CONFIG:Debug>:Debug>")
target_link_libraries(sharpemu_bink2_bridge PRIVATE
bcrypt ole32 oleaut32 psapi shlwapi strmiids user32 uuid ws2_32)
elseif(APPLE)
target_link_libraries(sharpemu_bink2_bridge PRIVATE
"-framework AppKit"
"-framework AudioToolbox"
"-framework CoreAudio"
"-framework CoreFoundation"
"-framework CoreMedia"
"-framework CoreServices"
"-framework CoreVideo"
"-framework Security"
"-framework VideoToolbox")
else()
target_link_libraries(sharpemu_bink2_bridge PRIVATE dl m pthread)
endif()
set_target_properties(sharpemu_bink2_bridge PROPERTIES
C_VISIBILITY_PRESET hidden
VISIBILITY_INLINES_HIDDEN YES
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/out"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/out")
foreach(configuration Debug Release RelWithDebInfo MinSizeRel)
string(TOUPPER "${configuration}" configuration_upper)
set_target_properties(sharpemu_bink2_bridge PROPERTIES
LIBRARY_OUTPUT_DIRECTORY_${configuration_upper} "${CMAKE_BINARY_DIR}/out"
RUNTIME_OUTPUT_DIRECTORY_${configuration_upper} "${CMAKE_BINARY_DIR}/out")
endforeach()
+279 -45
View File
@@ -1,66 +1,300 @@
/* /*
* Copyright (C) 2026 SharpEmu Emulator Project * Copyright (C) 2026 SharpEmu Emulator Project
* SPDX-License-Identifier: GPL-2.0-or-later * SPDX-License-Identifier: GPL-2.0-or-later
*
* Build this small adapter with a licensed RAD Bink 2 SDK. The SDK and its
* headers are not distributed by SharpEmu. See docs/bink2-bridge.md.
*/ */
#include <errno.h>
#include <stdint.h> #include <stdint.h>
#include "bink.h" #include <stdio.h>
#include <stdlib.h>
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/error.h>
#include <libswscale/swscale.h>
#if defined(_WIN32)
#define SHARPEMU_EXPORT __declspec(dllexport)
#else
#define SHARPEMU_EXPORT __attribute__((visibility("default")))
#endif
typedef struct sharpemu_bink2_info { typedef struct sharpemu_bink2_info {
uint32_t width; uint32_t width;
uint32_t height; uint32_t height;
uint32_t frames_per_second_numerator; uint32_t frames_per_second_numerator;
uint32_t frames_per_second_denominator; uint32_t frames_per_second_denominator;
} sharpemu_bink2_info; } sharpemu_bink2_info;
int sharpemu_bink2_open_utf8(const char *path, HBINK *movie, sharpemu_bink2_info *info) { typedef struct sharpemu_bink2_movie {
HBINK bink; AVFormatContext *format;
if (!path || !movie || !info) return 0; 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); static void sharpemu_bink2_destroy(sharpemu_bink2_movie *movie) {
if (!bink) return 0; if (!movie) {
return;
}
if (bink->Width == 0 || bink->Height == 0) { sws_freeContext(movie->converter);
BinkClose(bink); 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; return 0;
} }
*movie = bink; *movie_out = NULL;
info->width = bink->Width; movie = (sharpemu_bink2_movie *)calloc(1, sizeof(*movie));
info->height = bink->Height; if (!movie) {
info->frames_per_second_numerator = bink->FrameRate; return 0;
info->frames_per_second_denominator = bink->FrameRateDiv; }
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; return 1;
} }
int sharpemu_bink2_decode_next_bgra(HBINK movie, uint8_t *destination, SHARPEMU_EXPORT int sharpemu_bink2_open_utf8(
uint32_t stride, uint32_t destination_bytes) { const char *path,
uint64_t needed; void **movie_out,
uint64_t min_stride; sharpemu_bink2_info *info) {
return sharpemu_bink2_open_internal(path, 0, 0, movie_out, info);
if (!movie || !destination) return 0;
min_stride = (uint64_t)movie->Width * 4;
if ((uint64_t)stride < min_stride) return 0;
needed = (uint64_t)stride * movie->Height;
if (needed > destination_bytes) return 0;
/* Async Bink I/O has not filled the next frame yet; retry on the next host present. */
if (BinkWait(movie)) return 0;
if (!BinkDoFrame(movie)) return 0;
if (!BinkCopyToBuffer(movie, destination, stride, movie->Height, 0, 0, BINKSURFACE32RA)) return 0;
BinkNextFrame(movie);
return 1;
} }
void sharpemu_bink2_close(HBINK movie) { SHARPEMU_EXPORT int sharpemu_bink2_open_scaled_utf8(
if (movie) BinkClose(movie); const char *path,
uint32_t maximum_width,
uint32_t maximum_height,
void **movie_out,
sharpemu_bink2_info *info) {
return sharpemu_bink2_open_internal(
path, maximum_width, maximum_height, movie_out, info);
}
static int sharpemu_bink2_receive_frame(sharpemu_bink2_movie *movie) {
int result;
for (;;) {
result = avcodec_receive_frame(movie->codec, movie->frame);
if (result >= 0) {
return 1;
}
if (result == AVERROR_EOF) {
return 0;
}
if (result != AVERROR(EAGAIN)) {
sharpemu_bink2_log_error("decode", result);
return 0;
}
if (movie->draining) {
return 0;
}
for (;;) {
result = av_read_frame(movie->format, movie->packet);
if (result < 0) {
movie->draining = 1;
result = avcodec_send_packet(movie->codec, NULL);
if (result < 0 && result != AVERROR_EOF) {
sharpemu_bink2_log_error("decoder drain", result);
return 0;
}
break;
}
if (movie->packet->stream_index != movie->video_stream) {
av_packet_unref(movie->packet);
continue;
}
result = avcodec_send_packet(movie->codec, movie->packet);
av_packet_unref(movie->packet);
if (result < 0 && result != AVERROR(EAGAIN)) {
sharpemu_bink2_log_error("packet submit", result);
return 0;
}
break;
}
}
}
SHARPEMU_EXPORT int sharpemu_bink2_decode_next_bgra(
void *handle,
uint8_t *destination,
uint32_t stride,
uint32_t destination_bytes) {
sharpemu_bink2_movie *movie = (sharpemu_bink2_movie *)handle;
uint8_t *destination_planes[4] = {destination, NULL, NULL, NULL};
int destination_strides[4] = {(int)stride, 0, 0, 0};
uint64_t required_bytes;
int converted_rows;
if (!movie || !destination || stride < movie->output_width * 4) {
return 0;
}
required_bytes = (uint64_t)stride * movie->output_height;
if (required_bytes > destination_bytes || !sharpemu_bink2_receive_frame(movie)) {
return 0;
}
movie->converter = sws_getCachedContext(
movie->converter,
movie->frame->width,
movie->frame->height,
(enum AVPixelFormat)movie->frame->format,
(int)movie->output_width,
(int)movie->output_height,
AV_PIX_FMT_BGRA,
SWS_FAST_BILINEAR,
NULL,
NULL,
NULL);
if (!movie->converter) {
av_frame_unref(movie->frame);
return 0;
}
converted_rows = sws_scale(
movie->converter,
(const uint8_t *const *)movie->frame->data,
movie->frame->linesize,
0,
movie->frame->height,
destination_planes,
destination_strides);
av_frame_unref(movie->frame);
return converted_rows == (int)movie->output_height;
}
SHARPEMU_EXPORT void sharpemu_bink2_close(void *movie) {
sharpemu_bink2_destroy((sharpemu_bink2_movie *)movie);
} }
+39
View File
@@ -87,4 +87,43 @@ SPDX-License-Identifier: GPL-2.0-or-later
</ItemGroup> </ItemGroup>
</Target> </Target>
<!-- Building the Bink2 bridge (native/bink2-bridge/sharpemu_bink2_bridge.c)
requires cmake, ninja, and clang-cl (Windows) or a C compiler
(Linux/macOS); the same tools the CI runners already ship with. No
prebuilt/download fallback: anyone publishing SharpEmu is expected to
have the same toolchain, matching every other native dependency in
this repo. -->
<PropertyGroup>
<Bink2BridgeBuildDir>$(BaseIntermediateOutputPath)bink2-bridge/$(RuntimeIdentifier)</Bink2BridgeBuildDir>
<Bink2BridgeFileName Condition="$([MSBuild]::IsOSPlatform('Windows'))">sharpemu_bink2_bridge.dll</Bink2BridgeFileName>
<Bink2BridgeFileName Condition="$([MSBuild]::IsOSPlatform('Linux'))">libsharpemu_bink2_bridge.so</Bink2BridgeFileName>
<Bink2BridgeFileName Condition="$([MSBuild]::IsOSPlatform('OSX'))">libsharpemu_bink2_bridge.dylib</Bink2BridgeFileName>
<Bink2BridgeOutput>$(Bink2BridgeBuildDir)/out/$(Bink2BridgeFileName)</Bink2BridgeOutput>
</PropertyGroup>
<Target Name="BuildBink2Bridge"
BeforeTargets="ComputeResolvedFilesToPublishList"
Condition="'$(RuntimeIdentifier)' != '' And '$(Bink2BridgeFileName)' != ''">
<Exec Condition="$([MSBuild]::IsOSPlatform('Windows'))"
Command="cmake -S &quot;$(RepoRoot)native/bink2-bridge&quot; -B &quot;$(Bink2BridgeBuildDir)&quot; -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang-cl -DSHARPEMU_TARGET_RID=$(RuntimeIdentifier)" />
<Exec Condition="!$([MSBuild]::IsOSPlatform('Windows'))"
Command="cmake -S &quot;$(RepoRoot)native/bink2-bridge&quot; -B &quot;$(Bink2BridgeBuildDir)&quot; -DCMAKE_BUILD_TYPE=Release -DSHARPEMU_TARGET_RID=$(RuntimeIdentifier)" />
<Exec Command="cmake --build &quot;$(Bink2BridgeBuildDir)&quot; --config Release" />
</Target>
<!-- Embeds the bridge into the single-file bundle (self-extracted at first
load, see Bink2MovieBridge.NativeAdapter) instead of leaving it as a
loose file next to the executable, so a downloaded release needs no
setup: the DLL is already inside SharpEmu.exe. -->
<Target Name="EmbedBink2Bridge"
AfterTargets="ComputeResolvedFilesToPublishList"
DependsOnTargets="BuildBink2Bridge"
Condition="'$(RuntimeIdentifier)' != '' And Exists('$(Bink2BridgeOutput)')">
<ItemGroup>
<ResolvedFileToPublish Include="$(Bink2BridgeOutput)">
<RelativePath>$(Bink2BridgeFileName)</RelativePath>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
</Project> </Project>
+204 -51
View File
@@ -3,6 +3,7 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using SharpEmu.HLE; using SharpEmu.HLE;
using SharpEmu.Libs.Bink;
using SharpEmu.Libs.Gpu; using SharpEmu.Libs.Gpu;
using SharpEmu.ShaderCompiler; using SharpEmu.ShaderCompiler;
using SharpEmu.Libs.Kernel; using SharpEmu.Libs.Kernel;
@@ -48,6 +49,7 @@ public static partial class AgcExports
private const uint ItWriteData = 0x37; private const uint ItWriteData = 0x37;
private const uint ItDispatchDirect = 0x15; private const uint ItDispatchDirect = 0x15;
private const uint ItDispatchIndirect = 0x16; private const uint ItDispatchIndirect = 0x16;
private const uint ItSetPredication = 0x20;
private const uint ItWaitRegMem = 0x3C; private const uint ItWaitRegMem = 0x3C;
private const uint ItIndirectBuffer = 0x3F; private const uint ItIndirectBuffer = 0x3F;
private const uint ItEventWrite = 0x46; private const uint ItEventWrite = 0x46;
@@ -277,7 +279,7 @@ public static partial class AgcExports
private static long _labelProducerSequence; private static long _labelProducerSequence;
private static readonly object _labelProducerGate = new(); private static readonly object _labelProducerGate = new();
private static readonly List<LabelProducerTrace> _labelProducers = []; private static readonly List<LabelProducerTrace> _labelProducers = [];
private static readonly HashSet<(object Memory, ulong Address, ulong SubmissionId)> private static readonly HashSet<(object Memory, ulong Address)>
_tracedProducerlessWaits = new(); _tracedProducerlessWaits = new();
private static long _shaderTranslationMissTraceCount; private static long _shaderTranslationMissTraceCount;
private static long _translatedDrawTraceCount; private static long _translatedDrawTraceCount;
@@ -543,6 +545,7 @@ public static partial class AgcExports
public uint IndexSize { get; set; } public uint IndexSize { get; set; }
public uint InstanceCount { get; set; } = 1; public uint InstanceCount { get; set; } = 1;
public uint DrawIndexOffset { get; set; } public uint DrawIndexOffset { get; set; }
public bool PredicateSkip { get; set; }
public string QueueName { get; set; } = "graphics"; public string QueueName { get; set; } = "graphics";
public ulong ActiveSubmissionId { get; set; } public ulong ActiveSubmissionId { get; set; }
public Queue<PendingSubmission> PendingSubmissions { get; } = new(); public Queue<PendingSubmission> PendingSubmissions { get; } = new();
@@ -1183,12 +1186,12 @@ public static partial class AgcExports
return ReturnPointer(ctx, 0); return ReturnPointer(ctx, 0);
} }
var packetDwords = size == 0 ? 6u : 9u; var packetDwords = size == 0 ? 7u : 9u;
var packetRegister = size == 0 ? RWaitMem32 : RWaitMem64; var packetRegister = size == 0 ? RWaitMem32 : RWaitMem64;
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress) || if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress) ||
!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItNop, packetRegister)) || !TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItNop, packetRegister)) ||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address) || !TryWriteUInt32(ctx, commandAddress + 4, (uint)address & (size == 0 ? ~0x3u : ~0x7u)) ||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32)) || !TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32) & 0x3FFFFu) ||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)mask)) !TryWriteUInt32(ctx, commandAddress + 12, (uint)mask))
{ {
return ReturnPointer(ctx, 0); return ReturnPointer(ctx, 0);
@@ -1196,8 +1199,9 @@ public static partial class AgcExports
if (size == 0) if (size == 0)
{ {
if (!TryWriteUInt32(ctx, commandAddress + 16, compareFunction) || if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)reference) ||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference)) !TryWriteUInt32(ctx, commandAddress + 20, EncodeWaitRegMem32Control(compareFunction, 0, cachePolicy)) ||
!TryWriteUInt32(ctx, commandAddress + 24, EncodeWaitRegMemPoll(pollCycles)))
{ {
return ReturnPointer(ctx, 0); return ReturnPointer(ctx, 0);
} }
@@ -1205,8 +1209,8 @@ public static partial class AgcExports
else if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)(mask >> 32)) || else if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)(mask >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference) || !TryWriteUInt32(ctx, commandAddress + 20, (uint)reference) ||
!TryWriteUInt32(ctx, commandAddress + 24, (uint)(reference >> 32)) || !TryWriteUInt32(ctx, commandAddress + 24, (uint)(reference >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 28, compareFunction) || !TryWriteUInt32(ctx, commandAddress + 28, EncodeWaitRegMem64Control(compareFunction, 0, cachePolicy)) ||
!TryWriteUInt32(ctx, commandAddress + 32, pollCycles / 40)) !TryWriteUInt32(ctx, commandAddress + 32, EncodeWaitRegMemPoll(pollCycles)))
{ {
return ReturnPointer(ctx, 0); return ReturnPointer(ctx, 0);
} }
@@ -1826,38 +1830,21 @@ public static partial class AgcExports
return ReturnPointer(ctx, 0); return ReturnPointer(ctx, 0);
} }
var standardWait = operation is 2 or 3; var packetDwords = size == 0 ? 7u : 9u;
var packetDwords = standardWait ? 7u : size == 0 ? 6u : 9u;
var packetRegister = size == 0 ? RWaitMem32 : RWaitMem64; var packetRegister = size == 0 ? RWaitMem32 : RWaitMem64;
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress)) if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress) ||
{ !TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItNop, packetRegister)) ||
return ReturnPointer(ctx, 0); !TryWriteUInt32(ctx, commandAddress + 4, (uint)address & (size == 0 ? ~0x3u : ~0x7u)) ||
} !TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32) & 0x3FFFFu) ||
if (standardWait)
{
if (!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItWaitRegMem, 0)) ||
!TryWriteUInt32(ctx, commandAddress + 4, compareFunction | ((operation & 1) << 8)) ||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)address) ||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)(address >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 16, (uint)reference) ||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)mask) ||
!TryWriteUInt32(ctx, commandAddress + 24, pollCycles / 40))
{
return ReturnPointer(ctx, 0);
}
}
else if (!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItNop, packetRegister)) ||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address) ||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)mask)) !TryWriteUInt32(ctx, commandAddress + 12, (uint)mask))
{ {
return ReturnPointer(ctx, 0); return ReturnPointer(ctx, 0);
} }
else if (size == 0) else if (size == 0)
{ {
if (!TryWriteUInt32(ctx, commandAddress + 16, compareFunction | (operation << 8)) || if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)reference) ||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference)) !TryWriteUInt32(ctx, commandAddress + 20, EncodeWaitRegMem32Control(compareFunction, operation, cachePolicy)) ||
!TryWriteUInt32(ctx, commandAddress + 24, EncodeWaitRegMemPoll(pollCycles)))
{ {
return ReturnPointer(ctx, 0); return ReturnPointer(ctx, 0);
} }
@@ -1865,8 +1852,8 @@ public static partial class AgcExports
else if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)(mask >> 32)) || else if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)(mask >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference) || !TryWriteUInt32(ctx, commandAddress + 20, (uint)reference) ||
!TryWriteUInt32(ctx, commandAddress + 24, (uint)(reference >> 32)) || !TryWriteUInt32(ctx, commandAddress + 24, (uint)(reference >> 32)) ||
!TryWriteUInt32(ctx, commandAddress + 28, compareFunction | (operation << 8)) || !TryWriteUInt32(ctx, commandAddress + 28, EncodeWaitRegMem64Control(compareFunction, operation, cachePolicy)) ||
!TryWriteUInt32(ctx, commandAddress + 32, pollCycles / 40)) !TryWriteUInt32(ctx, commandAddress + 32, EncodeWaitRegMemPoll(pollCycles)))
{ {
return ReturnPointer(ctx, 0); return ReturnPointer(ctx, 0);
} }
@@ -2305,7 +2292,14 @@ public static partial class AgcExports
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); 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_OK)
: SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); : SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
} }
@@ -2328,7 +2322,7 @@ public static partial class AgcExports
var fieldOffset = op == ItWaitRegMem var fieldOffset = op == ItWaitRegMem
? 4UL ? 4UL
: op == ItNop && register == RWaitMem32 : op == ItNop && register == RWaitMem32
? 16UL ? 20UL
: op == ItNop && register == RWaitMem64 : op == ItNop && register == RWaitMem64
? 28UL ? 28UL
: 0; : 0;
@@ -2357,7 +2351,7 @@ public static partial class AgcExports
var wrote = op == ItWaitRegMem var wrote = op == ItWaitRegMem
? TryWriteUInt32(ctx, commandAddress + 16, (uint)reference) ? TryWriteUInt32(ctx, commandAddress + 16, (uint)reference)
: op == ItNop && register == RWaitMem32 : op == ItNop && register == RWaitMem32
? TryWriteUInt32(ctx, commandAddress + 20, (uint)reference) ? TryWriteUInt32(ctx, commandAddress + 16, (uint)reference)
: op == ItNop && register == RWaitMem64 && : op == ItNop && register == RWaitMem64 &&
ctx.TryWriteUInt64(commandAddress + 20, reference); ctx.TryWriteUInt64(commandAddress + 20, reference);
return wrote return wrote
@@ -3105,6 +3099,26 @@ public static partial class AgcExports
CountSubmittedOpcode(op, register); 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 && if (op == ItNop &&
register is RDrawReset or RAcbReset && register is RDrawReset or RAcbReset &&
length >= 2) length >= 2)
@@ -3823,7 +3837,7 @@ public static partial class AgcExports
if (!stale && producer is null && if (!stale && producer is null &&
!_tracedProducerlessWaits.Add( !_tracedProducerlessWaits.Add(
(memory, waiter.WaitAddress, waiter.SubmissionId))) (memory, waiter.WaitAddress)))
{ {
return; return;
} }
@@ -4019,6 +4033,111 @@ public static partial class AgcExports
state.DrawIndexOffset = 0; 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( private static bool RangesOverlap(
ulong leftAddress, ulong leftAddress,
ulong leftLength, ulong leftLength,
@@ -4561,6 +4680,7 @@ public static partial class AgcExports
private static bool TryParseSubmittedWait( private static bool TryParseSubmittedWait(
CpuContext ctx, CpuContext ctx,
ulong packetAddress, ulong packetAddress,
uint packetLength,
bool is64Bit, bool is64Bit,
bool isStandard, bool isStandard,
out ulong waitAddress, out ulong waitAddress,
@@ -4591,8 +4711,10 @@ public static partial class AgcExports
return true; return true;
} }
var legacyWait32 = !is64Bit && packetLength == 6;
var controlOffset = is64Bit ? 28u : legacyWait32 ? 16u : 20u;
if (!TryReadUInt64(ctx, packetAddress + 4, out waitAddress) || 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; return false;
} }
@@ -4605,8 +4727,9 @@ public static partial class AgcExports
TryReadUInt64(ctx, packetAddress + 20, out reference); TryReadUInt64(ctx, packetAddress + 20, out reference);
} }
var referenceOffset = legacyWait32 ? 20u : 16u;
if (!TryReadUInt32(ctx, packetAddress + 12, out var mask32) || if (!TryReadUInt32(ctx, packetAddress + 12, out var mask32) ||
!TryReadUInt32(ctx, packetAddress + 20, out var reference32)) !TryReadUInt32(ctx, packetAddress + referenceOffset, out var reference32))
{ {
return false; return false;
} }
@@ -4711,7 +4834,7 @@ public static partial class AgcExports
bool tracePacket) bool tracePacket)
{ {
if (!TryParseSubmittedWait( 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 waitAddress, out var reference, out var mask, out var compareFunction,
out var controlValue)) out var controlValue))
{ {
@@ -10910,6 +11033,23 @@ public static partial class AgcExports
((op & 0xFFu) << 8) | ((op & 0xFFu) << 8) |
((register & 0x3Fu) << 2); ((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) => private static uint Pm4Length(uint header) =>
((header >> 16) & 0x3FFFu) + 2u; ((header >> 16) & 0x3FFFu) + 2u;
@@ -11364,16 +11504,21 @@ public static partial class AgcExports
public static int DcbSetPredication(CpuContext ctx) public static int DcbSetPredication(CpuContext ctx)
{ {
var dcb = ctx[CpuRegister.Rdi]; 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) if (dcb == 0)
{ {
return ReturnPointer(ctx, 0); return ReturnPointer(ctx, 0);
} }
if (!TryAllocateCommandDwords(ctx, dcb, 3, out var cmd) || var flags = (condition << 8) | (waitOperation << 12) | (operation << 16);
!ctx.TryWriteUInt32(cmd, Pm4(3, ItNop, RZero)) || if (!TryAllocateCommandDwords(ctx, dcb, 4, out var cmd) ||
!ctx.TryWriteUInt32(cmd + 4, (uint)(address & 0xFFFF_FFFFUL)) || !ctx.TryWriteUInt32(cmd, Pm4(4, ItSetPredication, RZero)) ||
!ctx.TryWriteUInt32(cmd + 8, (uint)(address >> 32))) !ctx.TryWriteUInt32(cmd + 4, flags) ||
!ctx.TryWriteUInt32(cmd + 8, (uint)address & 0xFFFF_FFF0u) ||
!ctx.TryWriteUInt32(cmd + 12, (uint)(address >> 32)))
{ {
return ReturnPointer(ctx, 0); return ReturnPointer(ctx, 0);
} }
@@ -11388,9 +11533,17 @@ public static partial class AgcExports
LibraryName = "libSceAgc")] LibraryName = "libSceAgc")]
public static int SetPacketPredication(CpuContext ctx) public static int SetPacketPredication(CpuContext ctx)
{ {
// Global predication toggle on a packet; a no-op is safe for rendering. var packetAddress = ctx[CpuRegister.Rdi];
ctx[CpuRegister.Rax] = 0; var predication = ctx[CpuRegister.Rsi];
return (int)OrbisGen2Result.ORBIS_GEN2_OK; 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), // ABI (reversed from Quake): rdi = array of DCB base addresses (u64 each),
@@ -11565,7 +11718,7 @@ public static partial class AgcExports
uint owner; uint owner;
lock (state.Gate) lock (state.Gate)
{ {
if (!state.ResourceRegistrationInitialized || if (state.ResourceRegistrationInitialized &&
state.ResourceRegistrationMaxOwners != 0 && state.ResourceRegistrationMaxOwners != 0 &&
state.ResourceOwners.Count >= state.ResourceRegistrationMaxOwners) state.ResourceOwners.Count >= state.ResourceRegistrationMaxOwners)
{ {
+157 -50
View File
@@ -1,6 +1,9 @@
// Copyright (C) 2026 SharpEmu Emulator Project // Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
namespace SharpEmu.Libs.Agc; namespace SharpEmu.Libs.Agc;
/// <summary> /// <summary>
@@ -16,8 +19,11 @@ namespace SharpEmu.Libs.Agc;
/// other D/R and pipe/bank-XOR modes stay opt-in while their complete AddrLib /// other D/R and pipe/bank-XOR modes stay opt-in while their complete AddrLib
/// equations are being ported. /// equations are being ported.
/// </summary> /// </summary>
internal static class GnmTiling internal static unsafe class GnmTiling
{ {
private const int ParallelDetileElementThreshold = 512 * 512;
private const int MaxDetileWorkers = 4;
// Oberon uses the 16-pipe / 8-pixel-packer RB+ topology. These are the // 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 // 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. // topology. Each entry describes one address bit as an XOR of X/Y bits.
@@ -118,6 +124,14 @@ internal static class GnmTiling
StringComparison.Ordinal); StringComparison.Ordinal);
private static readonly HashSet<uint> _reportedModes = new(); private static readonly HashSet<uint> _reportedModes = new();
private static readonly ConcurrentDictionary<(uint SwizzleMode, int BppLog2), PatternTerms>
_patternTermCache = new();
private static readonly ConcurrentDictionary<(SwizzleKind Kind, int Width, int Height), int[]>
_blockTableCache = new();
private static readonly ParallelOptions _parallelDetileOptions = new()
{
MaxDegreeOfParallelism = Math.Min(MaxDetileWorkers, Environment.ProcessorCount),
};
public static bool Enabled => _enabled || !_disabled; public static bool Enabled => _enabled || !_disabled;
@@ -404,64 +418,83 @@ internal static class GnmTiling
return false; return false;
} }
// Precompute the within-block element offset for each (x, y) inside a // Address tables depend only on the swizzle equation and element size,
// single block. The swizzle equation only depends on the in-block // so retain them across textures instead of rebuilding them per upload.
// coordinates, so this table is reused for every block — turning the
// per-pixel bit-interleave (a loop + calls) into a single array lookup.
// Detiling a 2048x2048 texture is millions of elements; without this the
// per-pixel math makes DETILE unusably slow during asset streaming.
var hasExactXorPattern = TryGetExactXorPattern(swizzleMode, bppLog2, out var xorPattern); var hasExactXorPattern = TryGetExactXorPattern(swizzleMode, bppLog2, out var xorPattern);
var blockTable = hasExactXorPattern ? [] : new int[blockWidth * blockHeight]; var patternTerms = hasExactXorPattern
for (var by = 0; !hasExactXorPattern && by < blockHeight; by++) ? _patternTermCache.GetOrAdd(
{ (swizzleMode, bppLog2),
for (var bx = 0; bx < blockWidth; bx++) _ => CreatePatternTerms(xorPattern))
{ : default;
blockTable[by * blockWidth + bx] = (int)(kind == SwizzleKind.ZOrder var blockTable = hasExactXorPattern
? MortonInterleave((uint)bx, (uint)by, blockWidth, blockHeight) ? []
: StandardSwizzleOffset((uint)bx, (uint)by, blockWidth, blockHeight)); : _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 // The XOR equation offset factors cleanly into independent X and Y
// fields — each output bit is parity(x & XMask) XOR parity(y & YMask), // 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). // 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 // Exact equations repeat at a small power-of-two period. Cached axis
// inner loop drops from a 16-bit interleave with 32 PopCounts per element // terms reduce the inner loop to two array loads and one XOR.
// to a single array load and one XOR. fixed (byte* tiledPointer = tiled)
var xTermByColumn = hasExactXorPattern ? new int[elementsWide] : []; fixed (byte* linearPointer = linear)
for (var x = 0; hasExactXorPattern && x < elementsWide; x++)
{ {
xTermByColumn[x] = (int)PatternAxisTerm((uint)x, xorPattern, useX: true); var sourceAddress = (nint)tiledPointer;
} var destinationAddress = (nint)linearPointer;
var sourceLength = tiled.Length;
for (var y = 0; y < elementsHigh; y++) var destinationLength = linear.Length;
{ var blockWidthShift = BitLog2((uint)blockWidth);
var blockY = y / blockHeight; var blockWidthMask = blockWidth - 1;
var inBlockY = y % blockHeight; var detileRow = (int y) =>
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 blockX = x / blockWidth; var blockY = y / blockHeight;
var inBlockX = x % blockWidth; var inBlockY = y & (blockHeight - 1);
var rowBlockBase = (long)blockY * blocksPerRow;
var blockIndex = rowBlockBase + blockX; var tableRowBase = inBlockY * blockWidth;
var sourceByte = hasExactXorPattern var destRowBase = (long)y * elementsWide * bytesPerElement;
? blockIndex * blockBytes + (xTermByColumn[x] ^ yTerm) var yTerm = hasExactXorPattern
: (blockIndex * blockElements + blockTable[tableRowBase + inBlockX]) * ? patternTerms.Y[y & patternTerms.YMask]
(long)bytesPerElement; : 0;
var destByte = destRowBase + (long)x * bytesPerElement; for (var x = 0; x < elementsWide; x++)
if (sourceByte + bytesPerElement > tiled.Length ||
destByte + bytesPerElement > linear.Length)
{ {
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) CopyElement(
.CopyTo(linear.Slice((int)destByte, bytesPerElement)); (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, ZOrder,
} }
private readonly record struct PatternTerms(int[] X, int XMask, int[] Y, int YMask);
private static PatternTerms CreatePatternTerms(AddressBit[] pattern)
{
uint xMask = 0;
uint yMask = 0;
foreach (var bit in pattern)
{
xMask |= bit.XMask;
yMask |= bit.YMask;
}
var xLength = AxisTermPeriod(xMask);
var yLength = AxisTermPeriod(yMask);
var xTerms = new int[xLength];
var yTerms = new int[yLength];
for (var x = 0; x < xTerms.Length; x++)
{
xTerms[x] = (int)PatternAxisTerm((uint)x, pattern, useX: true);
}
for (var y = 0; y < yTerms.Length; y++)
{
yTerms[y] = (int)PatternAxisTerm((uint)y, pattern, useX: false);
}
return new PatternTerms(xTerms, xLength - 1, yTerms, yLength - 1);
}
private static int AxisTermPeriod(uint mask) =>
mask == 0 ? 1 : 1 << (32 - System.Numerics.BitOperations.LeadingZeroCount(mask));
private static int[] CreateBlockTable(SwizzleKind kind, int blockWidth, int blockHeight)
{
var table = new int[blockWidth * blockHeight];
for (var y = 0; y < blockHeight; y++)
{
for (var x = 0; x < blockWidth; x++)
{
table[y * blockWidth + x] = (int)(kind == SwizzleKind.ZOrder
? MortonInterleave((uint)x, (uint)y, blockWidth, blockHeight)
: StandardSwizzleOffset((uint)x, (uint)y, blockWidth, blockHeight));
}
}
return table;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void CopyElement(byte* source, byte* destination, int bytesPerElement)
{
switch (bytesPerElement)
{
case 1:
*destination = *source;
break;
case 2:
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<ushort>(source));
break;
case 4:
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<uint>(source));
break;
case 8:
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<ulong>(source));
break;
case 16:
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<UInt128>(source));
break;
default:
Unsafe.CopyBlockUnaligned(destination, source, (uint)bytesPerElement);
break;
}
}
private static readonly AddressBit Zero = new(0, 0); private static readonly AddressBit Zero = new(0, 0);
private static AddressBit X(int bit) => new(1u << bit, 0); private static AddressBit X(int bit) => new(1u << bit, 0);
+39
View File
@@ -343,6 +343,45 @@ internal static class GpuWaitRegistry
return expired; return expired;
} }
public static List<WaitingDcb>? CollectAllForMemory(object memory)
{
List<WaitingDcb>? collected = null;
lock (_gate)
{
List<ulong>? emptied = null;
foreach (var (address, list) in _waiters)
{
for (var index = list.Count - 1; index >= 0; index--)
{
if (!ReferenceEquals(list[index].Memory, memory))
{
continue;
}
collected ??= new List<WaitingDcb>();
collected.Add(list[index]);
list.RemoveAt(index);
}
if (list.Count == 0)
{
emptied ??= new List<ulong>();
emptied.Add(address);
}
}
if (emptied is not null)
{
foreach (var address in emptied)
{
_waiters.Remove(address);
}
}
}
return collected;
}
/// <summary>Records the value a label producer wrote, for the deadlock /// <summary>Records the value a label producer wrote, for the deadlock
/// breaker. Also latches any already-waiting waiter it satisfies.</summary> /// breaker. Also latches any already-waiting waiter it satisfies.</summary>
public static bool RecordProduced(object memory, ulong address, ulong value) public static bool RecordProduced(object memory, ulong address, ulong value)
+20 -3
View File
@@ -1131,16 +1131,18 @@ public static class AvPlayerExports
} }
} }
private static string? FindFfmpeg() => internal static string? FindFfmpeg() =>
FindFfmpeg( FindFfmpeg(
Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH"), Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH"),
Environment.GetEnvironmentVariable("PATH"), Environment.GetEnvironmentVariable("PATH"),
OperatingSystem.IsWindows()); OperatingSystem.IsWindows(),
AppContext.BaseDirectory);
internal static string? FindFfmpeg( internal static string? FindFfmpeg(
string? configured, string? configured,
string? searchPath, string? searchPath,
bool isWindows) bool isWindows,
string? baseDirectory = null)
{ {
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured)) if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
{ {
@@ -1148,6 +1150,21 @@ public static class AvPlayerExports
} }
var executable = isWindows ? "ffmpeg.exe" : "ffmpeg"; 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) foreach (var directory in (searchPath ?? string.Empty)
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{ {
+536 -86
View File
@@ -18,15 +18,46 @@ namespace SharpEmu.Libs.Bink;
internal static class Bink2MovieBridge internal static class Bink2MovieBridge
{ {
private const uint MaxDimension = 16384; private const uint MaxDimension = 16384;
private const uint MaxHostVideoWidth = 1920;
private const uint MaxHostVideoHeight = 1080;
private static readonly object Gate = new(); private static readonly object Gate = new();
private static NativeAdapter? _adapter; private static NativeAdapter? _adapter;
private static string? _activePath; private static string? _activePath;
private static IntPtr _activeMovie;
private static Bink2MovieInfo _activeInfo; private static Bink2MovieInfo _activeInfo;
private static byte[]? _frameBuffer; 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 _loadAttempted;
private static bool _availabilityReported; private static bool _availabilityReported;
private static uint _presentationWidth = MaxHostVideoWidth;
private static uint _presentationHeight = MaxHostVideoHeight;
internal static bool IsHostPlaybackActive
{
get
{
lock (Gate)
{
return _playback is not null || _frameBuffer is not null;
}
}
}
internal static void SetPresentationSize(uint width, uint height)
{
if (width == 0 || height == 0)
{
return;
}
lock (Gate)
{
_presentationWidth = Math.Min(width, MaxHostVideoWidth);
_presentationHeight = Math.Min(height, MaxHostVideoHeight);
}
}
/// <summary> /// <summary>
/// Returns true only when movie skipping was explicitly requested. Without /// Returns true only when movie skipping was explicitly requested. Without
@@ -37,109 +68,106 @@ internal static class Bink2MovieBridge
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) && hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
ResolveMode() == MovieMode.Skip; ResolveMode() == MovieMode.Skip;
internal static void ObserveGuestMovie(string hostPath) /// <summary>
/// Starts or queues host decoding. Decoded frames are only exposed as a
/// sampled guest texture; presentation and UI composition remain guest-owned.
/// </summary>
internal static bool ObserveGuestMovie(string hostPath)
{ {
if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) || if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
!File.Exists(hostPath)) !File.Exists(hostPath))
{ {
return; return false;
} }
lock (Gate) lock (Gate)
{ {
if (string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase)) if (string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
{ {
return; return _playback is not null || _frameBuffer is not null;
} }
var mode = ResolveMode(); var mode = ResolveMode();
if (mode == MovieMode.Dummy) if (mode is MovieMode.Guest or MovieMode.Skip)
{ {
AttachDummyMovieLocked(hostPath); return false;
return;
} }
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(); AttachMovieLocked(hostPath, mode);
if (adapter is null) return string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase) &&
{ (_playback is not null || _frameBuffer is not null);
return;
}
CloseActiveLocked();
if (!adapter.TryOpen(hostPath, out var movie, out var info))
{
Console.Error.WriteLine(
"[LOADER][WARN] Bink2 bridge could not open movie '" +
Path.GetFileName(hostPath) + "'.");
return;
}
if (!IsValid(info))
{
adapter.Close(movie);
Console.Error.WriteLine(
"[LOADER][WARN] Bink2 bridge rejected invalid movie dimensions for '" +
Path.GetFileName(hostPath) + "'.");
return;
}
_activePath = hostPath;
_activeMovie = movie;
_activeInfo = info;
_frameBuffer = GC.AllocateUninitializedArray<byte>(GetFrameBufferLength(info));
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge attached: " + Path.GetFileName(hostPath) + " " +
info.Width + "x" + info.Height + " @ " +
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
} }
} }
internal static bool TryDecodeNextFrame( internal static bool TryDecodeNextFrame(
bool advanceClock,
out byte[] pixels, out byte[] pixels,
out uint width, out uint width,
out uint height) out uint height,
out bool advanced,
out long frameSerial,
out string hostPath)
{ {
lock (Gate) lock (Gate)
{ {
pixels = []; pixels = [];
width = 0; width = 0;
height = 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; if (_playback.IsFinished)
width = _activeInfo.Width; {
height = _activeInfo.Height; var completedPath = _activePath;
return true; 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) return false;
{
if (!_adapter.DecodeNextBgra(
_activeMovie,
(IntPtr)destination,
_activeInfo.Width * 4,
(uint)_frameBuffer.Length))
{
return false;
}
}
} }
pixels = _frameBuffer; pixels = _frameBuffer;
width = _activeInfo.Width; width = _activeInfo.Width;
height = _activeInfo.Height; height = _activeInfo.Height;
advanced = !_frameBufferPresented;
_frameBufferPresented = true;
if (advanced)
{
frameSerial = ++_frameSerial;
}
return true; return true;
} }
} }
@@ -152,6 +180,63 @@ internal static class Bink2MovieBridge
private static int GetFrameBufferLength(Bink2MovieInfo info) => private static int GetFrameBufferLength(Bink2MovieInfo info) =>
checked((int)((ulong)info.Width * info.Height * 4)); 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() private static MovieMode ResolveMode()
{ {
var configured = Environment.GetEnvironmentVariable("SHARPEMU_BINK_MODE"); var configured = Environment.GetEnvironmentVariable("SHARPEMU_BINK_MODE");
@@ -170,15 +255,23 @@ internal static class Bink2MovieBridge
return MovieMode.Skip; return MovieMode.Skip;
} }
// Prefer the optional host adapter when one is supplied. Otherwise let if (string.Equals(configured, "guest", StringComparison.OrdinalIgnoreCase))
// the game's statically linked Bink implementation consume the file.
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE")) ||
EnumerateAdapterCandidates().Any(File.Exists))
{ {
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) private static void AttachDummyMovieLocked(string hostPath)
@@ -195,22 +288,61 @@ internal static class Bink2MovieBridge
_activePath = hostPath; _activePath = hostPath;
_activeInfo = info; _activeInfo = info;
_frameBuffer = GC.AllocateUninitializedArray<byte>(GetFrameBufferLength(info)); _frameBuffer = GC.AllocateUninitializedArray<byte>(GetFrameBufferLength(info));
_frameBufferPresented = false;
FillDummyFrame(_frameBuffer, info.Width, info.Height); FillDummyFrame(_frameBuffer, info.Width, info.Height);
_usingDummyMovie = true;
Console.Error.WriteLine( Console.Error.WriteLine(
"[LOADER][INFO] Bink dummy attached: " + Path.GetFileName(hostPath) + " " + "[LOADER][INFO] Bink dummy attached: " + Path.GetFileName(hostPath) + " " +
info.Width + "x" + info.Height + "."); 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; info = default;
Span<byte> header = stackalloc byte[32]; Span<byte> header = stackalloc byte[36];
try try
{ {
using var stream = File.OpenRead(path); using var stream = File.OpenRead(path);
if (stream.Read(header) != header.Length || stream.ReadExactly(header);
!header[..4].SequenceEqual("KB2j"u8)) if (!header[..3].SequenceEqual("KB2"u8))
{ {
return false; return false;
} }
@@ -219,10 +351,11 @@ internal static class Bink2MovieBridge
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x14, 4)), BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x14, 4)),
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x18, 4)), BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x18, 4)),
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x1C, 4)), BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x1C, 4)),
1); BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x20, 4)));
return true; return info.FramesPerSecondNumerator != 0 &&
info.FramesPerSecondDenominator != 0;
} }
catch (IOException) catch (Exception exception) when (exception is IOException or EndOfStreamException)
{ {
return false; return false;
} }
@@ -252,6 +385,26 @@ internal static class Bink2MovieBridge
} }
_loadAttempted = true; _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()) foreach (var candidate in EnumerateAdapterCandidates())
{ {
if (!NativeLibrary.TryLoad(candidate, out var library)) if (!NativeLibrary.TryLoad(candidate, out var library))
@@ -304,20 +457,20 @@ internal static class Bink2MovieBridge
private static void CloseActiveLocked() private static void CloseActiveLocked()
{ {
if (_activeMovie != IntPtr.Zero) _playback?.Dispose();
{ _playback = null;
_adapter?.Close(_activeMovie);
}
_activePath = null; _activePath = null;
_activeMovie = IntPtr.Zero;
_activeInfo = default; _activeInfo = default;
_frameBuffer = null; _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)] [StructLayout(LayoutKind.Sequential)]
private readonly struct Bink2MovieInfo internal readonly struct Bink2MovieInfo
{ {
public readonly uint Width; public readonly uint Width;
public readonly uint Height; public readonly uint Height;
@@ -343,13 +496,68 @@ internal static class Bink2MovieBridge
Skip, Skip,
Dummy, Dummy,
Native, Native,
Ffmpeg,
} }
private sealed class NativeFrameDecoder : IBinkFrameDecoder
{
private readonly NativeAdapter _adapter;
private readonly IntPtr _movie;
private int _disposed;
internal NativeFrameDecoder(NativeAdapter adapter, IntPtr movie, Bink2MovieInfo info)
{
_adapter = adapter;
_movie = movie;
Width = info.Width;
Height = info.Height;
FramesPerSecondNumerator = info.FramesPerSecondNumerator;
FramesPerSecondDenominator = info.FramesPerSecondDenominator;
}
public uint Width { get; }
public uint Height { get; }
public uint FramesPerSecondNumerator { get; }
public uint FramesPerSecondDenominator { get; }
public unsafe bool TryDecodeNextFrame(Span<byte> destination)
{
fixed (byte* pointer = destination)
{
return _adapter.DecodeNextBgra(
_movie,
(IntPtr)pointer,
Width * 4,
checked((uint)destination.Length));
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0)
{
_adapter.Close(_movie);
}
}
}
private sealed class NativeAdapter private sealed class NativeAdapter
{ {
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int OpenUtf8Delegate(IntPtr pathUtf8, out IntPtr movie, out Bink2MovieInfo info); 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)] [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int DecodeNextBgraDelegate(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes); 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 delegate void CloseDelegate(IntPtr movie);
private readonly OpenUtf8Delegate _openUtf8; private readonly OpenUtf8Delegate _openUtf8;
private readonly OpenScaledUtf8Delegate? _openScaledUtf8;
private readonly DecodeNextBgraDelegate _decodeNextBgra; private readonly DecodeNextBgraDelegate _decodeNextBgra;
private readonly CloseDelegate _close; private readonly CloseDelegate _close;
private NativeAdapter( private NativeAdapter(
OpenUtf8Delegate openUtf8, OpenUtf8Delegate openUtf8,
OpenScaledUtf8Delegate? openScaledUtf8,
DecodeNextBgraDelegate decodeNextBgra, DecodeNextBgraDelegate decodeNextBgra,
CloseDelegate close) CloseDelegate close)
{ {
_openUtf8 = openUtf8; _openUtf8 = openUtf8;
_openScaledUtf8 = openScaledUtf8;
_decodeNextBgra = decodeNextBgra; _decodeNextBgra = decodeNextBgra;
_close = close; _close = close;
} }
@@ -380,19 +591,42 @@ internal static class Bink2MovieBridge
return false; return false;
} }
OpenScaledUtf8Delegate? openScaled = null;
if (NativeLibrary.TryGetExport(
library,
"sharpemu_bink2_open_scaled_utf8",
out var scaledOpen))
{
openScaled = Marshal.GetDelegateForFunctionPointer<OpenScaledUtf8Delegate>(scaledOpen);
}
adapter = new NativeAdapter( adapter = new NativeAdapter(
Marshal.GetDelegateForFunctionPointer<OpenUtf8Delegate>(open), Marshal.GetDelegateForFunctionPointer<OpenUtf8Delegate>(open),
openScaled,
Marshal.GetDelegateForFunctionPointer<DecodeNextBgraDelegate>(decode), Marshal.GetDelegateForFunctionPointer<DecodeNextBgraDelegate>(decode),
Marshal.GetDelegateForFunctionPointer<CloseDelegate>(close)); Marshal.GetDelegateForFunctionPointer<CloseDelegate>(close));
return true; 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); var utf8 = Marshal.StringToCoTaskMemUTF8(path);
try 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 finally
{ {
@@ -405,4 +639,220 @@ internal static class Bink2MovieBridge
internal void Close(IntPtr movie) => _close(movie); internal void Close(IntPtr movie) => _close(movie);
} }
private static readonly Queue<string> PendingMoviePaths = new();
private static readonly HashSet<string> PendingMoviePathSet =
new(StringComparer.OrdinalIgnoreCase);
private static void AttachNextQueuedMovieLocked()
{
while (PendingMoviePaths.Count > 0)
{
var path = PendingMoviePaths.Dequeue();
PendingMoviePathSet.Remove(path);
if (!File.Exists(path))
{
continue;
}
AttachMovieLocked(path, ResolveMode());
if (_playback is not null || _frameBuffer is not null)
{
return;
}
}
}
// Longest a guest _read() will block waiting for real host playback to
// finish. A safety net, not a target: real movies finish well under
// this. Bounds the damage if a movie fails to attach/decode after being
// queued, so the guest thread doesn't hang forever.
private const long MaxCompletionWaitMilliseconds = 5 * 60 * 1000;
/// <summary>
/// Blocks the calling (guest I/O) thread until the host has actually
/// finished presenting <paramref name="hostPath"/> — either because it
/// played through, or because something else took over the timeline.
///
/// The completion shim tells the guest's own Bink header parse "this
/// movie is one frame and already done" so its native decoder never
/// blocks the guest on real per-frame work. Without this wait, that lie
/// lands the instant the guest reads the header, so guest-side game
/// logic races far ahead of whatever the host is still showing on
/// screen: pressing a button lands on the (already-advanced) guest
/// state, but the video visibly keeps playing, and any real-time-gated
/// trigger later in the guest's own flow can fire against a clock that
/// no longer matches wall time. Gating the "done" read on real host
/// completion keeps guest pacing and on-screen playback in lockstep.
/// </summary>
internal static void WaitForHostPlaybackToFinish(string hostPath)
{
var deadline = Environment.TickCount64 + MaxCompletionWaitMilliseconds;
lock (Gate)
{
while (IsTrackedLocked(hostPath))
{
var remaining = deadline - Environment.TickCount64;
if (remaining <= 0)
{
Console.Error.WriteLine(
"[LOADER][WARN] Bink2 bridge completion wait timed out for '" +
Path.GetFileName(hostPath) + "'.");
return;
}
Monitor.Wait(Gate, (int)Math.Min(remaining, 200));
}
}
}
private static bool IsTrackedLocked(string hostPath) =>
string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase) ||
PendingMoviePathSet.Contains(hostPath);
internal static bool TryTakeOverGuestMovie(
string hostPath,
out BinkGuestCompletionShim completionShim,
out bool observed)
{
completionShim = default;
observed = ObserveGuestMovie(hostPath);
// Keep the real header visible so the guest creates its movie surface
// and draw. Host-decoded pixels replace that sampled image later; a
// one-frame completion shim would finish before the descriptor exists.
return false;
}
internal static void NotifyGuestMovieClosed(string hostPath)
{
lock (Gate)
{
if (PendingMoviePathSet.Remove(hostPath))
{
var retained = PendingMoviePaths
.Where(path => !string.Equals(
path,
hostPath,
StringComparison.OrdinalIgnoreCase))
.ToArray();
PendingMoviePaths.Clear();
foreach (var path in retained)
{
PendingMoviePaths.Enqueue(path);
}
}
if (!string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
{
Monitor.PulseAll(Gate);
return;
}
Console.Error.WriteLine(
"[LOADER][INFO] Bink2 bridge stopped by guest close: " +
Path.GetFileName(hostPath));
CloseActiveLocked();
AttachNextQueuedMovieLocked();
}
}
internal static bool TryReadGuestCompletionShim(
string hostPath,
out BinkGuestCompletionShim completionShim)
{
completionShim = default;
Span<byte> header = stackalloc byte[48];
try
{
using var stream = File.OpenRead(hostPath);
stream.ReadExactly(header);
if (!header[..3].SequenceEqual("KB2"u8))
{
return false;
}
var frameCount = BinaryPrimitives.ReadUInt32LittleEndian(header[8..12]);
var audioTrackCount = BinaryPrimitives.ReadUInt32LittleEndian(header[40..44]);
if (frameCount < 2 || audioTrackCount > 256)
{
return false;
}
var revision = header[3];
var frameIndexOffset = 44L + checked(12L * audioTrackCount);
if (revision == (byte)'m')
{
frameIndexOffset += 16;
}
else if (revision is (byte)'i' or (byte)'j' or (byte)'k' or (byte)'n')
{
frameIndexOffset += 4;
}
Span<byte> frameOffsets = stackalloc byte[8];
stream.Position = frameIndexOffset;
stream.ReadExactly(frameOffsets);
var firstFrameOffset = BinaryPrimitives.ReadUInt32LittleEndian(frameOffsets[..4]) & ~1u;
var secondFrameOffset = BinaryPrimitives.ReadUInt32LittleEndian(frameOffsets[4..]) & ~1u;
if (firstFrameOffset < frameIndexOffset + 8 ||
secondFrameOffset <= firstFrameOffset ||
secondFrameOffset > stream.Length)
{
return false;
}
completionShim = new BinkGuestCompletionShim(
secondFrameOffset - 8,
secondFrameOffset - firstFrameOffset);
return true;
}
catch (Exception exception) when (
exception is IOException or EndOfStreamException or OverflowException)
{
return false;
}
}
internal readonly struct BinkGuestCompletionShim
{
private readonly uint _fileSizeMinusHeader;
private readonly uint _largestFrameSize;
internal BinkGuestCompletionShim(uint fileSizeMinusHeader, uint largestFrameSize)
{
_fileSizeMinusHeader = fileSizeMinusHeader;
_largestFrameSize = largestFrameSize;
}
/// <summary>
/// Rewrites the frame-count/size fields the guest's own Bink header
/// parse reads, if this read covers them. Returns true when the
/// NumFrames field (the field that tells the guest "this movie is
/// done") was in range, so the caller can gate that specific read on
/// the host's real playback actually finishing first.
/// </summary>
internal bool Patch(long fileOffset, Span<byte> bytes)
{
PatchUInt32(fileOffset, bytes, 4, _fileSizeMinusHeader);
var touchedCompletionField = PatchUInt32(fileOffset, bytes, 8, 1);
PatchUInt32(fileOffset, bytes, 12, _largestFrameSize);
return touchedCompletionField;
}
private static bool PatchUInt32(
long fileOffset,
Span<byte> bytes,
long fieldOffset,
uint value)
{
var relativeOffset = fieldOffset - fileOffset;
if (relativeOffset < 0 || relativeOffset + sizeof(uint) > bytes.Length)
{
return false;
}
BinaryPrimitives.WriteUInt32LittleEndian(
bytes.Slice((int)relativeOffset, sizeof(uint)),
value);
return true;
}
}
} }
+246
View File
@@ -0,0 +1,246 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
namespace SharpEmu.Libs.Bink;
internal interface IBinkFrameDecoder : IDisposable
{
uint Width { get; }
uint Height { get; }
uint FramesPerSecondNumerator { get; }
uint FramesPerSecondDenominator { get; }
bool TryDecodeNextFrame(Span<byte> destination);
}
/// <summary>
/// Keeps blocking codec work away from the Vulkan presentation thread and
/// releases decoded frames according to the movie time base.
/// </summary>
internal sealed class BinkFramePlayback : IDisposable
{
private const int BufferCount = 5;
private readonly object _gate = new();
private readonly IBinkFrameDecoder _decoder;
private readonly Queue<byte[]> _freeBuffers = new();
private readonly Queue<DecodedFrame> _decodedFrames = new();
private readonly Thread _decoderThread;
private byte[]? _currentFrame;
private byte[]? _retiredFrame;
private long _currentFrameIndex = -1;
private long _nextDecodedFrameIndex;
private long _playbackStartTimestamp;
private bool _playbackClockStarted;
private bool _decoderCompleted;
private bool _stopRequested;
private bool _finished;
private int _disposed;
internal BinkFramePlayback(IBinkFrameDecoder decoder)
{
_decoder = decoder;
Width = decoder.Width;
Height = decoder.Height;
FramesPerSecondNumerator = decoder.FramesPerSecondNumerator;
FramesPerSecondDenominator = decoder.FramesPerSecondDenominator;
var frameBytes = checked((int)((ulong)Width * Height * 4));
for (var index = 0; index < BufferCount; index++)
{
_freeBuffers.Enqueue(GC.AllocateUninitializedArray<byte>(frameBytes));
}
_decoderThread = new Thread(DecodeLoop)
{
IsBackground = true,
Name = "SharpEmu Bink video decoder",
};
_decoderThread.Start();
}
internal uint Width { get; }
internal uint Height { get; }
internal uint FramesPerSecondNumerator { get; }
internal uint FramesPerSecondDenominator { get; }
internal bool IsFinished
{
get
{
lock (_gate)
{
return _finished;
}
}
}
internal bool TryGetFrame(
bool advanceClock,
out byte[] pixels,
out bool advanced)
{
lock (_gate)
{
pixels = [];
advanced = false;
if (_finished)
{
return false;
}
if (_currentFrame is null)
{
if (_decodedFrames.Count == 0)
{
if (_decoderCompleted)
{
_finished = true;
}
return false;
}
var first = _decodedFrames.Dequeue();
_currentFrame = first.Pixels;
_currentFrameIndex = first.Index;
advanced = true;
Monitor.PulseAll(_gate);
}
if (advanceClock && !_playbackClockStarted)
{
_playbackStartTimestamp = Stopwatch.GetTimestamp();
_playbackClockStarted = true;
}
var elapsedSeconds = _playbackClockStarted
? Stopwatch.GetElapsedTime(_playbackStartTimestamp).TotalSeconds
: 0;
var targetFrameIndex = (long)Math.Floor(
elapsedSeconds * FramesPerSecondNumerator / FramesPerSecondDenominator);
DecodedFrame? replacement = null;
while (_decodedFrames.Count > 0 &&
_decodedFrames.Peek().Index <= targetFrameIndex)
{
if (replacement is { } skipped)
{
_freeBuffers.Enqueue(skipped.Pixels);
}
replacement = _decodedFrames.Dequeue();
}
if (replacement is { } next)
{
if (_retiredFrame is not null)
{
_freeBuffers.Enqueue(_retiredFrame);
}
_retiredFrame = _currentFrame;
_currentFrame = next.Pixels;
_currentFrameIndex = next.Index;
advanced = true;
Monitor.PulseAll(_gate);
}
var frameDurationSeconds =
(double)FramesPerSecondDenominator / FramesPerSecondNumerator;
if (_playbackClockStarted &&
_decoderCompleted &&
_decodedFrames.Count == 0 &&
elapsedSeconds >= (_currentFrameIndex + 1) * frameDurationSeconds)
{
_finished = true;
return false;
}
pixels = _currentFrame;
return true;
}
}
private void DecodeLoop()
{
try
{
while (true)
{
byte[] destination;
lock (_gate)
{
while (!_stopRequested && _freeBuffers.Count == 0)
{
Monitor.Wait(_gate);
}
if (_stopRequested)
{
return;
}
destination = _freeBuffers.Dequeue();
}
if (!_decoder.TryDecodeNextFrame(destination))
{
lock (_gate)
{
_freeBuffers.Enqueue(destination);
_decoderCompleted = true;
Monitor.PulseAll(_gate);
}
return;
}
lock (_gate)
{
_decodedFrames.Enqueue(new DecodedFrame(
_nextDecodedFrameIndex++, destination));
Monitor.PulseAll(_gate);
}
}
}
catch (Exception exception) when (exception is IOException or
InvalidOperationException)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Bink decoder stopped: {exception.Message}");
lock (_gate)
{
_decoderCompleted = true;
Monitor.PulseAll(_gate);
}
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
lock (_gate)
{
_stopRequested = true;
Monitor.PulseAll(_gate);
}
if (Thread.CurrentThread != _decoderThread &&
!_decoderThread.Join(TimeSpan.FromMilliseconds(100)))
{
_decoder.Dispose();
_decoderThread.Join(TimeSpan.FromSeconds(2));
}
else
{
_decoder.Dispose();
}
}
private readonly record struct DecodedFrame(long Index, byte[] Pixels);
}
@@ -0,0 +1,166 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
using SharpEmu.Libs.AvPlayer;
namespace SharpEmu.Libs.Bink;
internal sealed class FfmpegBinkFrameSource : IBinkFrameDecoder
{
private readonly Process _process;
private readonly Stream _output;
private int _errorLines;
private int _disposed;
private FfmpegBinkFrameSource(
Process process,
uint width,
uint height,
uint framesPerSecondNumerator,
uint framesPerSecondDenominator)
{
_process = process;
_output = process.StandardOutput.BaseStream;
Width = width;
Height = height;
FramesPerSecondNumerator = framesPerSecondNumerator;
FramesPerSecondDenominator = framesPerSecondDenominator;
}
public uint Width { get; }
public uint Height { get; }
public uint FramesPerSecondNumerator { get; }
public uint FramesPerSecondDenominator { get; }
internal static bool IsAvailable => AvPlayerExports.FindFfmpeg() is not null;
internal static bool TryOpen(
string path,
uint width,
uint height,
uint framesPerSecondNumerator,
uint framesPerSecondDenominator,
out FfmpegBinkFrameSource? source)
{
source = null;
var ffmpeg = AvPlayerExports.FindFfmpeg();
if (ffmpeg is null)
{
return false;
}
var startInfo = new ProcessStartInfo(ffmpeg)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-nostdin");
startInfo.ArgumentList.Add("-hide_banner");
startInfo.ArgumentList.Add("-loglevel");
startInfo.ArgumentList.Add("error");
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(path);
startInfo.ArgumentList.Add("-map");
startInfo.ArgumentList.Add("0:v:0");
startInfo.ArgumentList.Add("-an");
startInfo.ArgumentList.Add("-pix_fmt");
startInfo.ArgumentList.Add("bgra");
startInfo.ArgumentList.Add("-f");
startInfo.ArgumentList.Add("rawvideo");
startInfo.ArgumentList.Add("pipe:1");
try
{
var process = Process.Start(startInfo);
if (process is null)
{
return false;
}
source = new FfmpegBinkFrameSource(
process,
width,
height,
framesPerSecondNumerator,
framesPerSecondDenominator);
process.ErrorDataReceived += source.OnErrorData;
process.BeginErrorReadLine();
return true;
}
catch (Exception exception) when (exception is IOException or
InvalidOperationException or
System.ComponentModel.Win32Exception)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Bink FFmpeg decoder could not start: {exception.Message}");
return false;
}
}
public bool TryDecodeNextFrame(Span<byte> destination)
{
try
{
var offset = 0;
while (offset < destination.Length)
{
var read = _output.Read(destination[offset..]);
if (read == 0)
{
return false;
}
offset += read;
}
return true;
}
catch (Exception exception) when (exception is IOException or ObjectDisposedException)
{
if (Volatile.Read(ref _disposed) == 0)
{
Console.Error.WriteLine(
$"[LOADER][WARN] Bink FFmpeg stream failed: {exception.Message}");
}
return false;
}
}
private void OnErrorData(object sender, DataReceivedEventArgs eventArgs)
{
if (string.IsNullOrWhiteSpace(eventArgs.Data) ||
Interlocked.Increment(ref _errorLines) > 20)
{
return;
}
Console.Error.WriteLine($"[LOADER][FFMPEG-BINK] {eventArgs.Data}");
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_output.Dispose();
try
{
if (!_process.HasExited)
{
_process.Kill(entireProcessTree: true);
}
}
catch (InvalidOperationException)
{
}
finally
{
_process.Dispose();
}
}
}
@@ -97,6 +97,9 @@ public static partial class KernelMemoryCompatExports
private static readonly object _fdGate = new(); private static readonly object _fdGate = new();
private static readonly Dictionary<int, FileStream> _openFiles = new(); private static readonly Dictionary<int, FileStream> _openFiles = new();
private static readonly Dictionary<int, Bink2MovieBridge.BinkGuestCompletionShim>
_binkGuestCompletionShims = new();
private static readonly Dictionary<int, string> _observedBinkGuestFiles = new();
private static readonly Dictionary<int, OpenDirectory> _openDirectories = new(); private static readonly Dictionary<int, OpenDirectory> _openDirectories = new();
private static readonly object _libcAllocGate = new(); private static readonly object _libcAllocGate = new();
private static readonly object _memoryGate = new(); private static readonly object _memoryGate = new();
@@ -268,13 +271,13 @@ public static partial class KernelMemoryCompatExports
} }
_nextVirtualAddress = Math.Max(_nextVirtualAddress, address + mappedLength); _nextVirtualAddress = Math.Max(_nextVirtualAddress, address + mappedLength);
_mappedRegions[address] = new MappedRegion( ReplaceMappedRegionRangeLocked(new MappedRegion(
address, address,
mappedLength, mappedLength,
OrbisProtCpuReadWrite, OrbisProtCpuReadWrite,
IsFlexible: false, IsFlexible: false,
IsDirect: false, IsDirect: false,
DirectStart: 0); DirectStart: 0));
} }
for (ulong offset = 0; offset < mappedLength;) for (ulong offset = 0; offset < mappedLength;)
@@ -300,13 +303,13 @@ public static partial class KernelMemoryCompatExports
lock (_memoryGate) lock (_memoryGate)
{ {
_mappedRegions[address] = new MappedRegion( ReplaceMappedRegionRangeLocked(new MappedRegion(
address, address,
length, length,
Protection: 0, Protection: 0,
IsFlexible: false, IsFlexible: false,
IsDirect: false, IsDirect: false,
DirectStart: 0); DirectStart: 0));
} }
} }
@@ -1468,6 +1471,14 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND; 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)) if (IsMutatingOpen(flags) && IsReadOnlyGuestMutationPath(guestPath))
{ {
LogOpenTrace($"_open readonly path='{guestPath}' host='{hostPath}' flags=0x{flags:X8}"); LogOpenTrace($"_open readonly path='{guestPath}' host='{hostPath}' flags=0x{flags:X8}");
@@ -1516,13 +1527,22 @@ public static partial class KernelMemoryCompatExports
lock (_fdGate) lock (_fdGate)
{ {
_openFiles[fd] = stream; _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 if (useBinkCompletionShim)
// import for the HLE codec layer to intercept. The successful {
// guest file open is the stable boundary at which the optional LogOpenTrace(
// host Bink bridge can attach to the same movie. "_open bink-host-shim path='" + guestPath + "' host='" + hostPath +
Bink2MovieBridge.ObserveGuestMovie(hostPath); "' flags=0x" + flags.ToString("X8") + " fd=" + fd);
}
if (IsMutatingOpen(flags)) if (IsMutatingOpen(flags))
{ {
@@ -2053,10 +2073,21 @@ public static partial class KernelMemoryCompatExports
} }
FileStream? stream; FileStream? stream;
var notifyBinkClose = false;
string? observedBinkPath = null;
lock (_fdGate) lock (_fdGate)
{ {
if (_openFiles.Remove(fd, out stream)) 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)) else if (_openDirectories.Remove(fd))
{ {
@@ -2069,6 +2100,10 @@ public static partial class KernelMemoryCompatExports
} }
} }
if (notifyBinkClose)
{
Bink2MovieBridge.NotifyGuestMovieClosed(observedBinkPath!);
}
stream.Dispose(); stream.Dispose();
ctx[CpuRegister.Rax] = 0; ctx[CpuRegister.Rax] = 0;
return (int)OrbisGen2Result.ORBIS_GEN2_OK; return (int)OrbisGen2Result.ORBIS_GEN2_OK;
@@ -2096,9 +2131,12 @@ public static partial class KernelMemoryCompatExports
} }
FileStream? stream; FileStream? stream;
Bink2MovieBridge.BinkGuestCompletionShim completionShim = default;
var useBinkCompletionShim = false;
lock (_fdGate) lock (_fdGate)
{ {
_openFiles.TryGetValue(fd, out stream); _openFiles.TryGetValue(fd, out stream);
useBinkCompletionShim = _binkGuestCompletionShims.TryGetValue(fd, out completionShim);
} }
if (stream is null) if (stream is null)
@@ -2118,6 +2156,17 @@ public static partial class KernelMemoryCompatExports
var buffer = GC.AllocateUninitializedArray<byte>(requested); var buffer = GC.AllocateUninitializedArray<byte>(requested);
var read = stream.Read(buffer, 0, 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))) if (read > 0 && !ctx.Memory.TryWrite(bufferAddress, buffer.AsSpan(0, read)))
{ {
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT; return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
@@ -3091,13 +3140,13 @@ public static partial class KernelMemoryCompatExports
} }
_nextVirtualAddress = Math.Max(_nextVirtualAddress, mappedAddress + length); _nextVirtualAddress = Math.Max(_nextVirtualAddress, mappedAddress + length);
_mappedRegions[mappedAddress] = new MappedRegion( ReplaceMappedRegionRangeLocked(new MappedRegion(
mappedAddress, mappedAddress,
length, length,
protection, protection,
IsFlexible: false, IsFlexible: false,
IsDirect: true, IsDirect: true,
DirectStart: directMemoryStart); DirectStart: directMemoryStart));
} }
if (!ctx.TryWriteUInt64(inOutAddressPointer, mappedAddress)) if (!ctx.TryWriteUInt64(inOutAddressPointer, mappedAddress))
@@ -3183,13 +3232,13 @@ public static partial class KernelMemoryCompatExports
_nextVirtualAddress = Math.Max(_nextVirtualAddress, mappedAddress + length); _nextVirtualAddress = Math.Max(_nextVirtualAddress, mappedAddress + length);
_allocatedFlexibleBytes = Math.Min(FlexibleMemorySizeBytes, _allocatedFlexibleBytes + length); _allocatedFlexibleBytes = Math.Min(FlexibleMemorySizeBytes, _allocatedFlexibleBytes + length);
_mappedRegions[mappedAddress] = new MappedRegion( ReplaceMappedRegionRangeLocked(new MappedRegion(
mappedAddress, mappedAddress,
length, length,
protection, protection,
IsFlexible: true, IsFlexible: true,
IsDirect: false, IsDirect: false,
DirectStart: 0); DirectStart: 0));
} }
if (!ctx.TryWriteUInt64(inOutAddressPointer, mappedAddress)) if (!ctx.TryWriteUInt64(inOutAddressPointer, mappedAddress))
@@ -2230,7 +2230,6 @@ internal static unsafe class VulkanVideoPresenter
if (IsGuestWorkCompletedLocked(pending.RequiredGuestWorkSequence)) if (IsGuestWorkCompletedLocked(pending.RequiredGuestWorkSequence))
{ {
presentation = _pendingGuestImagePresentations.Dequeue(); presentation = _pendingGuestImagePresentations.Dequeue();
TryReplaceWithBinkFrame(ref presentation);
return true; return true;
} }
@@ -2263,29 +2262,10 @@ internal static unsafe class VulkanVideoPresenter
} }
presentation = latest; presentation = latest;
TryReplaceWithBinkFrame(ref presentation);
return true; return true;
} }
} }
private static void TryReplaceWithBinkFrame(ref Presentation presentation)
{
if (!Bink2MovieBridge.TryDecodeNextFrame(out var pixels, out var width, out var height))
{
return;
}
presentation = new Presentation(
pixels,
width,
height,
presentation.Sequence,
GuestDrawKind.None,
TranslatedDraw: null,
presentation.RequiredGuestWorkSequence,
IsSplash: false);
}
private static readonly HashSet<long> _tracedGuestImagePresentRejections = new(); private static readonly HashSet<long> _tracedGuestImagePresentRejections = new();
private static bool HasPendingGuestPresentation(long presentedSequence) private static bool HasPendingGuestPresentation(long presentedSequence)
@@ -2835,6 +2815,40 @@ internal static unsafe class VulkanVideoPresenter
private VkBuffer _stagingBuffer; private VkBuffer _stagingBuffer;
private DeviceMemory _stagingMemory; private DeviceMemory _stagingMemory;
private ulong _stagingSize; private ulong _stagingSize;
private VkBuffer[] _frameUploadBuffers = [];
private DeviceMemory[] _frameUploadMemory = [];
private nint[] _frameUploadMapped = [];
private Image _hostMovieImage;
private DeviceMemory _hostMovieImageMemory;
private ImageView _hostMovieImageView;
private uint _hostMovieImageWidth;
private uint _hostMovieImageHeight;
private Format _hostMovieImageFormat;
private uint _hostMovieImageDstSelect;
private bool _hostMovieImageInitialized;
private Image _hostMovieChromaImage;
private DeviceMemory _hostMovieChromaImageMemory;
private ImageView _hostMovieChromaImageView;
private uint _hostMovieChromaImageWidth;
private uint _hostMovieChromaImageHeight;
private uint _hostMovieChromaImageDstSelect;
private bool _hostMovieChromaImageInitialized;
private byte[]? _hostMovieFramePixels;
private byte[]? _hostMovieLumaPixels;
private byte[]? _hostMovieChromaPixels;
private uint _hostMovieFrameWidth;
private uint _hostMovieFrameHeight;
private long _hostMovieFrameSerial;
private long _hostMovieConvertedFrameSerial = -1;
private long _hostMovieLumaUploadedFrameSerial = -1;
private long _hostMovieChromaUploadedFrameSerial = -1;
private string? _hostMovieFramePath;
private ulong _hostMovieLumaTextureAddress;
private ulong _hostMovieChromaTextureAddress;
private uint _hostMovieLumaDstSelect;
private uint _hostMovieChromaDstSelect;
private readonly HashSet<string> _tracedHostMovieTextureBindings =
new(StringComparer.Ordinal);
// Perf overlay: CPU-rasterized panel copied through per-slot staging // Perf overlay: CPU-rasterized panel copied through per-slot staging
// buffers into one image, then blitted onto the swapchain. // buffers into one image, then blitted onto the swapchain.
private Image _overlayImage; private Image _overlayImage;
@@ -3027,6 +3041,9 @@ internal static unsafe class VulkanVideoPresenter
public bool OwnsStorage; public bool OwnsStorage;
public bool IsStorage; public bool IsStorage;
public bool Cached; public bool Cached;
public bool IsHostMovie;
public int HostMoviePlane = -1;
public long HostMovieFrameSerial;
public ulong CpuContentFingerprint; public ulong CpuContentFingerprint;
public bool UpdatesCpuContent; public bool UpdatesCpuContent;
public GuestSampler SamplerState; public GuestSampler SamplerState;
@@ -4328,6 +4345,7 @@ internal static unsafe class VulkanVideoPresenter
var surfaceFormat = ChooseSurfaceFormat(formats); var surfaceFormat = ChooseSurfaceFormat(formats);
_swapchainFormat = surfaceFormat.Format; _swapchainFormat = surfaceFormat.Format;
_extent = ChooseExtent(capabilities); _extent = ChooseExtent(capabilities);
Bink2MovieBridge.SetPresentationSize(_extent.Width, _extent.Height);
var presentMode = ChoosePresentMode(); var presentMode = ChoosePresentMode();
var imageCount = capabilities.MinImageCount + 1; var imageCount = capabilities.MinImageCount + 1;
if (capabilities.MaxImageCount != 0) if (capabilities.MaxImageCount != 0)
@@ -4474,6 +4492,7 @@ internal static unsafe class VulkanVideoPresenter
_presentationCommandBuffer = _commandBuffer; _presentationCommandBuffer = _commandBuffer;
CreateStagingBuffer((ulong)_extent.Width * _extent.Height * 4); CreateStagingBuffer((ulong)_extent.Width * _extent.Height * 4);
CreateFrameUploadBuffers(_stagingSize);
CreateOverlayResources(); CreateOverlayResources();
} }
@@ -6076,10 +6095,15 @@ internal static unsafe class VulkanVideoPresenter
} }
} }
var hostMovieTextures = FindHostMovieTextureBindings(draw.Textures);
for (var index = 0; index < draw.Textures.Count; index++) for (var index = 0; index < draw.Textures.Count; index++)
{ {
var texture = draw.Textures[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 var feedbackTarget = !texture.IsStorage
? feedbackTargets?.FirstOrDefault(target => ? feedbackTargets?.FirstOrDefault(target =>
ReferenceEquals(resolved.GuestImage, target)) ReferenceEquals(resolved.GuestImage, target))
@@ -6934,6 +6958,343 @@ internal static unsafe class VulkanVideoPresenter
} }
} }
private void PumpHostMovieFrame()
{
if (!Bink2MovieBridge.TryDecodeNextFrame(
advanceClock: _hostMovieLumaTextureAddress != 0 &&
_hostMovieChromaTextureAddress != 0,
out var pixels,
out var width,
out var height,
out var advanced,
out var frameSerial,
out var hostPath))
{
// Keep the last decoded image until a replacement arrives.
// Movie sessions are queued asynchronously; clearing here
// exposes the guest decoder's neutral surfaces between the
// final frame of one movie and the first frame of the next.
return;
}
if (!string.Equals(
_hostMovieFramePath,
hostPath,
StringComparison.OrdinalIgnoreCase))
{
_hostMovieFramePath = hostPath;
_hostMovieLumaTextureAddress = 0;
_hostMovieChromaTextureAddress = 0;
_hostMovieLumaDstSelect = 0;
_hostMovieChromaDstSelect = 0;
_hostMovieConvertedFrameSerial = -1;
_hostMovieLumaUploadedFrameSerial = -1;
_hostMovieChromaUploadedFrameSerial = -1;
}
if (!advanced && _hostMovieFramePixels is not null)
{
return;
}
_hostMovieFramePixels = pixels;
_hostMovieFrameWidth = width;
_hostMovieFrameHeight = height;
_hostMovieFrameSerial = frameSerial;
}
private readonly record struct HostMovieTextureBindings(int Luma, int Chroma)
{
public static HostMovieTextureBindings None { get; } = new(-1, -1);
}
private HostMovieTextureBindings FindHostMovieTextureBindings(
IReadOnlyList<GuestDrawTexture> textures)
{
if (_hostMovieFramePixels is null ||
_hostMovieFrameWidth == 0 ||
_hostMovieFrameHeight == 0)
{
return HostMovieTextureBindings.None;
}
if (_hostMovieLumaTextureAddress != 0 &&
_hostMovieChromaTextureAddress != 0)
{
var lumaIndex = -1;
var chromaIndex = -1;
for (var index = 0; index < textures.Count; index++)
{
if (textures[index].Address == _hostMovieLumaTextureAddress)
{
lumaIndex = index;
}
else if (textures[index].Address == _hostMovieChromaTextureAddress)
{
chromaIndex = index;
}
}
if (lumaIndex >= 0 && chromaIndex >= 0)
{
return RememberHostMovieTextureMappings(
textures,
lumaIndex,
chromaIndex);
}
// Bluepoint alternates decoder output between multiple Y/UV
// surface pairs. Fall through and discover the active pair
// instead of sampling the stale guest surface on every other
// movie draw.
}
var bestLumaIndex = -1;
var bestChromaIndex = -1;
ulong bestArea = 0;
for (var lumaIndex = 0; lumaIndex < textures.Count; lumaIndex++)
{
var luma = textures[lumaIndex];
if (!IsHostMovieLumaCandidate(luma))
{
continue;
}
for (var chromaIndex = 0; chromaIndex < textures.Count; chromaIndex++)
{
var chroma = textures[chromaIndex];
if (!IsHostMovieChromaCandidate(luma, chroma))
{
continue;
}
var area = (ulong)luma.Width * luma.Height;
if (bestLumaIndex < 0 || area > bestArea)
{
bestLumaIndex = lumaIndex;
bestChromaIndex = chromaIndex;
bestArea = area;
}
}
}
if (bestLumaIndex < 0 || bestChromaIndex < 0)
{
return HostMovieTextureBindings.None;
}
var lumaTexture = textures[bestLumaIndex];
var chromaTexture = textures[bestChromaIndex];
_hostMovieLumaTextureAddress = lumaTexture.Address;
_hostMovieChromaTextureAddress = chromaTexture.Address;
_hostMovieLumaDstSelect = lumaTexture.DstSelect;
_hostMovieChromaDstSelect = chromaTexture.DstSelect;
var traceKey =
$"{_hostMovieFramePath}|{lumaTexture.Address:X16}|{chromaTexture.Address:X16}";
if (_tracedHostMovieTextureBindings.Add(traceKey))
{
Console.Error.WriteLine(
$"[LOADER][INFO] Bink2 YUV textures bound: " +
$"{Path.GetFileName(_hostMovieFramePath)} " +
$"y={bestLumaIndex}:0x{lumaTexture.Address:X16}:" +
$"{lumaTexture.Width}x{lumaTexture.Height}:dst=0x{lumaTexture.DstSelect:X} " +
$"uv={bestChromaIndex}:0x{chromaTexture.Address:X16}:" +
$"{chromaTexture.Width}x{chromaTexture.Height}:dst=0x{chromaTexture.DstSelect:X} " +
$"host={_hostMovieFrameWidth}x{_hostMovieFrameHeight}.");
}
return new HostMovieTextureBindings(bestLumaIndex, bestChromaIndex);
}
private HostMovieTextureBindings RememberHostMovieTextureMappings(
IReadOnlyList<GuestDrawTexture> textures,
int lumaIndex,
int chromaIndex)
{
_hostMovieLumaDstSelect = textures[lumaIndex].DstSelect;
_hostMovieChromaDstSelect = textures[chromaIndex].DstSelect;
return new HostMovieTextureBindings(lumaIndex, chromaIndex);
}
private bool IsHostMovieLumaCandidate(GuestDrawTexture texture)
{
if (texture.Address == 0 ||
texture.IsStorage ||
texture.IsFallback ||
texture.ArrayedView ||
texture.ArrayLayers > 1 ||
texture.Format != 1 ||
texture.NumberType != 0 ||
texture.Width < 1280 ||
texture.Height < 720)
{
return false;
}
var guestAspect = (ulong)texture.Width * _hostMovieFrameHeight;
var hostAspect = (ulong)texture.Height * _hostMovieFrameWidth;
var difference = guestAspect > hostAspect
? guestAspect - hostAspect
: hostAspect - guestAspect;
return difference * 100 <= Math.Max(guestAspect, hostAspect) * 2;
}
private static bool IsHostMovieChromaCandidate(
GuestDrawTexture luma,
GuestDrawTexture chroma)
{
return chroma.Address != 0 &&
chroma.Address != luma.Address &&
!chroma.IsStorage &&
!chroma.IsFallback &&
!chroma.ArrayedView &&
chroma.ArrayLayers <= 1 &&
chroma.Format == 3 &&
chroma.NumberType == 0 &&
luma.Width == chroma.Width * 2 &&
luma.Height == chroma.Height * 2;
}
private TextureResource CreateHostMovieTextureResource(
GuestDrawTexture texture,
int plane)
{
EnsureHostMovieYuvFrame();
var isLuma = plane == 0;
var pixels = isLuma ? _hostMovieLumaPixels! : _hostMovieChromaPixels!;
var width = isLuma
? _hostMovieFrameWidth
: (_hostMovieFrameWidth + 1) / 2;
var height = isLuma
? _hostMovieFrameHeight
: (_hostMovieFrameHeight + 1) / 2;
EnsureHostMovieImages(
_hostMovieFrameWidth,
_hostMovieFrameHeight,
_hostMovieLumaDstSelect,
(_hostMovieFrameWidth + 1) / 2,
(_hostMovieFrameHeight + 1) / 2,
_hostMovieChromaDstSelect);
var uploadedFrameSerial = isLuma
? _hostMovieLumaUploadedFrameSerial
: _hostMovieChromaUploadedFrameSerial;
var needsUpload = uploadedFrameSerial != _hostMovieFrameSerial;
VkBuffer stagingBuffer = default;
DeviceMemory stagingMemory = default;
if (needsUpload)
{
(stagingBuffer, stagingMemory) = CreateTextureStagingBuffer(
pixels,
$"Bink2 frame {_hostMovieFrameSerial} plane {plane} staging");
}
return new TextureResource
{
Address = texture.Address,
StagingBuffer = stagingBuffer,
StagingMemory = stagingMemory,
Image = isLuma ? _hostMovieImage : _hostMovieChromaImage,
View = isLuma ? _hostMovieImageView : _hostMovieChromaImageView,
Width = width,
Height = height,
RowLength = width,
DstSelect = texture.DstSelect,
NeedsUpload = needsUpload,
IsHostMovie = true,
HostMoviePlane = plane,
HostMovieFrameSerial = _hostMovieFrameSerial,
SamplerState = texture.Sampler,
};
}
private void EnsureHostMovieYuvFrame()
{
if (_hostMovieConvertedFrameSerial == _hostMovieFrameSerial)
{
return;
}
var bgra = _hostMovieFramePixels ??
throw new InvalidOperationException("Host movie frame is unavailable.");
var width = checked((int)_hostMovieFrameWidth);
var height = checked((int)_hostMovieFrameHeight);
var chromaWidth = (width + 1) / 2;
var chromaHeight = (height + 1) / 2;
if (_hostMovieLumaPixels?.Length != width * height)
{
_hostMovieLumaPixels = GC.AllocateUninitializedArray<byte>(width * height);
}
if (_hostMovieChromaPixels?.Length != chromaWidth * chromaHeight * 2)
{
_hostMovieChromaPixels =
GC.AllocateUninitializedArray<byte>(chromaWidth * chromaHeight * 2);
}
ConvertBgraToYuv420(
bgra,
width,
height,
_hostMovieLumaPixels,
_hostMovieChromaPixels);
_hostMovieConvertedFrameSerial = _hostMovieFrameSerial;
}
internal static void ConvertBgraToYuv420(
ReadOnlySpan<byte> bgra,
int width,
int height,
Span<byte> luma,
Span<byte> chroma)
{
var chromaWidth = (width + 1) / 2;
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
var source = (y * width + x) * 4;
var b = bgra[source];
var g = bgra[source + 1];
var r = bgra[source + 2];
luma[y * width + x] = ClampByte(
(54 * r + 183 * g + 19 * b + 128) >> 8);
}
}
for (var y = 0; y < height; y += 2)
{
for (var x = 0; x < width; x += 2)
{
var red = 0;
var green = 0;
var blue = 0;
var samples = 0;
for (var sampleY = y; sampleY < Math.Min(y + 2, height); sampleY++)
{
for (var sampleX = x; sampleX < Math.Min(x + 2, width); sampleX++)
{
var source = (sampleY * width + sampleX) * 4;
blue += bgra[source];
green += bgra[source + 1];
red += bgra[source + 2];
samples++;
}
}
red /= samples;
green /= samples;
blue /= samples;
var destination = ((y / 2) * chromaWidth + x / 2) * 2;
chroma[destination] = ClampByte(
((128 * red - 116 * green - 12 * blue + 128) >> 8) + 128);
chroma[destination + 1] = ClampByte(
((-29 * red - 99 * green + 128 * blue + 128) >> 8) + 128);
}
}
}
private static byte ClampByte(int value) => (byte)Math.Clamp(value, 0, 255);
[MethodImpl(MethodImplOptions.NoInlining)] [MethodImpl(MethodImplOptions.NoInlining)]
private TextureResource ResolveTextureResource(GuestDrawTexture texture) private TextureResource ResolveTextureResource(GuestDrawTexture texture)
{ {
@@ -9539,6 +9900,26 @@ internal static unsafe class VulkanVideoPresenter
_stagingSize = size; _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) private uint FindMemoryType(uint typeBits, MemoryPropertyFlags requiredFlags)
{ {
_vk.GetPhysicalDeviceMemoryProperties(_physicalDevice, out var properties); _vk.GetPhysicalDeviceMemoryProperties(_physicalDevice, out var properties);
@@ -9590,6 +9971,8 @@ internal static unsafe class VulkanVideoPresenter
return; return;
} }
PumpHostMovieFrame();
if (_skipAllCompute || if (_skipAllCompute ||
AddressListContains("SHARPEMU_SKIP_COMPUTE_CS", work.ShaderAddress) || AddressListContains("SHARPEMU_SKIP_COMPUTE_CS", work.ShaderAddress) ||
(_skipTallComputeZ > 0 && work.GroupCountZ >= _skipTallComputeZ)) (_skipTallComputeZ > 0 && work.GroupCountZ >= _skipTallComputeZ))
@@ -12476,10 +12859,12 @@ internal static unsafe class VulkanVideoPresenter
EvictDirtyCachedTextures(); EvictDirtyCachedTextures();
var completedWork = 0; var completedWork = 0;
HashSet<string>? deferredOrderedQueues = null; HashSet<string>? deferredOrderedQueues = null;
var renderWorkDeadline = _renderWorkBudgetTicks > 0 var workBudgetTicks = _renderWorkBudgetTicks;
? System.Diagnostics.Stopwatch.GetTimestamp() + _renderWorkBudgetTicks var renderWorkDeadline = workBudgetTicks > 0
? System.Diagnostics.Stopwatch.GetTimestamp() + workBudgetTicks
: long.MaxValue; : long.MaxValue;
while (completedWork < _maxGuestWorkPerRender) var workLimit = _maxGuestWorkPerRender;
while (completedWork < workLimit)
{ {
// Never block the macOS main thread waiting for in-flight GPU // Never block the macOS main thread waiting for in-flight GPU
// work to drain. If submission is at capacity (a slow-compute // work to drain. If submission is at capacity (a slow-compute
@@ -12536,6 +12921,14 @@ internal static unsafe class VulkanVideoPresenter
} }
try 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) switch (work)
{ {
case VulkanOffscreenGuestDraw offscreenDraw: case VulkanOffscreenGuestDraw offscreenDraw:
@@ -12613,7 +13006,8 @@ internal static unsafe class VulkanVideoPresenter
FlushBatchedGuestCommands(); FlushBatchedGuestCommands();
CollectAbandonedGuestImageVersions(); CollectAbandonedGuestImageVersions();
if (!TryTakePresentation(_presentedSequence, out var presentation)) Presentation presentation;
if (!TryTakePresentation(_presentedSequence, out presentation))
{ {
if (_pendingHostSplashReplay is { } splash) if (_pendingHostSplashReplay is { } splash)
{ {
@@ -12637,7 +13031,6 @@ internal static unsafe class VulkanVideoPresenter
return; return;
} }
} }
if (_hostSurface is not null) if (_hostSurface is not null)
{ {
if (presentation.IsSplash && presentation.Pixels is not null) if (presentation.IsSplash && presentation.Pixels is not null)
@@ -12690,6 +13083,7 @@ internal static unsafe class VulkanVideoPresenter
{ {
return; return;
} }
} }
TranslatedDrawResources? translatedResources = null; TranslatedDrawResources? translatedResources = null;
@@ -12817,19 +13211,11 @@ internal static unsafe class VulkanVideoPresenter
if (pixels is not null) if (pixels is not null)
{ {
// The staging buffer is shared across frame slots; a CPU var mapped = (void*)_frameUploadMapped[frameSlot];
// pixel upload (splash / host frames) degrades to serial
// presentation rather than corrupting an in-flight copy.
WaitAllFrameSlots();
void* mapped;
Check(
_vk.MapMemory(_device, _stagingMemory, 0, (ulong)pixels.Length, 0, &mapped),
"vkMapMemory");
fixed (byte* source = pixels) fixed (byte* source = pixels)
{ {
System.Buffer.MemoryCopy(source, mapped, pixels.Length, pixels.Length); System.Buffer.MemoryCopy(source, mapped, pixels.Length, pixels.Length);
} }
_vk.UnmapMemory(_device, _stagingMemory);
} }
Check(_vk.ResetCommandBuffer(_commandBuffer, 0), "vkResetCommandBuffer"); Check(_vk.ResetCommandBuffer(_commandBuffer, 0), "vkResetCommandBuffer");
@@ -12843,7 +13229,7 @@ internal static unsafe class VulkanVideoPresenter
PipelineStageFlags waitStage; PipelineStageFlags waitStage;
if (pixels is not null) if (pixels is not null)
{ {
RecordUpload(imageIndex); RecordUpload(imageIndex, frameSlot);
waitStage = PipelineStageFlags.TransferBit; waitStage = PipelineStageFlags.TransferBit;
} }
else if (presentation.DrawKind == GuestDrawKind.FullscreenBarycentric) else if (presentation.DrawKind == GuestDrawKind.FullscreenBarycentric)
@@ -13365,11 +13751,22 @@ internal static unsafe class VulkanVideoPresenter
continue; continue;
} }
var hostMovieImageInitialized = texture.HostMoviePlane switch
{
0 => _hostMovieImageInitialized,
1 => _hostMovieChromaImageInitialized,
_ => false,
};
var toTransfer = new ImageMemoryBarrier var toTransfer = new ImageMemoryBarrier
{ {
SType = StructureType.ImageMemoryBarrier, SType = StructureType.ImageMemoryBarrier,
SrcAccessMask = texture.IsHostMovie && hostMovieImageInitialized
? AccessFlags.ShaderReadBit
: 0,
DstAccessMask = AccessFlags.TransferWriteBit, DstAccessMask = AccessFlags.TransferWriteBit,
OldLayout = ImageLayout.Undefined, OldLayout = texture.IsHostMovie && hostMovieImageInitialized
? ImageLayout.ShaderReadOnlyOptimal
: ImageLayout.Undefined,
NewLayout = ImageLayout.TransferDstOptimal, NewLayout = ImageLayout.TransferDstOptimal,
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored, SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Vk.QueueFamilyIgnored, DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
@@ -13378,7 +13775,9 @@ internal static unsafe class VulkanVideoPresenter
}; };
_vk.CmdPipelineBarrier( _vk.CmdPipelineBarrier(
_commandBuffer, _commandBuffer,
PipelineStageFlags.TopOfPipeBit, texture.IsHostMovie && hostMovieImageInitialized
? PipelineStageFlags.AllCommandsBit
: PipelineStageFlags.TopOfPipeBit,
PipelineStageFlags.TransferBit, PipelineStageFlags.TransferBit,
0, 0,
0, 0,
@@ -13438,6 +13837,19 @@ internal static unsafe class VulkanVideoPresenter
// reuse the image without restaging it. // reuse the image without restaging it.
texture.NeedsUpload = false; 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] var oldLayout = _imageInitialized[imageIndex]
? ImageLayout.PresentSrcKhr ? ImageLayout.PresentSrcKhr
@@ -14793,7 +15374,7 @@ internal static unsafe class VulkanVideoPresenter
}; };
_vk.CmdCopyBufferToImage( _vk.CmdCopyBufferToImage(
_commandBuffer, _commandBuffer,
_stagingBuffer, _frameUploadBuffers[frameSlot],
_swapchainImages[imageIndex], _swapchainImages[imageIndex],
ImageLayout.TransferDstOptimal, ImageLayout.TransferDstOptimal,
1, 1,
@@ -15661,7 +16242,26 @@ internal static unsafe class VulkanVideoPresenter
private void DestroySwapchainResources() private void DestroySwapchainResources()
{ {
DestroyHostMovieImage();
DestroyPresentEncodeImage(); 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) if (_stagingBuffer.Handle != 0)
{ {
_vk.DestroyBuffer(_device, _stagingBuffer, null); _vk.DestroyBuffer(_device, _stagingBuffer, null);
@@ -0,0 +1,93 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
public sealed class AgcPredicationTests
{
private const ulong BaseAddress = 0x1_0000_0000;
private const ulong CommandBufferAddress = BaseAddress + 0x100;
private const ulong PacketAddress = BaseAddress + 0x400;
private const ulong PredicateAddress = BaseAddress + 0x800;
[Fact]
public void DcbSetPredication_EmitsGen5Packet()
{
var memory = new FakeCpuMemory(BaseAddress, 0x2000);
var ctx = new CpuContext(memory, Generation.Gen5);
WriteUInt64(memory, CommandBufferAddress + 0x10, PacketAddress);
WriteUInt64(memory, CommandBufferAddress + 0x18, PacketAddress + 0x100);
ctx[CpuRegister.Rdi] = CommandBufferAddress;
ctx[CpuRegister.Rsi] = 1;
ctx[CpuRegister.Rdx] = 3;
ctx[CpuRegister.Rcx] = 1;
ctx[CpuRegister.R8] = PredicateAddress + 7;
ctx[CpuRegister.R9] = 2;
var result = AgcExports.DcbSetPredication(ctx);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
Assert.Equal(PacketAddress, ctx[CpuRegister.Rax]);
Assert.Equal(0xC002_2000u, ReadUInt32(memory, PacketAddress));
Assert.Equal(0x0003_1100u, ReadUInt32(memory, PacketAddress + 4));
Assert.Equal(unchecked((uint)PredicateAddress), ReadUInt32(memory, PacketAddress + 8));
Assert.Equal((uint)(PredicateAddress >> 32), ReadUInt32(memory, PacketAddress + 12));
Assert.Equal(PacketAddress + 16, ReadUInt64(memory, CommandBufferAddress + 0x10));
}
[Fact]
public void SetPacketPredication_TogglesPacketHeaderBit()
{
var memory = new FakeCpuMemory(BaseAddress, 0x1000);
var ctx = new CpuContext(memory, Generation.Gen5);
const uint header = 0xC003_1500;
WriteUInt32(memory, PacketAddress, header);
ctx[CpuRegister.Rdi] = PacketAddress;
ctx[CpuRegister.Rsi] = 1;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
AgcExports.SetPacketPredication(ctx));
Assert.Equal(header | 1u, ReadUInt32(memory, PacketAddress));
ctx[CpuRegister.Rsi] = 0;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
AgcExports.SetPacketPredication(ctx));
Assert.Equal(header, ReadUInt32(memory, PacketAddress));
}
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[4];
Assert.True(memory.TryRead(address, buffer));
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
}
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[8];
Assert.True(memory.TryRead(address, buffer));
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
}
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
{
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
Assert.True(memory.TryWrite(address, buffer));
}
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
{
Span<byte> buffer = stackalloc byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
Assert.True(memory.TryWrite(address, buffer));
}
}
@@ -0,0 +1,61 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
public sealed class AgcResourceOwnerTests
{
private const ulong BaseAddress = 0x1_0000_0000;
private const ulong OwnerAddress = BaseAddress + 0x100;
private const ulong NameAddress = BaseAddress + 0x200;
private const ulong RegistrationMemoryAddress = BaseAddress + 0x400;
[Fact]
public void RegisterOwner_DoesNotRequireOptionalResourceRegistryMemory()
{
var memory = new FakeCpuMemory(BaseAddress, 0x2000);
var ctx = new CpuContext(memory, Generation.Gen5);
memory.WriteCString(NameAddress, "GIRender");
ctx[CpuRegister.Rdi] = OwnerAddress;
ctx[CpuRegister.Rsi] = NameAddress;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DriverRegisterOwner(ctx));
Assert.NotEqual(0u, ReadUInt32(memory, OwnerAddress));
}
[Fact]
public void RegisterOwner_RespectsExplicitRegistryCapacity()
{
var memory = new FakeCpuMemory(BaseAddress, 0x2000);
var ctx = new CpuContext(memory, Generation.Gen5);
ctx[CpuRegister.Rdi] = RegistrationMemoryAddress;
ctx[CpuRegister.Rsi] = 0x1000;
ctx[CpuRegister.Rdx] = 1;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_OK,
AgcExports.DriverInitResourceRegistration(ctx));
memory.WriteCString(NameAddress, "First");
ctx[CpuRegister.Rdi] = OwnerAddress;
ctx[CpuRegister.Rsi] = NameAddress;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DriverRegisterOwner(ctx));
memory.WriteCString(NameAddress, "Second");
ctx[CpuRegister.Rdi] = OwnerAddress + 4;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
AgcExports.DriverRegisterOwner(ctx));
}
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[4];
Assert.True(memory.TryRead(address, buffer));
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
}
}
@@ -0,0 +1,133 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
using Xunit;
namespace SharpEmu.Libs.Tests.Agc;
public sealed class AgcWaitRegMemTests
{
private const ulong BaseAddress = 0x1_0000_0000;
private const ulong CommandBufferAddress = BaseAddress + 0x100;
private const ulong PacketAddress = BaseAddress + 0x400;
private const ulong StackAddress = BaseAddress + 0x800;
[Fact]
public void DcbWaitRegMem32_EmitsGen5PacketLayout()
{
var memory = CreateMemory(out var ctx);
var waitAddress = BaseAddress + 0xC03;
ctx[CpuRegister.Rdi] = CommandBufferAddress;
ctx[CpuRegister.Rsi] = 0;
ctx[CpuRegister.Rdx] = 3;
ctx[CpuRegister.Rcx] = 4;
ctx[CpuRegister.R8] = 2;
ctx[CpuRegister.R9] = waitAddress;
WriteUInt64(memory, StackAddress + 8, 0x1122_3344_5566_7788);
WriteUInt64(memory, StackAddress + 16, 0xAABB_CCDD_EEFF_0011);
WriteUInt32(memory, StackAddress + 24, 0x123456);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DcbWaitRegMem(ctx));
Assert.Equal(PacketAddress, ctx[CpuRegister.Rax]);
Assert.Equal(0xC005_1028u, ReadUInt32(memory, PacketAddress));
Assert.Equal(0x0000_0C00u, ReadUInt32(memory, PacketAddress + 4));
Assert.Equal(1u, ReadUInt32(memory, PacketAddress + 8));
Assert.Equal(0xEEFF_0011u, ReadUInt32(memory, PacketAddress + 12));
Assert.Equal(0x5566_7788u, ReadUInt32(memory, PacketAddress + 16));
Assert.Equal(0x0400_0053u, ReadUInt32(memory, PacketAddress + 20));
Assert.Equal(0xFFFFu, ReadUInt32(memory, PacketAddress + 24));
Assert.Equal(PacketAddress + 28, ReadUInt64(memory, CommandBufferAddress + 0x10));
}
[Fact]
public void DcbWaitRegMem64_EmitsGen5PacketLayout()
{
var memory = CreateMemory(out var ctx);
var waitAddress = BaseAddress + 0xC07;
ctx[CpuRegister.Rdi] = CommandBufferAddress;
ctx[CpuRegister.Rsi] = 1;
ctx[CpuRegister.Rdx] = 6;
ctx[CpuRegister.Rcx] = 3;
ctx[CpuRegister.R8] = 1;
ctx[CpuRegister.R9] = waitAddress;
WriteUInt64(memory, StackAddress + 8, 0x1122_3344_5566_7788);
WriteUInt64(memory, StackAddress + 16, 0xAABB_CCDD_EEFF_0011);
WriteUInt32(memory, StackAddress + 24, 0x320);
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DcbWaitRegMem(ctx));
Assert.Equal(0xC007_1058u, ReadUInt32(memory, PacketAddress));
Assert.Equal(0x0000_0C00u, ReadUInt32(memory, PacketAddress + 4));
Assert.Equal(1u, ReadUInt32(memory, PacketAddress + 8));
Assert.Equal(0xEEFF_0011u, ReadUInt32(memory, PacketAddress + 12));
Assert.Equal(0xAABB_CCDDu, ReadUInt32(memory, PacketAddress + 16));
Assert.Equal(0x5566_7788u, ReadUInt32(memory, PacketAddress + 20));
Assert.Equal(0x1122_3344u, ReadUInt32(memory, PacketAddress + 24));
Assert.Equal(0x0200_0156u, ReadUInt32(memory, PacketAddress + 28));
Assert.Equal(0x32u, ReadUInt32(memory, PacketAddress + 32));
}
[Fact]
public void WaitRegMemPatchFunctions_UseGen5Fields()
{
var memory = CreateMemory(out var ctx);
WriteUInt32(memory, PacketAddress, 0xC005_1028);
WriteUInt32(memory, PacketAddress + 20, 0x0400_0153);
ctx[CpuRegister.Rdi] = PacketAddress;
ctx[CpuRegister.Rsi] = BaseAddress + 0xD07;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.WaitRegMemPatchAddress(ctx));
Assert.Equal(0x0000_0D04u, ReadUInt32(memory, PacketAddress + 4));
Assert.Equal(1u, ReadUInt32(memory, PacketAddress + 8));
ctx[CpuRegister.Rsi] = 5;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.WaitRegMemPatchCompareFunction(ctx));
Assert.Equal(0x0400_0155u, ReadUInt32(memory, PacketAddress + 20));
ctx[CpuRegister.Rsi] = 0xDEAD_BEEF;
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.WaitRegMemPatchReference(ctx));
Assert.Equal(0xDEAD_BEEFu, ReadUInt32(memory, PacketAddress + 16));
}
private static FakeCpuMemory CreateMemory(out CpuContext ctx)
{
var memory = new FakeCpuMemory(BaseAddress, 0x2000);
ctx = new CpuContext(memory, Generation.Gen5);
ctx[CpuRegister.Rsp] = StackAddress;
WriteUInt64(memory, CommandBufferAddress + 0x10, PacketAddress);
WriteUInt64(memory, CommandBufferAddress + 0x18, PacketAddress + 0x100);
return memory;
}
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[4];
Assert.True(memory.TryRead(address, buffer));
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
}
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
{
Span<byte> buffer = stackalloc byte[8];
Assert.True(memory.TryRead(address, buffer));
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
}
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
{
Span<byte> buffer = stackalloc byte[4];
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
Assert.True(memory.TryWrite(address, buffer));
}
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
{
Span<byte> buffer = stackalloc byte[8];
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
Assert.True(memory.TryWrite(address, buffer));
}
}
@@ -37,8 +37,12 @@ public sealed class GnmTilingDetileTests
return offset; return offset;
} }
[Fact] [Theory]
public void TryDetile_ExactXorMode27_MatchesReferenceAddressEquation() [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 uint swizzleMode = 27; // 64 KiB RB+ R_X
const int bytesPerElement = 2; const int bytesPerElement = 2;
@@ -46,8 +50,6 @@ public sealed class GnmTilingDetileTests
// SquareBlockDimensions(32768 elements): 15 bits split 8/7, x favored. // SquareBlockDimensions(32768 elements): 15 bits split 8/7, x favored.
const int blockWidth = 256; const int blockWidth = 256;
const int blockHeight = 128; 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 blocksPerRow = (elementsWide + blockWidth - 1) / blockWidth;
var blocksPerColumn = (elementsHigh + blockHeight - 1) / blockHeight; var blocksPerColumn = (elementsHigh + blockHeight - 1) / blockHeight;
@@ -99,6 +99,25 @@ public sealed class AvPlayerPathTests : IDisposable
AvPlayerExports.GetFfprobePath(ffmpeg, isWindows)); 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] [Fact]
public void RelativeFileUriCannotEscapeApp0() public void RelativeFileUriCannotEscapeApp0()
{ {
@@ -0,0 +1,79 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.Libs.Bink;
using Xunit;
namespace SharpEmu.Libs.Tests.Bink;
public sealed class Bink2MovieBridgeTests : IDisposable
{
private readonly string _tempDirectory = Path.Combine(
Path.GetTempPath(),
$"sharpemu-bink-{Guid.NewGuid():N}");
public Bink2MovieBridgeTests()
{
Directory.CreateDirectory(_tempDirectory);
}
[Fact]
public void HeaderPreservesFractionalFrameRate()
{
var path = WriteHeader("KB2j"u8, 3840, 2160, 30_000, 1_001);
Assert.True(Bink2MovieBridge.TryReadBinkInfo(path, out var info));
Assert.Equal(3840u, info.Width);
Assert.Equal(2160u, info.Height);
Assert.Equal(30_000u, info.FramesPerSecondNumerator);
Assert.Equal(1_001u, info.FramesPerSecondDenominator);
}
[Theory]
[InlineData("KB2g")]
[InlineData("KB2i")]
[InlineData("KB2j")]
public void HeaderAcceptsBink2Revisions(string signature)
{
var path = WriteHeader(
System.Text.Encoding.ASCII.GetBytes(signature),
1920,
1080,
60,
1);
Assert.True(Bink2MovieBridge.TryReadBinkInfo(path, out _));
}
[Fact]
public void HeaderRejectsMissingFrameRateDenominator()
{
var path = WriteHeader("KB2j"u8, 1920, 1080, 60, 0);
Assert.False(Bink2MovieBridge.TryReadBinkInfo(path, out _));
}
private string WriteHeader(
ReadOnlySpan<byte> signature,
uint width,
uint height,
uint fpsNumerator,
uint fpsDenominator)
{
var header = new byte[36];
signature.CopyTo(header);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x14), width);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x18), height);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x1C), fpsNumerator);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x20), fpsDenominator);
var path = Path.Combine(_tempDirectory, $"{Guid.NewGuid():N}.bk2");
File.WriteAllBytes(path, header);
return path;
}
public void Dispose()
{
Directory.Delete(_tempDirectory, recursive: true);
}
}
@@ -0,0 +1,105 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.Bink;
using Xunit;
namespace SharpEmu.Libs.Tests.Bink;
public sealed class BinkFramePlaybackTests
{
[Fact]
public void FramesAdvanceAccordingToMovieClock()
{
using var playback = new BinkFramePlayback(new SequenceDecoder(1, 2, 3));
Assert.Equal(1, WaitForAdvancedFrame(playback)[0]);
Assert.True(playback.TryGetFrame(true, out var heldFrame, out var advanced));
Assert.False(advanced);
Assert.Equal(1, heldFrame[0]);
Assert.Equal(2, WaitForAdvancedFrame(playback)[0]);
Assert.Equal(3, WaitForAdvancedFrame(playback)[0]);
}
private static byte[] WaitForAdvancedFrame(BinkFramePlayback playback)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
while (DateTime.UtcNow < deadline)
{
if (playback.TryGetFrame(true, out var frame, out var advanced) && advanced)
{
return frame;
}
Thread.Sleep(1);
}
throw new TimeoutException("The decoder did not produce a frame.");
}
[Fact]
public void FirstFrameWaitsUntilPresentationStarts()
{
using var playback = new BinkFramePlayback(new SequenceDecoder(1, 2));
var first = WaitForFrame(playback, advanceClock: false);
Assert.Equal(1, first[0]);
Thread.Sleep(100);
Assert.True(playback.TryGetFrame(false, out var held, out var advanced));
Assert.False(advanced);
Assert.Equal(1, held[0]);
Assert.True(playback.TryGetFrame(true, out held, out advanced));
Assert.False(advanced);
Assert.Equal(1, held[0]);
Assert.Equal(2, WaitForAdvancedFrame(playback)[0]);
}
private static byte[] WaitForFrame(
BinkFramePlayback playback,
bool advanceClock)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
while (DateTime.UtcNow < deadline)
{
if (playback.TryGetFrame(advanceClock, out var frame, out _))
{
return frame;
}
Thread.Sleep(1);
}
throw new TimeoutException("The decoder did not produce a frame.");
}
private sealed class SequenceDecoder(params byte[] values) : IBinkFrameDecoder
{
private int _index;
public uint Width => 1;
public uint Height => 1;
public uint FramesPerSecondNumerator => 20;
public uint FramesPerSecondDenominator => 1;
public bool TryDecodeNextFrame(Span<byte> destination)
{
if (_index >= values.Length)
{
return false;
}
destination.Fill(values[_index++]);
return true;
}
public void Dispose()
{
}
}
}
@@ -301,6 +301,38 @@ public sealed class KernelMemoryCompatExportsTests
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT, result); 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] [Fact]
public void Mprotect_ZeroAddressReturnsInvalidArgument() public void Mprotect_ZeroAddressReturnsInvalidArgument()
{ {