A vplayer fix (#821)

* Astrobot-video-fix

* Astrobot-video-fix with updated files

* Astrobot-video-fix with tests files
This commit is contained in:
Astell
2026-08-16 23:57:42 +02:00
committed by GitHub
parent 7caf430aa9
commit 1660111189
8 changed files with 1280 additions and 75 deletions
+671 -56
View File
@@ -4,7 +4,9 @@
using SharpEmu.HLE; using SharpEmu.HLE;
using SharpEmu.Libs.Kernel; using SharpEmu.Libs.Kernel;
using SharpEmu.Libs.Media; using SharpEmu.Libs.Media;
using SharpEmu.Libs.VideoOut;
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
@@ -22,14 +24,213 @@ public static class AvPlayerExports
private const int FrameHeightAlignment = 16; private const int FrameHeightAlignment = 16;
private const int FrameInfoSize = 40; private const int FrameInfoSize = 40;
private const int FrameInfoExSize = 104; private const int FrameInfoExSize = 104;
// This structure is 32 bytes. A larger write can damage the guest stack. // The legacy destination is 40 bytes on Gen4 but only 32 bytes on Gen5.
private const int StreamInfoSize = 32; // Writing the Gen4 layout into a Gen5 caller can overwrite its stack canary.
private const int StreamInfoExSize = 32; private const int Gen4StreamInfoSize = 40;
private const int Gen5StreamInfoSize = 32;
private const int StreamInfoExSize = 104;
private const int MaxGuestPathLength = 4096; private const int MaxGuestPathLength = 4096;
private const int VideoPitchAlignment = 256;
private static readonly object StateGate = new(); private static readonly object StateGate = new();
private static readonly HashSet<string> TracedOnce = new(); private static readonly HashSet<string> TracedOnce = new();
private static readonly Dictionary<ulong, PlayerState> Players = new(); private static readonly Dictionary<ulong, PlayerState> Players = new();
private static readonly ConcurrentDictionary<ulong, ulong> VideoBufferRanges = new();
private static readonly bool TraceVideoImages = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_TRACE_AVPLAYER_IMAGES"),
"1",
StringComparison.Ordinal);
private static int _traceCount; private static int _traceCount;
private static int _videoPayloadTraceCount;
private static long _fallbackPresentationSerial;
internal static bool TryGetFallbackPresentationFrame(
out byte[] pixels,
out uint width,
out uint height,
out long serial)
{
lock (StateGate)
{
PlayerState? latest = null;
foreach (var player in Players.Values)
{
if (player.FallbackPlayback is { } playback)
{
if (playback.TryGetFrame(
advanceClock: true,
out var playbackPixels,
out var advanced))
{
var skipFirstDecodedFrame =
player.SkipFirstFallbackPlaybackFrame;
if (ShouldPublishFallbackPlaybackFrame(
advanced,
player.FallbackPresentationPixels is not null,
ref skipFirstDecodedFrame))
{
player.FallbackPresentationPixels = playbackPixels;
player.FallbackPresentationWidth = playback.Width;
player.FallbackPresentationHeight = playback.Height;
player.FallbackPresentationSerial =
Interlocked.Increment(ref _fallbackPresentationSerial);
}
player.SkipFirstFallbackPlaybackFrame =
skipFirstDecodedFrame;
}
else if (playback.IsFinished)
{
playback.Dispose();
player.FallbackPlayback = null;
player.FallbackPlaybackCompleted = true;
player.FallbackPlaybackCompletedTicks = Stopwatch.GetTimestamp();
Trace(
$"host_fallback_finished handle=0x{player.Handle:X16} " +
"holding_last_frame=true");
}
}
// The host decoder can finish long before a heavily throttled
// guest AvPlayer reaches EOF. Keep its final image over the
// stale guest texture until the guest has actually consumed
// the stream; otherwise frame zero becomes visible again and
// the intro appears to start a second time. The hold is
// bounded: a title that pauses its player after the poster
// frame never reaches EOF, and an unbounded hold would pin the
// final movie image over everything the game renders next.
if (ShouldReleaseCompletedFallback(
player.FallbackPlaybackCompleted,
player.EndOfStream,
player.FallbackPlaybackCompletedTicks,
Stopwatch.GetTimestamp()))
{
ClearFallbackPresentation(player);
}
if (player.FallbackPresentationPixels is null ||
player.FallbackPresentationSerial <= 0 ||
latest is not null &&
player.FallbackPresentationSerial <= latest.FallbackPresentationSerial)
{
continue;
}
latest = player;
}
if (latest?.FallbackPresentationPixels is not { } frame)
{
pixels = [];
width = 0;
height = 0;
serial = 0;
return false;
}
pixels = frame;
width = latest.FallbackPresentationWidth;
height = latest.FallbackPresentationHeight;
serial = latest.FallbackPresentationSerial;
return IsValidBgraFrame(pixels, width, height);
}
}
internal static bool ShouldPublishFallbackPlaybackFrame(
bool advanced,
bool hasPresentation,
ref bool skipFirstDecodedFrame)
{
if (advanced && hasPresentation && skipFirstDecodedFrame)
{
skipFirstDecodedFrame = false;
return false;
}
return advanced || !hasPresentation;
}
/// <summary>
/// How long a finished host playback keeps its final image on screen while
/// waiting for the guest player to reach end of stream. Titles that pause
/// their AvPlayer after the first frame never do, so the hold expires.
/// </summary>
private static readonly long FallbackHoldGraceTicks = Stopwatch.Frequency;
internal static bool ShouldReleaseCompletedFallback(
bool fallbackPlaybackCompleted,
bool guestEndOfStream,
long completedTicks,
long nowTicks) =>
fallbackPlaybackCompleted &&
(guestEndOfStream ||
completedTicks != 0 && nowTicks - completedTicks >= FallbackHoldGraceTicks);
private static void ClearFallbackPresentation(PlayerState player)
{
player.FallbackPresentationPixels = null;
player.FallbackPresentationWidth = 0;
player.FallbackPresentationHeight = 0;
player.FallbackPresentationSerial = 0;
player.FallbackPlaybackCompleted = false;
player.FallbackPlaybackCompletedTicks = 0;
player.SkipFirstFallbackPlaybackFrame = false;
Trace(
$"host_fallback_released handle=0x{player.Handle:X16} " +
$"guest_eof={player.EndOfStream}");
}
internal static bool ShouldTraceVideoBufferAddress(ulong address)
{
if (!TraceVideoImages || address == 0)
{
return false;
}
foreach (var (start, length) in VideoBufferRanges)
{
if (address >= start && address - start < length)
{
return true;
}
}
return false;
}
internal static bool ShouldTraceVideoBufferRange(ulong address, ulong length)
{
if (!TraceVideoImages || address == 0 || length == 0)
{
return false;
}
foreach (var (start, rangeLength) in VideoBufferRanges)
{
if (address <= start
? start - address < length
: address - start < rangeLength)
{
return true;
}
}
return false;
}
private static void RegisterVideoBuffer(ulong address, int size, int index, string source)
{
if (address == 0 || size <= 0)
{
return;
}
VideoBufferRanges[address] = checked((ulong)size);
if (TraceVideoImages)
{
Console.Error.WriteLine(
$"[AVPLAYER][TRACE] video_buffer index={index} source={source} " +
$"data=0x{address:X16} size={size}");
}
}
private sealed class PlayerState : IDisposable private sealed class PlayerState : IDisposable
{ {
@@ -45,6 +246,8 @@ public static class AvPlayerExports
public int Height { get; set; } public int Height { get; set; }
public double FramesPerSecond { get; set; } = 30.0; public double FramesPerSecond { get; set; } = 30.0;
public ulong DurationMilliseconds { get; set; } public ulong DurationMilliseconds { get; set; }
public bool HasAudio { get; set; }
public bool IsGen5 { get; init; }
public bool Started { get; set; } public bool Started { get; set; }
public bool Paused { get; set; } public bool Paused { get; set; }
public bool Looping { get; set; } public bool Looping { get; set; }
@@ -61,10 +264,20 @@ public static class AvPlayerExports
public int GuestBufferStride { get; set; } public int GuestBufferStride { get; set; }
public int NextGuestBuffer { get; set; } public int NextGuestBuffer { get; set; }
public ulong LastGuestBuffer { get; set; } public ulong LastGuestBuffer { get; set; }
public ulong LastVideoTimestamp { get; set; }
public long NextFrameIndex { get; set; } public long NextFrameIndex { get; set; }
public ulong AudioBufferBase { get; set; } public ulong AudioBufferBase { get; set; }
public int NextAudioBuffer { get; set; } public int NextAudioBuffer { get; set; }
public long NextAudioFrameIndex { get; set; } public long NextAudioFrameIndex { get; set; }
public byte[]? FallbackPresentationPixels { get; set; }
public uint FallbackPresentationWidth { get; set; }
public uint FallbackPresentationHeight { get; set; }
public long FallbackPresentationSerial { get; set; }
public MediaFramePlayback? FallbackPlayback { get; set; }
public bool FallbackPlaybackAttempted { get; set; }
public bool FallbackPlaybackCompleted { get; set; }
public long FallbackPlaybackCompletedTicks { get; set; }
public bool SkipFirstFallbackPlaybackFrame { get; set; }
public void Dispose() public void Dispose()
{ {
@@ -72,6 +285,8 @@ public static class AvPlayerExports
DecoderOutput = null; DecoderOutput = null;
AudioDecoderOutput?.Dispose(); AudioDecoderOutput?.Dispose();
AudioDecoderOutput = null; AudioDecoderOutput = null;
FallbackPlayback?.Dispose();
FallbackPlayback = null;
} }
public void ResetPlayback() public void ResetPlayback()
@@ -79,9 +294,19 @@ public static class AvPlayerExports
Dispose(); Dispose();
PlaybackClock.Reset(); PlaybackClock.Reset();
NextFrameIndex = 0; NextFrameIndex = 0;
LastGuestBuffer = 0;
LastVideoTimestamp = 0;
NextAudioFrameIndex = 0; NextAudioFrameIndex = 0;
SkippedFrameDebt = 0; SkippedFrameDebt = 0;
EndOfStream = false; EndOfStream = false;
FallbackPresentationPixels = null;
FallbackPresentationWidth = 0;
FallbackPresentationHeight = 0;
FallbackPresentationSerial = 0;
FallbackPlaybackAttempted = false;
FallbackPlaybackCompleted = false;
FallbackPlaybackCompletedTicks = 0;
SkipFirstFallbackPlaybackFrame = false;
} }
} }
@@ -102,10 +327,12 @@ public static class AvPlayerExports
lock (StateGate) lock (StateGate)
{ {
var autoStartOffset = GetAutoStartOffset(ctx.TargetGeneration, extended: false);
Players.Add(handle, new PlayerState Players.Add(handle, new PlayerState
{ {
Handle = handle, Handle = handle,
AutoStart = TryReadByte(ctx, initDataAddress + 108, out var autoStart) && autoStart != 0, IsGen5 = IsGen5Target(ctx.TargetGeneration),
AutoStart = TryReadByte(ctx, initDataAddress + autoStartOffset, out var autoStart) && autoStart != 0,
AllocatorObject = TryReadUInt64(ctx, initDataAddress, out var allocatorObject) ? allocatorObject : 0, AllocatorObject = TryReadUInt64(ctx, initDataAddress, out var allocatorObject) ? allocatorObject : 0,
AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 24, out var allocateTexture) ? allocateTexture : 0, AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 24, out var allocateTexture) ? allocateTexture : 0,
AllocateCallback = TryReadUInt64(ctx, initDataAddress + 8, out var allocate) ? allocate : 0, AllocateCallback = TryReadUInt64(ctx, initDataAddress + 8, out var allocate) ? allocate : 0,
@@ -157,10 +384,12 @@ public static class AvPlayerExports
lock (StateGate) lock (StateGate)
{ {
var autoStartOffset = GetAutoStartOffset(ctx.TargetGeneration, extended: true);
Players.Add(handle, new PlayerState Players.Add(handle, new PlayerState
{ {
Handle = handle, Handle = handle,
AutoStart = TryReadByte(ctx, initDataAddress + 164, out var autoStart) && autoStart != 0, IsGen5 = IsGen5Target(ctx.TargetGeneration),
AutoStart = TryReadByte(ctx, initDataAddress + autoStartOffset, out var autoStart) && autoStart != 0,
AllocatorObject = TryReadUInt64(ctx, initDataAddress + 8, out var allocatorObject) ? allocatorObject : 0, AllocatorObject = TryReadUInt64(ctx, initDataAddress + 8, out var allocatorObject) ? allocatorObject : 0,
AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 32, out var allocateTexture) ? allocateTexture : 0, AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 32, out var allocateTexture) ? allocateTexture : 0,
AllocateCallback = TryReadUInt64(ctx, initDataAddress + 16, out var allocate) ? allocate : 0, AllocateCallback = TryReadUInt64(ctx, initDataAddress + 16, out var allocate) ? allocate : 0,
@@ -321,20 +550,24 @@ public static class AvPlayerExports
LibraryName = "libSceAvPlayer")] LibraryName = "libSceAvPlayer")]
public static int AvPlayerResume(CpuContext ctx) public static int AvPlayerResume(CpuContext ctx)
{ {
PlayerState player;
lock (StateGate) lock (StateGate)
{ {
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var player)) if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var foundPlayer))
{ {
return SetReturn(ctx, InvalidParameters); return SetReturn(ctx, InvalidParameters);
} }
player = foundPlayer;
player.Paused = false; player.Paused = false;
if (player.DecoderOutput is not null) if (player.DecoderOutput is not null)
{ {
player.PlaybackClock.Start(); player.PlaybackClock.Start();
} }
return SetReturn(ctx, 0);
} }
NotifyEvent(ctx, player, 3); // StatePlay
return SetReturn(ctx, 0);
} }
[SysAbiExport( [SysAbiExport(
@@ -385,8 +618,33 @@ public static class AvPlayerExports
ExportName = "sceAvPlayerGetStreamInfoEx", ExportName = "sceAvPlayerGetStreamInfoEx",
Target = Generation.Gen5, Target = Generation.Gen5,
LibraryName = "libSceAvPlayer")] LibraryName = "libSceAvPlayer")]
public static int AvPlayerGetStreamInfoEx(CpuContext ctx) => public static int AvPlayerGetStreamInfoEx(CpuContext ctx)
GetStreamInfoCore(ctx, StreamInfoExSize); {
var streamIndex = unchecked((uint)ctx[CpuRegister.Rsi]);
var infoAddress = ctx[CpuRegister.Rdx];
lock (StateGate)
{
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) ||
streamIndex > (player.HasAudio ? 1u : 0u) ||
infoAddress == 0)
{
return SetReturn(ctx, InvalidParameters);
}
Span<byte> info = stackalloc byte[StreamInfoExSize];
info.Clear();
WriteGen5StreamInfoEx(
info,
GetStreamType(ctx.TargetGeneration, streamIndex),
streamIndex == 0 ? checked((uint)player.Width) : 0,
streamIndex == 0 ? checked((uint)player.Height) : 0,
streamIndex == 0 ? player.FramesPerSecond : 0,
player.DurationMilliseconds);
return SetReturn(
ctx,
ctx.Memory.TryWrite(infoAddress, info) ? 0 : InvalidParameters);
}
}
[SysAbiExport( [SysAbiExport(
Nid = "XC9wM+xULz8", Nid = "XC9wM+xULz8",
@@ -460,14 +718,15 @@ public static class AvPlayerExports
{ {
var found = Players.TryGetValue(ctx[CpuRegister.Rdi], out var player); var found = Players.TryGetValue(ctx[CpuRegister.Rdi], out var player);
if (!found || infoAddress == 0 || !player!.Started || player.Paused || if (!found || infoAddress == 0 || !player!.Started || player.Paused ||
player.EndOfStream || player.SourcePath is null || !EnsureAudioDecoder(player)) player.EndOfStream || player.SourcePath is null ||
!player.HasAudio || !EnsureAudioDecoder(player))
{ {
TraceOnce( TraceOnce(
"audio_data_refused", "audio_data_refused",
$"audio_data refused found={found} info=0x{infoAddress:X16} " + $"audio_data refused found={found} info=0x{infoAddress:X16} " +
$"started={(found && player!.Started)} paused={(found && player!.Paused)} " + $"started={(found && player!.Started)} paused={(found && player!.Paused)} " +
$"eos={(found && player!.EndOfStream)} " + $"eos={(found && player!.EndOfStream)} " +
$"decoder={(found && player!.SourcePath is not null && EnsureAudioDecoder(player))}"); $"has_audio={(found && player!.HasAudio)}");
return SetReturn(ctx, 0); return SetReturn(ctx, 0);
} }
@@ -550,9 +809,11 @@ public static class AvPlayerExports
{ {
lock (StateGate) lock (StateGate)
{ {
var known = Players.ContainsKey(ctx[CpuRegister.Rdi]); return SetReturn(
TraceOnce("stream_count", $"stream_count known={known} returned={(known ? 2 : -1)}"); ctx,
return SetReturn(ctx, known ? 2 : InvalidParameters); Players.TryGetValue(ctx[CpuRegister.Rdi], out var player)
? player.HasAudio ? 2 : 1
: InvalidParameters);
} }
} }
@@ -560,7 +821,12 @@ public static class AvPlayerExports
ulong handle, ulong handle,
int width, int width,
int height, int height,
ulong durationMilliseconds) ulong durationMilliseconds,
ulong allocateTextureCallback = 0,
ulong allocateCallback = 0,
bool hasAudio = false,
double framesPerSecond = 30.0,
bool isGen5 = true)
{ {
PlayerState? previous; PlayerState? previous;
lock (StateGate) lock (StateGate)
@@ -569,15 +835,40 @@ public static class AvPlayerExports
Players[handle] = new PlayerState Players[handle] = new PlayerState
{ {
Handle = handle, Handle = handle,
IsGen5 = isGen5,
Width = width, Width = width,
Height = height, Height = height,
DurationMilliseconds = durationMilliseconds, DurationMilliseconds = durationMilliseconds,
HasAudio = hasAudio,
FramesPerSecond = framesPerSecond,
AllocateTextureCallback = allocateTextureCallback,
AllocateCallback = allocateCallback,
}; };
} }
previous?.Dispose(); previous?.Dispose();
} }
internal static bool AllocateGuestVideoBuffersForTest(
CpuContext ctx,
ulong handle,
out ulong firstBuffer)
{
lock (StateGate)
{
if (!Players.TryGetValue(handle, out var player))
{
firstBuffer = 0;
return false;
}
var bufferSize = GetVideoBufferSize(player);
var allocated = AllocateGuestVideoBuffers(ctx, player, bufferSize);
firstBuffer = player.GuestBuffers[0];
return allocated && firstBuffer != 0;
}
}
internal static void RemovePlayerForTest(ulong handle) internal static void RemovePlayerForTest(ulong handle)
{ {
PlayerState? player; PlayerState? player;
@@ -595,23 +886,27 @@ public static class AvPlayerExports
Target = Generation.Gen4 | Generation.Gen5, Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")] LibraryName = "libSceAvPlayer")]
public static int AvPlayerGetStreamInfo(CpuContext ctx) => public static int AvPlayerGetStreamInfo(CpuContext ctx) =>
GetStreamInfoCore(ctx, StreamInfoSize); GetStreamInfoCore(ctx);
private static int GetStreamInfoCore(CpuContext ctx, int infoSize) private static int GetStreamInfoCore(CpuContext ctx)
{ {
var streamIndex = unchecked((uint)ctx[CpuRegister.Rsi]); var streamIndex = unchecked((uint)ctx[CpuRegister.Rsi]);
var infoAddress = ctx[CpuRegister.Rdx]; var infoAddress = ctx[CpuRegister.Rdx];
lock (StateGate) lock (StateGate)
{ {
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) || if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) ||
streamIndex > 1 || infoAddress == 0 || player.Width <= 0 || player.Height <= 0) streamIndex > (player.HasAudio ? 1u : 0u) ||
infoAddress == 0 || player.Width <= 0 || player.Height <= 0)
{ {
return SetReturn(ctx, InvalidParameters); return SetReturn(ctx, InvalidParameters);
} }
var infoSize = GetLegacyStreamInfoSize(ctx.TargetGeneration);
Span<byte> info = stackalloc byte[infoSize]; Span<byte> info = stackalloc byte[infoSize];
info.Clear(); info.Clear();
BinaryPrimitives.WriteUInt32LittleEndian(info[0..], streamIndex); // 0=video, 1=audio BinaryPrimitives.WriteUInt32LittleEndian(
info[0..],
GetStreamType(ctx.TargetGeneration, streamIndex));
if (streamIndex == 0) if (streamIndex == 0)
{ {
BinaryPrimitives.WriteUInt32LittleEndian(info[8..], checked((uint)player.Width)); BinaryPrimitives.WriteUInt32LittleEndian(info[8..], checked((uint)player.Width));
@@ -650,7 +945,14 @@ public static class AvPlayerExports
player = foundPlayer; player = foundPlayer;
var hostPath = ResolveGuestPath(guestPath); var hostPath = ResolveGuestPath(guestPath);
if (hostPath is null || !ProbeVideo(hostPath, out var width, out var height, out var fps, out var duration)) if (hostPath is null ||
!ProbeVideo(
hostPath,
out var width,
out var height,
out var fps,
out var duration,
out var hasAudio))
{ {
Console.Error.WriteLine($"[AVPLAYER][ERROR] Could not open guest video '{guestPath}' (resolved '{hostPath ?? "<none>"}')."); Console.Error.WriteLine($"[AVPLAYER][ERROR] Could not open guest video '{guestPath}' (resolved '{hostPath ?? "<none>"}').");
return SetReturn(ctx, OperationFailed); return SetReturn(ctx, OperationFailed);
@@ -662,14 +964,13 @@ public static class AvPlayerExports
player.Height = height; player.Height = height;
player.FramesPerSecond = fps; player.FramesPerSecond = fps;
player.DurationMilliseconds = duration; player.DurationMilliseconds = duration;
player.HasAudio = hasAudio;
player.Started = player.AutoStart; player.Started = player.AutoStart;
autoStart = player.AutoStart; autoStart = player.AutoStart;
Trace($"source guest='{guestPath}' host='{hostPath}' {width}x{height} fps={fps:F3} duration_ms={duration} auto_start={player.AutoStart}"); Trace(
$"source guest='{guestPath}' host='{hostPath}' {width}x{height} " +
$"fps={fps:F3} duration_ms={duration} audio={hasAudio} auto_start={player.AutoStart}");
} }
EnsureGuestVideoBuffers(ctx, player);
NotifyEvent(ctx, player, 2); // StateReady NotifyEvent(ctx, player, 2); // StateReady
if (autoStart) if (autoStart)
{ {
@@ -684,12 +985,23 @@ public static class AvPlayerExports
lock (StateGate) lock (StateGate)
{ {
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) || if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) ||
infoAddress == 0 || !player.Started || player.Paused || player.EndOfStream || infoAddress == 0 || !player.Started || player.EndOfStream ||
player.SourcePath is null) player.SourcePath is null)
{ {
return SetReturn(ctx, 0); return SetReturn(ctx, 0);
} }
if (player.Paused)
{
return SetReturn(
ctx,
player.IsGen5 &&
player.LastGuestBuffer != 0 &&
WriteHeldVideoFrameInfo(ctx, player, infoAddress, extended)
? 1
: 0);
}
if (!EnsureDecoder(player)) if (!EnsureDecoder(player))
{ {
player.EndOfStream = true; player.EndOfStream = true;
@@ -731,6 +1043,7 @@ public static class AvPlayerExports
{ {
return SetReturn(ctx, 0); return SetReturn(ctx, 0);
} }
player.LastVideoTimestamp = timestamp;
Trace($"video_frame handle=0x{player.Handle:X16} ex={extended} ts={timestamp} data=0x{player.LastGuestBuffer:X16}"); Trace($"video_frame handle=0x{player.Handle:X16} ex={extended} ts={timestamp} data=0x{player.LastGuestBuffer:X16}");
return SetReturn(ctx, 1); return SetReturn(ctx, 1);
@@ -856,9 +1169,10 @@ public static class AvPlayerExports
return false; return false;
} }
var alignedWidth = AlignUp(player.Width, FramePitchAlignment); var alignedWidth = AlignUp(player.Width, 16);
var alignedHeight = AlignUp(player.Height, FrameHeightAlignment); var alignedHeight = AlignUp(player.Height, 16);
var bufferStride = GetVideoBufferSize(player); var (pitch, bufferHeight) = GetFrameGeometry(player, extended);
var bufferStride = CalculateNv12BufferSize(pitch, bufferHeight);
if (player.GuestBuffers[0] == 0) if (player.GuestBuffers[0] == 0)
{ {
if (!AllocateGuestVideoBuffers(ctx, player, bufferStride)) if (!AllocateGuestVideoBuffers(ctx, player, bufferStride))
@@ -866,12 +1180,34 @@ public static class AvPlayerExports
return false; return false;
} }
player.GuestBufferStride = bufferStride; player.GuestBufferStride = bufferStride;
Trace(
$"video_layout ex={extended} width={player.Width} height={player.Height} " +
$"pitch={pitch} uv_offset={checked(pitch * bufferHeight)} size={bufferStride}");
} }
var frameData = player.RawFrame; var frameData = player.RawFrame;
if (!extended && (alignedWidth != player.Width || alignedHeight != player.Height)) if (extended)
{ {
player.PaddedFrame ??= new byte[bufferStride]; if (player.PaddedFrame is null || player.PaddedFrame.Length != bufferStride)
{
player.PaddedFrame = new byte[bufferStride];
}
CopyNv12ToGuestBuffer(
player.RawFrame,
player.PaddedFrame,
player.Width,
player.Height,
player.Width,
player.Width,
pitch);
frameData = player.PaddedFrame;
}
else if (alignedWidth != player.Width || alignedHeight != player.Height)
{
if (player.PaddedFrame is null || player.PaddedFrame.Length != bufferStride)
{
player.PaddedFrame = new byte[bufferStride];
}
player.PaddedFrame.AsSpan().Clear(); player.PaddedFrame.AsSpan().Clear();
for (var row = 0; row < player.Height; row++) for (var row = 0; row < player.Height; row++)
{ {
@@ -895,44 +1231,218 @@ public static class AvPlayerExports
{ {
return false; return false;
} }
if (player.TextureAllocatorFailed)
{
EnsureFallbackPlayback(player);
if (player.FallbackPresentationPixels is null)
{
// Keep one immediate poster frame while the background decoder
// starts. Subsequent frames come from the bounded, scaled host
// playback; converting every 4K NV12 guest frame here would
// duplicate decoding work and dominate the emulation thread.
var bgra = GC.AllocateUninitializedArray<byte>(
checked(player.Width * player.Height * 4));
ConvertNv12ToBgra(
frameData,
pitch,
bufferHeight,
player.Width,
player.Height,
bgra);
player.FallbackPresentationPixels = bgra;
player.FallbackPresentationWidth = checked((uint)player.Width);
player.FallbackPresentationHeight = checked((uint)player.Height);
player.FallbackPresentationSerial =
Interlocked.Increment(ref _fallbackPresentationSerial);
player.SkipFirstFallbackPlaybackFrame =
player.FallbackPlayback is not null;
}
}
if (TraceVideoImages)
{
var traceIndex = Interlocked.Increment(ref _videoPayloadTraceCount);
if (traceIndex <= 16)
{
var summary = GuestImageUploadPayloadDiagnostics.Summarize(frameData);
Console.Error.WriteLine(
$"[AVPLAYER][TRACE] video_payload index={traceIndex - 1} " +
$"data=0x{bufferAddress:X16} bytes={frameData.Length} " +
$"pitch={pitch} uv_offset={checked(pitch * bufferHeight)} " +
$"nonzero_bytes={summary.NonzeroBytes}/{frameData.Length} " +
$"hash=0x{summary.Hash:X16}");
}
}
Span<byte> info = extended Span<byte> info = extended
? stackalloc byte[FrameInfoExSize] ? stackalloc byte[FrameInfoExSize]
: stackalloc byte[FrameInfoSize]; : stackalloc byte[FrameInfoSize];
info.Clear(); info.Clear();
BinaryPrimitives.WriteUInt64LittleEndian(info[0..], bufferAddress); WriteVideoFrameInfo(
BinaryPrimitives.WriteUInt64LittleEndian(info[16..], timestamp); info,
BinaryPrimitives.WriteUInt32LittleEndian(info[24..], checked((uint)(extended ? player.Width : alignedWidth))); ctx.TargetGeneration,
BinaryPrimitives.WriteUInt32LittleEndian(info[28..], checked((uint)(extended ? player.Height : alignedHeight))); extended,
BinaryPrimitives.WriteSingleLittleEndian(info[32..], 1.0f); bufferAddress,
if (extended) timestamp,
{ checked((uint)pitch),
BinaryPrimitives.WriteUInt32LittleEndian(info[60..], checked((uint)player.Width)); checked((uint)player.Width),
info[64] = 8; checked((uint)(extended ? player.Height : bufferHeight)),
info[65] = 8; checked((uint)pitch),
} player.FramesPerSecond);
return ctx.Memory.TryWrite(infoAddress, info); return ctx.Memory.TryWrite(infoAddress, info);
} }
private static (int Pitch, int Height) GetFrameGeometry(
PlayerState player,
bool extended)
{
var gen5Extended = extended && player.IsGen5;
return (
gen5Extended ? CalculateNv12Pitch(player.Width) : AlignUp(player.Width, 16),
gen5Extended ? player.Height : AlignUp(player.Height, 16));
}
private static bool WriteHeldVideoFrameInfo(
CpuContext ctx,
PlayerState player,
ulong infoAddress,
bool extended)
{
var (pitch, bufferHeight) = GetFrameGeometry(player, extended);
Span<byte> info = extended
? stackalloc byte[FrameInfoExSize]
: stackalloc byte[FrameInfoSize];
info.Clear();
WriteVideoFrameInfo(
info,
ctx.TargetGeneration,
extended,
player.LastGuestBuffer,
player.LastVideoTimestamp,
checked((uint)pitch),
checked((uint)player.Width),
checked((uint)(extended ? player.Height : bufferHeight)),
checked((uint)pitch),
player.FramesPerSecond);
return ctx.Memory.TryWrite(infoAddress, info);
}
/// <summary>
/// The title-provided allocators can reject large decoded surfaces. In
/// that case the guest has no texture it can sample, and some titles pause
/// their AvPlayer after acquiring a poster frame. Keep that compatibility
/// path useful by running a separate, bounded host playback to completion.
/// MediaFramePlayback performs decode work off the Vulkan thread, advances
/// on the movie clock, drops frames when rendering is slow, and relinquishes
/// presentation automatically at EOF so normal guest rendering resumes.
/// </summary>
private static void EnsureFallbackPlayback(PlayerState player)
{
if (player.FallbackPlaybackAttempted || player.SourcePath is null)
{
return;
}
player.FallbackPlaybackAttempted = true;
var videoOptions = HostVideoHost.CurrentOptions;
var maximumWidth = checked((uint)videoOptions.Width);
var maximumHeight = checked((uint)videoOptions.Height);
if (!FfmpegVideoDecoder.TryOpen(
player.SourcePath,
maximumWidth,
maximumHeight,
out var decoder) ||
decoder is null)
{
Console.Error.WriteLine(
$"[AVPLAYER][WARN] Could not start host fallback playback for '{player.SourcePath}'.");
return;
}
player.FallbackPlayback = new MediaFramePlayback(decoder);
Trace(
$"host_fallback_started handle=0x{player.Handle:X16} " +
$"source={player.Width}x{player.Height} output={decoder.Width}x{decoder.Height} " +
$"host_limit={maximumWidth}x{maximumHeight} " +
$"fps={decoder.FramesPerSecondNumerator}/{decoder.FramesPerSecondDenominator}");
}
internal static int CalculateNv12Pitch(int width) =>
AlignUp(width, VideoPitchAlignment);
internal static int CalculateNv12BufferSize(int pitch, int height) =>
checked(pitch * height * 3 / 2);
internal static void ConvertNv12ToBgra(
ReadOnlySpan<byte> nv12,
int pitch,
int bufferHeight,
int width,
int height,
Span<byte> bgra)
{
var requiredNv12 = CalculateNv12BufferSize(pitch, bufferHeight);
var requiredBgra = checked(width * height * 4);
if (pitch < width || bufferHeight < height ||
nv12.Length < requiredNv12 || bgra.Length < requiredBgra)
{
throw new ArgumentException("NV12 frame dimensions do not match the supplied buffers.");
}
var chromaOffset = checked(pitch * bufferHeight);
for (var y = 0; y < height; y++)
{
var lumaRow = y * pitch;
var chromaRow = chromaOffset + ((y >> 1) * pitch);
var outputRow = y * width * 4;
for (var x = 0; x < width; x++)
{
var luma = nv12[lumaRow + x];
var chromaColumn = x & ~1;
var u = nv12[chromaRow + chromaColumn];
var v = nv12[chromaRow + chromaColumn + 1];
var c = Math.Max(0, luma - 16);
var d = u - 128;
var e = v - 128;
var output = outputRow + (x * 4);
bgra[output] = ClampToByte((298 * c + 516 * d + 128) >> 8);
bgra[output + 1] = ClampToByte((298 * c - 100 * d - 208 * e + 128) >> 8);
bgra[output + 2] = ClampToByte((298 * c + 409 * e + 128) >> 8);
bgra[output + 3] = byte.MaxValue;
}
}
}
private static byte ClampToByte(int value) =>
checked((byte)Math.Clamp(value, byte.MinValue, byte.MaxValue));
private static int GetVideoBufferSize(PlayerState player) => private static int GetVideoBufferSize(PlayerState player) =>
checked( checked(
AlignUp(player.Width, FramePitchAlignment) * AlignUp(player.Width, FramePitchAlignment) *
AlignUp(player.Height, FrameHeightAlignment) * 3 / 2); AlignUp(player.Height, FrameHeightAlignment) * 3 / 2);
private static void EnsureGuestVideoBuffers(CpuContext ctx, PlayerState player) internal static void CopyNv12ToGuestBuffer(
ReadOnlySpan<byte> source,
Span<byte> destination,
int width,
int height,
int sourceLumaStride,
int sourceChromaStride,
int destinationPitch)
{ {
lock (StateGate) var sourceChromaOffset = checked(sourceLumaStride * height);
{ var destinationChromaOffset = checked(destinationPitch * height);
if (player.GuestBuffers[0] != 0 || player.Width <= 0 || player.Height <= 0) var destinationSize = CalculateNv12BufferSize(destinationPitch, height);
{ destination[..destinationSize].Clear();
return;
}
var bufferSize = GetVideoBufferSize(player); for (var row = 0; row < height; row++)
if (AllocateGuestVideoBuffers(ctx, player, bufferSize)) {
{ source.Slice(row * sourceLumaStride, width)
player.GuestBufferStride = bufferSize; .CopyTo(destination.Slice(row * destinationPitch, width));
} }
for (var row = 0; row < height / 2; row++)
{
source.Slice(sourceChromaOffset + (row * sourceChromaStride), width)
.CopyTo(destination.Slice(destinationChromaOffset + (row * destinationPitch), width));
} }
} }
@@ -976,6 +1486,7 @@ public static class AvPlayerExports
break; break;
} }
player.GuestBuffers[index] = buffer; player.GuestBuffers[index] = buffer;
RegisterVideoBuffer(buffer, bufferSize, index, "guest-callback");
Trace($"{kind}_buffer index={index} data=0x{buffer:X16} size={bufferSize}"); Trace($"{kind}_buffer index={index} data=0x{buffer:X16} size={bufferSize}");
} }
@@ -984,7 +1495,6 @@ public static class AvPlayerExports
return true; return true;
} }
} }
player.TextureAllocatorFailed = true; player.TextureAllocatorFailed = true;
} }
@@ -999,6 +1509,7 @@ public static class AvPlayerExports
for (var index = 0; index < player.GuestBuffers.Length; index++) for (var index = 0; index < player.GuestBuffers.Length; index++)
{ {
player.GuestBuffers[index] = bufferBase + checked((ulong)(index * bufferSize)); player.GuestBuffers[index] = bufferBase + checked((ulong)(index * bufferSize));
RegisterVideoBuffer(player.GuestBuffers[index], bufferSize, index, "hle-fallback");
} }
Console.Error.WriteLine("[AVPLAYER][WARN] Guest texture allocator unavailable; using generic HLE memory."); Console.Error.WriteLine("[AVPLAYER][WARN] Guest texture allocator unavailable; using generic HLE memory.");
return true; return true;
@@ -1009,12 +1520,14 @@ public static class AvPlayerExports
out int width, out int width,
out int height, out int height,
out double framesPerSecond, out double framesPerSecond,
out ulong durationMilliseconds) out ulong durationMilliseconds,
out bool hasAudio)
{ {
width = 0; width = 0;
height = 0; height = 0;
framesPerSecond = 30.0; framesPerSecond = 30.0;
durationMilliseconds = 0; durationMilliseconds = 0;
hasAudio = false;
if (!FfmpegMediaStream.TryProbe(path, out width, out height, out var rate, out var duration)) if (!FfmpegMediaStream.TryProbe(path, out width, out height, out var rate, out var duration))
{ {
@@ -1031,6 +1544,10 @@ public static class AvPlayerExports
durationMilliseconds = checked((ulong)Math.Max(0, Math.Round(duration * 1000.0))); durationMilliseconds = checked((ulong)Math.Max(0, Math.Round(duration * 1000.0)));
} }
hasAudio = FfmpegMediaStream.TryOpenAudio(path, out var audioStream) &&
audioStream is not null;
audioStream?.Dispose();
return width > 0 && height > 0 && framesPerSecond > 0; return width > 0 && height > 0 && framesPerSecond > 0;
} }
@@ -1320,6 +1837,104 @@ public static class AvPlayerExports
return true; return true;
} }
internal static bool IsValidBgraFrame(
ReadOnlySpan<byte> pixels,
uint width,
uint height)
{
if (width == 0 || height == 0)
{
return false;
}
var requiredBytes = (ulong)width * height * 4;
return requiredBytes <= int.MaxValue &&
pixels.Length >= checked((int)requiredBytes);
}
internal static bool IsGen5Target(Generation generation) =>
(generation & Generation.Gen5) != 0;
internal static ulong GetAutoStartOffset(Generation generation, bool extended) =>
IsGen5Target(generation)
? extended ? 168UL : 112UL
: extended ? 164UL : 108UL;
internal static int GetLegacyStreamInfoSize(Generation generation) =>
IsGen5Target(generation)
? Gen5StreamInfoSize
: Gen4StreamInfoSize;
internal static uint GetStreamType(Generation generation, uint streamIndex) =>
IsGen5Target(generation)
? streamIndex + 1
: streamIndex;
internal static void WriteGen5StreamInfoEx(
Span<byte> info,
uint streamType,
uint width,
uint height,
double framesPerSecond,
ulong durationMilliseconds)
{
if (info.Length < StreamInfoExSize)
{
throw new ArgumentException(
$"Stream-info buffer must contain at least {StreamInfoExSize} bytes.",
nameof(info));
}
BinaryPrimitives.WriteUInt64LittleEndian(info[0..], StreamInfoExSize);
BinaryPrimitives.WriteUInt32LittleEndian(info[8..], streamType);
BinaryPrimitives.WriteUInt32LittleEndian(info[16..], width);
BinaryPrimitives.WriteUInt32LittleEndian(info[20..], height);
BinaryPrimitives.WriteDoubleLittleEndian(info[0x40..], framesPerSecond);
BinaryPrimitives.WriteUInt64LittleEndian(info[0x60..], durationMilliseconds);
}
internal static void WriteVideoFrameInfo(
Span<byte> info,
Generation generation,
bool extended,
ulong bufferAddress,
ulong timestamp,
uint width,
uint visibleWidth,
uint height,
uint pitch,
double framesPerSecond)
{
var requiredSize = extended ? FrameInfoExSize : FrameInfoSize;
if (info.Length < requiredSize)
{
throw new ArgumentException(
$"Frame-info buffer must contain at least {requiredSize} bytes.",
nameof(info));
}
BinaryPrimitives.WriteUInt64LittleEndian(info[0..], bufferAddress);
BinaryPrimitives.WriteUInt64LittleEndian(info[16..], timestamp);
BinaryPrimitives.WriteUInt32LittleEndian(info[24..], width);
BinaryPrimitives.WriteUInt32LittleEndian(info[28..], height);
BinaryPrimitives.WriteSingleLittleEndian(info[32..], 1.0f);
if (!extended)
{
return;
}
BinaryPrimitives.WriteUInt32LittleEndian(
info[48..],
width > visibleWidth ? width - visibleWidth : 0);
BinaryPrimitives.WriteUInt32LittleEndian(info[60..], pitch);
info[64] = 8;
info[65] = 8;
if (IsGen5Target(generation))
{
BinaryPrimitives.WriteDoubleLittleEndian(info[0x48..], framesPerSecond);
}
}
private static bool TryReadNullTerminatedUtf8(CpuContext ctx, ulong address, int maxLength, out string value) private static bool TryReadNullTerminatedUtf8(CpuContext ctx, ulong address, int maxLength, out string value)
{ {
value = string.Empty; value = string.Empty;
@@ -0,0 +1,23 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.Libs.VideoOut;
internal static class GuestImageUploadPayloadDiagnostics
{
internal static (long NonzeroBytes, ulong Hash) Summarize(ReadOnlySpan<byte> pixels)
{
const ulong offsetBasis = 14695981039346656037UL;
const ulong prime = 1099511628211UL;
var nonzeroBytes = 0L;
var hash = offsetBasis;
foreach (var value in pixels)
{
nonzeroBytes += value == 0 ? 0 : 1;
hash = (hash ^ value) * prime;
}
return (nonzeroBytes, hash);
}
}
@@ -59,9 +59,14 @@ public sealed record HostVideoOptions
public static class HostVideoHost public static class HostVideoHost
{ {
private static HostVideoOptions _currentOptions = HostVideoOptions.Default;
public static HostVideoOptions CurrentOptions => Volatile.Read(ref _currentOptions);
public static bool TryConfigureVideo(HostVideoOptions options) public static bool TryConfigureVideo(HostVideoOptions options)
{ {
var normalized = options.Normalize(); var normalized = options.Normalize();
Volatile.Write(ref _currentOptions, normalized);
return VulkanVideoPresenter.TryConfigureVideo(normalized) & return VulkanVideoPresenter.TryConfigureVideo(normalized) &
MetalVideoPresenter.TryConfigureVideo(normalized); MetalVideoPresenter.TryConfigureVideo(normalized);
} }
@@ -6,6 +6,7 @@ using Silk.NET.Core.Native;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using SharpEmu.HLE; using SharpEmu.HLE;
using SharpEmu.Libs.Agc; using SharpEmu.Libs.Agc;
using SharpEmu.Libs.AvPlayer;
using SharpEmu.Libs.Media; using SharpEmu.Libs.Media;
using SharpEmu.Libs.Gpu; using SharpEmu.Libs.Gpu;
using SharpEmu.ShaderCompiler; using SharpEmu.ShaderCompiler;
@@ -2426,6 +2427,7 @@ internal static unsafe class VulkanVideoPresenter
if (IsGuestWorkCompletedLocked(pending.RequiredGuestWorkSequence)) if (IsGuestWorkCompletedLocked(pending.RequiredGuestWorkSequence))
{ {
presentation = _pendingGuestImagePresentations.Dequeue(); presentation = _pendingGuestImagePresentations.Dequeue();
TryReplaceWithHostMovieFrame(ref presentation);
return true; return true;
} }
@@ -2458,10 +2460,97 @@ internal static unsafe class VulkanVideoPresenter
} }
presentation = latest; presentation = latest;
TryReplaceWithHostMovieFrame(ref presentation);
return true; return true;
} }
} }
/// <summary>
/// AvPlayer titles whose guest texture allocators reject the decoded movie
/// surface have no sampled image to draw, so the movie would never become
/// visible. In that case the AvPlayer HLE keeps a host-decoded BGRA frame
/// available; substitute it for the guest image the title is flipping.
/// </summary>
private static void TryReplaceWithHostMovieFrame(ref Presentation presentation)
{
if (!TryTakeHostMovieFrame(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);
}
/// <summary>
/// The movie is decoded on the host clock, so it must not be limited to the
/// title's flip rate: emulated flips are far slower than 59.94 Hz, which
/// would turn the intro into a slideshow. The render loop uses this on the
/// ticks where the guest produced no new flip, keeping the same presented
/// sequence so guest presentation bookkeeping is untouched.
/// </summary>
private static bool TryTakeHostMovieOnlyPresentation(
long presentedSequence,
out Presentation presentation)
{
if (!TryTakeHostMovieFrame(out var pixels, out var width, out var height))
{
presentation = default;
return false;
}
presentation = new Presentation(
pixels,
width,
height,
presentedSequence,
GuestDrawKind.None,
TranslatedDraw: null,
RequiredGuestWorkSequence: 0,
IsSplash: false);
return true;
}
private static bool TryTakeHostMovieFrame(
out byte[] pixels,
out uint width,
out uint height)
{
if (!AvPlayerExports.TryGetFallbackPresentationFrame(
out pixels,
out width,
out height,
out var serial))
{
return false;
}
if (Interlocked.Exchange(
ref _tracedAvPlayerFallbackPresentationSerial,
serial) != serial)
{
var frameCount = Interlocked.Increment(
ref _avPlayerFallbackPresentationCount);
if (frameCount <= 4 || frameCount % 30 == 0)
{
Console.Error.WriteLine(
"[VIDEOOUT][INFO] AvPlayer host fallback frame presented: " +
$"frame={frameCount} serial={serial} size={width}x{height}.");
}
}
return true;
}
private static long _tracedAvPlayerFallbackPresentationSerial;
private static long _avPlayerFallbackPresentationCount;
private static readonly HashSet<long> _tracedGuestImagePresentRejections = new(); private static readonly HashSet<long> _tracedGuestImagePresentRejections = new();
private static bool HasPendingGuestPresentation(long presentedSequence) private static bool HasPendingGuestPresentation(long presentedSequence)
@@ -15567,6 +15656,12 @@ internal static unsafe class VulkanVideoPresenter
tookPresentation = TryTakePresentation(_presentedSequence, out presentation); tookPresentation = TryTakePresentation(_presentedSequence, out presentation);
} }
if (!tookPresentation &&
TryTakeHostMovieOnlyPresentation(_presentedSequence, out presentation))
{
tookPresentation = true;
}
if (!tookPresentation) if (!tookPresentation)
{ {
// A render-loop tick with no newer flip is normal. Warn only when // A render-loop tick with no newer flip is normal. Warn only when
@@ -0,0 +1,147 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using System.Diagnostics;
using SharpEmu.HLE;
using SharpEmu.Libs.AvPlayer;
using Xunit;
namespace SharpEmu.Libs.Tests.AvPlayer;
public sealed class AvPlayerAbiTests
{
[Theory]
[InlineData(Generation.Gen4, false, 108UL)]
[InlineData(Generation.Gen5, false, 112UL)]
[InlineData(Generation.Gen4, true, 164UL)]
[InlineData(Generation.Gen5, true, 168UL)]
public void InitAutoStartOffsetMatchesGeneration(
Generation generation,
bool extended,
ulong expected)
{
Assert.Equal(expected, AvPlayerExports.GetAutoStartOffset(generation, extended));
}
[Theory]
[InlineData(Generation.Gen4, 40)]
[InlineData(Generation.Gen5, 32)]
public void LegacyStreamInfoSizeMatchesGeneration(
Generation generation,
int expected)
{
Assert.Equal(expected, AvPlayerExports.GetLegacyStreamInfoSize(generation));
}
[Theory]
[InlineData(Generation.Gen4, 0u, 0u)]
[InlineData(Generation.Gen4, 1u, 1u)]
[InlineData(Generation.Gen5, 0u, 1u)]
[InlineData(Generation.Gen5, 1u, 2u)]
public void StreamTypeMatchesGeneration(
Generation generation,
uint streamIndex,
uint expected)
{
Assert.Equal(expected, AvPlayerExports.GetStreamType(generation, streamIndex));
}
[Fact]
public void Gen5FrameInfoExCarriesPitchCropAndFrameRate()
{
var info = new byte[104];
AvPlayerExports.WriteVideoFrameInfo(
info,
Generation.Gen5,
extended: true,
bufferAddress: 0x1234_5000,
timestamp: 2_903,
width: 512,
visibleWidth: 378,
height: 150,
pitch: 512,
framesPerSecond: 29.97);
Assert.Equal(0x1234_5000UL, BinaryPrimitives.ReadUInt64LittleEndian(info));
Assert.Equal(2_903UL, BinaryPrimitives.ReadUInt64LittleEndian(info.AsSpan(16)));
Assert.Equal(512u, BinaryPrimitives.ReadUInt32LittleEndian(info.AsSpan(24)));
Assert.Equal(150u, BinaryPrimitives.ReadUInt32LittleEndian(info.AsSpan(28)));
Assert.Equal(134u, BinaryPrimitives.ReadUInt32LittleEndian(info.AsSpan(48)));
Assert.Equal(512u, BinaryPrimitives.ReadUInt32LittleEndian(info.AsSpan(60)));
Assert.Equal(8, info[64]);
Assert.Equal(8, info[65]);
Assert.Equal(29.97, BinaryPrimitives.ReadDoubleLittleEndian(info.AsSpan(0x48)));
}
[Fact]
public void FallbackFrameValidationUsesTheDecodedDimensions()
{
var fullHdFrame = new byte[1920 * 1080 * 4];
Assert.True(AvPlayerExports.IsValidBgraFrame(fullHdFrame, 1920, 1080));
Assert.False(AvPlayerExports.IsValidBgraFrame(fullHdFrame, 3840, 2160));
Assert.False(AvPlayerExports.IsValidBgraFrame(fullHdFrame, 0, 1080));
}
[Fact]
public void PosterSuppressesTheDuplicateFirstHostDecodedFrame()
{
var skipFirstDecodedFrame = true;
Assert.False(AvPlayerExports.ShouldPublishFallbackPlaybackFrame(
advanced: true,
hasPresentation: true,
ref skipFirstDecodedFrame));
Assert.False(skipFirstDecodedFrame);
Assert.True(AvPlayerExports.ShouldPublishFallbackPlaybackFrame(
advanced: true,
hasPresentation: true,
ref skipFirstDecodedFrame));
}
[Theory]
[InlineData(false, false, false)]
[InlineData(false, true, false)]
[InlineData(true, false, false)]
[InlineData(true, true, true)]
public void CompletedFallbackIsReleasedAtGuestEndOfStream(
bool fallbackCompleted,
bool guestEndOfStream,
bool expected)
{
var completedTicks = Stopwatch.GetTimestamp();
Assert.Equal(
expected,
AvPlayerExports.ShouldReleaseCompletedFallback(
fallbackCompleted,
guestEndOfStream,
completedTicks,
completedTicks));
}
/// <summary>
/// A title that pauses its player after the poster frame never reaches end
/// of stream, so the hold must expire on its own; otherwise the last movie
/// image stays pinned over everything the game renders next.
/// </summary>
[Fact]
public void CompletedFallbackHoldExpiresWithoutGuestEndOfStream()
{
var completedTicks = Stopwatch.GetTimestamp();
Assert.False(
AvPlayerExports.ShouldReleaseCompletedFallback(
fallbackPlaybackCompleted: true,
guestEndOfStream: false,
completedTicks,
completedTicks + (Stopwatch.Frequency / 10)));
Assert.True(
AvPlayerExports.ShouldReleaseCompletedFallback(
fallbackPlaybackCompleted: true,
guestEndOfStream: false,
completedTicks,
completedTicks + (Stopwatch.Frequency * 2)));
}
}
@@ -0,0 +1,142 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Core.Memory;
using SharpEmu.HLE;
using SharpEmu.Libs.AvPlayer;
using SharpEmu.Libs.Tests.Kernel;
using Xunit;
namespace SharpEmu.Libs.Tests.AvPlayer;
[Collection(KernelMemoryCompatStateCollection.Name)]
public sealed class AvPlayerAllocationTests : IDisposable
{
private const ulong Handle = 0xA0_0000_1000;
private readonly IGuestThreadScheduler? _previousScheduler = GuestThreadExecution.Scheduler;
[Fact]
public void FailedGuestAllocatorsFallBackToHleMemoryInTheSameAttempt()
{
using var memory = new PhysicalVirtualMemory();
var context = new CpuContext(memory, Generation.Gen5);
var scheduler = new FailingAllocatorScheduler();
GuestThreadExecution.Scheduler = scheduler;
AvPlayerExports.RegisterPlayerForTest(
Handle,
width: 16,
height: 16,
durationMilliseconds: 1,
allocateTextureCallback: 0x1000,
allocateCallback: 0x2000);
Assert.True(AvPlayerExports.AllocateGuestVideoBuffersForTest(
context,
Handle,
out var firstBuffer));
Assert.NotEqual(0UL, firstBuffer);
Assert.Equal(2, scheduler.CallCount);
}
public void Dispose()
{
AvPlayerExports.RemovePlayerForTest(Handle);
GuestThreadExecution.Scheduler = _previousScheduler;
}
private sealed class FailingAllocatorScheduler : IGuestThreadScheduler
{
public int CallCount { get; private set; }
public bool SupportsGuestContextTransfer => false;
public void RegisterGuestThreadContext(ulong threadHandle, CpuContext context)
{
}
public bool TryStartThread(
CpuContext creatorContext,
GuestThreadStartRequest request,
out string? error)
{
error = "not supported";
return false;
}
public bool TryJoinThread(
CpuContext callerContext,
ulong threadHandle,
out ulong returnValue,
out string? error)
{
returnValue = 0;
error = "not supported";
return false;
}
public void Pump(CpuContext callerContext, string reason)
{
}
public int WakeBlockedThreads(string wakeKey, int maxCount = int.MaxValue) => 0;
public bool TrySetGuestThreadPriority(ulong guestThreadHandle, int guestPriority) => false;
public bool TrySetGuestThreadAffinity(ulong guestThreadHandle, ulong affinityMask) => false;
public IReadOnlyList<GuestThreadSnapshot> SnapshotThreads() => [];
public bool TryCallGuestFunction(
CpuContext callerContext,
ulong entryPoint,
ulong arg0,
ulong arg1,
ulong stackAddress,
ulong stackSize,
string reason,
out string? error)
{
error = "not supported";
return false;
}
public bool TryCallGuestFunction(
CpuContext callerContext,
ulong entryPoint,
ulong arg0,
ulong arg1,
ulong arg2,
ulong stackAddress,
ulong stackSize,
string reason,
out ulong returnValue,
out string? error)
{
CallCount++;
returnValue = 0;
error = "allocator rejected the request";
return false;
}
public bool TryCallGuestContinuation(
CpuContext callerContext,
GuestCpuContinuation continuation,
string reason,
out string? error)
{
error = "not supported";
return false;
}
public bool TryRaiseGuestException(
CpuContext callerContext,
ulong threadHandle,
ulong handler,
int exceptionType,
out string? error)
{
error = "not supported";
return false;
}
}
}
@@ -0,0 +1,106 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.Libs.AvPlayer;
using Xunit;
namespace SharpEmu.Libs.Tests.AvPlayer;
public sealed class AvPlayerNv12LayoutTests
{
[Theory]
[InlineData(1920, 2048)]
[InlineData(3840, 3840)]
[InlineData(4097, 4352)]
public void CalculateNv12Pitch_AlignsTo256Bytes(int width, int expectedPitch)
{
Assert.Equal(expectedPitch, AvPlayerExports.CalculateNv12Pitch(width));
}
[Fact]
public void CalculateNv12BufferSize_IncludesBothPlanesAtTheAlignedPitch()
{
Assert.Equal(3_317_760, AvPlayerExports.CalculateNv12BufferSize(2048, 1080));
}
[Fact]
public void ConvertNv12ToBgra_UsesTheInterleavedChromaPlaneAndOpaqueAlpha()
{
byte[] nv12 =
[
16, 235,
81, 145,
128, 128,
];
var bgra = new byte[2 * 2 * 4];
AvPlayerExports.ConvertNv12ToBgra(
nv12,
pitch: 2,
bufferHeight: 2,
width: 2,
height: 2,
bgra);
Assert.Equal(
new byte[]
{
0, 0, 0, 255,
255, 255, 255, 255,
76, 76, 76, 255,
150, 150, 150, 255,
},
bgra);
}
[Fact]
public void CopyNv12ToGuestBuffer_UsesSourceStridesAndPitchedUvOffset()
{
const int width = 4;
const int height = 4;
const int sourceLumaStride = 6;
const int sourceChromaStride = 8;
const int destinationPitch = 8;
var source = Enumerable.Repeat((byte)0xEE, 40).ToArray();
for (var row = 0; row < height; row++)
{
for (var column = 0; column < width; column++)
{
source[(row * sourceLumaStride) + column] = checked((byte)(1 + (row * 10) + column));
}
}
var sourceChromaOffset = sourceLumaStride * height;
for (var row = 0; row < height / 2; row++)
{
for (var column = 0; column < width; column++)
{
source[sourceChromaOffset + (row * sourceChromaStride) + column] =
checked((byte)(101 + (row * 10) + column));
}
}
var destination = Enumerable.Repeat((byte)0xCC, 48).ToArray();
AvPlayerExports.CopyNv12ToGuestBuffer(
source,
destination,
width,
height,
sourceLumaStride,
sourceChromaStride,
destinationPitch);
var expected = new byte[48];
for (var row = 0; row < height; row++)
{
source.AsSpan(row * sourceLumaStride, width)
.CopyTo(expected.AsSpan(row * destinationPitch, width));
}
var destinationChromaOffset = destinationPitch * height;
for (var row = 0; row < height / 2; row++)
{
source.AsSpan(sourceChromaOffset + (row * sourceChromaStride), width)
.CopyTo(expected.AsSpan(destinationChromaOffset + (row * destinationPitch), width));
}
Assert.Equal(expected, destination);
}
}
@@ -19,36 +19,39 @@ public sealed class AvPlayerStreamInfoTests
private const byte Sentinel = 0xAB; private const byte Sentinel = 0xAB;
[Theory] [Theory]
[InlineData(false, 0u)] [InlineData(Generation.Gen5, 0u, 32, 1u)]
[InlineData(true, 0u)] [InlineData(Generation.Gen5, 1u, 32, 2u)]
[InlineData(false, 1u)] [InlineData(Generation.Gen4, 0u, 40, 0u)]
[InlineData(true, 1u)] [InlineData(Generation.Gen4, 1u, 40, 1u)]
public void GetStreamInfoFunctionsDoNotWritePastThe32ByteStructure( public void GetStreamInfoUsesTheGenerationSpecificLayout(
bool useExtendedFunction, Generation generation,
uint streamIndex) uint streamIndex,
int structureSize,
uint expectedStreamType)
{ {
var memory = new FakeCpuMemory(BaseAddress, MemorySize); var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var context = new CpuContext(memory, Generation.Gen5); var context = new CpuContext(memory, generation);
AvPlayerExports.RegisterPlayerForTest(
Handle,
1280,
720,
DurationMilliseconds,
hasAudio: true);
AvPlayerExports.RegisterPlayerForTest(Handle, 1280, 720, DurationMilliseconds);
try try
{ {
Span<byte> window = stackalloc byte[40]; Span<byte> window = stackalloc byte[48];
window.Fill(Sentinel); window.Fill(Sentinel);
Assert.True(memory.TryWrite(InfoAddress, window)); Assert.True(memory.TryWrite(InfoAddress, window));
context[CpuRegister.Rdi] = Handle; context[CpuRegister.Rdi] = Handle;
context[CpuRegister.Rsi] = streamIndex; context[CpuRegister.Rsi] = streamIndex;
context[CpuRegister.Rdx] = InfoAddress; context[CpuRegister.Rdx] = InfoAddress;
Assert.Equal(0, AvPlayerExports.AvPlayerGetStreamInfo(context));
var resultCode = useExtendedFunction Span<byte> result = stackalloc byte[48];
? AvPlayerExports.AvPlayerGetStreamInfoEx(context)
: AvPlayerExports.AvPlayerGetStreamInfo(context);
Assert.Equal(0, resultCode);
Span<byte> result = stackalloc byte[40];
Assert.True(memory.TryRead(InfoAddress, result)); Assert.True(memory.TryRead(InfoAddress, result));
Assert.Equal(streamIndex, BinaryPrimitives.ReadUInt32LittleEndian(result)); Assert.Equal(expectedStreamType, BinaryPrimitives.ReadUInt32LittleEndian(result));
if (streamIndex == 0) if (streamIndex == 0)
{ {
Assert.Equal(1280u, BinaryPrimitives.ReadUInt32LittleEndian(result[8..])); Assert.Equal(1280u, BinaryPrimitives.ReadUInt32LittleEndian(result[8..]));
@@ -61,7 +64,71 @@ public sealed class AvPlayerStreamInfoTests
} }
Assert.Equal(DurationMilliseconds, BinaryPrimitives.ReadUInt64LittleEndian(result[24..])); Assert.Equal(DurationMilliseconds, BinaryPrimitives.ReadUInt64LittleEndian(result[24..]));
for (var index = 32; index < result.Length; index++) for (var index = structureSize; index < result.Length; index++)
{
Assert.Equal(Sentinel, result[index]);
}
}
finally
{
AvPlayerExports.RemovePlayerForTest(Handle);
}
}
[Fact]
public void StreamInfoRejectsAudioIndexForVideoOnlyMedia()
{
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var context = new CpuContext(memory, Generation.Gen5);
AvPlayerExports.RegisterPlayerForTest(Handle, 1280, 720, DurationMilliseconds);
try
{
context[CpuRegister.Rdi] = Handle;
context[CpuRegister.Rsi] = 1;
context[CpuRegister.Rdx] = InfoAddress;
Assert.NotEqual(0, AvPlayerExports.AvPlayerGetStreamInfo(context));
Assert.NotEqual(0, AvPlayerExports.AvPlayerGetStreamInfoEx(context));
}
finally
{
AvPlayerExports.RemovePlayerForTest(Handle);
}
}
[Fact]
public void GetStreamInfoExWritesThe104ByteGen5Descriptor()
{
var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var context = new CpuContext(memory, Generation.Gen5);
AvPlayerExports.RegisterPlayerForTest(
Handle,
378,
150,
DurationMilliseconds,
framesPerSecond: 29.97);
try
{
Span<byte> window = stackalloc byte[120];
window.Fill(Sentinel);
Assert.True(memory.TryWrite(InfoAddress, window));
context[CpuRegister.Rdi] = Handle;
context[CpuRegister.Rsi] = 0;
context[CpuRegister.Rdx] = InfoAddress;
Assert.Equal(0, AvPlayerExports.AvPlayerGetStreamInfoEx(context));
Span<byte> result = stackalloc byte[120];
Assert.True(memory.TryRead(InfoAddress, result));
Assert.Equal(104UL, BinaryPrimitives.ReadUInt64LittleEndian(result));
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(result[8..]));
Assert.Equal(378u, BinaryPrimitives.ReadUInt32LittleEndian(result[16..]));
Assert.Equal(150u, BinaryPrimitives.ReadUInt32LittleEndian(result[20..]));
Assert.Equal(29.97, BinaryPrimitives.ReadDoubleLittleEndian(result[0x40..]));
Assert.Equal(DurationMilliseconds, BinaryPrimitives.ReadUInt64LittleEndian(result[0x60..]));
for (var index = 104; index < result.Length; index++)
{ {
Assert.Equal(Sentinel, result[index]); Assert.Equal(Sentinel, result[index]);
} }
@@ -79,7 +146,12 @@ public sealed class AvPlayerStreamInfoTests
{ {
var memory = new FakeCpuMemory(BaseAddress, MemorySize); var memory = new FakeCpuMemory(BaseAddress, MemorySize);
var context = new CpuContext(memory, Generation.Gen5); var context = new CpuContext(memory, Generation.Gen5);
AvPlayerExports.RegisterPlayerForTest(Handle, 1280, 720, DurationMilliseconds); AvPlayerExports.RegisterPlayerForTest(
Handle,
1280,
720,
DurationMilliseconds,
hasAudio: true);
try try
{ {