mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-26 20:50:53 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f37dd85e0 | |||
| 38621e7be9 | |||
| c9c0793059 | |||
| d3600c9255 | |||
| 5f97031df5 | |||
| 2a4da8c0a9 | |||
| 4c37e64c66 | |||
| fc9e3ff393 | |||
| eb47d753f6 | |||
| 6aa78bb55b | |||
| 9be6f85ef0 | |||
| 4c8c67a3dd | |||
| ada67a1924 | |||
| 2379e8988c | |||
| 105c58b380 | |||
| da35f0db47 | |||
| 1f3963c543 | |||
| 4bb1af93d7 | |||
| 0ae785c617 | |||
| e01092aa38 | |||
| 224a36eba7 | |||
| ac883e44fa |
@@ -108,10 +108,17 @@ release includes the MoltenVK Vulkan implementation.
|
||||
## Build
|
||||
|
||||
1. Install the .NET SDK version specified in [`global.json`](./global.json).
|
||||
2. Clone the repository: `git clone https://github.com/sharpemu/sharpemu.git`
|
||||
3. Open the solution file (`SharpEmu.slnx`) in **VSCode**.
|
||||
4. Build the project: `dotnet build` or `dotnet publish`
|
||||
5. Build artifacts will be located in the `artifacts` directory.
|
||||
2. `dotnet publish` also builds the Bink 2 bridge
|
||||
(`native/bink2-bridge/sharpemu_bink2_bridge.c`) from source, so also
|
||||
install:
|
||||
* **Windows:** [CMake](https://cmake.org/download/), [Ninja](https://github.com/ninja-build/ninja/releases), and [LLVM](https://github.com/llvm/llvm-project/releases) (for `clang-cl`)
|
||||
* **Linux/macOS:** CMake and a C compiler toolchain (e.g. `build-essential` on Linux, Xcode Command Line Tools on macOS)
|
||||
|
||||
`dotnet build` alone doesn't need these; it skips the bridge.
|
||||
3. Clone the repository: `git clone https://github.com/sharpemu/sharpemu.git`
|
||||
4. Open the solution file (`SharpEmu.slnx`) in **VSCode**.
|
||||
5. Build the project: `dotnet build` or `dotnet publish`
|
||||
6. Build artifacts will be located in the `artifacts` directory.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
|
||||
+32
-14
@@ -14,31 +14,49 @@ available, presents its decoded BGRA frames at the normal guest-flip boundary.
|
||||
This preserves the game's own timing and lets the host Vulkan presenter display
|
||||
the movie without trying to execute the PS5-specific Bink GPU decode path.
|
||||
|
||||
Without an adapter, Bink files remain visible to the guest and the game's
|
||||
statically linked decoder runs normally. Set SHARPEMU_BINK_MODE=skip only when
|
||||
explicitly testing a title whose cinematics are optional.
|
||||
The default path decodes through the bundled FFmpeg-backed native bridge
|
||||
(`native/bink2-bridge/sharpemu_bink2_bridge.c`); see "Supplying the adapter"
|
||||
below for where that binary comes from. Set `SHARPEMU_BINK_MODE=guest` to
|
||||
leave decoding to the Bink implementation statically linked into the game
|
||||
instead. Set `skip` only when explicitly testing a title whose cinematics are
|
||||
optional.
|
||||
|
||||
Set SHARPEMU_BINK_MODE=dummy to retain the open and show a built-in,
|
||||
non-decoded placeholder frame. This requires no SDK, but is a visual diagnostic
|
||||
only; it does not decode the movie or alter its game logic. Set
|
||||
SHARPEMU_BINK_MODE=native to force native bridge mode.
|
||||
only; it does not decode the movie or alter its game logic.
|
||||
SHARPEMU_BINK_MODE=native is equivalent to the default and mainly useful for
|
||||
being explicit about it.
|
||||
|
||||
The experimental `SHARPEMU_BINK_MODE=ffmpeg` override forces a host FFmpeg
|
||||
source. SharpEmu searches
|
||||
`SHARPEMU_FFMPEG_PATH`, the executable directory, its `ffmpeg` subdirectory,
|
||||
and then `PATH`. The FFmpeg build must contain the Bink 2 decoder; stock FFmpeg
|
||||
builds that only recognize the Bink container are not sufficient.
|
||||
|
||||
## Supplying the adapter
|
||||
|
||||
Bink 2 is proprietary. Obtain a compatible Mac Bink 2 SDK from RAD Game Tools,
|
||||
then compile sharpemu_bink2_bridge.c against the SDK's bink.h and Mac library.
|
||||
The adapter deliberately contains only a three-function C ABI so the managed
|
||||
emulator never depends on RAD's private binary ABI.
|
||||
The adapter (`native/bink2-bridge/sharpemu_bink2_bridge.c`) links against a
|
||||
custom FFmpeg build (`github.com/sharpemu/ffmpeg-core`, LGPL-2.1) that adds a
|
||||
Bink 2 decoder to FFmpeg 7.1.2; no proprietary RAD SDK is needed to build or
|
||||
run SharpEmu.
|
||||
|
||||
Place the resulting libsharpemu_bink2_bridge.dylib next to the SharpEmu
|
||||
executable, or point to it explicitly:
|
||||
`dotnet publish` builds it from source with CMake + Ninja + clang-cl (Windows)
|
||||
or the platform's default C compiler (Linux/macOS), targeting win-x64,
|
||||
linux-x64, osx-x64, or osx-arm64, then embeds the result in the published
|
||||
single-file executable. Publishing SharpEmu therefore requires that
|
||||
toolchain locally (the same one the CI runners already ship with); there is
|
||||
no prebuilt/download fallback. A downloaded release needs no such setup: the
|
||||
compiled adapter is already inside `SharpEmu.exe`.
|
||||
|
||||
SHARPEMU_BINK2_BRIDGE=/absolute/path/libsharpemu_bink2_bridge.dylib \
|
||||
To use a different build of the adapter, point to it explicitly:
|
||||
|
||||
SHARPEMU_BINK2_BRIDGE=/absolute/path/sharpemu_bink2_bridge.dll \
|
||||
./SharpEmu /path/to/eboot.bin
|
||||
|
||||
The expected exports are sharpemu_bink2_open_utf8,
|
||||
sharpemu_bink2_decode_next_bgra, and sharpemu_bink2_close. The supplied
|
||||
adapter opens one movie, exposes BGRA pixels, and advances after each decoded
|
||||
sharpemu_bink2_open_scaled_utf8, sharpemu_bink2_decode_next_bgra, and
|
||||
sharpemu_bink2_close. The adapter opens one movie, optionally scaling it down
|
||||
to a maximum size, exposes BGRA pixels, and advances after each decoded
|
||||
frame. The managed side validates dimensions and retains ownership of the
|
||||
destination buffer.
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -1,12 +1,22 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SharpEmu Emulator Project
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*
|
||||
* Build this small adapter with a licensed RAD Bink 2 SDK. The SDK and its
|
||||
* headers are not distributed by SharpEmu. See docs/bink2-bridge.md.
|
||||
*/
|
||||
#include <errno.h>
|
||||
#include <stdint.h>
|
||||
#include "bink.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavformat/avformat.h>
|
||||
#include <libavutil/error.h>
|
||||
#include <libswscale/swscale.h>
|
||||
|
||||
#if defined(_WIN32)
|
||||
#define SHARPEMU_EXPORT __declspec(dllexport)
|
||||
#else
|
||||
#define SHARPEMU_EXPORT __attribute__((visibility("default")))
|
||||
#endif
|
||||
|
||||
typedef struct sharpemu_bink2_info {
|
||||
uint32_t width;
|
||||
@@ -15,52 +25,276 @@ typedef struct sharpemu_bink2_info {
|
||||
uint32_t frames_per_second_denominator;
|
||||
} sharpemu_bink2_info;
|
||||
|
||||
int sharpemu_bink2_open_utf8(const char *path, HBINK *movie, sharpemu_bink2_info *info) {
|
||||
HBINK bink;
|
||||
if (!path || !movie || !info) return 0;
|
||||
typedef struct sharpemu_bink2_movie {
|
||||
AVFormatContext *format;
|
||||
AVCodecContext *codec;
|
||||
struct SwsContext *converter;
|
||||
AVFrame *frame;
|
||||
AVPacket *packet;
|
||||
int video_stream;
|
||||
uint32_t output_width;
|
||||
uint32_t output_height;
|
||||
int draining;
|
||||
} sharpemu_bink2_movie;
|
||||
|
||||
*movie = NULL;
|
||||
static void sharpemu_bink2_log_error(const char *operation, int error) {
|
||||
char message[AV_ERROR_MAX_STRING_SIZE];
|
||||
if (av_strerror(error, message, sizeof(message)) < 0) {
|
||||
snprintf(message, sizeof(message), "FFmpeg error %d", error);
|
||||
}
|
||||
fprintf(stderr, "[BINK2][ERROR] %s: %s\n", operation, message);
|
||||
}
|
||||
|
||||
bink = BinkOpen(path, 0);
|
||||
if (!bink) return 0;
|
||||
static void sharpemu_bink2_destroy(sharpemu_bink2_movie *movie) {
|
||||
if (!movie) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bink->Width == 0 || bink->Height == 0) {
|
||||
BinkClose(bink);
|
||||
sws_freeContext(movie->converter);
|
||||
av_packet_free(&movie->packet);
|
||||
av_frame_free(&movie->frame);
|
||||
avcodec_free_context(&movie->codec);
|
||||
avformat_close_input(&movie->format);
|
||||
free(movie);
|
||||
}
|
||||
|
||||
static AVRational sharpemu_bink2_frame_rate(AVFormatContext *format, AVStream *stream) {
|
||||
AVRational rate = av_guess_frame_rate(format, stream, NULL);
|
||||
if (rate.num <= 0 || rate.den <= 0) {
|
||||
rate = stream->avg_frame_rate;
|
||||
}
|
||||
if (rate.num <= 0 || rate.den <= 0) {
|
||||
rate = stream->r_frame_rate;
|
||||
}
|
||||
if (rate.num <= 0 || rate.den <= 0) {
|
||||
rate = (AVRational){30, 1};
|
||||
}
|
||||
return rate;
|
||||
}
|
||||
|
||||
static int sharpemu_bink2_open_internal(
|
||||
const char *path,
|
||||
uint32_t maximum_width,
|
||||
uint32_t maximum_height,
|
||||
void **movie_out,
|
||||
sharpemu_bink2_info *info) {
|
||||
sharpemu_bink2_movie *movie;
|
||||
const AVCodec *decoder = NULL;
|
||||
AVStream *stream;
|
||||
AVRational frame_rate;
|
||||
int result;
|
||||
|
||||
if (!path || !movie_out || !info) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
*movie = bink;
|
||||
info->width = bink->Width;
|
||||
info->height = bink->Height;
|
||||
info->frames_per_second_numerator = bink->FrameRate;
|
||||
info->frames_per_second_denominator = bink->FrameRateDiv;
|
||||
*movie_out = NULL;
|
||||
movie = (sharpemu_bink2_movie *)calloc(1, sizeof(*movie));
|
||||
if (!movie) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
result = avformat_open_input(&movie->format, path, NULL, NULL);
|
||||
if (result < 0) {
|
||||
sharpemu_bink2_log_error("open", result);
|
||||
sharpemu_bink2_destroy(movie);
|
||||
return 0;
|
||||
}
|
||||
|
||||
result = avformat_find_stream_info(movie->format, NULL);
|
||||
if (result < 0) {
|
||||
sharpemu_bink2_log_error("stream info", result);
|
||||
sharpemu_bink2_destroy(movie);
|
||||
return 0;
|
||||
}
|
||||
|
||||
result = av_find_best_stream(
|
||||
movie->format, AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0);
|
||||
if (result < 0 || !decoder) {
|
||||
sharpemu_bink2_log_error("video stream", result);
|
||||
sharpemu_bink2_destroy(movie);
|
||||
return 0;
|
||||
}
|
||||
|
||||
movie->video_stream = result;
|
||||
stream = movie->format->streams[movie->video_stream];
|
||||
movie->codec = avcodec_alloc_context3(decoder);
|
||||
if (!movie->codec) {
|
||||
sharpemu_bink2_destroy(movie);
|
||||
return 0;
|
||||
}
|
||||
|
||||
result = avcodec_parameters_to_context(movie->codec, stream->codecpar);
|
||||
if (result < 0) {
|
||||
sharpemu_bink2_log_error("codec parameters", result);
|
||||
sharpemu_bink2_destroy(movie);
|
||||
return 0;
|
||||
}
|
||||
|
||||
movie->codec->thread_count = 0;
|
||||
movie->codec->thread_type = FF_THREAD_FRAME | FF_THREAD_SLICE;
|
||||
result = avcodec_open2(movie->codec, decoder, NULL);
|
||||
if (result < 0) {
|
||||
sharpemu_bink2_log_error("codec open", result);
|
||||
sharpemu_bink2_destroy(movie);
|
||||
return 0;
|
||||
}
|
||||
|
||||
movie->frame = av_frame_alloc();
|
||||
movie->packet = av_packet_alloc();
|
||||
if (!movie->frame || !movie->packet ||
|
||||
movie->codec->width <= 0 || movie->codec->height <= 0) {
|
||||
sharpemu_bink2_destroy(movie);
|
||||
return 0;
|
||||
}
|
||||
|
||||
frame_rate = sharpemu_bink2_frame_rate(movie->format, stream);
|
||||
movie->output_width = (uint32_t)movie->codec->width;
|
||||
movie->output_height = (uint32_t)movie->codec->height;
|
||||
if (maximum_width > 0 && maximum_height > 0 &&
|
||||
(movie->output_width > maximum_width ||
|
||||
movie->output_height > maximum_height)) {
|
||||
if ((uint64_t)movie->output_width * maximum_height >
|
||||
(uint64_t)movie->output_height * maximum_width) {
|
||||
movie->output_height = (uint32_t)((uint64_t)movie->output_height *
|
||||
maximum_width /
|
||||
movie->output_width);
|
||||
movie->output_width = maximum_width;
|
||||
} else {
|
||||
movie->output_width = (uint32_t)((uint64_t)movie->output_width *
|
||||
maximum_height /
|
||||
movie->output_height);
|
||||
movie->output_height = maximum_height;
|
||||
}
|
||||
if (movie->output_width == 0) {
|
||||
movie->output_width = 1;
|
||||
}
|
||||
if (movie->output_height == 0) {
|
||||
movie->output_height = 1;
|
||||
}
|
||||
}
|
||||
|
||||
info->width = movie->output_width;
|
||||
info->height = movie->output_height;
|
||||
info->frames_per_second_numerator = (uint32_t)frame_rate.num;
|
||||
info->frames_per_second_denominator = (uint32_t)frame_rate.den;
|
||||
*movie_out = movie;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int sharpemu_bink2_decode_next_bgra(HBINK movie, uint8_t *destination,
|
||||
uint32_t stride, uint32_t destination_bytes) {
|
||||
uint64_t needed;
|
||||
uint64_t min_stride;
|
||||
SHARPEMU_EXPORT int sharpemu_bink2_open_utf8(
|
||||
const char *path,
|
||||
void **movie_out,
|
||||
sharpemu_bink2_info *info) {
|
||||
return sharpemu_bink2_open_internal(path, 0, 0, movie_out, info);
|
||||
}
|
||||
|
||||
if (!movie || !destination) return 0;
|
||||
SHARPEMU_EXPORT int sharpemu_bink2_open_scaled_utf8(
|
||||
const char *path,
|
||||
uint32_t maximum_width,
|
||||
uint32_t maximum_height,
|
||||
void **movie_out,
|
||||
sharpemu_bink2_info *info) {
|
||||
return sharpemu_bink2_open_internal(
|
||||
path, maximum_width, maximum_height, movie_out, info);
|
||||
}
|
||||
|
||||
min_stride = (uint64_t)movie->Width * 4;
|
||||
if ((uint64_t)stride < min_stride) return 0;
|
||||
static int sharpemu_bink2_receive_frame(sharpemu_bink2_movie *movie) {
|
||||
int result;
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void sharpemu_bink2_close(HBINK movie) {
|
||||
if (movie) BinkClose(movie);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -87,4 +87,43 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<!-- Building the Bink2 bridge (native/bink2-bridge/sharpemu_bink2_bridge.c)
|
||||
requires cmake, ninja, and clang-cl (Windows) or a C compiler
|
||||
(Linux/macOS); the same tools the CI runners already ship with. No
|
||||
prebuilt/download fallback: anyone publishing SharpEmu is expected to
|
||||
have the same toolchain, matching every other native dependency in
|
||||
this repo. -->
|
||||
<PropertyGroup>
|
||||
<Bink2BridgeBuildDir>$(BaseIntermediateOutputPath)bink2-bridge/$(RuntimeIdentifier)</Bink2BridgeBuildDir>
|
||||
<Bink2BridgeFileName Condition="$([MSBuild]::IsOSPlatform('Windows'))">sharpemu_bink2_bridge.dll</Bink2BridgeFileName>
|
||||
<Bink2BridgeFileName Condition="$([MSBuild]::IsOSPlatform('Linux'))">libsharpemu_bink2_bridge.so</Bink2BridgeFileName>
|
||||
<Bink2BridgeFileName Condition="$([MSBuild]::IsOSPlatform('OSX'))">libsharpemu_bink2_bridge.dylib</Bink2BridgeFileName>
|
||||
<Bink2BridgeOutput>$(Bink2BridgeBuildDir)/out/$(Bink2BridgeFileName)</Bink2BridgeOutput>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="BuildBink2Bridge"
|
||||
BeforeTargets="ComputeResolvedFilesToPublishList"
|
||||
Condition="'$(RuntimeIdentifier)' != '' And '$(Bink2BridgeFileName)' != ''">
|
||||
<Exec Condition="$([MSBuild]::IsOSPlatform('Windows'))"
|
||||
Command="cmake -S "$(RepoRoot)native/bink2-bridge" -B "$(Bink2BridgeBuildDir)" -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang-cl -DSHARPEMU_TARGET_RID=$(RuntimeIdentifier)" />
|
||||
<Exec Condition="!$([MSBuild]::IsOSPlatform('Windows'))"
|
||||
Command="cmake -S "$(RepoRoot)native/bink2-bridge" -B "$(Bink2BridgeBuildDir)" -DCMAKE_BUILD_TYPE=Release -DSHARPEMU_TARGET_RID=$(RuntimeIdentifier)" />
|
||||
<Exec Command="cmake --build "$(Bink2BridgeBuildDir)" --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>
|
||||
|
||||
@@ -51,12 +51,13 @@ public sealed partial class DirectExecutionBackend
|
||||
// round-trips through the real ucontext, so it works on every supported OS. EXTRQ/
|
||||
// INSERTQ additionally read and write an XMM register: on Windows contextRecord is the
|
||||
// live CONTEXT the OS resumes the thread from, so touching the Xmm0.. slots is visible
|
||||
// to the guest, but on POSIX contextRecord is a CONTEXT-shaped scratch buffer that the
|
||||
// bridge only populates with the 17 general-purpose registers - the XMM region is never
|
||||
// read from or written back to the real mcontext/ucontext. Running this on POSIX would
|
||||
// silently compute a result from stale/zeroed XMM bytes and then discard whatever it
|
||||
// "wrote", so keep it Windows-only, matching Kyty's own scope for the identical fix.
|
||||
return OperatingSystem.IsWindows() && TryRecoverSse4aExtractInsert(contextRecord, rip);
|
||||
// to the guest, and on Linux the bridge copies the mcontext's FXSAVE image into the
|
||||
// Xmm0.. slots and writes them back through sigreturn (_posixXmmContextBridged). On
|
||||
// Darwin the XMM area is still a zeroed scratch buffer - running this there would
|
||||
// silently compute a result from stale bytes and then discard whatever it "wrote", so
|
||||
// the recovery declines until that bridge exists.
|
||||
return (OperatingSystem.IsWindows() || _posixXmmContextBridged) &&
|
||||
TryRecoverSse4aExtractInsert(contextRecord, rip);
|
||||
}
|
||||
|
||||
private unsafe bool TryRecoverMonitorxMwaitx(void* contextRecord, ulong rip)
|
||||
@@ -97,7 +98,8 @@ public sealed partial class DirectExecutionBackend
|
||||
|
||||
private unsafe bool TryRecoverSse4aExtractInsert(void* contextRecord, ulong rip)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() || !TryReadFaultingInstruction(rip, out var instruction))
|
||||
if (!OperatingSystem.IsWindows() && !_posixXmmContextBridged ||
|
||||
!TryReadFaultingInstruction(rip, out var instruction))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -483,7 +483,7 @@ public sealed partial class DirectExecutionBackend
|
||||
if (count <= 16 || count % 65536 == 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (SHARPEMU_IGNORE_INT41=1)");
|
||||
$"[LOADER][WARN] Ignored guest int 0x41 trap #{count} at 0x{rip:X16} (default-on; set SHARPEMU_IGNORE_INT41=0 to disable)");
|
||||
Console.Error.Flush();
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -1404,11 +1404,13 @@ public sealed partial class DirectExecutionBackend
|
||||
"vWU-odnS+fU" or // sceAmprMeasureCommandSizeReadFile
|
||||
"sSAUCCU1dv4" or // sceAmprMeasureCommandSizeWriteKernelEventQueue_04_00
|
||||
"C+IEj+BsAFM" or // sceAmprMeasureCommandSizeWriteAddressOnCompletion
|
||||
"4fgtGfXDrFc" or // sceAmprMeasureCommandSizeWriteAddress_04_00
|
||||
"tZDDEo2tE5k" or // sceAmprCommandBufferGetSize
|
||||
"GnxKOHEawhk" or // sceAmprCommandBufferGetCurrentOffset
|
||||
"gzndltBEzWc" or // sceAmprCommandBufferGetNumCommands
|
||||
"H896Pt-yB4I" or // sceAmprCommandBufferWriteKernelEventQueue_04_00
|
||||
"sJXyWHjP-F8" or // sceAmprCommandBufferWriteAddressOnCompletion
|
||||
"j0+3uJMxYJY" or // sceAmprCommandBufferWriteAddress_04_00
|
||||
"mPpPxv5CZt4" or // sceSystemServiceGetHdrToneMapLuminance
|
||||
"1FZBKy8HeNU" or // sceVideoOutGetVblankStatus
|
||||
"ASoW5WE-UPo" or // sceKernelAprSubmitCommandBufferAndGetResult
|
||||
@@ -1554,11 +1556,13 @@ public sealed partial class DirectExecutionBackend
|
||||
"vWU-odnS+fU" or
|
||||
"sSAUCCU1dv4" or
|
||||
"C+IEj+BsAFM" or
|
||||
"4fgtGfXDrFc" or
|
||||
"tZDDEo2tE5k" or
|
||||
"GnxKOHEawhk" or
|
||||
"gzndltBEzWc" or
|
||||
"H896Pt-yB4I" or
|
||||
"sJXyWHjP-F8" or
|
||||
"j0+3uJMxYJY" or
|
||||
"mPpPxv5CZt4" or
|
||||
"1FZBKy8HeNU" or
|
||||
"ASoW5WE-UPo" or
|
||||
|
||||
@@ -50,6 +50,19 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
private const int LinuxUcontextGregsOffset = 40;
|
||||
private const int LinuxGregsErrOffset = 19 * 8;
|
||||
|
||||
// The kernel's x86-64 sigcontext places the FXSAVE-image pointer right
|
||||
// after the general registers it hands to the handler: err(152)
|
||||
// trapno(160) oldmask(168) cr2(176) fpstate(184), all relative to
|
||||
// GetPosixRegisterBase. glibc and musl both overlay this kernel layout
|
||||
// verbatim (glibc's mcontext_t.fpregs is the same slot), so the offset
|
||||
// is libc-independent. Inside the FXSAVE image the XMM registers start
|
||||
// at +160 (32-byte header + 8 legacy x87/MMX slots x 16 bytes) - the
|
||||
// same relative position they occupy in the Win64 CONTEXT's FltSave
|
||||
// area (Win64ContextXmm0Offset = 256 + 160).
|
||||
private const int LinuxGregsFpstateOffset = 184;
|
||||
private const int FxsaveXmmOffset = 160;
|
||||
private const int XmmBlockSize = 16 * 16;
|
||||
|
||||
// Byte offsets of the general registers relative to GetPosixRegisterBase,
|
||||
// ordered to match the contiguous Win64 CONTEXT block CTX_RAX..CTX_RIP
|
||||
// (rax, rcx, rdx, rbx, rsp, rbp, rsi, rdi, r8..r15, rip). Verified
|
||||
@@ -71,6 +84,15 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
[ThreadStatic]
|
||||
private static int _posixSignalHandlerDepth;
|
||||
|
||||
// True while the current thread's in-flight POSIX fault carries the real
|
||||
// XMM registers in the CONTEXT scratch buffer and writes to them will
|
||||
// reach the mcontext on resume. Gates recovery paths (SSE4a EXTRQ/
|
||||
// INSERTQ) that would otherwise compute results from a zeroed XMM area
|
||||
// and silently discard what they "wrote". Darwin is not bridged yet, so
|
||||
// the flag stays false there.
|
||||
[ThreadStatic]
|
||||
private static bool _posixXmmContextBridged;
|
||||
|
||||
private void SetupPosixExceptionHandler()
|
||||
{
|
||||
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_DISABLE_POSIX_SIGNALS"), "1", StringComparison.Ordinal))
|
||||
@@ -252,6 +274,26 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
WriteCtxU64(contextRecord, CTX_RAX + i * 8, *(ulong*)(registers + offsets[i]));
|
||||
}
|
||||
|
||||
// Bridge the XMM registers alongside the GPRs where the layout is
|
||||
// known: on Linux the fpstate pointer and FXSAVE image are kernel
|
||||
// ABI, so recovery paths that read or write XMM state (SSE4a
|
||||
// EXTRQ/INSERTQ) see the live registers and their writes reach the
|
||||
// guest through sigreturn.
|
||||
byte* fpstate = null;
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
fpstate = *(byte**)(registers + LinuxGregsFpstateOffset);
|
||||
if (fpstate != null)
|
||||
{
|
||||
Buffer.MemoryCopy(
|
||||
fpstate + FxsaveXmmOffset,
|
||||
contextRecord + Win64ContextXmm0Offset,
|
||||
XmmBlockSize,
|
||||
XmmBlockSize);
|
||||
}
|
||||
}
|
||||
_posixXmmContextBridged = fpstate != null;
|
||||
|
||||
EXCEPTION_RECORD record = default;
|
||||
record.ExceptionAddress = (void*)ReadCtxU64(contextRecord, CTX_RIP);
|
||||
if (signal == PosixSigIll)
|
||||
@@ -317,6 +359,14 @@ public sealed unsafe partial class DirectExecutionBackend
|
||||
{
|
||||
*(ulong*)(registers + offsets[i]) = ReadCtxU64(contextRecord, CTX_RAX + i * 8);
|
||||
}
|
||||
if (fpstate != null)
|
||||
{
|
||||
Buffer.MemoryCopy(
|
||||
contextRecord + Win64ContextXmm0Offset,
|
||||
fpstate + FxsaveXmmOffset,
|
||||
XmmBlockSize,
|
||||
XmmBlockSize);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1123,7 +1123,9 @@ public sealed unsafe partial class DirectExecutionBackend : INativeCpuBackend, I
|
||||
_logStrlenBursts = _logStrlenImports ||
|
||||
string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_STRLEN_BURSTS"), "1", StringComparison.Ordinal);
|
||||
_logGuestContext = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_CONTEXT"), "1", StringComparison.Ordinal);
|
||||
_ignoreGuestInt41 = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_IGNORE_INT41"), "1", StringComparison.Ordinal);
|
||||
var ignoreGuestInt41Env = Environment.GetEnvironmentVariable("SHARPEMU_IGNORE_INT41");
|
||||
_ignoreGuestInt41 = !string.Equals(ignoreGuestInt41Env, "0", StringComparison.Ordinal) &&
|
||||
!string.Equals(ignoreGuestInt41Env, "false", StringComparison.OrdinalIgnoreCase);
|
||||
_ignoredGuestInt41Count = 0;
|
||||
_logGuestThreads = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_GUEST_THREADS"), "1", StringComparison.Ordinal);
|
||||
_logUsleep = string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_USLEEP"), "1", StringComparison.Ordinal);
|
||||
|
||||
@@ -199,9 +199,32 @@ public sealed class SelfLoader : ISelfLoader
|
||||
{
|
||||
if (!physicalVm.TryAllocateAtExact(imageBase, totalImageSize, executable: true, out var allocatedBase))
|
||||
{
|
||||
// Exact allocation failed — the host may have already claimed
|
||||
// part of this range (ASLR, Rosetta 2, or another process).
|
||||
// Try backing the fixed range page by page to claim whatever
|
||||
// free gaps exist. If the whole range is occupied the backfill
|
||||
// returns false and we surface the original failure reason.
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER] Exact allocation at main image base 0x{imageBase:X16} " +
|
||||
$"(size=0x{totalImageSize:X}) failed; attempting fixed-range backfill.");
|
||||
if (!physicalVm.TryBackFixedRange(imageBase, totalImageSize, executable: true))
|
||||
{
|
||||
// TryBackFixedRange may have partially backed pages before
|
||||
// failing. The earlier Clear() already reset all regions, so
|
||||
// this second Clear() is idempotent for everything except the
|
||||
// partial backfill — it frees only those orphaned pages.
|
||||
physicalVm.Clear();
|
||||
var reason = physicalVm.DescribeAddressForDiagnostics(imageBase);
|
||||
throw new InvalidOperationException(
|
||||
$"Could not allocate main image at required base 0x{imageBase:X16} (size=0x{totalImageSize:X}): {reason}.");
|
||||
$"Could not allocate main image at required base 0x{imageBase:X16} " +
|
||||
$"(size=0x{totalImageSize:X}): {reason}. " +
|
||||
"Try closing other applications, rebooting, or " +
|
||||
(OperatingSystem.IsWindows()
|
||||
? "setting SHARPEMU_DISABLE_MITIGATION_RELAUNCH=1."
|
||||
: "ensuring no other process maps into this address range."));
|
||||
}
|
||||
|
||||
allocatedBase = imageBase;
|
||||
}
|
||||
|
||||
imageBase = allocatedBase;
|
||||
@@ -714,8 +737,9 @@ public sealed class SelfLoader : ISelfLoader
|
||||
|
||||
importedRelocations = BuildImportedRelocations(descriptors);
|
||||
|
||||
var stubEligibleNids = CollectStubEligibleNids(descriptors, moduleManager);
|
||||
var stubImportNids = orderedImportNids
|
||||
.Where(nid => ShouldCreateImportStub(nid, descriptors, moduleManager))
|
||||
.Where(stubEligibleNids.Contains)
|
||||
.ToArray();
|
||||
var stubsByAddress = CreateImportStubMapping(virtualMemory, stubImportNids);
|
||||
Console.WriteLine($"[LOADER] Created {stubsByAddress.Count} import stubs");
|
||||
@@ -1160,6 +1184,35 @@ public sealed class SelfLoader : ISelfLoader
|
||||
isWeak);
|
||||
}
|
||||
|
||||
// Collects every NID that needs a trap import stub in a single pass over the
|
||||
// descriptors. This mirrors ShouldCreateImportStub applied per NID, but avoids
|
||||
// the O(nids * descriptors) rescan that filtering each unique NID against the
|
||||
// full descriptor list would incur on large modules. A NID qualifies as soon as
|
||||
// one of its descriptors is non-weak, or is weak but resolvable via the module
|
||||
// manager.
|
||||
private static HashSet<string> CollectStubEligibleNids(
|
||||
IReadOnlyList<RelocationDescriptor> descriptors,
|
||||
IModuleManager? moduleManager)
|
||||
{
|
||||
var eligible = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < descriptors.Count; i++)
|
||||
{
|
||||
var descriptor = descriptors[i];
|
||||
var nid = descriptor.ImportNid;
|
||||
if (nid is null || eligible.Contains(nid))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!descriptor.IsWeak || moduleManager?.TryGetExport(nid, out _) == true)
|
||||
{
|
||||
eligible.Add(nid);
|
||||
}
|
||||
}
|
||||
|
||||
return eligible;
|
||||
}
|
||||
|
||||
private static bool ShouldCreateImportStub(
|
||||
string nid,
|
||||
IReadOnlyList<RelocationDescriptor> descriptors,
|
||||
@@ -2431,6 +2484,19 @@ public sealed class SelfLoader : ISelfLoader
|
||||
Debug.Assert(
|
||||
!ShouldCreateImportStub("weak", [weak], moduleManager: null),
|
||||
"An unresolved weak symbol incorrectly received a trap import stub.");
|
||||
|
||||
var strong = new RelocationDescriptor(
|
||||
TargetAddress: 0x3000,
|
||||
Addend: 0,
|
||||
ImportNid: "strong",
|
||||
SymbolValue: 0,
|
||||
RelocationValueKind.Pointer,
|
||||
IsDataImport: false);
|
||||
var mixed = new List<RelocationDescriptor> { weak, strong };
|
||||
var eligible = CollectStubEligibleNids(mixed, moduleManager: null);
|
||||
Debug.Assert(
|
||||
eligible.Contains("strong") && !eligible.Contains("weak"),
|
||||
"CollectStubEligibleNids disagreed with the per-NID stub eligibility rule.");
|
||||
}
|
||||
|
||||
private static ulong AlignUp(ulong value, ulong alignment)
|
||||
|
||||
@@ -446,13 +446,20 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
// us over whole free or occupied stretches. Only free stretches get backed;
|
||||
// stretches already reserved or committed by another allocation are left as
|
||||
// they are, which is exactly what a fixed mapping does on hardware.
|
||||
//
|
||||
// Because backing may span several disjoint free runs, allocations are
|
||||
// staged: host pages are reserved/committed first, and the corresponding
|
||||
// MemoryRegions are inserted only once every gap in the range has been
|
||||
// backed. If any gap fails to back, every earlier host allocation is freed
|
||||
// and no region is inserted, so the address space is left untouched.
|
||||
var stagedAllocations = new List<(ulong Address, ulong Size)>();
|
||||
|
||||
var cursor = start;
|
||||
var backedAny = false;
|
||||
while (cursor < end)
|
||||
{
|
||||
if (!_hostMemory.Query(cursor, out var info))
|
||||
{
|
||||
return false;
|
||||
goto Rollback;
|
||||
}
|
||||
|
||||
var queriedEnd = info.RegionSize > ulong.MaxValue - info.BaseAddress
|
||||
@@ -461,7 +468,7 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
var runEnd = Math.Min(end, queriedEnd);
|
||||
if (runEnd <= cursor)
|
||||
{
|
||||
return false;
|
||||
goto Rollback;
|
||||
}
|
||||
|
||||
if (info.State == HostRegionState.Free)
|
||||
@@ -475,35 +482,52 @@ public sealed unsafe class PhysicalVirtualMemory : IVirtualMemory, IGuestMemoryA
|
||||
_hostMemory.Free(allocated);
|
||||
}
|
||||
|
||||
goto Rollback;
|
||||
}
|
||||
|
||||
stagedAllocations.Add((cursor, runSize));
|
||||
TraceVmem($"Backed fixed range gap: 0x{cursor:X16} - 0x{runEnd:X16} ({runSize} bytes)");
|
||||
}
|
||||
|
||||
cursor = runEnd;
|
||||
}
|
||||
|
||||
if (stagedAllocations.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// All gaps backed successfully — insert regions in one batch.
|
||||
var protection = executable ? PAGE_EXECUTE_READWRITE : PAGE_READWRITE;
|
||||
_gate.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
foreach (var (gapAddress, gapSize) in stagedAllocations)
|
||||
{
|
||||
InsertRegionSorted(new MemoryRegion
|
||||
{
|
||||
VirtualAddress = cursor,
|
||||
Size = runSize,
|
||||
VirtualAddress = gapAddress,
|
||||
Size = gapSize,
|
||||
IsExecutable = executable,
|
||||
IsReservedOnly = false,
|
||||
Protection = protection
|
||||
});
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.ExitWriteLock();
|
||||
}
|
||||
|
||||
TraceVmem($"Backed fixed range gap: 0x{cursor:X16} - 0x{runEnd:X16} ({runSize} bytes)");
|
||||
backedAny = true;
|
||||
return true;
|
||||
|
||||
Rollback:
|
||||
foreach (var (gapAddress, _) in stagedAllocations)
|
||||
{
|
||||
_hostMemory.Free(gapAddress);
|
||||
}
|
||||
|
||||
cursor = runEnd;
|
||||
}
|
||||
|
||||
return backedAny;
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TryAllocateAtOrAbove(
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
|
||||
namespace SharpEmu.Libs.Acm;
|
||||
|
||||
public static class AcmExports
|
||||
{
|
||||
private static int _nextContextHandle;
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "ZIXln2K3XMk",
|
||||
ExportName = "sceAcmContextCreate",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAcm")]
|
||||
public static int AcmContextCreate(CpuContext ctx)
|
||||
{
|
||||
var outContextAddress = ctx[CpuRegister.Rdi];
|
||||
if (outContextAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
var handle = (ulong)Interlocked.Increment(ref _nextContextHandle);
|
||||
Span<byte> handleBytes = stackalloc byte[sizeof(ulong)];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(handleBytes, handle);
|
||||
return ctx.Memory.TryWrite(outContextAddress, handleBytes)
|
||||
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "jBgBjAj02R8",
|
||||
ExportName = "sceAcmContextDestroy",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAcm")]
|
||||
public static int AcmContextDestroy(CpuContext ctx)
|
||||
{
|
||||
_ = ctx;
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Bink;
|
||||
using SharpEmu.Libs.Gpu;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
@@ -48,6 +49,7 @@ public static partial class AgcExports
|
||||
private const uint ItWriteData = 0x37;
|
||||
private const uint ItDispatchDirect = 0x15;
|
||||
private const uint ItDispatchIndirect = 0x16;
|
||||
private const uint ItSetPredication = 0x20;
|
||||
private const uint ItWaitRegMem = 0x3C;
|
||||
private const uint ItIndirectBuffer = 0x3F;
|
||||
private const uint ItEventWrite = 0x46;
|
||||
@@ -221,6 +223,7 @@ public static partial class AgcExports
|
||||
(ulong Es, ulong State, ulong AliasAlignment),
|
||||
IGuestCompiledShader> _depthOnlyVertexShaderCache = new();
|
||||
private static readonly Dictionary<ulong, ulong> _shaderHeadersByCode = new();
|
||||
private static readonly ConcurrentDictionary<ulong, byte> _arrayUploadUnsupported = new();
|
||||
private static readonly bool _traceAgc = string.Equals(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_LOG_AGC"),
|
||||
"1",
|
||||
@@ -276,7 +279,7 @@ public static partial class AgcExports
|
||||
private static long _labelProducerSequence;
|
||||
private static readonly object _labelProducerGate = new();
|
||||
private static readonly List<LabelProducerTrace> _labelProducers = [];
|
||||
private static readonly HashSet<(object Memory, ulong Address, ulong SubmissionId)>
|
||||
private static readonly HashSet<(object Memory, ulong Address)>
|
||||
_tracedProducerlessWaits = new();
|
||||
private static long _shaderTranslationMissTraceCount;
|
||||
private static long _translatedDrawTraceCount;
|
||||
@@ -542,6 +545,7 @@ public static partial class AgcExports
|
||||
public uint IndexSize { get; set; }
|
||||
public uint InstanceCount { get; set; } = 1;
|
||||
public uint DrawIndexOffset { get; set; }
|
||||
public bool PredicateSkip { get; set; }
|
||||
public string QueueName { get; set; } = "graphics";
|
||||
public ulong ActiveSubmissionId { get; set; }
|
||||
public Queue<PendingSubmission> PendingSubmissions { get; } = new();
|
||||
@@ -1182,12 +1186,12 @@ public static partial class AgcExports
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
var packetDwords = size == 0 ? 6u : 9u;
|
||||
var packetDwords = size == 0 ? 7u : 9u;
|
||||
var packetRegister = size == 0 ? RWaitMem32 : RWaitMem64;
|
||||
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress) ||
|
||||
!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItNop, packetRegister)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address & (size == 0 ? ~0x3u : ~0x7u)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32) & 0x3FFFFu) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)mask))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
@@ -1195,8 +1199,9 @@ public static partial class AgcExports
|
||||
|
||||
if (size == 0)
|
||||
{
|
||||
if (!TryWriteUInt32(ctx, commandAddress + 16, compareFunction) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference))
|
||||
if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)reference) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, EncodeWaitRegMem32Control(compareFunction, 0, cachePolicy)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 24, EncodeWaitRegMemPoll(pollCycles)))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
@@ -1204,8 +1209,8 @@ public static partial class AgcExports
|
||||
else if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)(mask >> 32)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 24, (uint)(reference >> 32)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 28, compareFunction) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 32, pollCycles / 40))
|
||||
!TryWriteUInt32(ctx, commandAddress + 28, EncodeWaitRegMem64Control(compareFunction, 0, cachePolicy)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 32, EncodeWaitRegMemPoll(pollCycles)))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
@@ -1825,38 +1830,21 @@ public static partial class AgcExports
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
var standardWait = operation is 2 or 3;
|
||||
var packetDwords = standardWait ? 7u : size == 0 ? 6u : 9u;
|
||||
var packetDwords = size == 0 ? 7u : 9u;
|
||||
var packetRegister = size == 0 ? RWaitMem32 : RWaitMem64;
|
||||
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
if (standardWait)
|
||||
{
|
||||
if (!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItWaitRegMem, 0)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, compareFunction | ((operation & 1) << 8)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)address) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)(address >> 32)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 16, (uint)reference) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)mask) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 24, pollCycles / 40))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
}
|
||||
else if (!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItNop, packetRegister)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32)) ||
|
||||
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress) ||
|
||||
!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItNop, packetRegister)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address & (size == 0 ? ~0x3u : ~0x7u)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32) & 0x3FFFFu) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)mask))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
else if (size == 0)
|
||||
{
|
||||
if (!TryWriteUInt32(ctx, commandAddress + 16, compareFunction | (operation << 8)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference))
|
||||
if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)reference) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, EncodeWaitRegMem32Control(compareFunction, operation, cachePolicy)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 24, EncodeWaitRegMemPoll(pollCycles)))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
@@ -1864,8 +1852,8 @@ public static partial class AgcExports
|
||||
else if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)(mask >> 32)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 24, (uint)(reference >> 32)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 28, compareFunction | (operation << 8)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 32, pollCycles / 40))
|
||||
!TryWriteUInt32(ctx, commandAddress + 28, EncodeWaitRegMem64Control(compareFunction, operation, cachePolicy)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 32, EncodeWaitRegMemPoll(pollCycles)))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
@@ -2304,7 +2292,14 @@ public static partial class AgcExports
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
return ctx.TryWriteUInt64(commandAddress + fieldOffset, address)
|
||||
var wrote = op == ItNop && register is RWaitMem32 or RWaitMem64
|
||||
? TryWriteUInt32(
|
||||
ctx,
|
||||
commandAddress + fieldOffset,
|
||||
(uint)address & (register == RWaitMem32 ? ~0x3u : ~0x7u)) &&
|
||||
TryWriteUInt32(ctx, commandAddress + fieldOffset + 4, (uint)(address >> 32) & 0x3FFFFu)
|
||||
: ctx.TryWriteUInt64(commandAddress + fieldOffset, address);
|
||||
return wrote
|
||||
? SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
: SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
@@ -2327,7 +2322,7 @@ public static partial class AgcExports
|
||||
var fieldOffset = op == ItWaitRegMem
|
||||
? 4UL
|
||||
: op == ItNop && register == RWaitMem32
|
||||
? 16UL
|
||||
? 20UL
|
||||
: op == ItNop && register == RWaitMem64
|
||||
? 28UL
|
||||
: 0;
|
||||
@@ -2356,7 +2351,7 @@ public static partial class AgcExports
|
||||
var wrote = op == ItWaitRegMem
|
||||
? TryWriteUInt32(ctx, commandAddress + 16, (uint)reference)
|
||||
: op == ItNop && register == RWaitMem32
|
||||
? TryWriteUInt32(ctx, commandAddress + 20, (uint)reference)
|
||||
? TryWriteUInt32(ctx, commandAddress + 16, (uint)reference)
|
||||
: op == ItNop && register == RWaitMem64 &&
|
||||
ctx.TryWriteUInt64(commandAddress + 20, reference);
|
||||
return wrote
|
||||
@@ -3104,6 +3099,26 @@ public static partial class AgcExports
|
||||
CountSubmittedOpcode(op, register);
|
||||
}
|
||||
|
||||
if ((header & 1u) != 0 && state.PredicateSkip)
|
||||
{
|
||||
if (tracePackets)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.dcb.predicated_skip queue={state.QueueName} " +
|
||||
$"packet=0x{currentAddress:X16} op=0x{op:X2} len={length}");
|
||||
}
|
||||
|
||||
offset += length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op == ItSetPredication)
|
||||
{
|
||||
ApplySubmittedPredication(ctx, state, currentAddress, length, tracePackets);
|
||||
offset += length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op == ItNop &&
|
||||
register is RDrawReset or RAcbReset &&
|
||||
length >= 2)
|
||||
@@ -3822,7 +3837,7 @@ public static partial class AgcExports
|
||||
|
||||
if (!stale && producer is null &&
|
||||
!_tracedProducerlessWaits.Add(
|
||||
(memory, waiter.WaitAddress, waiter.SubmissionId)))
|
||||
(memory, waiter.WaitAddress)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -4018,6 +4033,111 @@ public static partial class AgcExports
|
||||
state.DrawIndexOffset = 0;
|
||||
}
|
||||
|
||||
private static void ApplySubmittedPredication(
|
||||
CpuContext ctx,
|
||||
SubmittedDcbState state,
|
||||
ulong packetAddress,
|
||||
uint packetLength,
|
||||
bool tracePacket)
|
||||
{
|
||||
if (packetLength < 3 ||
|
||||
!TryReadUInt32(ctx, packetAddress + 4, out var first) ||
|
||||
!TryReadUInt32(ctx, packetAddress + 8, out var second))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const uint flagsMask = 0x0007_1100u;
|
||||
uint flags;
|
||||
ulong predicateAddress;
|
||||
if (packetLength >= 4 &&
|
||||
(first & ~flagsMask) == 0 &&
|
||||
TryReadUInt32(ctx, packetAddress + 12, out var third) &&
|
||||
third <= 0xFFFFu)
|
||||
{
|
||||
flags = first;
|
||||
predicateAddress = ((ulong)third << 32) | (second & 0xFFFF_FFF0u);
|
||||
}
|
||||
else
|
||||
{
|
||||
flags = second;
|
||||
predicateAddress = (first & 0xFFFF_FFF0u) | ((ulong)(second & 0xFFu) << 32);
|
||||
}
|
||||
|
||||
var operation = (flags >> 16) & 0x7u;
|
||||
if (operation == 0)
|
||||
{
|
||||
state.PredicateSkip = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation != 3)
|
||||
{
|
||||
if (tracePacket)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.dcb.predication_unsupported packet=0x{packetAddress:X16} " +
|
||||
$"op={operation} addr=0x{predicateAddress:X16}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var waitOperation = (flags >> 12) & 1u;
|
||||
var value = 0UL;
|
||||
var readSucceeded = false;
|
||||
void ReadPredicate() =>
|
||||
readSucceeded = ctx.TryReadUInt64(predicateAddress, out value);
|
||||
|
||||
if (waitOperation != 0)
|
||||
{
|
||||
var sequence = GuestGpu.Current.SubmitOrderedGuestAction(
|
||||
ReadPredicate,
|
||||
$"set_predication read 0x{predicateAddress:X16}");
|
||||
if (sequence == 0)
|
||||
{
|
||||
ReadPredicate();
|
||||
}
|
||||
else if (!GuestGpu.Current.WaitForGuestWork(sequence))
|
||||
{
|
||||
if (tracePacket)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.dcb.predication_wait_failed packet=0x{packetAddress:X16} " +
|
||||
$"addr=0x{predicateAddress:X16} sequence={sequence}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ReadPredicate();
|
||||
}
|
||||
|
||||
if (!readSucceeded)
|
||||
{
|
||||
if (tracePacket)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.dcb.predication_read_failed packet=0x{packetAddress:X16} " +
|
||||
$"addr=0x{predicateAddress:X16}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var condition = (flags >> 8) & 1u;
|
||||
state.PredicateSkip = condition == 0 ? value != 0 : value == 0;
|
||||
if (tracePacket)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.dcb.predication packet=0x{packetAddress:X16} " +
|
||||
$"addr=0x{predicateAddress:X16} value=0x{value:X16} " +
|
||||
$"condition={condition} wait={waitOperation} skip={state.PredicateSkip}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RangesOverlap(
|
||||
ulong leftAddress,
|
||||
ulong leftLength,
|
||||
@@ -4560,6 +4680,7 @@ public static partial class AgcExports
|
||||
private static bool TryParseSubmittedWait(
|
||||
CpuContext ctx,
|
||||
ulong packetAddress,
|
||||
uint packetLength,
|
||||
bool is64Bit,
|
||||
bool isStandard,
|
||||
out ulong waitAddress,
|
||||
@@ -4590,8 +4711,10 @@ public static partial class AgcExports
|
||||
return true;
|
||||
}
|
||||
|
||||
var legacyWait32 = !is64Bit && packetLength == 6;
|
||||
var controlOffset = is64Bit ? 28u : legacyWait32 ? 16u : 20u;
|
||||
if (!TryReadUInt64(ctx, packetAddress + 4, out waitAddress) ||
|
||||
!TryReadUInt32(ctx, packetAddress + (is64Bit ? 28u : 16u), out var control))
|
||||
!TryReadUInt32(ctx, packetAddress + controlOffset, out var control))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -4604,8 +4727,9 @@ public static partial class AgcExports
|
||||
TryReadUInt64(ctx, packetAddress + 20, out reference);
|
||||
}
|
||||
|
||||
var referenceOffset = legacyWait32 ? 20u : 16u;
|
||||
if (!TryReadUInt32(ctx, packetAddress + 12, out var mask32) ||
|
||||
!TryReadUInt32(ctx, packetAddress + 20, out var reference32))
|
||||
!TryReadUInt32(ctx, packetAddress + referenceOffset, out var reference32))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -4710,7 +4834,7 @@ public static partial class AgcExports
|
||||
bool tracePacket)
|
||||
{
|
||||
if (!TryParseSubmittedWait(
|
||||
ctx, packetAddress, is64Bit, isStandard,
|
||||
ctx, packetAddress, length, is64Bit, isStandard,
|
||||
out var waitAddress, out var reference, out var mask, out var compareFunction,
|
||||
out var controlValue))
|
||||
{
|
||||
@@ -7984,7 +8108,8 @@ public static partial class AgcExports
|
||||
descriptor.Address != 0 &&
|
||||
(descriptor.Type == Gen5TextureType2DArray ||
|
||||
descriptor.Type == Gen5TextureType1DArray) &&
|
||||
descriptor.Depth > 1;
|
||||
descriptor.Depth > 1 &&
|
||||
!_arrayUploadUnsupported.ContainsKey(descriptor.Address);
|
||||
var arrayUploadLayers = wantsArrayUpload ? descriptor.Depth : 1u;
|
||||
|
||||
// Upload-known (not plain availability): the presenter's answer goes
|
||||
@@ -8213,6 +8338,8 @@ public static partial class AgcExports
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
_arrayUploadUnsupported.TryAdd(descriptor.Address, 0);
|
||||
}
|
||||
|
||||
var source = new byte[(int)physicalSourceByteCount];
|
||||
@@ -10906,6 +11033,23 @@ public static partial class AgcExports
|
||||
((op & 0xFFu) << 8) |
|
||||
((register & 0x3Fu) << 2);
|
||||
|
||||
private static uint EncodeWaitRegMemPoll(uint pollCycles) =>
|
||||
Math.Min(pollCycles >> 4, 0xFFFFu);
|
||||
|
||||
private static uint EncodeWaitRegMem32Control(uint compareFunction, uint operation, uint cachePolicy) =>
|
||||
0x10u |
|
||||
(compareFunction & 0x7u) |
|
||||
((operation & 0x3u) << 8) |
|
||||
((operation & 0xCu) << 4) |
|
||||
((cachePolicy & 0x3u) << 25);
|
||||
|
||||
private static uint EncodeWaitRegMem64Control(uint compareFunction, uint operation, uint cachePolicy) =>
|
||||
0x10u |
|
||||
(compareFunction & 0x7u) |
|
||||
((operation & 0x1u) << 8) |
|
||||
((operation & 0x6u) << 5) |
|
||||
((cachePolicy & 0x3u) << 25);
|
||||
|
||||
private static uint Pm4Length(uint header) =>
|
||||
((header >> 16) & 0x3FFFu) + 2u;
|
||||
|
||||
@@ -11360,16 +11504,21 @@ public static partial class AgcExports
|
||||
public static int DcbSetPredication(CpuContext ctx)
|
||||
{
|
||||
var dcb = ctx[CpuRegister.Rdi];
|
||||
var address = ctx[CpuRegister.Rsi];
|
||||
var condition = (uint)(ctx[CpuRegister.Rsi] & 1u);
|
||||
var operation = (uint)(ctx[CpuRegister.Rdx] & 0x7u);
|
||||
var waitOperation = (uint)(ctx[CpuRegister.Rcx] & 1u);
|
||||
var address = ctx[CpuRegister.R8];
|
||||
if (dcb == 0)
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
if (!TryAllocateCommandDwords(ctx, dcb, 3, out var cmd) ||
|
||||
!ctx.TryWriteUInt32(cmd, Pm4(3, ItNop, RZero)) ||
|
||||
!ctx.TryWriteUInt32(cmd + 4, (uint)(address & 0xFFFF_FFFFUL)) ||
|
||||
!ctx.TryWriteUInt32(cmd + 8, (uint)(address >> 32)))
|
||||
var flags = (condition << 8) | (waitOperation << 12) | (operation << 16);
|
||||
if (!TryAllocateCommandDwords(ctx, dcb, 4, out var cmd) ||
|
||||
!ctx.TryWriteUInt32(cmd, Pm4(4, ItSetPredication, RZero)) ||
|
||||
!ctx.TryWriteUInt32(cmd + 4, flags) ||
|
||||
!ctx.TryWriteUInt32(cmd + 8, (uint)address & 0xFFFF_FFF0u) ||
|
||||
!ctx.TryWriteUInt32(cmd + 12, (uint)(address >> 32)))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
@@ -11384,9 +11533,17 @@ public static partial class AgcExports
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int SetPacketPredication(CpuContext ctx)
|
||||
{
|
||||
// Global predication toggle on a packet; a no-op is safe for rendering.
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
var packetAddress = ctx[CpuRegister.Rdi];
|
||||
var predication = ctx[CpuRegister.Rsi];
|
||||
if (packetAddress == 0 || !TryReadUInt32(ctx, packetAddress, out var header))
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
header = (header & ~1u) | (predication == 1 ? 1u : 0u);
|
||||
return !ctx.TryWriteUInt32(packetAddress, header)
|
||||
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT)
|
||||
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
// ABI (reversed from Quake): rdi = array of DCB base addresses (u64 each),
|
||||
@@ -11561,7 +11718,7 @@ public static partial class AgcExports
|
||||
uint owner;
|
||||
lock (state.Gate)
|
||||
{
|
||||
if (!state.ResourceRegistrationInitialized ||
|
||||
if (state.ResourceRegistrationInitialized &&
|
||||
state.ResourceRegistrationMaxOwners != 0 &&
|
||||
state.ResourceOwners.Count >= state.ResourceRegistrationMaxOwners)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace SharpEmu.Libs.Agc;
|
||||
|
||||
/// <summary>
|
||||
@@ -16,8 +19,11 @@ namespace SharpEmu.Libs.Agc;
|
||||
/// other D/R and pipe/bank-XOR modes stay opt-in while their complete AddrLib
|
||||
/// equations are being ported.
|
||||
/// </summary>
|
||||
internal static class GnmTiling
|
||||
internal static unsafe class GnmTiling
|
||||
{
|
||||
private const int ParallelDetileElementThreshold = 512 * 512;
|
||||
private const int MaxDetileWorkers = 4;
|
||||
|
||||
// Oberon uses the 16-pipe / 8-pixel-packer RB+ topology. These are the
|
||||
// single-sample 64 KiB equations generated by AMD AddrLib for that exact
|
||||
// topology. Each entry describes one address bit as an XOR of X/Y bits.
|
||||
@@ -118,6 +124,14 @@ internal static class GnmTiling
|
||||
StringComparison.Ordinal);
|
||||
|
||||
private static readonly HashSet<uint> _reportedModes = new();
|
||||
private static readonly ConcurrentDictionary<(uint SwizzleMode, int BppLog2), PatternTerms>
|
||||
_patternTermCache = new();
|
||||
private static readonly ConcurrentDictionary<(SwizzleKind Kind, int Width, int Height), int[]>
|
||||
_blockTableCache = new();
|
||||
private static readonly ParallelOptions _parallelDetileOptions = new()
|
||||
{
|
||||
MaxDegreeOfParallelism = Math.Min(MaxDetileWorkers, Environment.ProcessorCount),
|
||||
};
|
||||
|
||||
public static bool Enabled => _enabled || !_disabled;
|
||||
|
||||
@@ -404,50 +418,83 @@ internal static class GnmTiling
|
||||
return false;
|
||||
}
|
||||
|
||||
// Precompute the within-block element offset for each (x, y) inside a
|
||||
// single block. The swizzle equation only depends on the in-block
|
||||
// coordinates, so this table is reused for every block — turning the
|
||||
// per-pixel bit-interleave (a loop + calls) into a single array lookup.
|
||||
// Detiling a 2048x2048 texture is millions of elements; without this the
|
||||
// per-pixel math makes DETILE unusably slow during asset streaming.
|
||||
// Address tables depend only on the swizzle equation and element size,
|
||||
// so retain them across textures instead of rebuilding them per upload.
|
||||
var hasExactXorPattern = TryGetExactXorPattern(swizzleMode, bppLog2, out var xorPattern);
|
||||
var blockTable = hasExactXorPattern ? [] : new int[blockWidth * blockHeight];
|
||||
for (var by = 0; !hasExactXorPattern && by < blockHeight; by++)
|
||||
{
|
||||
for (var bx = 0; bx < blockWidth; bx++)
|
||||
{
|
||||
blockTable[by * blockWidth + bx] = (int)(kind == SwizzleKind.ZOrder
|
||||
? MortonInterleave((uint)bx, (uint)by, blockWidth, blockHeight)
|
||||
: StandardSwizzleOffset((uint)bx, (uint)by, blockWidth, blockHeight));
|
||||
}
|
||||
}
|
||||
var patternTerms = hasExactXorPattern
|
||||
? _patternTermCache.GetOrAdd(
|
||||
(swizzleMode, bppLog2),
|
||||
_ => CreatePatternTerms(xorPattern))
|
||||
: default;
|
||||
var blockTable = hasExactXorPattern
|
||||
? []
|
||||
: _blockTableCache.GetOrAdd(
|
||||
(kind, blockWidth, blockHeight),
|
||||
static key => CreateBlockTable(key.Kind, key.Width, key.Height));
|
||||
|
||||
for (var y = 0; y < elementsHigh; y++)
|
||||
// The XOR equation offset factors cleanly into independent X and Y
|
||||
// fields — each output bit is parity(x & XMask) XOR parity(y & YMask),
|
||||
// and parity distributes over XOR, so offset(x, y) == xTerm(x) ^ yTerm(y).
|
||||
// Exact equations repeat at a small power-of-two period. Cached axis
|
||||
// terms reduce the inner loop to two array loads and one XOR.
|
||||
fixed (byte* tiledPointer = tiled)
|
||||
fixed (byte* linearPointer = linear)
|
||||
{
|
||||
var sourceAddress = (nint)tiledPointer;
|
||||
var destinationAddress = (nint)linearPointer;
|
||||
var sourceLength = tiled.Length;
|
||||
var destinationLength = linear.Length;
|
||||
var blockWidthShift = BitLog2((uint)blockWidth);
|
||||
var blockWidthMask = blockWidth - 1;
|
||||
var detileRow = (int y) =>
|
||||
{
|
||||
var blockY = y / blockHeight;
|
||||
var inBlockY = y % blockHeight;
|
||||
var inBlockY = y & (blockHeight - 1);
|
||||
var rowBlockBase = (long)blockY * blocksPerRow;
|
||||
var tableRowBase = inBlockY * blockWidth;
|
||||
var destRowBase = (long)y * elementsWide * bytesPerElement;
|
||||
var yTerm = hasExactXorPattern
|
||||
? patternTerms.Y[y & patternTerms.YMask]
|
||||
: 0;
|
||||
for (var x = 0; x < elementsWide; x++)
|
||||
{
|
||||
var blockX = x / blockWidth;
|
||||
var inBlockX = x % blockWidth;
|
||||
|
||||
var blockX = x >> blockWidthShift;
|
||||
var inBlockX = x & blockWidthMask;
|
||||
var blockIndex = rowBlockBase + blockX;
|
||||
var sourceByte = hasExactXorPattern
|
||||
? blockIndex * blockBytes + ComputePatternOffset((uint)x, (uint)y, xorPattern)
|
||||
? blockIndex * blockBytes + (patternTerms.X[x & patternTerms.XMask] ^ yTerm)
|
||||
: (blockIndex * blockElements + blockTable[tableRowBase + inBlockX]) *
|
||||
(long)bytesPerElement;
|
||||
var destByte = destRowBase + (long)x * bytesPerElement;
|
||||
if (sourceByte + bytesPerElement > tiled.Length ||
|
||||
destByte + bytesPerElement > linear.Length)
|
||||
if (sourceByte < 0 ||
|
||||
sourceByte + bytesPerElement > sourceLength ||
|
||||
destByte + bytesPerElement > destinationLength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
tiled.Slice((int)sourceByte, bytesPerElement)
|
||||
.CopyTo(linear.Slice((int)destByte, bytesPerElement));
|
||||
CopyElement(
|
||||
(byte*)sourceAddress + sourceByte,
|
||||
(byte*)destinationAddress + destByte,
|
||||
bytesPerElement);
|
||||
}
|
||||
};
|
||||
|
||||
var elementCount = (long)elementsWide * elementsHigh;
|
||||
if (elementCount >= ParallelDetileElementThreshold && Environment.ProcessorCount > 1)
|
||||
{
|
||||
Parallel.For(
|
||||
0,
|
||||
elementsHigh,
|
||||
_parallelDetileOptions,
|
||||
detileRow);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var y = 0; y < elementsHigh; y++)
|
||||
{
|
||||
detileRow(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,6 +507,80 @@ internal static class GnmTiling
|
||||
ZOrder,
|
||||
}
|
||||
|
||||
private readonly record struct PatternTerms(int[] X, int XMask, int[] Y, int YMask);
|
||||
|
||||
private static PatternTerms CreatePatternTerms(AddressBit[] pattern)
|
||||
{
|
||||
uint xMask = 0;
|
||||
uint yMask = 0;
|
||||
foreach (var bit in pattern)
|
||||
{
|
||||
xMask |= bit.XMask;
|
||||
yMask |= bit.YMask;
|
||||
}
|
||||
|
||||
var xLength = AxisTermPeriod(xMask);
|
||||
var yLength = AxisTermPeriod(yMask);
|
||||
var xTerms = new int[xLength];
|
||||
var yTerms = new int[yLength];
|
||||
for (var x = 0; x < xTerms.Length; x++)
|
||||
{
|
||||
xTerms[x] = (int)PatternAxisTerm((uint)x, pattern, useX: true);
|
||||
}
|
||||
|
||||
for (var y = 0; y < yTerms.Length; y++)
|
||||
{
|
||||
yTerms[y] = (int)PatternAxisTerm((uint)y, pattern, useX: false);
|
||||
}
|
||||
|
||||
return new PatternTerms(xTerms, xLength - 1, yTerms, yLength - 1);
|
||||
}
|
||||
|
||||
private static int AxisTermPeriod(uint mask) =>
|
||||
mask == 0 ? 1 : 1 << (32 - System.Numerics.BitOperations.LeadingZeroCount(mask));
|
||||
|
||||
private static int[] CreateBlockTable(SwizzleKind kind, int blockWidth, int blockHeight)
|
||||
{
|
||||
var table = new int[blockWidth * blockHeight];
|
||||
for (var y = 0; y < blockHeight; y++)
|
||||
{
|
||||
for (var x = 0; x < blockWidth; x++)
|
||||
{
|
||||
table[y * blockWidth + x] = (int)(kind == SwizzleKind.ZOrder
|
||||
? MortonInterleave((uint)x, (uint)y, blockWidth, blockHeight)
|
||||
: StandardSwizzleOffset((uint)x, (uint)y, blockWidth, blockHeight));
|
||||
}
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void CopyElement(byte* source, byte* destination, int bytesPerElement)
|
||||
{
|
||||
switch (bytesPerElement)
|
||||
{
|
||||
case 1:
|
||||
*destination = *source;
|
||||
break;
|
||||
case 2:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<ushort>(source));
|
||||
break;
|
||||
case 4:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<uint>(source));
|
||||
break;
|
||||
case 8:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<ulong>(source));
|
||||
break;
|
||||
case 16:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<UInt128>(source));
|
||||
break;
|
||||
default:
|
||||
Unsafe.CopyBlockUnaligned(destination, source, (uint)bytesPerElement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly AddressBit Zero = new(0, 0);
|
||||
|
||||
private static AddressBit X(int bit) => new(1u << bit, 0);
|
||||
@@ -493,14 +614,20 @@ internal static class GnmTiling
|
||||
return pattern.Length != 0;
|
||||
}
|
||||
|
||||
private static long ComputePatternOffset(uint x, uint y, AddressBit[] pattern)
|
||||
// The AddrLib within-block byte offset is a per-bit XOR equation:
|
||||
// offset = OR over bits of ( parity(x & XMask) XOR parity(y & YMask) ) << bit
|
||||
// Because parity distributes over XOR, that whole offset factors into two
|
||||
// independent axis terms: PatternAxisTerm(x, useX: true) ^
|
||||
// PatternAxisTerm(y, useX: false). Splitting the axes lets TryDetile cache
|
||||
// the X term per column and hoist the Y term per row instead of recomputing
|
||||
// the full 16-bit interleave (32 PopCounts) for every element.
|
||||
private static uint PatternAxisTerm(uint coordinate, AddressBit[] pattern, bool useX)
|
||||
{
|
||||
uint offset = 0;
|
||||
for (var bit = 0; bit < pattern.Length; bit++)
|
||||
{
|
||||
var equation = pattern[bit];
|
||||
var parity = (System.Numerics.BitOperations.PopCount(x & equation.XMask) +
|
||||
System.Numerics.BitOperations.PopCount(y & equation.YMask)) & 1;
|
||||
var mask = useX ? pattern[bit].XMask : pattern[bit].YMask;
|
||||
var parity = System.Numerics.BitOperations.PopCount(coordinate & mask) & 1;
|
||||
offset |= (uint)parity << bit;
|
||||
}
|
||||
|
||||
|
||||
@@ -343,6 +343,45 @@ internal static class GpuWaitRegistry
|
||||
return expired;
|
||||
}
|
||||
|
||||
public static List<WaitingDcb>? CollectAllForMemory(object memory)
|
||||
{
|
||||
List<WaitingDcb>? collected = null;
|
||||
lock (_gate)
|
||||
{
|
||||
List<ulong>? emptied = null;
|
||||
foreach (var (address, list) in _waiters)
|
||||
{
|
||||
for (var index = list.Count - 1; index >= 0; index--)
|
||||
{
|
||||
if (!ReferenceEquals(list[index].Memory, memory))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
collected ??= new List<WaitingDcb>();
|
||||
collected.Add(list[index]);
|
||||
list.RemoveAt(index);
|
||||
}
|
||||
|
||||
if (list.Count == 0)
|
||||
{
|
||||
emptied ??= new List<ulong>();
|
||||
emptied.Add(address);
|
||||
}
|
||||
}
|
||||
|
||||
if (emptied is not null)
|
||||
{
|
||||
foreach (var address in emptied)
|
||||
{
|
||||
_waiters.Remove(address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
/// <summary>Records the value a label producer wrote, for the deadlock
|
||||
/// breaker. Also latches any already-waiting waiter it satisfies.</summary>
|
||||
public static bool RecordProduced(object memory, ulong address, ulong value)
|
||||
|
||||
@@ -340,6 +340,18 @@ public static class AmprExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "4fgtGfXDrFc",
|
||||
ExportName = "sceAmprMeasureCommandSizeWriteAddress_04_00",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAmpr")]
|
||||
public static int MeasureCommandSizeWriteAddress0400(CpuContext ctx)
|
||||
{
|
||||
TraceAmpr(ctx, "measure_write_address", 0, WriteAddressRecordSize, 0);
|
||||
ctx[CpuRegister.Rax] = WriteAddressRecordSize;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "tZDDEo2tE5k",
|
||||
ExportName = "sceAmprCommandBufferGetSize",
|
||||
@@ -509,6 +521,32 @@ public static class AmprExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "j0+3uJMxYJY",
|
||||
ExportName = "sceAmprCommandBufferWriteAddress_04_00",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAmpr")]
|
||||
public static int CommandBufferWriteAddress0400(CpuContext ctx)
|
||||
{
|
||||
var commandBuffer = ctx[CpuRegister.Rdi];
|
||||
var address = ctx[CpuRegister.Rsi];
|
||||
var value = ctx[CpuRegister.Rdx];
|
||||
|
||||
if (commandBuffer == 0 || address == 0)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
if (!AppendWriteAddressRecord(ctx, commandBuffer, address, value))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
TraceAmpr(ctx, "write_address", commandBuffer, address, value);
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
public static int CompleteCommandBuffer(CpuContext ctx, ulong commandBuffer)
|
||||
{
|
||||
if (commandBuffer == 0)
|
||||
|
||||
@@ -17,7 +17,7 @@ public static class AjmExports
|
||||
private const int OrbisAjmErrorCodecAlreadyRegistered = unchecked((int)0x80930009);
|
||||
private const int OrbisAjmErrorCodecNotRegistered = unchecked((int)0x8093000A);
|
||||
private const int OrbisAjmErrorWrongRevisionFlag = unchecked((int)0x8093000B);
|
||||
private const uint MaxCodecType = 23;
|
||||
private const uint MaxCodecType = 25;
|
||||
private const int MaxInstanceIndex = 0x2FFF;
|
||||
private static readonly ConcurrentDictionary<uint, AjmContextState> Contexts = new();
|
||||
private static int _nextContextId;
|
||||
|
||||
@@ -25,6 +25,10 @@ internal static class AudioPcmConversion
|
||||
float volume)
|
||||
{
|
||||
var sourceFrameSize = checked(channels * bytesPerSample);
|
||||
// Volume is constant for the whole submission, so clamp it once here
|
||||
// rather than per sample inside the loop (this runs on every real-time
|
||||
// audio buffer, hundreds of frames at a time).
|
||||
var clampedVolume = Math.Clamp(volume, 0.0f, 1.0f);
|
||||
for (var frame = 0; frame < frames; frame++)
|
||||
{
|
||||
var sourceFrame = source.Slice(frame * sourceFrameSize, sourceFrameSize);
|
||||
@@ -32,8 +36,8 @@ internal static class AudioPcmConversion
|
||||
var right = channels == 1
|
||||
? left
|
||||
: ReadSample(sourceFrame, 1, bytesPerSample, isFloat);
|
||||
left = ApplyVolume(left, volume);
|
||||
right = ApplyVolume(right, volume);
|
||||
left = ApplyVolume(left, clampedVolume);
|
||||
right = ApplyVolume(right, clampedVolume);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(destination[(frame * OutputFrameSize)..], left);
|
||||
BinaryPrimitives.WriteInt16LittleEndian(destination[((frame * OutputFrameSize) + 2)..], right);
|
||||
}
|
||||
@@ -67,9 +71,10 @@ internal static class AudioPcmConversion
|
||||
return checked((short)MathF.Round(value * scale));
|
||||
}
|
||||
|
||||
// <paramref name="volume"/> is expected pre-clamped to [0, 1] by the caller.
|
||||
private static short ApplyVolume(short sample, float volume)
|
||||
{
|
||||
var scaled = MathF.Round(sample * Math.Clamp(volume, 0.0f, 1.0f));
|
||||
var scaled = MathF.Round(sample * volume);
|
||||
return (short)Math.Clamp(scaled, short.MinValue, short.MaxValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1131,16 +1131,18 @@ public static class AvPlayerExports
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FindFfmpeg() =>
|
||||
internal static string? FindFfmpeg() =>
|
||||
FindFfmpeg(
|
||||
Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH"),
|
||||
Environment.GetEnvironmentVariable("PATH"),
|
||||
OperatingSystem.IsWindows());
|
||||
OperatingSystem.IsWindows(),
|
||||
AppContext.BaseDirectory);
|
||||
|
||||
internal static string? FindFfmpeg(
|
||||
string? configured,
|
||||
string? searchPath,
|
||||
bool isWindows)
|
||||
bool isWindows,
|
||||
string? baseDirectory = null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
|
||||
{
|
||||
@@ -1148,6 +1150,21 @@ public static class AvPlayerExports
|
||||
}
|
||||
|
||||
var executable = isWindows ? "ffmpeg.exe" : "ffmpeg";
|
||||
if (!string.IsNullOrWhiteSpace(baseDirectory))
|
||||
{
|
||||
foreach (var candidate in new[]
|
||||
{
|
||||
Path.Combine(baseDirectory, executable),
|
||||
Path.Combine(baseDirectory, "ffmpeg", executable),
|
||||
})
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var directory in (searchPath ?? string.Empty)
|
||||
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
|
||||
@@ -18,15 +18,46 @@ namespace SharpEmu.Libs.Bink;
|
||||
internal static class Bink2MovieBridge
|
||||
{
|
||||
private const uint MaxDimension = 16384;
|
||||
private const uint MaxHostVideoWidth = 1920;
|
||||
private const uint MaxHostVideoHeight = 1080;
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static NativeAdapter? _adapter;
|
||||
private static string? _activePath;
|
||||
private static IntPtr _activeMovie;
|
||||
private static Bink2MovieInfo _activeInfo;
|
||||
private static byte[]? _frameBuffer;
|
||||
private static bool _usingDummyMovie;
|
||||
private static bool _frameBufferPresented;
|
||||
private static BinkFramePlayback? _playback;
|
||||
private static long _frameSerial;
|
||||
private static bool _loadAttempted;
|
||||
private static bool _availabilityReported;
|
||||
private static uint _presentationWidth = MaxHostVideoWidth;
|
||||
private static uint _presentationHeight = MaxHostVideoHeight;
|
||||
|
||||
internal static bool IsHostPlaybackActive
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
return _playback is not null || _frameBuffer is not null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static void SetPresentationSize(uint width, uint height)
|
||||
{
|
||||
if (width == 0 || height == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
_presentationWidth = Math.Min(width, MaxHostVideoWidth);
|
||||
_presentationHeight = Math.Min(height, MaxHostVideoHeight);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true only when movie skipping was explicitly requested. Without
|
||||
@@ -37,33 +68,136 @@ internal static class Bink2MovieBridge
|
||||
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
|
||||
ResolveMode() == MovieMode.Skip;
|
||||
|
||||
internal static void ObserveGuestMovie(string hostPath)
|
||||
/// <summary>
|
||||
/// Starts or queues host decoding. Decoded frames are only exposed as a
|
||||
/// sampled guest texture; presentation and UI composition remain guest-owned.
|
||||
/// </summary>
|
||||
internal static bool ObserveGuestMovie(string hostPath)
|
||||
{
|
||||
if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
|
||||
!File.Exists(hostPath))
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
if (string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
return _playback is not null || _frameBuffer is not null;
|
||||
}
|
||||
|
||||
var mode = ResolveMode();
|
||||
if (mode == MovieMode.Dummy)
|
||||
if (mode is MovieMode.Guest or MovieMode.Skip)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_playback is not null || _frameBuffer is not null)
|
||||
{
|
||||
if (PendingMoviePathSet.Add(hostPath))
|
||||
{
|
||||
PendingMoviePaths.Enqueue(hostPath);
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge queued: " +
|
||||
Path.GetFileName(hostPath));
|
||||
}
|
||||
return PendingMoviePathSet.Contains(hostPath);
|
||||
}
|
||||
|
||||
AttachMovieLocked(hostPath, mode);
|
||||
return string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase) &&
|
||||
(_playback is not null || _frameBuffer is not null);
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryDecodeNextFrame(
|
||||
bool advanceClock,
|
||||
out byte[] pixels,
|
||||
out uint width,
|
||||
out uint height,
|
||||
out bool advanced,
|
||||
out long frameSerial,
|
||||
out string hostPath)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
pixels = [];
|
||||
width = 0;
|
||||
height = 0;
|
||||
advanced = false;
|
||||
frameSerial = _frameSerial;
|
||||
hostPath = _activePath ?? string.Empty;
|
||||
|
||||
if (_playback is not null)
|
||||
{
|
||||
if (!_playback.TryGetFrame(advanceClock, out pixels, out advanced))
|
||||
{
|
||||
if (_playback.IsFinished)
|
||||
{
|
||||
var completedPath = _activePath;
|
||||
CloseActiveLocked();
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge completed: " +
|
||||
Path.GetFileName(completedPath));
|
||||
AttachNextQueuedMovieLocked();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
width = _activeInfo.Width;
|
||||
height = _activeInfo.Height;
|
||||
if (advanced)
|
||||
{
|
||||
frameSerial = ++_frameSerial;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_frameBuffer is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
pixels = _frameBuffer;
|
||||
width = _activeInfo.Width;
|
||||
height = _activeInfo.Height;
|
||||
advanced = !_frameBufferPresented;
|
||||
_frameBufferPresented = true;
|
||||
if (advanced)
|
||||
{
|
||||
frameSerial = ++_frameSerial;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsValid(Bink2MovieInfo info) =>
|
||||
info.Width > 0 && info.Height > 0 &&
|
||||
info.Width <= MaxDimension && info.Height <= MaxDimension &&
|
||||
(ulong)info.Width * info.Height * 4 <= int.MaxValue;
|
||||
|
||||
private static int GetFrameBufferLength(Bink2MovieInfo info) =>
|
||||
checked((int)((ulong)info.Width * info.Height * 4));
|
||||
|
||||
private static void AttachMovieLocked(string hostPath, MovieMode mode)
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case MovieMode.Dummy:
|
||||
AttachDummyMovieLocked(hostPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode != MovieMode.Native)
|
||||
{
|
||||
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)
|
||||
{
|
||||
@@ -71,7 +205,12 @@ internal static class Bink2MovieBridge
|
||||
}
|
||||
|
||||
CloseActiveLocked();
|
||||
if (!adapter.TryOpen(hostPath, out var movie, out var info))
|
||||
if (!adapter.TryOpen(
|
||||
hostPath,
|
||||
_presentationWidth,
|
||||
_presentationHeight,
|
||||
out var movie,
|
||||
out var info))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink2 bridge could not open movie '" +
|
||||
@@ -88,69 +227,15 @@ internal static class Bink2MovieBridge
|
||||
return;
|
||||
}
|
||||
|
||||
_activePath = hostPath;
|
||||
_activeMovie = movie;
|
||||
_activeInfo = info;
|
||||
_frameBuffer = GC.AllocateUninitializedArray<byte>(GetFrameBufferLength(info));
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryDecodeNextFrame(
|
||||
out byte[] pixels,
|
||||
out uint width,
|
||||
out uint height)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
pixels = [];
|
||||
width = 0;
|
||||
height = 0;
|
||||
if (_adapter is null || _activeMovie == IntPtr.Zero || _frameBuffer is null)
|
||||
{
|
||||
if (_usingDummyMovie && _frameBuffer is not null)
|
||||
{
|
||||
pixels = _frameBuffer;
|
||||
width = _activeInfo.Width;
|
||||
height = _activeInfo.Height;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
unsafe
|
||||
{
|
||||
fixed (byte* destination = _frameBuffer)
|
||||
{
|
||||
if (!_adapter.DecodeNextBgra(
|
||||
_activeMovie,
|
||||
(IntPtr)destination,
|
||||
_activeInfo.Width * 4,
|
||||
(uint)_frameBuffer.Length))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pixels = _frameBuffer;
|
||||
width = _activeInfo.Width;
|
||||
height = _activeInfo.Height;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsValid(Bink2MovieInfo info) =>
|
||||
info.Width > 0 && info.Height > 0 &&
|
||||
info.Width <= MaxDimension && info.Height <= MaxDimension &&
|
||||
(ulong)info.Width * info.Height * 4 <= int.MaxValue;
|
||||
|
||||
private static int GetFrameBufferLength(Bink2MovieInfo info) =>
|
||||
checked((int)((ulong)info.Width * info.Height * 4));
|
||||
|
||||
private static MovieMode ResolveMode()
|
||||
{
|
||||
@@ -170,15 +255,23 @@ internal static class Bink2MovieBridge
|
||||
return MovieMode.Skip;
|
||||
}
|
||||
|
||||
// Prefer the optional host adapter when one is supplied. Otherwise let
|
||||
// the game's statically linked Bink implementation consume the file.
|
||||
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE")) ||
|
||||
EnumerateAdapterCandidates().Any(File.Exists))
|
||||
if (string.Equals(configured, "guest", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MovieMode.Native;
|
||||
return MovieMode.Guest;
|
||||
}
|
||||
|
||||
return MovieMode.Guest;
|
||||
if (string.Equals(configured, "ffmpeg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MovieMode.Ffmpeg;
|
||||
}
|
||||
|
||||
// Native is the default: the bridge ships embedded in the published
|
||||
// single-file executable (see SharpEmu.CLI.csproj), so it isn't a
|
||||
// loose file next to the exe to probe for with File.Exists here.
|
||||
// GetAdapterLocked() degrades gracefully (falls back to the guest's
|
||||
// own decode, logging one informational line) if it's genuinely
|
||||
// unavailable, so defaulting to Native unconditionally is safe.
|
||||
return MovieMode.Native;
|
||||
}
|
||||
|
||||
private static void AttachDummyMovieLocked(string hostPath)
|
||||
@@ -195,22 +288,61 @@ internal static class Bink2MovieBridge
|
||||
_activePath = hostPath;
|
||||
_activeInfo = info;
|
||||
_frameBuffer = GC.AllocateUninitializedArray<byte>(GetFrameBufferLength(info));
|
||||
_frameBufferPresented = false;
|
||||
FillDummyFrame(_frameBuffer, info.Width, info.Height);
|
||||
_usingDummyMovie = true;
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink dummy attached: " + Path.GetFileName(hostPath) + " " +
|
||||
info.Width + "x" + info.Height + ".");
|
||||
}
|
||||
|
||||
private static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
|
||||
private static void AttachFfmpegMovieLocked(string hostPath)
|
||||
{
|
||||
if (!TryReadBinkInfo(hostPath, out var info) || !IsValid(info))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink FFmpeg source has an invalid header: " +
|
||||
Path.GetFileName(hostPath));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!FfmpegBinkFrameSource.TryOpen(
|
||||
hostPath,
|
||||
info.Width,
|
||||
info.Height,
|
||||
info.FramesPerSecondNumerator,
|
||||
info.FramesPerSecondDenominator,
|
||||
out var source) || source is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AttachPlaybackLocked(hostPath, info, source);
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink FFmpeg source attached: " +
|
||||
Path.GetFileName(hostPath) + " " + info.Width + "x" + info.Height + " @ " +
|
||||
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
|
||||
}
|
||||
|
||||
private static void AttachPlaybackLocked(
|
||||
string hostPath,
|
||||
Bink2MovieInfo info,
|
||||
IBinkFrameDecoder decoder)
|
||||
{
|
||||
CloseActiveLocked();
|
||||
_activePath = hostPath;
|
||||
_activeInfo = info;
|
||||
_playback = new BinkFramePlayback(decoder);
|
||||
}
|
||||
|
||||
internal static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
|
||||
{
|
||||
info = default;
|
||||
Span<byte> header = stackalloc byte[32];
|
||||
Span<byte> header = stackalloc byte[36];
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
if (stream.Read(header) != header.Length ||
|
||||
!header[..4].SequenceEqual("KB2j"u8))
|
||||
stream.ReadExactly(header);
|
||||
if (!header[..3].SequenceEqual("KB2"u8))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -219,10 +351,11 @@ internal static class Bink2MovieBridge
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x14, 4)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x18, 4)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x1C, 4)),
|
||||
1);
|
||||
return true;
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(0x20, 4)));
|
||||
return info.FramesPerSecondNumerator != 0 &&
|
||||
info.FramesPerSecondDenominator != 0;
|
||||
}
|
||||
catch (IOException)
|
||||
catch (Exception exception) when (exception is IOException or EndOfStreamException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -252,6 +385,26 @@ internal static class Bink2MovieBridge
|
||||
}
|
||||
|
||||
_loadAttempted = true;
|
||||
|
||||
// Assembly-relative resolution participates in the single-file
|
||||
// bundle's native-library extraction, so it finds the bridge whether
|
||||
// it was embedded in the publish or sits as a loose file next to the
|
||||
// executable, without us needing to know which. Skipped when the env
|
||||
// override is set so that override still takes priority below.
|
||||
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE")) &&
|
||||
NativeLibrary.TryLoad(
|
||||
"sharpemu_bink2_bridge", typeof(Bink2MovieBridge).Assembly, null, out var bundledLibrary))
|
||||
{
|
||||
if (NativeAdapter.TryCreate(bundledLibrary, out var bundledAdapter))
|
||||
{
|
||||
_adapter = bundledAdapter;
|
||||
Console.Error.WriteLine("[LOADER][INFO] Bink2 bridge loaded (bundled).");
|
||||
return bundledAdapter;
|
||||
}
|
||||
|
||||
NativeLibrary.Free(bundledLibrary);
|
||||
}
|
||||
|
||||
foreach (var candidate in EnumerateAdapterCandidates())
|
||||
{
|
||||
if (!NativeLibrary.TryLoad(candidate, out var library))
|
||||
@@ -304,20 +457,20 @@ internal static class Bink2MovieBridge
|
||||
|
||||
private static void CloseActiveLocked()
|
||||
{
|
||||
if (_activeMovie != IntPtr.Zero)
|
||||
{
|
||||
_adapter?.Close(_activeMovie);
|
||||
}
|
||||
|
||||
_playback?.Dispose();
|
||||
_playback = null;
|
||||
_activePath = null;
|
||||
_activeMovie = IntPtr.Zero;
|
||||
_activeInfo = default;
|
||||
_frameBuffer = null;
|
||||
_usingDummyMovie = false;
|
||||
_frameBufferPresented = false;
|
||||
|
||||
// Wake any guest _read() blocked in WaitForHostPlaybackToFinish: its
|
||||
// movie either just finished or is being pre-empted by a new attach.
|
||||
Monitor.PulseAll(Gate);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private readonly struct Bink2MovieInfo
|
||||
internal readonly struct Bink2MovieInfo
|
||||
{
|
||||
public readonly uint Width;
|
||||
public readonly uint Height;
|
||||
@@ -343,13 +496,68 @@ internal static class Bink2MovieBridge
|
||||
Skip,
|
||||
Dummy,
|
||||
Native,
|
||||
Ffmpeg,
|
||||
}
|
||||
|
||||
private sealed class NativeFrameDecoder : IBinkFrameDecoder
|
||||
{
|
||||
private readonly NativeAdapter _adapter;
|
||||
private readonly IntPtr _movie;
|
||||
private int _disposed;
|
||||
|
||||
internal NativeFrameDecoder(NativeAdapter adapter, IntPtr movie, Bink2MovieInfo info)
|
||||
{
|
||||
_adapter = adapter;
|
||||
_movie = movie;
|
||||
Width = info.Width;
|
||||
Height = info.Height;
|
||||
FramesPerSecondNumerator = info.FramesPerSecondNumerator;
|
||||
FramesPerSecondDenominator = info.FramesPerSecondDenominator;
|
||||
}
|
||||
|
||||
public uint Width { get; }
|
||||
|
||||
public uint Height { get; }
|
||||
|
||||
public uint FramesPerSecondNumerator { get; }
|
||||
|
||||
public uint FramesPerSecondDenominator { get; }
|
||||
|
||||
public unsafe bool TryDecodeNextFrame(Span<byte> destination)
|
||||
{
|
||||
fixed (byte* pointer = destination)
|
||||
{
|
||||
return _adapter.DecodeNextBgra(
|
||||
_movie,
|
||||
(IntPtr)pointer,
|
||||
Width * 4,
|
||||
checked((uint)destination.Length));
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 0)
|
||||
{
|
||||
_adapter.Close(_movie);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private sealed class NativeAdapter
|
||||
{
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int OpenUtf8Delegate(IntPtr pathUtf8, out IntPtr movie, out Bink2MovieInfo info);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int OpenScaledUtf8Delegate(
|
||||
IntPtr pathUtf8,
|
||||
uint maximumWidth,
|
||||
uint maximumHeight,
|
||||
out IntPtr movie,
|
||||
out Bink2MovieInfo info);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int DecodeNextBgraDelegate(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes);
|
||||
|
||||
@@ -357,15 +565,18 @@ internal static class Bink2MovieBridge
|
||||
private delegate void CloseDelegate(IntPtr movie);
|
||||
|
||||
private readonly OpenUtf8Delegate _openUtf8;
|
||||
private readonly OpenScaledUtf8Delegate? _openScaledUtf8;
|
||||
private readonly DecodeNextBgraDelegate _decodeNextBgra;
|
||||
private readonly CloseDelegate _close;
|
||||
|
||||
private NativeAdapter(
|
||||
OpenUtf8Delegate openUtf8,
|
||||
OpenScaledUtf8Delegate? openScaledUtf8,
|
||||
DecodeNextBgraDelegate decodeNextBgra,
|
||||
CloseDelegate close)
|
||||
{
|
||||
_openUtf8 = openUtf8;
|
||||
_openScaledUtf8 = openScaledUtf8;
|
||||
_decodeNextBgra = decodeNextBgra;
|
||||
_close = close;
|
||||
}
|
||||
@@ -380,19 +591,42 @@ internal static class Bink2MovieBridge
|
||||
return false;
|
||||
}
|
||||
|
||||
OpenScaledUtf8Delegate? openScaled = null;
|
||||
if (NativeLibrary.TryGetExport(
|
||||
library,
|
||||
"sharpemu_bink2_open_scaled_utf8",
|
||||
out var scaledOpen))
|
||||
{
|
||||
openScaled = Marshal.GetDelegateForFunctionPointer<OpenScaledUtf8Delegate>(scaledOpen);
|
||||
}
|
||||
|
||||
adapter = new NativeAdapter(
|
||||
Marshal.GetDelegateForFunctionPointer<OpenUtf8Delegate>(open),
|
||||
openScaled,
|
||||
Marshal.GetDelegateForFunctionPointer<DecodeNextBgraDelegate>(decode),
|
||||
Marshal.GetDelegateForFunctionPointer<CloseDelegate>(close));
|
||||
return true;
|
||||
}
|
||||
|
||||
internal bool TryOpen(string path, out IntPtr movie, out Bink2MovieInfo info)
|
||||
internal bool TryOpen(
|
||||
string path,
|
||||
uint maximumWidth,
|
||||
uint maximumHeight,
|
||||
out IntPtr movie,
|
||||
out Bink2MovieInfo info)
|
||||
{
|
||||
var utf8 = Marshal.StringToCoTaskMemUTF8(path);
|
||||
try
|
||||
{
|
||||
return _openUtf8(utf8, out movie, out info) != 0 && movie != IntPtr.Zero;
|
||||
var result = _openScaledUtf8 is not null
|
||||
? _openScaledUtf8(
|
||||
utf8,
|
||||
maximumWidth,
|
||||
maximumHeight,
|
||||
out movie,
|
||||
out info)
|
||||
: _openUtf8(utf8, out movie, out info);
|
||||
return result != 0 && movie != IntPtr.Zero;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -405,4 +639,220 @@ internal static class Bink2MovieBridge
|
||||
|
||||
internal void Close(IntPtr movie) => _close(movie);
|
||||
}
|
||||
|
||||
private static readonly Queue<string> PendingMoviePaths = new();
|
||||
private static readonly HashSet<string> PendingMoviePathSet =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private static void AttachNextQueuedMovieLocked()
|
||||
{
|
||||
while (PendingMoviePaths.Count > 0)
|
||||
{
|
||||
var path = PendingMoviePaths.Dequeue();
|
||||
PendingMoviePathSet.Remove(path);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AttachMovieLocked(path, ResolveMode());
|
||||
if (_playback is not null || _frameBuffer is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Longest a guest _read() will block waiting for real host playback to
|
||||
// finish. A safety net, not a target: real movies finish well under
|
||||
// this. Bounds the damage if a movie fails to attach/decode after being
|
||||
// queued, so the guest thread doesn't hang forever.
|
||||
private const long MaxCompletionWaitMilliseconds = 5 * 60 * 1000;
|
||||
/// <summary>
|
||||
/// Blocks the calling (guest I/O) thread until the host has actually
|
||||
/// finished presenting <paramref name="hostPath"/> — either because it
|
||||
/// played through, or because something else took over the timeline.
|
||||
///
|
||||
/// The completion shim tells the guest's own Bink header parse "this
|
||||
/// movie is one frame and already done" so its native decoder never
|
||||
/// blocks the guest on real per-frame work. Without this wait, that lie
|
||||
/// lands the instant the guest reads the header, so guest-side game
|
||||
/// logic races far ahead of whatever the host is still showing on
|
||||
/// screen: pressing a button lands on the (already-advanced) guest
|
||||
/// state, but the video visibly keeps playing, and any real-time-gated
|
||||
/// trigger later in the guest's own flow can fire against a clock that
|
||||
/// no longer matches wall time. Gating the "done" read on real host
|
||||
/// completion keeps guest pacing and on-screen playback in lockstep.
|
||||
/// </summary>
|
||||
internal static void WaitForHostPlaybackToFinish(string hostPath)
|
||||
{
|
||||
var deadline = Environment.TickCount64 + MaxCompletionWaitMilliseconds;
|
||||
lock (Gate)
|
||||
{
|
||||
while (IsTrackedLocked(hostPath))
|
||||
{
|
||||
var remaining = deadline - Environment.TickCount64;
|
||||
if (remaining <= 0)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink2 bridge completion wait timed out for '" +
|
||||
Path.GetFileName(hostPath) + "'.");
|
||||
return;
|
||||
}
|
||||
|
||||
Monitor.Wait(Gate, (int)Math.Min(remaining, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTrackedLocked(string hostPath) =>
|
||||
string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
PendingMoviePathSet.Contains(hostPath);
|
||||
|
||||
internal static bool TryTakeOverGuestMovie(
|
||||
string hostPath,
|
||||
out BinkGuestCompletionShim completionShim,
|
||||
out bool observed)
|
||||
{
|
||||
completionShim = default;
|
||||
observed = ObserveGuestMovie(hostPath);
|
||||
|
||||
// Keep the real header visible so the guest creates its movie surface
|
||||
// and draw. Host-decoded pixels replace that sampled image later; a
|
||||
// one-frame completion shim would finish before the descriptor exists.
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static void NotifyGuestMovieClosed(string hostPath)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (PendingMoviePathSet.Remove(hostPath))
|
||||
{
|
||||
var retained = PendingMoviePaths
|
||||
.Where(path => !string.Equals(
|
||||
path,
|
||||
hostPath,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
PendingMoviePaths.Clear();
|
||||
foreach (var path in retained)
|
||||
{
|
||||
PendingMoviePaths.Enqueue(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Monitor.PulseAll(Gate);
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge stopped by guest close: " +
|
||||
Path.GetFileName(hostPath));
|
||||
CloseActiveLocked();
|
||||
AttachNextQueuedMovieLocked();
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryReadGuestCompletionShim(
|
||||
string hostPath,
|
||||
out BinkGuestCompletionShim completionShim)
|
||||
{
|
||||
completionShim = default;
|
||||
Span<byte> header = stackalloc byte[48];
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(hostPath);
|
||||
stream.ReadExactly(header);
|
||||
if (!header[..3].SequenceEqual("KB2"u8))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var frameCount = BinaryPrimitives.ReadUInt32LittleEndian(header[8..12]);
|
||||
var audioTrackCount = BinaryPrimitives.ReadUInt32LittleEndian(header[40..44]);
|
||||
if (frameCount < 2 || audioTrackCount > 256)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var revision = header[3];
|
||||
var frameIndexOffset = 44L + checked(12L * audioTrackCount);
|
||||
if (revision == (byte)'m')
|
||||
{
|
||||
frameIndexOffset += 16;
|
||||
}
|
||||
else if (revision is (byte)'i' or (byte)'j' or (byte)'k' or (byte)'n')
|
||||
{
|
||||
frameIndexOffset += 4;
|
||||
}
|
||||
|
||||
Span<byte> frameOffsets = stackalloc byte[8];
|
||||
stream.Position = frameIndexOffset;
|
||||
stream.ReadExactly(frameOffsets);
|
||||
var firstFrameOffset = BinaryPrimitives.ReadUInt32LittleEndian(frameOffsets[..4]) & ~1u;
|
||||
var secondFrameOffset = BinaryPrimitives.ReadUInt32LittleEndian(frameOffsets[4..]) & ~1u;
|
||||
if (firstFrameOffset < frameIndexOffset + 8 ||
|
||||
secondFrameOffset <= firstFrameOffset ||
|
||||
secondFrameOffset > stream.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
completionShim = new BinkGuestCompletionShim(
|
||||
secondFrameOffset - 8,
|
||||
secondFrameOffset - firstFrameOffset);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is IOException or EndOfStreamException or OverflowException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly struct BinkGuestCompletionShim
|
||||
{
|
||||
private readonly uint _fileSizeMinusHeader;
|
||||
private readonly uint _largestFrameSize;
|
||||
|
||||
internal BinkGuestCompletionShim(uint fileSizeMinusHeader, uint largestFrameSize)
|
||||
{
|
||||
_fileSizeMinusHeader = fileSizeMinusHeader;
|
||||
_largestFrameSize = largestFrameSize;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites the frame-count/size fields the guest's own Bink header
|
||||
/// parse reads, if this read covers them. Returns true when the
|
||||
/// NumFrames field (the field that tells the guest "this movie is
|
||||
/// done") was in range, so the caller can gate that specific read on
|
||||
/// the host's real playback actually finishing first.
|
||||
/// </summary>
|
||||
internal bool Patch(long fileOffset, Span<byte> bytes)
|
||||
{
|
||||
PatchUInt32(fileOffset, bytes, 4, _fileSizeMinusHeader);
|
||||
var touchedCompletionField = PatchUInt32(fileOffset, bytes, 8, 1);
|
||||
PatchUInt32(fileOffset, bytes, 12, _largestFrameSize);
|
||||
return touchedCompletionField;
|
||||
}
|
||||
|
||||
private static bool PatchUInt32(
|
||||
long fileOffset,
|
||||
Span<byte> bytes,
|
||||
long fieldOffset,
|
||||
uint value)
|
||||
{
|
||||
var relativeOffset = fieldOffset - fileOffset;
|
||||
if (relativeOffset < 0 || relativeOffset + sizeof(uint) > bytes.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(
|
||||
bytes.Slice((int)relativeOffset, sizeof(uint)),
|
||||
value);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,37 @@ public static class FontExports
|
||||
return SetSuccess(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "3BrWWFU+4ts",
|
||||
ExportName = "sceFontGetVerticalLayout",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceFont")]
|
||||
public static int GetVerticalLayout(CpuContext ctx)
|
||||
{
|
||||
var layoutAddress = ctx[CpuRegister.Rsi];
|
||||
if (layoutAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
// Baseline (horizontal offset), line advance, decoration extent.
|
||||
// Mirrors the same three-float layout as GetHorizontalLayout, but
|
||||
// interpreted for vertical writing (e.g. CJK text rendered top-to-bottom).
|
||||
var values = new[] { 8.0f, 16.0f, 0.0f };
|
||||
for (var index = 0; index < values.Length; index++)
|
||||
{
|
||||
if (!TryWriteUInt32(
|
||||
ctx,
|
||||
layoutAddress + (ulong)(index * sizeof(float)),
|
||||
BitConverter.SingleToUInt32Bits(values[index])))
|
||||
{
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
}
|
||||
|
||||
return SetSuccess(ctx);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "cKYtVmeSTcw",
|
||||
ExportName = "sceFontOpenFontSet",
|
||||
|
||||
@@ -119,6 +119,17 @@ public static class JsonExports
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
// Catalog alias NID for the same callback setter.
|
||||
#pragma warning disable SHEM004
|
||||
[SysAbiExport(
|
||||
Nid = "00oCq0RwSAY",
|
||||
ExportName = "_ZN3sce4Json11Initializer27setGlobalNullAccessCallbackEPFRKNS0_5ValueENS0_9ValueTypeEPS3_PvES7_",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceJson")]
|
||||
public static int InitializerSetGlobalNullAccessCallbackAlt(CpuContext ctx) =>
|
||||
InitializerSetGlobalNullAccessCallback(ctx);
|
||||
#pragma warning restore SHEM004
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "WSOuge5IsCg",
|
||||
ExportName = "_ZN3sce4Json14InitParameter2C1Ev",
|
||||
|
||||
@@ -267,6 +267,11 @@ public static partial class KernelMemoryCompatExports
|
||||
}
|
||||
|
||||
var hostPath = ResolveGuestPath(guestPath);
|
||||
if (string.IsNullOrEmpty(hostPath))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var stream = new FileStream(hostPath, FileMode.Open, FileAccess.Write, FileShare.ReadWrite);
|
||||
@@ -310,6 +315,11 @@ public static partial class KernelMemoryCompatExports
|
||||
|
||||
var fromHost = ResolveGuestPath(fromGuest);
|
||||
var toHost = ResolveGuestPath(toGuest);
|
||||
if (string.IsNullOrEmpty(fromHost) || string.IsNullOrEmpty(toHost))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(fromHost))
|
||||
|
||||
@@ -97,6 +97,9 @@ public static partial class KernelMemoryCompatExports
|
||||
|
||||
private static readonly object _fdGate = new();
|
||||
private static readonly Dictionary<int, FileStream> _openFiles = new();
|
||||
private static readonly Dictionary<int, Bink2MovieBridge.BinkGuestCompletionShim>
|
||||
_binkGuestCompletionShims = new();
|
||||
private static readonly Dictionary<int, string> _observedBinkGuestFiles = new();
|
||||
private static readonly Dictionary<int, OpenDirectory> _openDirectories = new();
|
||||
private static readonly object _libcAllocGate = new();
|
||||
private static readonly object _memoryGate = new();
|
||||
@@ -268,13 +271,13 @@ public static partial class KernelMemoryCompatExports
|
||||
}
|
||||
|
||||
_nextVirtualAddress = Math.Max(_nextVirtualAddress, address + mappedLength);
|
||||
_mappedRegions[address] = new MappedRegion(
|
||||
ReplaceMappedRegionRangeLocked(new MappedRegion(
|
||||
address,
|
||||
mappedLength,
|
||||
OrbisProtCpuReadWrite,
|
||||
IsFlexible: false,
|
||||
IsDirect: false,
|
||||
DirectStart: 0);
|
||||
DirectStart: 0));
|
||||
}
|
||||
|
||||
for (ulong offset = 0; offset < mappedLength;)
|
||||
@@ -300,13 +303,13 @@ public static partial class KernelMemoryCompatExports
|
||||
|
||||
lock (_memoryGate)
|
||||
{
|
||||
_mappedRegions[address] = new MappedRegion(
|
||||
ReplaceMappedRegionRangeLocked(new MappedRegion(
|
||||
address,
|
||||
length,
|
||||
Protection: 0,
|
||||
IsFlexible: false,
|
||||
IsDirect: false,
|
||||
DirectStart: 0);
|
||||
DirectStart: 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1448,6 +1451,13 @@ public static partial class KernelMemoryCompatExports
|
||||
var hostPath = ResolveGuestPath(guestPath);
|
||||
var access = ResolveOpenAccess(flags);
|
||||
var mode = ResolveOpenMode(flags, access);
|
||||
// A denied path (empty host path) must not reach FileStream, which would
|
||||
// throw an ArgumentException the catch below does not cover.
|
||||
if (string.IsNullOrEmpty(hostPath))
|
||||
{
|
||||
LogOpenTrace($"_open denied path='{guestPath}' flags=0x{flags:X8}");
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (Bink2MovieBridge.ShouldSkipGuestMovie(hostPath))
|
||||
@@ -1461,6 +1471,14 @@ public static partial class KernelMemoryCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
Bink2MovieBridge.BinkGuestCompletionShim binkCompletionShim = default;
|
||||
var observedBinkMovie = false;
|
||||
var useBinkCompletionShim = access == FileAccess.Read &&
|
||||
Bink2MovieBridge.TryTakeOverGuestMovie(
|
||||
hostPath,
|
||||
out binkCompletionShim,
|
||||
out observedBinkMovie);
|
||||
|
||||
if (IsMutatingOpen(flags) && IsReadOnlyGuestMutationPath(guestPath))
|
||||
{
|
||||
LogOpenTrace($"_open readonly path='{guestPath}' host='{hostPath}' flags=0x{flags:X8}");
|
||||
@@ -1509,13 +1527,22 @@ public static partial class KernelMemoryCompatExports
|
||||
lock (_fdGate)
|
||||
{
|
||||
_openFiles[fd] = stream;
|
||||
if (useBinkCompletionShim)
|
||||
{
|
||||
_binkGuestCompletionShims[fd] = binkCompletionShim;
|
||||
}
|
||||
if (observedBinkMovie)
|
||||
{
|
||||
_observedBinkGuestFiles[fd] = hostPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Bink is linked directly into some games, so there is no media
|
||||
// import for the HLE codec layer to intercept. The successful
|
||||
// guest file open is the stable boundary at which the optional
|
||||
// host Bink bridge can attach to the same movie.
|
||||
Bink2MovieBridge.ObserveGuestMovie(hostPath);
|
||||
if (useBinkCompletionShim)
|
||||
{
|
||||
LogOpenTrace(
|
||||
"_open bink-host-shim path='" + guestPath + "' host='" + hostPath +
|
||||
"' flags=0x" + flags.ToString("X8") + " fd=" + fd);
|
||||
}
|
||||
|
||||
if (IsMutatingOpen(flags))
|
||||
{
|
||||
@@ -2046,10 +2073,21 @@ public static partial class KernelMemoryCompatExports
|
||||
}
|
||||
|
||||
FileStream? stream;
|
||||
var notifyBinkClose = false;
|
||||
string? observedBinkPath = null;
|
||||
lock (_fdGate)
|
||||
{
|
||||
if (_openFiles.Remove(fd, out stream))
|
||||
{
|
||||
_binkGuestCompletionShims.Remove(fd);
|
||||
if (_observedBinkGuestFiles.Remove(fd, out observedBinkPath))
|
||||
{
|
||||
notifyBinkClose = !_observedBinkGuestFiles.Values.Any(path =>
|
||||
string.Equals(
|
||||
path,
|
||||
observedBinkPath,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
else if (_openDirectories.Remove(fd))
|
||||
{
|
||||
@@ -2062,6 +2100,10 @@ public static partial class KernelMemoryCompatExports
|
||||
}
|
||||
}
|
||||
|
||||
if (notifyBinkClose)
|
||||
{
|
||||
Bink2MovieBridge.NotifyGuestMovieClosed(observedBinkPath!);
|
||||
}
|
||||
stream.Dispose();
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
@@ -2089,9 +2131,12 @@ public static partial class KernelMemoryCompatExports
|
||||
}
|
||||
|
||||
FileStream? stream;
|
||||
Bink2MovieBridge.BinkGuestCompletionShim completionShim = default;
|
||||
var useBinkCompletionShim = false;
|
||||
lock (_fdGate)
|
||||
{
|
||||
_openFiles.TryGetValue(fd, out stream);
|
||||
useBinkCompletionShim = _binkGuestCompletionShims.TryGetValue(fd, out completionShim);
|
||||
}
|
||||
|
||||
if (stream is null)
|
||||
@@ -2111,6 +2156,17 @@ public static partial class KernelMemoryCompatExports
|
||||
|
||||
var buffer = GC.AllocateUninitializedArray<byte>(requested);
|
||||
var read = stream.Read(buffer, 0, requested);
|
||||
if (read > 0 && useBinkCompletionShim)
|
||||
{
|
||||
// The patched NumFrames field is what tells the guest "this
|
||||
// movie is fully consumed" - hold that specific read until the
|
||||
// host has actually finished showing it, so guest-side game
|
||||
// logic can't race ahead of what's still on screen.
|
||||
if (completionShim.Patch(positionBefore, buffer.AsSpan(0, read)))
|
||||
{
|
||||
Bink2MovieBridge.WaitForHostPlaybackToFinish(stream.Name);
|
||||
}
|
||||
}
|
||||
if (read > 0 && !ctx.Memory.TryWrite(bufferAddress, buffer.AsSpan(0, read)))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
@@ -2654,6 +2710,26 @@ public static partial class KernelMemoryCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "n1-v6FgU7MQ",
|
||||
ExportName = "sceKernelConfiguredFlexibleMemorySize",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libKernel")]
|
||||
public static int KernelConfiguredFlexibleMemorySize(CpuContext ctx)
|
||||
{
|
||||
var outSizeAddress = ctx[CpuRegister.Rdi];
|
||||
if (outSizeAddress == 0)
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT;
|
||||
}
|
||||
|
||||
Span<byte> sizeBytes = stackalloc byte[sizeof(ulong)];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(sizeBytes, FlexibleMemorySizeBytes);
|
||||
return ctx.Memory.TryWrite(outSizeAddress, sizeBytes)
|
||||
? (int)OrbisGen2Result.ORBIS_GEN2_OK
|
||||
: (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "rTXw65xmLIA",
|
||||
ExportName = "sceKernelAllocateDirectMemory",
|
||||
@@ -3064,13 +3140,13 @@ public static partial class KernelMemoryCompatExports
|
||||
}
|
||||
|
||||
_nextVirtualAddress = Math.Max(_nextVirtualAddress, mappedAddress + length);
|
||||
_mappedRegions[mappedAddress] = new MappedRegion(
|
||||
ReplaceMappedRegionRangeLocked(new MappedRegion(
|
||||
mappedAddress,
|
||||
length,
|
||||
protection,
|
||||
IsFlexible: false,
|
||||
IsDirect: true,
|
||||
DirectStart: directMemoryStart);
|
||||
DirectStart: directMemoryStart));
|
||||
}
|
||||
|
||||
if (!ctx.TryWriteUInt64(inOutAddressPointer, mappedAddress))
|
||||
@@ -3156,13 +3232,13 @@ public static partial class KernelMemoryCompatExports
|
||||
|
||||
_nextVirtualAddress = Math.Max(_nextVirtualAddress, mappedAddress + length);
|
||||
_allocatedFlexibleBytes = Math.Min(FlexibleMemorySizeBytes, _allocatedFlexibleBytes + length);
|
||||
_mappedRegions[mappedAddress] = new MappedRegion(
|
||||
ReplaceMappedRegionRangeLocked(new MappedRegion(
|
||||
mappedAddress,
|
||||
length,
|
||||
protection,
|
||||
IsFlexible: true,
|
||||
IsDirect: false,
|
||||
DirectStart: 0);
|
||||
DirectStart: 0));
|
||||
}
|
||||
|
||||
if (!ctx.TryWriteUInt64(inOutAddressPointer, mappedAddress))
|
||||
@@ -4778,21 +4854,32 @@ public static partial class KernelMemoryCompatExports
|
||||
return guestPath;
|
||||
}
|
||||
|
||||
if (TryResolveRegisteredGuestMount(guestPath, out var mountedPath))
|
||||
if (TryResolveRegisteredGuestMount(guestPath, out var mountedPath, out var mountPrefixMatched))
|
||||
{
|
||||
return mountedPath;
|
||||
}
|
||||
|
||||
// A registered mount claimed this path by prefix but denied it (failed
|
||||
// containment or a reparse point inside the mount). That denial is
|
||||
// authoritative: do NOT fall through to the built-in branches below,
|
||||
// which could re-resolve an overlapping prefix (e.g. a registered
|
||||
// "/app0" vs the built-in SHARPEMU_APP0_DIR branch) against a different
|
||||
// root and turn the denial back into a resolution.
|
||||
if (mountPrefixMatched)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (guestPath.StartsWith("/devlog/app/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var relative = NormalizeMountRelativePath(guestPath["/devlog/app/".Length..]);
|
||||
return Path.Combine(ResolveDevlogAppRoot(), relative);
|
||||
return CombineWithinMount(ResolveDevlogAppRoot(), relative);
|
||||
}
|
||||
|
||||
if (guestPath.StartsWith("devlog/app/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var relative = NormalizeMountRelativePath(guestPath["devlog/app/".Length..]);
|
||||
return Path.Combine(ResolveDevlogAppRoot(), relative);
|
||||
return CombineWithinMount(ResolveDevlogAppRoot(), relative);
|
||||
}
|
||||
|
||||
if (string.Equals(guestPath, "/devlog/app", StringComparison.OrdinalIgnoreCase) ||
|
||||
@@ -4804,7 +4891,7 @@ public static partial class KernelMemoryCompatExports
|
||||
if (guestPath.StartsWith("/temp0/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var relative = NormalizeMountRelativePath(guestPath["/temp0/".Length..]);
|
||||
return Path.Combine(ResolveTemp0Root(), relative);
|
||||
return CombineWithinMount(ResolveTemp0Root(), relative);
|
||||
}
|
||||
|
||||
if (string.Equals(guestPath, "/temp0", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -4815,13 +4902,13 @@ public static partial class KernelMemoryCompatExports
|
||||
if (guestPath.StartsWith("/download0/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var relative = NormalizeMountRelativePath(guestPath["/download0/".Length..]);
|
||||
return Path.Combine(ResolveDownload0Root(), relative);
|
||||
return CombineWithinMount(ResolveDownload0Root(), relative);
|
||||
}
|
||||
|
||||
if (guestPath.StartsWith("download0/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var relative = NormalizeMountRelativePath(guestPath["download0/".Length..]);
|
||||
return Path.Combine(ResolveDownload0Root(), relative);
|
||||
return CombineWithinMount(ResolveDownload0Root(), relative);
|
||||
}
|
||||
|
||||
if (string.Equals(guestPath, "/download0", StringComparison.OrdinalIgnoreCase) ||
|
||||
@@ -4833,13 +4920,13 @@ public static partial class KernelMemoryCompatExports
|
||||
if (guestPath.StartsWith("/hostapp/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var relative = NormalizeMountRelativePath(guestPath["/hostapp/".Length..]);
|
||||
return Path.Combine(ResolveHostappRoot(), relative);
|
||||
return CombineWithinMount(ResolveHostappRoot(), relative);
|
||||
}
|
||||
|
||||
if (guestPath.StartsWith("hostapp/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var relative = NormalizeMountRelativePath(guestPath["hostapp/".Length..]);
|
||||
return Path.Combine(ResolveHostappRoot(), relative);
|
||||
return CombineWithinMount(ResolveHostappRoot(), relative);
|
||||
}
|
||||
|
||||
if (string.Equals(guestPath, "/hostapp", StringComparison.OrdinalIgnoreCase) ||
|
||||
@@ -4862,7 +4949,7 @@ public static partial class KernelMemoryCompatExports
|
||||
guestPath.StartsWith("$\\", StringComparison.Ordinal))
|
||||
{
|
||||
var relative = NormalizeMountRelativePath(guestPath[2..]);
|
||||
return Path.Combine(app0Root, relative);
|
||||
return CombineWithinMount(app0Root, relative);
|
||||
}
|
||||
|
||||
if (string.Equals(guestPath, "/app0", StringComparison.OrdinalIgnoreCase) ||
|
||||
@@ -4874,13 +4961,13 @@ public static partial class KernelMemoryCompatExports
|
||||
if (guestPath.StartsWith("/app0/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var relative = NormalizeMountRelativePath(guestPath["/app0/".Length..]);
|
||||
return Path.Combine(app0Root, relative);
|
||||
return CombineWithinMount(app0Root, relative);
|
||||
}
|
||||
|
||||
if (guestPath.StartsWith("app0/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var relative = NormalizeMountRelativePath(guestPath["app0/".Length..]);
|
||||
return Path.Combine(app0Root, relative);
|
||||
return CombineWithinMount(app0Root, relative);
|
||||
}
|
||||
|
||||
if (!Path.IsPathFullyQualified(guestPath) &&
|
||||
@@ -4888,16 +4975,26 @@ public static partial class KernelMemoryCompatExports
|
||||
!guestPath.StartsWith("\\", StringComparison.Ordinal))
|
||||
{
|
||||
var relative = NormalizeMountRelativePath(guestPath);
|
||||
return Path.Combine(app0Root, relative);
|
||||
return CombineWithinMount(app0Root, relative);
|
||||
}
|
||||
}
|
||||
|
||||
return guestPath;
|
||||
// Default-deny: a guest path that matched no mount prefix must NOT be
|
||||
// handed back verbatim as a host path. Returning it raw let any absolute
|
||||
// guest path address the host filesystem directly ("/etc/passwd",
|
||||
// "C:\\Windows\\...") because it is already fully qualified and skips the
|
||||
// relative-path app0 fallback above. Callers treat an empty host path as
|
||||
// "resolves to nothing" and fail the syscall with NOT_FOUND.
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static bool TryResolveRegisteredGuestMount(string guestPath, out string hostPath)
|
||||
private static bool TryResolveRegisteredGuestMount(
|
||||
string guestPath,
|
||||
out string hostPath,
|
||||
out bool mountPrefixMatched)
|
||||
{
|
||||
hostPath = string.Empty;
|
||||
mountPrefixMatched = false;
|
||||
var normalizedGuestPath = NormalizeGuestStatCachePath(guestPath);
|
||||
if (normalizedGuestPath is null)
|
||||
{
|
||||
@@ -4925,10 +5022,30 @@ public static partial class KernelMemoryCompatExports
|
||||
return false;
|
||||
}
|
||||
|
||||
// A registered mount owns this prefix. Whatever the outcome below
|
||||
// (containment or reparse denial), the caller must NOT fall through to a
|
||||
// built-in branch for the same prefix, or a denied path could be
|
||||
// re-resolved against a different root.
|
||||
mountPrefixMatched = true;
|
||||
|
||||
var relativePath = normalizedGuestPath[matchedMountPoint.Length..].TrimStart('/');
|
||||
var candidate = Path.GetFullPath(Path.Combine(
|
||||
string candidate;
|
||||
try
|
||||
{
|
||||
candidate = Path.GetFullPath(Path.Combine(
|
||||
matchedHostRoot,
|
||||
NormalizeMountRelativePath(relativePath)));
|
||||
}
|
||||
catch (Exception ex) when (
|
||||
ex is IOException or ArgumentException or NotSupportedException)
|
||||
{
|
||||
// The relative part comes from an untrusted guest path; a crafted
|
||||
// over-long or invalid path can make GetFullPath throw. Fail closed
|
||||
// rather than propagate out of ResolveGuestPath (which callers invoke
|
||||
// outside their try blocks).
|
||||
return false;
|
||||
}
|
||||
|
||||
var rootWithSeparator = Path.TrimEndingDirectorySeparator(matchedHostRoot) + Path.DirectorySeparatorChar;
|
||||
// Host-semantics comparison: an ignore-case check on a case-sensitive
|
||||
// host would let a relative path escape into a sibling directory that
|
||||
@@ -4940,6 +5057,14 @@ public static partial class KernelMemoryCompatExports
|
||||
return false;
|
||||
}
|
||||
|
||||
// Textual containment does not follow symlinks/junctions; refuse a
|
||||
// reparse point planted inside the mount that would redirect onto the
|
||||
// host filesystem. See EscapesMountViaReparsePoint.
|
||||
if (EscapesMountViaReparsePoint(Path.GetFullPath(matchedHostRoot), candidate))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
hostPath = candidate;
|
||||
return true;
|
||||
}
|
||||
@@ -4998,6 +5123,116 @@ public static partial class KernelMemoryCompatExports
|
||||
return string.Join(Path.DirectorySeparatorChar, resolved);
|
||||
}
|
||||
|
||||
// Combines a mount-relative guest path onto a built-in mount root and
|
||||
// re-verifies the result stays under that root. NormalizeMountRelativePath
|
||||
// strips "." / ".." but splits only on separators, so a drive-qualified
|
||||
// token like "C:" survives as a segment; Path.Combine then DISCARDS the
|
||||
// mount root because its second argument is drive-rooted, yielding a raw
|
||||
// host path such as "C:\Windows\...". Re-resolving with Path.GetFullPath and
|
||||
// checking containment (the same guard TryResolveRegisteredGuestMount uses)
|
||||
// rejects that escape. Returns string.Empty on denial, which callers treat
|
||||
// as an unresolved path.
|
||||
private static string CombineWithinMount(string mountRoot, string relative)
|
||||
{
|
||||
string fullRoot;
|
||||
string candidate;
|
||||
try
|
||||
{
|
||||
fullRoot = Path.GetFullPath(mountRoot);
|
||||
candidate = Path.GetFullPath(Path.Combine(fullRoot, relative));
|
||||
}
|
||||
catch (Exception ex) when (
|
||||
ex is IOException or ArgumentException or NotSupportedException)
|
||||
{
|
||||
// The relative part comes from an untrusted guest path; a crafted
|
||||
// over-long or invalid path can make GetFullPath throw. Fail closed
|
||||
// rather than propagate out of ResolveGuestPath (which callers invoke
|
||||
// outside their try blocks).
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var rootWithSeparator =
|
||||
Path.TrimEndingDirectorySeparator(fullRoot) + Path.DirectorySeparatorChar;
|
||||
if (!string.Equals(candidate, fullRoot, HostFsPathComparison) &&
|
||||
!candidate.StartsWith(rootWithSeparator, HostFsPathComparison))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (EscapesMountViaReparsePoint(fullRoot, candidate))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
|
||||
// Lexical containment (Path.GetFullPath + StartsWith) proves the TEXTUAL
|
||||
// path stays under the mount root, but it does not follow symlinks or
|
||||
// Windows junctions. A malicious game dump can plant a reparse point inside
|
||||
// an otherwise-contained mount (e.g. "/app0/link" -> "/") so that
|
||||
// "/app0/link/etc/passwd" passes the textual check yet resolves onto the
|
||||
// host filesystem. Walk each already-existing component from the mount root
|
||||
// down to the candidate and refuse if any is a reparse point. Components
|
||||
// that do not yet exist (e.g. an O_CREAT target and its parents) carry no
|
||||
// link to follow and are simply skipped. Mirrors the reparse rejection in
|
||||
// AvPlayerExports.TryResolveSandboxedFile.
|
||||
private static bool EscapesMountViaReparsePoint(string mountRoot, string candidate)
|
||||
{
|
||||
var rootTrimmed = Path.TrimEndingDirectorySeparator(mountRoot);
|
||||
if (string.Equals(candidate, rootTrimmed, HostFsPathComparison))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var relative = Path.GetRelativePath(rootTrimmed, candidate);
|
||||
// A leading ".." segment means the candidate is not under the root. Match
|
||||
// the segment precisely: bare ".." or a "../" prefix, NOT a legitimate
|
||||
// file merely named "..foo". This branch is a defensive fallback (lexical
|
||||
// containment already passed before it runs), so it fails closed.
|
||||
if (relative == "." || relative == ".." || Path.IsPathRooted(relative) ||
|
||||
relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) ||
|
||||
relative.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal))
|
||||
{
|
||||
// Not actually under the root (should have been caught lexically);
|
||||
// treat as an escape rather than walk outside it.
|
||||
return true;
|
||||
}
|
||||
|
||||
var current = rootTrimmed;
|
||||
foreach (var segment in relative.Split(
|
||||
Path.DirectorySeparatorChar,
|
||||
StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
current = Path.Combine(current, segment);
|
||||
try
|
||||
{
|
||||
if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException)
|
||||
{
|
||||
// Component does not exist yet (create path); nothing to follow.
|
||||
break;
|
||||
}
|
||||
catch (Exception ex) when (
|
||||
ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
|
||||
{
|
||||
// The path comes from an untrusted dump, so a crafted over-long,
|
||||
// invalid-char, or unreadable intermediate component can make
|
||||
// GetAttributes throw. Fail closed: if containment cannot be
|
||||
// verified, treat it as an escape rather than let the exception
|
||||
// crash the syscall (ResolveGuestPath runs outside the callers'
|
||||
// try blocks).
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ResolveDevlogAppRoot()
|
||||
{
|
||||
var configuredRoot = Environment.GetEnvironmentVariable("SHARPEMU_DEVLOG_APP_DIR");
|
||||
|
||||
@@ -191,6 +191,27 @@ public static class KernelSemaphoreCompatExports
|
||||
WakePredicate,
|
||||
deadline))
|
||||
{
|
||||
// A signal may have arrived between releasing the semaphore gate
|
||||
// (after incrementing WaitingThreads) and the scheduler registering
|
||||
// this block. When that happens WakeBlockedThreads cannot find the
|
||||
// waiter yet and the exit-handler re-check runs later; a re-check
|
||||
// here keeps the thread from yielding to the scheduler at all when
|
||||
// the count is already sufficient.
|
||||
lock (semaphore.Gate)
|
||||
{
|
||||
if (semaphore.Count >= needCount)
|
||||
{
|
||||
semaphore.Count -= needCount;
|
||||
semaphore.WaitingThreads = Math.Max(0, semaphore.WaitingThreads - 1);
|
||||
GuestThreadExecution.TryConsumeCurrentThreadBlock(out _);
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"wait-recheck handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} {FormatCallSite(ctx)}");
|
||||
}
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
}
|
||||
|
||||
if (_traceSema)
|
||||
{
|
||||
TraceSemaphore($"wait-block handle=0x{handle:X8} name='{semaphore.Name}' need={needCount} count={semaphore.Count} timeout={(timeoutAddress == 0 ? "infinite" : timeoutUsec)} waiters={semaphore.WaitingThreads} {FormatCallSite(ctx)}");
|
||||
|
||||
@@ -10,6 +10,11 @@ public static class NpWebApi2Exports
|
||||
private const int NpWebApi2ErrorInvalidArgument = unchecked((int)0x80553402);
|
||||
|
||||
private static int _initialized;
|
||||
private static int _nextLibraryContextHandle;
|
||||
private static int _nextPushEventHandle;
|
||||
private static int _nextUserContextHandle = 1000;
|
||||
private static readonly object _contextGate = new();
|
||||
private static readonly HashSet<int> _libraryContexts = [];
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "+o9816YQhqQ",
|
||||
@@ -26,9 +31,28 @@ public static class NpWebApi2Exports
|
||||
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
|
||||
}
|
||||
|
||||
var libraryContextId = CreateLibraryContextId();
|
||||
Interlocked.Exchange(ref _initialized, 1);
|
||||
TraceNpWebApi2("init", httpContextId, poolSize);
|
||||
return ctx.SetReturn(0);
|
||||
return ctx.SetReturn(libraryContextId);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "MsaFhR+lPE4",
|
||||
ExportName = "sceNpWebApi2PushEventCreateFilter",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceNpWebApi2")]
|
||||
public static int NpWebApi2PushEventCreateFilter(CpuContext ctx)
|
||||
{
|
||||
var libraryContextId = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
if (!IsValidLibraryContextId(libraryContextId))
|
||||
{
|
||||
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
|
||||
}
|
||||
|
||||
var filterHandle = Interlocked.Increment(ref _nextPushEventHandle);
|
||||
TraceNpWebApi2("push-event-create-filter", libraryContextId, (ulong)filterHandle);
|
||||
return ctx.SetReturn(filterHandle);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
@@ -38,9 +62,16 @@ public static class NpWebApi2Exports
|
||||
LibraryName = "libSceNpWebApi2")]
|
||||
public static int NpWebApi2InitializeAlt(CpuContext ctx)
|
||||
{
|
||||
var libraryContextId = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
if (!IsValidLibraryContextId(libraryContextId))
|
||||
{
|
||||
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
|
||||
}
|
||||
|
||||
var handle = CreatePushEventHandle();
|
||||
Interlocked.Exchange(ref _initialized, 1);
|
||||
TraceNpWebApi2("init-alt", unchecked((int)ctx[CpuRegister.Rdi]), ctx[CpuRegister.Rsi]);
|
||||
return ctx.SetReturn(0);
|
||||
TraceNpWebApi2("init-alt", libraryContextId, 0);
|
||||
return ctx.SetReturn(handle);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
@@ -50,12 +81,25 @@ public static class NpWebApi2Exports
|
||||
LibraryName = "libSceNpWebApi2")]
|
||||
public static int NpWebApi2CreateUserContext(CpuContext ctx)
|
||||
{
|
||||
// No PSN backend: refuse user-context creation so the title's online
|
||||
// layer backs off instead of driving a half-created context handle.
|
||||
TraceNpWebApi2("create-user-context", unchecked((int)ctx[CpuRegister.Rdi]), ctx[CpuRegister.Rsi]);
|
||||
var libraryContextId = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var userId = unchecked((int)ctx[CpuRegister.Rsi]);
|
||||
|
||||
TraceNpWebApi2(
|
||||
"create-user-context",
|
||||
libraryContextId,
|
||||
unchecked((uint)userId));
|
||||
|
||||
if (Volatile.Read(ref _initialized) == 0 ||
|
||||
!IsValidLibraryContextId(libraryContextId) ||
|
||||
userId == -1)
|
||||
{
|
||||
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
|
||||
}
|
||||
|
||||
var userContextId = Interlocked.Increment(ref _nextUserContextHandle);
|
||||
return ctx.SetReturn(userContextId);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "bEvXpcEk200",
|
||||
ExportName = "sceNpWebApi2Terminate",
|
||||
@@ -64,11 +108,57 @@ public static class NpWebApi2Exports
|
||||
public static int NpWebApi2Terminate(CpuContext ctx)
|
||||
{
|
||||
var libraryContextId = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
Interlocked.Exchange(ref _initialized, 0);
|
||||
if (!IsValidLibraryContextId(libraryContextId))
|
||||
{
|
||||
return ctx.SetReturn(NpWebApi2ErrorInvalidArgument);
|
||||
}
|
||||
|
||||
RemoveLibraryContextId(libraryContextId);
|
||||
TraceNpWebApi2("term", libraryContextId, 0);
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
private static int CreateLibraryContextId()
|
||||
{
|
||||
var handle = Interlocked.Increment(ref _nextLibraryContextHandle);
|
||||
lock (_contextGate)
|
||||
{
|
||||
_libraryContexts.Add(handle);
|
||||
}
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
private static int CreatePushEventHandle()
|
||||
{
|
||||
return Interlocked.Increment(ref _nextPushEventHandle);
|
||||
}
|
||||
|
||||
private static bool IsValidLibraryContextId(int libraryContextId)
|
||||
{
|
||||
if (libraryContextId <= 0 || libraryContextId >= 0x8000)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_contextGate)
|
||||
{
|
||||
return _libraryContexts.Contains(libraryContextId);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RemoveLibraryContextId(int libraryContextId)
|
||||
{
|
||||
lock (_contextGate)
|
||||
{
|
||||
_libraryContexts.Remove(libraryContextId);
|
||||
if (_libraryContexts.Count == 0)
|
||||
{
|
||||
Interlocked.Exchange(ref _initialized, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void TraceNpWebApi2(string operation, int id, ulong arg0)
|
||||
{
|
||||
if (!string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_NP_WEB_API2"), "1", StringComparison.Ordinal))
|
||||
|
||||
@@ -28,6 +28,7 @@ public static class SaveDataExports
|
||||
private const ulong ResultInfosOffset = 0x20;
|
||||
private const uint SortKeyFreeBlocks = 5;
|
||||
private const uint SortOrderDescent = 1;
|
||||
private const uint MountModeReadOnly = 1u << 0;
|
||||
private const uint MountModeCreate = 1u << 2;
|
||||
private const uint MountModeCreate2 = 1u << 5;
|
||||
private const int MountResultSize = 0x40;
|
||||
@@ -713,16 +714,43 @@ public static class SaveDataExports
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
if (userId < 0 || string.IsNullOrWhiteSpace(dirName))
|
||||
return MountSaveData(
|
||||
ctx,
|
||||
"mount3",
|
||||
userId,
|
||||
ResolveConfiguredTitleId(),
|
||||
dirName,
|
||||
blocks,
|
||||
systemBlocks,
|
||||
mountMode,
|
||||
resource,
|
||||
mode,
|
||||
resultAddress);
|
||||
}
|
||||
|
||||
private static int MountSaveData(
|
||||
CpuContext ctx,
|
||||
string operation,
|
||||
int userId,
|
||||
string titleId,
|
||||
string dirName,
|
||||
ulong blocks,
|
||||
ulong systemBlocks,
|
||||
uint mountMode,
|
||||
uint resource,
|
||||
uint mode,
|
||||
ulong resultAddress)
|
||||
{
|
||||
if (userId < 0 || string.IsNullOrWhiteSpace(titleId) || string.IsNullOrWhiteSpace(dirName))
|
||||
{
|
||||
return SetReturn(ctx, OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var titleId = ResolveConfiguredTitleId();
|
||||
var sanitizedTitleId = SanitizePathSegment(titleId.Trim());
|
||||
var savePath = Path.Combine(
|
||||
ResolveTitleSaveRoot(userId, titleId),
|
||||
ResolveTitleSaveRoot(userId, sanitizedTitleId),
|
||||
SanitizePathSegment(dirName));
|
||||
var existed = Directory.Exists(savePath);
|
||||
var create = (mountMode & MountModeCreate) != 0;
|
||||
@@ -760,7 +788,7 @@ public static class SaveDataExports
|
||||
}
|
||||
|
||||
TraceSaveData(
|
||||
$"mount3 user={userId} title={titleId} dir={dirName} blocks={blocks} " +
|
||||
$"{operation} user={userId} title={sanitizedTitleId} dir={dirName} blocks={blocks} " +
|
||||
$"system_blocks={systemBlocks} mount_mode=0x{mountMode:X} resource={resource} mode={mode} " +
|
||||
$"mount_point={mountPoint} created={!existed} root='{savePath}'");
|
||||
return SetReturn(ctx, 0);
|
||||
@@ -779,6 +807,52 @@ public static class SaveDataExports
|
||||
}
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "WAzWTZm1H+I",
|
||||
ExportName = "sceSaveDataTransferringMount",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataTransferringMount(CpuContext ctx)
|
||||
{
|
||||
var mountAddress = ctx[CpuRegister.Rdi];
|
||||
var resultAddress = ctx[CpuRegister.Rsi];
|
||||
if (mountAddress == 0 || resultAddress == 0)
|
||||
{
|
||||
return SetReturn(ctx, OrbisSaveDataErrorParameter);
|
||||
}
|
||||
|
||||
if (!TryReadInt32(ctx, mountAddress, out var userId) ||
|
||||
!ctx.TryReadUInt64(mountAddress + 0x08, out var titleIdAddress) ||
|
||||
!ctx.TryReadUInt64(mountAddress + 0x10, out var dirNameAddress) ||
|
||||
titleIdAddress == 0 ||
|
||||
dirNameAddress == 0 ||
|
||||
!TryReadFixedAscii(ctx, titleIdAddress, SaveDataTitleIdSize, out var titleId) ||
|
||||
!TryReadFixedAscii(ctx, dirNameAddress, SaveDataDirNameSize, out var dirName))
|
||||
{
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
return MountSaveData(
|
||||
ctx,
|
||||
"transferring_mount",
|
||||
userId,
|
||||
titleId,
|
||||
dirName,
|
||||
0,
|
||||
0,
|
||||
MountModeReadOnly,
|
||||
0,
|
||||
0,
|
||||
resultAddress);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "RjMlsR8EXrw",
|
||||
ExportName = "sceSaveDataTransferringMountPs4",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataTransferringMountPs4(CpuContext ctx) => SaveDataTransferringMount(ctx);
|
||||
|
||||
private static int _nextTransactionResource;
|
||||
[SysAbiExport(
|
||||
Nid = "gjRZNnw0JPE",
|
||||
@@ -787,6 +861,34 @@ public static class SaveDataExports
|
||||
LibraryName = "libSceSaveData")]
|
||||
public static int SaveDataCreateTransactionResource(CpuContext ctx)
|
||||
{
|
||||
// Demon's Souls first-run call:
|
||||
// RDI = 0xC0000, RSI = RDX + 8, RDX = resource output.
|
||||
// Writing integer handle 1 makes the title dereference [1 + 8],
|
||||
// causing the repeatable access violation at guest address 0x9.
|
||||
var desWorkSize = ctx[CpuRegister.Rdi];
|
||||
var desWorkAddress = ctx[CpuRegister.Rsi];
|
||||
var desResourceAddress = ctx[CpuRegister.Rdx];
|
||||
|
||||
if (desWorkSize == 0xC0000 &&
|
||||
desResourceAddress != 0 &&
|
||||
desResourceAddress <= ulong.MaxValue - sizeof(ulong) &&
|
||||
desWorkAddress == desResourceAddress + sizeof(ulong))
|
||||
{
|
||||
if (!ctx.TryWriteUInt64(desResourceAddress, 0))
|
||||
{
|
||||
return SetReturn(
|
||||
ctx,
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
TraceSaveData(
|
||||
$"create_transaction_resource_des_guard " +
|
||||
$"work_size=0x{desWorkSize:X} " +
|
||||
$"work=0x{desWorkAddress:X} " +
|
||||
$"resource_addr=0x{desResourceAddress:X} resource=0x0");
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
}
|
||||
var userId = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var reserved = ctx[CpuRegister.Rsi];
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ public static class ShareExports
|
||||
|
||||
private static int _initialized;
|
||||
private static string _contentParam = string.Empty;
|
||||
private static readonly object _callbackGate = new();
|
||||
private static ulong _contentEventCallback;
|
||||
private static ulong _contentEventCallbackArgument;
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "nBDD66kiFW8",
|
||||
@@ -62,6 +65,56 @@ public static class ShareExports
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "Sygnk9dr5WQ",
|
||||
ExportName = "sceShareRegisterContentEventCallback",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceShareUtility")]
|
||||
public static int ShareRegisterContentEventCallback(CpuContext ctx)
|
||||
{
|
||||
var callback = ctx[CpuRegister.Rdi];
|
||||
var argument = ctx[CpuRegister.Rsi];
|
||||
if (callback == 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
lock (_callbackGate)
|
||||
{
|
||||
_contentEventCallback = callback;
|
||||
_contentEventCallbackArgument = argument;
|
||||
}
|
||||
|
||||
TraceShare($"register_content_event_callback fn=0x{callback:X16} arg=0x{argument:X16}");
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "KnsfHKmZqFA",
|
||||
ExportName = "sceShareUnregisterContentEventCallback",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceShareUtility")]
|
||||
public static int ShareUnregisterContentEventCallback(CpuContext ctx)
|
||||
{
|
||||
var callback = ctx[CpuRegister.Rdi];
|
||||
if (callback == 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
lock (_callbackGate)
|
||||
{
|
||||
if (_contentEventCallback == callback)
|
||||
{
|
||||
_contentEventCallback = 0;
|
||||
_contentEventCallbackArgument = 0;
|
||||
}
|
||||
}
|
||||
|
||||
TraceShare($"unregister_content_event_callback fn=0x{callback:X16}");
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
private static bool TryReadNullTerminatedUtf8(CpuContext ctx, ulong address, int maxLength, out string value)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[maxLength];
|
||||
|
||||
@@ -118,7 +118,7 @@ public static class UserServiceExports
|
||||
var userId = unchecked((int)ctx[CpuRegister.Rdi]);
|
||||
var nameAddress = ctx[CpuRegister.Rsi];
|
||||
var capacity = ctx[CpuRegister.Rdx];
|
||||
if (userId != PrimaryUserId)
|
||||
if (userId != PrimaryUserId && userId != 1)
|
||||
{
|
||||
return SetReturn(ctx, OrbisUserServiceErrorInvalidParameter);
|
||||
}
|
||||
@@ -144,6 +144,16 @@ public static class UserServiceExports
|
||||
: SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
// Title-captured alias NID for the same username query.
|
||||
#pragma warning disable SHEM004
|
||||
[SysAbiExport(
|
||||
Nid = "znaWI0gpuo8",
|
||||
ExportName = "sceUserServiceGetUserName",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceUserService")]
|
||||
public static int UserServiceGetUserNameAlt(CpuContext ctx) => UserServiceGetUserName(ctx);
|
||||
#pragma warning restore SHEM004
|
||||
|
||||
// Name not yet in ps5_names.txt and the NID was captured from titles; revisit when the symbol is catalogued.
|
||||
#pragma warning disable SHEM006
|
||||
[SysAbiExport(
|
||||
|
||||
@@ -2230,7 +2230,6 @@ internal static unsafe class VulkanVideoPresenter
|
||||
if (IsGuestWorkCompletedLocked(pending.RequiredGuestWorkSequence))
|
||||
{
|
||||
presentation = _pendingGuestImagePresentations.Dequeue();
|
||||
TryReplaceWithBinkFrame(ref presentation);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2263,29 +2262,10 @@ internal static unsafe class VulkanVideoPresenter
|
||||
}
|
||||
|
||||
presentation = latest;
|
||||
TryReplaceWithBinkFrame(ref presentation);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryReplaceWithBinkFrame(ref Presentation presentation)
|
||||
{
|
||||
if (!Bink2MovieBridge.TryDecodeNextFrame(out var pixels, out var width, out var height))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
presentation = new Presentation(
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
presentation.Sequence,
|
||||
GuestDrawKind.None,
|
||||
TranslatedDraw: null,
|
||||
presentation.RequiredGuestWorkSequence,
|
||||
IsSplash: false);
|
||||
}
|
||||
|
||||
private static readonly HashSet<long> _tracedGuestImagePresentRejections = new();
|
||||
|
||||
private static bool HasPendingGuestPresentation(long presentedSequence)
|
||||
@@ -2835,6 +2815,40 @@ internal static unsafe class VulkanVideoPresenter
|
||||
private VkBuffer _stagingBuffer;
|
||||
private DeviceMemory _stagingMemory;
|
||||
private ulong _stagingSize;
|
||||
private VkBuffer[] _frameUploadBuffers = [];
|
||||
private DeviceMemory[] _frameUploadMemory = [];
|
||||
private nint[] _frameUploadMapped = [];
|
||||
private Image _hostMovieImage;
|
||||
private DeviceMemory _hostMovieImageMemory;
|
||||
private ImageView _hostMovieImageView;
|
||||
private uint _hostMovieImageWidth;
|
||||
private uint _hostMovieImageHeight;
|
||||
private Format _hostMovieImageFormat;
|
||||
private uint _hostMovieImageDstSelect;
|
||||
private bool _hostMovieImageInitialized;
|
||||
private Image _hostMovieChromaImage;
|
||||
private DeviceMemory _hostMovieChromaImageMemory;
|
||||
private ImageView _hostMovieChromaImageView;
|
||||
private uint _hostMovieChromaImageWidth;
|
||||
private uint _hostMovieChromaImageHeight;
|
||||
private uint _hostMovieChromaImageDstSelect;
|
||||
private bool _hostMovieChromaImageInitialized;
|
||||
private byte[]? _hostMovieFramePixels;
|
||||
private byte[]? _hostMovieLumaPixels;
|
||||
private byte[]? _hostMovieChromaPixels;
|
||||
private uint _hostMovieFrameWidth;
|
||||
private uint _hostMovieFrameHeight;
|
||||
private long _hostMovieFrameSerial;
|
||||
private long _hostMovieConvertedFrameSerial = -1;
|
||||
private long _hostMovieLumaUploadedFrameSerial = -1;
|
||||
private long _hostMovieChromaUploadedFrameSerial = -1;
|
||||
private string? _hostMovieFramePath;
|
||||
private ulong _hostMovieLumaTextureAddress;
|
||||
private ulong _hostMovieChromaTextureAddress;
|
||||
private uint _hostMovieLumaDstSelect;
|
||||
private uint _hostMovieChromaDstSelect;
|
||||
private readonly HashSet<string> _tracedHostMovieTextureBindings =
|
||||
new(StringComparer.Ordinal);
|
||||
// Perf overlay: CPU-rasterized panel copied through per-slot staging
|
||||
// buffers into one image, then blitted onto the swapchain.
|
||||
private Image _overlayImage;
|
||||
@@ -3027,6 +3041,9 @@ internal static unsafe class VulkanVideoPresenter
|
||||
public bool OwnsStorage;
|
||||
public bool IsStorage;
|
||||
public bool Cached;
|
||||
public bool IsHostMovie;
|
||||
public int HostMoviePlane = -1;
|
||||
public long HostMovieFrameSerial;
|
||||
public ulong CpuContentFingerprint;
|
||||
public bool UpdatesCpuContent;
|
||||
public GuestSampler SamplerState;
|
||||
@@ -3251,6 +3268,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WaitForRenderDocAttachIfRequested();
|
||||
_vk = Vk.GetApi();
|
||||
CreateInstance();
|
||||
@@ -3265,6 +3284,12 @@ internal static unsafe class VulkanVideoPresenter
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] Vulkan VideoOut ready: {_extent.Width}x{_extent.Height}, format={_swapchainFormat}");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_vulkanReady = false;
|
||||
Console.Error.WriteLine($"[LOADER][WARN] Vulkan VideoOut disabled: {exception.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WaitForRenderDocAttachIfRequested()
|
||||
{
|
||||
@@ -4320,6 +4345,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
var surfaceFormat = ChooseSurfaceFormat(formats);
|
||||
_swapchainFormat = surfaceFormat.Format;
|
||||
_extent = ChooseExtent(capabilities);
|
||||
Bink2MovieBridge.SetPresentationSize(_extent.Width, _extent.Height);
|
||||
var presentMode = ChoosePresentMode();
|
||||
var imageCount = capabilities.MinImageCount + 1;
|
||||
if (capabilities.MaxImageCount != 0)
|
||||
@@ -4466,6 +4492,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
_presentationCommandBuffer = _commandBuffer;
|
||||
|
||||
CreateStagingBuffer((ulong)_extent.Width * _extent.Height * 4);
|
||||
CreateFrameUploadBuffers(_stagingSize);
|
||||
CreateOverlayResources();
|
||||
}
|
||||
|
||||
@@ -5457,6 +5484,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
FlipVersion = version,
|
||||
Width = source.Width,
|
||||
Height = source.Height,
|
||||
LogicalWidth = source.LogicalWidth,
|
||||
LogicalHeight = source.LogicalHeight,
|
||||
MipLevels = 1,
|
||||
GuestFormat = source.GuestFormat,
|
||||
Format = source.Format,
|
||||
@@ -6066,10 +6095,15 @@ internal static unsafe class VulkanVideoPresenter
|
||||
}
|
||||
}
|
||||
|
||||
var hostMovieTextures = FindHostMovieTextureBindings(draw.Textures);
|
||||
for (var index = 0; index < draw.Textures.Count; index++)
|
||||
{
|
||||
var texture = draw.Textures[index];
|
||||
var resolved = ResolveTextureResource(texture);
|
||||
var resolved = index == hostMovieTextures.Luma
|
||||
? CreateHostMovieTextureResource(texture, plane: 0)
|
||||
: index == hostMovieTextures.Chroma
|
||||
? CreateHostMovieTextureResource(texture, plane: 1)
|
||||
: ResolveTextureResource(texture);
|
||||
var feedbackTarget = !texture.IsStorage
|
||||
? feedbackTargets?.FirstOrDefault(target =>
|
||||
ReferenceEquals(resolved.GuestImage, target))
|
||||
@@ -6924,6 +6958,343 @@ internal static unsafe class VulkanVideoPresenter
|
||||
}
|
||||
}
|
||||
|
||||
private void PumpHostMovieFrame()
|
||||
{
|
||||
if (!Bink2MovieBridge.TryDecodeNextFrame(
|
||||
advanceClock: _hostMovieLumaTextureAddress != 0 &&
|
||||
_hostMovieChromaTextureAddress != 0,
|
||||
out var pixels,
|
||||
out var width,
|
||||
out var height,
|
||||
out var advanced,
|
||||
out var frameSerial,
|
||||
out var hostPath))
|
||||
{
|
||||
// Keep the last decoded image until a replacement arrives.
|
||||
// Movie sessions are queued asynchronously; clearing here
|
||||
// exposes the guest decoder's neutral surfaces between the
|
||||
// final frame of one movie and the first frame of the next.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(
|
||||
_hostMovieFramePath,
|
||||
hostPath,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_hostMovieFramePath = hostPath;
|
||||
_hostMovieLumaTextureAddress = 0;
|
||||
_hostMovieChromaTextureAddress = 0;
|
||||
_hostMovieLumaDstSelect = 0;
|
||||
_hostMovieChromaDstSelect = 0;
|
||||
_hostMovieConvertedFrameSerial = -1;
|
||||
_hostMovieLumaUploadedFrameSerial = -1;
|
||||
_hostMovieChromaUploadedFrameSerial = -1;
|
||||
}
|
||||
|
||||
if (!advanced && _hostMovieFramePixels is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_hostMovieFramePixels = pixels;
|
||||
_hostMovieFrameWidth = width;
|
||||
_hostMovieFrameHeight = height;
|
||||
_hostMovieFrameSerial = frameSerial;
|
||||
}
|
||||
|
||||
private readonly record struct HostMovieTextureBindings(int Luma, int Chroma)
|
||||
{
|
||||
public static HostMovieTextureBindings None { get; } = new(-1, -1);
|
||||
}
|
||||
|
||||
private HostMovieTextureBindings FindHostMovieTextureBindings(
|
||||
IReadOnlyList<GuestDrawTexture> textures)
|
||||
{
|
||||
if (_hostMovieFramePixels is null ||
|
||||
_hostMovieFrameWidth == 0 ||
|
||||
_hostMovieFrameHeight == 0)
|
||||
{
|
||||
return HostMovieTextureBindings.None;
|
||||
}
|
||||
|
||||
if (_hostMovieLumaTextureAddress != 0 &&
|
||||
_hostMovieChromaTextureAddress != 0)
|
||||
{
|
||||
var lumaIndex = -1;
|
||||
var chromaIndex = -1;
|
||||
for (var index = 0; index < textures.Count; index++)
|
||||
{
|
||||
if (textures[index].Address == _hostMovieLumaTextureAddress)
|
||||
{
|
||||
lumaIndex = index;
|
||||
}
|
||||
else if (textures[index].Address == _hostMovieChromaTextureAddress)
|
||||
{
|
||||
chromaIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
if (lumaIndex >= 0 && chromaIndex >= 0)
|
||||
{
|
||||
return RememberHostMovieTextureMappings(
|
||||
textures,
|
||||
lumaIndex,
|
||||
chromaIndex);
|
||||
}
|
||||
|
||||
// Bluepoint alternates decoder output between multiple Y/UV
|
||||
// surface pairs. Fall through and discover the active pair
|
||||
// instead of sampling the stale guest surface on every other
|
||||
// movie draw.
|
||||
}
|
||||
|
||||
var bestLumaIndex = -1;
|
||||
var bestChromaIndex = -1;
|
||||
ulong bestArea = 0;
|
||||
for (var lumaIndex = 0; lumaIndex < textures.Count; lumaIndex++)
|
||||
{
|
||||
var luma = textures[lumaIndex];
|
||||
if (!IsHostMovieLumaCandidate(luma))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var chromaIndex = 0; chromaIndex < textures.Count; chromaIndex++)
|
||||
{
|
||||
var chroma = textures[chromaIndex];
|
||||
if (!IsHostMovieChromaCandidate(luma, chroma))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var area = (ulong)luma.Width * luma.Height;
|
||||
if (bestLumaIndex < 0 || area > bestArea)
|
||||
{
|
||||
bestLumaIndex = lumaIndex;
|
||||
bestChromaIndex = chromaIndex;
|
||||
bestArea = area;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestLumaIndex < 0 || bestChromaIndex < 0)
|
||||
{
|
||||
return HostMovieTextureBindings.None;
|
||||
}
|
||||
|
||||
var lumaTexture = textures[bestLumaIndex];
|
||||
var chromaTexture = textures[bestChromaIndex];
|
||||
_hostMovieLumaTextureAddress = lumaTexture.Address;
|
||||
_hostMovieChromaTextureAddress = chromaTexture.Address;
|
||||
_hostMovieLumaDstSelect = lumaTexture.DstSelect;
|
||||
_hostMovieChromaDstSelect = chromaTexture.DstSelect;
|
||||
var traceKey =
|
||||
$"{_hostMovieFramePath}|{lumaTexture.Address:X16}|{chromaTexture.Address:X16}";
|
||||
if (_tracedHostMovieTextureBindings.Add(traceKey))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] Bink2 YUV textures bound: " +
|
||||
$"{Path.GetFileName(_hostMovieFramePath)} " +
|
||||
$"y={bestLumaIndex}:0x{lumaTexture.Address:X16}:" +
|
||||
$"{lumaTexture.Width}x{lumaTexture.Height}:dst=0x{lumaTexture.DstSelect:X} " +
|
||||
$"uv={bestChromaIndex}:0x{chromaTexture.Address:X16}:" +
|
||||
$"{chromaTexture.Width}x{chromaTexture.Height}:dst=0x{chromaTexture.DstSelect:X} " +
|
||||
$"host={_hostMovieFrameWidth}x{_hostMovieFrameHeight}.");
|
||||
}
|
||||
|
||||
return new HostMovieTextureBindings(bestLumaIndex, bestChromaIndex);
|
||||
}
|
||||
|
||||
private HostMovieTextureBindings RememberHostMovieTextureMappings(
|
||||
IReadOnlyList<GuestDrawTexture> textures,
|
||||
int lumaIndex,
|
||||
int chromaIndex)
|
||||
{
|
||||
_hostMovieLumaDstSelect = textures[lumaIndex].DstSelect;
|
||||
_hostMovieChromaDstSelect = textures[chromaIndex].DstSelect;
|
||||
return new HostMovieTextureBindings(lumaIndex, chromaIndex);
|
||||
}
|
||||
|
||||
private bool IsHostMovieLumaCandidate(GuestDrawTexture texture)
|
||||
{
|
||||
if (texture.Address == 0 ||
|
||||
texture.IsStorage ||
|
||||
texture.IsFallback ||
|
||||
texture.ArrayedView ||
|
||||
texture.ArrayLayers > 1 ||
|
||||
texture.Format != 1 ||
|
||||
texture.NumberType != 0 ||
|
||||
texture.Width < 1280 ||
|
||||
texture.Height < 720)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var guestAspect = (ulong)texture.Width * _hostMovieFrameHeight;
|
||||
var hostAspect = (ulong)texture.Height * _hostMovieFrameWidth;
|
||||
var difference = guestAspect > hostAspect
|
||||
? guestAspect - hostAspect
|
||||
: hostAspect - guestAspect;
|
||||
return difference * 100 <= Math.Max(guestAspect, hostAspect) * 2;
|
||||
}
|
||||
|
||||
private static bool IsHostMovieChromaCandidate(
|
||||
GuestDrawTexture luma,
|
||||
GuestDrawTexture chroma)
|
||||
{
|
||||
return chroma.Address != 0 &&
|
||||
chroma.Address != luma.Address &&
|
||||
!chroma.IsStorage &&
|
||||
!chroma.IsFallback &&
|
||||
!chroma.ArrayedView &&
|
||||
chroma.ArrayLayers <= 1 &&
|
||||
chroma.Format == 3 &&
|
||||
chroma.NumberType == 0 &&
|
||||
luma.Width == chroma.Width * 2 &&
|
||||
luma.Height == chroma.Height * 2;
|
||||
}
|
||||
|
||||
private TextureResource CreateHostMovieTextureResource(
|
||||
GuestDrawTexture texture,
|
||||
int plane)
|
||||
{
|
||||
EnsureHostMovieYuvFrame();
|
||||
var isLuma = plane == 0;
|
||||
var pixels = isLuma ? _hostMovieLumaPixels! : _hostMovieChromaPixels!;
|
||||
var width = isLuma
|
||||
? _hostMovieFrameWidth
|
||||
: (_hostMovieFrameWidth + 1) / 2;
|
||||
var height = isLuma
|
||||
? _hostMovieFrameHeight
|
||||
: (_hostMovieFrameHeight + 1) / 2;
|
||||
EnsureHostMovieImages(
|
||||
_hostMovieFrameWidth,
|
||||
_hostMovieFrameHeight,
|
||||
_hostMovieLumaDstSelect,
|
||||
(_hostMovieFrameWidth + 1) / 2,
|
||||
(_hostMovieFrameHeight + 1) / 2,
|
||||
_hostMovieChromaDstSelect);
|
||||
|
||||
var uploadedFrameSerial = isLuma
|
||||
? _hostMovieLumaUploadedFrameSerial
|
||||
: _hostMovieChromaUploadedFrameSerial;
|
||||
var needsUpload = uploadedFrameSerial != _hostMovieFrameSerial;
|
||||
VkBuffer stagingBuffer = default;
|
||||
DeviceMemory stagingMemory = default;
|
||||
if (needsUpload)
|
||||
{
|
||||
(stagingBuffer, stagingMemory) = CreateTextureStagingBuffer(
|
||||
pixels,
|
||||
$"Bink2 frame {_hostMovieFrameSerial} plane {plane} staging");
|
||||
}
|
||||
|
||||
return new TextureResource
|
||||
{
|
||||
Address = texture.Address,
|
||||
StagingBuffer = stagingBuffer,
|
||||
StagingMemory = stagingMemory,
|
||||
Image = isLuma ? _hostMovieImage : _hostMovieChromaImage,
|
||||
View = isLuma ? _hostMovieImageView : _hostMovieChromaImageView,
|
||||
Width = width,
|
||||
Height = height,
|
||||
RowLength = width,
|
||||
DstSelect = texture.DstSelect,
|
||||
NeedsUpload = needsUpload,
|
||||
IsHostMovie = true,
|
||||
HostMoviePlane = plane,
|
||||
HostMovieFrameSerial = _hostMovieFrameSerial,
|
||||
SamplerState = texture.Sampler,
|
||||
};
|
||||
}
|
||||
|
||||
private void EnsureHostMovieYuvFrame()
|
||||
{
|
||||
if (_hostMovieConvertedFrameSerial == _hostMovieFrameSerial)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var bgra = _hostMovieFramePixels ??
|
||||
throw new InvalidOperationException("Host movie frame is unavailable.");
|
||||
var width = checked((int)_hostMovieFrameWidth);
|
||||
var height = checked((int)_hostMovieFrameHeight);
|
||||
var chromaWidth = (width + 1) / 2;
|
||||
var chromaHeight = (height + 1) / 2;
|
||||
if (_hostMovieLumaPixels?.Length != width * height)
|
||||
{
|
||||
_hostMovieLumaPixels = GC.AllocateUninitializedArray<byte>(width * height);
|
||||
}
|
||||
if (_hostMovieChromaPixels?.Length != chromaWidth * chromaHeight * 2)
|
||||
{
|
||||
_hostMovieChromaPixels =
|
||||
GC.AllocateUninitializedArray<byte>(chromaWidth * chromaHeight * 2);
|
||||
}
|
||||
|
||||
ConvertBgraToYuv420(
|
||||
bgra,
|
||||
width,
|
||||
height,
|
||||
_hostMovieLumaPixels,
|
||||
_hostMovieChromaPixels);
|
||||
_hostMovieConvertedFrameSerial = _hostMovieFrameSerial;
|
||||
}
|
||||
|
||||
internal static void ConvertBgraToYuv420(
|
||||
ReadOnlySpan<byte> bgra,
|
||||
int width,
|
||||
int height,
|
||||
Span<byte> luma,
|
||||
Span<byte> chroma)
|
||||
{
|
||||
var chromaWidth = (width + 1) / 2;
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
var source = (y * width + x) * 4;
|
||||
var b = bgra[source];
|
||||
var g = bgra[source + 1];
|
||||
var r = bgra[source + 2];
|
||||
luma[y * width + x] = ClampByte(
|
||||
(54 * r + 183 * g + 19 * b + 128) >> 8);
|
||||
}
|
||||
}
|
||||
|
||||
for (var y = 0; y < height; y += 2)
|
||||
{
|
||||
for (var x = 0; x < width; x += 2)
|
||||
{
|
||||
var red = 0;
|
||||
var green = 0;
|
||||
var blue = 0;
|
||||
var samples = 0;
|
||||
for (var sampleY = y; sampleY < Math.Min(y + 2, height); sampleY++)
|
||||
{
|
||||
for (var sampleX = x; sampleX < Math.Min(x + 2, width); sampleX++)
|
||||
{
|
||||
var source = (sampleY * width + sampleX) * 4;
|
||||
blue += bgra[source];
|
||||
green += bgra[source + 1];
|
||||
red += bgra[source + 2];
|
||||
samples++;
|
||||
}
|
||||
}
|
||||
|
||||
red /= samples;
|
||||
green /= samples;
|
||||
blue /= samples;
|
||||
var destination = ((y / 2) * chromaWidth + x / 2) * 2;
|
||||
chroma[destination] = ClampByte(
|
||||
((128 * red - 116 * green - 12 * blue + 128) >> 8) + 128);
|
||||
chroma[destination + 1] = ClampByte(
|
||||
((-29 * red - 99 * green + 128 * blue + 128) >> 8) + 128);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte ClampByte(int value) => (byte)Math.Clamp(value, 0, 255);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private TextureResource ResolveTextureResource(GuestDrawTexture texture)
|
||||
{
|
||||
@@ -7604,6 +7975,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
Address = 0,
|
||||
Width = width,
|
||||
Height = height,
|
||||
LogicalWidth = width,
|
||||
LogicalHeight = height,
|
||||
MipLevels = 1,
|
||||
GuestFormat = GetGuestTextureFormat(texture.Format, texture.NumberType),
|
||||
Format = vkFormat,
|
||||
@@ -7810,6 +8183,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
Address = texture.Address,
|
||||
Width = width,
|
||||
Height = height,
|
||||
LogicalWidth = width,
|
||||
LogicalHeight = height,
|
||||
MipLevels = 1,
|
||||
GuestFormat = guestFormat,
|
||||
Format = vkFormat,
|
||||
@@ -9525,6 +9900,26 @@ internal static unsafe class VulkanVideoPresenter
|
||||
_stagingSize = size;
|
||||
}
|
||||
|
||||
private void CreateFrameUploadBuffers(ulong size)
|
||||
{
|
||||
_frameUploadBuffers = new VkBuffer[MaxFramesInFlight];
|
||||
_frameUploadMemory = new DeviceMemory[MaxFramesInFlight];
|
||||
_frameUploadMapped = new nint[MaxFramesInFlight];
|
||||
for (var slot = 0; slot < MaxFramesInFlight; slot++)
|
||||
{
|
||||
_frameUploadBuffers[slot] = CreateBuffer(
|
||||
size,
|
||||
BufferUsageFlags.TransferSrcBit,
|
||||
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
|
||||
out _frameUploadMemory[slot]);
|
||||
void* mapped;
|
||||
Check(
|
||||
_vk.MapMemory(_device, _frameUploadMemory[slot], 0, size, 0, &mapped),
|
||||
"vkMapMemory(frame upload)");
|
||||
_frameUploadMapped[slot] = (nint)mapped;
|
||||
}
|
||||
}
|
||||
|
||||
private uint FindMemoryType(uint typeBits, MemoryPropertyFlags requiredFlags)
|
||||
{
|
||||
_vk.GetPhysicalDeviceMemoryProperties(_physicalDevice, out var properties);
|
||||
@@ -9576,6 +9971,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
return;
|
||||
}
|
||||
|
||||
PumpHostMovieFrame();
|
||||
|
||||
if (_skipAllCompute ||
|
||||
AddressListContains("SHARPEMU_SKIP_COMPUTE_CS", work.ShaderAddress) ||
|
||||
(_skipTallComputeZ > 0 && work.GroupCountZ >= _skipTallComputeZ))
|
||||
@@ -11091,6 +11488,65 @@ internal static unsafe class VulkanVideoPresenter
|
||||
target.Initialized = true;
|
||||
}
|
||||
|
||||
// Returns the source row length in texels when the upload is a linear
|
||||
// image whose rows are padded to a wider hardware pitch, or 0 when the
|
||||
// data is an exact tightly packed match or not a recognisable padded
|
||||
// layout (in which case the caller keeps rejecting it). Only a pitch
|
||||
// equal to the width rounded up to a common alignment is accepted, so an
|
||||
// oversized buffer that still carries mip data is left rejected rather
|
||||
// than mis-copied.
|
||||
private static uint TryGetPaddedUploadRowLength(
|
||||
GuestImageResource target,
|
||||
ulong uploadByteCount,
|
||||
ulong expectedByteCount)
|
||||
{
|
||||
if (expectedByteCount == 0
|
||||
|| target.Width == 0
|
||||
|| target.Height == 0
|
||||
|| uploadByteCount <= expectedByteCount)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var texelCount = (ulong)target.Width * target.Height;
|
||||
if (texelCount == 0 || expectedByteCount % texelCount != 0)
|
||||
{
|
||||
// Block-compressed or otherwise non-linear: no per-texel pitch.
|
||||
return 0;
|
||||
}
|
||||
|
||||
var bytesPerTexel = expectedByteCount / texelCount;
|
||||
if (bytesPerTexel == 0 || uploadByteCount % target.Height != 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var rowBytes = uploadByteCount / target.Height;
|
||||
if (rowBytes % bytesPerTexel != 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var rowTexels = rowBytes / bytesPerTexel;
|
||||
if (rowTexels < target.Width || rowTexels > uint.MaxValue)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach (var alignment in (ReadOnlySpan<uint>)[8, 16, 32, 64, 128, 256])
|
||||
{
|
||||
if (AlignUp(target.Width, alignment) == rowTexels)
|
||||
{
|
||||
return (uint)rowTexels;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static uint AlignUp(uint value, uint alignment) =>
|
||||
(value + alignment - 1) / alignment * alignment;
|
||||
|
||||
private void UploadGuestImageInitialData(GuestImageResource target, byte[] pixels)
|
||||
{
|
||||
var guestDataFormat = (target.GuestFormat & 0x8000_0000u) != 0
|
||||
@@ -11103,7 +11559,19 @@ internal static unsafe class VulkanVideoPresenter
|
||||
target.Format,
|
||||
target.Width,
|
||||
target.Height);
|
||||
if (expectedByteCount == 0 || (ulong)uploadPixels.Length != expectedByteCount)
|
||||
|
||||
// The guest can hand us linear pixel data whose rows are padded out
|
||||
// to a hardware pitch wider than the image, so the byte count runs
|
||||
// past the tightly packed width*height*bpp we compute. Recover the
|
||||
// real source row length and copy with it instead of dropping the
|
||||
// upload, which would otherwise leave the texture blank.
|
||||
var uploadRowLengthTexels = TryGetPaddedUploadRowLength(
|
||||
target,
|
||||
(ulong)uploadPixels.Length,
|
||||
expectedByteCount);
|
||||
if (expectedByteCount == 0
|
||||
|| ((ulong)uploadPixels.Length != expectedByteCount
|
||||
&& uploadRowLengthTexels == 0))
|
||||
{
|
||||
if (_rejectedGuestImageUploads.Add(
|
||||
(target.Address, uploadPixels.Length, expectedByteCount, target.Format)))
|
||||
@@ -11177,7 +11645,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
var copyRegion = new BufferImageCopy
|
||||
{
|
||||
BufferOffset = 0,
|
||||
BufferRowLength = 0,
|
||||
BufferRowLength = uploadRowLengthTexels,
|
||||
BufferImageHeight = 0,
|
||||
ImageSubresource = new ImageSubresourceLayers(
|
||||
ImageAspectFlags.ColorBit,
|
||||
@@ -12391,10 +12859,12 @@ internal static unsafe class VulkanVideoPresenter
|
||||
EvictDirtyCachedTextures();
|
||||
var completedWork = 0;
|
||||
HashSet<string>? deferredOrderedQueues = null;
|
||||
var renderWorkDeadline = _renderWorkBudgetTicks > 0
|
||||
? System.Diagnostics.Stopwatch.GetTimestamp() + _renderWorkBudgetTicks
|
||||
var workBudgetTicks = _renderWorkBudgetTicks;
|
||||
var renderWorkDeadline = workBudgetTicks > 0
|
||||
? System.Diagnostics.Stopwatch.GetTimestamp() + workBudgetTicks
|
||||
: long.MaxValue;
|
||||
while (completedWork < _maxGuestWorkPerRender)
|
||||
var workLimit = _maxGuestWorkPerRender;
|
||||
while (completedWork < workLimit)
|
||||
{
|
||||
// Never block the macOS main thread waiting for in-flight GPU
|
||||
// work to drain. If submission is at capacity (a slow-compute
|
||||
@@ -12451,6 +12921,14 @@ internal static unsafe class VulkanVideoPresenter
|
||||
}
|
||||
try
|
||||
{
|
||||
// A host-decoded movie only overrides which image gets
|
||||
// presented (see the presentation selection below); the
|
||||
// guest's own command stream keeps draining normally.
|
||||
// Silently discarding these instead of executing them
|
||||
// leaves guest-visible completion state (labels, buffers,
|
||||
// job results) permanently unwritten, which desyncs the
|
||||
// engine's own job system and previously crashed it
|
||||
// shortly after the movie finished.
|
||||
switch (work)
|
||||
{
|
||||
case VulkanOffscreenGuestDraw offscreenDraw:
|
||||
@@ -12528,7 +13006,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
FlushBatchedGuestCommands();
|
||||
CollectAbandonedGuestImageVersions();
|
||||
|
||||
if (!TryTakePresentation(_presentedSequence, out var presentation))
|
||||
Presentation presentation;
|
||||
if (!TryTakePresentation(_presentedSequence, out presentation))
|
||||
{
|
||||
if (_pendingHostSplashReplay is { } splash)
|
||||
{
|
||||
@@ -12552,7 +13031,6 @@ internal static unsafe class VulkanVideoPresenter
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_hostSurface is not null)
|
||||
{
|
||||
if (presentation.IsSplash && presentation.Pixels is not null)
|
||||
@@ -12605,6 +13083,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
TranslatedDrawResources? translatedResources = null;
|
||||
@@ -12732,19 +13211,11 @@ internal static unsafe class VulkanVideoPresenter
|
||||
|
||||
if (pixels is not null)
|
||||
{
|
||||
// The staging buffer is shared across frame slots; a CPU
|
||||
// pixel upload (splash / host frames) degrades to serial
|
||||
// presentation rather than corrupting an in-flight copy.
|
||||
WaitAllFrameSlots();
|
||||
void* mapped;
|
||||
Check(
|
||||
_vk.MapMemory(_device, _stagingMemory, 0, (ulong)pixels.Length, 0, &mapped),
|
||||
"vkMapMemory");
|
||||
var mapped = (void*)_frameUploadMapped[frameSlot];
|
||||
fixed (byte* source = pixels)
|
||||
{
|
||||
System.Buffer.MemoryCopy(source, mapped, pixels.Length, pixels.Length);
|
||||
}
|
||||
_vk.UnmapMemory(_device, _stagingMemory);
|
||||
}
|
||||
|
||||
Check(_vk.ResetCommandBuffer(_commandBuffer, 0), "vkResetCommandBuffer");
|
||||
@@ -12758,7 +13229,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
PipelineStageFlags waitStage;
|
||||
if (pixels is not null)
|
||||
{
|
||||
RecordUpload(imageIndex);
|
||||
RecordUpload(imageIndex, frameSlot);
|
||||
waitStage = PipelineStageFlags.TransferBit;
|
||||
}
|
||||
else if (presentation.DrawKind == GuestDrawKind.FullscreenBarycentric)
|
||||
@@ -13280,11 +13751,22 @@ internal static unsafe class VulkanVideoPresenter
|
||||
continue;
|
||||
}
|
||||
|
||||
var hostMovieImageInitialized = texture.HostMoviePlane switch
|
||||
{
|
||||
0 => _hostMovieImageInitialized,
|
||||
1 => _hostMovieChromaImageInitialized,
|
||||
_ => false,
|
||||
};
|
||||
var toTransfer = new ImageMemoryBarrier
|
||||
{
|
||||
SType = StructureType.ImageMemoryBarrier,
|
||||
SrcAccessMask = texture.IsHostMovie && hostMovieImageInitialized
|
||||
? AccessFlags.ShaderReadBit
|
||||
: 0,
|
||||
DstAccessMask = AccessFlags.TransferWriteBit,
|
||||
OldLayout = ImageLayout.Undefined,
|
||||
OldLayout = texture.IsHostMovie && hostMovieImageInitialized
|
||||
? ImageLayout.ShaderReadOnlyOptimal
|
||||
: ImageLayout.Undefined,
|
||||
NewLayout = ImageLayout.TransferDstOptimal,
|
||||
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
||||
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
||||
@@ -13293,7 +13775,9 @@ internal static unsafe class VulkanVideoPresenter
|
||||
};
|
||||
_vk.CmdPipelineBarrier(
|
||||
_commandBuffer,
|
||||
PipelineStageFlags.TopOfPipeBit,
|
||||
texture.IsHostMovie && hostMovieImageInitialized
|
||||
? PipelineStageFlags.AllCommandsBit
|
||||
: PipelineStageFlags.TopOfPipeBit,
|
||||
PipelineStageFlags.TransferBit,
|
||||
0,
|
||||
0,
|
||||
@@ -13353,6 +13837,19 @@ internal static unsafe class VulkanVideoPresenter
|
||||
// reuse the image without restaging it.
|
||||
texture.NeedsUpload = false;
|
||||
}
|
||||
if (texture.IsHostMovie)
|
||||
{
|
||||
if (texture.HostMoviePlane == 0)
|
||||
{
|
||||
_hostMovieImageInitialized = true;
|
||||
_hostMovieLumaUploadedFrameSerial = texture.HostMovieFrameSerial;
|
||||
}
|
||||
else if (texture.HostMoviePlane == 1)
|
||||
{
|
||||
_hostMovieChromaImageInitialized = true;
|
||||
_hostMovieChromaUploadedFrameSerial = texture.HostMovieFrameSerial;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14666,7 +15163,176 @@ internal static unsafe class VulkanVideoPresenter
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordUpload(uint imageIndex)
|
||||
private void EnsureHostMovieImages(
|
||||
uint lumaWidth,
|
||||
uint lumaHeight,
|
||||
uint lumaDstSelect,
|
||||
uint chromaWidth,
|
||||
uint chromaHeight,
|
||||
uint chromaDstSelect)
|
||||
{
|
||||
if (_hostMovieImage.Handle != 0 &&
|
||||
_hostMovieImageWidth == lumaWidth &&
|
||||
_hostMovieImageHeight == lumaHeight &&
|
||||
_hostMovieImageFormat == Format.R8Unorm &&
|
||||
_hostMovieImageDstSelect == lumaDstSelect &&
|
||||
_hostMovieChromaImage.Handle != 0 &&
|
||||
_hostMovieChromaImageWidth == chromaWidth &&
|
||||
_hostMovieChromaImageHeight == chromaHeight &&
|
||||
_hostMovieChromaImageDstSelect == chromaDstSelect)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_hostMovieImage.Handle != 0 || _hostMovieChromaImage.Handle != 0)
|
||||
{
|
||||
FlushBatchedGuestCommands();
|
||||
WaitForAllGuestSubmissions();
|
||||
DrainFrameSlots();
|
||||
DestroyHostMovieImage();
|
||||
}
|
||||
|
||||
CreateHostMoviePlaneImage(
|
||||
lumaWidth,
|
||||
lumaHeight,
|
||||
Format.R8Unorm,
|
||||
lumaDstSelect,
|
||||
"luma",
|
||||
out _hostMovieImage,
|
||||
out _hostMovieImageMemory,
|
||||
out _hostMovieImageView);
|
||||
CreateHostMoviePlaneImage(
|
||||
chromaWidth,
|
||||
chromaHeight,
|
||||
Format.R8G8Unorm,
|
||||
chromaDstSelect,
|
||||
"chroma",
|
||||
out _hostMovieChromaImage,
|
||||
out _hostMovieChromaImageMemory,
|
||||
out _hostMovieChromaImageView);
|
||||
_hostMovieImageWidth = lumaWidth;
|
||||
_hostMovieImageHeight = lumaHeight;
|
||||
_hostMovieImageFormat = Format.R8Unorm;
|
||||
_hostMovieImageDstSelect = lumaDstSelect;
|
||||
_hostMovieChromaImageWidth = chromaWidth;
|
||||
_hostMovieChromaImageHeight = chromaHeight;
|
||||
_hostMovieChromaImageDstSelect = chromaDstSelect;
|
||||
_hostMovieImageInitialized = false;
|
||||
_hostMovieChromaImageInitialized = false;
|
||||
_hostMovieLumaUploadedFrameSerial = -1;
|
||||
_hostMovieChromaUploadedFrameSerial = -1;
|
||||
}
|
||||
|
||||
private void CreateHostMoviePlaneImage(
|
||||
uint width,
|
||||
uint height,
|
||||
Format format,
|
||||
uint dstSelect,
|
||||
string planeName,
|
||||
out Image image,
|
||||
out DeviceMemory memory,
|
||||
out ImageView view)
|
||||
{
|
||||
var imageInfo = new ImageCreateInfo
|
||||
{
|
||||
SType = StructureType.ImageCreateInfo,
|
||||
ImageType = ImageType.Type2D,
|
||||
Format = format,
|
||||
Extent = new Extent3D(width, height, 1),
|
||||
MipLevels = 1,
|
||||
ArrayLayers = 1,
|
||||
Samples = SampleCountFlags.Count1Bit,
|
||||
Tiling = ImageTiling.Optimal,
|
||||
Usage = ImageUsageFlags.TransferDstBit | ImageUsageFlags.SampledBit,
|
||||
SharingMode = SharingMode.Exclusive,
|
||||
InitialLayout = ImageLayout.Undefined,
|
||||
};
|
||||
Check(
|
||||
_vk.CreateImage(_device, &imageInfo, null, out image),
|
||||
$"vkCreateImage(host movie {planeName})");
|
||||
_vk.GetImageMemoryRequirements(_device, image, out var requirements);
|
||||
var allocationInfo = new MemoryAllocateInfo
|
||||
{
|
||||
SType = StructureType.MemoryAllocateInfo,
|
||||
AllocationSize = requirements.Size,
|
||||
MemoryTypeIndex = FindMemoryType(
|
||||
requirements.MemoryTypeBits,
|
||||
MemoryPropertyFlags.DeviceLocalBit),
|
||||
};
|
||||
Check(
|
||||
_vk.AllocateMemory(
|
||||
_device,
|
||||
&allocationInfo,
|
||||
null,
|
||||
out memory),
|
||||
$"vkAllocateMemory(host movie {planeName})");
|
||||
Check(
|
||||
_vk.BindImageMemory(_device, image, memory, 0),
|
||||
$"vkBindImageMemory(host movie {planeName})");
|
||||
var viewInfo = new ImageViewCreateInfo
|
||||
{
|
||||
SType = StructureType.ImageViewCreateInfo,
|
||||
Image = image,
|
||||
ViewType = ImageViewType.Type2D,
|
||||
Format = format,
|
||||
Components = ToVkComponentMapping(dstSelect),
|
||||
SubresourceRange = ColorSubresourceRange(),
|
||||
};
|
||||
Check(
|
||||
_vk.CreateImageView(
|
||||
_device,
|
||||
&viewInfo,
|
||||
null,
|
||||
out view),
|
||||
$"vkCreateImageView(host movie {planeName})");
|
||||
SetDebugName(ObjectType.Image, image.Handle, $"SharpEmu Bink2 {planeName} image");
|
||||
SetDebugName(ObjectType.ImageView, view.Handle, $"SharpEmu Bink2 {planeName} view");
|
||||
}
|
||||
|
||||
private void DestroyHostMovieImage()
|
||||
{
|
||||
if (_hostMovieImageView.Handle != 0)
|
||||
{
|
||||
_vk.DestroyImageView(_device, _hostMovieImageView, null);
|
||||
_hostMovieImageView = default;
|
||||
}
|
||||
if (_hostMovieImage.Handle != 0)
|
||||
{
|
||||
_vk.DestroyImage(_device, _hostMovieImage, null);
|
||||
_hostMovieImage = default;
|
||||
}
|
||||
if (_hostMovieImageMemory.Handle != 0)
|
||||
{
|
||||
_vk.FreeMemory(_device, _hostMovieImageMemory, null);
|
||||
_hostMovieImageMemory = default;
|
||||
}
|
||||
if (_hostMovieChromaImageView.Handle != 0)
|
||||
{
|
||||
_vk.DestroyImageView(_device, _hostMovieChromaImageView, null);
|
||||
_hostMovieChromaImageView = default;
|
||||
}
|
||||
if (_hostMovieChromaImage.Handle != 0)
|
||||
{
|
||||
_vk.DestroyImage(_device, _hostMovieChromaImage, null);
|
||||
_hostMovieChromaImage = default;
|
||||
}
|
||||
if (_hostMovieChromaImageMemory.Handle != 0)
|
||||
{
|
||||
_vk.FreeMemory(_device, _hostMovieChromaImageMemory, null);
|
||||
_hostMovieChromaImageMemory = default;
|
||||
}
|
||||
_hostMovieImageWidth = 0;
|
||||
_hostMovieImageHeight = 0;
|
||||
_hostMovieImageFormat = Format.Undefined;
|
||||
_hostMovieChromaImageWidth = 0;
|
||||
_hostMovieChromaImageHeight = 0;
|
||||
_hostMovieImageInitialized = false;
|
||||
_hostMovieChromaImageInitialized = false;
|
||||
_hostMovieLumaUploadedFrameSerial = -1;
|
||||
_hostMovieChromaUploadedFrameSerial = -1;
|
||||
}
|
||||
|
||||
private void RecordUpload(uint imageIndex, int frameSlot)
|
||||
{
|
||||
var oldLayout = _imageInitialized[imageIndex]
|
||||
? ImageLayout.PresentSrcKhr
|
||||
@@ -14708,7 +15374,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
};
|
||||
_vk.CmdCopyBufferToImage(
|
||||
_commandBuffer,
|
||||
_stagingBuffer,
|
||||
_frameUploadBuffers[frameSlot],
|
||||
_swapchainImages[imageIndex],
|
||||
ImageLayout.TransferDstOptimal,
|
||||
1,
|
||||
@@ -15576,7 +16242,26 @@ internal static unsafe class VulkanVideoPresenter
|
||||
|
||||
private void DestroySwapchainResources()
|
||||
{
|
||||
DestroyHostMovieImage();
|
||||
DestroyPresentEncodeImage();
|
||||
for (var slot = 0; slot < _frameUploadBuffers.Length; slot++)
|
||||
{
|
||||
if (_frameUploadMapped.Length > slot && _frameUploadMapped[slot] != 0)
|
||||
{
|
||||
_vk.UnmapMemory(_device, _frameUploadMemory[slot]);
|
||||
}
|
||||
if (_frameUploadBuffers[slot].Handle != 0)
|
||||
{
|
||||
_vk.DestroyBuffer(_device, _frameUploadBuffers[slot], null);
|
||||
}
|
||||
if (_frameUploadMemory[slot].Handle != 0)
|
||||
{
|
||||
_vk.FreeMemory(_device, _frameUploadMemory[slot], null);
|
||||
}
|
||||
}
|
||||
_frameUploadBuffers = [];
|
||||
_frameUploadMemory = [];
|
||||
_frameUploadMapped = [];
|
||||
if (_stagingBuffer.Handle != 0)
|
||||
{
|
||||
_vk.DestroyBuffer(_device, _stagingBuffer, null);
|
||||
|
||||
@@ -80,7 +80,7 @@ public static class Gen5ShaderTranslator
|
||||
public static bool IsScalarConsumed(ulong[] mask, uint register) =>
|
||||
register < 256 && (mask[register >> 6] & (1UL << (int)(register & 63))) != 0;
|
||||
|
||||
private const int MaxInstructions = 4096;
|
||||
private const int MaxInstructions = 16384;
|
||||
private const uint PsUserDataRegister = 0x0C;
|
||||
private const uint VsUserDataRegister = 0x4C;
|
||||
private const uint GsUserDataRegister = 0x8C;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,20 @@ using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
// Gen5ShaderScalarEvaluator.FallbackMemoryReader is a process-global static. This
|
||||
// test swaps it, but the SharpEmu.Libs [ModuleInitializer] (AgcShaderCompilerHooks)
|
||||
// reassigns the same static the first time any Libs type is touched. Under xUnit's
|
||||
// default cross-class parallelism a Libs test running concurrently can fire that
|
||||
// initializer mid-test and clobber the swapped-in reader (observed as all-zero
|
||||
// reads on CI). A DisableParallelization collection runs alone in the non-parallel
|
||||
// phase, so nothing else can mutate the static while this test holds it.
|
||||
[CollectionDefinition(Gen5ScalarEvaluatorStateCollection.Name, DisableParallelization = true)]
|
||||
public sealed class Gen5ScalarEvaluatorStateCollection
|
||||
{
|
||||
public const string Name = "Gen5ScalarEvaluatorState";
|
||||
}
|
||||
|
||||
[Collection(Gen5ScalarEvaluatorStateCollection.Name)]
|
||||
public sealed class Gen5ScalarMemoryFallbackTests
|
||||
{
|
||||
private const ulong ScalarTableAddress = 0x4_4665_4FD0;
|
||||
|
||||
@@ -13,7 +13,8 @@ public sealed class Gen5ShaderDecoderBoundaryTests
|
||||
private const ulong ShaderAddress = 0x1_0000_0000;
|
||||
private const uint Export = 0xF8000000;
|
||||
private const uint Nop = 0xBF800000;
|
||||
private const int MaximumInstructionCount = 4096;
|
||||
private const uint EndPgm = 0xBF810000;
|
||||
private const int MaximumInstructionCount = 16384;
|
||||
|
||||
[Fact]
|
||||
public void MissingAddress_IsRejectedWithoutReadingGuestMemory()
|
||||
@@ -99,6 +100,23 @@ public sealed class Gen5ShaderDecoderBoundaryTests
|
||||
memory.Reads[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProgramMayEndAfterPreviousDecoderLimit()
|
||||
{
|
||||
const int previousDecoderLimit = 4096;
|
||||
var words = new uint[previousDecoderLimit + 1];
|
||||
Array.Fill(words, Nop);
|
||||
words[^1] = EndPgm;
|
||||
var memory = RecordingCpuMemory.FromWords(ShaderAddress, words);
|
||||
|
||||
var decoded = Decode(memory, ShaderAddress, out var program, out var error);
|
||||
|
||||
Assert.True(decoded, error);
|
||||
Assert.Equal(words.Length, program.Instructions.Count);
|
||||
Assert.Equal("SEndpgm", program.Instructions[^1].Opcode);
|
||||
Assert.Equal(words.Length, memory.Reads.Count);
|
||||
}
|
||||
|
||||
private static bool Decode(
|
||||
RecordingCpuMemory memory,
|
||||
ulong address,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.Agc;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
// TryDetile's exact-XOR fast path (PS5 swizzle modes 5/9/24/27) factors the
|
||||
// AddrLib bit-interleave into independent per-column X and per-row Y terms so
|
||||
// the inner loop is one array load and one XOR instead of a 16-bit interleave.
|
||||
// These tests pin that the factored output stays byte-identical to the direct
|
||||
// AddrLib address equation.
|
||||
public sealed class GnmTilingDetileTests
|
||||
{
|
||||
// Independent re-derivation of the 64 KiB RB+ R_X equation (swizzle mode 27,
|
||||
// 2 bytes/element) straight from the address-bit table, so the tiled source
|
||||
// layout does not depend on TryDetile's own internal factoring.
|
||||
private static readonly (uint XMask, uint YMask)[] RbPlus64KRenderX2Bpp =
|
||||
[
|
||||
(0, 0), (1u << 0, 0), (1u << 1, 0), (1u << 2, 0),
|
||||
(0, 1u << 0), (0, 1u << 1), (0, 1u << 2), (1u << 3, 0),
|
||||
(1u << 7, (1u << 4) | (1u << 7)), (1u << 4, 1u << 4), (1u << 6, 1u << 5), (1u << 5, 1u << 6),
|
||||
(0, 1u << 3), (1u << 6, 0), (1u << 7, 1u << 7), (1u << 8, 1u << 6),
|
||||
];
|
||||
|
||||
private static uint ReferenceOffset(uint x, uint y, (uint XMask, uint YMask)[] pattern)
|
||||
{
|
||||
uint offset = 0;
|
||||
for (var bit = 0; bit < pattern.Length; bit++)
|
||||
{
|
||||
var parity = (System.Numerics.BitOperations.PopCount(x & pattern[bit].XMask) +
|
||||
System.Numerics.BitOperations.PopCount(y & pattern[bit].YMask)) & 1;
|
||||
offset |= (uint)parity << bit;
|
||||
}
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(384, 200)]
|
||||
[InlineData(768, 512)]
|
||||
public void TryDetile_ExactXorMode27_MatchesReferenceAddressEquation(
|
||||
int elementsWide,
|
||||
int elementsHigh)
|
||||
{
|
||||
const uint swizzleMode = 27; // 64 KiB RB+ R_X
|
||||
const int bytesPerElement = 2;
|
||||
const int blockBytes = 65536;
|
||||
// SquareBlockDimensions(32768 elements): 15 bits split 8/7, x favored.
|
||||
const int blockWidth = 256;
|
||||
const int blockHeight = 128;
|
||||
var blocksPerRow = (elementsWide + blockWidth - 1) / blockWidth;
|
||||
var blocksPerColumn = (elementsHigh + blockHeight - 1) / blockHeight;
|
||||
|
||||
// Lay out a tiled source where each element stores its own linear index,
|
||||
// placed at the byte address the AddrLib equation dictates. The tiled
|
||||
// buffer is sized by padded whole blocks (block addressing overshoots the
|
||||
// linear extent). A correct detile must recover ascending linear indices.
|
||||
var tiled = new byte[blocksPerRow * blocksPerColumn * blockBytes];
|
||||
for (var y = 0; y < elementsHigh; y++)
|
||||
{
|
||||
for (var x = 0; x < elementsWide; x++)
|
||||
{
|
||||
var blockIndex = (long)(y / blockHeight) * blocksPerRow + (x / blockWidth);
|
||||
// The equation yields a byte offset within the block (bit 0 is
|
||||
// Zero at 2bpp, keeping element writes 2-byte aligned).
|
||||
var sourceByte = (int)(blockIndex * blockBytes +
|
||||
ReferenceOffset((uint)x, (uint)y, RbPlus64KRenderX2Bpp));
|
||||
var linearIndex = (ushort)(y * elementsWide + x);
|
||||
tiled[sourceByte] = (byte)linearIndex;
|
||||
tiled[sourceByte + 1] = (byte)(linearIndex >> 8);
|
||||
}
|
||||
}
|
||||
|
||||
var linear = new byte[elementsWide * elementsHigh * bytesPerElement];
|
||||
var ok = GnmTiling.TryDetile(tiled, linear, swizzleMode, elementsWide, elementsHigh, bytesPerElement);
|
||||
|
||||
Assert.True(ok);
|
||||
for (var i = 0; i < elementsWide * elementsHigh; i++)
|
||||
{
|
||||
var value = (ushort)(linear[i * 2] | (linear[i * 2 + 1] << 8));
|
||||
Assert.Equal((ushort)i, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Ampr;
|
||||
using System.Buffers.Binary;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Ampr;
|
||||
|
||||
public sealed class AmprWriteAddressTests
|
||||
{
|
||||
[Fact]
|
||||
public void MeasureCommandSizeWriteAddress0400_MatchesOnCompletionVariant()
|
||||
{
|
||||
const string nid = "4fgtGfXDrFc";
|
||||
const ulong memoryBase = 0x1_0000_0000;
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x1000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
var manager = CreateManagerWithExport(
|
||||
nid,
|
||||
"sceAmprMeasureCommandSizeWriteAddress_04_00");
|
||||
|
||||
Assert.Equal(OrbisGen2Result.ORBIS_GEN2_OK, manager.Dispatch(nid, context));
|
||||
var measured = context[CpuRegister.Rax];
|
||||
|
||||
Assert.Equal(0, AmprExports.MeasureCommandSizeWriteAddressOnCompletion(context));
|
||||
Assert.Equal(context[CpuRegister.Rax], measured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandBufferWriteAddress0400_WritesValueOnCompletion()
|
||||
{
|
||||
const string nid = "j0+3uJMxYJY";
|
||||
const ulong memoryBase = 0x1_0000_0000;
|
||||
const ulong commandBufferAddress = memoryBase + 0x100;
|
||||
const ulong recordBufferAddress = memoryBase + 0x200;
|
||||
const ulong watcherAddress = memoryBase + 0x800;
|
||||
const ulong watcherValue = 1;
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x1000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
var manager = CreateManagerWithExport(
|
||||
nid,
|
||||
"sceAmprCommandBufferWriteAddress_04_00");
|
||||
|
||||
context[CpuRegister.Rdi] = commandBufferAddress;
|
||||
context[CpuRegister.Rsi] = recordBufferAddress;
|
||||
context[CpuRegister.Rdx] = 0x100;
|
||||
|
||||
Assert.Equal(0, AmprExports.CommandBufferConstructor(context));
|
||||
|
||||
context[CpuRegister.Rdi] = commandBufferAddress;
|
||||
context[CpuRegister.Rsi] = watcherAddress;
|
||||
context[CpuRegister.Rdx] = watcherValue;
|
||||
|
||||
Assert.Equal(OrbisGen2Result.ORBIS_GEN2_OK, manager.Dispatch(nid, context));
|
||||
|
||||
Span<byte> watcher = stackalloc byte[sizeof(ulong)];
|
||||
Assert.True(memory.TryRead(watcherAddress, watcher));
|
||||
Assert.Equal(0UL, BinaryPrimitives.ReadUInt64LittleEndian(watcher));
|
||||
|
||||
Assert.Equal(0, AmprExports.CompleteCommandBuffer(context, commandBufferAddress));
|
||||
|
||||
Assert.True(memory.TryRead(watcherAddress, watcher));
|
||||
Assert.Equal(watcherValue, BinaryPrimitives.ReadUInt64LittleEndian(watcher));
|
||||
}
|
||||
|
||||
private static ModuleManager CreateManagerWithExport(string nid, string exportName)
|
||||
{
|
||||
var manager = new ModuleManager();
|
||||
manager.RegisterExports(
|
||||
SharpEmu.Generated.SysAbiExportRegistry.CreateExports(Generation.Gen5));
|
||||
|
||||
Assert.True(manager.TryGetExport(nid, out var export), $"NID {nid} did not register.");
|
||||
Assert.Equal(exportName, export.Name);
|
||||
Assert.Equal("libSceAmpr", export.LibraryName);
|
||||
Assert.Equal(Generation.Gen5, export.Target);
|
||||
return manager;
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,25 @@ public sealed class AprStreamingContractTests
|
||||
const ulong destinationAddress = memoryBase + 0x2000;
|
||||
const ulong stackAddress = memoryBase + 0x3000;
|
||||
byte[] fileContents = [10, 11, 12, 13, 14, 15, 16, 17];
|
||||
var hostPath = Path.GetTempFileName();
|
||||
// The kernel FS resolver default-denies raw absolute host paths, so the
|
||||
// guest addresses the file through a registered mount instead of handing
|
||||
// in a bare host temp path.
|
||||
var mountRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"sharpemu-apr-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(mountRoot);
|
||||
var mountPoint = $"/sharpemu_apr_mnt_{Guid.NewGuid():N}";
|
||||
const string fileName = "asset.bin";
|
||||
var hostPath = Path.Combine(mountRoot, fileName);
|
||||
var guestPath = $"{mountPoint}/{fileName}";
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(hostPath, fileContents);
|
||||
KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, mountRoot);
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x4000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
memory.WriteCString(pathAddress, hostPath);
|
||||
memory.WriteCString(pathAddress, guestPath);
|
||||
WriteUInt64(memory, pathListAddress, pathAddress);
|
||||
|
||||
context[CpuRegister.Rdi] = pathListAddress;
|
||||
@@ -86,7 +97,11 @@ public sealed class AprStreamingContractTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(hostPath);
|
||||
KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint);
|
||||
if (Directory.Exists(mountRoot))
|
||||
{
|
||||
Directory.Delete(mountRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,19 +171,29 @@ public sealed class AprStreamingContractTests
|
||||
const ulong sizesAddress = memoryBase + 0x880;
|
||||
const ulong errorIndexAddress = memoryBase + 0x8F0;
|
||||
byte[] fileContents = [1, 2, 3, 4, 5];
|
||||
var hostPath = Path.GetTempFileName();
|
||||
var missingHostPath = Path.Combine(
|
||||
// Entries 0 and 2 must resolve to a real file; the kernel FS resolver
|
||||
// default-denies raw absolute host paths, so the present file is reached
|
||||
// through a registered mount. The missing entry stays an unresolvable
|
||||
// path so the batch fails mid-way at index 1.
|
||||
var mountRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"sharpemu-apr-missing-{Guid.NewGuid():N}.bin");
|
||||
$"sharpemu-apr-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(mountRoot);
|
||||
var mountPoint = $"/sharpemu_apr_mnt_{Guid.NewGuid():N}";
|
||||
const string fileName = "asset.bin";
|
||||
var hostPath = Path.Combine(mountRoot, fileName);
|
||||
var guestPath = $"{mountPoint}/{fileName}";
|
||||
var missingGuestPath = $"{mountPoint}/missing-{Guid.NewGuid():N}.bin";
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(hostPath, fileContents);
|
||||
KernelMemoryCompatExports.RegisterGuestPathMount(mountPoint, mountRoot);
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x4000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
memory.WriteCString(memoryBase + 0x200, hostPath);
|
||||
memory.WriteCString(memoryBase + 0x400, missingHostPath);
|
||||
memory.WriteCString(memoryBase + 0x600, hostPath);
|
||||
memory.WriteCString(memoryBase + 0x200, guestPath);
|
||||
memory.WriteCString(memoryBase + 0x400, missingGuestPath);
|
||||
memory.WriteCString(memoryBase + 0x600, guestPath);
|
||||
WriteUInt64(memory, pathListAddress, memoryBase + 0x200);
|
||||
WriteUInt64(memory, pathListAddress + 8, memoryBase + 0x400);
|
||||
WriteUInt64(memory, pathListAddress + 16, memoryBase + 0x600);
|
||||
@@ -192,7 +217,11 @@ public sealed class AprStreamingContractTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(hostPath);
|
||||
KernelMemoryCompatExports.UnregisterGuestPathMount(mountPoint);
|
||||
if (Directory.Exists(mountRoot))
|
||||
{
|
||||
Directory.Delete(mountRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,19 @@ public sealed class AjmExportsTests : IDisposable
|
||||
Assert.Equal(InvalidContext, RegisterCodec(contextId + 1, 1));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(23u)]
|
||||
[InlineData(24u)]
|
||||
public void Gen5CodecTypesCanRegisterAndCreateInstances(uint codecType)
|
||||
{
|
||||
var contextId = Initialize();
|
||||
|
||||
Assert.Equal(0, RegisterCodec(contextId, codecType));
|
||||
Assert.Equal(
|
||||
0,
|
||||
CreateInstance(contextId, codecType, 0x401, InstanceAddress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InstanceDestroy_RejectsUnknownContextAndSlot()
|
||||
{
|
||||
|
||||
@@ -99,6 +99,25 @@ public sealed class AvPlayerPathTests : IDisposable
|
||||
AvPlayerExports.GetFfprobePath(ffmpeg, isWindows));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, "ffmpeg")]
|
||||
[InlineData(true, "ffmpeg.exe")]
|
||||
public void MediaToolLookupFindsPackagedBinary(bool isWindows, string executable)
|
||||
{
|
||||
var publishDirectory = Path.Combine(_tempRoot, "publish");
|
||||
Directory.CreateDirectory(Path.Combine(publishDirectory, "ffmpeg"));
|
||||
var ffmpeg = Path.Combine(publishDirectory, "ffmpeg", executable);
|
||||
File.WriteAllBytes(ffmpeg, []);
|
||||
|
||||
Assert.Equal(
|
||||
ffmpeg,
|
||||
AvPlayerExports.FindFfmpeg(
|
||||
configured: null,
|
||||
searchPath: null,
|
||||
isWindows,
|
||||
publishDirectory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RelativeFileUriCannotEscapeApp0()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.Libs.Bink;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Bink;
|
||||
|
||||
public sealed class Bink2MovieBridgeTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDirectory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"sharpemu-bink-{Guid.NewGuid():N}");
|
||||
|
||||
public Bink2MovieBridgeTests()
|
||||
{
|
||||
Directory.CreateDirectory(_tempDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeaderPreservesFractionalFrameRate()
|
||||
{
|
||||
var path = WriteHeader("KB2j"u8, 3840, 2160, 30_000, 1_001);
|
||||
|
||||
Assert.True(Bink2MovieBridge.TryReadBinkInfo(path, out var info));
|
||||
Assert.Equal(3840u, info.Width);
|
||||
Assert.Equal(2160u, info.Height);
|
||||
Assert.Equal(30_000u, info.FramesPerSecondNumerator);
|
||||
Assert.Equal(1_001u, info.FramesPerSecondDenominator);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("KB2g")]
|
||||
[InlineData("KB2i")]
|
||||
[InlineData("KB2j")]
|
||||
public void HeaderAcceptsBink2Revisions(string signature)
|
||||
{
|
||||
var path = WriteHeader(
|
||||
System.Text.Encoding.ASCII.GetBytes(signature),
|
||||
1920,
|
||||
1080,
|
||||
60,
|
||||
1);
|
||||
|
||||
Assert.True(Bink2MovieBridge.TryReadBinkInfo(path, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeaderRejectsMissingFrameRateDenominator()
|
||||
{
|
||||
var path = WriteHeader("KB2j"u8, 1920, 1080, 60, 0);
|
||||
|
||||
Assert.False(Bink2MovieBridge.TryReadBinkInfo(path, out _));
|
||||
}
|
||||
|
||||
private string WriteHeader(
|
||||
ReadOnlySpan<byte> signature,
|
||||
uint width,
|
||||
uint height,
|
||||
uint fpsNumerator,
|
||||
uint fpsDenominator)
|
||||
{
|
||||
var header = new byte[36];
|
||||
signature.CopyTo(header);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x14), width);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x18), height);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x1C), fpsNumerator);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x20), fpsDenominator);
|
||||
var path = Path.Combine(_tempDirectory, $"{Guid.NewGuid():N}.bk2");
|
||||
File.WriteAllBytes(path, header);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Directory.Delete(_tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.Bink;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Bink;
|
||||
|
||||
public sealed class BinkFramePlaybackTests
|
||||
{
|
||||
[Fact]
|
||||
public void FramesAdvanceAccordingToMovieClock()
|
||||
{
|
||||
using var playback = new BinkFramePlayback(new SequenceDecoder(1, 2, 3));
|
||||
|
||||
Assert.Equal(1, WaitForAdvancedFrame(playback)[0]);
|
||||
Assert.True(playback.TryGetFrame(true, out var heldFrame, out var advanced));
|
||||
Assert.False(advanced);
|
||||
Assert.Equal(1, heldFrame[0]);
|
||||
|
||||
Assert.Equal(2, WaitForAdvancedFrame(playback)[0]);
|
||||
Assert.Equal(3, WaitForAdvancedFrame(playback)[0]);
|
||||
}
|
||||
|
||||
private static byte[] WaitForAdvancedFrame(BinkFramePlayback playback)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (playback.TryGetFrame(true, out var frame, out var advanced) && advanced)
|
||||
{
|
||||
return frame;
|
||||
}
|
||||
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
|
||||
throw new TimeoutException("The decoder did not produce a frame.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstFrameWaitsUntilPresentationStarts()
|
||||
{
|
||||
using var playback = new BinkFramePlayback(new SequenceDecoder(1, 2));
|
||||
|
||||
var first = WaitForFrame(playback, advanceClock: false);
|
||||
Assert.Equal(1, first[0]);
|
||||
Thread.Sleep(100);
|
||||
|
||||
Assert.True(playback.TryGetFrame(false, out var held, out var advanced));
|
||||
Assert.False(advanced);
|
||||
Assert.Equal(1, held[0]);
|
||||
|
||||
Assert.True(playback.TryGetFrame(true, out held, out advanced));
|
||||
Assert.False(advanced);
|
||||
Assert.Equal(1, held[0]);
|
||||
Assert.Equal(2, WaitForAdvancedFrame(playback)[0]);
|
||||
}
|
||||
|
||||
private static byte[] WaitForFrame(
|
||||
BinkFramePlayback playback,
|
||||
bool advanceClock)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (playback.TryGetFrame(advanceClock, out var frame, out _))
|
||||
{
|
||||
return frame;
|
||||
}
|
||||
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
|
||||
throw new TimeoutException("The decoder did not produce a frame.");
|
||||
}
|
||||
|
||||
private sealed class SequenceDecoder(params byte[] values) : IBinkFrameDecoder
|
||||
{
|
||||
private int _index;
|
||||
|
||||
public uint Width => 1;
|
||||
|
||||
public uint Height => 1;
|
||||
|
||||
public uint FramesPerSecondNumerator => 20;
|
||||
|
||||
public uint FramesPerSecondDenominator => 1;
|
||||
|
||||
public bool TryDecodeNextFrame(Span<byte> destination)
|
||||
{
|
||||
if (_index >= values.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
destination.Fill(values[_index++]);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using SharpEmu.Core.Cpu.Emulation;
|
||||
using SharpEmu.Core.Cpu.Native;
|
||||
using SharpEmu.HLE;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Cpu;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for the SSE4a EXTRQ/INSERTQ fault recovery through the POSIX signal bridge on
|
||||
/// Linux. Each test fabricates the exact frame the kernel hands the SIGILL handler - gregs
|
||||
/// whose RIP points at a real EXTRQ/INSERTQ encoding in probe-visible host memory, plus an
|
||||
/// FXSAVE image carrying the XMM registers - and drives the production entry point
|
||||
/// (TryHandlePosixFault) over it. The bridge must capture the XMM state into the CONTEXT
|
||||
/// scratch buffer, the recovery must decode and emulate the instruction, and the write-back
|
||||
/// must land the result in the FXSAVE image and advance RIP, because that is precisely what
|
||||
/// sigreturn restores on a live fault.
|
||||
/// </summary>
|
||||
public sealed unsafe class Sse4aPosixSignalRecoveryTests
|
||||
{
|
||||
private const int PosixSigIll = 4;
|
||||
private const int LinuxUcontextGregsOffset = 40;
|
||||
private const int LinuxGregsRipOffset = 16 * 8;
|
||||
private const int LinuxGregsFpstateOffset = 184;
|
||||
private const int FxsaveXmm0Offset = 160;
|
||||
private const int FxsaveXmm1Offset = 176;
|
||||
|
||||
private static readonly MethodInfo TryHandlePosixFault = typeof(DirectExecutionBackend).GetMethod(
|
||||
"TryHandlePosixFault",
|
||||
BindingFlags.Static | BindingFlags.NonPublic)!;
|
||||
|
||||
private static readonly FieldInfo PosixSignalBackend = typeof(DirectExecutionBackend).GetField(
|
||||
"_posixSignalBackend",
|
||||
BindingFlags.Static | BindingFlags.NonPublic)!;
|
||||
|
||||
private static readonly FieldInfo EmulatedCounter = typeof(DirectExecutionBackend).GetField(
|
||||
"_sse4aInstructionsEmulated",
|
||||
BindingFlags.Static | BindingFlags.NonPublic)!;
|
||||
|
||||
private static readonly FieldInfo XmmBridgedFlag = typeof(DirectExecutionBackend).GetField(
|
||||
"_posixXmmContextBridged",
|
||||
BindingFlags.Static | BindingFlags.NonPublic)!;
|
||||
|
||||
private static readonly MethodInfo TryRecoverAmdCompat = typeof(DirectExecutionBackend).GetMethod(
|
||||
"TryRecoverAmdCompatInstruction",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
|
||||
[Fact]
|
||||
public void ExtrqSigillRoundTripsXmmThroughTheBridge()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux() ||
|
||||
RuntimeInformation.ProcessArchitecture != Architecture.X64)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// extrq xmm0, 0x10, 0x08
|
||||
var code = AllocateProbeVisibleCode([0x66, 0x0F, 0x78, 0xC0, 0x10, 0x08]);
|
||||
try
|
||||
{
|
||||
const ulong value = 0x1234_5678_9ABC_DEF0UL;
|
||||
var frame = new FakeSignalFrame((ulong)code);
|
||||
frame.SetXmmLow(FxsaveXmm0Offset, value);
|
||||
var emulatedBefore = (long)EmulatedCounter.GetValue(null)!;
|
||||
|
||||
Assert.True(frame.Dispatch());
|
||||
|
||||
Assert.Equal(
|
||||
Sse4aBitFieldEmulator.ExtractBitField(value, length: 0x10, index: 0x08),
|
||||
frame.XmmLow(FxsaveXmm0Offset));
|
||||
Assert.Equal(0UL, frame.XmmHigh(FxsaveXmm0Offset));
|
||||
Assert.Equal((ulong)code + 6, frame.Rip);
|
||||
Assert.True((long)EmulatedCounter.GetValue(null)! > emulatedBefore);
|
||||
}
|
||||
finally
|
||||
{
|
||||
FreeProbeVisibleCode(code);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InsertqSigillReadsSourceXmmThroughTheBridge()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux() ||
|
||||
RuntimeInformation.ProcessArchitecture != Architecture.X64)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// insertq xmm0, xmm1, 0x10, 0x08
|
||||
var code = AllocateProbeVisibleCode([0xF2, 0x0F, 0x78, 0xC1, 0x10, 0x08]);
|
||||
try
|
||||
{
|
||||
const ulong destination = 0x1111_2222_3333_4444UL;
|
||||
const ulong source = 0xAAAA_BBBB_CCCC_DDDDUL;
|
||||
var frame = new FakeSignalFrame((ulong)code);
|
||||
frame.SetXmmLow(FxsaveXmm0Offset, destination);
|
||||
frame.SetXmmLow(FxsaveXmm1Offset, source);
|
||||
|
||||
Assert.True(frame.Dispatch());
|
||||
|
||||
Assert.Equal(
|
||||
Sse4aBitFieldEmulator.InsertBitField(destination, source, length: 0x10, index: 0x08),
|
||||
frame.XmmLow(FxsaveXmm0Offset));
|
||||
Assert.Equal((ulong)code + 6, frame.Rip);
|
||||
}
|
||||
finally
|
||||
{
|
||||
FreeProbeVisibleCode(code);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecoveryDeclinesWhenNoXmmStateWasBridged()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux() ||
|
||||
RuntimeInformation.ProcessArchitecture != Architecture.X64)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// extrq xmm0, 0x10, 0x08 - valid and recoverable, but without bridged XMM state
|
||||
// (fpstate missing from the frame) the recovery must decline rather than emulate
|
||||
// over the zeroed scratch bytes. Drive the recovery entry directly: earlier tests
|
||||
// on this thread leave the thread-static bridge flag set, so clear it the way a
|
||||
// fpstate-less capture would.
|
||||
var code = AllocateProbeVisibleCode([0x66, 0x0F, 0x78, 0xC0, 0x10, 0x08]);
|
||||
try
|
||||
{
|
||||
XmmBridgedFlag.SetValue(null, false);
|
||||
var backend = RuntimeHelpers.GetUninitializedObject(typeof(DirectExecutionBackend));
|
||||
var contextRecord = stackalloc byte[0x4D0];
|
||||
|
||||
var recovered = (bool)TryRecoverAmdCompat.Invoke(
|
||||
backend,
|
||||
[Pointer.Box(contextRecord, typeof(void*)), (ulong)code])!;
|
||||
|
||||
Assert.False(recovered);
|
||||
}
|
||||
finally
|
||||
{
|
||||
FreeProbeVisibleCode(code);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Linux x86-64 signal frame as TryHandlePosixFault consumes it: a ucontext whose
|
||||
/// mcontext gregs sit at +40 (kernel sigcontext layout) with the fpstate pointer at
|
||||
/// gregs+184 aiming at a 512-byte FXSAVE image.
|
||||
/// </summary>
|
||||
private sealed class FakeSignalFrame
|
||||
{
|
||||
private readonly byte[] _ucontext = new byte[512];
|
||||
private readonly byte[] _fpstate = new byte[512];
|
||||
private readonly bool _wireFpstate;
|
||||
|
||||
public FakeSignalFrame(ulong rip, bool wireFpstate = true)
|
||||
{
|
||||
_wireFpstate = wireFpstate;
|
||||
fixed (byte* ucontext = _ucontext)
|
||||
{
|
||||
*(ulong*)(ucontext + LinuxUcontextGregsOffset + LinuxGregsRipOffset) = rip;
|
||||
}
|
||||
}
|
||||
|
||||
public ulong Rip
|
||||
{
|
||||
get
|
||||
{
|
||||
fixed (byte* ucontext = _ucontext)
|
||||
{
|
||||
return *(ulong*)(ucontext + LinuxUcontextGregsOffset + LinuxGregsRipOffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetXmmLow(int fxsaveOffset, ulong value)
|
||||
{
|
||||
fixed (byte* fpstate = _fpstate)
|
||||
{
|
||||
*(ulong*)(fpstate + fxsaveOffset) = value;
|
||||
}
|
||||
}
|
||||
|
||||
public ulong XmmLow(int fxsaveOffset)
|
||||
{
|
||||
fixed (byte* fpstate = _fpstate)
|
||||
{
|
||||
return *(ulong*)(fpstate + fxsaveOffset);
|
||||
}
|
||||
}
|
||||
|
||||
public ulong XmmHigh(int fxsaveOffset)
|
||||
{
|
||||
fixed (byte* fpstate = _fpstate)
|
||||
{
|
||||
return *(ulong*)(fpstate + fxsaveOffset + 8);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Dispatch()
|
||||
{
|
||||
EnsureBridgeBackend();
|
||||
fixed (byte* ucontext = _ucontext)
|
||||
fixed (byte* fpstate = _fpstate)
|
||||
{
|
||||
if (_wireFpstate)
|
||||
{
|
||||
*(byte**)(ucontext + LinuxUcontextGregsOffset + LinuxGregsFpstateOffset) = fpstate;
|
||||
}
|
||||
|
||||
return (bool)TryHandlePosixFault.Invoke(
|
||||
null,
|
||||
[PosixSigIll, (nint)0, (nint)ucontext])!;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// TryHandlePosixFault only runs the recovery chain when a backend instance is
|
||||
/// registered. The tests do not need any of the constructor's state (and must not run
|
||||
/// it: it installs process-wide signal handlers), so register an uninitialized
|
||||
/// instance - the SIGILL recovery path only touches static state.
|
||||
/// </summary>
|
||||
private static void EnsureBridgeBackend()
|
||||
{
|
||||
if (PosixSignalBackend.GetValue(null) == null)
|
||||
{
|
||||
PosixSignalBackend.SetValue(
|
||||
null,
|
||||
RuntimeHelpers.GetUninitializedObject(typeof(DirectExecutionBackend)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The instruction bytes must live in memory the fault-time page probe
|
||||
/// (TryReadHostBytes -> VirtualQuery) can see; on POSIX that is HostMemory's shadow
|
||||
/// region table, the same allocator guest code pages come from. A raw libc mmap or a
|
||||
/// pinned managed array would be invisible and the recovery would decline before
|
||||
/// decoding.
|
||||
/// </summary>
|
||||
private static nint AllocateProbeVisibleCode(ReadOnlySpan<byte> instructions)
|
||||
{
|
||||
var size = checked((nuint)Environment.SystemPageSize);
|
||||
var mapping = (nint)HostMemory.Alloc(
|
||||
null,
|
||||
size,
|
||||
HostMemory.MEM_COMMIT | HostMemory.MEM_RESERVE,
|
||||
HostMemory.PAGE_READWRITE);
|
||||
Assert.NotEqual((nint)0, mapping);
|
||||
|
||||
instructions.CopyTo(new Span<byte>((void*)mapping, checked((int)size)));
|
||||
return mapping;
|
||||
}
|
||||
|
||||
private static void FreeProbeVisibleCode(nint mapping)
|
||||
{
|
||||
Assert.True(HostMemory.Free((void*)mapping, 0, HostMemory.MEM_RELEASE));
|
||||
}
|
||||
}
|
||||
@@ -41,4 +41,32 @@ public sealed class FontExportsTests
|
||||
Assert.Equal(0.0f, BinaryPrimitives.ReadSingleLittleEndian(layout[8..]));
|
||||
Assert.Equal(Sentinel, BinaryPrimitives.ReadUInt32LittleEndian(layout[12..]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVerticalLayout_WritesExactlyThreeFloats()
|
||||
{
|
||||
const uint Sentinel = 0xDEADBEEF;
|
||||
Span<byte> sentinelBytes = stackalloc byte[sizeof(uint)];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(sentinelBytes, Sentinel);
|
||||
Assert.True(_ctx.Memory.TryWrite(LayoutAddress + 12, sentinelBytes));
|
||||
|
||||
_ctx[CpuRegister.Rsi] = LayoutAddress;
|
||||
Assert.Equal(0, FontExports.GetVerticalLayout(_ctx));
|
||||
|
||||
Span<byte> layout = stackalloc byte[16];
|
||||
Assert.True(_ctx.Memory.TryRead(LayoutAddress, layout));
|
||||
Assert.Equal(8.0f, BinaryPrimitives.ReadSingleLittleEndian(layout));
|
||||
Assert.Equal(16.0f, BinaryPrimitives.ReadSingleLittleEndian(layout[4..]));
|
||||
Assert.Equal(0.0f, BinaryPrimitives.ReadSingleLittleEndian(layout[8..]));
|
||||
Assert.Equal(Sentinel, BinaryPrimitives.ReadUInt32LittleEndian(layout[12..]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetVerticalLayout_NullBuffer_ReturnsInvalidArgument()
|
||||
{
|
||||
_ctx[CpuRegister.Rsi] = 0;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
|
||||
FontExports.GetVerticalLayout(_ctx));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +301,38 @@ public sealed class KernelMemoryCompatExportsTests
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VirtualQuery_PreservesReservationPastFixedCommitAtSameBase()
|
||||
{
|
||||
const ulong memoryBase = 0x12_0000_0000;
|
||||
const ulong reservedLength = 0x1_0000;
|
||||
const ulong committedLength = 0x2000;
|
||||
const ulong inOutAddress = memoryBase + 0x1_7000;
|
||||
const ulong infoAddress = memoryBase + 0x1_8000;
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x2_0000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
KernelMemoryCompatExports.RegisterReservedVirtualRange(memoryBase, reservedLength);
|
||||
Assert.True(memory.TryWrite(inOutAddress, BitConverter.GetBytes(memoryBase)));
|
||||
context[CpuRegister.Rdi] = inOutAddress;
|
||||
context[CpuRegister.Rsi] = committedLength;
|
||||
context[CpuRegister.Rdx] = 0x03;
|
||||
context[CpuRegister.Rcx] = 0x10; // fixed mapping
|
||||
|
||||
Assert.Equal(0, KernelMemoryCompatExports.KernelMapNamedFlexibleMemory(context));
|
||||
|
||||
context[CpuRegister.Rdi] = memoryBase + 0x8000;
|
||||
context[CpuRegister.Rsi] = 0;
|
||||
context[CpuRegister.Rdx] = infoAddress;
|
||||
context[CpuRegister.Rcx] = 0x48;
|
||||
|
||||
Assert.Equal(0, KernelMemoryCompatExports.KernelVirtualQuery(context));
|
||||
Assert.True(context.TryReadUInt64(infoAddress, out var regionStart));
|
||||
Assert.True(context.TryReadUInt64(infoAddress + 8, out var regionEnd));
|
||||
Assert.Equal(memoryBase + committedLength, regionStart);
|
||||
Assert.Equal(memoryBase + reservedLength, regionEnd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mprotect_ZeroAddressReturnsInvalidArgument()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.Kernel;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Kernel;
|
||||
|
||||
// The kernel path resolver is the guest->host sandbox boundary: every real
|
||||
// file syscall (open/stat/unlink/mkdir/rmdir/rename/chmod) maps a guest path
|
||||
// to a host path through KernelMemoryCompatExports.ResolveGuestPath and then
|
||||
// hands the result straight to the host filesystem. These tests pin the
|
||||
// containment guarantees: an unmapped or absolute guest path must resolve to
|
||||
// nothing (default-deny), and a mount-relative path must never escape its root.
|
||||
[Collection(KernelMemoryCompatStateCollection.Name)]
|
||||
public sealed class KernelSandboxEscapeTests : IDisposable
|
||||
{
|
||||
private readonly string? _originalApp0;
|
||||
private readonly string _tempRoot;
|
||||
private readonly string _app0Root;
|
||||
|
||||
public KernelSandboxEscapeTests()
|
||||
{
|
||||
_originalApp0 = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
|
||||
_tempRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"sharpemu-sandbox-{Guid.NewGuid():N}");
|
||||
_app0Root = Path.Combine(_tempRoot, "app0");
|
||||
Directory.CreateDirectory(_app0Root);
|
||||
Environment.SetEnvironmentVariable("SHARPEMU_APP0_DIR", _app0Root);
|
||||
|
||||
// ResolveApp0Root caches _cachedApp0Root once, so a per-test env var is
|
||||
// ignored after an earlier test populates it. Registering an explicit
|
||||
// mount routes /app0 through the updatable mount table instead, which is
|
||||
// the pattern KernelPathCaseSensitivityTests uses for the same reason.
|
||||
KernelMemoryCompatExports.RegisterGuestPathMount("/app0", _app0Root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
KernelMemoryCompatExports.UnregisterGuestPathMount("/app0");
|
||||
Environment.SetEnvironmentVariable("SHARPEMU_APP0_DIR", _originalApp0);
|
||||
if (Directory.Exists(_tempRoot))
|
||||
{
|
||||
Directory.Delete(_tempRoot, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
// Finding #1: an absolute guest path that matches no mount prefix used to be
|
||||
// returned verbatim, letting the guest open arbitrary host files. These forms
|
||||
// are absolute (or a UNC/backslash root) on every host, so they are denied
|
||||
// everywhere.
|
||||
[Theory]
|
||||
[InlineData("/etc/passwd")]
|
||||
[InlineData("/etc/shadow")]
|
||||
[InlineData("/root/.ssh/id_rsa")]
|
||||
[InlineData("\\\\server\\share\\secret")]
|
||||
[InlineData("/proc/self/mem")]
|
||||
public void ResolveGuestPath_UnmappedAbsolutePathIsDenied(string guestPath)
|
||||
{
|
||||
Assert.Equal(string.Empty, KernelMemoryCompatExports.ResolveGuestPath(guestPath));
|
||||
}
|
||||
|
||||
// A Windows drive-qualified path (e.g. "C:\Windows\...") is only absolute on
|
||||
// Windows. On Unix "C:" is an ordinary relative filename, so the resolver
|
||||
// legitimately contains it under app0 rather than denying it; this escape is
|
||||
// therefore Windows-specific.
|
||||
[Fact]
|
||||
public void ResolveGuestPath_UnmappedWindowsDrivePathIsDenied()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.Equal(
|
||||
string.Empty,
|
||||
KernelMemoryCompatExports.ResolveGuestPath("C:\\Windows\\System32\\drivers\\etc\\hosts"));
|
||||
}
|
||||
|
||||
// A recognized mount prefix must still resolve to a path under its root, so
|
||||
// the default-deny does not regress legitimate access.
|
||||
[Fact]
|
||||
public void ResolveGuestPath_App0PathResolvesUnderApp0Root()
|
||||
{
|
||||
var resolved = KernelMemoryCompatExports.ResolveGuestPath("/app0/game.bin");
|
||||
|
||||
Assert.False(string.IsNullOrEmpty(resolved));
|
||||
var rootWithSep = Path.TrimEndingDirectorySeparator(_app0Root) + Path.DirectorySeparatorChar;
|
||||
Assert.StartsWith(rootWithSep, Path.GetFullPath(resolved));
|
||||
}
|
||||
|
||||
// Drive-letter injection: NormalizeMountRelativePath clamps "." / ".." but
|
||||
// splits only on separators, so a "C:" token survives as a segment.
|
||||
// Path.Combine then discards the mount root because the tail is drive-rooted,
|
||||
// yielding a raw host path. A resolved path outside the mount root must be
|
||||
// denied. On non-Windows hosts "C:" is an ordinary directory name and stays
|
||||
// contained, so this specifically pins the Windows escape.
|
||||
[Theory]
|
||||
[InlineData("app0/C:/Windows/Temp/evil.dll")]
|
||||
[InlineData("/app0/C:/Windows/Temp/evil.dll")]
|
||||
[InlineData("download0/C:/Windows/Temp/evil.dll")]
|
||||
[InlineData("/temp0/C:/Windows/Temp/evil.dll")]
|
||||
public void ResolveGuestPath_DriveLetterInjectionCannotEscapeMount(string guestPath)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var resolved = KernelMemoryCompatExports.ResolveGuestPath(guestPath);
|
||||
|
||||
// Either denied outright, or (defensively) still under a SharpEmu mount
|
||||
// root — never a bare "C:\Windows\..." host path.
|
||||
if (!string.IsNullOrEmpty(resolved))
|
||||
{
|
||||
Assert.DoesNotContain("Windows", Path.GetFullPath(resolved), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
// A "C:"-style token under app0 must resolve inside the app0 root, not to the
|
||||
// host drive root.
|
||||
[Fact]
|
||||
public void ResolveGuestPath_DriveTokenStaysUnderApp0OnNonWindows()
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var resolved = KernelMemoryCompatExports.ResolveGuestPath("/app0/C:/data.bin");
|
||||
|
||||
Assert.False(string.IsNullOrEmpty(resolved));
|
||||
var rootWithSep = Path.TrimEndingDirectorySeparator(_app0Root) + Path.DirectorySeparatorChar;
|
||||
Assert.StartsWith(rootWithSep, Path.GetFullPath(resolved));
|
||||
}
|
||||
|
||||
// Finding #3: lexical containment does not follow symlinks/junctions. A dump
|
||||
// that plants a reparse point inside app0 pointing outside it must not let a
|
||||
// guest path through it escape onto the host filesystem. Creating a symlink
|
||||
// can require privilege (Windows without Developer Mode); skip if it fails.
|
||||
[Fact]
|
||||
public void ResolveGuestPath_ReparsePointInsideMountIsDenied()
|
||||
{
|
||||
var outsideDir = Path.Combine(_tempRoot, "outside");
|
||||
Directory.CreateDirectory(outsideDir);
|
||||
File.WriteAllBytes(Path.Combine(outsideDir, "secret.bin"), [1, 2, 3]);
|
||||
|
||||
var linkPath = Path.Combine(_app0Root, "link");
|
||||
try
|
||||
{
|
||||
Directory.CreateSymbolicLink(linkPath, outsideDir);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Unprivileged host cannot create the link; nothing to assert.
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanity: the link genuinely redirects outside the mount, so a resolver
|
||||
// that followed it would reach the planted secret.
|
||||
Assert.True(File.Exists(Path.Combine(linkPath, "secret.bin")));
|
||||
|
||||
var resolved = KernelMemoryCompatExports.ResolveGuestPath("/app0/link/secret.bin");
|
||||
|
||||
Assert.Equal(string.Empty, resolved);
|
||||
}
|
||||
|
||||
// The reparse defense must not break a legitimate real (non-link) file that
|
||||
// happens to sit deep under the mount root.
|
||||
[Fact]
|
||||
public void ResolveGuestPath_RealNestedFileUnderMountResolves()
|
||||
{
|
||||
var nested = Path.Combine(_app0Root, "a", "b");
|
||||
Directory.CreateDirectory(nested);
|
||||
File.WriteAllBytes(Path.Combine(nested, "c.bin"), [9]);
|
||||
|
||||
var resolved = KernelMemoryCompatExports.ResolveGuestPath("/app0/a/b/c.bin");
|
||||
|
||||
Assert.False(string.IsNullOrEmpty(resolved));
|
||||
var rootWithSep = Path.TrimEndingDirectorySeparator(_app0Root) + Path.DirectorySeparatorChar;
|
||||
Assert.StartsWith(rootWithSep, Path.GetFullPath(resolved));
|
||||
}
|
||||
|
||||
// The containment guard resolves paths via Path.GetFullPath / File.GetAttributes,
|
||||
// which throw on a crafted over-long or invalid path. Because ResolveGuestPath
|
||||
// runs outside the callers' try blocks, an uncaught throw would crash the
|
||||
// syscall on untrusted input. The resolver must instead fail closed: return an
|
||||
// empty host path (which callers map to NOT_FOUND), never throw.
|
||||
[Theory]
|
||||
[InlineData("/app0/")] // filled with a long segment below
|
||||
[InlineData("/app0/bad\0name")]
|
||||
public void ResolveGuestPath_MalformedPathUnderMountFailsClosed(string prefix)
|
||||
{
|
||||
var guestPath = prefix.EndsWith('/')
|
||||
? prefix + new string('a', 40_000)
|
||||
: prefix;
|
||||
|
||||
// Fail closed: the resolver must return an empty host path (never throw,
|
||||
// never a non-empty resolution). A 40k-char segment exceeds NAME_MAX on
|
||||
// Linux and a NUL trips GetFullPath on Windows; both must deny.
|
||||
var resolved = KernelMemoryCompatExports.ResolveGuestPath(guestPath);
|
||||
|
||||
Assert.Equal(string.Empty, resolved);
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,27 @@ public sealed class GuestMemoryAllocatorTests
|
||||
Assert.Equal([alignedAddress + 0x1000], host.FreedAddresses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBackFixedRangeRollsBackEarlierGapsWhenLaterGapCannotBeBacked()
|
||||
{
|
||||
// Layout: committed | free | committed | free
|
||||
// First free gap allocates successfully, second fails.
|
||||
// The first allocation must be freed — nothing should leak.
|
||||
const ulong rangeBase = 0x0000_0020_2F00_0000;
|
||||
const ulong rangeSize = 0x4000;
|
||||
using var host = new GappedHostMemory(rangeBase);
|
||||
using var memory = new PhysicalVirtualMemory(host);
|
||||
|
||||
Assert.False(memory.TryBackFixedRange(rangeBase, rangeSize, executable: false));
|
||||
|
||||
// First gap allocates successfully; second gap is attempted and fails.
|
||||
Assert.Equal(
|
||||
[(rangeBase + 0x1000, 0x1000), (rangeBase + 0x3000, 0x1000)],
|
||||
host.AllocationCalls);
|
||||
// First gap must have been freed during rollback — nothing leaks.
|
||||
Assert.Equal([rangeBase + 0x1000], host.FreedAddresses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBackFixedRangeFillsOnlyTheFreePagesOfAPartiallyOccupiedRange()
|
||||
{
|
||||
@@ -228,6 +249,108 @@ public sealed class GuestMemoryAllocatorTests
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class GappedHostMemory : IHostMemory, IDisposable
|
||||
{
|
||||
// Layout (4 × 0x1000 pages):
|
||||
// [committed] [free] [committed] [free]
|
||||
// Allocate succeeds for the first free gap, fails for the second.
|
||||
private readonly ulong _base;
|
||||
private readonly ulong _firstGapStart;
|
||||
private readonly ulong _firstGapEnd;
|
||||
private readonly ulong _secondGapStart;
|
||||
private readonly ulong _secondGapEnd;
|
||||
private readonly ulong _end;
|
||||
|
||||
public GappedHostMemory(ulong @base)
|
||||
{
|
||||
_base = @base;
|
||||
_firstGapStart = @base + 0x1000;
|
||||
_firstGapEnd = @base + 0x2000;
|
||||
_secondGapStart = @base + 0x3000;
|
||||
_secondGapEnd = @base + 0x4000;
|
||||
_end = @base + 0x4000;
|
||||
}
|
||||
|
||||
public List<(ulong Address, ulong Size)> AllocationCalls { get; } = [];
|
||||
public List<ulong> FreedAddresses { get; } = [];
|
||||
|
||||
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection)
|
||||
{
|
||||
AllocationCalls.Add((desiredAddress, size));
|
||||
|
||||
// First free gap — succeed.
|
||||
if (desiredAddress == _firstGapStart && size == 0x1000)
|
||||
{
|
||||
return desiredAddress;
|
||||
}
|
||||
|
||||
// Second free gap — fail to trigger rollback.
|
||||
return 0;
|
||||
}
|
||||
|
||||
public ulong Reserve(ulong desiredAddress, ulong size, HostPageProtection protection) => 0;
|
||||
|
||||
public bool Commit(ulong address, ulong size, HostPageProtection protection) => true;
|
||||
|
||||
public bool Free(ulong address)
|
||||
{
|
||||
FreedAddresses.Add(address);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Protect(ulong address, ulong size, HostPageProtection protection, out uint rawOldProtection)
|
||||
{
|
||||
rawOldProtection = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool ProtectRaw(ulong address, ulong size, uint rawProtection, out uint rawOldProtection)
|
||||
{
|
||||
rawOldProtection = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool Query(ulong address, out HostRegionInfo info)
|
||||
{
|
||||
if (address < _firstGapStart)
|
||||
{
|
||||
// First block: committed.
|
||||
info = new HostRegionInfo(address, _base, _firstGapStart - address,
|
||||
HostRegionState.Committed, RawState: 0x1000,
|
||||
HostPageProtection.ReadWrite, RawProtection: 0x04, RawAllocationProtection: 0x04);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (address < _firstGapEnd)
|
||||
{
|
||||
// Second block: free (first gap).
|
||||
info = new HostRegionInfo(address, AllocationBase: 0, _firstGapEnd - address,
|
||||
HostRegionState.Free, RawState: 0x10000,
|
||||
HostPageProtection.NoAccess, RawProtection: 0x01, RawAllocationProtection: 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (address < _secondGapStart)
|
||||
{
|
||||
// Third block: committed.
|
||||
info = new HostRegionInfo(address, _base + 0x2000, _secondGapStart - address,
|
||||
HostRegionState.Committed, RawState: 0x1000,
|
||||
HostPageProtection.ReadWrite, RawProtection: 0x04, RawAllocationProtection: 0x04);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fourth block: free (second gap — this one will fail to allocate).
|
||||
info = new HostRegionInfo(address, AllocationBase: 0, _end - address,
|
||||
HostRegionState.Free, RawState: 0x10000,
|
||||
HostPageProtection.NoAccess, RawProtection: 0x01, RawAllocationProtection: 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void FlushInstructionCache(ulong address, ulong size) { }
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class FakeHostMemory : IHostMemory
|
||||
{
|
||||
public ulong Allocate(ulong desiredAddress, ulong size, HostPageProtection protection) =>
|
||||
|
||||
Reference in New Issue
Block a user