From 5864328e35faf30ecee12cf904c61baa93a9f95c Mon Sep 17 00:00:00 2001
From: Berk <12572227+par274@users.noreply.github.com>
Date: Fri, 31 Jul 2026 01:31:14 +0300
Subject: [PATCH] Merge media decoding into one FFmpeg bridge (#706)
* [media] merge bink into shared ffmpeg bridge
* [avplayer] decode in process and fix stream info size
* [font] add glyph and teardown exports
* [build] bump ffmpeg runtime to 3b502d4
* [font] add glyph and teardown exports
* [build] bump ffmpeg runtime to 3b502d4
* [avplayer] decode in process
* [font] add glyph and teardown exports
* [build] bump ffmpeg runtime to 3b502d4
---
src/SharpEmu.CLI/SharpEmu.CLI.csproj | 7 +-
src/SharpEmu.Libs/Agc/AgcExports.cs | 2 +-
src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs | 492 ++++++------------
.../Bink/FfmpegBinkFrameSource.cs | 166 ------
src/SharpEmu.Libs/Font/FontExports.cs | 151 ++++++
.../Kernel/KernelMemoryCompatExports.cs | 16 +-
src/SharpEmu.Libs/Media/FfmpegMediaStream.cs | 489 +++++++++++++++++
src/SharpEmu.Libs/Media/FfmpegRuntime.cs | 32 ++
.../FfmpegVideoDecoder.cs} | 43 +-
.../HostMovieBridge.cs} | 87 ++--
.../MediaFramePlayback.cs} | 10 +-
.../VideoOut/VulkanVideoPresenter.cs | 6 +-
.../AvPlayer/AvPlayerPathTests.cs | 43 --
.../HostMovieBridgeTests.cs} | 14 +-
.../MediaFramePlaybackTests.cs} | 16 +-
15 files changed, 920 insertions(+), 654 deletions(-)
delete mode 100644 src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs
create mode 100644 src/SharpEmu.Libs/Media/FfmpegMediaStream.cs
create mode 100644 src/SharpEmu.Libs/Media/FfmpegRuntime.cs
rename src/SharpEmu.Libs/{Bink/FfmpegNativeBinkFrameSource.cs => Media/FfmpegVideoDecoder.cs} (92%)
rename src/SharpEmu.Libs/{Bink/Bink2MovieBridge.cs => Media/HostMovieBridge.cs} (89%)
rename src/SharpEmu.Libs/{Bink/BinkFramePlayback.cs => Media/MediaFramePlayback.cs} (97%)
rename tests/SharpEmu.Libs.Tests/{Bink/Bink2MovieBridgeTests.cs => Media/HostMovieBridgeTests.cs} (84%)
rename tests/SharpEmu.Libs.Tests/{Bink/BinkFramePlaybackTests.cs => Media/MediaFramePlaybackTests.cs} (86%)
diff --git a/src/SharpEmu.CLI/SharpEmu.CLI.csproj b/src/SharpEmu.CLI/SharpEmu.CLI.csproj
index 2714e437..a2a896c5 100644
--- a/src/SharpEmu.CLI/SharpEmu.CLI.csproj
+++ b/src/SharpEmu.CLI/SharpEmu.CLI.csproj
@@ -111,15 +111,14 @@ SPDX-License-Identifier: GPL-2.0-or-later
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
- FfmpegNativeBinkFrameSource uses the same literal "plugins" folder
- name. -->
+ vs. target-RID mixups) for no benefit. Runtime code (FfmpegRuntime)
+ uses the same literal "plugins" folder name. -->
plugins
- 2c92585
+ 3b502d4
$(BaseIntermediateOutputPath)ffmpeg-runtime/$(FfmpegRuntimeTag)/$(RuntimeIdentifier)
ffmpeg-windows-x64.zip
diff --git a/src/SharpEmu.Libs/Agc/AgcExports.cs b/src/SharpEmu.Libs/Agc/AgcExports.cs
index 0af15bce..ba27ac17 100644
--- a/src/SharpEmu.Libs/Agc/AgcExports.cs
+++ b/src/SharpEmu.Libs/Agc/AgcExports.cs
@@ -3,7 +3,7 @@
using System.Collections.Concurrent;
using SharpEmu.HLE;
-using SharpEmu.Libs.Bink;
+using SharpEmu.Libs.Media;
using SharpEmu.Libs.Gpu;
using SharpEmu.ShaderCompiler;
using SharpEmu.Libs.Kernel;
diff --git a/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs b/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs
index a92983df..8260bb13 100644
--- a/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs
+++ b/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs
@@ -3,6 +3,7 @@
using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
+using SharpEmu.Libs.Media;
using System.Buffers.Binary;
using System.Diagnostics;
using System.Globalization;
@@ -15,6 +16,10 @@ public static class AvPlayerExports
private const int InvalidParameters = unchecked((int)0x806A0001);
private const int OperationFailed = unchecked((int)0x806A0002);
private const int FrameBufferCount = 3;
+ private const int MaxCatchUpFrames = 2;
+ private const ulong TextureAllocationAlignment = 0x100;
+ private const int FramePitchAlignment = 64;
+ private const int FrameHeightAlignment = 16;
private const int FrameInfoSize = 40;
private const int FrameInfoExSize = 104;
// This structure is 32 bytes. A larger write can damage the guest stack.
@@ -22,6 +27,7 @@ public static class AvPlayerExports
private const int StreamInfoExSize = 32;
private const int MaxGuestPathLength = 4096;
private static readonly object StateGate = new();
+ private static readonly HashSet TracedOnce = new();
private static readonly Dictionary Players = new();
private static int _traceCount;
@@ -31,6 +37,7 @@ public static class AvPlayerExports
public bool AutoStart { get; init; }
public ulong AllocatorObject { get; init; }
public ulong AllocateTextureCallback { get; init; }
+ public ulong AllocateCallback { get; init; }
public ulong EventObject { get; init; }
public ulong EventCallback { get; init; }
public string? SourcePath { get; set; }
@@ -42,11 +49,10 @@ public static class AvPlayerExports
public bool Paused { get; set; }
public bool Looping { get; set; }
public bool EndOfStream { get; set; }
- public Process? Decoder { get; set; }
public Stream? DecoderOutput { get; set; }
- public Process? AudioDecoder { get; set; }
public Stream? AudioDecoderOutput { get; set; }
public Stopwatch PlaybackClock { get; } = new();
+ public long SkippedFrameDebt { get; set; }
public byte[]? RawFrame { get; set; }
public byte[]? RawAudioFrame { get; set; }
public byte[]? PaddedFrame { get; set; }
@@ -66,42 +72,6 @@ public static class AvPlayerExports
DecoderOutput = null;
AudioDecoderOutput?.Dispose();
AudioDecoderOutput = null;
- if (Decoder is not null)
- {
- try
- {
- if (!Decoder.HasExited)
- {
- Decoder.Kill(entireProcessTree: true);
- }
- }
- catch (InvalidOperationException)
- {
- }
- finally
- {
- Decoder.Dispose();
- Decoder = null;
- }
- }
- if (AudioDecoder is not null)
- {
- try
- {
- if (!AudioDecoder.HasExited)
- {
- AudioDecoder.Kill(entireProcessTree: true);
- }
- }
- catch (InvalidOperationException)
- {
- }
- finally
- {
- AudioDecoder.Dispose();
- AudioDecoder = null;
- }
- }
}
public void ResetPlayback()
@@ -110,6 +80,7 @@ public static class AvPlayerExports
PlaybackClock.Reset();
NextFrameIndex = 0;
NextAudioFrameIndex = 0;
+ SkippedFrameDebt = 0;
EndOfStream = false;
}
}
@@ -137,6 +108,7 @@ public static class AvPlayerExports
AutoStart = TryReadByte(ctx, initDataAddress + 108, out var autoStart) && autoStart != 0,
AllocatorObject = TryReadUInt64(ctx, initDataAddress, out var allocatorObject) ? allocatorObject : 0,
AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 24, out var allocateTexture) ? allocateTexture : 0,
+ AllocateCallback = TryReadUInt64(ctx, initDataAddress + 8, out var allocate) ? allocate : 0,
EventObject = TryReadUInt64(ctx, initDataAddress + 80, out var eventObject) ? eventObject : 0,
EventCallback = TryReadUInt64(ctx, initDataAddress + 88, out var eventCallback) ? eventCallback : 0,
});
@@ -191,6 +163,7 @@ public static class AvPlayerExports
AutoStart = TryReadByte(ctx, initDataAddress + 164, out var autoStart) && autoStart != 0,
AllocatorObject = TryReadUInt64(ctx, initDataAddress + 8, out var allocatorObject) ? allocatorObject : 0,
AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 32, out var allocateTexture) ? allocateTexture : 0,
+ AllocateCallback = TryReadUInt64(ctx, initDataAddress + 16, out var allocate) ? allocate : 0,
EventObject = TryReadUInt64(ctx, initDataAddress + 88, out var eventObject) ? eventObject : 0,
EventCallback = TryReadUInt64(ctx, initDataAddress + 96, out var eventCallback) ? eventCallback : 0,
});
@@ -356,7 +329,7 @@ public static class AvPlayerExports
}
player.Paused = false;
- if (player.Decoder is not null)
+ if (player.DecoderOutput is not null)
{
player.PlaybackClock.Start();
}
@@ -388,7 +361,13 @@ public static class AvPlayerExports
ExportName = "sceAvPlayerEnableStream",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
- public static int AvPlayerEnableStream(CpuContext ctx) => ValidatePlayer(ctx);
+ public static int AvPlayerEnableStream(CpuContext ctx)
+ {
+ TraceOnce(
+ $"enable_stream_{ctx[CpuRegister.Rsi]}",
+ $"enable_stream index={ctx[CpuRegister.Rsi]}");
+ return ValidatePlayer(ctx);
+ }
[SysAbiExport(
Nid = "k-q+xOxdc3E",
@@ -445,10 +424,13 @@ public static class AvPlayerExports
{
lock (StateGate)
{
- return SetReturn(
- ctx,
- Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) &&
- player.Started && !player.EndOfStream ? 1 : 0);
+ var found = Players.TryGetValue(ctx[CpuRegister.Rdi], out var player);
+ var active = found && player!.Started && !player.EndOfStream;
+ TraceOnce(
+ "is_active",
+ $"is_active found={found} started={(found && player!.Started)} " +
+ $"eos={(found && player!.EndOfStream)} returned={(active ? 1 : 0)}");
+ return SetReturn(ctx, active ? 1 : 0);
}
}
@@ -476,13 +458,21 @@ public static class AvPlayerExports
var infoAddress = ctx[CpuRegister.Rsi];
lock (StateGate)
{
- if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) ||
- infoAddress == 0 || !player.Started || player.Paused || player.EndOfStream ||
- player.SourcePath is null || !EnsureAudioDecoder(player))
+ var found = Players.TryGetValue(ctx[CpuRegister.Rdi], out var player);
+ if (!found || infoAddress == 0 || !player!.Started || player.Paused ||
+ player.EndOfStream || player.SourcePath is null || !EnsureAudioDecoder(player))
{
+ TraceOnce(
+ "audio_data_refused",
+ $"audio_data refused found={found} info=0x{infoAddress:X16} " +
+ $"started={(found && player!.Started)} paused={(found && player!.Paused)} " +
+ $"eos={(found && player!.EndOfStream)} " +
+ $"decoder={(found && player!.SourcePath is not null && EnsureAudioDecoder(player))}");
return SetReturn(ctx, 0);
}
+ TraceOnce("audio_data_ok", "audio_data first delivery");
+
const int samplesPerFrame = 1024;
const int channelCount = 2;
const int sampleRate = 48_000;
@@ -560,7 +550,9 @@ public static class AvPlayerExports
{
lock (StateGate)
{
- return SetReturn(ctx, Players.ContainsKey(ctx[CpuRegister.Rdi]) ? 2 : InvalidParameters);
+ var known = Players.ContainsKey(ctx[CpuRegister.Rdi]);
+ TraceOnce("stream_count", $"stream_count known={known} returned={(known ? 2 : -1)}");
+ return SetReturn(ctx, known ? 2 : InvalidParameters);
}
}
@@ -637,6 +629,10 @@ public static class AvPlayerExports
return SetReturn(ctx, InvalidParameters);
}
+ TraceOnce(
+ $"stream_info_{streamIndex}_{infoSize}",
+ $"stream_info index={streamIndex} size={infoSize} " +
+ $"type={(streamIndex == 0 ? "video" : "audio")} duration_ms={player.DurationMilliseconds}");
return SetReturn(ctx, 0);
}
}
@@ -672,6 +668,8 @@ public static class AvPlayerExports
}
+ EnsureGuestVideoBuffers(ctx, player);
+
NotifyEvent(ctx, player, 2); // StateReady
if (autoStart)
{
@@ -699,7 +697,20 @@ public static class AvPlayerExports
}
var fps = Math.Max(1.0, player.FramesPerSecond);
- var expectedFrame = (long)Math.Floor(player.PlaybackClock.Elapsed.TotalSeconds * fps);
+ var expectedFrame =
+ (long)Math.Floor(player.PlaybackClock.Elapsed.TotalSeconds * fps) -
+ player.SkippedFrameDebt;
+ var behind = expectedFrame - player.NextFrameIndex;
+ if (behind > MaxCatchUpFrames)
+ {
+ player.SkippedFrameDebt += behind - MaxCatchUpFrames;
+ expectedFrame = player.NextFrameIndex + MaxCatchUpFrames;
+ TraceOnce(
+ "catch_up_capped",
+ $"catch_up capped behind={behind} max={MaxCatchUpFrames} " +
+ $"fps={fps:F3} {player.Width}x{player.Height}");
+ }
+
while (player.NextFrameIndex < expectedFrame)
{
if (!ReadFrame(player))
@@ -748,62 +759,28 @@ public static class AvPlayerExports
return true;
}
- var ffmpeg = FindFfmpeg();
- if (ffmpeg is null || player.SourcePath is null)
+ if (player.SourcePath is null)
{
- Console.Error.WriteLine("[AVPLAYER][ERROR] FFmpeg was not found. Set SHARPEMU_FFMPEG_PATH.");
return false;
}
- var startInfo = new ProcessStartInfo(ffmpeg)
+ if (!FfmpegMediaStream.TryOpenVideo(
+ player.SourcePath,
+ checked((int)player.Width),
+ checked((int)player.Height),
+ out var videoStream) ||
+ videoStream is null)
{
- 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(player.SourcePath);
- startInfo.ArgumentList.Add("-map");
- startInfo.ArgumentList.Add("0:v:0");
- startInfo.ArgumentList.Add("-an");
- startInfo.ArgumentList.Add("-pix_fmt");
- startInfo.ArgumentList.Add("nv12");
- startInfo.ArgumentList.Add("-f");
- startInfo.ArgumentList.Add("rawvideo");
- startInfo.ArgumentList.Add("pipe:1");
-
- try
- {
- player.Decoder = Process.Start(startInfo);
- if (player.Decoder is null)
- {
- return false;
- }
- player.Decoder.ErrorDataReceived += (_, eventArgs) =>
- {
- if (!string.IsNullOrWhiteSpace(eventArgs.Data))
- {
- Console.Error.WriteLine($"[AVPLAYER][FFMPEG] {eventArgs.Data}");
- }
- };
- player.Decoder.BeginErrorReadLine();
- player.DecoderOutput = player.Decoder.StandardOutput.BaseStream;
- player.RawFrame = new byte[checked(player.Width * player.Height * 3 / 2)];
- player.PlaybackClock.Start();
- Trace($"decoder_started pid={player.Decoder.Id} source='{player.SourcePath}'");
- return true;
- }
- catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception)
- {
- Console.Error.WriteLine($"[AVPLAYER][ERROR] Failed to launch FFmpeg: {exception.Message}");
- player.Dispose();
+ Console.Error.WriteLine(
+ $"[AVPLAYER][ERROR] Could not open a video stream in '{player.SourcePath}'.");
return false;
}
+
+ player.DecoderOutput = videoStream;
+ player.RawFrame = new byte[checked(player.Width * player.Height * 3 / 2)];
+ player.PlaybackClock.Start();
+ Trace($"decoder_started source='{player.SourcePath}' {player.Width}x{player.Height} nv12");
+ return true;
}
private static bool EnsureAudioDecoder(PlayerState player)
@@ -813,65 +790,21 @@ public static class AvPlayerExports
return true;
}
- var ffmpeg = FindFfmpeg();
- if (ffmpeg is null || player.SourcePath is null)
+ if (player.SourcePath is null)
{
return false;
}
- var startInfo = new ProcessStartInfo(ffmpeg)
+ if (!FfmpegMediaStream.TryOpenAudio(player.SourcePath, out var audioStream) ||
+ audioStream is null)
{
- 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(player.SourcePath);
- startInfo.ArgumentList.Add("-map");
- startInfo.ArgumentList.Add("0:a:0");
- startInfo.ArgumentList.Add("-vn");
- startInfo.ArgumentList.Add("-ac");
- startInfo.ArgumentList.Add("2");
- startInfo.ArgumentList.Add("-ar");
- startInfo.ArgumentList.Add("48000");
- startInfo.ArgumentList.Add("-f");
- startInfo.ArgumentList.Add("s16le");
- startInfo.ArgumentList.Add("pipe:1");
-
- try
- {
- player.AudioDecoder = Process.Start(startInfo);
- if (player.AudioDecoder is null)
- {
- return false;
- }
- player.AudioDecoder.ErrorDataReceived += (_, eventArgs) =>
- {
- if (!string.IsNullOrWhiteSpace(eventArgs.Data))
- {
- Console.Error.WriteLine($"[AVPLAYER][FFMPEG-AUDIO] {eventArgs.Data}");
- }
- };
- player.AudioDecoder.BeginErrorReadLine();
- player.AudioDecoderOutput = player.AudioDecoder.StandardOutput.BaseStream;
- player.RawAudioFrame = new byte[1024 * 2 * sizeof(short)];
- Trace($"audio_decoder_started pid={player.AudioDecoder.Id} source='{player.SourcePath}'");
- return true;
- }
- catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception)
- {
- Console.Error.WriteLine($"[AVPLAYER][ERROR] Failed to launch FFmpeg audio decoder: {exception.Message}");
- player.AudioDecoderOutput?.Dispose();
- player.AudioDecoderOutput = null;
- player.AudioDecoder?.Dispose();
- player.AudioDecoder = null;
return false;
}
+
+ player.AudioDecoderOutput = audioStream;
+ player.RawAudioFrame = new byte[1024 * FfmpegMediaStream.AudioChannels * sizeof(short)];
+ Trace($"audio_decoder_started source='{player.SourcePath}' s16 stereo 48000");
+ return true;
}
private static bool ReadFrame(PlayerState player)
@@ -923,9 +856,9 @@ public static class AvPlayerExports
return false;
}
- var alignedWidth = AlignUp(player.Width, 16);
- var alignedHeight = AlignUp(player.Height, 16);
- var bufferStride = checked(alignedWidth * alignedHeight * 3 / 2);
+ var alignedWidth = AlignUp(player.Width, FramePitchAlignment);
+ var alignedHeight = AlignUp(player.Height, FrameHeightAlignment);
+ var bufferStride = GetVideoBufferSize(player);
if (player.GuestBuffers[0] == 0)
{
if (!AllocateGuestVideoBuffers(ctx, player, bufferStride))
@@ -981,39 +914,78 @@ public static class AvPlayerExports
return ctx.Memory.TryWrite(infoAddress, info);
}
+ private static int GetVideoBufferSize(PlayerState player) =>
+ checked(
+ AlignUp(player.Width, FramePitchAlignment) *
+ AlignUp(player.Height, FrameHeightAlignment) * 3 / 2);
+
+ private static void EnsureGuestVideoBuffers(CpuContext ctx, PlayerState player)
+ {
+ lock (StateGate)
+ {
+ if (player.GuestBuffers[0] != 0 || player.Width <= 0 || player.Height <= 0)
+ {
+ return;
+ }
+
+ var bufferSize = GetVideoBufferSize(player);
+ if (AllocateGuestVideoBuffers(ctx, player, bufferSize))
+ {
+ player.GuestBufferStride = bufferSize;
+ }
+ }
+ }
+
private static bool AllocateGuestVideoBuffers(CpuContext ctx, PlayerState player, int bufferSize)
{
var scheduler = GuestThreadExecution.Scheduler;
- if (!player.TextureAllocatorFailed && player.AllocateTextureCallback != 0 && scheduler is not null)
+ if (!player.TextureAllocatorFailed && scheduler is not null)
{
- for (var index = 0; index < player.GuestBuffers.Length; index++)
+ foreach (var (callback, kind) in new[]
+ {
+ (player.AllocateTextureCallback, "texture"),
+ (player.AllocateCallback, "generic"),
+ })
{
- if (!scheduler.TryCallGuestFunction(
- ctx,
- player.AllocateTextureCallback,
- player.AllocatorObject,
- 0x100,
- checked((ulong)bufferSize),
- 0,
- 0,
- "avplayer_allocate_texture",
- out var buffer,
- out var error) || buffer == 0)
+ if (callback == 0)
{
- Console.Error.WriteLine(
- $"[AVPLAYER][ERROR] Guest texture allocation failed index={index} " +
- $"callback=0x{player.AllocateTextureCallback:X16}: {error ?? "returned null"}");
- player.TextureAllocatorFailed = true;
- Array.Clear(player.GuestBuffers);
- break;
+ continue;
+ }
+
+ var allocated = true;
+ for (var index = 0; index < player.GuestBuffers.Length; index++)
+ {
+ if (!scheduler.TryCallGuestFunction(
+ ctx,
+ callback,
+ player.AllocatorObject,
+ TextureAllocationAlignment,
+ checked((ulong)bufferSize),
+ 0,
+ 0,
+ "avplayer_allocate_" + kind,
+ out var buffer,
+ out var error) || buffer == 0)
+ {
+ Console.Error.WriteLine(
+ $"[AVPLAYER][WARN] Guest {kind} allocation failed index={index} " +
+ $"callback=0x{callback:X16} size={bufferSize} " +
+ $"align=0x{TextureAllocationAlignment:X}: {error ?? "returned null"}");
+ allocated = false;
+ Array.Clear(player.GuestBuffers);
+ break;
+ }
+ player.GuestBuffers[index] = buffer;
+ Trace($"{kind}_buffer index={index} data=0x{buffer:X16} size={bufferSize}");
+ }
+
+ if (allocated)
+ {
+ return true;
}
- player.GuestBuffers[index] = buffer;
- Trace($"texture_buffer index={index} data=0x{buffer:X16} size={bufferSize}");
- }
- if (!player.TextureAllocatorFailed)
- {
- return true;
}
+
+ player.TextureAllocatorFailed = true;
}
if (!KernelMemoryCompatExports.TryAllocateHleData(
@@ -1043,158 +1015,25 @@ public static class AvPlayerExports
height = 0;
framesPerSecond = 30.0;
durationMilliseconds = 0;
- var ffmpeg = FindFfmpeg();
- if (ffmpeg is null)
- {
- return false;
- }
- var ffprobe = GetFfprobePath(ffmpeg, OperatingSystem.IsWindows());
- if (!File.Exists(ffprobe))
+
+ if (!FfmpegMediaStream.TryProbe(path, out width, out height, out var rate, out var duration))
{
return false;
}
- var startInfo = new ProcessStartInfo(ffprobe)
+ if (rate > 0)
{
- UseShellExecute = false,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- CreateNoWindow = true,
- };
- startInfo.ArgumentList.Add("-v");
- startInfo.ArgumentList.Add("error");
- startInfo.ArgumentList.Add("-select_streams");
- startInfo.ArgumentList.Add("v:0");
- startInfo.ArgumentList.Add("-show_entries");
- startInfo.ArgumentList.Add("stream=width,height,avg_frame_rate,duration");
- startInfo.ArgumentList.Add("-of");
- startInfo.ArgumentList.Add("default=noprint_wrappers=1");
- startInfo.ArgumentList.Add(path);
-
- try
- {
- using var process = Process.Start(startInfo);
- if (process is null)
- {
- return false;
- }
- var output = process.StandardOutput.ReadToEnd();
- var error = process.StandardError.ReadToEnd();
- process.WaitForExit();
- if (process.ExitCode != 0)
- {
- Console.Error.WriteLine($"[AVPLAYER][FFPROBE] {error.Trim()}");
- return false;
- }
-
- foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
- {
- var separator = line.IndexOf('=');
- if (separator < 1)
- {
- continue;
- }
- var key = line[..separator];
- var value = line[(separator + 1)..];
- switch (key)
- {
- case "width":
- _ = int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out width);
- break;
- case "height":
- _ = int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out height);
- break;
- case "avg_frame_rate":
- var parts = value.Split('/');
- if (parts.Length == 2 &&
- double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var numerator) &&
- double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var denominator) &&
- denominator != 0)
- {
- framesPerSecond = numerator / denominator;
- }
- break;
- case "duration":
- if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var duration))
- {
- durationMilliseconds = checked((ulong)Math.Max(0, Math.Round(duration * 1000.0)));
- }
- break;
- }
- }
- return width > 0 && height > 0 && framesPerSecond > 0;
+ framesPerSecond = rate;
}
- catch (Exception exception) when (exception is IOException or InvalidOperationException or System.ComponentModel.Win32Exception)
+
+ if (duration > 0)
{
- Console.Error.WriteLine($"[AVPLAYER][ERROR] Failed to probe video: {exception.Message}");
- return false;
+ durationMilliseconds = checked((ulong)Math.Max(0, Math.Round(duration * 1000.0)));
}
+
+ return width > 0 && height > 0 && framesPerSecond > 0;
}
- internal static string? FindFfmpeg() =>
- FindFfmpeg(
- Environment.GetEnvironmentVariable("SHARPEMU_FFMPEG_PATH"),
- Environment.GetEnvironmentVariable("PATH"),
- OperatingSystem.IsWindows(),
- AppContext.BaseDirectory);
-
- internal static string? FindFfmpeg(
- string? configured,
- string? searchPath,
- bool isWindows,
- string? baseDirectory = null)
- {
- if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
- {
- return configured;
- }
-
- 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))
- {
- var candidate = Path.Combine(RemovePathQuotes(directory), executable);
- if (File.Exists(candidate))
- {
- return candidate;
- }
- }
-
- foreach (var candidate in new[] { "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg" })
- {
- if (File.Exists(candidate))
- {
- return candidate;
- }
- }
- return null;
- }
-
- internal static string GetFfprobePath(string ffmpeg, bool isWindows) =>
- Path.Combine(
- Path.GetDirectoryName(ffmpeg) ?? string.Empty,
- isWindows ? "ffprobe.exe" : "ffprobe");
-
- private static string RemovePathQuotes(string directory) =>
- directory.Length >= 2 && directory[0] == '"' && directory[^1] == '"'
- ? directory[1..^1]
- : directory;
-
internal static string? ResolveGuestPath(string guestPath)
{
if (string.IsNullOrWhiteSpace(guestPath))
@@ -1606,4 +1445,17 @@ public static class AvPlayerExports
Console.Error.WriteLine($"[AVPLAYER][INFO] {message}");
}
}
+
+ private static void TraceOnce(string key, string message)
+ {
+ lock (TracedOnce)
+ {
+ if (!TracedOnce.Add(key))
+ {
+ return;
+ }
+ }
+
+ Console.Error.WriteLine($"[AVPLAYER][INFO] {message}");
+ }
}
diff --git a/src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs b/src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs
deleted file mode 100644
index f2aae6d9..00000000
--- a/src/SharpEmu.Libs/Bink/FfmpegBinkFrameSource.cs
+++ /dev/null
@@ -1,166 +0,0 @@
-// Copyright (C) 2026 SharpEmu Emulator Project
-// SPDX-License-Identifier: GPL-2.0-or-later
-
-using System.Diagnostics;
-using SharpEmu.Libs.AvPlayer;
-
-namespace SharpEmu.Libs.Bink;
-
-internal sealed class FfmpegBinkFrameSource : IBinkFrameDecoder
-{
- private readonly Process _process;
- private readonly Stream _output;
- private int _errorLines;
- private int _disposed;
-
- private FfmpegBinkFrameSource(
- Process process,
- uint width,
- uint height,
- uint framesPerSecondNumerator,
- uint framesPerSecondDenominator)
- {
- _process = process;
- _output = process.StandardOutput.BaseStream;
- Width = width;
- Height = height;
- FramesPerSecondNumerator = framesPerSecondNumerator;
- FramesPerSecondDenominator = framesPerSecondDenominator;
- }
-
- public uint Width { get; }
-
- public uint Height { get; }
-
- public uint FramesPerSecondNumerator { get; }
-
- public uint FramesPerSecondDenominator { get; }
-
- internal static bool IsAvailable => AvPlayerExports.FindFfmpeg() is not null;
-
- internal static bool TryOpen(
- string path,
- uint width,
- uint height,
- uint framesPerSecondNumerator,
- uint framesPerSecondDenominator,
- out FfmpegBinkFrameSource? source)
- {
- source = null;
- var ffmpeg = AvPlayerExports.FindFfmpeg();
- if (ffmpeg is null)
- {
- return false;
- }
-
- var startInfo = new ProcessStartInfo(ffmpeg)
- {
- UseShellExecute = false,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- CreateNoWindow = true,
- };
- startInfo.ArgumentList.Add("-nostdin");
- startInfo.ArgumentList.Add("-hide_banner");
- startInfo.ArgumentList.Add("-loglevel");
- startInfo.ArgumentList.Add("error");
- startInfo.ArgumentList.Add("-i");
- startInfo.ArgumentList.Add(path);
- startInfo.ArgumentList.Add("-map");
- startInfo.ArgumentList.Add("0:v:0");
- startInfo.ArgumentList.Add("-an");
- startInfo.ArgumentList.Add("-pix_fmt");
- startInfo.ArgumentList.Add("bgra");
- startInfo.ArgumentList.Add("-f");
- startInfo.ArgumentList.Add("rawvideo");
- startInfo.ArgumentList.Add("pipe:1");
-
- try
- {
- var process = Process.Start(startInfo);
- if (process is null)
- {
- return false;
- }
-
- source = new FfmpegBinkFrameSource(
- process,
- width,
- height,
- framesPerSecondNumerator,
- framesPerSecondDenominator);
- process.ErrorDataReceived += source.OnErrorData;
- process.BeginErrorReadLine();
- return true;
- }
- catch (Exception exception) when (exception is IOException or
- InvalidOperationException or
- System.ComponentModel.Win32Exception)
- {
- Console.Error.WriteLine(
- $"[LOADER][WARN] Bink FFmpeg decoder could not start: {exception.Message}");
- return false;
- }
- }
-
- public bool TryDecodeNextFrame(Span destination)
- {
- try
- {
- var offset = 0;
- while (offset < destination.Length)
- {
- var read = _output.Read(destination[offset..]);
- if (read == 0)
- {
- return false;
- }
- offset += read;
- }
- return true;
- }
- catch (Exception exception) when (exception is IOException or ObjectDisposedException)
- {
- if (Volatile.Read(ref _disposed) == 0)
- {
- Console.Error.WriteLine(
- $"[LOADER][WARN] Bink FFmpeg stream failed: {exception.Message}");
- }
- return false;
- }
- }
-
- private void OnErrorData(object sender, DataReceivedEventArgs eventArgs)
- {
- if (string.IsNullOrWhiteSpace(eventArgs.Data) ||
- Interlocked.Increment(ref _errorLines) > 20)
- {
- return;
- }
- Console.Error.WriteLine($"[LOADER][FFMPEG-BINK] {eventArgs.Data}");
- }
-
- public void Dispose()
- {
- if (Interlocked.Exchange(ref _disposed, 1) != 0)
- {
- return;
- }
-
- _output.Dispose();
- try
- {
- if (!_process.HasExited)
- {
- _process.Kill(entireProcessTree: true);
- }
- }
- catch (InvalidOperationException)
- {
- }
- finally
- {
- _process.Dispose();
- }
- }
-}
diff --git a/src/SharpEmu.Libs/Font/FontExports.cs b/src/SharpEmu.Libs/Font/FontExports.cs
index fab39708..57c3ff1f 100644
--- a/src/SharpEmu.Libs/Font/FontExports.cs
+++ b/src/SharpEmu.Libs/Font/FontExports.cs
@@ -8,7 +8,13 @@ namespace SharpEmu.Libs.Font;
public static class FontExports
{
+ private const ushort GlyphMagic = 0x0F03;
+ private const int GlyphSize = 0x100;
+ private const int GlyphMetricsSize = 8 * sizeof(float);
+ private const int RenderOutputSize = 0x40;
+
private static readonly object AllocationGate = new();
+ private static readonly Stack FreeGlyphs = new();
private static ulong _librarySelectionAddress;
private static ulong _rendererSelectionAddress;
@@ -321,6 +327,151 @@ public static class FontExports
return SetSuccess(ctx);
}
+ [SysAbiExport(
+ Nid = "C-4Qw5Srlyw",
+ ExportName = "sceFontGenerateCharGlyph",
+ Target = Generation.Gen5,
+ LibraryName = "libSceFont")]
+ public static int GenerateCharGlyph(CpuContext ctx)
+ {
+ var outputAddress = ctx[CpuRegister.Rcx];
+ if (outputAddress == 0)
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+
+ if (!TryRentGlyph(ctx, out var glyph) ||
+ !ctx.TryWriteUInt64(outputAddress, glyph))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ return SetSuccess(ctx);
+ }
+
+ [SysAbiExport(
+ Nid = "8-zmgsxkBek",
+ ExportName = "sceFontGlyphDefineAttribute",
+ Target = Generation.Gen5,
+ LibraryName = "libSceFont")]
+ public static int GlyphDefineAttribute(CpuContext ctx) => SetSuccess(ctx);
+
+ [SysAbiExport(
+ Nid = "LHDoRWVFGqk",
+ ExportName = "sceFontDeleteGlyph",
+ Target = Generation.Gen5,
+ LibraryName = "libSceFont")]
+ public static int DeleteGlyph(CpuContext ctx)
+ {
+ var glyphPointerAddress = ctx[CpuRegister.Rsi];
+ if (glyphPointerAddress == 0)
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+
+ if (!ctx.TryReadUInt64(glyphPointerAddress, out var glyph))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ if (glyph != 0)
+ {
+ lock (AllocationGate)
+ {
+ FreeGlyphs.Push(glyph);
+ }
+ }
+
+ return ctx.TryWriteUInt64(glyphPointerAddress, 0)
+ ? SetSuccess(ctx)
+ : SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ [SysAbiExport(
+ Nid = "kAenWy1Zw5o",
+ ExportName = "sceFontRenderCharGlyphImageHorizontal",
+ Target = Generation.Gen5,
+ LibraryName = "libSceFont")]
+ public static int RenderCharGlyphImageHorizontal(CpuContext ctx)
+ {
+ var metricsAddress = ctx[CpuRegister.Rcx];
+ var resultAddress = ctx[CpuRegister.R8];
+
+ if (metricsAddress != 0)
+ {
+ var values = new[] { 8.0f, 16.0f, 0.0f, 12.0f, 8.0f, 0.0f, 0.0f, 16.0f };
+ for (var index = 0; index < values.Length; index++)
+ {
+ if (!TryWriteUInt32(
+ ctx,
+ metricsAddress + (ulong)(index * sizeof(float)),
+ BitConverter.SingleToUInt32Bits(values[index])))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+ }
+ }
+
+ if (resultAddress != 0)
+ {
+ Span cleared = stackalloc byte[RenderOutputSize];
+ cleared.Clear();
+ if (!ctx.Memory.TryWrite(resultAddress, cleared))
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+ }
+
+ return SetSuccess(ctx);
+ }
+
+ [SysAbiExport(
+ Nid = "vzHs3C8lWJk",
+ ExportName = "sceFontCloseFont",
+ Target = Generation.Gen5,
+ LibraryName = "libSceFont")]
+ public static int CloseFont(CpuContext ctx) => SetSuccess(ctx);
+
+ [SysAbiExport(
+ Nid = "1QjhKxrsOB8",
+ ExportName = "sceFontUnbindRenderer",
+ Target = Generation.Gen5,
+ LibraryName = "libSceFont")]
+ public static int UnbindRenderer(CpuContext ctx) => SetSuccess(ctx);
+
+ [SysAbiExport(
+ Nid = "exAxkyVLt0s",
+ ExportName = "sceFontDestroyRenderer",
+ Target = Generation.Gen5,
+ LibraryName = "libSceFont")]
+ public static int DestroyRenderer(CpuContext ctx)
+ {
+ var rendererPointerAddress = ctx[CpuRegister.Rdi];
+ if (rendererPointerAddress == 0)
+ {
+ return SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
+ }
+
+ return ctx.TryWriteUInt64(rendererPointerAddress, 0)
+ ? SetSuccess(ctx)
+ : SetReturn(ctx, OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
+ }
+
+ private static bool TryRentGlyph(CpuContext ctx, out ulong glyph)
+ {
+ lock (AllocationGate)
+ {
+ if (FreeGlyphs.Count > 0)
+ {
+ glyph = FreeGlyphs.Pop();
+ return TryWriteUInt16(ctx, glyph, GlyphMagic);
+ }
+ }
+
+ return TryAllocateOpaque(ctx, GlyphSize, out glyph) &&
+ TryWriteUInt16(ctx, glyph, GlyphMagic);
+ }
+
private static int ReturnSelection(CpuContext ctx, ref ulong selectionAddress, uint objectSize)
{
if (ctx[CpuRegister.Rdi] != 0)
diff --git a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
index 319ee53d..2ff1d1f8 100644
--- a/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
+++ b/src/SharpEmu.Libs/Kernel/KernelMemoryCompatExports.cs
@@ -3,7 +3,7 @@
using SharpEmu.HLE;
using SharpEmu.Libs.Ampr;
-using SharpEmu.Libs.Bink;
+using SharpEmu.Libs.Media;
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Concurrent;
@@ -97,7 +97,7 @@ public static partial class KernelMemoryCompatExports
private static readonly object _fdGate = new();
private static readonly Dictionary _openFiles = new();
- private static readonly Dictionary
+ private static readonly Dictionary
_binkGuestCompletionShims = new();
private static readonly Dictionary _observedBinkGuestFiles = new();
private static readonly Dictionary _openDirectories = new();
@@ -1478,7 +1478,7 @@ public static partial class KernelMemoryCompatExports
}
try
{
- if (Bink2MovieBridge.ShouldSkipGuestMovie(hostPath))
+ if (HostMovieBridge.ShouldSkipGuestMovie(hostPath))
{
LogOpenTrace(
"_open bink-skip path='" + guestPath + "' host='" + hostPath +
@@ -1489,10 +1489,10 @@ public static partial class KernelMemoryCompatExports
return (int)OrbisGen2Result.ORBIS_GEN2_ERROR_NOT_FOUND;
}
- Bink2MovieBridge.BinkGuestCompletionShim binkCompletionShim = default;
+ HostMovieBridge.BinkGuestCompletionShim binkCompletionShim = default;
var observedBinkMovie = false;
var useBinkCompletionShim = access == FileAccess.Read &&
- Bink2MovieBridge.TryTakeOverGuestMovie(
+ HostMovieBridge.TryTakeOverGuestMovie(
hostPath,
out binkCompletionShim,
out observedBinkMovie);
@@ -2227,7 +2227,7 @@ public static partial class KernelMemoryCompatExports
if (notifyBinkClose)
{
- Bink2MovieBridge.NotifyGuestMovieClosed(observedBinkPath!);
+ HostMovieBridge.NotifyGuestMovieClosed(observedBinkPath!);
}
stream.Dispose();
ctx[CpuRegister.Rax] = 0;
@@ -2256,7 +2256,7 @@ public static partial class KernelMemoryCompatExports
}
FileStream? stream;
- Bink2MovieBridge.BinkGuestCompletionShim completionShim = default;
+ HostMovieBridge.BinkGuestCompletionShim completionShim = default;
var useBinkCompletionShim = false;
lock (_fdGate)
{
@@ -2289,7 +2289,7 @@ public static partial class KernelMemoryCompatExports
// logic can't race ahead of what's still on screen.
if (completionShim.Patch(positionBefore, buffer.AsSpan(0, read)))
{
- Bink2MovieBridge.WaitForHostPlaybackToFinish(stream.Name);
+ HostMovieBridge.WaitForHostPlaybackToFinish(stream.Name);
}
}
if (read > 0 && !ctx.Memory.TryWrite(bufferAddress, buffer.AsSpan(0, read)))
diff --git a/src/SharpEmu.Libs/Media/FfmpegMediaStream.cs b/src/SharpEmu.Libs/Media/FfmpegMediaStream.cs
new file mode 100644
index 00000000..804a8b34
--- /dev/null
+++ b/src/SharpEmu.Libs/Media/FfmpegMediaStream.cs
@@ -0,0 +1,489 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System;
+using System.IO;
+using System.Threading;
+using FFmpeg.AutoGen;
+
+namespace SharpEmu.Libs.Media;
+internal sealed unsafe class FfmpegMediaStream : Stream
+{
+ internal const int AudioSampleRate = 48000;
+ internal const int AudioChannels = 2;
+
+ private readonly object _decodeGate = new();
+ private readonly bool _isVideo;
+ private readonly int _videoWidth;
+ private readonly int _videoHeight;
+
+ private AVFormatContext* _formatContext;
+ private AVCodecContext* _codecContext;
+ private AVFrame* _frame;
+ private AVPacket* _packet;
+ private SwsContext* _swsContext;
+ private SwrContext* _swrContext;
+ private int _streamIndex;
+
+ private byte[] _pending = [];
+ private int _pendingOffset;
+ private bool _draining;
+ private bool _finished;
+ private int _disposed;
+
+ private FfmpegMediaStream(bool isVideo, int width, int height)
+ {
+ _isVideo = isVideo;
+ _videoWidth = width;
+ _videoHeight = height;
+ }
+
+ public override bool CanRead => true;
+
+ public override bool CanSeek => false;
+
+ public override bool CanWrite => false;
+
+ public override long Length => throw new NotSupportedException();
+
+ public override long Position
+ {
+ get => throw new NotSupportedException();
+ set => throw new NotSupportedException();
+ }
+
+ internal static bool TryOpenVideo(
+ string path,
+ int width,
+ int height,
+ out FfmpegMediaStream? stream) =>
+ TryOpen(path, AVMediaType.AVMEDIA_TYPE_VIDEO, width, height, out stream);
+
+ internal static bool TryOpenAudio(string path, out FfmpegMediaStream? stream) =>
+ TryOpen(path, AVMediaType.AVMEDIA_TYPE_AUDIO, 0, 0, out stream);
+
+ private static bool TryOpen(
+ string path,
+ AVMediaType mediaType,
+ int width,
+ int height,
+ out FfmpegMediaStream? stream)
+ {
+ stream = null;
+ FfmpegRuntime.EnsureInitialized();
+
+ var candidate = new FfmpegMediaStream(mediaType == AVMediaType.AVMEDIA_TYPE_VIDEO, width, height);
+ AVFormatContext* formatContext = null;
+ try
+ {
+ if (ffmpeg.avformat_open_input(&formatContext, path, null, null) < 0)
+ {
+ return false;
+ }
+
+ if (ffmpeg.avformat_find_stream_info(formatContext, null) < 0)
+ {
+ ffmpeg.avformat_close_input(&formatContext);
+ return false;
+ }
+
+ AVCodec* decoder = null;
+ var streamIndex = ffmpeg.av_find_best_stream(formatContext, mediaType, -1, -1, &decoder, 0);
+ if (streamIndex < 0 || decoder is null)
+ {
+ ffmpeg.avformat_close_input(&formatContext);
+ return false;
+ }
+
+ var codecContext = ffmpeg.avcodec_alloc_context3(decoder);
+ if (codecContext is null ||
+ ffmpeg.avcodec_parameters_to_context(
+ codecContext,
+ formatContext->streams[streamIndex]->codecpar) < 0 ||
+ ffmpeg.avcodec_open2(codecContext, decoder, null) < 0)
+ {
+ if (codecContext is not null)
+ {
+ ffmpeg.avcodec_free_context(&codecContext);
+ }
+
+ ffmpeg.avformat_close_input(&formatContext);
+ return false;
+ }
+
+ candidate._formatContext = formatContext;
+ candidate._codecContext = codecContext;
+ candidate._streamIndex = streamIndex;
+ candidate._frame = ffmpeg.av_frame_alloc();
+ candidate._packet = ffmpeg.av_packet_alloc();
+ if (candidate._frame is null || candidate._packet is null)
+ {
+ candidate.Dispose();
+ return false;
+ }
+
+ stream = candidate;
+ return true;
+ }
+ catch (Exception exception)
+ {
+ Console.Error.WriteLine(
+ $"[AVPLAYER][ERROR] in-process decoder failed to open '{path}': {exception.Message}");
+ if (formatContext is not null)
+ {
+ ffmpeg.avformat_close_input(&formatContext);
+ }
+
+ candidate.Dispose();
+ return false;
+ }
+ }
+
+ internal static bool TryProbe(
+ string path,
+ out int width,
+ out int height,
+ out double frameRate,
+ out double durationSeconds)
+ {
+ width = 0;
+ height = 0;
+ frameRate = 0;
+ durationSeconds = 0;
+ FfmpegRuntime.EnsureInitialized();
+
+ AVFormatContext* formatContext = 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;
+ }
+
+ var streamIndex = ffmpeg.av_find_best_stream(
+ formatContext, AVMediaType.AVMEDIA_TYPE_VIDEO, -1, -1, null, 0);
+ if (streamIndex < 0)
+ {
+ return false;
+ }
+
+ var stream = formatContext->streams[streamIndex];
+ width = stream->codecpar->width;
+ height = stream->codecpar->height;
+
+ var rate = stream->avg_frame_rate;
+ if (rate.den > 0 && rate.num > 0)
+ {
+ frameRate = (double)rate.num / rate.den;
+ }
+
+ if (stream->duration > 0 && stream->time_base.den > 0)
+ {
+ durationSeconds = stream->duration *
+ ((double)stream->time_base.num / stream->time_base.den);
+ }
+ else if (formatContext->duration > 0)
+ {
+ durationSeconds = (double)formatContext->duration / ffmpeg.AV_TIME_BASE;
+ }
+
+ return width > 0 && height > 0;
+ }
+ finally
+ {
+ if (formatContext is not null)
+ {
+ ffmpeg.avformat_close_input(&formatContext);
+ }
+ }
+ }
+
+ public override int Read(byte[] buffer, int offset, int count) =>
+ Read(buffer.AsSpan(offset, count));
+
+ public override int Read(Span buffer)
+ {
+ if (buffer.IsEmpty)
+ {
+ return 0;
+ }
+
+ lock (_decodeGate)
+ {
+ if (Volatile.Read(ref _disposed) != 0)
+ {
+ return 0;
+ }
+
+ var written = 0;
+ while (written < buffer.Length)
+ {
+ if (_pendingOffset >= _pending.Length)
+ {
+ if (_finished || !TryDecodeIntoPending())
+ {
+ break;
+ }
+
+ continue;
+ }
+
+ var available = _pending.Length - _pendingOffset;
+ var take = Math.Min(available, buffer.Length - written);
+ _pending.AsSpan(_pendingOffset, take).CopyTo(buffer[written..]);
+ _pendingOffset += take;
+ written += take;
+ }
+
+ return written;
+ }
+ }
+
+ private bool TryDecodeIntoPending()
+ {
+ if (!TryReceiveFrame())
+ {
+ _finished = true;
+ return false;
+ }
+
+ var produced = _isVideo ? ConvertVideoFrame() : ConvertAudioFrame();
+ ffmpeg.av_frame_unref(_frame);
+ if (produced is null)
+ {
+ _finished = true;
+ return false;
+ }
+
+ _pending = produced;
+ _pendingOffset = 0;
+ return true;
+ }
+
+ private byte[]? ConvertVideoFrame()
+ {
+ var width = _videoWidth > 0 ? _videoWidth : _frame->width;
+ var height = _videoHeight > 0 ? _videoHeight : _frame->height;
+ if (width <= 0 || height <= 0)
+ {
+ return null;
+ }
+
+ _swsContext = ffmpeg.sws_getCachedContext(
+ _swsContext,
+ _frame->width,
+ _frame->height,
+ (AVPixelFormat)_frame->format,
+ width,
+ height,
+ AVPixelFormat.AV_PIX_FMT_NV12,
+ ffmpeg.SWS_FAST_BILINEAR,
+ null,
+ null,
+ null);
+ if (_swsContext is null)
+ {
+ return null;
+ }
+ var lumaBytes = width * height;
+ var output = new byte[lumaBytes + lumaBytes / 2];
+ fixed (byte* outputPointer = output)
+ {
+ var planes = new byte*[4] { outputPointer, outputPointer + lumaBytes, null, null };
+ var strides = new int[4] { width, width, 0, 0 };
+ var rows = ffmpeg.sws_scale(
+ _swsContext,
+ _frame->data,
+ _frame->linesize,
+ 0,
+ _frame->height,
+ planes,
+ strides);
+ return rows == height ? output : null;
+ }
+ }
+
+ private byte[]? ConvertAudioFrame()
+ {
+ var outputLayout = new AVChannelLayout();
+ ffmpeg.av_channel_layout_default(&outputLayout, AudioChannels);
+
+ var inputLayout = _frame->ch_layout;
+ SwrContext* swrContext = _swrContext;
+ var configureResult = ffmpeg.swr_alloc_set_opts2(
+ &swrContext,
+ &outputLayout,
+ AVSampleFormat.AV_SAMPLE_FMT_S16,
+ AudioSampleRate,
+ &inputLayout,
+ (AVSampleFormat)_frame->format,
+ _frame->sample_rate,
+ 0,
+ null);
+ _swrContext = swrContext;
+ if (configureResult < 0 || _swrContext is null)
+ {
+ return null;
+ }
+
+ if (ffmpeg.swr_is_initialized(_swrContext) == 0 && ffmpeg.swr_init(_swrContext) < 0)
+ {
+ return null;
+ }
+
+ var maxSamples = (int)ffmpeg.av_rescale_rnd(
+ ffmpeg.swr_get_delay(_swrContext, _frame->sample_rate) + _frame->nb_samples,
+ AudioSampleRate,
+ _frame->sample_rate,
+ AVRounding.AV_ROUND_UP);
+ if (maxSamples <= 0)
+ {
+ return null;
+ }
+
+ var output = new byte[maxSamples * AudioChannels * sizeof(short)];
+ fixed (byte* outputPointer = output)
+ {
+ var planes = stackalloc byte*[1];
+ planes[0] = outputPointer;
+ var converted = ffmpeg.swr_convert(
+ _swrContext,
+ planes,
+ maxSamples,
+ _frame->extended_data,
+ _frame->nb_samples);
+ if (converted < 0)
+ {
+ return null;
+ }
+
+ var byteCount = converted * AudioChannels * sizeof(short);
+ if (byteCount == output.Length)
+ {
+ return output;
+ }
+
+ var trimmed = new byte[byteCount];
+ output.AsSpan(0, byteCount).CopyTo(trimmed);
+ return trimmed;
+ }
+ }
+
+ private bool TryReceiveFrame()
+ {
+ while (true)
+ {
+ var receiveResult = ffmpeg.avcodec_receive_frame(_codecContext, _frame);
+ if (receiveResult >= 0)
+ {
+ return true;
+ }
+
+ if (receiveResult == ffmpeg.AVERROR_EOF ||
+ receiveResult != ffmpeg.AVERROR(ffmpeg.EAGAIN) ||
+ _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;
+ return ffmpeg.avcodec_send_packet(_codecContext, null) >= 0;
+ }
+
+ if (_packet->stream_index != _streamIndex)
+ {
+ ffmpeg.av_packet_unref(_packet);
+ continue;
+ }
+
+ var sendResult = ffmpeg.avcodec_send_packet(_codecContext, _packet);
+ ffmpeg.av_packet_unref(_packet);
+ return sendResult >= 0;
+ }
+ }
+
+ public override void Flush()
+ {
+ }
+
+ public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
+
+ public override void SetLength(long value) => throw new NotSupportedException();
+
+ public override void Write(byte[] buffer, int offset, int count) =>
+ throw new NotSupportedException();
+
+ protected override void Dispose(bool disposing)
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) != 0)
+ {
+ base.Dispose(disposing);
+ return;
+ }
+
+ lock (_decodeGate)
+ {
+ if (_swsContext is not null)
+ {
+ ffmpeg.sws_freeContext(_swsContext);
+ _swsContext = null;
+ }
+
+ if (_swrContext is not null)
+ {
+ var swrContext = _swrContext;
+ ffmpeg.swr_free(&swrContext);
+ _swrContext = null;
+ }
+
+ if (_frame is not null)
+ {
+ var frame = _frame;
+ ffmpeg.av_frame_free(&frame);
+ _frame = null;
+ }
+
+ if (_packet is not null)
+ {
+ var packet = _packet;
+ ffmpeg.av_packet_free(&packet);
+ _packet = 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;
+ }
+ }
+
+ base.Dispose(disposing);
+ }
+}
diff --git a/src/SharpEmu.Libs/Media/FfmpegRuntime.cs b/src/SharpEmu.Libs/Media/FfmpegRuntime.cs
new file mode 100644
index 00000000..6c8a0a31
--- /dev/null
+++ b/src/SharpEmu.Libs/Media/FfmpegRuntime.cs
@@ -0,0 +1,32 @@
+// Copyright (C) 2026 SharpEmu Emulator Project
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+using System;
+using System.IO;
+using FFmpeg.AutoGen;
+
+namespace SharpEmu.Libs.Media;
+internal static class FfmpegRuntime
+{
+ private static readonly object _gate = new();
+ private static bool _initialized;
+ internal static void EnsureInitialized()
+ {
+ if (_initialized)
+ {
+ return;
+ }
+
+ lock (_gate)
+ {
+ if (_initialized)
+ {
+ return;
+ }
+
+ _initialized = true;
+ ffmpeg.RootPath = Path.Combine(AppContext.BaseDirectory, "plugins");
+ DynamicallyLoadedBindings.Initialize();
+ }
+ }
+}
diff --git a/src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs b/src/SharpEmu.Libs/Media/FfmpegVideoDecoder.cs
similarity index 92%
rename from src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs
rename to src/SharpEmu.Libs/Media/FfmpegVideoDecoder.cs
index 0551ad70..25af92d0 100644
--- a/src/SharpEmu.Libs/Bink/FfmpegNativeBinkFrameSource.cs
+++ b/src/SharpEmu.Libs/Media/FfmpegVideoDecoder.cs
@@ -5,7 +5,7 @@ using System.Buffers;
using FFmpeg.AutoGen;
using SharpEmu.HLE.Host;
-namespace SharpEmu.Libs.Bink;
+namespace SharpEmu.Libs.Media;
///
/// Decodes a .bk2 (or any FFmpeg-readable movie) directly via FFmpeg's C API
@@ -13,7 +13,7 @@ namespace SharpEmu.Libs.Bink;
/// libraries published by github.com/sharpemu/ffmpeg-core -- no native C
/// bridge of our own to build. See docs/bink2-bridge.md.
///
-internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
+internal sealed unsafe class FfmpegVideoDecoder : IMediaFrameDecoder
{
private const int OutputAudioChannels = 2;
private const int OutputAudioBytesPerSample = sizeof(short);
@@ -48,7 +48,7 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
public uint FramesPerSecondDenominator { get; }
- private FfmpegNativeBinkFrameSource(
+ private FfmpegVideoDecoder(
AVFormatContext* formatContext,
AVCodecContext* codecContext,
int videoStreamIndex,
@@ -77,43 +77,14 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
_packet = ffmpeg.av_packet_alloc();
}
- private static bool _rootPathInitialized;
-
- ///
- /// 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.
- ///
- 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();
- }
+ private static void EnsureRootPathInitialized() =>
+ SharpEmu.Libs.Media.FfmpegRuntime.EnsureInitialized();
internal static bool TryOpen(
string path,
uint maximumWidth,
uint maximumHeight,
- out FfmpegNativeBinkFrameSource? source)
+ out FfmpegVideoDecoder? source)
{
source = null;
EnsureRootPathInitialized();
@@ -222,7 +193,7 @@ internal sealed unsafe class FfmpegNativeBinkFrameSource : IBinkFrameDecoder
outputHeight = Math.Max(1, outputHeight);
}
- source = new FfmpegNativeBinkFrameSource(
+ source = new FfmpegVideoDecoder(
formatContext,
codecContext,
videoStreamIndex,
diff --git a/src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs b/src/SharpEmu.Libs/Media/HostMovieBridge.cs
similarity index 89%
rename from src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs
rename to src/SharpEmu.Libs/Media/HostMovieBridge.cs
index 907c2f08..bc8ac84e 100644
--- a/src/SharpEmu.Libs/Bink/Bink2MovieBridge.cs
+++ b/src/SharpEmu.Libs/Media/HostMovieBridge.cs
@@ -4,29 +4,44 @@
using System.Runtime.InteropServices;
using System.Buffers.Binary;
-namespace SharpEmu.Libs.Bink;
+namespace SharpEmu.Libs.Media;
///
-/// Optional host-side Bink 2 bridge for games that ship a static Bink player.
+/// Host-side movie bridge for games that decode video inside their own
+/// executable instead of going through an HLE decoder.
///
-/// The game in that case never imports libSceVideodec, so an HLE video-decoder
-/// export cannot see its movie frames. Kernel file opens identify the active
-/// .bk2 file and the presenter requests BGRA frames from a tiny native adapter.
-/// The adapter is deliberately a separate, user-supplied library: Bink 2 is a
-/// proprietary SDK and SharpEmu must neither bundle it nor depend on its ABI.
+/// Such a game never imports libSceVideodec or sceAvPlayer, so no HLE export
+/// can see its movie frames. Kernel file opens identify the active movie and
+/// the presenter requests BGRA frames from —
+/// the same decoder sceAvPlayer uses, so every format is handled in one place.
///
-internal static class Bink2MovieBridge
+internal static class HostMovieBridge
{
private const uint MaxDimension = 16384;
private const uint MaxHostVideoWidth = 1920;
private const uint MaxHostVideoHeight = 1080;
+ private static readonly string[] SelfDecodedMovieExtensions = [".bk2"];
+
+ private static bool IsSelfDecodedMovie(string hostPath)
+ {
+ foreach (var extension in SelfDecodedMovieExtensions)
+ {
+ if (hostPath.EndsWith(extension, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
private static readonly object Gate = new();
private static string? _activePath;
private static Bink2MovieInfo _activeInfo;
private static byte[]? _frameBuffer;
private static bool _frameBufferPresented;
- private static BinkFramePlayback? _playback;
+ private static MediaFramePlayback? _playback;
private static long _frameSerial;
private static uint _presentationWidth = MaxHostVideoWidth;
private static uint _presentationHeight = MaxHostVideoHeight;
@@ -62,7 +77,7 @@ internal static class Bink2MovieBridge
/// statically linked into its executable.
///
internal static bool ShouldSkipGuestMovie(string hostPath) =>
- hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) &&
+ IsSelfDecodedMovie(hostPath) &&
ResolveMode() == MovieMode.Skip;
///
@@ -71,8 +86,7 @@ internal static class Bink2MovieBridge
///
internal static bool ObserveGuestMovie(string hostPath)
{
- if (!hostPath.EndsWith(".bk2", StringComparison.OrdinalIgnoreCase) ||
- !File.Exists(hostPath))
+ if (!IsSelfDecodedMovie(hostPath) || !File.Exists(hostPath))
{
return false;
}
@@ -186,9 +200,6 @@ internal static class Bink2MovieBridge
case MovieMode.Dummy:
AttachDummyMovieLocked(hostPath);
return;
- case MovieMode.Ffmpeg:
- AttachFfmpegMovieLocked(hostPath);
- return;
case MovieMode.Native:
AttachNativeMovieLocked(hostPath);
return;
@@ -197,7 +208,7 @@ internal static class Bink2MovieBridge
private static void AttachNativeMovieLocked(string hostPath)
{
- if (!FfmpegNativeBinkFrameSource.TryOpen(
+ if (!FfmpegVideoDecoder.TryOpen(
hostPath, _presentationWidth, _presentationHeight, out var source) ||
source is null)
{
@@ -250,14 +261,13 @@ internal static class Bink2MovieBridge
if (string.Equals(configured, "ffmpeg", StringComparison.OrdinalIgnoreCase))
{
- return MovieMode.Ffmpeg;
+ return MovieMode.Native;
}
- // 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.
+ // Native is the default: FfmpegVideoDecoder.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 it is safe.
return MovieMode.Native;
}
@@ -282,43 +292,15 @@ internal static class Bink2MovieBridge
info.Width + "x" + info.Height + ".");
}
- 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)
+ IMediaFrameDecoder decoder)
{
CloseActiveLocked();
_activePath = hostPath;
_activeInfo = info;
- _playback = new BinkFramePlayback(decoder);
+ _playback = new MediaFramePlayback(decoder);
}
internal static bool TryReadBinkInfo(string path, out Bink2MovieInfo info)
@@ -406,7 +388,6 @@ internal static class Bink2MovieBridge
Skip,
Dummy,
Native,
- Ffmpeg,
}
private static readonly Queue PendingMoviePaths = new();
diff --git a/src/SharpEmu.Libs/Bink/BinkFramePlayback.cs b/src/SharpEmu.Libs/Media/MediaFramePlayback.cs
similarity index 97%
rename from src/SharpEmu.Libs/Bink/BinkFramePlayback.cs
rename to src/SharpEmu.Libs/Media/MediaFramePlayback.cs
index 1f392e6e..8014fab3 100644
--- a/src/SharpEmu.Libs/Bink/BinkFramePlayback.cs
+++ b/src/SharpEmu.Libs/Media/MediaFramePlayback.cs
@@ -4,9 +4,9 @@
using System.Diagnostics;
using SharpEmu.HLE.Host;
-namespace SharpEmu.Libs.Bink;
+namespace SharpEmu.Libs.Media;
-internal interface IBinkFrameDecoder : IDisposable
+internal interface IMediaFrameDecoder : IDisposable
{
uint Width { get; }
@@ -23,12 +23,12 @@ internal interface IBinkFrameDecoder : IDisposable
/// Keeps blocking codec work away from the Vulkan presentation thread and
/// releases decoded frames according to the movie time base.
///
-internal sealed class BinkFramePlayback : IDisposable
+internal sealed class MediaFramePlayback : IDisposable
{
private const int BufferCount = 5;
private readonly object _gate = new();
- private readonly IBinkFrameDecoder _decoder;
+ private readonly IMediaFrameDecoder _decoder;
private readonly Queue _freeBuffers = new();
private readonly Queue _decodedFrames = new();
private readonly Thread _decoderThread;
@@ -45,7 +45,7 @@ internal sealed class BinkFramePlayback : IDisposable
private bool _finished;
private int _disposed;
- internal BinkFramePlayback(IBinkFrameDecoder decoder)
+ internal MediaFramePlayback(IMediaFrameDecoder decoder)
{
_decoder = decoder;
Width = decoder.Width;
diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
index 6de2f2a3..6417b336 100644
--- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
+++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs
@@ -5,7 +5,7 @@ using Silk.NET.Core;
using Silk.NET.Core.Native;
using SharpEmu.HLE;
using SharpEmu.Libs.Agc;
-using SharpEmu.Libs.Bink;
+using SharpEmu.Libs.Media;
using SharpEmu.Libs.Gpu;
using SharpEmu.ShaderCompiler;
using SharpEmu.ShaderCompiler.Vulkan;
@@ -4504,7 +4504,7 @@ internal static unsafe class VulkanVideoPresenter
_swapchainFormat = surfaceFormat.Format;
_swapchainColorSpace = surfaceFormat.ColorSpace;
_extent = ChooseExtent(capabilities);
- Bink2MovieBridge.SetPresentationSize(_extent.Width, _extent.Height);
+ HostMovieBridge.SetPresentationSize(_extent.Width, _extent.Height);
var presentMode = ChoosePresentMode();
var imageCount = capabilities.MinImageCount + 1;
if (capabilities.MaxImageCount != 0)
@@ -7727,7 +7727,7 @@ internal static unsafe class VulkanVideoPresenter
private void PumpHostMovieFrame()
{
- if (!Bink2MovieBridge.TryDecodeNextFrame(
+ if (!HostMovieBridge.TryDecodeNextFrame(
advanceClock: _hostMovieLumaTextureAddress != 0 &&
_hostMovieChromaTextureAddress != 0,
out var pixels,
diff --git a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerPathTests.cs b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerPathTests.cs
index 8c55abff..f7a9c05b 100644
--- a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerPathTests.cs
+++ b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerPathTests.cs
@@ -75,49 +75,6 @@ public sealed class AvPlayerPathTests : IDisposable
AssertPathIsInsideApp0(resolved);
}
- [Theory]
- [InlineData(false, "ffmpeg", "ffprobe")]
- [InlineData(true, "ffmpeg.exe", "ffprobe.exe")]
- public void MediaToolLookupUsesPlatformNames(
- bool isWindows,
- string ffmpegName,
- string ffprobeName)
- {
- var toolDirectory = Path.Combine(_tempRoot, "Media Tools");
- Directory.CreateDirectory(toolDirectory);
- var ffmpeg = Path.Combine(toolDirectory, ffmpegName);
- File.WriteAllBytes(ffmpeg, []);
-
- var resolved = AvPlayerExports.FindFfmpeg(
- configured: null,
- searchPath: $"\"{toolDirectory}\"",
- isWindows);
-
- Assert.Equal(ffmpeg, resolved);
- Assert.Equal(
- Path.Combine(toolDirectory, ffprobeName),
- AvPlayerExports.GetFfprobePath(ffmpeg, isWindows));
- }
-
- [Theory]
- [InlineData(false, "ffmpeg")]
- [InlineData(true, "ffmpeg.exe")]
- public void MediaToolLookupFindsPackagedBinary(bool isWindows, string executable)
- {
- var publishDirectory = Path.Combine(_tempRoot, "publish");
- Directory.CreateDirectory(Path.Combine(publishDirectory, "ffmpeg"));
- var ffmpeg = Path.Combine(publishDirectory, "ffmpeg", executable);
- File.WriteAllBytes(ffmpeg, []);
-
- Assert.Equal(
- ffmpeg,
- AvPlayerExports.FindFfmpeg(
- configured: null,
- searchPath: null,
- isWindows,
- publishDirectory));
- }
-
[Fact]
public void RelativeFileUriCannotEscapeApp0()
{
diff --git a/tests/SharpEmu.Libs.Tests/Bink/Bink2MovieBridgeTests.cs b/tests/SharpEmu.Libs.Tests/Media/HostMovieBridgeTests.cs
similarity index 84%
rename from tests/SharpEmu.Libs.Tests/Bink/Bink2MovieBridgeTests.cs
rename to tests/SharpEmu.Libs.Tests/Media/HostMovieBridgeTests.cs
index ca51f9bc..7e921c1e 100644
--- a/tests/SharpEmu.Libs.Tests/Bink/Bink2MovieBridgeTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Media/HostMovieBridgeTests.cs
@@ -2,18 +2,18 @@
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
-using SharpEmu.Libs.Bink;
+using SharpEmu.Libs.Media;
using Xunit;
-namespace SharpEmu.Libs.Tests.Bink;
+namespace SharpEmu.Libs.Tests.Media;
-public sealed class Bink2MovieBridgeTests : IDisposable
+public sealed class HostMovieBridgeTests : IDisposable
{
private readonly string _tempDirectory = Path.Combine(
Path.GetTempPath(),
$"sharpemu-bink-{Guid.NewGuid():N}");
- public Bink2MovieBridgeTests()
+ public HostMovieBridgeTests()
{
Directory.CreateDirectory(_tempDirectory);
}
@@ -23,7 +23,7 @@ public sealed class Bink2MovieBridgeTests : IDisposable
{
var path = WriteHeader("KB2j"u8, 3840, 2160, 30_000, 1_001);
- Assert.True(Bink2MovieBridge.TryReadBinkInfo(path, out var info));
+ Assert.True(HostMovieBridge.TryReadBinkInfo(path, out var info));
Assert.Equal(3840u, info.Width);
Assert.Equal(2160u, info.Height);
Assert.Equal(30_000u, info.FramesPerSecondNumerator);
@@ -43,7 +43,7 @@ public sealed class Bink2MovieBridgeTests : IDisposable
60,
1);
- Assert.True(Bink2MovieBridge.TryReadBinkInfo(path, out _));
+ Assert.True(HostMovieBridge.TryReadBinkInfo(path, out _));
}
[Fact]
@@ -51,7 +51,7 @@ public sealed class Bink2MovieBridgeTests : IDisposable
{
var path = WriteHeader("KB2j"u8, 1920, 1080, 60, 0);
- Assert.False(Bink2MovieBridge.TryReadBinkInfo(path, out _));
+ Assert.False(HostMovieBridge.TryReadBinkInfo(path, out _));
}
private string WriteHeader(
diff --git a/tests/SharpEmu.Libs.Tests/Bink/BinkFramePlaybackTests.cs b/tests/SharpEmu.Libs.Tests/Media/MediaFramePlaybackTests.cs
similarity index 86%
rename from tests/SharpEmu.Libs.Tests/Bink/BinkFramePlaybackTests.cs
rename to tests/SharpEmu.Libs.Tests/Media/MediaFramePlaybackTests.cs
index 20aefbea..521718cc 100644
--- a/tests/SharpEmu.Libs.Tests/Bink/BinkFramePlaybackTests.cs
+++ b/tests/SharpEmu.Libs.Tests/Media/MediaFramePlaybackTests.cs
@@ -1,17 +1,17 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
-using SharpEmu.Libs.Bink;
+using SharpEmu.Libs.Media;
using Xunit;
-namespace SharpEmu.Libs.Tests.Bink;
+namespace SharpEmu.Libs.Tests.Media;
-public sealed class BinkFramePlaybackTests
+public sealed class MediaFramePlaybackTests
{
[Fact]
public void FramesAdvanceAccordingToMovieClock()
{
- using var playback = new BinkFramePlayback(new SequenceDecoder(1, 2, 3));
+ using var playback = new MediaFramePlayback(new SequenceDecoder(1, 2, 3));
Assert.Equal(1, WaitForAdvancedFrame(playback)[0]);
Assert.True(playback.TryGetFrame(true, out var heldFrame, out var advanced));
@@ -22,7 +22,7 @@ public sealed class BinkFramePlaybackTests
Assert.Equal(3, WaitForAdvancedFrame(playback)[0]);
}
- private static byte[] WaitForAdvancedFrame(BinkFramePlayback playback)
+ private static byte[] WaitForAdvancedFrame(MediaFramePlayback playback)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
while (DateTime.UtcNow < deadline)
@@ -41,7 +41,7 @@ public sealed class BinkFramePlaybackTests
[Fact]
public void FirstFrameWaitsUntilPresentationStarts()
{
- using var playback = new BinkFramePlayback(new SequenceDecoder(1, 2));
+ using var playback = new MediaFramePlayback(new SequenceDecoder(1, 2));
var first = WaitForFrame(playback, advanceClock: false);
Assert.Equal(1, first[0]);
@@ -58,7 +58,7 @@ public sealed class BinkFramePlaybackTests
}
private static byte[] WaitForFrame(
- BinkFramePlayback playback,
+ MediaFramePlayback playback,
bool advanceClock)
{
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
@@ -75,7 +75,7 @@ public sealed class BinkFramePlaybackTests
throw new TimeoutException("The decoder did not produce a frame.");
}
- private sealed class SequenceDecoder(params byte[] values) : IBinkFrameDecoder
+ private sealed class SequenceDecoder(params byte[] values) : IMediaFrameDecoder
{
private int _index;