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.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<string> TracedOnce = 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 _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
{
@@ -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<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(
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<byte> 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 ?? "<none>"}').");
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<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
? 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<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) =>
checked(
AlignUp(player.Width, FramePitchAlignment) *
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)
{
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<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)
{
value = string.Empty;