sharpemu/src/SharpEmu.Libs/AvPlayer/AvPlayerExports.cs

2949 lines
105 KiB
C#

// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE;
using SharpEmu.Libs.Kernel;
using SharpEmu.Libs.Media;
using SharpEmu.Libs.VideoOut;
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Threading.Channels;
namespace SharpEmu.Libs.AvPlayer;
public static class AvPlayerExports
{
private const int InvalidParameters = unchecked((int)0x806A0001);
private const int OperationFailed = unchecked((int)0x806A0002);
private const int WarningJumpComplete = unchecked((int)0x806A00A3);
private const int DefaultFrameBufferCount = 2;
private const int MinimumFrameBufferCount = 2;
private const int MaximumFrameBufferCount = 16;
private const int MaxCatchUpFrames = 2;
private const uint AvSyncModeDefault = 0;
private const uint AvSyncModeNone = 1;
private const int AudioSamplesPerFrame = 1024;
private const int AudioSampleRate = 48_000;
private const ulong TextureAllocationAlignment = 0x100;
private const int FramePitchAlignment = 64;
private const int FrameHeightAlignment = 16;
private const int FrameInfoSize = 40;
private const int FrameInfoExSize = 104;
// 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 ReplacementFileReadBufferSize = 1024 * 1024;
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)
{
// The GPU worker must remain available for guest allocation callbacks.
if (!Monitor.TryEnter(StateGate))
{
pixels = [];
width = 0;
height = 0;
serial = 0;
return false;
}
try
{
PlayerState? latest = null;
foreach (var player in Players.Values)
{
if (!ShouldPresentFallback(player.Started, player.Paused))
{
continue;
}
if (player.FallbackPlayback is { } playback &&
player.FallbackRequestedFrameIndex > player.FallbackPresentedFrameIndex)
{
if (playback.TryGetFrameAtOrBeforeIndex(
player.FallbackRequestedFrameIndex,
out var playbackPixels,
out var playbackFrameIndex,
out var advanced))
{
if (playbackFrameIndex > player.FallbackPresentedFrameIndex)
{
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;
player.FallbackPresentedFrameIndex = playbackFrameIndex;
}
}
else if (playback.IsFinished)
{
playback.Dispose();
player.FallbackPlayback = null;
player.FallbackPlaybackCompleted = true;
player.FallbackCompletionPending = true;
player.FallbackPlaybackCompletedTicks = Stopwatch.GetTimestamp();
if (ShouldCompleteGuestPlaybackAfterFallback(
player.FallbackCompletionPending,
player.Looping))
{
CompleteGuestPlaybackAfterFallback(player);
}
Trace(
$"host_fallback_finished handle=0x{player.Handle:X16} " +
$"guest_eof={player.EndOfStream} " +
$"completion_pending={player.FallbackCompletionPending} " +
"holding_last_frame=true");
}
}
// Keep a completed fallback frame only until the guest-visible
// player reaches EOF or the bounded grace period expires. A
// looping player does not use fallback completion as EOF.
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);
}
finally
{
Monitor.Exit(StateGate);
}
}
internal static bool ShouldPublishFallbackPlaybackFrame(
bool advanced,
bool hasPresentation,
ref bool skipFirstDecodedFrame)
{
if (advanced && hasPresentation && skipFirstDecodedFrame)
{
skipFirstDecodedFrame = false;
return false;
}
return advanced || !hasPresentation;
}
internal static bool ShouldCompleteGuestPlaybackAfterFallback(
bool fallbackCompletionPending,
bool looping) =>
fallbackCompletionPending && !looping;
internal static bool ShouldPresentFallback(bool started, bool paused) =>
started && !paused;
internal static bool ShouldCreateFallbackPoster(
bool fallbackPlaybackAttempted,
bool fallbackPlaybackRunning,
bool hasPresentation) =>
!hasPresentation &&
(!fallbackPlaybackAttempted || fallbackPlaybackRunning);
/// <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)
=> TraceVideoImages && IsVideoBufferAddress(address);
internal static bool IsVideoBufferAddress(ulong address)
{
if (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}");
}
}
internal enum VideoFrameReadResult
{
Pending,
Ready,
End,
}
internal sealed class VideoFrameQueue : IDisposable
{
private readonly Stream _stream;
private readonly int _frameByteCount;
private readonly Channel<byte[]> _frames;
private readonly ConcurrentQueue<byte[]> _reusableFrames = new();
private byte[]? _currentFrame;
private readonly CancellationTokenSource _stop = new();
private readonly Thread _worker;
private int _completed;
private int _disposed;
public VideoFrameQueue(
Stream stream,
int frameByteCount,
int capacity)
{
_stream = stream;
_frameByteCount = frameByteCount;
_frames = Channel.CreateBounded<byte[]>(new BoundedChannelOptions(
Math.Clamp(capacity, MinimumFrameBufferCount, MaximumFrameBufferCount))
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true,
SingleWriter = true,
});
_worker = new Thread(DecodeFrames)
{
IsBackground = true,
Name = "SharpEmu AvPlayer Video Decoder",
};
_worker.Start();
}
public VideoFrameReadResult TryRead(out byte[]? frame)
{
var completed = Volatile.Read(ref _completed) != 0;
if (_frames.Reader.TryRead(out frame))
{
// The consumer keeps this frame until the next successful read.
// Storage belongs to this queue and is never shared with a new playback.
if (_currentFrame is not null)
{
_reusableFrames.Enqueue(_currentFrame);
}
_currentFrame = frame;
return VideoFrameReadResult.Ready;
}
frame = null;
return completed
? VideoFrameReadResult.End
: VideoFrameReadResult.Pending;
}
private void DecodeFrames()
{
try
{
while (!_stop.IsCancellationRequested)
{
if (!_reusableFrames.TryDequeue(out var frame))
{
frame = GC.AllocateUninitializedArray<byte>(_frameByteCount);
}
if (!ReadExactly(_stream, frame))
{
break;
}
_frames.Writer.WriteAsync(frame, _stop.Token)
.AsTask()
.GetAwaiter()
.GetResult();
}
}
catch (OperationCanceledException) when (_stop.IsCancellationRequested)
{
}
catch (ObjectDisposedException) when (_stop.IsCancellationRequested)
{
}
catch (ChannelClosedException) when (_stop.IsCancellationRequested)
{
}
catch (IOException exception)
{
Console.Error.WriteLine(
$"[AVPLAYER][ERROR] FFmpeg stream read failed: {exception.Message}");
}
finally
{
Volatile.Write(ref _completed, 1);
_frames.Writer.TryComplete();
_stream.Dispose();
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_stop.Cancel();
_stream.Dispose();
_frames.Writer.TryComplete();
}
}
private sealed class PlayerState : IDisposable
{
public required ulong Handle { get; init; }
public bool AutoStart { get; init; }
public ulong AllocatorObject { get; init; }
public ulong AllocateTextureCallback { get; init; }
public ulong AllocateCallback { get; init; }
public ulong FileObject { get; init; }
public ulong FileOpenCallback { get; init; }
public ulong FileCloseCallback { get; init; }
public ulong FileReadOffsetCallback { get; init; }
public ulong FileSizeCallback { get; init; }
public object FileReplacementGate { get; } = new();
public ulong EventObject { get; init; }
public ulong EventCallback { get; init; }
public string? SourcePath { get; set; }
public string? OwnedSourcePath { get; set; }
public ulong ReplacementFileReadBuffer { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public double FramesPerSecond { get; set; } = 30.0;
public float AspectRatio { get; set; }
public bool VideoFullRange { get; set; }
public uint ColorPrimaries { get; set; }
public uint TransferCharacteristics { get; set; }
public ulong DurationMilliseconds { get; set; }
public bool HasAudio { get; set; }
public uint AvSyncMode { get; set; } = AvSyncModeDefault;
public bool IsGen5 { get; init; }
public bool Started { get; set; }
public bool Paused { get; set; }
public bool Looping { get; set; }
public bool EndOfStream { get; set; }
public bool SeekVideoFramePending { get; set; }
public ulong StartTimeMilliseconds { get; set; }
public VideoFrameQueue? VideoDecoder { get; set; }
public Stream? AudioDecoderOutput { get; set; }
public Stopwatch PlaybackClock { get; } = new();
public byte[]? RawFrame { get; set; }
public byte[]? RawAudioFrame { get; set; }
public byte[]? PaddedFrame { get; set; }
public ulong[] GuestBuffers { get; init; } = new ulong[DefaultFrameBufferCount];
public bool TextureAllocatorFailed { get; set; }
public int GuestBufferStride { get; set; }
public int NextGuestBuffer { get; set; }
public ulong LastGuestBuffer { get; set; }
public ulong LastVideoTimestamp { get; set; }
public ulong EventDataBuffer { 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 bool FallbackCompletionPending { get; set; }
public long FallbackPlaybackCompletedTicks { get; set; }
public bool SkipFirstFallbackPlaybackFrame { get; set; }
public long FallbackRequestedFrameIndex { get; set; } = -1;
public long FallbackPresentedFrameIndex { get; set; } = -1;
public void Dispose()
{
DisposePlaybackResources();
if (OwnedSourcePath is { } ownedSourcePath)
{
TryDeleteMaterializedSource(ownedSourcePath);
OwnedSourcePath = null;
}
SourcePath = null;
}
private void DisposePlaybackResources()
{
VideoDecoder?.Dispose();
VideoDecoder = null;
AudioDecoderOutput?.Dispose();
AudioDecoderOutput = null;
FallbackPlayback?.Dispose();
FallbackPlayback = null;
}
public void ResetPlayback()
{
DisposePlaybackResources();
PlaybackClock.Reset();
NextFrameIndex = 0;
LastGuestBuffer = 0;
LastVideoTimestamp = 0;
SeekVideoFramePending = false;
StartTimeMilliseconds = 0;
NextAudioFrameIndex = 0;
EndOfStream = false;
FallbackPresentationPixels = null;
FallbackPresentationWidth = 0;
FallbackPresentationHeight = 0;
FallbackPresentationSerial = 0;
FallbackPlaybackAttempted = false;
FallbackPlaybackCompleted = false;
FallbackCompletionPending = false;
FallbackPlaybackCompletedTicks = 0;
SkipFirstFallbackPlaybackFrame = false;
FallbackRequestedFrameIndex = -1;
FallbackPresentedFrameIndex = -1;
}
}
[SysAbiExport(
Nid = "aS66RI0gGgo",
ExportName = "sceAvPlayerInit",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerInit(CpuContext ctx)
{
var initDataAddress = ctx[CpuRegister.Rdi];
if (initDataAddress == 0 ||
!KernelMemoryCompatExports.TryAllocateHleData(ctx, 0x40, 16, out var handle))
{
ctx[CpuRegister.Rax] = 0;
return 0;
}
lock (StateGate)
{
var autoStartOffset = GetAutoStartOffset(ctx.TargetGeneration, extended: false);
Players.Add(handle, new PlayerState
{
Handle = handle,
IsGen5 = IsGen5Target(ctx.TargetGeneration),
AutoStart = TryReadByte(ctx, initDataAddress + autoStartOffset, out var autoStart) && autoStart != 0,
GuestBuffers = new ulong[ReadOutputVideoFrameBufferCount(
ctx,
initDataAddress,
extended: false)],
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,
FileObject = TryReadUInt64(ctx, initDataAddress + 40, out var fileObject) ? fileObject : 0,
FileOpenCallback = TryReadUInt64(ctx, initDataAddress + 48, out var fileOpen) ? fileOpen : 0,
FileCloseCallback = TryReadUInt64(ctx, initDataAddress + 56, out var fileClose) ? fileClose : 0,
FileReadOffsetCallback = TryReadUInt64(ctx, initDataAddress + 64, out var fileReadOffset) ? fileReadOffset : 0,
FileSizeCallback = TryReadUInt64(ctx, initDataAddress + 72, out var fileSize) ? fileSize : 0,
EventObject = TryReadUInt64(ctx, initDataAddress + 80, out var eventObject) ? eventObject : 0,
EventCallback = TryReadUInt64(ctx, initDataAddress + 88, out var eventCallback) ? eventCallback : 0,
});
}
Trace(
$"init handle=0x{handle:X16} " +
$"alloc_texture=0x{Players[handle].AllocateTextureCallback:X16} " +
$"file_object=0x{Players[handle].FileObject:X16} " +
$"file_open=0x{Players[handle].FileOpenCallback:X16} " +
$"file_close=0x{Players[handle].FileCloseCallback:X16} " +
$"file_read=0x{Players[handle].FileReadOffsetCallback:X16} " +
$"file_size=0x{Players[handle].FileSizeCallback:X16} " +
$"video_buffers={Players[handle].GuestBuffers.Length}");
ctx[CpuRegister.Rax] = handle;
return unchecked((int)handle);
}
[SysAbiExport(
Nid = "HD1YKVU26-M",
ExportName = "sceAvPlayerPostInit",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerPostInit(CpuContext ctx)
{
var handle = ctx[CpuRegister.Rdi];
var dataAddress = ctx[CpuRegister.Rsi];
lock (StateGate)
{
return SetReturn(
ctx,
handle != 0 && dataAddress != 0 && Players.ContainsKey(handle)
? 0
: InvalidParameters);
}
}
[SysAbiExport(
Nid = "o9eWRkSL+M4",
ExportName = "sceAvPlayerInitEx",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerInitEx(CpuContext ctx)
{
var initDataAddress = ctx[CpuRegister.Rdi];
var playerOutAddress = ctx[CpuRegister.Rsi];
if (initDataAddress == 0 ||
playerOutAddress == 0 ||
!KernelMemoryCompatExports.TryAllocateHleData(ctx, 0x40, 16, out var handle) ||
!ctx.TryWriteUInt64(playerOutAddress, handle))
{
return SetReturn(ctx, InvalidParameters);
}
lock (StateGate)
{
var autoStartOffset = GetAutoStartOffset(ctx.TargetGeneration, extended: true);
Players.Add(handle, new PlayerState
{
Handle = handle,
IsGen5 = IsGen5Target(ctx.TargetGeneration),
AutoStart = TryReadByte(ctx, initDataAddress + autoStartOffset, out var autoStart) && autoStart != 0,
GuestBuffers = new ulong[ReadOutputVideoFrameBufferCount(
ctx,
initDataAddress,
extended: true)],
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,
FileObject = TryReadUInt64(ctx, initDataAddress + 48, out var fileObject) ? fileObject : 0,
FileOpenCallback = TryReadUInt64(ctx, initDataAddress + 56, out var fileOpen) ? fileOpen : 0,
FileCloseCallback = TryReadUInt64(ctx, initDataAddress + 64, out var fileClose) ? fileClose : 0,
FileReadOffsetCallback = TryReadUInt64(ctx, initDataAddress + 72, out var fileReadOffset) ? fileReadOffset : 0,
FileSizeCallback = TryReadUInt64(ctx, initDataAddress + 80, out var fileSize) ? fileSize : 0,
EventObject = TryReadUInt64(ctx, initDataAddress + 88, out var eventObject) ? eventObject : 0,
EventCallback = TryReadUInt64(ctx, initDataAddress + 96, out var eventCallback) ? eventCallback : 0,
});
}
Trace(
$"init_ex handle=0x{handle:X16} " +
$"alloc_texture=0x{Players[handle].AllocateTextureCallback:X16} " +
$"file_object=0x{Players[handle].FileObject:X16} " +
$"file_open=0x{Players[handle].FileOpenCallback:X16} " +
$"file_close=0x{Players[handle].FileCloseCallback:X16} " +
$"file_read=0x{Players[handle].FileReadOffsetCallback:X16} " +
$"file_size=0x{Players[handle].FileSizeCallback:X16} " +
$"video_buffers={Players[handle].GuestBuffers.Length}");
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "eBTreZ84JFY",
ExportName = "sceAvPlayerSetLogCallback",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerSetLogCallback(CpuContext ctx) => SetReturn(ctx, 0);
[SysAbiExport(
Nid = "NkJwDzKmIlw",
ExportName = "sceAvPlayerClose",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerClose(CpuContext ctx)
{
PlayerState? player;
lock (StateGate)
{
if (!Players.Remove(ctx[CpuRegister.Rdi], out player))
{
return SetReturn(ctx, InvalidParameters);
}
}
player.Dispose();
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "KMcEa+rHsIo",
ExportName = "sceAvPlayerAddSource",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerAddSource(CpuContext ctx)
{
if (!TryReadNullTerminatedUtf8(ctx, ctx[CpuRegister.Rsi], MaxGuestPathLength, out var path))
{
return SetReturn(ctx, InvalidParameters);
}
return AddSource(ctx, path);
}
[SysAbiExport(
Nid = "x8uvuFOPZhU",
ExportName = "sceAvPlayerAddSourceEx",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerAddSourceEx(CpuContext ctx)
{
var uriType = unchecked((uint)ctx[CpuRegister.Rsi]);
var detailsAddress = ctx[CpuRegister.Rdx];
if (uriType != 0 || detailsAddress == 0 ||
!ctx.TryReadUInt64(detailsAddress, out var pathAddress) ||
!TryReadUInt32(ctx, detailsAddress + sizeof(ulong), out var pathLength) ||
pathLength == 0 || pathLength > MaxGuestPathLength ||
!TryReadUtf8(ctx, pathAddress, checked((int)pathLength), out var path))
{
return SetReturn(ctx, InvalidParameters);
}
return AddSource(ctx, path.TrimEnd('\0'));
}
[SysAbiExport(
Nid = "ET4Gr-Uu07s",
ExportName = "sceAvPlayerStart",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerStart(CpuContext ctx)
{
PlayerState player;
lock (StateGate)
{
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var foundPlayer) || foundPlayer.SourcePath is null)
{
return SetReturn(ctx, InvalidParameters);
}
player = foundPlayer;
player.Started = true;
player.Paused = false;
player.EndOfStream = false;
Trace($"start handle=0x{player.Handle:X16}");
}
// The platform player opens its codecs and launches dedicated decoder
// workers as part of Start. Starting lazily from the first data poll can
// starve high-resolution streams behind guest execution and audio work.
if (!EnsureDecoder(player))
{
lock (StateGate)
{
player.Started = false;
player.EndOfStream = true;
}
return SetReturn(ctx, OperationFailed);
}
// Event callbacks are guest code and can immediately query the player.
// Never hold StateGate while waiting for one or the callback deadlocks
// when it re-enters an AvPlayer export on another guest worker.
NotifyEvent(ctx, player, 3); // StatePlay
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "ZC17w3vB5Lo",
ExportName = "sceAvPlayerStop",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerStop(CpuContext ctx)
{
PlayerState player;
lock (StateGate)
{
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var foundPlayer))
{
return SetReturn(ctx, InvalidParameters);
}
player = foundPlayer;
player.ResetPlayback();
player.Started = false;
}
NotifyEvent(ctx, player, 1); // StateStop
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "9y5v+fGN4Wk",
ExportName = "sceAvPlayerPause",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerPause(CpuContext ctx)
{
PlayerState player;
lock (StateGate)
{
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var foundPlayer))
{
return SetReturn(ctx, InvalidParameters);
}
player = foundPlayer;
player.Paused = true;
player.PlaybackClock.Stop();
player.FallbackPlayback?.Pause();
Console.Error.WriteLine($"[AVPLAYER][INFO] pause handle=0x{player.Handle:X16}");
}
NotifyEvent(ctx, player, 4); // StatePause
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "w5moABNwnRY",
ExportName = "sceAvPlayerResume",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerResume(CpuContext ctx)
{
PlayerState player;
lock (StateGate)
{
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var foundPlayer))
{
return SetReturn(ctx, InvalidParameters);
}
player = foundPlayer;
player.Paused = false;
player.SeekVideoFramePending = false;
if (player.VideoDecoder is not null)
{
player.PlaybackClock.Start();
}
player.FallbackPlayback?.Resume();
Console.Error.WriteLine($"[AVPLAYER][INFO] resume handle=0x{player.Handle:X16}");
}
NotifyEvent(ctx, player, 3); // StatePlay
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "OVths0xGfho",
ExportName = "sceAvPlayerSetLooping",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerSetLooping(CpuContext ctx)
{
lock (StateGate)
{
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var player))
{
return SetReturn(ctx, InvalidParameters);
}
player.Looping = ctx[CpuRegister.Rsi] != 0;
if (ShouldCompleteGuestPlaybackAfterFallback(
player.FallbackCompletionPending,
player.Looping))
{
CompleteGuestPlaybackAfterFallback(player);
}
Trace(
$"set_looping handle=0x{player.Handle:X16} enabled={player.Looping} " +
$"guest_eof={player.EndOfStream} " +
$"completion_pending={player.FallbackCompletionPending}");
return SetReturn(ctx, 0);
}
}
[SysAbiExport(
Nid = "ODJK2sn9w4A",
ExportName = "sceAvPlayerEnableStream",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerEnableStream(CpuContext ctx)
{
TraceOnce(
$"enable_stream_{ctx[CpuRegister.Rsi]}",
$"enable_stream index={ctx[CpuRegister.Rsi]}");
return ValidatePlayer(ctx);
}
[SysAbiExport(
Nid = "k-q+xOxdc3E",
ExportName = "sceAvPlayerSetAvSyncMode",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerSetAvSyncMode(CpuContext ctx)
{
var requestedMode = unchecked((uint)ctx[CpuRegister.Rsi]);
lock (StateGate)
{
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) ||
!IsValidAvSyncMode(requestedMode))
{
return SetReturn(ctx, InvalidParameters);
}
player.AvSyncMode = requestedMode;
Trace(
$"set_av_sync_mode handle=0x{player.Handle:X16} " +
$"mode={player.AvSyncMode}");
return SetReturn(ctx, 0);
}
}
[SysAbiExport(
Nid = "ctTAcF5DiKQ",
ExportName = "sceAvPlayerGetStreamInfoEx",
Target = Generation.Gen5,
LibraryName = "libSceAvPlayer")]
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();
if (streamIndex == 0)
{
WriteGen5StreamInfoEx(
info,
GetStreamType(ctx.TargetGeneration, streamIndex),
checked((uint)player.Width),
checked((uint)player.Height),
player.FramesPerSecond,
player.DurationMilliseconds,
player.AspectRatio,
player.VideoFullRange,
player.ColorPrimaries,
player.TransferCharacteristics);
}
else
{
WriteGen5AudioStreamInfoEx(
info,
GetStreamType(ctx.TargetGeneration, streamIndex),
channelCount: 2,
sampleRate: AudioSampleRate,
durationMilliseconds: player.DurationMilliseconds);
}
return SetReturn(
ctx,
ctx.Memory.TryWrite(infoAddress, info) ? 0 : InvalidParameters);
}
}
[SysAbiExport(
Nid = "XC9wM+xULz8",
ExportName = "sceAvPlayerJumpToTime",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerJumpToTime(CpuContext ctx)
{
PlayerState player;
var requestedMilliseconds = ctx[CpuRegister.Rsi];
lock (StateGate)
{
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var foundPlayer) ||
foundPlayer.SourcePath is null)
{
return SetReturn(ctx, InvalidParameters);
}
player = foundPlayer;
var wasPaused = player.Paused;
player.ResetPlayback();
player.Started = true;
player.Paused = wasPaused;
player.StartTimeMilliseconds = Math.Min(
requestedMilliseconds,
player.DurationMilliseconds);
player.NextFrameIndex = 0;
player.NextAudioFrameIndex = 0;
player.SeekVideoFramePending = wasPaused;
Trace(
$"jump handle=0x{player.Handle:X16} requested_ms={requestedMilliseconds} " +
$"start_ms={player.StartTimeMilliseconds} paused={wasPaused} " +
$"seek_frame={player.SeekVideoFramePending}");
}
NotifyWarning(ctx, player, WarningJumpComplete);
return SetReturn(ctx, 0);
}
[SysAbiExport(
Nid = "yN7Jhuv8g24",
ExportName = "sceAvPlayerVprintf",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerVprintf(CpuContext ctx) => SetReturn(ctx, 0);
[SysAbiExport(
Nid = "UbQoYawOsfY",
ExportName = "sceAvPlayerIsActive",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerIsActive(CpuContext ctx)
{
lock (StateGate)
{
var found = Players.TryGetValue(ctx[CpuRegister.Rdi], out var player);
var active = found && player!.Started && !player.EndOfStream;
TraceOnce(
"is_active",
$"is_active found={found} started={(found && player!.Started)} " +
$"eos={(found && player!.EndOfStream)} returned={(active ? 1 : 0)}");
return SetReturn(ctx, active ? 1 : 0);
}
}
[SysAbiExport(
Nid = "o3+RWnHViSg",
ExportName = "sceAvPlayerGetVideoData",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerGetVideoData(CpuContext ctx) => GetVideoData(ctx, extended: false);
[SysAbiExport(
Nid = "JdksQu8pNdQ",
ExportName = "sceAvPlayerGetVideoDataEx",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerGetVideoDataEx(CpuContext ctx) => GetVideoData(ctx, extended: true);
[SysAbiExport(
Nid = "Wnp1OVcrZgk",
ExportName = "sceAvPlayerGetAudioData",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerGetAudioData(CpuContext ctx)
{
var infoAddress = ctx[CpuRegister.Rsi];
lock (StateGate)
{
var found = Players.TryGetValue(ctx[CpuRegister.Rdi], out var player);
if (!found || infoAddress == 0 || !player!.Started || player.Paused ||
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)} " +
$"has_audio={(found && player!.HasAudio)}");
return SetReturn(ctx, 0);
}
TraceOnce("audio_data_ok", "audio_data first delivery");
const int channelCount = 2;
const int audioFrameSize = AudioSamplesPerFrame * channelCount * sizeof(short);
if (player.RawAudioFrame is null ||
!ReadExactly(player.AudioDecoderOutput, player.RawAudioFrame))
{
return SetReturn(ctx, 0);
}
if (player.AudioBufferBase == 0)
{
if (!KernelMemoryCompatExports.TryAllocateHleData(
ctx,
audioFrameSize * 8UL,
0x100,
out var audioBufferBase))
{
return SetReturn(ctx, 0);
}
player.AudioBufferBase = audioBufferBase;
}
var bufferAddress = player.AudioBufferBase +
checked((ulong)(player.NextAudioBuffer * audioFrameSize));
player.NextAudioBuffer = (player.NextAudioBuffer + 1) % 8;
if (!ctx.Memory.TryWrite(bufferAddress, player.RawAudioFrame))
{
return SetReturn(ctx, 0);
}
var timestamp = player.StartTimeMilliseconds + checked((ulong)(
player.NextAudioFrameIndex * AudioSamplesPerFrame * 1000L /
AudioSampleRate));
player.NextAudioFrameIndex++;
Span<byte> info = stackalloc byte[FrameInfoSize];
info.Clear();
BinaryPrimitives.WriteUInt64LittleEndian(info[0..], bufferAddress);
BinaryPrimitives.WriteUInt64LittleEndian(info[16..], timestamp);
BinaryPrimitives.WriteUInt16LittleEndian(info[24..], channelCount);
BinaryPrimitives.WriteUInt32LittleEndian(info[28..], AudioSampleRate);
BinaryPrimitives.WriteUInt32LittleEndian(info[32..], audioFrameSize);
if (!ctx.Memory.TryWrite(infoAddress, info))
{
return SetReturn(ctx, 0);
}
Trace($"audio_frame handle=0x{player.Handle:X16} ts={timestamp} data=0x{bufferAddress:X16}");
return SetReturn(ctx, 1);
}
}
[SysAbiExport(
Nid = "wwM99gjFf1Y",
ExportName = "sceAvPlayerCurrentTime",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerCurrentTime(CpuContext ctx)
{
lock (StateGate)
{
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var player))
{
return SetReturn(ctx, InvalidParameters);
}
var milliseconds = player.AvSyncMode == AvSyncModeDefault && player.HasAudio
? player.StartTimeMilliseconds + checked((ulong)(
player.NextAudioFrameIndex * AudioSamplesPerFrame * 1000L /
AudioSampleRate))
: player.StartTimeMilliseconds +
checked((ulong)player.PlaybackClock.ElapsedMilliseconds);
ctx[CpuRegister.Rax] = milliseconds;
return unchecked((int)milliseconds);
}
}
[SysAbiExport(
Nid = "hdTyRzCXQeQ",
ExportName = "sceAvPlayerStreamCount",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerStreamCount(CpuContext ctx)
{
lock (StateGate)
{
return SetReturn(
ctx,
Players.TryGetValue(ctx[CpuRegister.Rdi], out var player)
? player.HasAudio ? 2 : 1
: InvalidParameters);
}
}
internal static void RegisterPlayerForTest(
ulong handle,
int width,
int height,
ulong durationMilliseconds,
ulong allocateTextureCallback = 0,
ulong allocateCallback = 0,
ulong fileObject = 0,
ulong fileOpenCallback = 0,
ulong fileCloseCallback = 0,
ulong fileReadOffsetCallback = 0,
ulong fileSizeCallback = 0,
bool hasAudio = false,
double framesPerSecond = 30.0,
bool isGen5 = true)
{
PlayerState? previous;
lock (StateGate)
{
Players.Remove(handle, out previous);
Players[handle] = new PlayerState
{
Handle = handle,
IsGen5 = isGen5,
Width = width,
Height = height,
DurationMilliseconds = durationMilliseconds,
HasAudio = hasAudio,
FramesPerSecond = framesPerSecond,
AspectRatio = height > 0 ? (float)width / height : 0,
AllocateTextureCallback = allocateTextureCallback,
AllocateCallback = allocateCallback,
FileObject = fileObject,
FileOpenCallback = fileOpenCallback,
FileCloseCallback = fileCloseCallback,
FileReadOffsetCallback = fileReadOffsetCallback,
FileSizeCallback = fileSizeCallback,
};
}
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;
lock (StateGate)
{
Players.Remove(handle, out player);
}
player?.Dispose();
}
[SysAbiExport(
Nid = "d8FcbzfAdQw",
ExportName = "sceAvPlayerGetStreamInfo",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAvPlayer")]
public static int AvPlayerGetStreamInfo(CpuContext ctx) =>
GetStreamInfoCore(ctx);
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 > (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..],
GetStreamType(ctx.TargetGeneration, streamIndex));
if (streamIndex == 0)
{
BinaryPrimitives.WriteUInt32LittleEndian(info[8..], checked((uint)player.Width));
BinaryPrimitives.WriteUInt32LittleEndian(info[12..], checked((uint)player.Height));
BinaryPrimitives.WriteSingleLittleEndian(info[16..], player.AspectRatio);
}
else
{
BinaryPrimitives.WriteUInt16LittleEndian(info[8..], 2);
BinaryPrimitives.WriteUInt32LittleEndian(info[12..], 48_000);
}
BinaryPrimitives.WriteUInt64LittleEndian(info[24..], player.DurationMilliseconds);
if (!ctx.Memory.TryWrite(infoAddress, info))
{
return SetReturn(ctx, InvalidParameters);
}
TraceOnce(
$"stream_info_{streamIndex}_{infoSize}",
$"stream_info index={streamIndex} size={infoSize} " +
$"type={(streamIndex == 0 ? "video" : "audio")} duration_ms={player.DurationMilliseconds}");
return SetReturn(ctx, 0);
}
}
internal static bool MaterializeReplacementSourceForTest(
CpuContext ctx,
ulong handle,
string guestPath,
out string path)
{
path = string.Empty;
PlayerState? player;
lock (StateGate)
{
Players.TryGetValue(handle, out player);
}
if (player is null ||
!TryMaterializeReplacementSource(ctx, player, guestPath, out path))
{
return false;
}
lock (StateGate)
{
if (!Players.TryGetValue(handle, out var currentPlayer) ||
!ReferenceEquals(currentPlayer, player))
{
TryDeleteMaterializedSource(path);
path = string.Empty;
return false;
}
player.SourcePath = path;
player.OwnedSourcePath = path;
return true;
}
}
private static int AddSource(CpuContext ctx, string guestPath)
{
PlayerState player;
bool autoStart;
lock (StateGate)
{
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var foundPlayer))
{
return SetReturn(ctx, InvalidParameters);
}
player = foundPlayer;
}
var ownsHostPath = false;
string? hostPath;
if (player.FileOpenCallback != 0)
{
if (TryMaterializeReplacementSource(
ctx,
player,
guestPath,
out var materializedPath))
{
hostPath = materializedPath;
ownsHostPath = true;
}
else
{
// A replacement interface is optional and may reject paths it
// does not own. Preserve the ordinary sandboxed file path when
// the same source is directly available and decodable.
hostPath = ResolveGuestPath(guestPath);
}
}
else
{
hostPath = ResolveGuestPath(guestPath);
}
if (hostPath is null ||
!ProbeVideo(
hostPath,
out var width,
out var height,
out var fps,
out var duration,
out var hasAudio,
out var aspectRatio,
out var videoFullRange,
out var colorPrimaries,
out var transferCharacteristics))
{
if (ownsHostPath && hostPath is not null)
{
TryDeleteMaterializedSource(hostPath);
}
Console.Error.WriteLine($"[AVPLAYER][ERROR] Could not open guest video '{guestPath}' (resolved '{hostPath ?? "<none>"}').");
return SetReturn(ctx, OperationFailed);
}
string? previousOwnedSource;
lock (StateGate)
{
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var currentPlayer) ||
!ReferenceEquals(currentPlayer, player))
{
if (ownsHostPath)
{
TryDeleteMaterializedSource(hostPath);
}
return SetReturn(ctx, InvalidParameters);
}
player.ResetPlayback();
previousOwnedSource = player.OwnedSourcePath;
player.SourcePath = hostPath;
player.OwnedSourcePath = ownsHostPath ? hostPath : null;
player.Width = width;
player.Height = height;
player.FramesPerSecond = fps;
player.AspectRatio = aspectRatio;
player.VideoFullRange = videoFullRange;
player.ColorPrimaries = colorPrimaries;
player.TransferCharacteristics = transferCharacteristics;
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} aspect={aspectRatio:F3} full_range={videoFullRange} " +
$"primaries={colorPrimaries} transfer={transferCharacteristics} " +
$"duration_ms={duration} audio={hasAudio} auto_start={player.AutoStart}");
}
if (previousOwnedSource is not null && previousOwnedSource != hostPath)
{
TryDeleteMaterializedSource(previousOwnedSource);
}
NotifyEvent(ctx, player, 2); // StateReady
if (autoStart)
{
NotifyEvent(ctx, player, 3); // StatePlay
}
return SetReturn(ctx, 0);
}
private static bool TryMaterializeReplacementSource(
CpuContext ctx,
PlayerState player,
string guestPath,
out string path)
{
lock (player.FileReplacementGate)
{
return TryMaterializeReplacementSourceCore(
ctx,
player,
guestPath,
out path);
}
}
private static bool TryMaterializeReplacementSourceCore(
CpuContext ctx,
PlayerState player,
string guestPath,
out string path)
{
path = string.Empty;
if (player.FileOpenCallback == 0 ||
player.FileCloseCallback == 0 ||
player.FileReadOffsetCallback == 0 ||
player.FileSizeCallback == 0 ||
GuestThreadExecution.Scheduler is not { } scheduler)
{
Console.Error.WriteLine(
"[AVPLAYER][WARN] Replacement file interface is incomplete or the guest scheduler is unavailable.");
return false;
}
if (player.ReplacementFileReadBuffer == 0)
{
if (!KernelMemoryCompatExports.TryAllocateHleData(
ctx,
ReplacementFileReadBufferSize,
0x1000,
out var readBuffer))
{
return false;
}
player.ReplacementFileReadBuffer = readBuffer;
}
var encodedPath = Encoding.UTF8.GetBytes(guestPath + '\0');
if (encodedPath.Length > ReplacementFileReadBufferSize ||
!ctx.Memory.TryWrite(player.ReplacementFileReadBuffer, encodedPath))
{
return false;
}
if (!scheduler.TryCallGuestFunction(
ctx,
player.FileOpenCallback,
player.FileObject,
player.ReplacementFileReadBuffer,
0,
0,
0,
"avplayer_file_open",
out var openResult,
out var openError) ||
unchecked((int)openResult) < 0)
{
Console.Error.WriteLine(
$"[AVPLAYER][WARN] Replacement file open failed callback=0x{player.FileOpenCallback:X16}: " +
$"{openError ?? $"result={unchecked((int)openResult)}"}");
return false;
}
Trace(
$"replacement_file open object=0x{player.FileObject:X16} " +
$"callback=0x{player.FileOpenCallback:X16} result={unchecked((int)openResult)}");
var materializedPath = Path.Combine(
Path.GetTempPath(),
"SharpEmu",
"AvPlayer",
Path.GetRandomFileName());
var hostBuffer = ArrayPool<byte>.Shared.Rent(ReplacementFileReadBufferSize);
try
{
if (!scheduler.TryCallGuestFunction(
ctx,
player.FileSizeCallback,
player.FileObject,
0,
0,
0,
0,
"avplayer_file_size",
out var size,
out var sizeError) ||
size == 0 || size > long.MaxValue)
{
Console.Error.WriteLine(
$"[AVPLAYER][WARN] Replacement file size failed " +
$"object=0x{player.FileObject:X16} callback=0x{player.FileSizeCallback:X16}: " +
$"{sizeError ?? $"size={size}"}");
return false;
}
Directory.CreateDirectory(Path.GetDirectoryName(materializedPath)!);
using var output = new FileStream(
materializedPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.Read,
ReplacementFileReadBufferSize,
FileOptions.SequentialScan);
for (ulong position = 0; position < size;)
{
var requested = checked((uint)Math.Min(
(ulong)ReplacementFileReadBufferSize,
size - position));
if (!scheduler.TryCallGuestFunction(
ctx,
player.FileReadOffsetCallback,
player.FileObject,
player.ReplacementFileReadBuffer,
position,
requested,
0,
0,
"avplayer_file_read_offset",
out var rawRead,
out var readError))
{
Console.Error.WriteLine(
$"[AVPLAYER][WARN] Replacement file read failed callback=0x{player.FileReadOffsetCallback:X16}: " +
$"{readError ?? "guest callback failed"}");
return false;
}
var read = unchecked((int)rawRead);
if (read <= 0 || (uint)read > requested ||
!ctx.Memory.TryRead(
player.ReplacementFileReadBuffer,
hostBuffer.AsSpan(0, read)))
{
Console.Error.WriteLine(
$"[AVPLAYER][WARN] Replacement file read returned invalid length {read} at offset {position}.");
return false;
}
output.Write(hostBuffer, 0, read);
position += checked((uint)read);
}
output.Flush();
path = materializedPath;
materializedPath = string.Empty;
Trace($"replacement_source materialized bytes={size}");
return true;
}
finally
{
ArrayPool<byte>.Shared.Return(hostBuffer);
if (!scheduler.TryCallGuestFunction(
ctx,
player.FileCloseCallback,
player.FileObject,
0,
0,
0,
0,
"avplayer_file_close",
out var closeResult,
out var closeError) ||
unchecked((int)closeResult) < 0)
{
Console.Error.WriteLine(
$"[AVPLAYER][WARN] Replacement file close failed callback=0x{player.FileCloseCallback:X16}: " +
$"{closeError ?? $"result={unchecked((int)closeResult)}"}");
}
if (materializedPath.Length != 0)
{
TryDeleteMaterializedSource(materializedPath);
}
}
}
private static void TryDeleteMaterializedSource(string path)
{
try
{
File.Delete(path);
}
catch (IOException)
{
}
catch (UnauthorizedAccessException)
{
}
}
private static int GetVideoData(CpuContext ctx, bool extended)
{
var infoAddress = ctx[CpuRegister.Rsi];
lock (StateGate)
{
if (!Players.TryGetValue(ctx[CpuRegister.Rdi], out var player) ||
infoAddress == 0 || !player.Started || player.EndOfStream ||
player.SourcePath is null)
{
return SetReturn(ctx, 0);
}
if (player.Paused)
{
if (player.SeekVideoFramePending &&
EnsureDecoder(player) &&
ReadFrame(player) == VideoFrameReadResult.Ready)
{
var seekFrameIndex = player.NextFrameIndex;
var seekTimestamp = player.StartTimeMilliseconds +
checked((ulong)Math.Round(
seekFrameIndex * 1000.0 /
Math.Max(1.0, player.FramesPerSecond)));
player.NextFrameIndex++;
if (WriteVideoFrame(
ctx,
player,
infoAddress,
seekTimestamp,
seekFrameIndex,
extended))
{
player.LastVideoTimestamp = seekTimestamp;
player.SeekVideoFramePending = false;
Trace(
$"seek_frame handle=0x{player.Handle:X16} ex={extended} " +
$"ts={seekTimestamp} data=0x{player.LastGuestBuffer:X16}");
return SetReturn(ctx, 1);
}
}
return SetReturn(ctx, 0);
}
if (!EnsureDecoder(player))
{
player.EndOfStream = true;
return SetReturn(ctx, 0);
}
var fps = Math.Max(1.0, player.FramesPerSecond);
if (player.AvSyncMode == AvSyncModeDefault)
{
var expectedFrame = CalculateExpectedDefaultSyncVideoFrame(
player.HasAudio,
player.NextAudioFrameIndex,
player.PlaybackClock.Elapsed.TotalSeconds,
fps);
if (player.NextFrameIndex > expectedFrame)
{
return SetReturn(ctx, 0);
}
var behind = expectedFrame - player.NextFrameIndex;
if (behind > MaxCatchUpFrames)
{
// Limit this call's work without changing the shared playback clock.
expectedFrame = player.NextFrameIndex + MaxCatchUpFrames;
TraceOnce(
"catch_up_capped",
$"catch_up capped behind={behind} max={MaxCatchUpFrames} " +
$"fps={fps:F3} {player.Width}x{player.Height}");
}
while (player.NextFrameIndex < expectedFrame)
{
var skippedFrame = ReadFrame(player);
if (skippedFrame == VideoFrameReadResult.Pending)
{
return SetReturn(ctx, 0);
}
if (skippedFrame == VideoFrameReadResult.End)
{
return FinishStream(ctx, player);
}
player.NextFrameIndex++;
}
}
var frameResult = ReadFrame(player);
if (frameResult == VideoFrameReadResult.Pending)
{
return SetReturn(ctx, 0);
}
if (frameResult == VideoFrameReadResult.End)
{
return FinishStream(ctx, player);
}
var frameIndex = player.NextFrameIndex;
var timestamp = player.StartTimeMilliseconds +
checked((ulong)Math.Round(frameIndex * 1000.0 / fps));
player.NextFrameIndex++;
if (!WriteVideoFrame(
ctx,
player,
infoAddress,
timestamp,
frameIndex,
extended))
{
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);
}
}
private static int FinishStream(CpuContext ctx, PlayerState player)
{
TraceOnce(
"video_end",
$"video_end handle=0x{player.Handle:X16} next_frame={player.NextFrameIndex} " +
$"audio_blocks={player.NextAudioFrameIndex} looping={player.Looping}");
if (player.Looping)
{
player.ResetPlayback();
player.Started = true;
}
else
{
player.EndOfStream = true;
player.PlaybackClock.Stop();
player.FallbackPlayback?.Dispose();
player.FallbackPlayback = null;
if (player.FallbackPresentationPixels is not null)
{
player.FallbackPlaybackCompleted = true;
player.FallbackCompletionPending = false;
player.FallbackPlaybackCompletedTicks = Stopwatch.GetTimestamp();
}
}
return SetReturn(ctx, 0);
}
private static bool EnsureDecoder(PlayerState player)
{
if (player.VideoDecoder is not null)
{
return true;
}
if (player.SourcePath is null)
{
return false;
}
if (!FfmpegMediaStream.TryOpenVideo(
player.SourcePath,
checked((int)player.Width),
checked((int)player.Height),
out var videoStream) ||
videoStream is null)
{
Console.Error.WriteLine(
$"[AVPLAYER][ERROR] Could not open a video stream in '{player.SourcePath}'.");
return false;
}
if (player.StartTimeMilliseconds != 0 &&
!videoStream.TrySeekMilliseconds(player.StartTimeMilliseconds))
{
videoStream.Dispose();
Console.Error.WriteLine(
$"[AVPLAYER][ERROR] Could not seek video stream to " +
$"{player.StartTimeMilliseconds} ms.");
return false;
}
player.VideoDecoder = new VideoFrameQueue(
videoStream,
checked(player.Width * player.Height * 3 / 2),
player.GuestBuffers.Length);
if (!player.Paused)
{
player.PlaybackClock.Start();
}
Trace($"decoder_started source='{player.SourcePath}' {player.Width}x{player.Height} nv12");
return true;
}
private static bool EnsureAudioDecoder(PlayerState player)
{
if (player.AudioDecoderOutput is not null)
{
return true;
}
if (player.SourcePath is null)
{
return false;
}
if (!FfmpegMediaStream.TryOpenAudio(player.SourcePath, out var audioStream) ||
audioStream is null)
{
return false;
}
if (player.StartTimeMilliseconds != 0 &&
!audioStream.TrySeekMilliseconds(player.StartTimeMilliseconds))
{
audioStream.Dispose();
return false;
}
player.AudioDecoderOutput = audioStream;
player.RawAudioFrame = new byte[1024 * FfmpegMediaStream.AudioChannels * sizeof(short)];
Trace($"audio_decoder_started source='{player.SourcePath}' s16 stereo 48000");
return true;
}
private static VideoFrameReadResult ReadFrame(PlayerState player)
{
if (player.VideoDecoder is null)
{
return VideoFrameReadResult.End;
}
var result = player.VideoDecoder.TryRead(out var frame);
if (result == VideoFrameReadResult.Ready)
{
player.RawFrame = frame;
}
return result;
}
private static bool ReadExactly(Stream? stream, byte[] buffer)
{
if (stream is null)
{
return false;
}
var offset = 0;
while (offset < buffer.Length)
{
var read = stream.Read(buffer, offset, buffer.Length - offset);
if (read == 0)
{
return false;
}
offset += read;
}
return true;
}
private static bool WriteVideoFrame(
CpuContext ctx,
PlayerState player,
ulong infoAddress,
ulong timestamp,
long frameIndex,
bool extended)
{
if (player.RawFrame is null)
{
return false;
}
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))
{
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)
{
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++)
{
player.RawFrame.AsSpan(row * player.Width, player.Width)
.CopyTo(player.PaddedFrame.AsSpan(row * alignedWidth, player.Width));
}
var rawChromaOffset = player.Width * player.Height;
var paddedChromaOffset = alignedWidth * alignedHeight;
for (var row = 0; row < player.Height / 2; row++)
{
player.RawFrame.AsSpan(rawChromaOffset + (row * player.Width), player.Width)
.CopyTo(player.PaddedFrame.AsSpan(paddedChromaOffset + (row * alignedWidth), player.Width));
}
frameData = player.PaddedFrame;
}
var bufferAddress = player.GuestBuffers[player.NextGuestBuffer];
player.NextGuestBuffer =
(player.NextGuestBuffer + 1) % player.GuestBuffers.Length;
player.LastGuestBuffer = bufferAddress;
if (!ctx.Memory.TryWrite(bufferAddress, frameData))
{
return false;
}
if (player.TextureAllocatorFailed)
{
EnsureFallbackPlayback(player);
if (ShouldCreateFallbackPoster(
player.FallbackPlaybackAttempted,
player.FallbackPlayback is not null,
player.FallbackPresentationPixels is not 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();
WriteVideoFrameInfo(
info,
ctx.TargetGeneration,
extended,
bufferAddress,
timestamp,
checked((uint)(extended && player.IsGen5 ? player.Width : AlignUp(player.Width, 16))),
checked((uint)player.Width),
checked((uint)(extended ? player.Height : bufferHeight)),
checked((uint)pitch),
player.FramesPerSecond,
player.AspectRatio,
player.VideoFullRange,
player.ColorPrimaries,
player.TransferCharacteristics);
if (!ctx.Memory.TryWrite(infoAddress, info))
{
return false;
}
if (player.FallbackPlayback is not null ||
player.FallbackPresentationPixels is not null)
{
player.FallbackRequestedFrameIndex = Math.Max(
player.FallbackRequestedFrameIndex,
frameIndex);
}
return true;
}
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)(extended && player.IsGen5 ? player.Width : AlignUp(player.Width, 16))),
checked((uint)player.Width),
checked((uint)(extended ? player.Height : bufferHeight)),
checked((uint)pitch),
player.FramesPerSecond,
player.AspectRatio,
player.VideoFullRange,
player.ColorPrimaries,
player.TransferCharacteristics);
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. The guest
/// AvPlayer delivery index controls which fallback frame can be visible, so
/// decode-ahead cannot bypass pause or client-controlled presentation.
/// </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,
enableAudio: false) ||
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} " +
"host_audio=false");
}
private static void CompleteGuestPlaybackAfterFallback(PlayerState player)
{
player.FallbackCompletionPending = false;
player.EndOfStream = true;
player.PlaybackClock.Stop();
}
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);
internal static void CopyNv12ToGuestBuffer(
ReadOnlySpan<byte> source,
Span<byte> destination,
int width,
int height,
int sourceLumaStride,
int sourceChromaStride,
int destinationPitch)
{
var sourceChromaOffset = checked(sourceLumaStride * height);
var destinationChromaOffset = checked(destinationPitch * height);
var destinationSize = CalculateNv12BufferSize(destinationPitch, height);
destination[..destinationSize].Clear();
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));
}
}
private static bool AllocateGuestVideoBuffers(CpuContext ctx, PlayerState player, int bufferSize)
{
var scheduler = GuestThreadExecution.Scheduler;
if (!player.TextureAllocatorFailed && scheduler is not null)
{
foreach (var (callback, kind) in new[]
{
(player.AllocateTextureCallback, "texture"),
(player.AllocateCallback, "generic"),
})
{
if (callback == 0)
{
continue;
}
var allocated = true;
for (var index = 0; index < player.GuestBuffers.Length; index++)
{
if (!scheduler.TryCallGuestFunction(
ctx,
callback,
player.AllocatorObject,
TextureAllocationAlignment,
checked((ulong)bufferSize),
0,
0,
"avplayer_allocate_" + kind,
out var buffer,
out var error) || buffer == 0)
{
Console.Error.WriteLine(
$"[AVPLAYER][WARN] Guest {kind} allocation failed index={index} " +
$"callback=0x{callback:X16} size={bufferSize} " +
$"align=0x{TextureAllocationAlignment:X}: {error ?? "returned null"}");
allocated = false;
Array.Clear(player.GuestBuffers);
break;
}
player.GuestBuffers[index] = buffer;
RegisterVideoBuffer(buffer, bufferSize, index, "guest-callback");
Trace($"{kind}_buffer index={index} data=0x{buffer:X16} size={bufferSize}");
}
if (allocated)
{
return true;
}
}
player.TextureAllocatorFailed = true;
}
if (!KernelMemoryCompatExports.TryAllocateHleData(
ctx,
checked((ulong)bufferSize * (ulong)player.GuestBuffers.Length),
0x1000,
out var bufferBase))
{
return false;
}
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;
}
private static bool ProbeVideo(
string path,
out int width,
out int height,
out double framesPerSecond,
out ulong durationMilliseconds,
out bool hasAudio,
out float aspectRatio,
out bool videoFullRange,
out uint colorPrimaries,
out uint transferCharacteristics)
{
width = 0;
height = 0;
framesPerSecond = 30.0;
durationMilliseconds = 0;
hasAudio = false;
aspectRatio = 0;
videoFullRange = false;
colorPrimaries = 0;
transferCharacteristics = 0;
if (!FfmpegMediaStream.TryProbe(
path,
out width,
out height,
out var rate,
out var duration,
out aspectRatio,
out videoFullRange,
out colorPrimaries,
out transferCharacteristics))
{
return false;
}
if (rate > 0)
{
framesPerSecond = rate;
}
if (duration > 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;
}
internal static string? ResolveGuestPath(string guestPath)
{
if (string.IsNullOrWhiteSpace(guestPath))
{
return null;
}
var normalized = guestPath.Replace('\\', '/');
var fileReference = normalized.StartsWith("file:", StringComparison.OrdinalIgnoreCase);
var unrealProjectRelative =
normalized.StartsWith("../", StringComparison.Ordinal) ||
normalized.StartsWith("./", StringComparison.Ordinal);
if (normalized.StartsWith("file://", StringComparison.OrdinalIgnoreCase) &&
Uri.TryCreate(normalized, UriKind.Absolute, out var uri) &&
uri.IsFile)
{
if (!string.IsNullOrEmpty(uri.Host) &&
!string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase))
{
return null;
}
normalized = uri.LocalPath.Replace('\\', '/');
}
else if (normalized.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
{
// Some console middleware emits Unreal-style project-relative
// media references such as file://../../../Project/Content/....
// System.Uri rejects these because the first ".." is parsed as
// an invalid authority. Treat the scheme as a guest-path marker;
// the app0 sandbox below resolves the relative path.
normalized = normalized["file://".Length..];
unrealProjectRelative = true;
}
else if (normalized.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized["file:".Length..];
unrealProjectRelative = true;
}
if (unrealProjectRelative)
{
if (!TryRemoveUnrealLeadingDotSegments(normalized, out normalized))
{
return null;
}
}
var app0 = Environment.GetEnvironmentVariable("SHARPEMU_APP0_DIR");
if (string.IsNullOrWhiteSpace(app0))
{
return null;
}
var app0MountedPath = false;
foreach (var prefix in new[] { "app0:/", "/app0/", "app0/", "app0:" })
{
if (normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[prefix.Length..];
app0MountedPath = true;
break;
}
}
if (!app0MountedPath &&
(string.Equals(normalized, "app0:", StringComparison.OrdinalIgnoreCase) ||
string.Equals(normalized, "/app0", StringComparison.OrdinalIgnoreCase) ||
string.Equals(normalized, "app0", StringComparison.OrdinalIgnoreCase)))
{
normalized = string.Empty;
app0MountedPath = true;
}
try
{
if (fileReference)
{
if (!TryDecodeFileReference(normalized, out normalized))
{
return null;
}
}
else if (ContainsInvalidMediaPathCharacters(normalized))
{
return null;
}
if ((!fileReference &&
!app0MountedPath &&
Uri.TryCreate(normalized, UriKind.Absolute, out _)) ||
Path.IsPathFullyQualified(normalized) ||
normalized.StartsWith("/", StringComparison.Ordinal))
{
return null;
}
if (!TryNormalizeApp0RelativePath(normalized, out var relativePath) ||
relativePath.Length == 0)
{
return null;
}
var root = Path.GetFullPath(app0);
var candidate = Path.GetFullPath(Path.Combine(root, relativePath));
var relativeToRoot = Path.GetRelativePath(root, candidate);
if (Path.IsPathFullyQualified(relativeToRoot) ||
string.Equals(relativeToRoot, "..", StringComparison.Ordinal) ||
relativeToRoot.StartsWith(
".." + Path.DirectorySeparatorChar,
StringComparison.Ordinal))
{
return null;
}
return TryResolveSandboxedFile(root, relativePath, out var resolved)
? resolved
: null;
}
catch (Exception exception) when (exception is ArgumentException or
IOException or
NotSupportedException or
UnauthorizedAccessException or
UriFormatException)
{
return null;
}
}
private static bool TryRemoveUnrealLeadingDotSegments(
string guestPath,
out string normalized)
{
var removedParent = false;
while (guestPath.StartsWith("../", StringComparison.Ordinal) ||
guestPath.StartsWith("./", StringComparison.Ordinal))
{
removedParent |= guestPath.StartsWith("../", StringComparison.Ordinal);
guestPath = guestPath[(guestPath.IndexOf('/') + 1)..];
}
normalized = guestPath;
return !removedParent || guestPath.Contains('/');
}
private static bool TryDecodeFileReference(string encoded, out string decoded)
{
decoded = string.Empty;
for (var index = 0; index < encoded.Length; index++)
{
if (encoded[index] != '%')
{
continue;
}
if (index + 2 >= encoded.Length ||
!Uri.IsHexDigit(encoded[index + 1]) ||
!Uri.IsHexDigit(encoded[index + 2]))
{
return false;
}
var escapedByte = Convert.ToByte(encoded.Substring(index + 1, 2), 16);
if (escapedByte is (byte)'/' or (byte)'\\')
{
return false;
}
index += 2;
}
decoded = Uri.UnescapeDataString(encoded);
return !ContainsInvalidMediaPathCharacters(decoded);
}
private static bool ContainsInvalidMediaPathCharacters(string path) =>
path.IndexOfAny(['?', '#']) >= 0 || path.Any(char.IsControl);
private static bool TryNormalizeApp0RelativePath(
string guestPath,
out string relativePath)
{
var segments = new List<string>();
foreach (var segment in guestPath.TrimStart('/').Split(
'/',
StringSplitOptions.RemoveEmptyEntries))
{
if (segment == ".")
{
continue;
}
if (segment == "..")
{
if (segments.Count == 0)
{
relativePath = string.Empty;
return false;
}
segments.RemoveAt(segments.Count - 1);
continue;
}
segments.Add(segment);
}
relativePath = string.Join(Path.DirectorySeparatorChar, segments);
return true;
}
private static bool TryResolveSandboxedFile(
string root,
string relativePath,
out string resolved)
{
resolved = string.Empty;
var current = root;
var segments = relativePath.Split(
Path.DirectorySeparatorChar,
StringSplitOptions.RemoveEmptyEntries);
for (var index = 0; index < segments.Length; index++)
{
var exact = Path.Combine(current, segments[index]);
var finalSegment = index == segments.Length - 1;
string? match;
if (finalSegment ? File.Exists(exact) : Directory.Exists(exact))
{
match = exact;
}
else
{
if (!Directory.Exists(current))
{
return false;
}
match = null;
foreach (var entry in Directory.EnumerateFileSystemEntries(current))
{
if (!string.Equals(
Path.GetFileName(entry),
segments[index],
StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (match is not null)
{
// A case-sensitive host can contain two names that are
// indistinguishable to the guest. Refuse an ambiguous
// media path instead of selecting one nondeterministically.
return false;
}
match = entry;
}
}
if (match is null ||
(finalSegment ? !File.Exists(match) : !Directory.Exists(match)))
{
return false;
}
if ((File.GetAttributes(match) & FileAttributes.ReparsePoint) != 0)
{
// App packages do not need host filesystem links. Refusing
// them keeps media resolution inside the configured app0
// tree even when a dump contains a symlink or junction.
return false;
}
current = match;
}
if (!File.Exists(current))
{
return false;
}
resolved = Path.GetFullPath(current);
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 ? 116UL : 108UL
: extended ? 164UL : 108UL;
internal static ulong GetOutputVideoFrameBufferCountOffset(
Generation generation,
bool extended) =>
IsGen5Target(generation)
? extended ? 552UL : 104UL
: extended ? 600UL : 104UL;
internal static int NormalizeOutputVideoFrameBufferCount(int requested) =>
requested is >= MinimumFrameBufferCount and <= MaximumFrameBufferCount
? requested
: DefaultFrameBufferCount;
internal static bool IsValidAvSyncMode(uint mode) =>
mode is AvSyncModeDefault or AvSyncModeNone;
internal static long CalculateExpectedDefaultSyncVideoFrame(
bool hasAudio,
long deliveredAudioFrameCount,
double internalClockSeconds,
double framesPerSecond)
{
var clockSeconds = hasAudio
? deliveredAudioFrameCount * AudioSamplesPerFrame / (double)AudioSampleRate
: Math.Max(0, internalClockSeconds);
return Math.Max(0, (long)Math.Floor(clockSeconds * Math.Max(1, framesPerSecond)));
}
private static int ReadOutputVideoFrameBufferCount(
CpuContext ctx,
ulong initDataAddress,
bool extended)
{
var offset = GetOutputVideoFrameBufferCountOffset(
ctx.TargetGeneration,
extended);
return TryReadUInt32(ctx, initDataAddress + offset, out var requested)
? NormalizeOutputVideoFrameBufferCount(unchecked((int)requested))
: DefaultFrameBufferCount;
}
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,
float aspectRatio = 0,
bool videoFullRange = false,
uint colorPrimaries = 0,
uint transferCharacteristics = 0)
{
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.WriteSingleLittleEndian(info[24..], aspectRatio);
info[58] = videoFullRange ? (byte)1 : (byte)0;
BinaryPrimitives.WriteDoubleLittleEndian(info[0x40..], framesPerSecond);
BinaryPrimitives.WriteUInt32LittleEndian(info[0x48..], colorPrimaries);
BinaryPrimitives.WriteUInt32LittleEndian(info[0x4C..], transferCharacteristics);
BinaryPrimitives.WriteUInt64LittleEndian(info[0x60..], durationMilliseconds);
}
internal static void WriteGen5AudioStreamInfoEx(
Span<byte> info,
uint streamType,
ushort channelCount,
uint sampleRate,
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.WriteUInt16LittleEndian(info[16..], channelCount);
BinaryPrimitives.WriteUInt32LittleEndian(info[20..], sampleRate);
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,
float aspectRatio = 0,
bool videoFullRange = false,
uint colorPrimaries = 0,
uint transferCharacteristics = 0)
{
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..], aspectRatio);
if (!extended)
{
return;
}
BinaryPrimitives.WriteUInt32LittleEndian(
info[48..],
pitch > visibleWidth ? pitch - visibleWidth : 0);
BinaryPrimitives.WriteUInt32LittleEndian(info[60..], pitch);
info[64] = 8;
info[65] = 8;
info[66] = videoFullRange ? (byte)1 : (byte)0;
if (IsGen5Target(generation))
{
BinaryPrimitives.WriteDoubleLittleEndian(info[0x48..], framesPerSecond);
BinaryPrimitives.WriteUInt32LittleEndian(info[0x50..], colorPrimaries);
BinaryPrimitives.WriteUInt32LittleEndian(info[0x54..], transferCharacteristics);
}
}
private static bool TryReadNullTerminatedUtf8(CpuContext ctx, ulong address, int maxLength, out string value)
{
value = string.Empty;
if (address == 0 || maxLength <= 0)
{
return false;
}
var bytes = new List<byte>(Math.Min(maxLength, 256));
Span<byte> single = stackalloc byte[1];
for (var index = 0; index < maxLength; index++)
{
if (!ctx.Memory.TryRead(address + (ulong)index, single))
{
return false;
}
if (single[0] == 0)
{
value = Encoding.UTF8.GetString(bytes.ToArray());
return true;
}
bytes.Add(single[0]);
}
return false;
}
private static bool TryReadUtf8(CpuContext ctx, ulong address, int length, out string value)
{
value = string.Empty;
if (address == 0 || length <= 0)
{
return false;
}
var bytes = new byte[length];
if (!ctx.Memory.TryRead(address, bytes))
{
return false;
}
value = Encoding.UTF8.GetString(bytes);
return true;
}
private static bool TryReadByte(CpuContext ctx, ulong address, out byte value)
{
Span<byte> buffer = stackalloc byte[1];
if (!ctx.Memory.TryRead(address, buffer))
{
value = 0;
return false;
}
value = buffer[0];
return true;
}
private static bool TryReadUInt32(CpuContext ctx, ulong address, out uint value)
{
Span<byte> buffer = stackalloc byte[sizeof(uint)];
if (!ctx.Memory.TryRead(address, buffer))
{
value = 0;
return false;
}
value = BinaryPrimitives.ReadUInt32LittleEndian(buffer);
return true;
}
private static bool TryReadUInt64(CpuContext ctx, ulong address, out ulong value) =>
ctx.TryReadUInt64(address, out value);
private static void NotifyWarning(CpuContext ctx, PlayerState player, int warning)
{
var eventDataBuffer = player.EventDataBuffer;
if (eventDataBuffer == 0)
{
if (!KernelMemoryCompatExports.TryAllocateHleData(
ctx,
0x10,
0x10,
out eventDataBuffer))
{
Console.Error.WriteLine(
$"[AVPLAYER][WARN] Could not allocate event data for warning 0x{warning:X8}.");
return;
}
player.EventDataBuffer = eventDataBuffer;
}
Span<byte> data = stackalloc byte[sizeof(int)];
BinaryPrimitives.WriteInt32LittleEndian(data, warning);
if (!ctx.Memory.TryWrite(eventDataBuffer, data))
{
return;
}
NotifyEvent(ctx, player, 0x20, eventDataBuffer);
}
private static void NotifyEvent(CpuContext ctx, PlayerState player, ulong eventId) =>
NotifyEvent(ctx, player, eventId, 0);
private static void NotifyEvent(
CpuContext ctx,
PlayerState player,
ulong eventId,
ulong eventData)
{
if (player.EventCallback == 0)
{
Trace($"event skipped handle=0x{player.Handle:X16} id={eventId} callback=0");
return;
}
var scheduler = GuestThreadExecution.Scheduler;
string? error = null;
if (scheduler is null ||
!scheduler.TryCallGuestFunction(
callerContext: ctx,
entryPoint: player.EventCallback,
arg0: player.EventObject,
arg1: eventId,
arg2: 0,
arg3: eventData,
stackAddress: 0,
stackSize: 0,
reason: $"avplayer_event_{eventId}",
returnValue: out _,
error: out error))
{
Console.Error.WriteLine(
$"[AVPLAYER][WARN] Event callback failed handle=0x{player.Handle:X16} " +
$"event={eventId} callback=0x{player.EventCallback:X16}: {error ?? "scheduler unavailable"}");
return;
}
Trace($"event handle=0x{player.Handle:X16} id={eventId} callback=0x{player.EventCallback:X16}");
}
private static int AlignUp(int value, int alignment) =>
checked((value + alignment - 1) & -alignment);
private static int ValidatePlayer(CpuContext ctx)
{
lock (StateGate)
{
return SetReturn(ctx, Players.ContainsKey(ctx[CpuRegister.Rdi]) ? 0 : InvalidParameters);
}
}
private static int SetReturn(CpuContext ctx, int result)
{
ctx[CpuRegister.Rax] = unchecked((ulong)result);
return result;
}
private static void Trace(string message)
{
var count = Interlocked.Increment(ref _traceCount);
if (count <= 32 || count % 300 == 0)
{
Console.Error.WriteLine($"[AVPLAYER][INFO] {message}");
}
}
private static void TraceOnce(string key, string message)
{
lock (TracedOnce)
{
if (!TracedOnce.Add(key))
{
return;
}
}
Console.Error.WriteLine($"[AVPLAYER][INFO] {message}");
}
}