diff --git a/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs b/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs index 8260bb13..604da53b 100644 --- a/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs +++ b/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs @@ -4,7 +4,9 @@ using SharpEmu.HLE; using SharpEmu.Libs.Kernel; using SharpEmu.Libs.Media; +using SharpEmu.Libs.VideoOut; using System.Buffers.Binary; +using System.Collections.Concurrent; using System.Diagnostics; using System.Globalization; using System.Text; @@ -22,14 +24,213 @@ public static class AvPlayerExports 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. - private const int StreamInfoSize = 32; - private const int StreamInfoExSize = 32; + // The legacy destination is 40 bytes on Gen4 but only 32 bytes on Gen5. + // Writing the Gen4 layout into a Gen5 caller can overwrite its stack canary. + private const int Gen4StreamInfoSize = 40; + private const int Gen5StreamInfoSize = 32; + private const int StreamInfoExSize = 104; private const int MaxGuestPathLength = 4096; + private const int VideoPitchAlignment = 256; private static readonly object StateGate = new(); private static readonly HashSet TracedOnce = new(); private static readonly Dictionary Players = new(); + private static readonly ConcurrentDictionary 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 _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; + } + + /// + /// 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. + /// + 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 { @@ -45,6 +246,8 @@ public static class AvPlayerExports public int Height { get; set; } public double FramesPerSecond { get; set; } = 30.0; public ulong DurationMilliseconds { get; set; } + public bool HasAudio { get; set; } + public bool IsGen5 { get; init; } public bool Started { get; set; } public bool Paused { get; set; } public bool Looping { get; set; } @@ -61,10 +264,20 @@ public static class AvPlayerExports public int GuestBufferStride { get; set; } public int NextGuestBuffer { get; set; } public ulong LastGuestBuffer { get; set; } + public ulong LastVideoTimestamp { get; set; } public long NextFrameIndex { get; set; } public ulong AudioBufferBase { get; set; } public int NextAudioBuffer { 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() { @@ -72,6 +285,8 @@ public static class AvPlayerExports DecoderOutput = null; AudioDecoderOutput?.Dispose(); AudioDecoderOutput = null; + FallbackPlayback?.Dispose(); + FallbackPlayback = null; } public void ResetPlayback() @@ -79,9 +294,19 @@ public static class AvPlayerExports Dispose(); PlaybackClock.Reset(); NextFrameIndex = 0; + LastGuestBuffer = 0; + LastVideoTimestamp = 0; NextAudioFrameIndex = 0; SkippedFrameDebt = 0; 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) { + var autoStartOffset = GetAutoStartOffset(ctx.TargetGeneration, extended: false); Players.Add(handle, new PlayerState { 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, AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 24, out var allocateTexture) ? allocateTexture : 0, AllocateCallback = TryReadUInt64(ctx, initDataAddress + 8, out var allocate) ? allocate : 0, @@ -157,10 +384,12 @@ public static class AvPlayerExports lock (StateGate) { + var autoStartOffset = GetAutoStartOffset(ctx.TargetGeneration, extended: true); Players.Add(handle, new PlayerState { 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, AllocateTextureCallback = TryReadUInt64(ctx, initDataAddress + 32, out var allocateTexture) ? allocateTexture : 0, AllocateCallback = TryReadUInt64(ctx, initDataAddress + 16, out var allocate) ? allocate : 0, @@ -321,20 +550,24 @@ public static class AvPlayerExports LibraryName = "libSceAvPlayer")] public static int AvPlayerResume(CpuContext ctx) { + PlayerState player; lock (StateGate) { - if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var player)) + if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var foundPlayer)) { return SetReturn(ctx, InvalidParameters); } + player = foundPlayer; player.Paused = false; if (player.DecoderOutput is not null) { player.PlaybackClock.Start(); } - return SetReturn(ctx, 0); } + + NotifyEvent(ctx, player, 3); // StatePlay + return SetReturn(ctx, 0); } [SysAbiExport( @@ -385,8 +618,33 @@ public static class AvPlayerExports ExportName = "sceAvPlayerGetStreamInfoEx", Target = Generation.Gen5, LibraryName = "libSceAvPlayer")] - public static int AvPlayerGetStreamInfoEx(CpuContext ctx) => - GetStreamInfoCore(ctx, StreamInfoExSize); + public static int AvPlayerGetStreamInfoEx(CpuContext ctx) + { + 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 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( Nid = "XC9wM+xULz8", @@ -460,14 +718,15 @@ public static class AvPlayerExports { 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)) + player.EndOfStream || player.SourcePath is null || + !player.HasAudio || !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))}"); + $"has_audio={(found && player!.HasAudio)}"); return SetReturn(ctx, 0); } @@ -550,9 +809,11 @@ public static class AvPlayerExports { lock (StateGate) { - var known = Players.ContainsKey(ctx[CpuRegister.Rdi]); - TraceOnce("stream_count", $"stream_count known={known} returned={(known ? 2 : -1)}"); - return SetReturn(ctx, known ? 2 : InvalidParameters); + return SetReturn( + ctx, + Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) + ? player.HasAudio ? 2 : 1 + : InvalidParameters); } } @@ -560,7 +821,12 @@ public static class AvPlayerExports ulong handle, int width, int height, - ulong durationMilliseconds) + ulong durationMilliseconds, + ulong allocateTextureCallback = 0, + ulong allocateCallback = 0, + bool hasAudio = false, + double framesPerSecond = 30.0, + bool isGen5 = true) { PlayerState? previous; lock (StateGate) @@ -569,15 +835,40 @@ public static class AvPlayerExports Players[handle] = new PlayerState { Handle = handle, + IsGen5 = isGen5, Width = width, Height = height, DurationMilliseconds = durationMilliseconds, + HasAudio = hasAudio, + FramesPerSecond = framesPerSecond, + AllocateTextureCallback = allocateTextureCallback, + AllocateCallback = allocateCallback, }; } 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) { PlayerState? player; @@ -595,23 +886,27 @@ public static class AvPlayerExports Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAvPlayer")] 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 infoAddress = ctx[CpuRegister.Rdx]; lock (StateGate) { 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); } + var infoSize = GetLegacyStreamInfoSize(ctx.TargetGeneration); Span info = stackalloc byte[infoSize]; info.Clear(); - BinaryPrimitives.WriteUInt32LittleEndian(info[0..], streamIndex); // 0=video, 1=audio + BinaryPrimitives.WriteUInt32LittleEndian( + info[0..], + GetStreamType(ctx.TargetGeneration, streamIndex)); if (streamIndex == 0) { BinaryPrimitives.WriteUInt32LittleEndian(info[8..], checked((uint)player.Width)); @@ -650,7 +945,14 @@ public static class AvPlayerExports player = foundPlayer; 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 ?? ""}')."); return SetReturn(ctx, OperationFailed); @@ -662,14 +964,13 @@ public static class AvPlayerExports player.Height = height; player.FramesPerSecond = fps; player.DurationMilliseconds = duration; + player.HasAudio = hasAudio; player.Started = 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 if (autoStart) { @@ -684,12 +985,23 @@ public static class AvPlayerExports lock (StateGate) { 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) { 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)) { player.EndOfStream = true; @@ -731,6 +1043,7 @@ public static class AvPlayerExports { return SetReturn(ctx, 0); } + player.LastVideoTimestamp = timestamp; Trace($"video_frame handle=0x{player.Handle:X16} ex={extended} ts={timestamp} data=0x{player.LastGuestBuffer:X16}"); return SetReturn(ctx, 1); @@ -856,9 +1169,10 @@ public static class AvPlayerExports return false; } - var alignedWidth = AlignUp(player.Width, FramePitchAlignment); - var alignedHeight = AlignUp(player.Height, FrameHeightAlignment); - var bufferStride = GetVideoBufferSize(player); + var alignedWidth = AlignUp(player.Width, 16); + var alignedHeight = AlignUp(player.Height, 16); + var (pitch, bufferHeight) = GetFrameGeometry(player, extended); + var bufferStride = CalculateNv12BufferSize(pitch, bufferHeight); if (player.GuestBuffers[0] == 0) { if (!AllocateGuestVideoBuffers(ctx, player, bufferStride)) @@ -866,12 +1180,34 @@ public static class AvPlayerExports return false; } 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; - 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(); for (var row = 0; row < player.Height; row++) { @@ -895,44 +1231,218 @@ public static class AvPlayerExports { 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( + 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 info = extended ? stackalloc byte[FrameInfoExSize] : stackalloc byte[FrameInfoSize]; info.Clear(); - BinaryPrimitives.WriteUInt64LittleEndian(info[0..], bufferAddress); - BinaryPrimitives.WriteUInt64LittleEndian(info[16..], timestamp); - BinaryPrimitives.WriteUInt32LittleEndian(info[24..], checked((uint)(extended ? player.Width : alignedWidth))); - BinaryPrimitives.WriteUInt32LittleEndian(info[28..], checked((uint)(extended ? player.Height : alignedHeight))); - BinaryPrimitives.WriteSingleLittleEndian(info[32..], 1.0f); - if (extended) - { - BinaryPrimitives.WriteUInt32LittleEndian(info[60..], checked((uint)player.Width)); - info[64] = 8; - info[65] = 8; - } + WriteVideoFrameInfo( + info, + ctx.TargetGeneration, + extended, + bufferAddress, + timestamp, + checked((uint)pitch), + checked((uint)player.Width), + checked((uint)(extended ? player.Height : bufferHeight)), + checked((uint)pitch), + player.FramesPerSecond); 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 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); + } + + /// + /// 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. + /// + 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 nv12, + int pitch, + int bufferHeight, + int width, + int height, + Span 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) => checked( AlignUp(player.Width, FramePitchAlignment) * AlignUp(player.Height, FrameHeightAlignment) * 3 / 2); - private static void EnsureGuestVideoBuffers(CpuContext ctx, PlayerState player) + internal static void CopyNv12ToGuestBuffer( + ReadOnlySpan source, + Span destination, + int width, + int height, + int sourceLumaStride, + int sourceChromaStride, + int destinationPitch) { - lock (StateGate) - { - if (player.GuestBuffers[0] != 0 || player.Width <= 0 || player.Height <= 0) - { - return; - } + var sourceChromaOffset = checked(sourceLumaStride * height); + var destinationChromaOffset = checked(destinationPitch * height); + var destinationSize = CalculateNv12BufferSize(destinationPitch, height); + destination[..destinationSize].Clear(); - var bufferSize = GetVideoBufferSize(player); - if (AllocateGuestVideoBuffers(ctx, player, bufferSize)) - { - player.GuestBufferStride = bufferSize; - } + for (var row = 0; row < height; row++) + { + source.Slice(row * sourceLumaStride, width) + .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; } player.GuestBuffers[index] = buffer; + RegisterVideoBuffer(buffer, bufferSize, index, "guest-callback"); Trace($"{kind}_buffer index={index} data=0x{buffer:X16} size={bufferSize}"); } @@ -984,7 +1495,6 @@ public static class AvPlayerExports return true; } } - player.TextureAllocatorFailed = true; } @@ -999,6 +1509,7 @@ public static class AvPlayerExports for (var index = 0; index < player.GuestBuffers.Length; index++) { 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."); return true; @@ -1009,12 +1520,14 @@ public static class AvPlayerExports out int width, out int height, out double framesPerSecond, - out ulong durationMilliseconds) + out ulong durationMilliseconds, + out bool hasAudio) { width = 0; height = 0; framesPerSecond = 30.0; durationMilliseconds = 0; + hasAudio = false; 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))); } + hasAudio = FfmpegMediaStream.TryOpenAudio(path, out var audioStream) && + audioStream is not null; + audioStream?.Dispose(); + return width > 0 && height > 0 && framesPerSecond > 0; } @@ -1320,6 +1837,104 @@ public static class AvPlayerExports return true; } + internal static bool IsValidBgraFrame( + ReadOnlySpan 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 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 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) { value = string.Empty; diff --git a/src/SharpEmu.Libs/VideoOut/GuestImageUploadPayloadDiagnostics.cs b/src/SharpEmu.Libs/VideoOut/GuestImageUploadPayloadDiagnostics.cs new file mode 100644 index 00000000..3e5b7336 --- /dev/null +++ b/src/SharpEmu.Libs/VideoOut/GuestImageUploadPayloadDiagnostics.cs @@ -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 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); + } +} diff --git a/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs b/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs index 0511d541..d07185f8 100644 --- a/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs +++ b/src/SharpEmu.Libs/VideoOut/HostVideoOptions.cs @@ -59,9 +59,14 @@ public sealed record HostVideoOptions public static class HostVideoHost { + private static HostVideoOptions _currentOptions = HostVideoOptions.Default; + + public static HostVideoOptions CurrentOptions => Volatile.Read(ref _currentOptions); + public static bool TryConfigureVideo(HostVideoOptions options) { var normalized = options.Normalize(); + Volatile.Write(ref _currentOptions, normalized); return VulkanVideoPresenter.TryConfigureVideo(normalized) & MetalVideoPresenter.TryConfigureVideo(normalized); } diff --git a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs index 968d3038..fa39aede 100644 --- a/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs +++ b/src/SharpEmu.Libs/VideoOut/VulkanVideoPresenter.cs @@ -6,6 +6,7 @@ using Silk.NET.Core.Native; using System.Collections.Concurrent; using SharpEmu.HLE; using SharpEmu.Libs.Agc; +using SharpEmu.Libs.AvPlayer; using SharpEmu.Libs.Media; using SharpEmu.Libs.Gpu; using SharpEmu.ShaderCompiler; @@ -2426,6 +2427,7 @@ internal static unsafe class VulkanVideoPresenter if (IsGuestWorkCompletedLocked(pending.RequiredGuestWorkSequence)) { presentation = _pendingGuestImagePresentations.Dequeue(); + TryReplaceWithHostMovieFrame(ref presentation); return true; } @@ -2458,10 +2460,97 @@ internal static unsafe class VulkanVideoPresenter } presentation = latest; + TryReplaceWithHostMovieFrame(ref presentation); return true; } } + /// + /// 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. + /// + 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); + } + + /// + /// 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. + /// + 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 _tracedGuestImagePresentRejections = new(); private static bool HasPendingGuestPresentation(long presentedSequence) @@ -15567,6 +15656,12 @@ internal static unsafe class VulkanVideoPresenter tookPresentation = TryTakePresentation(_presentedSequence, out presentation); } + if (!tookPresentation && + TryTakeHostMovieOnlyPresentation(_presentedSequence, out presentation)) + { + tookPresentation = true; + } + if (!tookPresentation) { // A render-loop tick with no newer flip is normal. Warn only when diff --git a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerAbiTests.cs b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerAbiTests.cs new file mode 100644 index 00000000..2b5e794c --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerAbiTests.cs @@ -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)); + } + + /// + /// 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. + /// + [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))); + } +} diff --git a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerAllocationTests.cs b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerAllocationTests.cs new file mode 100644 index 00000000..faab4adb --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerAllocationTests.cs @@ -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 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; + } + } +} diff --git a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerNv12LayoutTests.cs b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerNv12LayoutTests.cs new file mode 100644 index 00000000..812ec354 --- /dev/null +++ b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerNv12LayoutTests.cs @@ -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); + } +} diff --git a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerStreamInfoTests.cs b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerStreamInfoTests.cs index ecb55ecb..7aa1284b 100644 --- a/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerStreamInfoTests.cs +++ b/tests/SharpEmu.Libs.Tests/AvPlayer/AvPlayerStreamInfoTests.cs @@ -19,36 +19,39 @@ public sealed class AvPlayerStreamInfoTests private const byte Sentinel = 0xAB; [Theory] - [InlineData(false, 0u)] - [InlineData(true, 0u)] - [InlineData(false, 1u)] - [InlineData(true, 1u)] - public void GetStreamInfoFunctionsDoNotWritePastThe32ByteStructure( - bool useExtendedFunction, - uint streamIndex) + [InlineData(Generation.Gen5, 0u, 32, 1u)] + [InlineData(Generation.Gen5, 1u, 32, 2u)] + [InlineData(Generation.Gen4, 0u, 40, 0u)] + [InlineData(Generation.Gen4, 1u, 40, 1u)] + public void GetStreamInfoUsesTheGenerationSpecificLayout( + Generation generation, + uint streamIndex, + int structureSize, + uint expectedStreamType) { 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 { - Span window = stackalloc byte[40]; + Span window = stackalloc byte[48]; window.Fill(Sentinel); Assert.True(memory.TryWrite(InfoAddress, window)); context[CpuRegister.Rdi] = Handle; context[CpuRegister.Rsi] = streamIndex; context[CpuRegister.Rdx] = InfoAddress; + Assert.Equal(0, AvPlayerExports.AvPlayerGetStreamInfo(context)); - var resultCode = useExtendedFunction - ? AvPlayerExports.AvPlayerGetStreamInfoEx(context) - : AvPlayerExports.AvPlayerGetStreamInfo(context); - Assert.Equal(0, resultCode); - - Span result = stackalloc byte[40]; + Span result = stackalloc byte[48]; Assert.True(memory.TryRead(InfoAddress, result)); - Assert.Equal(streamIndex, BinaryPrimitives.ReadUInt32LittleEndian(result)); + Assert.Equal(expectedStreamType, BinaryPrimitives.ReadUInt32LittleEndian(result)); if (streamIndex == 0) { Assert.Equal(1280u, BinaryPrimitives.ReadUInt32LittleEndian(result[8..])); @@ -61,7 +64,71 @@ public sealed class AvPlayerStreamInfoTests } 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 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 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]); } @@ -79,7 +146,12 @@ public sealed class AvPlayerStreamInfoTests { var memory = new FakeCpuMemory(BaseAddress, MemorySize); var context = new CpuContext(memory, Generation.Gen5); - AvPlayerExports.RegisterPlayerForTest(Handle, 1280, 720, DurationMilliseconds); + AvPlayerExports.RegisterPlayerForTest( + Handle, + 1280, + 720, + DurationMilliseconds, + hasAudio: true); try {