mirror of
https://github.com/par274/sharpemu.git
synced 2026-08-19 07:51:30 +08:00
A vplayer fix (#821)
* Astrobot-video-fix * Astrobot-video-fix with updated files * Astrobot-video-fix with tests files
This commit is contained in:
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user