mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-26 04:39:17 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93829e3242 | |||
| 4191a9e12b | |||
| 559b7f0a84 | |||
| 2272b9b576 | |||
| 8dd3172c0f | |||
| e7ea186ea8 | |||
| 74a519875b | |||
| 4682e64e81 | |||
| 912883de05 | |||
| f704586a8d |
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 229 KiB |
@@ -26,6 +26,25 @@ Before opening a pull request, please keep the following in mind:
|
||||
|
||||
If you're unsure about a design decision, feel free to open a discussion or draft PR first.
|
||||
|
||||
## Pull Request Expectations
|
||||
|
||||
Pull requests should provide real, observable emulator behavior rather than only suppressing errors or unresolved imports.
|
||||
|
||||
Changes that only return success, zero, or fabricated handles without implementing the expected state, output, or side effects will generally not be accepted. Functions that create resources, write output structures, register callbacks, or expose runtime state should model the behavior required by the guest.
|
||||
|
||||
When applicable, PRs should include:
|
||||
|
||||
- The affected game or application.
|
||||
- Relevant logs or failing imports.
|
||||
- Behavior before and after the change.
|
||||
- Real game testing and known limitations.
|
||||
|
||||
Avoid submitting large collections of speculative NIDs or unrelated exports. Keep each PR focused on one problem or a closely related set of changes.
|
||||
|
||||
Large architectural changes should be discussed with the maintainers before implementation. Contributors are encouraged to ask first when they are uncertain whether a proposed direction fits the project.
|
||||
|
||||
Opening a PR does not guarantee that it will be merged. Maintainers evaluate changes based on correctness, evidence, testing, scope, maintenance cost, and the long-term direction of the project.
|
||||
|
||||
## AI-Assisted Contributions
|
||||
|
||||
AI-assisted development is welcome and may be used for research, reverse engineering, code generation, or documentation.
|
||||
|
||||
@@ -9,11 +9,18 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<SharpEmuVersion>0.0.2-beta.4</SharpEmuVersion>
|
||||
<SharpEmuVersion>0.0.2-beta.5</SharpEmuVersion>
|
||||
<Version>$(SharpEmuVersion)</Version>
|
||||
|
||||
<RepoRoot>$([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)'))</RepoRoot>
|
||||
|
||||
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And $([MSBuild]::IsOSPlatform('Windows'))">win</_HostRidOSPrefix>
|
||||
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And '$(_HostRidOSPrefix)' == '' And $([MSBuild]::IsOSPlatform('Linux'))">linux</_HostRidOSPrefix>
|
||||
<_HostRidOSPrefix Condition="'$(RuntimeIdentifier)' == '' And '$(MSBuildProjectName)' == 'SharpEmu.CLI' And '$(_HostRidOSPrefix)' == '' And $([MSBuild]::IsOSPlatform('OSX'))">osx</_HostRidOSPrefix>
|
||||
<_HostRidArch Condition="'$(_HostRidOSPrefix)' != '' And '$([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture)' == 'Arm64'">arm64</_HostRidArch>
|
||||
<_HostRidArch Condition="'$(_HostRidOSPrefix)' != '' And '$(_HostRidArch)' == ''">x64</_HostRidArch>
|
||||
<RuntimeIdentifier Condition="'$(_HostRidOSPrefix)' != ''">$(_HostRidOSPrefix)-$(_HostRidArch)</RuntimeIdentifier>
|
||||
|
||||
<BaseIntermediateOutputPath>$(RepoRoot)artifacts/obj/$(MSBuildProjectName)/</BaseIntermediateOutputPath>
|
||||
<BaseOutputPath>$(RepoRoot)artifacts/bin/</BaseOutputPath>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="11.3.18" />
|
||||
<PackageVersion Include="Avalonia.Fonts.Inter" Version="11.3.18" />
|
||||
<PackageVersion Include="Avalonia.Themes.Fluent" Version="11.3.18" />
|
||||
<PackageVersion Include="FFmpeg.AutoGen" Version="7.1.1" />
|
||||
<PackageVersion Include="Iced" Version="1.21.0" />
|
||||
<PackageVersion Include="Microsoft.Build.Framework" Version="17.14.8" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||
|
||||
+51
-22
@@ -9,38 +9,67 @@ Demon's Souls plays Bink 2 (.bk2) files through a Bink implementation linked
|
||||
directly into eboot.bin. It does not use libSceVideodec, therefore an HLE video
|
||||
decoder cannot observe or replace those frames.
|
||||
|
||||
SharpEmu observes successful guest .bk2 opens and, when a Bink bridge is
|
||||
SharpEmu observes successful guest .bk2 opens and, when a Bink decoder is
|
||||
available, presents its decoded BGRA frames at the normal guest-flip boundary.
|
||||
This preserves the game's own timing and lets the host Vulkan presenter display
|
||||
the movie without trying to execute the PS5-specific Bink GPU decode path.
|
||||
|
||||
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 by calling FFmpeg's own C API directly from managed
|
||||
code (`src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs`, via the
|
||||
[FFmpeg.AutoGen](https://github.com/Ruslan-B/FFmpeg.AutoGen) P/Invoke
|
||||
bindings) against a custom FFmpeg build
|
||||
(`github.com/sharpemu/ffmpeg-core`, LGPL-2.1) that adds a Bink 2 decoder to
|
||||
FFmpeg 7.1.2; see "Supplying the FFmpeg libraries" below for where those
|
||||
libraries come from. No proprietary RAD SDK is needed to build or run
|
||||
SharpEmu, and there is no C/C++ code of SharpEmu's own involved in decoding
|
||||
-- SharpEmu.CLI.csproj only downloads a prebuilt release archive.
|
||||
|
||||
Set `SHARPEMU_BINK_MODE=guest` to leave decoding to the Bink implementation
|
||||
statically linked into the game instead. Set `skip` only when explicitly
|
||||
testing a title whose cinematics are optional.
|
||||
|
||||
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.
|
||||
|
||||
## Supplying the adapter
|
||||
The experimental `SHARPEMU_BINK_MODE=ffmpeg` override is unrelated to the
|
||||
default path above: instead of calling into FFmpeg in-process, it spawns a
|
||||
standalone `ffmpeg` executable and reads raw frames from its stdout
|
||||
(`src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs`). SharpEmu searches
|
||||
`SHARPEMU_FFMPEG_PATH`, the executable directory, its `ffmpeg` subdirectory,
|
||||
and then `PATH` (plus a couple of common Homebrew paths on macOS). That
|
||||
`ffmpeg` build must contain a Bink 2 decoder itself; a stock FFmpeg build that
|
||||
only recognizes the Bink container is not sufficient. Most users want the
|
||||
default `native` mode instead, which always has Bink 2 support since it's
|
||||
built against `ffmpeg-core` specifically.
|
||||
|
||||
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.
|
||||
## Supplying the FFmpeg libraries
|
||||
|
||||
Place the resulting libsharpemu_bink2_bridge.dylib next to the SharpEmu
|
||||
executable, or point to it explicitly:
|
||||
`dotnet publish` fetches a prebuilt release of `github.com/sharpemu/ffmpeg-core`
|
||||
(the tag is pinned in `SharpEmu.CLI.csproj`'s `FfmpegRuntimeTag`, matched to
|
||||
the `FFmpeg.AutoGen` package version in `Directory.Packages.props` -- both
|
||||
need to agree on the same FFmpeg ABI) and copies its dynamically linked
|
||||
libraries into a `plugins` folder next to the published executable. No C
|
||||
toolchain is required to build SharpEmu; publishing just downloads a zip.
|
||||
`plugins` is a loose, unpacked folder rather than something embedded in the
|
||||
single-file bundle, so the OS loader can resolve the libraries' own
|
||||
inter-dependencies (`avcodec` depends on `avutil`, etc.) itself.
|
||||
|
||||
SHARPEMU_BINK2_BRIDGE=/absolute/path/libsharpemu_bink2_bridge.dylib \
|
||||
./SharpEmu /path/to/eboot.bin
|
||||
A plain `dotnet publish` with no `-r` still works: it defaults to the host
|
||||
machine's own RID (see `Directory.Build.props`), so it fetches the matching
|
||||
`ffmpeg-core` archive and populates `plugins` without any extra flags.
|
||||
Passing an explicit `-r <rid>` (e.g. to cross-publish `linux-x64` from
|
||||
Windows) still overrides that default normally.
|
||||
|
||||
The expected exports are sharpemu_bink2_open_utf8,
|
||||
sharpemu_bink2_decode_next_bgra, and sharpemu_bink2_close. The supplied
|
||||
adapter opens one movie, exposes BGRA pixels, and advances after each decoded
|
||||
frame. The managed side validates dimensions and retains ownership of the
|
||||
destination buffer.
|
||||
To use a different set of FFmpeg libraries, drop them into the published
|
||||
`plugins` folder yourself (matching FFmpeg's own file-naming and versioning
|
||||
conventions, e.g. `avformat-61.dll` / `libavformat.so.61` / matching
|
||||
`.dylib`) -- `FfmpegNativeBinkFrameSource` points `ffmpeg.RootPath` at that
|
||||
folder and does not otherwise care where the files came from.
|
||||
|
||||
If the bridge is absent in native mode, SharpEmu logs one informational line
|
||||
and retains the existing guest rendering path.
|
||||
If the libraries are absent or fail to load, `FfmpegNativeBinkFrameSource.TryOpen`
|
||||
degrades gracefully: SharpEmu logs one informational line ("Bink2 bridge
|
||||
could not open movie ...") and leaves the guest's own rendering path
|
||||
untouched, rather than crashing.
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SharpEmu Emulator Project
|
||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
*
|
||||
* Build this small adapter with a licensed RAD Bink 2 SDK. The SDK and its
|
||||
* headers are not distributed by SharpEmu. See docs/bink2-bridge.md.
|
||||
*/
|
||||
#include <stdint.h>
|
||||
#include "bink.h"
|
||||
|
||||
typedef struct sharpemu_bink2_info {
|
||||
uint32_t width;
|
||||
uint32_t height;
|
||||
uint32_t frames_per_second_numerator;
|
||||
uint32_t frames_per_second_denominator;
|
||||
} sharpemu_bink2_info;
|
||||
|
||||
int sharpemu_bink2_open_utf8(const char *path, HBINK *movie, sharpemu_bink2_info *info) {
|
||||
HBINK bink;
|
||||
if (!path || !movie || !info) return 0;
|
||||
|
||||
*movie = NULL;
|
||||
|
||||
bink = BinkOpen(path, 0);
|
||||
if (!bink) return 0;
|
||||
|
||||
if (bink->Width == 0 || bink->Height == 0) {
|
||||
BinkClose(bink);
|
||||
return 0;
|
||||
}
|
||||
|
||||
*movie = bink;
|
||||
info->width = bink->Width;
|
||||
info->height = bink->Height;
|
||||
info->frames_per_second_numerator = bink->FrameRate;
|
||||
info->frames_per_second_denominator = bink->FrameRateDiv;
|
||||
return 1;
|
||||
}
|
||||
|
||||
int sharpemu_bink2_decode_next_bgra(HBINK movie, uint8_t *destination,
|
||||
uint32_t stride, uint32_t destination_bytes) {
|
||||
uint64_t needed;
|
||||
uint64_t min_stride;
|
||||
|
||||
if (!movie || !destination) return 0;
|
||||
|
||||
min_stride = (uint64_t)movie->Width * 4;
|
||||
if ((uint64_t)stride < min_stride) return 0;
|
||||
|
||||
needed = (uint64_t)stride * movie->Height;
|
||||
if (needed > destination_bytes) return 0;
|
||||
|
||||
/* Async Bink I/O has not filled the next frame yet; retry on the next host present. */
|
||||
if (BinkWait(movie)) return 0;
|
||||
|
||||
if (!BinkDoFrame(movie)) return 0;
|
||||
|
||||
if (!BinkCopyToBuffer(movie, destination, stride, movie->Height, 0, 0, BINKSURFACE32RA)) return 0;
|
||||
|
||||
BinkNextFrame(movie);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void sharpemu_bink2_close(HBINK movie) {
|
||||
if (movie) BinkClose(movie);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ internal static partial class Program
|
||||
}
|
||||
|
||||
args = NormalizeInternalArguments(args, out var isMitigatedChild);
|
||||
PreloadGlfw();
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
@@ -213,6 +214,27 @@ internal static partial class Program
|
||||
"as libvulkan.1.dylib.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SharpEmu.CLI.csproj publishes glfw into a "plugins" subfolder rather
|
||||
/// than flat next to the executable, which falls outside the default OS
|
||||
/// DLL/dlopen search path. Preloading it here by full path first means
|
||||
/// any later bare-name lookup (however Silk.NET/GLFW itself resolves the
|
||||
/// library) finds it already loaded in the process and reuses it -- the
|
||||
/// same technique <see cref="PreloadMacVulkanLoader"/> already relies on
|
||||
/// for the Vulkan loader.
|
||||
/// </summary>
|
||||
private static void PreloadGlfw()
|
||||
{
|
||||
var fileName = OperatingSystem.IsWindows() ? "glfw3.dll"
|
||||
: OperatingSystem.IsMacOS() ? "libglfw.3.dylib"
|
||||
: "libglfw.so.3";
|
||||
var candidate = Path.Combine(AppContext.BaseDirectory, "plugins", fileName);
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
NativeLibrary.TryLoad(candidate, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private static int RunEmulator(string[] args, bool isMitigatedChild)
|
||||
{
|
||||
Console.Error.WriteLine($"[DEBUG] SharpEmu starting with {args.Length} args");
|
||||
@@ -551,12 +573,7 @@ internal static partial class Program
|
||||
return false;
|
||||
}
|
||||
|
||||
var childArgs = new string[args.Length + 1];
|
||||
childArgs[0] = MitigatedChildFlag;
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
childArgs[i + 1] = args[i];
|
||||
}
|
||||
string[] childArgs = [MitigatedChildFlag, .. args];
|
||||
|
||||
var commandLine = BuildCommandLine(processPath, childArgs);
|
||||
var startupInfoEx = new STARTUPINFOEX();
|
||||
|
||||
@@ -20,6 +20,11 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<!-- osx-x64 is the macOS target: the CPU backend executes guest x86-64
|
||||
natively, so on Apple Silicon it runs under Rosetta 2. -->
|
||||
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
|
||||
<!-- A plain "dotnet publish" with no -r defaults $(RuntimeIdentifier) to
|
||||
the host's own RID; see Directory.Build.props, which is where that
|
||||
default actually has to live (PublishDir's RID suffix is decided
|
||||
there, evaluated before this file, so a default set only here would
|
||||
be too late for it). -->
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
@@ -60,7 +65,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
<PropertyGroup>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="..\..\LICENSE.txt">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
@@ -74,17 +79,75 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Keep glfw as a loose file next to the executable; every other native
|
||||
<!-- Native libraries (glfw, FFmpeg) publish into a subfolder next to the
|
||||
executable instead of sitting loose beside it, so the publish
|
||||
directory stays uncluttered as more native deps get added. The folder
|
||||
name is a fixed constant, not derived from the RID/architecture: each
|
||||
publish output only ever holds one architecture's binaries anyway, so
|
||||
varying the name added a class of bugs (RID resolution timing, host-OS
|
||||
vs. target-RID mixups) for no benefit. Runtime code (Program.cs's
|
||||
PreloadGlfw, FfmpegNativeBinkFrameSource's RootPath) uses the same
|
||||
literal "plugins" folder name. -->
|
||||
<PropertyGroup>
|
||||
<NativeLibraryFolderName>plugins</NativeLibraryFolderName>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Keep glfw as a loose file in the native subfolder; every other native
|
||||
library is embedded into the single-file bundle. -->
|
||||
<Target Name="KeepGlfwOutsideSingleFile" AfterTargets="ComputeResolvedFilesToPublishList">
|
||||
<ItemGroup>
|
||||
<_GlfwPublishFiles Include="@(ResolvedFileToPublish)"
|
||||
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
|
||||
Condition="$([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('glfw')) Or $([System.String]::Copy('%(ResolvedFileToPublish.Filename)').StartsWith('libglfw'))" />
|
||||
<ResolvedFileToPublish Remove="@(_GlfwPublishFiles)" />
|
||||
<ResolvedFileToPublish Include="@(_GlfwPublishFiles)">
|
||||
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
|
||||
<RelativePath>$(NativeLibraryFolderName)/%(Filename)%(Extension)</RelativePath>
|
||||
</ResolvedFileToPublish>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
<PropertyGroup>
|
||||
<FfmpegRuntimeTag>2c92585</FfmpegRuntimeTag>
|
||||
<FfmpegRuntimeDir>
|
||||
$(BaseIntermediateOutputPath)ffmpeg-runtime/$(FfmpegRuntimeTag)/$(RuntimeIdentifier)</FfmpegRuntimeDir>
|
||||
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'win-x64'">ffmpeg-windows-x64.zip</FfmpegRuntimePackage>
|
||||
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'linux-x64'">ffmpeg-linux-x64.zip</FfmpegRuntimePackage>
|
||||
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'osx-x64'">ffmpeg-macos-x64.zip</FfmpegRuntimePackage>
|
||||
<FfmpegRuntimePackage Condition="'$(RuntimeIdentifier)' == 'osx-arm64'">ffmpeg-macos-arm64.zip</FfmpegRuntimePackage>
|
||||
<FfmpegRuntimeArchive>$(FfmpegRuntimeDir)/$(FfmpegRuntimePackage)</FfmpegRuntimeArchive>
|
||||
<FfmpegRuntimeExtractDir>$(FfmpegRuntimeDir)/extracted</FfmpegRuntimeExtractDir>
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="FetchFfmpegRuntime"
|
||||
BeforeTargets="Publish"
|
||||
Condition="'$(RuntimeIdentifier)' != '' And '$(FfmpegRuntimePackage)' != ''">
|
||||
<DownloadFile
|
||||
SourceUrl="https://github.com/sharpemu/ffmpeg-core/releases/download/$(FfmpegRuntimeTag)/$(FfmpegRuntimePackage)"
|
||||
DestinationFolder="$(FfmpegRuntimeDir)"
|
||||
Condition="!Exists('$(FfmpegRuntimeArchive)')" />
|
||||
<Unzip
|
||||
SourceFiles="$(FfmpegRuntimeArchive)"
|
||||
DestinationFolder="$(FfmpegRuntimeExtractDir)"
|
||||
Condition="!Exists('$(FfmpegRuntimeExtractDir)')" />
|
||||
</Target>
|
||||
|
||||
<Target Name="PublishFfmpegRuntime"
|
||||
AfterTargets="Publish"
|
||||
DependsOnTargets="FetchFfmpegRuntime"
|
||||
Condition="'$(RuntimeIdentifier)' != '' And '$(FfmpegRuntimePackage)' != ''">
|
||||
<!-- Keyed off the target $(RuntimeIdentifier), not the host OS: publishing
|
||||
e.g. linux-x64 from a Windows machine is a supported cross-publish,
|
||||
and the extracted archive's own layout (bin/*.dll vs lib/*.so*) only
|
||||
depends on which platform's ffmpeg-core package was fetched. -->
|
||||
<ItemGroup>
|
||||
<_FfmpegRuntimeFiles Condition="$(RuntimeIdentifier.StartsWith('win'))"
|
||||
Include="$(FfmpegRuntimeExtractDir)/bin/*.dll" />
|
||||
<_FfmpegRuntimeFiles Condition="!$(RuntimeIdentifier.StartsWith('win'))"
|
||||
Include="$(FfmpegRuntimeExtractDir)/lib/*.so;$(FfmpegRuntimeExtractDir)/lib/*.so.*;$(FfmpegRuntimeExtractDir)/lib/*.dylib" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(_FfmpegRuntimeFiles)"
|
||||
DestinationFolder="$(PublishDir)$(NativeLibraryFolderName)"
|
||||
SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Bink;
|
||||
using SharpEmu.Libs.Gpu;
|
||||
using SharpEmu.ShaderCompiler;
|
||||
using SharpEmu.Libs.Kernel;
|
||||
@@ -48,6 +49,7 @@ public static partial class AgcExports
|
||||
private const uint ItWriteData = 0x37;
|
||||
private const uint ItDispatchDirect = 0x15;
|
||||
private const uint ItDispatchIndirect = 0x16;
|
||||
private const uint ItSetPredication = 0x20;
|
||||
private const uint ItWaitRegMem = 0x3C;
|
||||
private const uint ItIndirectBuffer = 0x3F;
|
||||
private const uint ItEventWrite = 0x46;
|
||||
@@ -277,7 +279,7 @@ public static partial class AgcExports
|
||||
private static long _labelProducerSequence;
|
||||
private static readonly object _labelProducerGate = new();
|
||||
private static readonly List<LabelProducerTrace> _labelProducers = [];
|
||||
private static readonly HashSet<(object Memory, ulong Address, ulong SubmissionId)>
|
||||
private static readonly HashSet<(object Memory, ulong Address)>
|
||||
_tracedProducerlessWaits = new();
|
||||
private static long _shaderTranslationMissTraceCount;
|
||||
private static long _translatedDrawTraceCount;
|
||||
@@ -543,6 +545,7 @@ public static partial class AgcExports
|
||||
public uint IndexSize { get; set; }
|
||||
public uint InstanceCount { get; set; } = 1;
|
||||
public uint DrawIndexOffset { get; set; }
|
||||
public bool PredicateSkip { get; set; }
|
||||
public string QueueName { get; set; } = "graphics";
|
||||
public ulong ActiveSubmissionId { get; set; }
|
||||
public Queue<PendingSubmission> PendingSubmissions { get; } = new();
|
||||
@@ -962,6 +965,20 @@ public static partial class AgcExports
|
||||
return ReturnPointer(ctx, commandAddress);
|
||||
}
|
||||
|
||||
// RenderThread/Subrender probe this before writing a NOP. Unresolved
|
||||
// GetSize returns NOT_FOUND and leaves command-buffer sizing broken.
|
||||
// CbNop rejects dwordCount < 2, so report that floor.
|
||||
[SysAbiExport(
|
||||
Nid = "t7PlZ9nt5Lc",
|
||||
ExportName = "sceAgcCbNopGetSize",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int CbNopGetSize(CpuContext ctx)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 2u * sizeof(uint);
|
||||
return (int)ctx[CpuRegister.Rax];
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "k3GhuSNmBLU",
|
||||
ExportName = "sceAgcCbDispatch",
|
||||
@@ -1159,6 +1176,18 @@ public static partial class AgcExports
|
||||
return ReturnPointer(ctx, commandAddress);
|
||||
}
|
||||
|
||||
// Matches the fixed 8-dword ACQUIRE_MEM packet AcbAcquireMem writes above.
|
||||
[SysAbiExport(
|
||||
Nid = "ewobAQeMo5k",
|
||||
ExportName = "sceAgcAcbAcquireMemGetSize",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int AcbAcquireMemGetSize(CpuContext ctx)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 8u * sizeof(uint);
|
||||
return (int)ctx[CpuRegister.Rax];
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "htn36gPnBk4",
|
||||
ExportName = "sceAgcAcbWaitRegMem",
|
||||
@@ -1183,12 +1212,12 @@ public static partial class AgcExports
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
var packetDwords = size == 0 ? 6u : 9u;
|
||||
var packetDwords = size == 0 ? 7u : 9u;
|
||||
var packetRegister = size == 0 ? RWaitMem32 : RWaitMem64;
|
||||
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress) ||
|
||||
!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItNop, packetRegister)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address & (size == 0 ? ~0x3u : ~0x7u)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32) & 0x3FFFFu) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)mask))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
@@ -1196,8 +1225,9 @@ public static partial class AgcExports
|
||||
|
||||
if (size == 0)
|
||||
{
|
||||
if (!TryWriteUInt32(ctx, commandAddress + 16, compareFunction) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference))
|
||||
if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)reference) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, EncodeWaitRegMem32Control(compareFunction, 0, cachePolicy)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 24, EncodeWaitRegMemPoll(pollCycles)))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
@@ -1205,8 +1235,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);
|
||||
}
|
||||
@@ -1733,6 +1763,18 @@ public static partial class AgcExports
|
||||
return ReturnPointer(ctx, commandAddress);
|
||||
}
|
||||
|
||||
// Matches the fixed 8-dword ACQUIRE_MEM packet DcbAcquireMem writes above.
|
||||
[SysAbiExport(
|
||||
Nid = "-vnlTPPXPrw",
|
||||
ExportName = "sceAgcDcbAcquireMemGetSize",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int DcbAcquireMemGetSize(CpuContext ctx)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 8u * sizeof(uint);
|
||||
return (int)ctx[CpuRegister.Rax];
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "i1jyy49AjXU",
|
||||
ExportName = "sceAgcDcbWriteData",
|
||||
@@ -1826,38 +1868,21 @@ public static partial class AgcExports
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
var standardWait = operation is 2 or 3;
|
||||
var packetDwords = standardWait ? 7u : size == 0 ? 6u : 9u;
|
||||
var packetDwords = size == 0 ? 7u : 9u;
|
||||
var packetRegister = size == 0 ? RWaitMem32 : RWaitMem64;
|
||||
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
if (standardWait)
|
||||
{
|
||||
if (!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItWaitRegMem, 0)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, compareFunction | ((operation & 1) << 8)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)address) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)(address >> 32)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 16, (uint)reference) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)mask) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 24, pollCycles / 40))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
}
|
||||
else if (!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItNop, packetRegister)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32)) ||
|
||||
if (!TryAllocateCommandDwords(ctx, commandBufferAddress, packetDwords, out var commandAddress) ||
|
||||
!TryWriteUInt32(ctx, commandAddress, Pm4(packetDwords, ItNop, packetRegister)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 4, (uint)address & (size == 0 ? ~0x3u : ~0x7u)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 8, (uint)(address >> 32) & 0x3FFFFu) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 12, (uint)mask))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
else if (size == 0)
|
||||
{
|
||||
if (!TryWriteUInt32(ctx, commandAddress + 16, compareFunction | (operation << 8)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference))
|
||||
if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)reference) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, EncodeWaitRegMem32Control(compareFunction, operation, cachePolicy)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 24, EncodeWaitRegMemPoll(pollCycles)))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
@@ -1865,8 +1890,8 @@ public static partial class AgcExports
|
||||
else if (!TryWriteUInt32(ctx, commandAddress + 16, (uint)(mask >> 32)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 20, (uint)reference) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 24, (uint)(reference >> 32)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 28, compareFunction | (operation << 8)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 32, pollCycles / 40))
|
||||
!TryWriteUInt32(ctx, commandAddress + 28, EncodeWaitRegMem64Control(compareFunction, operation, cachePolicy)) ||
|
||||
!TryWriteUInt32(ctx, commandAddress + 32, EncodeWaitRegMemPoll(pollCycles)))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
@@ -2305,7 +2330,14 @@ public static partial class AgcExports
|
||||
return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
return ctx.TryWriteUInt64(commandAddress + fieldOffset, address)
|
||||
var wrote = op == ItNop && register is RWaitMem32 or RWaitMem64
|
||||
? TryWriteUInt32(
|
||||
ctx,
|
||||
commandAddress + fieldOffset,
|
||||
(uint)address & (register == RWaitMem32 ? ~0x3u : ~0x7u)) &&
|
||||
TryWriteUInt32(ctx, commandAddress + fieldOffset + 4, (uint)(address >> 32) & 0x3FFFFu)
|
||||
: ctx.TryWriteUInt64(commandAddress + fieldOffset, address);
|
||||
return wrote
|
||||
? SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_OK)
|
||||
: SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
@@ -2328,7 +2360,7 @@ public static partial class AgcExports
|
||||
var fieldOffset = op == ItWaitRegMem
|
||||
? 4UL
|
||||
: op == ItNop && register == RWaitMem32
|
||||
? 16UL
|
||||
? 20UL
|
||||
: op == ItNop && register == RWaitMem64
|
||||
? 28UL
|
||||
: 0;
|
||||
@@ -2357,7 +2389,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
|
||||
@@ -2396,6 +2428,20 @@ public static partial class AgcExports
|
||||
: OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
// PatchAddress/PatchData touch UInt64 fields at +12/+20 of an RReleaseMem
|
||||
// packet, so the packet is at least 7 dwords; use the 8-dword RELEASE_MEM
|
||||
// family size already used elsewhere in this file.
|
||||
[SysAbiExport(
|
||||
Nid = "hL7C0IRpWZI",
|
||||
ExportName = "sceAgcCbQueueEndOfPipeActionGetSize",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int CbQueueEndOfPipeActionGetSize(CpuContext ctx)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 8u * sizeof(uint);
|
||||
return (int)ctx[CpuRegister.Rax];
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "0fWWK5uG9rQ",
|
||||
ExportName = "sceAgcQueueEndOfPipeActionPatchAddress",
|
||||
@@ -3105,6 +3151,26 @@ public static partial class AgcExports
|
||||
CountSubmittedOpcode(op, register);
|
||||
}
|
||||
|
||||
if ((header & 1u) != 0 && state.PredicateSkip)
|
||||
{
|
||||
if (tracePackets)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.dcb.predicated_skip queue={state.QueueName} " +
|
||||
$"packet=0x{currentAddress:X16} op=0x{op:X2} len={length}");
|
||||
}
|
||||
|
||||
offset += length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op == ItSetPredication)
|
||||
{
|
||||
ApplySubmittedPredication(ctx, state, currentAddress, length, tracePackets);
|
||||
offset += length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (op == ItNop &&
|
||||
register is RDrawReset or RAcbReset &&
|
||||
length >= 2)
|
||||
@@ -3823,7 +3889,7 @@ public static partial class AgcExports
|
||||
|
||||
if (!stale && producer is null &&
|
||||
!_tracedProducerlessWaits.Add(
|
||||
(memory, waiter.WaitAddress, waiter.SubmissionId)))
|
||||
(memory, waiter.WaitAddress)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -4019,6 +4085,111 @@ public static partial class AgcExports
|
||||
state.DrawIndexOffset = 0;
|
||||
}
|
||||
|
||||
private static void ApplySubmittedPredication(
|
||||
CpuContext ctx,
|
||||
SubmittedDcbState state,
|
||||
ulong packetAddress,
|
||||
uint packetLength,
|
||||
bool tracePacket)
|
||||
{
|
||||
if (packetLength < 3 ||
|
||||
!TryReadUInt32(ctx, packetAddress + 4, out var first) ||
|
||||
!TryReadUInt32(ctx, packetAddress + 8, out var second))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const uint flagsMask = 0x0007_1100u;
|
||||
uint flags;
|
||||
ulong predicateAddress;
|
||||
if (packetLength >= 4 &&
|
||||
(first & ~flagsMask) == 0 &&
|
||||
TryReadUInt32(ctx, packetAddress + 12, out var third) &&
|
||||
third <= 0xFFFFu)
|
||||
{
|
||||
flags = first;
|
||||
predicateAddress = ((ulong)third << 32) | (second & 0xFFFF_FFF0u);
|
||||
}
|
||||
else
|
||||
{
|
||||
flags = second;
|
||||
predicateAddress = (first & 0xFFFF_FFF0u) | ((ulong)(second & 0xFFu) << 32);
|
||||
}
|
||||
|
||||
var operation = (flags >> 16) & 0x7u;
|
||||
if (operation == 0)
|
||||
{
|
||||
state.PredicateSkip = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation != 3)
|
||||
{
|
||||
if (tracePacket)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.dcb.predication_unsupported packet=0x{packetAddress:X16} " +
|
||||
$"op={operation} addr=0x{predicateAddress:X16}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var waitOperation = (flags >> 12) & 1u;
|
||||
var value = 0UL;
|
||||
var readSucceeded = false;
|
||||
void ReadPredicate() =>
|
||||
readSucceeded = ctx.TryReadUInt64(predicateAddress, out value);
|
||||
|
||||
if (waitOperation != 0)
|
||||
{
|
||||
var sequence = GuestGpu.Current.SubmitOrderedGuestAction(
|
||||
ReadPredicate,
|
||||
$"set_predication read 0x{predicateAddress:X16}");
|
||||
if (sequence == 0)
|
||||
{
|
||||
ReadPredicate();
|
||||
}
|
||||
else if (!GuestGpu.Current.WaitForGuestWork(sequence))
|
||||
{
|
||||
if (tracePacket)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.dcb.predication_wait_failed packet=0x{packetAddress:X16} " +
|
||||
$"addr=0x{predicateAddress:X16} sequence={sequence}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ReadPredicate();
|
||||
}
|
||||
|
||||
if (!readSucceeded)
|
||||
{
|
||||
if (tracePacket)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.dcb.predication_read_failed packet=0x{packetAddress:X16} " +
|
||||
$"addr=0x{predicateAddress:X16}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var condition = (flags >> 8) & 1u;
|
||||
state.PredicateSkip = condition == 0 ? value != 0 : value == 0;
|
||||
if (tracePacket)
|
||||
{
|
||||
TraceAgc(
|
||||
$"agc.dcb.predication packet=0x{packetAddress:X16} " +
|
||||
$"addr=0x{predicateAddress:X16} value=0x{value:X16} " +
|
||||
$"condition={condition} wait={waitOperation} skip={state.PredicateSkip}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RangesOverlap(
|
||||
ulong leftAddress,
|
||||
ulong leftLength,
|
||||
@@ -4561,6 +4732,7 @@ public static partial class AgcExports
|
||||
private static bool TryParseSubmittedWait(
|
||||
CpuContext ctx,
|
||||
ulong packetAddress,
|
||||
uint packetLength,
|
||||
bool is64Bit,
|
||||
bool isStandard,
|
||||
out ulong waitAddress,
|
||||
@@ -4591,8 +4763,10 @@ public static partial class AgcExports
|
||||
return true;
|
||||
}
|
||||
|
||||
var legacyWait32 = !is64Bit && packetLength == 6;
|
||||
var controlOffset = is64Bit ? 28u : legacyWait32 ? 16u : 20u;
|
||||
if (!TryReadUInt64(ctx, packetAddress + 4, out waitAddress) ||
|
||||
!TryReadUInt32(ctx, packetAddress + (is64Bit ? 28u : 16u), out var control))
|
||||
!TryReadUInt32(ctx, packetAddress + controlOffset, out var control))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -4605,8 +4779,9 @@ public static partial class AgcExports
|
||||
TryReadUInt64(ctx, packetAddress + 20, out reference);
|
||||
}
|
||||
|
||||
var referenceOffset = legacyWait32 ? 20u : 16u;
|
||||
if (!TryReadUInt32(ctx, packetAddress + 12, out var mask32) ||
|
||||
!TryReadUInt32(ctx, packetAddress + 20, out var reference32))
|
||||
!TryReadUInt32(ctx, packetAddress + referenceOffset, out var reference32))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -4711,7 +4886,7 @@ public static partial class AgcExports
|
||||
bool tracePacket)
|
||||
{
|
||||
if (!TryParseSubmittedWait(
|
||||
ctx, packetAddress, is64Bit, isStandard,
|
||||
ctx, packetAddress, length, is64Bit, isStandard,
|
||||
out var waitAddress, out var reference, out var mask, out var compareFunction,
|
||||
out var controlValue))
|
||||
{
|
||||
@@ -10910,6 +11085,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;
|
||||
|
||||
@@ -11329,6 +11521,34 @@ public static partial class AgcExports
|
||||
$"[LOADER][TRACE] agc.create_shader dst=0x{destinationAddress:X16} header=0x{headerAddress:X16} code=0x{codeAddress:X16} {detail}");
|
||||
}
|
||||
|
||||
// Hardware REWIND is a fixed 2-dword header + valid-bit packet (same floor
|
||||
// as CbNopGetSize). No Rewind writer is implemented yet; size-only is enough
|
||||
// for callers that allocate the packet before filling it.
|
||||
[SysAbiExport(
|
||||
Nid = "QIXCsbipds0",
|
||||
ExportName = "sceAgcDcbRewindGetSize",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int DcbRewindGetSize(CpuContext ctx)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 2u * sizeof(uint);
|
||||
return (int)ctx[CpuRegister.Rax];
|
||||
}
|
||||
|
||||
// Matches the 4-dword INDIRECT_BUFFER packet DcbJump writes below.
|
||||
// Returning NOT_FOUND here left callers with a null packet pointer and an
|
||||
// immediate write AV on RenderThread.
|
||||
[SysAbiExport(
|
||||
Nid = "VEGu4dixjUg",
|
||||
ExportName = "sceAgcDcbJumpGetSize",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int DcbJumpGetSize(CpuContext ctx)
|
||||
{
|
||||
ctx[CpuRegister.Rax] = 4u * sizeof(uint);
|
||||
return (int)ctx[CpuRegister.Rax];
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "xSAR0LTcRKM",
|
||||
ExportName = "sceAgcDcbJump",
|
||||
@@ -11364,16 +11584,21 @@ public static partial class AgcExports
|
||||
public static int DcbSetPredication(CpuContext ctx)
|
||||
{
|
||||
var dcb = ctx[CpuRegister.Rdi];
|
||||
var address = ctx[CpuRegister.Rsi];
|
||||
var condition = (uint)(ctx[CpuRegister.Rsi] & 1u);
|
||||
var operation = (uint)(ctx[CpuRegister.Rdx] & 0x7u);
|
||||
var waitOperation = (uint)(ctx[CpuRegister.Rcx] & 1u);
|
||||
var address = ctx[CpuRegister.R8];
|
||||
if (dcb == 0)
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
|
||||
if (!TryAllocateCommandDwords(ctx, dcb, 3, out var cmd) ||
|
||||
!ctx.TryWriteUInt32(cmd, Pm4(3, ItNop, RZero)) ||
|
||||
!ctx.TryWriteUInt32(cmd + 4, (uint)(address & 0xFFFF_FFFFUL)) ||
|
||||
!ctx.TryWriteUInt32(cmd + 8, (uint)(address >> 32)))
|
||||
var flags = (condition << 8) | (waitOperation << 12) | (operation << 16);
|
||||
if (!TryAllocateCommandDwords(ctx, dcb, 4, out var cmd) ||
|
||||
!ctx.TryWriteUInt32(cmd, Pm4(4, ItSetPredication, RZero)) ||
|
||||
!ctx.TryWriteUInt32(cmd + 4, flags) ||
|
||||
!ctx.TryWriteUInt32(cmd + 8, (uint)address & 0xFFFF_FFF0u) ||
|
||||
!ctx.TryWriteUInt32(cmd + 12, (uint)(address >> 32)))
|
||||
{
|
||||
return ReturnPointer(ctx, 0);
|
||||
}
|
||||
@@ -11388,9 +11613,17 @@ public static partial class AgcExports
|
||||
LibraryName = "libSceAgc")]
|
||||
public static int SetPacketPredication(CpuContext ctx)
|
||||
{
|
||||
// Global predication toggle on a packet; a no-op is safe for rendering.
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
var packetAddress = ctx[CpuRegister.Rdi];
|
||||
var predication = ctx[CpuRegister.Rsi];
|
||||
if (packetAddress == 0 || !TryReadUInt32(ctx, packetAddress, out var header))
|
||||
{
|
||||
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
|
||||
}
|
||||
|
||||
header = (header & ~1u) | (predication == 1 ? 1u : 0u);
|
||||
return !ctx.TryWriteUInt32(packetAddress, header)
|
||||
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT)
|
||||
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
|
||||
}
|
||||
|
||||
// ABI (reversed from Quake): rdi = array of DCB base addresses (u64 each),
|
||||
@@ -11565,7 +11798,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,64 +418,83 @@ internal static class GnmTiling
|
||||
return false;
|
||||
}
|
||||
|
||||
// Precompute the within-block element offset for each (x, y) inside a
|
||||
// single block. The swizzle equation only depends on the in-block
|
||||
// coordinates, so this table is reused for every block — turning the
|
||||
// per-pixel bit-interleave (a loop + calls) into a single array lookup.
|
||||
// Detiling a 2048x2048 texture is millions of elements; without this the
|
||||
// per-pixel math makes DETILE unusably slow during asset streaming.
|
||||
// Address tables depend only on the swizzle equation and element size,
|
||||
// so retain them across textures instead of rebuilding them per upload.
|
||||
var hasExactXorPattern = TryGetExactXorPattern(swizzleMode, bppLog2, out var xorPattern);
|
||||
var blockTable = hasExactXorPattern ? [] : new int[blockWidth * blockHeight];
|
||||
for (var by = 0; !hasExactXorPattern && by < blockHeight; by++)
|
||||
{
|
||||
for (var bx = 0; bx < blockWidth; bx++)
|
||||
{
|
||||
blockTable[by * blockWidth + bx] = (int)(kind == SwizzleKind.ZOrder
|
||||
? MortonInterleave((uint)bx, (uint)by, blockWidth, blockHeight)
|
||||
: StandardSwizzleOffset((uint)bx, (uint)by, blockWidth, blockHeight));
|
||||
}
|
||||
}
|
||||
var patternTerms = hasExactXorPattern
|
||||
? _patternTermCache.GetOrAdd(
|
||||
(swizzleMode, bppLog2),
|
||||
_ => CreatePatternTerms(xorPattern))
|
||||
: default;
|
||||
var blockTable = hasExactXorPattern
|
||||
? []
|
||||
: _blockTableCache.GetOrAdd(
|
||||
(kind, blockWidth, blockHeight),
|
||||
static key => CreateBlockTable(key.Kind, key.Width, key.Height));
|
||||
|
||||
// The XOR equation offset factors cleanly into independent X and Y
|
||||
// fields — each output bit is parity(x & XMask) XOR parity(y & YMask),
|
||||
// and parity distributes over XOR, so offset(x, y) == xTerm(x) ^ yTerm(y).
|
||||
// Precompute the per-column X term once (reused across every row) so the
|
||||
// inner loop drops from a 16-bit interleave with 32 PopCounts per element
|
||||
// to a single array load and one XOR.
|
||||
var xTermByColumn = hasExactXorPattern ? new int[elementsWide] : [];
|
||||
for (var x = 0; hasExactXorPattern && x < elementsWide; x++)
|
||||
// Exact equations repeat at a small power-of-two period. Cached axis
|
||||
// terms reduce the inner loop to two array loads and one XOR.
|
||||
fixed (byte* tiledPointer = tiled)
|
||||
fixed (byte* linearPointer = linear)
|
||||
{
|
||||
xTermByColumn[x] = (int)PatternAxisTerm((uint)x, xorPattern, useX: true);
|
||||
}
|
||||
|
||||
for (var y = 0; y < elementsHigh; y++)
|
||||
{
|
||||
var blockY = y / blockHeight;
|
||||
var inBlockY = y % blockHeight;
|
||||
var rowBlockBase = (long)blockY * blocksPerRow;
|
||||
var tableRowBase = inBlockY * blockWidth;
|
||||
var destRowBase = (long)y * elementsWide * bytesPerElement;
|
||||
// Y term is constant across the row; hoist it out of the inner loop.
|
||||
var yTerm = hasExactXorPattern ? (int)PatternAxisTerm((uint)y, xorPattern, useX: false) : 0;
|
||||
for (var x = 0; x < elementsWide; x++)
|
||||
var sourceAddress = (nint)tiledPointer;
|
||||
var destinationAddress = (nint)linearPointer;
|
||||
var sourceLength = tiled.Length;
|
||||
var destinationLength = linear.Length;
|
||||
var blockWidthShift = BitLog2((uint)blockWidth);
|
||||
var blockWidthMask = blockWidth - 1;
|
||||
var detileRow = (int y) =>
|
||||
{
|
||||
var blockX = x / blockWidth;
|
||||
var inBlockX = x % blockWidth;
|
||||
|
||||
var blockIndex = rowBlockBase + blockX;
|
||||
var sourceByte = hasExactXorPattern
|
||||
? blockIndex * blockBytes + (xTermByColumn[x] ^ yTerm)
|
||||
: (blockIndex * blockElements + blockTable[tableRowBase + inBlockX]) *
|
||||
(long)bytesPerElement;
|
||||
var destByte = destRowBase + (long)x * bytesPerElement;
|
||||
if (sourceByte + bytesPerElement > tiled.Length ||
|
||||
destByte + bytesPerElement > linear.Length)
|
||||
var blockY = y / blockHeight;
|
||||
var inBlockY = y & (blockHeight - 1);
|
||||
var rowBlockBase = (long)blockY * blocksPerRow;
|
||||
var tableRowBase = inBlockY * blockWidth;
|
||||
var destRowBase = (long)y * elementsWide * bytesPerElement;
|
||||
var yTerm = hasExactXorPattern
|
||||
? patternTerms.Y[y & patternTerms.YMask]
|
||||
: 0;
|
||||
for (var x = 0; x < elementsWide; x++)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var blockX = x >> blockWidthShift;
|
||||
var inBlockX = x & blockWidthMask;
|
||||
var blockIndex = rowBlockBase + blockX;
|
||||
var sourceByte = hasExactXorPattern
|
||||
? blockIndex * blockBytes + (patternTerms.X[x & patternTerms.XMask] ^ yTerm)
|
||||
: (blockIndex * blockElements + blockTable[tableRowBase + inBlockX]) *
|
||||
(long)bytesPerElement;
|
||||
var destByte = destRowBase + (long)x * bytesPerElement;
|
||||
if (sourceByte < 0 ||
|
||||
sourceByte + bytesPerElement > sourceLength ||
|
||||
destByte + bytesPerElement > destinationLength)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
tiled.Slice((int)sourceByte, bytesPerElement)
|
||||
.CopyTo(linear.Slice((int)destByte, bytesPerElement));
|
||||
CopyElement(
|
||||
(byte*)sourceAddress + sourceByte,
|
||||
(byte*)destinationAddress + destByte,
|
||||
bytesPerElement);
|
||||
}
|
||||
};
|
||||
|
||||
var elementCount = (long)elementsWide * elementsHigh;
|
||||
if (elementCount >= ParallelDetileElementThreshold && Environment.ProcessorCount > 1)
|
||||
{
|
||||
Parallel.For(
|
||||
0,
|
||||
elementsHigh,
|
||||
_parallelDetileOptions,
|
||||
detileRow);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var y = 0; y < elementsHigh; y++)
|
||||
{
|
||||
detileRow(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,6 +507,80 @@ internal static class GnmTiling
|
||||
ZOrder,
|
||||
}
|
||||
|
||||
private readonly record struct PatternTerms(int[] X, int XMask, int[] Y, int YMask);
|
||||
|
||||
private static PatternTerms CreatePatternTerms(AddressBit[] pattern)
|
||||
{
|
||||
uint xMask = 0;
|
||||
uint yMask = 0;
|
||||
foreach (var bit in pattern)
|
||||
{
|
||||
xMask |= bit.XMask;
|
||||
yMask |= bit.YMask;
|
||||
}
|
||||
|
||||
var xLength = AxisTermPeriod(xMask);
|
||||
var yLength = AxisTermPeriod(yMask);
|
||||
var xTerms = new int[xLength];
|
||||
var yTerms = new int[yLength];
|
||||
for (var x = 0; x < xTerms.Length; x++)
|
||||
{
|
||||
xTerms[x] = (int)PatternAxisTerm((uint)x, pattern, useX: true);
|
||||
}
|
||||
|
||||
for (var y = 0; y < yTerms.Length; y++)
|
||||
{
|
||||
yTerms[y] = (int)PatternAxisTerm((uint)y, pattern, useX: false);
|
||||
}
|
||||
|
||||
return new PatternTerms(xTerms, xLength - 1, yTerms, yLength - 1);
|
||||
}
|
||||
|
||||
private static int AxisTermPeriod(uint mask) =>
|
||||
mask == 0 ? 1 : 1 << (32 - System.Numerics.BitOperations.LeadingZeroCount(mask));
|
||||
|
||||
private static int[] CreateBlockTable(SwizzleKind kind, int blockWidth, int blockHeight)
|
||||
{
|
||||
var table = new int[blockWidth * blockHeight];
|
||||
for (var y = 0; y < blockHeight; y++)
|
||||
{
|
||||
for (var x = 0; x < blockWidth; x++)
|
||||
{
|
||||
table[y * blockWidth + x] = (int)(kind == SwizzleKind.ZOrder
|
||||
? MortonInterleave((uint)x, (uint)y, blockWidth, blockHeight)
|
||||
: StandardSwizzleOffset((uint)x, (uint)y, blockWidth, blockHeight));
|
||||
}
|
||||
}
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static void CopyElement(byte* source, byte* destination, int bytesPerElement)
|
||||
{
|
||||
switch (bytesPerElement)
|
||||
{
|
||||
case 1:
|
||||
*destination = *source;
|
||||
break;
|
||||
case 2:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<ushort>(source));
|
||||
break;
|
||||
case 4:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<uint>(source));
|
||||
break;
|
||||
case 8:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<ulong>(source));
|
||||
break;
|
||||
case 16:
|
||||
Unsafe.WriteUnaligned(destination, Unsafe.ReadUnaligned<UInt128>(source));
|
||||
break;
|
||||
default:
|
||||
Unsafe.CopyBlockUnaligned(destination, source, (uint)bytesPerElement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly AddressBit Zero = new(0, 0);
|
||||
|
||||
private static AddressBit X(int bit) => new(1u << bit, 0);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -21,6 +21,7 @@ public static class AjmExports
|
||||
private const int MaxInstanceIndex = 0x2FFF;
|
||||
private static readonly ConcurrentDictionary<uint, AjmContextState> Contexts = new();
|
||||
private static int _nextContextId;
|
||||
private static int _nextBatchId;
|
||||
|
||||
private sealed class AjmContextState
|
||||
{
|
||||
@@ -227,10 +228,250 @@ public static class AjmExports
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueues a decode job on a batch. Titles call this on the Bink/AJM hot
|
||||
/// path; leaving it unresolved floods Import WARN spam. This is a silence
|
||||
/// stub, not a codec: advance the batch cursor and report the input as
|
||||
/// consumed with silence produced so the title does not spin on the same
|
||||
/// packet.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "39WxhR-ePew",
|
||||
ExportName = "sceAjmBatchJobDecode",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAjm")]
|
||||
public static int AjmBatchJobDecode(CpuContext ctx)
|
||||
{
|
||||
var infoAddress = ctx[CpuRegister.Rdi];
|
||||
var instanceId = unchecked((uint)ctx[CpuRegister.Rsi]);
|
||||
var inputAddress = ctx[CpuRegister.Rdx];
|
||||
var inputSize = ctx[CpuRegister.Rcx];
|
||||
var outputAddress = ctx[CpuRegister.R8];
|
||||
var outputSize = ctx[CpuRegister.R9];
|
||||
var resultAddress = ReadStackArg64(ctx, 0);
|
||||
|
||||
if (infoAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
|
||||
}
|
||||
|
||||
// Best-effort: bump the batch cursor when the guest filled AjmBatchInfo.
|
||||
// Still succeed without it — the unresolved stub returned 0 and titles
|
||||
// keep calling; failing here would reintroduce hot-path spam via retries.
|
||||
_ = TryAppendBatchJob(ctx, infoAddress, AjmJobRunSize);
|
||||
|
||||
// Silence: clear PCM out and claim full input consumed so the guest
|
||||
// advances its bitstream cursor instead of re-submitting forever.
|
||||
if (outputAddress != 0 && outputSize != 0 && outputSize <= MaxSilentPcmBytes)
|
||||
{
|
||||
ClearGuestMemory(ctx, outputAddress, outputSize);
|
||||
}
|
||||
|
||||
WriteDecodeStreamResult(
|
||||
ctx,
|
||||
resultAddress,
|
||||
inputConsumed: inputSize > int.MaxValue ? int.MaxValue : (int)inputSize,
|
||||
outputWritten: 0,
|
||||
totalDecodedSamples: 0,
|
||||
frames: inputSize != 0 || outputSize != 0 ? 1u : 0u);
|
||||
|
||||
Trace(
|
||||
$"batch_job_decode info=0x{infoAddress:X16} instance=0x{instanceId:X8} " +
|
||||
$"in=0x{inputAddress:X16}+0x{inputSize:X} out=0x{outputAddress:X16}+0x{outputSize:X} " +
|
||||
$"result=0x{resultAddress:X16}");
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Submits a built batch. Instant-complete silence stub: publish a batch id
|
||||
/// and clear any error out. Decode sidebands were already filled at
|
||||
/// job-enqueue time.
|
||||
/// </summary>
|
||||
[SysAbiExport(
|
||||
Nid = "5tOfnaClcqM",
|
||||
ExportName = "sceAjmBatchStart",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAjm")]
|
||||
public static int AjmBatchStart(CpuContext ctx)
|
||||
{
|
||||
var contextId = unchecked((uint)ctx[CpuRegister.Rdi]);
|
||||
var infoAddress = ctx[CpuRegister.Rsi];
|
||||
var priority = unchecked((int)ctx[CpuRegister.Rdx]);
|
||||
var errorAddress = ctx[CpuRegister.Rcx];
|
||||
var batchOutAddress = ctx[CpuRegister.R8];
|
||||
|
||||
if (infoAddress == 0 || batchOutAddress == 0)
|
||||
{
|
||||
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
|
||||
}
|
||||
|
||||
ClearAjmBatchError(ctx, errorAddress);
|
||||
|
||||
var batchId = unchecked((uint)Interlocked.Increment(ref _nextBatchId));
|
||||
Span<byte> batchValue = stackalloc byte[sizeof(uint)];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(batchValue, batchId);
|
||||
if (!ctx.Memory.TryWrite(batchOutAddress, batchValue))
|
||||
{
|
||||
return ctx.SetReturn(OrbisAjmErrorInvalidParameter);
|
||||
}
|
||||
|
||||
Trace(
|
||||
$"batch_start context={contextId} info=0x{infoAddress:X16} " +
|
||||
$"priority={priority} batch={batchId} error=0x{errorAddress:X16}");
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "-qLsfDAywIY",
|
||||
ExportName = "sceAjmBatchWait",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAjm")]
|
||||
public static int AjmBatchWait(CpuContext ctx)
|
||||
{
|
||||
// Batches complete synchronously in Start; Wait is a no-op success.
|
||||
var errorAddress = ctx[CpuRegister.Rcx];
|
||||
ClearAjmBatchError(ctx, errorAddress);
|
||||
Trace(
|
||||
$"batch_wait context={unchecked((uint)ctx[CpuRegister.Rdi])} " +
|
||||
$"batch={unchecked((uint)ctx[CpuRegister.Rsi])} " +
|
||||
$"timeout={unchecked((uint)ctx[CpuRegister.Rdx])}");
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "NVDXiUesSbA",
|
||||
ExportName = "sceAjmBatchCancel",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceAjm")]
|
||||
public static int AjmBatchCancel(CpuContext ctx)
|
||||
{
|
||||
Trace(
|
||||
$"batch_cancel context={unchecked((uint)ctx[CpuRegister.Rdi])} " +
|
||||
$"batch={unchecked((uint)ctx[CpuRegister.Rsi])}");
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
internal static void ResetForTests()
|
||||
{
|
||||
Contexts.Clear();
|
||||
Interlocked.Exchange(ref _nextContextId, 0);
|
||||
Interlocked.Exchange(ref _nextBatchId, 0);
|
||||
}
|
||||
|
||||
// AjmBatchInfo: buffer, offset, size, last_good_job, last_good_job_ra (5× u64).
|
||||
private const ulong AjmBatchInfoOffsetField = 8;
|
||||
private const ulong AjmBatchInfoSizeField = 16;
|
||||
private const ulong AjmBatchInfoLastGoodJobField = 24;
|
||||
private const ulong AjmJobRunSize = 64;
|
||||
private const ulong MaxSilentPcmBytes = 1 << 20;
|
||||
// AjmSidebandResult (8) + AjmSidebandStream (16) + AjmSidebandMFrame (8).
|
||||
private const int DecodeSidebandBytes = 32;
|
||||
|
||||
private static bool TryAppendBatchJob(CpuContext ctx, ulong infoAddress, ulong jobSize)
|
||||
{
|
||||
if (!TryReadUInt64(ctx, infoAddress, out var buffer) ||
|
||||
!TryReadUInt64(ctx, infoAddress + AjmBatchInfoOffsetField, out var offset) ||
|
||||
!TryReadUInt64(ctx, infoAddress + AjmBatchInfoSizeField, out var size))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (buffer == 0 || jobSize == 0 || offset > size || size - offset < jobSize)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var jobAddress = buffer + offset;
|
||||
ClearGuestMemory(ctx, jobAddress, jobSize);
|
||||
return TryWriteUInt64(ctx, infoAddress + AjmBatchInfoLastGoodJobField, jobAddress) &&
|
||||
TryWriteUInt64(ctx, infoAddress + AjmBatchInfoOffsetField, offset + jobSize);
|
||||
}
|
||||
|
||||
// AjmBatchError: int error_code; const void* job_addr; uint32_t cmd_offset; const void* job_ra;
|
||||
private const int AjmBatchErrorBytes = 24;
|
||||
|
||||
private static void ClearAjmBatchError(CpuContext ctx, ulong errorAddress)
|
||||
{
|
||||
if (errorAddress == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Span<byte> error = stackalloc byte[AjmBatchErrorBytes];
|
||||
error.Clear();
|
||||
_ = ctx.Memory.TryWrite(errorAddress, error);
|
||||
}
|
||||
|
||||
private static void WriteDecodeStreamResult(
|
||||
CpuContext ctx,
|
||||
ulong resultAddress,
|
||||
int inputConsumed,
|
||||
int outputWritten,
|
||||
ulong totalDecodedSamples,
|
||||
uint frames)
|
||||
{
|
||||
if (resultAddress == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Span<byte> sideband = stackalloc byte[DecodeSidebandBytes];
|
||||
sideband.Clear();
|
||||
// AjmSidebandResult.result / internal_result = 0 (OK)
|
||||
BinaryPrimitives.WriteInt32LittleEndian(sideband.Slice(8, 4), inputConsumed);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(sideband.Slice(12, 4), outputWritten);
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(sideband.Slice(16, 8), totalDecodedSamples);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(sideband.Slice(24, 4), frames);
|
||||
_ = ctx.Memory.TryWrite(resultAddress, sideband);
|
||||
}
|
||||
|
||||
private static void ClearGuestMemory(CpuContext ctx, ulong address, ulong byteCount)
|
||||
{
|
||||
if (address == 0 || byteCount == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var remaining = byteCount;
|
||||
var cursor = address;
|
||||
Span<byte> zero = stackalloc byte[256];
|
||||
while (remaining > 0)
|
||||
{
|
||||
var chunk = (int)Math.Min(remaining, (ulong)zero.Length);
|
||||
if (!ctx.Memory.TryWrite(cursor, zero[..chunk]))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
cursor += (ulong)chunk;
|
||||
remaining -= (ulong)chunk;
|
||||
}
|
||||
}
|
||||
|
||||
private static ulong ReadStackArg64(CpuContext ctx, int index)
|
||||
{
|
||||
var address = ctx[CpuRegister.Rsp] + sizeof(ulong) + ((ulong)index * sizeof(ulong));
|
||||
return TryReadUInt64(ctx, address, out var value) ? value : 0;
|
||||
}
|
||||
|
||||
private static bool TryReadUInt64(CpuContext ctx, ulong address, out ulong value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
|
||||
if (!ctx.Memory.TryRead(address, buffer))
|
||||
{
|
||||
value = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = BinaryPrimitives.ReadUInt64LittleEndian(buffer);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryWriteUInt64(CpuContext ctx, ulong address, ulong value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[sizeof(ulong)];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
||||
return ctx.Memory.TryWrite(address, buffer);
|
||||
}
|
||||
|
||||
private static void Trace(string message)
|
||||
|
||||
@@ -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,43 @@ 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 _loadAttempted;
|
||||
private static bool _availabilityReported;
|
||||
private static bool _frameBufferPresented;
|
||||
private static BinkFramePlayback? _playback;
|
||||
private static long _frameSerial;
|
||||
private static uint _presentationWidth = MaxHostVideoWidth;
|
||||
private static uint _presentationHeight = MaxHostVideoHeight;
|
||||
|
||||
internal static bool IsHostPlaybackActive
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
return _playback is not null || _frameBuffer is not null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static void SetPresentationSize(uint width, uint height)
|
||||
{
|
||||
if (width == 0 || height == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
_presentationWidth = Math.Min(width, MaxHostVideoWidth);
|
||||
_presentationHeight = Math.Min(height, MaxHostVideoHeight);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true only when movie skipping was explicitly requested. Without
|
||||
@@ -37,109 +65,106 @@ internal static class Bink2MovieBridge
|
||||
hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
|
||||
ResolveMode() == MovieMode.Skip;
|
||||
|
||||
internal static void ObserveGuestMovie(string hostPath)
|
||||
/// <summary>
|
||||
/// Starts or queues host decoding. Decoded frames are only exposed as a
|
||||
/// sampled guest texture; presentation and UI composition remain guest-owned.
|
||||
/// </summary>
|
||||
internal static bool ObserveGuestMovie(string hostPath)
|
||||
{
|
||||
if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
|
||||
!File.Exists(hostPath))
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
if (string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
return _playback is not null || _frameBuffer is not null;
|
||||
}
|
||||
|
||||
var mode = ResolveMode();
|
||||
if (mode == MovieMode.Dummy)
|
||||
if (mode is MovieMode.Guest or MovieMode.Skip)
|
||||
{
|
||||
AttachDummyMovieLocked(hostPath);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mode != MovieMode.Native)
|
||||
if (_playback is not null || _frameBuffer is not null)
|
||||
{
|
||||
return;
|
||||
if (PendingMoviePathSet.Add(hostPath))
|
||||
{
|
||||
PendingMoviePaths.Enqueue(hostPath);
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge queued: " +
|
||||
Path.GetFileName(hostPath));
|
||||
}
|
||||
return PendingMoviePathSet.Contains(hostPath);
|
||||
}
|
||||
|
||||
var adapter = GetAdapterLocked();
|
||||
if (adapter is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CloseActiveLocked();
|
||||
if (!adapter.TryOpen(hostPath, out var movie, out var info))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink2 bridge could not open movie '" +
|
||||
Path.GetFileName(hostPath) + "'.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsValid(info))
|
||||
{
|
||||
adapter.Close(movie);
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink2 bridge rejected invalid movie dimensions for '" +
|
||||
Path.GetFileName(hostPath) + "'.");
|
||||
return;
|
||||
}
|
||||
|
||||
_activePath = hostPath;
|
||||
_activeMovie = movie;
|
||||
_activeInfo = info;
|
||||
_frameBuffer = GC.AllocateUninitializedArray<byte>(GetFrameBufferLength(info));
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge attached: " + Path.GetFileName(hostPath) + " " +
|
||||
info.Width + "x" + info.Height + " @ " +
|
||||
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
|
||||
AttachMovieLocked(hostPath, mode);
|
||||
return string.Equals(_activePath, hostPath, StringComparison.OrdinalIgnoreCase) &&
|
||||
(_playback is not null || _frameBuffer is not null);
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryDecodeNextFrame(
|
||||
bool advanceClock,
|
||||
out byte[] pixels,
|
||||
out uint width,
|
||||
out uint height)
|
||||
out uint height,
|
||||
out bool advanced,
|
||||
out long frameSerial,
|
||||
out string hostPath)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
pixels = [];
|
||||
width = 0;
|
||||
height = 0;
|
||||
if (_adapter is null || _activeMovie == IntPtr.Zero || _frameBuffer is null)
|
||||
advanced = false;
|
||||
frameSerial = _frameSerial;
|
||||
hostPath = _activePath ?? string.Empty;
|
||||
|
||||
if (_playback is not null)
|
||||
{
|
||||
if (_usingDummyMovie && _frameBuffer is not null)
|
||||
if (!_playback.TryGetFrame(advanceClock, out pixels, out advanced))
|
||||
{
|
||||
pixels = _frameBuffer;
|
||||
width = _activeInfo.Width;
|
||||
height = _activeInfo.Height;
|
||||
return true;
|
||||
if (_playback.IsFinished)
|
||||
{
|
||||
var completedPath = _activePath;
|
||||
CloseActiveLocked();
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge completed: " +
|
||||
Path.GetFileName(completedPath));
|
||||
AttachNextQueuedMovieLocked();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
width = _activeInfo.Width;
|
||||
height = _activeInfo.Height;
|
||||
if (advanced)
|
||||
{
|
||||
frameSerial = ++_frameSerial;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
unsafe
|
||||
if (_frameBuffer is null)
|
||||
{
|
||||
fixed (byte* destination = _frameBuffer)
|
||||
{
|
||||
if (!_adapter.DecodeNextBgra(
|
||||
_activeMovie,
|
||||
(IntPtr)destination,
|
||||
_activeInfo.Width * 4,
|
||||
(uint)_frameBuffer.Length))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
pixels = _frameBuffer;
|
||||
width = _activeInfo.Width;
|
||||
height = _activeInfo.Height;
|
||||
advanced = !_frameBufferPresented;
|
||||
_frameBufferPresented = true;
|
||||
if (advanced)
|
||||
{
|
||||
frameSerial = ++_frameSerial;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -152,6 +177,52 @@ internal static class Bink2MovieBridge
|
||||
private static int GetFrameBufferLength(Bink2MovieInfo info) =>
|
||||
checked((int)((ulong)info.Width * info.Height * 4));
|
||||
|
||||
private static void AttachMovieLocked(string hostPath, MovieMode mode)
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case MovieMode.Dummy:
|
||||
AttachDummyMovieLocked(hostPath);
|
||||
return;
|
||||
case MovieMode.Ffmpeg:
|
||||
AttachFfmpegMovieLocked(hostPath);
|
||||
return;
|
||||
case MovieMode.Native:
|
||||
AttachNativeMovieLocked(hostPath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AttachNativeMovieLocked(string hostPath)
|
||||
{
|
||||
if (!FfmpegNativeBinkFrameSource.TryOpen(
|
||||
hostPath, _presentationWidth, _presentationHeight, out var source) ||
|
||||
source is null)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink2 bridge could not open movie '" +
|
||||
Path.GetFileName(hostPath) + "'.");
|
||||
return;
|
||||
}
|
||||
|
||||
var info = new Bink2MovieInfo(
|
||||
source.Width, source.Height, source.FramesPerSecondNumerator, source.FramesPerSecondDenominator);
|
||||
if (!IsValid(info))
|
||||
{
|
||||
source.Dispose();
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][WARN] Bink2 bridge rejected invalid movie dimensions for '" +
|
||||
Path.GetFileName(hostPath) + "'.");
|
||||
return;
|
||||
}
|
||||
|
||||
AttachPlaybackLocked(hostPath, info, source);
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge attached: " + Path.GetFileName(hostPath) + " " +
|
||||
info.Width + "x" + info.Height + " @ " +
|
||||
info.FramesPerSecondNumerator + "/" + info.FramesPerSecondDenominator + " fps.");
|
||||
}
|
||||
|
||||
private static MovieMode ResolveMode()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("SHARPEMU_BINK_MODE");
|
||||
@@ -170,15 +241,22 @@ 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: FfmpegNativeBinkFrameSource.TryOpen degrades
|
||||
// gracefully (falls back to the guest's own decode, logging one
|
||||
// informational line) if the FFmpeg libraries SharpEmu.CLI.csproj
|
||||
// downloads next to the executable are genuinely unavailable, so
|
||||
// defaulting to Native unconditionally is safe.
|
||||
return MovieMode.Native;
|
||||
}
|
||||
|
||||
private static void AttachDummyMovieLocked(string hostPath)
|
||||
@@ -195,22 +273,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 +336,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;
|
||||
}
|
||||
@@ -244,80 +362,23 @@ internal static class Bink2MovieBridge
|
||||
}
|
||||
}
|
||||
|
||||
private static NativeAdapter? GetAdapterLocked()
|
||||
{
|
||||
if (_loadAttempted)
|
||||
{
|
||||
return _adapter;
|
||||
}
|
||||
|
||||
_loadAttempted = true;
|
||||
foreach (var candidate in EnumerateAdapterCandidates())
|
||||
{
|
||||
if (!NativeLibrary.TryLoad(candidate, out var library))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (NativeAdapter.TryCreate(library, out var adapter))
|
||||
{
|
||||
_adapter = adapter;
|
||||
Console.Error.WriteLine("[LOADER][INFO] Bink2 bridge loaded: " + candidate);
|
||||
return adapter;
|
||||
}
|
||||
|
||||
NativeLibrary.Free(library);
|
||||
}
|
||||
|
||||
if (!_availabilityReported)
|
||||
{
|
||||
_availabilityReported = true;
|
||||
Console.Error.WriteLine(
|
||||
"[LOADER][INFO] Bink2 bridge unavailable; install the licensed adapter and set SHARPEMU_BINK2_BRIDGE.");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> EnumerateAdapterCandidates()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("SHARPEMU_BINK2_BRIDGE");
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
yield return configured;
|
||||
}
|
||||
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
if (OperatingSystem.IsMacOS())
|
||||
{
|
||||
yield return Path.Combine(baseDirectory, "libsharpemu_bink2_bridge.dylib");
|
||||
}
|
||||
else if (OperatingSystem.IsWindows())
|
||||
{
|
||||
yield return Path.Combine(baseDirectory, "sharpemu_bink2_bridge.dll");
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return Path.Combine(baseDirectory, "libsharpemu_bink2_bridge.so");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CloseActiveLocked()
|
||||
{
|
||||
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,66 +404,222 @@ internal static class Bink2MovieBridge
|
||||
Skip,
|
||||
Dummy,
|
||||
Native,
|
||||
Ffmpeg,
|
||||
}
|
||||
|
||||
private sealed class NativeAdapter
|
||||
private static readonly Queue<string> PendingMoviePaths = new();
|
||||
private static readonly HashSet<string> PendingMoviePathSet =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private static void AttachNextQueuedMovieLocked()
|
||||
{
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int OpenUtf8Delegate(IntPtr pathUtf8, out IntPtr movie, out Bink2MovieInfo info);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate int DecodeNextBgraDelegate(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
private delegate void CloseDelegate(IntPtr movie);
|
||||
|
||||
private readonly OpenUtf8Delegate _openUtf8;
|
||||
private readonly DecodeNextBgraDelegate _decodeNextBgra;
|
||||
private readonly CloseDelegate _close;
|
||||
|
||||
private NativeAdapter(
|
||||
OpenUtf8Delegate openUtf8,
|
||||
DecodeNextBgraDelegate decodeNextBgra,
|
||||
CloseDelegate close)
|
||||
while (PendingMoviePaths.Count > 0)
|
||||
{
|
||||
_openUtf8 = openUtf8;
|
||||
_decodeNextBgra = decodeNextBgra;
|
||||
_close = close;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryCreate(IntPtr library, out NativeAdapter? adapter)
|
||||
}
|
||||
// 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)
|
||||
{
|
||||
adapter = null;
|
||||
if (!NativeLibrary.TryGetExport(library, "sharpemu_bink2_open_utf8", out var open) ||
|
||||
!NativeLibrary.TryGetExport(library, "sharpemu_bink2_decode_next_bgra", out var decode) ||
|
||||
!NativeLibrary.TryGetExport(library, "sharpemu_bink2_close", out var close))
|
||||
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;
|
||||
}
|
||||
|
||||
adapter = new NativeAdapter(
|
||||
Marshal.GetDelegateForFunctionPointer<OpenUtf8Delegate>(open),
|
||||
Marshal.GetDelegateForFunctionPointer<DecodeNextBgraDelegate>(decode),
|
||||
Marshal.GetDelegateForFunctionPointer<CloseDelegate>(close));
|
||||
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;
|
||||
}
|
||||
|
||||
internal bool TryOpen(string path, out IntPtr movie, out Bink2MovieInfo info)
|
||||
catch (Exception exception) when (
|
||||
exception is IOException or EndOfStreamException or OverflowException)
|
||||
{
|
||||
var utf8 = Marshal.StringToCoTaskMemUTF8(path);
|
||||
try
|
||||
{
|
||||
return _openUtf8(utf8, out movie, out info) != 0 && movie != IntPtr.Zero;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeCoTaskMem(utf8);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly struct BinkGuestCompletionShim
|
||||
{
|
||||
private readonly uint _fileSizeMinusHeader;
|
||||
private readonly uint _largestFrameSize;
|
||||
|
||||
internal BinkGuestCompletionShim(uint fileSizeMinusHeader, uint largestFrameSize)
|
||||
{
|
||||
_fileSizeMinusHeader = fileSizeMinusHeader;
|
||||
_largestFrameSize = largestFrameSize;
|
||||
}
|
||||
|
||||
internal bool DecodeNextBgra(IntPtr movie, IntPtr destination, uint stride, uint destinationBytes) =>
|
||||
_decodeNextBgra(movie, destination, stride, destinationBytes) != 0;
|
||||
/// <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;
|
||||
}
|
||||
|
||||
internal void Close(IntPtr movie) => _close(movie);
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using FFmpeg.AutoGen;
|
||||
|
||||
namespace SharpEmu.Libs.Bink;
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a .bk2 (or any FFmpeg-readable movie) directly via FFmpeg's C API
|
||||
/// through FFmpeg.AutoGen P/Invoke bindings against the dynamically linked
|
||||
/// libraries published by github.com/sharpemu/ffmpeg-core -- no native C
|
||||
/// bridge of our own to build. See docs/bink2-bridge.md.
|
||||
/// </summary>
|
||||
internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
|
||||
{
|
||||
private AVFormatContext* _formatContext;
|
||||
private AVCodecContext* _codecContext;
|
||||
private SwsContext* _swsContext;
|
||||
private AVFrame* _frame;
|
||||
private AVPacket* _packet;
|
||||
private readonly int _videoStreamIndex;
|
||||
private bool _draining;
|
||||
private int _disposed;
|
||||
|
||||
public uint Width { get; }
|
||||
|
||||
public uint Height { get; }
|
||||
|
||||
public uint FramesPerSecondNumerator { get; }
|
||||
|
||||
public uint FramesPerSecondDenominator { get; }
|
||||
|
||||
private FfmpegNativeBinkFrameSource(
|
||||
AVFormatContext* formatContext,
|
||||
AVCodecContext* codecContext,
|
||||
int videoStreamIndex,
|
||||
uint width,
|
||||
uint height,
|
||||
uint framesPerSecondNumerator,
|
||||
uint framesPerSecondDenominator)
|
||||
{
|
||||
_formatContext = formatContext;
|
||||
_codecContext = codecContext;
|
||||
_videoStreamIndex = videoStreamIndex;
|
||||
Width = width;
|
||||
Height = height;
|
||||
FramesPerSecondNumerator = framesPerSecondNumerator;
|
||||
FramesPerSecondDenominator = framesPerSecondDenominator;
|
||||
_frame = ffmpeg.av_frame_alloc();
|
||||
_packet = ffmpeg.av_packet_alloc();
|
||||
}
|
||||
|
||||
private static bool _rootPathInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// Points FFmpeg.AutoGen at the FFmpeg shared libraries SharpEmu.CLI
|
||||
/// downloads next to the executable (see SharpEmu.CLI.csproj's
|
||||
/// FetchFfmpegRuntime target); kept as loose files rather than embedded
|
||||
/// in the single-file bundle so the OS loader can resolve the normal
|
||||
/// inter-library dependencies (avcodec depends on avutil, etc.) itself.
|
||||
/// </summary>
|
||||
private static void EnsureRootPathInitialized()
|
||||
{
|
||||
if (_rootPathInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_rootPathInitialized = true;
|
||||
// SharpEmu.CLI.csproj publishes FFmpeg's shared libraries into a
|
||||
// "plugins" subfolder next to the executable rather than flat beside
|
||||
// it (see NativeLibraryFolderName in SharpEmu.CLI.csproj).
|
||||
ffmpeg.RootPath = Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||
|
||||
// ffmpeg's static constructor runs DynamicallyLoadedBindings.Initialize()
|
||||
// itself, but that constructor fires on first touch of the ffmpeg type --
|
||||
// which is the RootPath assignment above -- so it binds against the
|
||||
// default (empty) RootPath before the assignment's own setter body runs.
|
||||
// Every function resolved during that first pass permanently throws
|
||||
// NotSupportedException. Re-running Initialize() now, with RootPath
|
||||
// actually set, rebinds everything against the real search path.
|
||||
DynamicallyLoadedBindings.Initialize();
|
||||
}
|
||||
|
||||
internal static bool TryOpen(
|
||||
string path,
|
||||
uint maximumWidth,
|
||||
uint maximumHeight,
|
||||
out FfmpegNativeBinkFrameSource? source)
|
||||
{
|
||||
source = null;
|
||||
EnsureRootPathInitialized();
|
||||
|
||||
AVFormatContext* formatContext = null;
|
||||
AVCodecContext* codecContext = null;
|
||||
try
|
||||
{
|
||||
if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ffmpeg.avformat_find_stream_info(formatContext, null) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
AVCodec* decoder = null;
|
||||
var videoStreamIndex = ffmpeg.av_find_best_stream(
|
||||
formatContext, AVMediaType.AVMEDIA_TYPE_VIDEO, -1, -1, &decoder, 0);
|
||||
if (videoStreamIndex < 0 || decoder is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var stream = formatContext->streams[videoStreamIndex];
|
||||
codecContext = ffmpeg.avcodec_alloc_context3(decoder);
|
||||
if (codecContext is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ffmpeg.avcodec_parameters_to_context(codecContext, stream->codecpar) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
codecContext->thread_count = 0;
|
||||
codecContext->thread_type = ffmpeg.FF_THREAD_FRAME | ffmpeg.FF_THREAD_SLICE;
|
||||
if (ffmpeg.avcodec_open2(codecContext, decoder, null) < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (codecContext->width <= 0 || codecContext->height <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var frameRate = ffmpeg.av_guess_frame_rate(formatContext, stream, null);
|
||||
if (frameRate.num <= 0 || frameRate.den <= 0)
|
||||
{
|
||||
frameRate = stream->avg_frame_rate;
|
||||
}
|
||||
if (frameRate.num <= 0 || frameRate.den <= 0)
|
||||
{
|
||||
frameRate = stream->r_frame_rate;
|
||||
}
|
||||
if (frameRate.num <= 0 || frameRate.den <= 0)
|
||||
{
|
||||
frameRate = new AVRational { num = 30, den = 1 };
|
||||
}
|
||||
|
||||
var outputWidth = (uint)codecContext->width;
|
||||
var outputHeight = (uint)codecContext->height;
|
||||
if (maximumWidth > 0 && maximumHeight > 0 &&
|
||||
(outputWidth > maximumWidth || outputHeight > maximumHeight))
|
||||
{
|
||||
if ((ulong)outputWidth * maximumHeight > (ulong)outputHeight * maximumWidth)
|
||||
{
|
||||
outputHeight = (uint)((ulong)outputHeight * maximumWidth / outputWidth);
|
||||
outputWidth = maximumWidth;
|
||||
}
|
||||
else
|
||||
{
|
||||
outputWidth = (uint)((ulong)outputWidth * maximumHeight / outputHeight);
|
||||
outputHeight = maximumHeight;
|
||||
}
|
||||
|
||||
outputWidth = Math.Max(1, outputWidth);
|
||||
outputHeight = Math.Max(1, outputHeight);
|
||||
}
|
||||
|
||||
source = new FfmpegNativeBinkFrameSource(
|
||||
formatContext,
|
||||
codecContext,
|
||||
videoStreamIndex,
|
||||
outputWidth,
|
||||
outputHeight,
|
||||
(uint)frameRate.num,
|
||||
(uint)frameRate.den);
|
||||
formatContext = null;
|
||||
codecContext = null;
|
||||
return true;
|
||||
}
|
||||
catch (DllNotFoundException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (codecContext is not null)
|
||||
{
|
||||
ffmpeg.avcodec_free_context(&codecContext);
|
||||
}
|
||||
|
||||
if (formatContext is not null)
|
||||
{
|
||||
ffmpeg.avformat_close_input(&formatContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryDecodeNextFrame(Span<byte> destination)
|
||||
{
|
||||
var stride = checked((int)(Width * 4));
|
||||
var required = (long)stride * Height;
|
||||
if (destination.Length < required)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryReceiveFrame())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_swsContext = ffmpeg.sws_getCachedContext(
|
||||
_swsContext,
|
||||
_frame->width,
|
||||
_frame->height,
|
||||
(AVPixelFormat)_frame->format,
|
||||
(int)Width,
|
||||
(int)Height,
|
||||
AVPixelFormat.AV_PIX_FMT_BGRA,
|
||||
ffmpeg.SWS_FAST_BILINEAR,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
if (_swsContext is null)
|
||||
{
|
||||
ffmpeg.av_frame_unref(_frame);
|
||||
return false;
|
||||
}
|
||||
|
||||
fixed (byte* destinationPointer = destination)
|
||||
{
|
||||
var destinationPlanes = new byte*[4] { destinationPointer, null, null, null };
|
||||
var destinationStrides = new int[4] { stride, 0, 0, 0 };
|
||||
var convertedRows = ffmpeg.sws_scale(
|
||||
_swsContext,
|
||||
_frame->data,
|
||||
_frame->linesize,
|
||||
0,
|
||||
_frame->height,
|
||||
destinationPlanes,
|
||||
destinationStrides);
|
||||
ffmpeg.av_frame_unref(_frame);
|
||||
return convertedRows == (int)Height;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryReceiveFrame()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var receiveResult = ffmpeg.avcodec_receive_frame(_codecContext, _frame);
|
||||
if (receiveResult >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (receiveResult == ffmpeg.AVERROR_EOF)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (receiveResult != ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_draining)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryFeedPacket())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryFeedPacket()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var readResult = ffmpeg.av_read_frame(_formatContext, _packet);
|
||||
if (readResult < 0)
|
||||
{
|
||||
_draining = true;
|
||||
ffmpeg.avcodec_send_packet(_codecContext, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_packet->stream_index != _videoStreamIndex)
|
||||
{
|
||||
ffmpeg.av_packet_unref(_packet);
|
||||
continue;
|
||||
}
|
||||
|
||||
var sendResult = ffmpeg.avcodec_send_packet(_codecContext, _packet);
|
||||
ffmpeg.av_packet_unref(_packet);
|
||||
if (sendResult < 0 && sendResult != ffmpeg.AVERROR(ffmpeg.EAGAIN))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_swsContext is not null)
|
||||
{
|
||||
ffmpeg.sws_freeContext(_swsContext);
|
||||
_swsContext = null;
|
||||
}
|
||||
|
||||
if (_packet is not null)
|
||||
{
|
||||
var packet = _packet;
|
||||
ffmpeg.av_packet_free(&packet);
|
||||
_packet = null;
|
||||
}
|
||||
|
||||
if (_frame is not null)
|
||||
{
|
||||
var frame = _frame;
|
||||
ffmpeg.av_frame_free(&frame);
|
||||
_frame = null;
|
||||
}
|
||||
|
||||
if (_codecContext is not null)
|
||||
{
|
||||
var codecContext = _codecContext;
|
||||
ffmpeg.avcodec_free_context(&codecContext);
|
||||
_codecContext = null;
|
||||
}
|
||||
|
||||
if (_formatContext is not null)
|
||||
{
|
||||
var formatContext = _formatContext;
|
||||
ffmpeg.avformat_close_input(&formatContext);
|
||||
_formatContext = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,9 @@ public static partial class KernelMemoryCompatExports
|
||||
|
||||
private static readonly object _fdGate = new();
|
||||
private static readonly Dictionary<int, FileStream> _openFiles = new();
|
||||
private static readonly Dictionary<int, Bink2MovieBridge.BinkGuestCompletionShim>
|
||||
_binkGuestCompletionShims = new();
|
||||
private static readonly Dictionary<int, string> _observedBinkGuestFiles = new();
|
||||
private static readonly Dictionary<int, OpenDirectory> _openDirectories = new();
|
||||
private static readonly object _libcAllocGate = new();
|
||||
private static readonly object _memoryGate = new();
|
||||
@@ -268,13 +271,13 @@ public static partial class KernelMemoryCompatExports
|
||||
}
|
||||
|
||||
_nextVirtualAddress = Math.Max(_nextVirtualAddress, address + mappedLength);
|
||||
_mappedRegions[address] = new MappedRegion(
|
||||
ReplaceMappedRegionRangeLocked(new MappedRegion(
|
||||
address,
|
||||
mappedLength,
|
||||
OrbisProtCpuReadWrite,
|
||||
IsFlexible: false,
|
||||
IsDirect: false,
|
||||
DirectStart: 0);
|
||||
DirectStart: 0));
|
||||
}
|
||||
|
||||
for (ulong offset = 0; offset < mappedLength;)
|
||||
@@ -300,13 +303,13 @@ public static partial class KernelMemoryCompatExports
|
||||
|
||||
lock (_memoryGate)
|
||||
{
|
||||
_mappedRegions[address] = new MappedRegion(
|
||||
ReplaceMappedRegionRangeLocked(new MappedRegion(
|
||||
address,
|
||||
length,
|
||||
Protection: 0,
|
||||
IsFlexible: false,
|
||||
IsDirect: false,
|
||||
DirectStart: 0);
|
||||
DirectStart: 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1468,6 +1471,14 @@ public static partial class KernelMemoryCompatExports
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
|
||||
}
|
||||
|
||||
Bink2MovieBridge.BinkGuestCompletionShim binkCompletionShim = default;
|
||||
var observedBinkMovie = false;
|
||||
var useBinkCompletionShim = access == FileAccess.Read &&
|
||||
Bink2MovieBridge.TryTakeOverGuestMovie(
|
||||
hostPath,
|
||||
out binkCompletionShim,
|
||||
out observedBinkMovie);
|
||||
|
||||
if (IsMutatingOpen(flags) && IsReadOnlyGuestMutationPath(guestPath))
|
||||
{
|
||||
LogOpenTrace($"_open readonly path='{guestPath}' host='{hostPath}' flags=0x{flags:X8}");
|
||||
@@ -1516,13 +1527,22 @@ public static partial class KernelMemoryCompatExports
|
||||
lock (_fdGate)
|
||||
{
|
||||
_openFiles[fd] = stream;
|
||||
if (useBinkCompletionShim)
|
||||
{
|
||||
_binkGuestCompletionShims[fd] = binkCompletionShim;
|
||||
}
|
||||
if (observedBinkMovie)
|
||||
{
|
||||
_observedBinkGuestFiles[fd] = hostPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Bink is linked directly into some games, so there is no media
|
||||
// import for the HLE codec layer to intercept. The successful
|
||||
// guest file open is the stable boundary at which the optional
|
||||
// host Bink bridge can attach to the same movie.
|
||||
Bink2MovieBridge.ObserveGuestMovie(hostPath);
|
||||
if (useBinkCompletionShim)
|
||||
{
|
||||
LogOpenTrace(
|
||||
"_open bink-host-shim path='" + guestPath + "' host='" + hostPath +
|
||||
"' flags=0x" + flags.ToString("X8") + " fd=" + fd);
|
||||
}
|
||||
|
||||
if (IsMutatingOpen(flags))
|
||||
{
|
||||
@@ -2053,10 +2073,21 @@ public static partial class KernelMemoryCompatExports
|
||||
}
|
||||
|
||||
FileStream? stream;
|
||||
var notifyBinkClose = false;
|
||||
string? observedBinkPath = null;
|
||||
lock (_fdGate)
|
||||
{
|
||||
if (_openFiles.Remove(fd, out stream))
|
||||
{
|
||||
_binkGuestCompletionShims.Remove(fd);
|
||||
if (_observedBinkGuestFiles.Remove(fd, out observedBinkPath))
|
||||
{
|
||||
notifyBinkClose = !_observedBinkGuestFiles.Values.Any(path =>
|
||||
string.Equals(
|
||||
path,
|
||||
observedBinkPath,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
else if (_openDirectories.Remove(fd))
|
||||
{
|
||||
@@ -2069,6 +2100,10 @@ public static partial class KernelMemoryCompatExports
|
||||
}
|
||||
}
|
||||
|
||||
if (notifyBinkClose)
|
||||
{
|
||||
Bink2MovieBridge.NotifyGuestMovieClosed(observedBinkPath!);
|
||||
}
|
||||
stream.Dispose();
|
||||
ctx[CpuRegister.Rax] = 0;
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_OK;
|
||||
@@ -2096,9 +2131,12 @@ public static partial class KernelMemoryCompatExports
|
||||
}
|
||||
|
||||
FileStream? stream;
|
||||
Bink2MovieBridge.BinkGuestCompletionShim completionShim = default;
|
||||
var useBinkCompletionShim = false;
|
||||
lock (_fdGate)
|
||||
{
|
||||
_openFiles.TryGetValue(fd, out stream);
|
||||
useBinkCompletionShim = _binkGuestCompletionShims.TryGetValue(fd, out completionShim);
|
||||
}
|
||||
|
||||
if (stream is null)
|
||||
@@ -2118,6 +2156,17 @@ public static partial class KernelMemoryCompatExports
|
||||
|
||||
var buffer = GC.AllocateUninitializedArray<byte>(requested);
|
||||
var read = stream.Read(buffer, 0, requested);
|
||||
if (read > 0 && useBinkCompletionShim)
|
||||
{
|
||||
// The patched NumFrames field is what tells the guest "this
|
||||
// movie is fully consumed" - hold that specific read until the
|
||||
// host has actually finished showing it, so guest-side game
|
||||
// logic can't race ahead of what's still on screen.
|
||||
if (completionShim.Patch(positionBefore, buffer.AsSpan(0, read)))
|
||||
{
|
||||
Bink2MovieBridge.WaitForHostPlaybackToFinish(stream.Name);
|
||||
}
|
||||
}
|
||||
if (read > 0 && !ctx.Memory.TryWrite(bufferAddress, buffer.AsSpan(0, read)))
|
||||
{
|
||||
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT;
|
||||
@@ -3091,13 +3140,13 @@ public static partial class KernelMemoryCompatExports
|
||||
}
|
||||
|
||||
_nextVirtualAddress = Math.Max(_nextVirtualAddress, mappedAddress + length);
|
||||
_mappedRegions[mappedAddress] = new MappedRegion(
|
||||
ReplaceMappedRegionRangeLocked(new MappedRegion(
|
||||
mappedAddress,
|
||||
length,
|
||||
protection,
|
||||
IsFlexible: false,
|
||||
IsDirect: true,
|
||||
DirectStart: directMemoryStart);
|
||||
DirectStart: directMemoryStart));
|
||||
}
|
||||
|
||||
if (!ctx.TryWriteUInt64(inOutAddressPointer, mappedAddress))
|
||||
@@ -3183,13 +3232,13 @@ public static partial class KernelMemoryCompatExports
|
||||
|
||||
_nextVirtualAddress = Math.Max(_nextVirtualAddress, mappedAddress + length);
|
||||
_allocatedFlexibleBytes = Math.Min(FlexibleMemorySizeBytes, _allocatedFlexibleBytes + length);
|
||||
_mappedRegions[mappedAddress] = new MappedRegion(
|
||||
ReplaceMappedRegionRangeLocked(new MappedRegion(
|
||||
mappedAddress,
|
||||
length,
|
||||
protection,
|
||||
IsFlexible: true,
|
||||
IsDirect: false,
|
||||
DirectStart: 0);
|
||||
DirectStart: 0));
|
||||
}
|
||||
|
||||
if (!ctx.TryWriteUInt64(inOutAddressPointer, mappedAddress))
|
||||
|
||||
@@ -25,6 +25,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FFmpeg.AutoGen" />
|
||||
<PackageReference Include="Silk.NET.Input" />
|
||||
<PackageReference Include="Silk.NET.Vulkan" />
|
||||
<PackageReference Include="Silk.NET.Vulkan.Extensions.EXT" />
|
||||
|
||||
@@ -45,6 +45,25 @@ public static class SystemServiceExports
|
||||
: ctx.SetReturn((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "8Lo6Zv94aho",
|
||||
ExportName = "sceSystemServiceDisableNoticeScreenSkipFlagAutoSet",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceSystemService")]
|
||||
public static int SystemServiceDisableNoticeScreenSkipFlagAutoSet(CpuContext ctx) =>
|
||||
ctx.SetReturn(0);
|
||||
|
||||
// Settings entry calls this immediately before spawning SaveModTime/Load
|
||||
// threads. An unresolved stub returns NOT_FOUND and the title can stall in
|
||||
// that path; accept the write and report success.
|
||||
[SysAbiExport(
|
||||
Nid = "Q3utJvma4Mo",
|
||||
ExportName = "sceSystemServiceSetNoticeScreenSkipFlag",
|
||||
Target = Generation.Gen5,
|
||||
LibraryName = "libSceSystemService")]
|
||||
public static int SystemServiceSetNoticeScreenSkipFlag(CpuContext ctx) =>
|
||||
ctx.SetReturn(0);
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "4veE0XiIugA",
|
||||
ExportName = "sceSystemServiceGetMainAppTitleId",
|
||||
|
||||
@@ -2230,7 +2230,6 @@ internal static unsafe class VulkanVideoPresenter
|
||||
if (IsGuestWorkCompletedLocked(pending.RequiredGuestWorkSequence))
|
||||
{
|
||||
presentation = _pendingGuestImagePresentations.Dequeue();
|
||||
TryReplaceWithBinkFrame(ref presentation);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2263,29 +2262,10 @@ internal static unsafe class VulkanVideoPresenter
|
||||
}
|
||||
|
||||
presentation = latest;
|
||||
TryReplaceWithBinkFrame(ref presentation);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryReplaceWithBinkFrame(ref Presentation presentation)
|
||||
{
|
||||
if (!Bink2MovieBridge.TryDecodeNextFrame(out var pixels, out var width, out var height))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
presentation = new Presentation(
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
presentation.Sequence,
|
||||
GuestDrawKind.None,
|
||||
TranslatedDraw: null,
|
||||
presentation.RequiredGuestWorkSequence,
|
||||
IsSplash: false);
|
||||
}
|
||||
|
||||
private static readonly HashSet<long> _tracedGuestImagePresentRejections = new();
|
||||
|
||||
private static bool HasPendingGuestPresentation(long presentedSequence)
|
||||
@@ -2835,6 +2815,40 @@ internal static unsafe class VulkanVideoPresenter
|
||||
private VkBuffer _stagingBuffer;
|
||||
private DeviceMemory _stagingMemory;
|
||||
private ulong _stagingSize;
|
||||
private VkBuffer[] _frameUploadBuffers = [];
|
||||
private DeviceMemory[] _frameUploadMemory = [];
|
||||
private nint[] _frameUploadMapped = [];
|
||||
private Image _hostMovieImage;
|
||||
private DeviceMemory _hostMovieImageMemory;
|
||||
private ImageView _hostMovieImageView;
|
||||
private uint _hostMovieImageWidth;
|
||||
private uint _hostMovieImageHeight;
|
||||
private Format _hostMovieImageFormat;
|
||||
private uint _hostMovieImageDstSelect;
|
||||
private bool _hostMovieImageInitialized;
|
||||
private Image _hostMovieChromaImage;
|
||||
private DeviceMemory _hostMovieChromaImageMemory;
|
||||
private ImageView _hostMovieChromaImageView;
|
||||
private uint _hostMovieChromaImageWidth;
|
||||
private uint _hostMovieChromaImageHeight;
|
||||
private uint _hostMovieChromaImageDstSelect;
|
||||
private bool _hostMovieChromaImageInitialized;
|
||||
private byte[]? _hostMovieFramePixels;
|
||||
private byte[]? _hostMovieLumaPixels;
|
||||
private byte[]? _hostMovieChromaPixels;
|
||||
private uint _hostMovieFrameWidth;
|
||||
private uint _hostMovieFrameHeight;
|
||||
private long _hostMovieFrameSerial;
|
||||
private long _hostMovieConvertedFrameSerial = -1;
|
||||
private long _hostMovieLumaUploadedFrameSerial = -1;
|
||||
private long _hostMovieChromaUploadedFrameSerial = -1;
|
||||
private string? _hostMovieFramePath;
|
||||
private ulong _hostMovieLumaTextureAddress;
|
||||
private ulong _hostMovieChromaTextureAddress;
|
||||
private uint _hostMovieLumaDstSelect;
|
||||
private uint _hostMovieChromaDstSelect;
|
||||
private readonly HashSet<string> _tracedHostMovieTextureBindings =
|
||||
new(StringComparer.Ordinal);
|
||||
// Perf overlay: CPU-rasterized panel copied through per-slot staging
|
||||
// buffers into one image, then blitted onto the swapchain.
|
||||
private Image _overlayImage;
|
||||
@@ -3027,6 +3041,9 @@ internal static unsafe class VulkanVideoPresenter
|
||||
public bool OwnsStorage;
|
||||
public bool IsStorage;
|
||||
public bool Cached;
|
||||
public bool IsHostMovie;
|
||||
public int HostMoviePlane = -1;
|
||||
public long HostMovieFrameSerial;
|
||||
public ulong CpuContentFingerprint;
|
||||
public bool UpdatesCpuContent;
|
||||
public GuestSampler SamplerState;
|
||||
@@ -4328,6 +4345,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
var surfaceFormat = ChooseSurfaceFormat(formats);
|
||||
_swapchainFormat = surfaceFormat.Format;
|
||||
_extent = ChooseExtent(capabilities);
|
||||
Bink2MovieBridge.SetPresentationSize(_extent.Width, _extent.Height);
|
||||
var presentMode = ChoosePresentMode();
|
||||
var imageCount = capabilities.MinImageCount + 1;
|
||||
if (capabilities.MaxImageCount != 0)
|
||||
@@ -4474,6 +4492,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
_presentationCommandBuffer = _commandBuffer;
|
||||
|
||||
CreateStagingBuffer((ulong)_extent.Width * _extent.Height * 4);
|
||||
CreateFrameUploadBuffers(_stagingSize);
|
||||
CreateOverlayResources();
|
||||
}
|
||||
|
||||
@@ -6076,10 +6095,15 @@ internal static unsafe class VulkanVideoPresenter
|
||||
}
|
||||
}
|
||||
|
||||
var hostMovieTextures = FindHostMovieTextureBindings(draw.Textures);
|
||||
for (var index = 0; index < draw.Textures.Count; index++)
|
||||
{
|
||||
var texture = draw.Textures[index];
|
||||
var resolved = ResolveTextureResource(texture);
|
||||
var resolved = index == hostMovieTextures.Luma
|
||||
? CreateHostMovieTextureResource(texture, plane: 0)
|
||||
: index == hostMovieTextures.Chroma
|
||||
? CreateHostMovieTextureResource(texture, plane: 1)
|
||||
: ResolveTextureResource(texture);
|
||||
var feedbackTarget = !texture.IsStorage
|
||||
? feedbackTargets?.FirstOrDefault(target =>
|
||||
ReferenceEquals(resolved.GuestImage, target))
|
||||
@@ -6934,6 +6958,343 @@ internal static unsafe class VulkanVideoPresenter
|
||||
}
|
||||
}
|
||||
|
||||
private void PumpHostMovieFrame()
|
||||
{
|
||||
if (!Bink2MovieBridge.TryDecodeNextFrame(
|
||||
advanceClock: _hostMovieLumaTextureAddress != 0 &&
|
||||
_hostMovieChromaTextureAddress != 0,
|
||||
out var pixels,
|
||||
out var width,
|
||||
out var height,
|
||||
out var advanced,
|
||||
out var frameSerial,
|
||||
out var hostPath))
|
||||
{
|
||||
// Keep the last decoded image until a replacement arrives.
|
||||
// Movie sessions are queued asynchronously; clearing here
|
||||
// exposes the guest decoder's neutral surfaces between the
|
||||
// final frame of one movie and the first frame of the next.
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(
|
||||
_hostMovieFramePath,
|
||||
hostPath,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_hostMovieFramePath = hostPath;
|
||||
_hostMovieLumaTextureAddress = 0;
|
||||
_hostMovieChromaTextureAddress = 0;
|
||||
_hostMovieLumaDstSelect = 0;
|
||||
_hostMovieChromaDstSelect = 0;
|
||||
_hostMovieConvertedFrameSerial = -1;
|
||||
_hostMovieLumaUploadedFrameSerial = -1;
|
||||
_hostMovieChromaUploadedFrameSerial = -1;
|
||||
}
|
||||
|
||||
if (!advanced && _hostMovieFramePixels is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_hostMovieFramePixels = pixels;
|
||||
_hostMovieFrameWidth = width;
|
||||
_hostMovieFrameHeight = height;
|
||||
_hostMovieFrameSerial = frameSerial;
|
||||
}
|
||||
|
||||
private readonly record struct HostMovieTextureBindings(int Luma, int Chroma)
|
||||
{
|
||||
public static HostMovieTextureBindings None { get; } = new(-1, -1);
|
||||
}
|
||||
|
||||
private HostMovieTextureBindings FindHostMovieTextureBindings(
|
||||
IReadOnlyList<GuestDrawTexture> textures)
|
||||
{
|
||||
if (_hostMovieFramePixels is null ||
|
||||
_hostMovieFrameWidth == 0 ||
|
||||
_hostMovieFrameHeight == 0)
|
||||
{
|
||||
return HostMovieTextureBindings.None;
|
||||
}
|
||||
|
||||
if (_hostMovieLumaTextureAddress != 0 &&
|
||||
_hostMovieChromaTextureAddress != 0)
|
||||
{
|
||||
var lumaIndex = -1;
|
||||
var chromaIndex = -1;
|
||||
for (var index = 0; index < textures.Count; index++)
|
||||
{
|
||||
if (textures[index].Address == _hostMovieLumaTextureAddress)
|
||||
{
|
||||
lumaIndex = index;
|
||||
}
|
||||
else if (textures[index].Address == _hostMovieChromaTextureAddress)
|
||||
{
|
||||
chromaIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
if (lumaIndex >= 0 && chromaIndex >= 0)
|
||||
{
|
||||
return RememberHostMovieTextureMappings(
|
||||
textures,
|
||||
lumaIndex,
|
||||
chromaIndex);
|
||||
}
|
||||
|
||||
// Bluepoint alternates decoder output between multiple Y/UV
|
||||
// surface pairs. Fall through and discover the active pair
|
||||
// instead of sampling the stale guest surface on every other
|
||||
// movie draw.
|
||||
}
|
||||
|
||||
var bestLumaIndex = -1;
|
||||
var bestChromaIndex = -1;
|
||||
ulong bestArea = 0;
|
||||
for (var lumaIndex = 0; lumaIndex < textures.Count; lumaIndex++)
|
||||
{
|
||||
var luma = textures[lumaIndex];
|
||||
if (!IsHostMovieLumaCandidate(luma))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var chromaIndex = 0; chromaIndex < textures.Count; chromaIndex++)
|
||||
{
|
||||
var chroma = textures[chromaIndex];
|
||||
if (!IsHostMovieChromaCandidate(luma, chroma))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var area = (ulong)luma.Width * luma.Height;
|
||||
if (bestLumaIndex < 0 || area > bestArea)
|
||||
{
|
||||
bestLumaIndex = lumaIndex;
|
||||
bestChromaIndex = chromaIndex;
|
||||
bestArea = area;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bestLumaIndex < 0 || bestChromaIndex < 0)
|
||||
{
|
||||
return HostMovieTextureBindings.None;
|
||||
}
|
||||
|
||||
var lumaTexture = textures[bestLumaIndex];
|
||||
var chromaTexture = textures[bestChromaIndex];
|
||||
_hostMovieLumaTextureAddress = lumaTexture.Address;
|
||||
_hostMovieChromaTextureAddress = chromaTexture.Address;
|
||||
_hostMovieLumaDstSelect = lumaTexture.DstSelect;
|
||||
_hostMovieChromaDstSelect = chromaTexture.DstSelect;
|
||||
var traceKey =
|
||||
$"{_hostMovieFramePath}|{lumaTexture.Address:X16}|{chromaTexture.Address:X16}";
|
||||
if (_tracedHostMovieTextureBindings.Add(traceKey))
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
$"[LOADER][INFO] Bink2 YUV textures bound: " +
|
||||
$"{Path.GetFileName(_hostMovieFramePath)} " +
|
||||
$"y={bestLumaIndex}:0x{lumaTexture.Address:X16}:" +
|
||||
$"{lumaTexture.Width}x{lumaTexture.Height}:dst=0x{lumaTexture.DstSelect:X} " +
|
||||
$"uv={bestChromaIndex}:0x{chromaTexture.Address:X16}:" +
|
||||
$"{chromaTexture.Width}x{chromaTexture.Height}:dst=0x{chromaTexture.DstSelect:X} " +
|
||||
$"host={_hostMovieFrameWidth}x{_hostMovieFrameHeight}.");
|
||||
}
|
||||
|
||||
return new HostMovieTextureBindings(bestLumaIndex, bestChromaIndex);
|
||||
}
|
||||
|
||||
private HostMovieTextureBindings RememberHostMovieTextureMappings(
|
||||
IReadOnlyList<GuestDrawTexture> textures,
|
||||
int lumaIndex,
|
||||
int chromaIndex)
|
||||
{
|
||||
_hostMovieLumaDstSelect = textures[lumaIndex].DstSelect;
|
||||
_hostMovieChromaDstSelect = textures[chromaIndex].DstSelect;
|
||||
return new HostMovieTextureBindings(lumaIndex, chromaIndex);
|
||||
}
|
||||
|
||||
private bool IsHostMovieLumaCandidate(GuestDrawTexture texture)
|
||||
{
|
||||
if (texture.Address == 0 ||
|
||||
texture.IsStorage ||
|
||||
texture.IsFallback ||
|
||||
texture.ArrayedView ||
|
||||
texture.ArrayLayers > 1 ||
|
||||
texture.Format != 1 ||
|
||||
texture.NumberType != 0 ||
|
||||
texture.Width < 1280 ||
|
||||
texture.Height < 720)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var guestAspect = (ulong)texture.Width * _hostMovieFrameHeight;
|
||||
var hostAspect = (ulong)texture.Height * _hostMovieFrameWidth;
|
||||
var difference = guestAspect > hostAspect
|
||||
? guestAspect - hostAspect
|
||||
: hostAspect - guestAspect;
|
||||
return difference * 100 <= Math.Max(guestAspect, hostAspect) * 2;
|
||||
}
|
||||
|
||||
private static bool IsHostMovieChromaCandidate(
|
||||
GuestDrawTexture luma,
|
||||
GuestDrawTexture chroma)
|
||||
{
|
||||
return chroma.Address != 0 &&
|
||||
chroma.Address != luma.Address &&
|
||||
!chroma.IsStorage &&
|
||||
!chroma.IsFallback &&
|
||||
!chroma.ArrayedView &&
|
||||
chroma.ArrayLayers <= 1 &&
|
||||
chroma.Format == 3 &&
|
||||
chroma.NumberType == 0 &&
|
||||
luma.Width == chroma.Width * 2 &&
|
||||
luma.Height == chroma.Height * 2;
|
||||
}
|
||||
|
||||
private TextureResource CreateHostMovieTextureResource(
|
||||
GuestDrawTexture texture,
|
||||
int plane)
|
||||
{
|
||||
EnsureHostMovieYuvFrame();
|
||||
var isLuma = plane == 0;
|
||||
var pixels = isLuma ? _hostMovieLumaPixels! : _hostMovieChromaPixels!;
|
||||
var width = isLuma
|
||||
? _hostMovieFrameWidth
|
||||
: (_hostMovieFrameWidth + 1) / 2;
|
||||
var height = isLuma
|
||||
? _hostMovieFrameHeight
|
||||
: (_hostMovieFrameHeight + 1) / 2;
|
||||
EnsureHostMovieImages(
|
||||
_hostMovieFrameWidth,
|
||||
_hostMovieFrameHeight,
|
||||
_hostMovieLumaDstSelect,
|
||||
(_hostMovieFrameWidth + 1) / 2,
|
||||
(_hostMovieFrameHeight + 1) / 2,
|
||||
_hostMovieChromaDstSelect);
|
||||
|
||||
var uploadedFrameSerial = isLuma
|
||||
? _hostMovieLumaUploadedFrameSerial
|
||||
: _hostMovieChromaUploadedFrameSerial;
|
||||
var needsUpload = uploadedFrameSerial != _hostMovieFrameSerial;
|
||||
VkBuffer stagingBuffer = default;
|
||||
DeviceMemory stagingMemory = default;
|
||||
if (needsUpload)
|
||||
{
|
||||
(stagingBuffer, stagingMemory) = CreateTextureStagingBuffer(
|
||||
pixels,
|
||||
$"Bink2 frame {_hostMovieFrameSerial} plane {plane} staging");
|
||||
}
|
||||
|
||||
return new TextureResource
|
||||
{
|
||||
Address = texture.Address,
|
||||
StagingBuffer = stagingBuffer,
|
||||
StagingMemory = stagingMemory,
|
||||
Image = isLuma ? _hostMovieImage : _hostMovieChromaImage,
|
||||
View = isLuma ? _hostMovieImageView : _hostMovieChromaImageView,
|
||||
Width = width,
|
||||
Height = height,
|
||||
RowLength = width,
|
||||
DstSelect = texture.DstSelect,
|
||||
NeedsUpload = needsUpload,
|
||||
IsHostMovie = true,
|
||||
HostMoviePlane = plane,
|
||||
HostMovieFrameSerial = _hostMovieFrameSerial,
|
||||
SamplerState = texture.Sampler,
|
||||
};
|
||||
}
|
||||
|
||||
private void EnsureHostMovieYuvFrame()
|
||||
{
|
||||
if (_hostMovieConvertedFrameSerial == _hostMovieFrameSerial)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var bgra = _hostMovieFramePixels ??
|
||||
throw new InvalidOperationException("Host movie frame is unavailable.");
|
||||
var width = checked((int)_hostMovieFrameWidth);
|
||||
var height = checked((int)_hostMovieFrameHeight);
|
||||
var chromaWidth = (width + 1) / 2;
|
||||
var chromaHeight = (height + 1) / 2;
|
||||
if (_hostMovieLumaPixels?.Length != width * height)
|
||||
{
|
||||
_hostMovieLumaPixels = GC.AllocateUninitializedArray<byte>(width * height);
|
||||
}
|
||||
if (_hostMovieChromaPixels?.Length != chromaWidth * chromaHeight * 2)
|
||||
{
|
||||
_hostMovieChromaPixels =
|
||||
GC.AllocateUninitializedArray<byte>(chromaWidth * chromaHeight * 2);
|
||||
}
|
||||
|
||||
ConvertBgraToYuv420(
|
||||
bgra,
|
||||
width,
|
||||
height,
|
||||
_hostMovieLumaPixels,
|
||||
_hostMovieChromaPixels);
|
||||
_hostMovieConvertedFrameSerial = _hostMovieFrameSerial;
|
||||
}
|
||||
|
||||
internal static void ConvertBgraToYuv420(
|
||||
ReadOnlySpan<byte> bgra,
|
||||
int width,
|
||||
int height,
|
||||
Span<byte> luma,
|
||||
Span<byte> chroma)
|
||||
{
|
||||
var chromaWidth = (width + 1) / 2;
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
for (var x = 0; x < width; x++)
|
||||
{
|
||||
var source = (y * width + x) * 4;
|
||||
var b = bgra[source];
|
||||
var g = bgra[source + 1];
|
||||
var r = bgra[source + 2];
|
||||
luma[y * width + x] = ClampByte(
|
||||
(54 * r + 183 * g + 19 * b + 128) >> 8);
|
||||
}
|
||||
}
|
||||
|
||||
for (var y = 0; y < height; y += 2)
|
||||
{
|
||||
for (var x = 0; x < width; x += 2)
|
||||
{
|
||||
var red = 0;
|
||||
var green = 0;
|
||||
var blue = 0;
|
||||
var samples = 0;
|
||||
for (var sampleY = y; sampleY < Math.Min(y + 2, height); sampleY++)
|
||||
{
|
||||
for (var sampleX = x; sampleX < Math.Min(x + 2, width); sampleX++)
|
||||
{
|
||||
var source = (sampleY * width + sampleX) * 4;
|
||||
blue += bgra[source];
|
||||
green += bgra[source + 1];
|
||||
red += bgra[source + 2];
|
||||
samples++;
|
||||
}
|
||||
}
|
||||
|
||||
red /= samples;
|
||||
green /= samples;
|
||||
blue /= samples;
|
||||
var destination = ((y / 2) * chromaWidth + x / 2) * 2;
|
||||
chroma[destination] = ClampByte(
|
||||
((128 * red - 116 * green - 12 * blue + 128) >> 8) + 128);
|
||||
chroma[destination + 1] = ClampByte(
|
||||
((-29 * red - 99 * green + 128 * blue + 128) >> 8) + 128);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte ClampByte(int value) => (byte)Math.Clamp(value, 0, 255);
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private TextureResource ResolveTextureResource(GuestDrawTexture texture)
|
||||
{
|
||||
@@ -9539,6 +9900,26 @@ internal static unsafe class VulkanVideoPresenter
|
||||
_stagingSize = size;
|
||||
}
|
||||
|
||||
private void CreateFrameUploadBuffers(ulong size)
|
||||
{
|
||||
_frameUploadBuffers = new VkBuffer[MaxFramesInFlight];
|
||||
_frameUploadMemory = new DeviceMemory[MaxFramesInFlight];
|
||||
_frameUploadMapped = new nint[MaxFramesInFlight];
|
||||
for (var slot = 0; slot < MaxFramesInFlight; slot++)
|
||||
{
|
||||
_frameUploadBuffers[slot] = CreateBuffer(
|
||||
size,
|
||||
BufferUsageFlags.TransferSrcBit,
|
||||
MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit,
|
||||
out _frameUploadMemory[slot]);
|
||||
void* mapped;
|
||||
Check(
|
||||
_vk.MapMemory(_device, _frameUploadMemory[slot], 0, size, 0, &mapped),
|
||||
"vkMapMemory(frame upload)");
|
||||
_frameUploadMapped[slot] = (nint)mapped;
|
||||
}
|
||||
}
|
||||
|
||||
private uint FindMemoryType(uint typeBits, MemoryPropertyFlags requiredFlags)
|
||||
{
|
||||
_vk.GetPhysicalDeviceMemoryProperties(_physicalDevice, out var properties);
|
||||
@@ -9590,6 +9971,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
return;
|
||||
}
|
||||
|
||||
PumpHostMovieFrame();
|
||||
|
||||
if (_skipAllCompute ||
|
||||
AddressListContains("SHARPEMU_SKIP_COMPUTE_CS", work.ShaderAddress) ||
|
||||
(_skipTallComputeZ > 0 && work.GroupCountZ >= _skipTallComputeZ))
|
||||
@@ -12476,10 +12859,12 @@ internal static unsafe class VulkanVideoPresenter
|
||||
EvictDirtyCachedTextures();
|
||||
var completedWork = 0;
|
||||
HashSet<string>? deferredOrderedQueues = null;
|
||||
var renderWorkDeadline = _renderWorkBudgetTicks > 0
|
||||
? System.Diagnostics.Stopwatch.GetTimestamp() + _renderWorkBudgetTicks
|
||||
var workBudgetTicks = _renderWorkBudgetTicks;
|
||||
var renderWorkDeadline = workBudgetTicks > 0
|
||||
? System.Diagnostics.Stopwatch.GetTimestamp() + workBudgetTicks
|
||||
: long.MaxValue;
|
||||
while (completedWork < _maxGuestWorkPerRender)
|
||||
var workLimit = _maxGuestWorkPerRender;
|
||||
while (completedWork < workLimit)
|
||||
{
|
||||
// Never block the macOS main thread waiting for in-flight GPU
|
||||
// work to drain. If submission is at capacity (a slow-compute
|
||||
@@ -12536,6 +12921,14 @@ internal static unsafe class VulkanVideoPresenter
|
||||
}
|
||||
try
|
||||
{
|
||||
// A host-decoded movie only overrides which image gets
|
||||
// presented (see the presentation selection below); the
|
||||
// guest's own command stream keeps draining normally.
|
||||
// Silently discarding these instead of executing them
|
||||
// leaves guest-visible completion state (labels, buffers,
|
||||
// job results) permanently unwritten, which desyncs the
|
||||
// engine's own job system and previously crashed it
|
||||
// shortly after the movie finished.
|
||||
switch (work)
|
||||
{
|
||||
case VulkanOffscreenGuestDraw offscreenDraw:
|
||||
@@ -12613,7 +13006,8 @@ internal static unsafe class VulkanVideoPresenter
|
||||
FlushBatchedGuestCommands();
|
||||
CollectAbandonedGuestImageVersions();
|
||||
|
||||
if (!TryTakePresentation(_presentedSequence, out var presentation))
|
||||
Presentation presentation;
|
||||
if (!TryTakePresentation(_presentedSequence, out presentation))
|
||||
{
|
||||
if (_pendingHostSplashReplay is { } splash)
|
||||
{
|
||||
@@ -12637,7 +13031,6 @@ internal static unsafe class VulkanVideoPresenter
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (_hostSurface is not null)
|
||||
{
|
||||
if (presentation.IsSplash && presentation.Pixels is not null)
|
||||
@@ -12690,6 +13083,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
TranslatedDrawResources? translatedResources = null;
|
||||
@@ -12817,19 +13211,11 @@ internal static unsafe class VulkanVideoPresenter
|
||||
|
||||
if (pixels is not null)
|
||||
{
|
||||
// The staging buffer is shared across frame slots; a CPU
|
||||
// pixel upload (splash / host frames) degrades to serial
|
||||
// presentation rather than corrupting an in-flight copy.
|
||||
WaitAllFrameSlots();
|
||||
void* mapped;
|
||||
Check(
|
||||
_vk.MapMemory(_device, _stagingMemory, 0, (ulong)pixels.Length, 0, &mapped),
|
||||
"vkMapMemory");
|
||||
var mapped = (void*)_frameUploadMapped[frameSlot];
|
||||
fixed (byte* source = pixels)
|
||||
{
|
||||
System.Buffer.MemoryCopy(source, mapped, pixels.Length, pixels.Length);
|
||||
}
|
||||
_vk.UnmapMemory(_device, _stagingMemory);
|
||||
}
|
||||
|
||||
Check(_vk.ResetCommandBuffer(_commandBuffer, 0), "vkResetCommandBuffer");
|
||||
@@ -12843,7 +13229,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
PipelineStageFlags waitStage;
|
||||
if (pixels is not null)
|
||||
{
|
||||
RecordUpload(imageIndex);
|
||||
RecordUpload(imageIndex, frameSlot);
|
||||
waitStage = PipelineStageFlags.TransferBit;
|
||||
}
|
||||
else if (presentation.DrawKind == GuestDrawKind.FullscreenBarycentric)
|
||||
@@ -13365,11 +13751,22 @@ internal static unsafe class VulkanVideoPresenter
|
||||
continue;
|
||||
}
|
||||
|
||||
var hostMovieImageInitialized = texture.HostMoviePlane switch
|
||||
{
|
||||
0 => _hostMovieImageInitialized,
|
||||
1 => _hostMovieChromaImageInitialized,
|
||||
_ => false,
|
||||
};
|
||||
var toTransfer = new ImageMemoryBarrier
|
||||
{
|
||||
SType = StructureType.ImageMemoryBarrier,
|
||||
SrcAccessMask = texture.IsHostMovie && hostMovieImageInitialized
|
||||
? AccessFlags.ShaderReadBit
|
||||
: 0,
|
||||
DstAccessMask = AccessFlags.TransferWriteBit,
|
||||
OldLayout = ImageLayout.Undefined,
|
||||
OldLayout = texture.IsHostMovie && hostMovieImageInitialized
|
||||
? ImageLayout.ShaderReadOnlyOptimal
|
||||
: ImageLayout.Undefined,
|
||||
NewLayout = ImageLayout.TransferDstOptimal,
|
||||
SrcQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
||||
DstQueueFamilyIndex = Vk.QueueFamilyIgnored,
|
||||
@@ -13378,7 +13775,9 @@ internal static unsafe class VulkanVideoPresenter
|
||||
};
|
||||
_vk.CmdPipelineBarrier(
|
||||
_commandBuffer,
|
||||
PipelineStageFlags.TopOfPipeBit,
|
||||
texture.IsHostMovie && hostMovieImageInitialized
|
||||
? PipelineStageFlags.AllCommandsBit
|
||||
: PipelineStageFlags.TopOfPipeBit,
|
||||
PipelineStageFlags.TransferBit,
|
||||
0,
|
||||
0,
|
||||
@@ -13438,6 +13837,19 @@ internal static unsafe class VulkanVideoPresenter
|
||||
// reuse the image without restaging it.
|
||||
texture.NeedsUpload = false;
|
||||
}
|
||||
if (texture.IsHostMovie)
|
||||
{
|
||||
if (texture.HostMoviePlane == 0)
|
||||
{
|
||||
_hostMovieImageInitialized = true;
|
||||
_hostMovieLumaUploadedFrameSerial = texture.HostMovieFrameSerial;
|
||||
}
|
||||
else if (texture.HostMoviePlane == 1)
|
||||
{
|
||||
_hostMovieChromaImageInitialized = true;
|
||||
_hostMovieChromaUploadedFrameSerial = texture.HostMovieFrameSerial;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14751,7 +15163,176 @@ internal static unsafe class VulkanVideoPresenter
|
||||
}
|
||||
}
|
||||
|
||||
private void RecordUpload(uint imageIndex)
|
||||
private void EnsureHostMovieImages(
|
||||
uint lumaWidth,
|
||||
uint lumaHeight,
|
||||
uint lumaDstSelect,
|
||||
uint chromaWidth,
|
||||
uint chromaHeight,
|
||||
uint chromaDstSelect)
|
||||
{
|
||||
if (_hostMovieImage.Handle != 0 &&
|
||||
_hostMovieImageWidth == lumaWidth &&
|
||||
_hostMovieImageHeight == lumaHeight &&
|
||||
_hostMovieImageFormat == Format.R8Unorm &&
|
||||
_hostMovieImageDstSelect == lumaDstSelect &&
|
||||
_hostMovieChromaImage.Handle != 0 &&
|
||||
_hostMovieChromaImageWidth == chromaWidth &&
|
||||
_hostMovieChromaImageHeight == chromaHeight &&
|
||||
_hostMovieChromaImageDstSelect == chromaDstSelect)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_hostMovieImage.Handle != 0 || _hostMovieChromaImage.Handle != 0)
|
||||
{
|
||||
FlushBatchedGuestCommands();
|
||||
WaitForAllGuestSubmissions();
|
||||
DrainFrameSlots();
|
||||
DestroyHostMovieImage();
|
||||
}
|
||||
|
||||
CreateHostMoviePlaneImage(
|
||||
lumaWidth,
|
||||
lumaHeight,
|
||||
Format.R8Unorm,
|
||||
lumaDstSelect,
|
||||
"luma",
|
||||
out _hostMovieImage,
|
||||
out _hostMovieImageMemory,
|
||||
out _hostMovieImageView);
|
||||
CreateHostMoviePlaneImage(
|
||||
chromaWidth,
|
||||
chromaHeight,
|
||||
Format.R8G8Unorm,
|
||||
chromaDstSelect,
|
||||
"chroma",
|
||||
out _hostMovieChromaImage,
|
||||
out _hostMovieChromaImageMemory,
|
||||
out _hostMovieChromaImageView);
|
||||
_hostMovieImageWidth = lumaWidth;
|
||||
_hostMovieImageHeight = lumaHeight;
|
||||
_hostMovieImageFormat = Format.R8Unorm;
|
||||
_hostMovieImageDstSelect = lumaDstSelect;
|
||||
_hostMovieChromaImageWidth = chromaWidth;
|
||||
_hostMovieChromaImageHeight = chromaHeight;
|
||||
_hostMovieChromaImageDstSelect = chromaDstSelect;
|
||||
_hostMovieImageInitialized = false;
|
||||
_hostMovieChromaImageInitialized = false;
|
||||
_hostMovieLumaUploadedFrameSerial = -1;
|
||||
_hostMovieChromaUploadedFrameSerial = -1;
|
||||
}
|
||||
|
||||
private void CreateHostMoviePlaneImage(
|
||||
uint width,
|
||||
uint height,
|
||||
Format format,
|
||||
uint dstSelect,
|
||||
string planeName,
|
||||
out Image image,
|
||||
out DeviceMemory memory,
|
||||
out ImageView view)
|
||||
{
|
||||
var imageInfo = new ImageCreateInfo
|
||||
{
|
||||
SType = StructureType.ImageCreateInfo,
|
||||
ImageType = ImageType.Type2D,
|
||||
Format = format,
|
||||
Extent = new Extent3D(width, height, 1),
|
||||
MipLevels = 1,
|
||||
ArrayLayers = 1,
|
||||
Samples = SampleCountFlags.Count1Bit,
|
||||
Tiling = ImageTiling.Optimal,
|
||||
Usage = ImageUsageFlags.TransferDstBit | ImageUsageFlags.SampledBit,
|
||||
SharingMode = SharingMode.Exclusive,
|
||||
InitialLayout = ImageLayout.Undefined,
|
||||
};
|
||||
Check(
|
||||
_vk.CreateImage(_device, &imageInfo, null, out image),
|
||||
$"vkCreateImage(host movie {planeName})");
|
||||
_vk.GetImageMemoryRequirements(_device, image, out var requirements);
|
||||
var allocationInfo = new MemoryAllocateInfo
|
||||
{
|
||||
SType = StructureType.MemoryAllocateInfo,
|
||||
AllocationSize = requirements.Size,
|
||||
MemoryTypeIndex = FindMemoryType(
|
||||
requirements.MemoryTypeBits,
|
||||
MemoryPropertyFlags.DeviceLocalBit),
|
||||
};
|
||||
Check(
|
||||
_vk.AllocateMemory(
|
||||
_device,
|
||||
&allocationInfo,
|
||||
null,
|
||||
out memory),
|
||||
$"vkAllocateMemory(host movie {planeName})");
|
||||
Check(
|
||||
_vk.BindImageMemory(_device, image, memory, 0),
|
||||
$"vkBindImageMemory(host movie {planeName})");
|
||||
var viewInfo = new ImageViewCreateInfo
|
||||
{
|
||||
SType = StructureType.ImageViewCreateInfo,
|
||||
Image = image,
|
||||
ViewType = ImageViewType.Type2D,
|
||||
Format = format,
|
||||
Components = ToVkComponentMapping(dstSelect),
|
||||
SubresourceRange = ColorSubresourceRange(),
|
||||
};
|
||||
Check(
|
||||
_vk.CreateImageView(
|
||||
_device,
|
||||
&viewInfo,
|
||||
null,
|
||||
out view),
|
||||
$"vkCreateImageView(host movie {planeName})");
|
||||
SetDebugName(ObjectType.Image, image.Handle, $"SharpEmu Bink2 {planeName} image");
|
||||
SetDebugName(ObjectType.ImageView, view.Handle, $"SharpEmu Bink2 {planeName} view");
|
||||
}
|
||||
|
||||
private void DestroyHostMovieImage()
|
||||
{
|
||||
if (_hostMovieImageView.Handle != 0)
|
||||
{
|
||||
_vk.DestroyImageView(_device, _hostMovieImageView, null);
|
||||
_hostMovieImageView = default;
|
||||
}
|
||||
if (_hostMovieImage.Handle != 0)
|
||||
{
|
||||
_vk.DestroyImage(_device, _hostMovieImage, null);
|
||||
_hostMovieImage = default;
|
||||
}
|
||||
if (_hostMovieImageMemory.Handle != 0)
|
||||
{
|
||||
_vk.FreeMemory(_device, _hostMovieImageMemory, null);
|
||||
_hostMovieImageMemory = default;
|
||||
}
|
||||
if (_hostMovieChromaImageView.Handle != 0)
|
||||
{
|
||||
_vk.DestroyImageView(_device, _hostMovieChromaImageView, null);
|
||||
_hostMovieChromaImageView = default;
|
||||
}
|
||||
if (_hostMovieChromaImage.Handle != 0)
|
||||
{
|
||||
_vk.DestroyImage(_device, _hostMovieChromaImage, null);
|
||||
_hostMovieChromaImage = default;
|
||||
}
|
||||
if (_hostMovieChromaImageMemory.Handle != 0)
|
||||
{
|
||||
_vk.FreeMemory(_device, _hostMovieChromaImageMemory, null);
|
||||
_hostMovieChromaImageMemory = default;
|
||||
}
|
||||
_hostMovieImageWidth = 0;
|
||||
_hostMovieImageHeight = 0;
|
||||
_hostMovieImageFormat = Format.Undefined;
|
||||
_hostMovieChromaImageWidth = 0;
|
||||
_hostMovieChromaImageHeight = 0;
|
||||
_hostMovieImageInitialized = false;
|
||||
_hostMovieChromaImageInitialized = false;
|
||||
_hostMovieLumaUploadedFrameSerial = -1;
|
||||
_hostMovieChromaUploadedFrameSerial = -1;
|
||||
}
|
||||
|
||||
private void RecordUpload(uint imageIndex, int frameSlot)
|
||||
{
|
||||
var oldLayout = _imageInitialized[imageIndex]
|
||||
? ImageLayout.PresentSrcKhr
|
||||
@@ -14793,7 +15374,7 @@ internal static unsafe class VulkanVideoPresenter
|
||||
};
|
||||
_vk.CmdCopyBufferToImage(
|
||||
_commandBuffer,
|
||||
_stagingBuffer,
|
||||
_frameUploadBuffers[frameSlot],
|
||||
_swapchainImages[imageIndex],
|
||||
ImageLayout.TransferDstOptimal,
|
||||
1,
|
||||
@@ -15661,7 +16242,26 @@ internal static unsafe class VulkanVideoPresenter
|
||||
|
||||
private void DestroySwapchainResources()
|
||||
{
|
||||
DestroyHostMovieImage();
|
||||
DestroyPresentEncodeImage();
|
||||
for (var slot = 0; slot < _frameUploadBuffers.Length; slot++)
|
||||
{
|
||||
if (_frameUploadMapped.Length > slot && _frameUploadMapped[slot] != 0)
|
||||
{
|
||||
_vk.UnmapMemory(_device, _frameUploadMemory[slot]);
|
||||
}
|
||||
if (_frameUploadBuffers[slot].Handle != 0)
|
||||
{
|
||||
_vk.DestroyBuffer(_device, _frameUploadBuffers[slot], null);
|
||||
}
|
||||
if (_frameUploadMemory[slot].Handle != 0)
|
||||
{
|
||||
_vk.FreeMemory(_device, _frameUploadMemory[slot], null);
|
||||
}
|
||||
}
|
||||
_frameUploadBuffers = [];
|
||||
_frameUploadMemory = [];
|
||||
_frameUploadMapped = [];
|
||||
if (_stagingBuffer.Handle != 0)
|
||||
{
|
||||
_vk.DestroyBuffer(_device, _stagingBuffer, null);
|
||||
|
||||
@@ -16,4 +16,37 @@ public static class VoiceQoSExports
|
||||
{
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "Trpt2QBZHCI",
|
||||
ExportName = "sceVoiceQoSGetStatus",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceVoiceQoS")]
|
||||
public static int VoiceQoSGetStatus(CpuContext ctx)
|
||||
{
|
||||
// Returns 0 to indicate connected state (voice available)
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "FuXenJLkk-c",
|
||||
ExportName = "sceVoiceQoSTerminate",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceVoiceQoS")]
|
||||
public static int VoiceQoSTerminate(CpuContext ctx)
|
||||
{
|
||||
// No-op: cleanup is handled by emulator shutdown
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
|
||||
[SysAbiExport(
|
||||
Nid = "+0lOiPZjnBI",
|
||||
ExportName = "sceVoiceQoSSetMode",
|
||||
Target = Generation.Gen4 | Generation.Gen5,
|
||||
LibraryName = "libSceVoiceQoS")]
|
||||
public static int VoiceQoSSetMode(CpuContext ctx)
|
||||
{
|
||||
// No-op: mode configuration is not emulated
|
||||
return ctx.SetReturn(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Agc;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
public sealed class AgcPredicationTests
|
||||
{
|
||||
private const ulong BaseAddress = 0x1_0000_0000;
|
||||
private const ulong CommandBufferAddress = BaseAddress + 0x100;
|
||||
private const ulong PacketAddress = BaseAddress + 0x400;
|
||||
private const ulong PredicateAddress = BaseAddress + 0x800;
|
||||
|
||||
[Fact]
|
||||
public void DcbSetPredication_EmitsGen5Packet()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, 0x2000);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
WriteUInt64(memory, CommandBufferAddress + 0x10, PacketAddress);
|
||||
WriteUInt64(memory, CommandBufferAddress + 0x18, PacketAddress + 0x100);
|
||||
|
||||
ctx[CpuRegister.Rdi] = CommandBufferAddress;
|
||||
ctx[CpuRegister.Rsi] = 1;
|
||||
ctx[CpuRegister.Rdx] = 3;
|
||||
ctx[CpuRegister.Rcx] = 1;
|
||||
ctx[CpuRegister.R8] = PredicateAddress + 7;
|
||||
ctx[CpuRegister.R9] = 2;
|
||||
|
||||
var result = AgcExports.DcbSetPredication(ctx);
|
||||
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, result);
|
||||
Assert.Equal(PacketAddress, ctx[CpuRegister.Rax]);
|
||||
Assert.Equal(0xC002_2000u, ReadUInt32(memory, PacketAddress));
|
||||
Assert.Equal(0x0003_1100u, ReadUInt32(memory, PacketAddress + 4));
|
||||
Assert.Equal(unchecked((uint)PredicateAddress), ReadUInt32(memory, PacketAddress + 8));
|
||||
Assert.Equal((uint)(PredicateAddress >> 32), ReadUInt32(memory, PacketAddress + 12));
|
||||
Assert.Equal(PacketAddress + 16, ReadUInt64(memory, CommandBufferAddress + 0x10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetPacketPredication_TogglesPacketHeaderBit()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, 0x1000);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
const uint header = 0xC003_1500;
|
||||
WriteUInt32(memory, PacketAddress, header);
|
||||
|
||||
ctx[CpuRegister.Rdi] = PacketAddress;
|
||||
ctx[CpuRegister.Rsi] = 1;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
AgcExports.SetPacketPredication(ctx));
|
||||
Assert.Equal(header | 1u, ReadUInt32(memory, PacketAddress));
|
||||
|
||||
ctx[CpuRegister.Rsi] = 0;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
AgcExports.SetPacketPredication(ctx));
|
||||
Assert.Equal(header, ReadUInt32(memory, PacketAddress));
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
|
||||
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Agc;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
public sealed class AgcResourceOwnerTests
|
||||
{
|
||||
private const ulong BaseAddress = 0x1_0000_0000;
|
||||
private const ulong OwnerAddress = BaseAddress + 0x100;
|
||||
private const ulong NameAddress = BaseAddress + 0x200;
|
||||
private const ulong RegistrationMemoryAddress = BaseAddress + 0x400;
|
||||
|
||||
[Fact]
|
||||
public void RegisterOwner_DoesNotRequireOptionalResourceRegistryMemory()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, 0x2000);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
memory.WriteCString(NameAddress, "GIRender");
|
||||
ctx[CpuRegister.Rdi] = OwnerAddress;
|
||||
ctx[CpuRegister.Rsi] = NameAddress;
|
||||
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DriverRegisterOwner(ctx));
|
||||
Assert.NotEqual(0u, ReadUInt32(memory, OwnerAddress));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterOwner_RespectsExplicitRegistryCapacity()
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, 0x2000);
|
||||
var ctx = new CpuContext(memory, Generation.Gen5);
|
||||
ctx[CpuRegister.Rdi] = RegistrationMemoryAddress;
|
||||
ctx[CpuRegister.Rsi] = 0x1000;
|
||||
ctx[CpuRegister.Rdx] = 1;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_OK,
|
||||
AgcExports.DriverInitResourceRegistration(ctx));
|
||||
|
||||
memory.WriteCString(NameAddress, "First");
|
||||
ctx[CpuRegister.Rdi] = OwnerAddress;
|
||||
ctx[CpuRegister.Rsi] = NameAddress;
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DriverRegisterOwner(ctx));
|
||||
|
||||
memory.WriteCString(NameAddress, "Second");
|
||||
ctx[CpuRegister.Rdi] = OwnerAddress + 4;
|
||||
Assert.Equal(
|
||||
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
|
||||
AgcExports.DriverRegisterOwner(ctx));
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.HLE;
|
||||
using SharpEmu.Libs.Agc;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Agc;
|
||||
|
||||
public sealed class AgcWaitRegMemTests
|
||||
{
|
||||
private const ulong BaseAddress = 0x1_0000_0000;
|
||||
private const ulong CommandBufferAddress = BaseAddress + 0x100;
|
||||
private const ulong PacketAddress = BaseAddress + 0x400;
|
||||
private const ulong StackAddress = BaseAddress + 0x800;
|
||||
|
||||
[Fact]
|
||||
public void DcbWaitRegMem32_EmitsGen5PacketLayout()
|
||||
{
|
||||
var memory = CreateMemory(out var ctx);
|
||||
var waitAddress = BaseAddress + 0xC03;
|
||||
|
||||
ctx[CpuRegister.Rdi] = CommandBufferAddress;
|
||||
ctx[CpuRegister.Rsi] = 0;
|
||||
ctx[CpuRegister.Rdx] = 3;
|
||||
ctx[CpuRegister.Rcx] = 4;
|
||||
ctx[CpuRegister.R8] = 2;
|
||||
ctx[CpuRegister.R9] = waitAddress;
|
||||
WriteUInt64(memory, StackAddress + 8, 0x1122_3344_5566_7788);
|
||||
WriteUInt64(memory, StackAddress + 16, 0xAABB_CCDD_EEFF_0011);
|
||||
WriteUInt32(memory, StackAddress + 24, 0x123456);
|
||||
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DcbWaitRegMem(ctx));
|
||||
Assert.Equal(PacketAddress, ctx[CpuRegister.Rax]);
|
||||
Assert.Equal(0xC005_1028u, ReadUInt32(memory, PacketAddress));
|
||||
Assert.Equal(0x0000_0C00u, ReadUInt32(memory, PacketAddress + 4));
|
||||
Assert.Equal(1u, ReadUInt32(memory, PacketAddress + 8));
|
||||
Assert.Equal(0xEEFF_0011u, ReadUInt32(memory, PacketAddress + 12));
|
||||
Assert.Equal(0x5566_7788u, ReadUInt32(memory, PacketAddress + 16));
|
||||
Assert.Equal(0x0400_0053u, ReadUInt32(memory, PacketAddress + 20));
|
||||
Assert.Equal(0xFFFFu, ReadUInt32(memory, PacketAddress + 24));
|
||||
Assert.Equal(PacketAddress + 28, ReadUInt64(memory, CommandBufferAddress + 0x10));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DcbWaitRegMem64_EmitsGen5PacketLayout()
|
||||
{
|
||||
var memory = CreateMemory(out var ctx);
|
||||
var waitAddress = BaseAddress + 0xC07;
|
||||
|
||||
ctx[CpuRegister.Rdi] = CommandBufferAddress;
|
||||
ctx[CpuRegister.Rsi] = 1;
|
||||
ctx[CpuRegister.Rdx] = 6;
|
||||
ctx[CpuRegister.Rcx] = 3;
|
||||
ctx[CpuRegister.R8] = 1;
|
||||
ctx[CpuRegister.R9] = waitAddress;
|
||||
WriteUInt64(memory, StackAddress + 8, 0x1122_3344_5566_7788);
|
||||
WriteUInt64(memory, StackAddress + 16, 0xAABB_CCDD_EEFF_0011);
|
||||
WriteUInt32(memory, StackAddress + 24, 0x320);
|
||||
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.DcbWaitRegMem(ctx));
|
||||
Assert.Equal(0xC007_1058u, ReadUInt32(memory, PacketAddress));
|
||||
Assert.Equal(0x0000_0C00u, ReadUInt32(memory, PacketAddress + 4));
|
||||
Assert.Equal(1u, ReadUInt32(memory, PacketAddress + 8));
|
||||
Assert.Equal(0xEEFF_0011u, ReadUInt32(memory, PacketAddress + 12));
|
||||
Assert.Equal(0xAABB_CCDDu, ReadUInt32(memory, PacketAddress + 16));
|
||||
Assert.Equal(0x5566_7788u, ReadUInt32(memory, PacketAddress + 20));
|
||||
Assert.Equal(0x1122_3344u, ReadUInt32(memory, PacketAddress + 24));
|
||||
Assert.Equal(0x0200_0156u, ReadUInt32(memory, PacketAddress + 28));
|
||||
Assert.Equal(0x32u, ReadUInt32(memory, PacketAddress + 32));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WaitRegMemPatchFunctions_UseGen5Fields()
|
||||
{
|
||||
var memory = CreateMemory(out var ctx);
|
||||
WriteUInt32(memory, PacketAddress, 0xC005_1028);
|
||||
WriteUInt32(memory, PacketAddress + 20, 0x0400_0153);
|
||||
|
||||
ctx[CpuRegister.Rdi] = PacketAddress;
|
||||
ctx[CpuRegister.Rsi] = BaseAddress + 0xD07;
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.WaitRegMemPatchAddress(ctx));
|
||||
Assert.Equal(0x0000_0D04u, ReadUInt32(memory, PacketAddress + 4));
|
||||
Assert.Equal(1u, ReadUInt32(memory, PacketAddress + 8));
|
||||
|
||||
ctx[CpuRegister.Rsi] = 5;
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.WaitRegMemPatchCompareFunction(ctx));
|
||||
Assert.Equal(0x0400_0155u, ReadUInt32(memory, PacketAddress + 20));
|
||||
|
||||
ctx[CpuRegister.Rsi] = 0xDEAD_BEEF;
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_OK, AgcExports.WaitRegMemPatchReference(ctx));
|
||||
Assert.Equal(0xDEAD_BEEFu, ReadUInt32(memory, PacketAddress + 16));
|
||||
}
|
||||
|
||||
private static FakeCpuMemory CreateMemory(out CpuContext ctx)
|
||||
{
|
||||
var memory = new FakeCpuMemory(BaseAddress, 0x2000);
|
||||
ctx = new CpuContext(memory, Generation.Gen5);
|
||||
ctx[CpuRegister.Rsp] = StackAddress;
|
||||
WriteUInt64(memory, CommandBufferAddress + 0x10, PacketAddress);
|
||||
WriteUInt64(memory, CommandBufferAddress + 0x18, PacketAddress + 0x100);
|
||||
return memory;
|
||||
}
|
||||
|
||||
private static uint ReadUInt32(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt32LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static ulong ReadUInt64(FakeCpuMemory memory, ulong address)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
Assert.True(memory.TryRead(address, buffer));
|
||||
return BinaryPrimitives.ReadUInt64LittleEndian(buffer);
|
||||
}
|
||||
|
||||
private static void WriteUInt32(FakeCpuMemory memory, ulong address, uint value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
|
||||
private static void WriteUInt64(FakeCpuMemory memory, ulong address, ulong value)
|
||||
{
|
||||
Span<byte> buffer = stackalloc byte[8];
|
||||
BinaryPrimitives.WriteUInt64LittleEndian(buffer, value);
|
||||
Assert.True(memory.TryWrite(address, buffer));
|
||||
}
|
||||
}
|
||||
@@ -37,8 +37,12 @@ public sealed class GnmTilingDetileTests
|
||||
return offset;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryDetile_ExactXorMode27_MatchesReferenceAddressEquation()
|
||||
[Theory]
|
||||
[InlineData(384, 200)]
|
||||
[InlineData(768, 512)]
|
||||
public void TryDetile_ExactXorMode27_MatchesReferenceAddressEquation(
|
||||
int elementsWide,
|
||||
int elementsHigh)
|
||||
{
|
||||
const uint swizzleMode = 27; // 64 KiB RB+ R_X
|
||||
const int bytesPerElement = 2;
|
||||
@@ -46,8 +50,6 @@ public sealed class GnmTilingDetileTests
|
||||
// SquareBlockDimensions(32768 elements): 15 bits split 8/7, x favored.
|
||||
const int blockWidth = 256;
|
||||
const int blockHeight = 128;
|
||||
const int elementsWide = 384; // spans two block columns and a partial third
|
||||
const int elementsHigh = 200;
|
||||
var blocksPerRow = (elementsWide + blockWidth - 1) / blockWidth;
|
||||
var blocksPerColumn = (elementsHigh + blockHeight - 1) / blockHeight;
|
||||
|
||||
|
||||
@@ -99,6 +99,25 @@ public sealed class AvPlayerPathTests : IDisposable
|
||||
AvPlayerExports.GetFfprobePath(ffmpeg, isWindows));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, "ffmpeg")]
|
||||
[InlineData(true, "ffmpeg.exe")]
|
||||
public void MediaToolLookupFindsPackagedBinary(bool isWindows, string executable)
|
||||
{
|
||||
var publishDirectory = Path.Combine(_tempRoot, "publish");
|
||||
Directory.CreateDirectory(Path.Combine(publishDirectory, "ffmpeg"));
|
||||
var ffmpeg = Path.Combine(publishDirectory, "ffmpeg", executable);
|
||||
File.WriteAllBytes(ffmpeg, []);
|
||||
|
||||
Assert.Equal(
|
||||
ffmpeg,
|
||||
AvPlayerExports.FindFfmpeg(
|
||||
configured: null,
|
||||
searchPath: null,
|
||||
isWindows,
|
||||
publishDirectory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RelativeFileUriCannotEscapeApp0()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using System.Buffers.Binary;
|
||||
using SharpEmu.Libs.Bink;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Bink;
|
||||
|
||||
public sealed class Bink2MovieBridgeTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDirectory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"sharpemu-bink-{Guid.NewGuid():N}");
|
||||
|
||||
public Bink2MovieBridgeTests()
|
||||
{
|
||||
Directory.CreateDirectory(_tempDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeaderPreservesFractionalFrameRate()
|
||||
{
|
||||
var path = WriteHeader("KB2j"u8, 3840, 2160, 30_000, 1_001);
|
||||
|
||||
Assert.True(Bink2MovieBridge.TryReadBinkInfo(path, out var info));
|
||||
Assert.Equal(3840u, info.Width);
|
||||
Assert.Equal(2160u, info.Height);
|
||||
Assert.Equal(30_000u, info.FramesPerSecondNumerator);
|
||||
Assert.Equal(1_001u, info.FramesPerSecondDenominator);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("KB2g")]
|
||||
[InlineData("KB2i")]
|
||||
[InlineData("KB2j")]
|
||||
public void HeaderAcceptsBink2Revisions(string signature)
|
||||
{
|
||||
var path = WriteHeader(
|
||||
System.Text.Encoding.ASCII.GetBytes(signature),
|
||||
1920,
|
||||
1080,
|
||||
60,
|
||||
1);
|
||||
|
||||
Assert.True(Bink2MovieBridge.TryReadBinkInfo(path, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HeaderRejectsMissingFrameRateDenominator()
|
||||
{
|
||||
var path = WriteHeader("KB2j"u8, 1920, 1080, 60, 0);
|
||||
|
||||
Assert.False(Bink2MovieBridge.TryReadBinkInfo(path, out _));
|
||||
}
|
||||
|
||||
private string WriteHeader(
|
||||
ReadOnlySpan<byte> signature,
|
||||
uint width,
|
||||
uint height,
|
||||
uint fpsNumerator,
|
||||
uint fpsDenominator)
|
||||
{
|
||||
var header = new byte[36];
|
||||
signature.CopyTo(header);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x14), width);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x18), height);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x1C), fpsNumerator);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(0x20), fpsDenominator);
|
||||
var path = Path.Combine(_tempDirectory, $"{Guid.NewGuid():N}.bk2");
|
||||
File.WriteAllBytes(path, header);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Directory.Delete(_tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright (C) 2026 SharpEmu Emulator Project
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
using SharpEmu.Libs.Bink;
|
||||
using Xunit;
|
||||
|
||||
namespace SharpEmu.Libs.Tests.Bink;
|
||||
|
||||
public sealed class BinkFramePlaybackTests
|
||||
{
|
||||
[Fact]
|
||||
public void FramesAdvanceAccordingToMovieClock()
|
||||
{
|
||||
using var playback = new BinkFramePlayback(new SequenceDecoder(1, 2, 3));
|
||||
|
||||
Assert.Equal(1, WaitForAdvancedFrame(playback)[0]);
|
||||
Assert.True(playback.TryGetFrame(true, out var heldFrame, out var advanced));
|
||||
Assert.False(advanced);
|
||||
Assert.Equal(1, heldFrame[0]);
|
||||
|
||||
Assert.Equal(2, WaitForAdvancedFrame(playback)[0]);
|
||||
Assert.Equal(3, WaitForAdvancedFrame(playback)[0]);
|
||||
}
|
||||
|
||||
private static byte[] WaitForAdvancedFrame(BinkFramePlayback playback)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (playback.TryGetFrame(true, out var frame, out var advanced) && advanced)
|
||||
{
|
||||
return frame;
|
||||
}
|
||||
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
|
||||
throw new TimeoutException("The decoder did not produce a frame.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstFrameWaitsUntilPresentationStarts()
|
||||
{
|
||||
using var playback = new BinkFramePlayback(new SequenceDecoder(1, 2));
|
||||
|
||||
var first = WaitForFrame(playback, advanceClock: false);
|
||||
Assert.Equal(1, first[0]);
|
||||
Thread.Sleep(100);
|
||||
|
||||
Assert.True(playback.TryGetFrame(false, out var held, out var advanced));
|
||||
Assert.False(advanced);
|
||||
Assert.Equal(1, held[0]);
|
||||
|
||||
Assert.True(playback.TryGetFrame(true, out held, out advanced));
|
||||
Assert.False(advanced);
|
||||
Assert.Equal(1, held[0]);
|
||||
Assert.Equal(2, WaitForAdvancedFrame(playback)[0]);
|
||||
}
|
||||
|
||||
private static byte[] WaitForFrame(
|
||||
BinkFramePlayback playback,
|
||||
bool advanceClock)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (playback.TryGetFrame(advanceClock, out var frame, out _))
|
||||
{
|
||||
return frame;
|
||||
}
|
||||
|
||||
Thread.Sleep(1);
|
||||
}
|
||||
|
||||
throw new TimeoutException("The decoder did not produce a frame.");
|
||||
}
|
||||
|
||||
private sealed class SequenceDecoder(params byte[] values) : IBinkFrameDecoder
|
||||
{
|
||||
private int _index;
|
||||
|
||||
public uint Width => 1;
|
||||
|
||||
public uint Height => 1;
|
||||
|
||||
public uint FramesPerSecondNumerator => 20;
|
||||
|
||||
public uint FramesPerSecondDenominator => 1;
|
||||
|
||||
public bool TryDecodeNextFrame(Span<byte> destination)
|
||||
{
|
||||
if (_index >= values.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
destination.Fill(values[_index++]);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,6 +301,38 @@ public sealed class KernelMemoryCompatExportsTests
|
||||
Assert.Equal((int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VirtualQuery_PreservesReservationPastFixedCommitAtSameBase()
|
||||
{
|
||||
const ulong memoryBase = 0x12_0000_0000;
|
||||
const ulong reservedLength = 0x1_0000;
|
||||
const ulong committedLength = 0x2000;
|
||||
const ulong inOutAddress = memoryBase + 0x1_7000;
|
||||
const ulong infoAddress = memoryBase + 0x1_8000;
|
||||
var memory = new FakeCpuMemory(memoryBase, 0x2_0000);
|
||||
var context = new CpuContext(memory, Generation.Gen5);
|
||||
|
||||
KernelMemoryCompatExports.RegisterReservedVirtualRange(memoryBase, reservedLength);
|
||||
Assert.True(memory.TryWrite(inOutAddress, BitConverter.GetBytes(memoryBase)));
|
||||
context[CpuRegister.Rdi] = inOutAddress;
|
||||
context[CpuRegister.Rsi] = committedLength;
|
||||
context[CpuRegister.Rdx] = 0x03;
|
||||
context[CpuRegister.Rcx] = 0x10; // fixed mapping
|
||||
|
||||
Assert.Equal(0, KernelMemoryCompatExports.KernelMapNamedFlexibleMemory(context));
|
||||
|
||||
context[CpuRegister.Rdi] = memoryBase + 0x8000;
|
||||
context[CpuRegister.Rsi] = 0;
|
||||
context[CpuRegister.Rdx] = infoAddress;
|
||||
context[CpuRegister.Rcx] = 0x48;
|
||||
|
||||
Assert.Equal(0, KernelMemoryCompatExports.KernelVirtualQuery(context));
|
||||
Assert.True(context.TryReadUInt64(infoAddress, out var regionStart));
|
||||
Assert.True(context.TryReadUInt64(infoAddress + 8, out var regionEnd));
|
||||
Assert.Equal(memoryBase + committedLength, regionStart);
|
||||
Assert.Equal(memoryBase + reservedLength, regionEnd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mprotect_ZeroAddressReturnsInvalidArgument()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user