// Copyright (C) 2026 SharpEmu Emulator Project // SPDX-License-Identifier: GPL-2.0-or-later using System.Buffers.Binary; using System.Collections; using System.Reflection; 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 AvPlayerPlaybackTests { [Theory] [InlineData(false, false)] [InlineData(false, true)] [InlineData(true, false)] [InlineData(true, true)] public void CatchUpReachesVideoEndAfterAudioStops(bool looping, bool extended) { using var playback = new DecodedPlayback(178, 10, 288, 6, looping); Assert.Equal(1, playback.ReadVideo(extended)); Assert.Equal(181L, playback.GetState("NextFrameIndex")); Assert.Equal(6144, playback.CurrentTime()); for (var audioBlock = 0; audioBlock < 5; audioBlock++) { Assert.Equal(1, playback.ReadAudio()); } Assert.Equal(1, playback.ReadVideo(extended)); Assert.Equal(1, playback.ReadAudio()); Assert.Equal(1, playback.ReadVideo(extended)); Assert.Equal(0, playback.ReadAudio()); Assert.Equal(294L, playback.GetState("NextAudioFrameIndex")); for (var attempt = 0; attempt < 10 && !playback.ReachedVideoEnd; attempt++) { playback.ReadVideo(extended); } Assert.True(playback.ReachedVideoEnd, "The audio clock must not strand eligible video frames."); Assert.Equal(looping ? 1 : 0, playback.IsActive()); if (looping) { Assert.Equal(0L, playback.GetState("NextFrameIndex")); Assert.Equal(0L, playback.GetState("NextAudioFrameIndex")); Assert.Null(playback.GetState("VideoDecoder")); Assert.Null(playback.GetState("AudioDecoderOutput")); } } [Fact] public void VideoWaitsForAudioUnlessSynchronizationIsDisabled() { using var playback = new DecodedPlayback(0, 4, 0, 2); Assert.Equal(1, playback.ReadVideo()); Assert.Equal(0, playback.ReadVideo()); Assert.Equal(1, playback.ReadAudio()); Assert.Equal(0, playback.ReadVideo()); Assert.Equal(1, playback.ReadAudio()); Assert.Equal(1, playback.ReadVideo()); Assert.Equal(0, playback.ReadVideo()); playback.SetSynchronizationMode(1); Assert.Equal(1, playback.ReadVideo()); Assert.Equal(3L, playback.GetState("NextFrameIndex")); } [Fact] public void PauseKeepsPendingFramesUntilResume() { using var playback = new DecodedPlayback(0, 3, 2, 1); playback.Pause(); Assert.Equal(0, playback.ReadVideo()); Assert.Equal(0, playback.ReadAudio()); Assert.Equal(0L, playback.GetState("NextFrameIndex")); Assert.Equal(2L, playback.GetState("NextAudioFrameIndex")); Assert.Equal(1, playback.IsActive()); playback.Resume(); Assert.Equal(1, playback.ReadVideo()); Assert.Equal(1, playback.ReadAudio()); } [Fact] public void AudioEndDoesNotReleaseVideoAheadOfTheAudioClock() { using var playback = new DecodedPlayback(0, 3, 2, 0); Assert.Equal(1, playback.ReadVideo()); Assert.Equal(0, playback.ReadAudio()); Assert.Equal(0, playback.ReadVideo()); Assert.Equal(2L, playback.GetState("NextFrameIndex")); Assert.Equal(1, playback.IsActive()); } private sealed class DecodedPlayback : IDisposable { private const ulong Handle = 0xA0_0000_2300; private const ulong MemoryAddress = 0x1_0000_0000; private const ulong InfoAddress = MemoryAddress + 0x100; private const int FrameByteCount = 16 * 16 * 3 / 2; private const BindingFlags PrivateStatic = BindingFlags.NonPublic | BindingFlags.Static; private const BindingFlags PrivateInstance = BindingFlags.NonPublic | BindingFlags.Instance; private readonly FakeCpuMemory _memory = new(MemoryAddress, 0x10000); private readonly CpuContext _context; private readonly object _player; private readonly Thread _decoderWorker; public DecodedPlayback( int firstVideoFrame, int videoFrameCount, int deliveredAudioBlocks, int remainingAudioBlocks, bool looping = false) { _context = new CpuContext(_memory, Generation.Gen5); AvPlayerExports.RegisterPlayerForTest(Handle, 16, 16, 6267, hasAudio: true); var players = (IDictionary)typeof(AvPlayerExports) .GetField("Players", PrivateStatic)!.GetValue(null)!; var stateGate = typeof(AvPlayerExports).GetField("StateGate", PrivateStatic)!.GetValue(null)!; lock (stateGate) { _player = players[Handle]!; } SetState("SourcePath", "decoded-test-stream"); SetState("Started", true); SetState("Looping", looping); SetState("NextFrameIndex", (long)firstVideoFrame); SetState("NextAudioFrameIndex", (long)deliveredAudioBlocks); SetState("AudioBufferBase", MemoryAddress + 0x6000); SetState("RawAudioFrame", new byte[4096]); SetState("AudioDecoderOutput", new MemoryStream(new byte[remainingAudioBlocks * 4096])); var buffers = GetState("GuestBuffers"); for (var bufferIndex = 0; bufferIndex < buffers.Length; bufferIndex++) { buffers[bufferIndex] = MemoryAddress + 0x1000 + (ulong)bufferIndex * 0x2000; } var videoBytes = new byte[videoFrameCount * FrameByteCount]; for (var frameIndex = 0; frameIndex < videoFrameCount; frameIndex++) { videoBytes.AsSpan(frameIndex * FrameByteCount, FrameByteCount) .Fill(checked((byte)(firstVideoFrame + frameIndex))); } var queueType = typeof(AvPlayerExports).GetNestedType("VideoFrameQueue", BindingFlags.NonPublic)!; var decoder = Activator.CreateInstance(queueType, new MemoryStream(videoBytes), FrameByteCount, 16)!; SetState("VideoDecoder", decoder); _decoderWorker = (Thread)queueType.GetField("_worker", PrivateInstance)!.GetValue(decoder)!; Assert.True(_decoderWorker.Join(TimeSpan.FromSeconds(5)), "The test decoder did not finish."); } public bool ReachedVideoEnd => GetState("EndOfStream") || GetState("VideoDecoder") is null; public int ReadVideo(bool extended = true) { SetArguments(); var result = extended ? AvPlayerExports.AvPlayerGetVideoDataEx(_context) : AvPlayerExports.AvPlayerGetVideoData(_context); if (result == 1) { Span frameInfo = stackalloc byte[40]; Assert.True(_memory.TryRead(InfoAddress, frameInfo)); var address = BinaryPrimitives.ReadUInt64LittleEndian(frameInfo); var timestamp = BinaryPrimitives.ReadUInt64LittleEndian(frameInfo[16..]); Span firstPixel = stackalloc byte[1]; Assert.True(_memory.TryRead(address, firstPixel)); Assert.Equal((byte)(GetState("NextFrameIndex") - 1), firstPixel[0]); Assert.Equal((ulong)Math.Round(firstPixel[0] * 1000.0 / 30), timestamp); } return result; } public int ReadAudio() { SetArguments(); return AvPlayerExports.AvPlayerGetAudioData(_context); } public int CurrentTime() { SetArguments(); return AvPlayerExports.AvPlayerCurrentTime(_context); } public int IsActive() { SetArguments(); return AvPlayerExports.AvPlayerIsActive(_context); } public void Pause() { SetArguments(); Assert.Equal(0, AvPlayerExports.AvPlayerPause(_context)); } public void Resume() { SetArguments(); Assert.Equal(0, AvPlayerExports.AvPlayerResume(_context)); } public void SetSynchronizationMode(uint mode) { SetArguments(); _context[CpuRegister.Rsi] = mode; Assert.Equal(0, AvPlayerExports.AvPlayerSetAvSyncMode(_context)); } public TValue GetState(string propertyName) => (TValue)_player.GetType().GetProperty(propertyName)!.GetValue(_player)!; private void SetState(string propertyName, object value) => _player.GetType().GetProperty(propertyName)!.SetValue(_player, value); private void SetArguments() { _context[CpuRegister.Rdi] = Handle; _context[CpuRegister.Rsi] = InfoAddress; } public void Dispose() { AvPlayerExports.RemovePlayerForTest(Handle); Assert.True(_decoderWorker.Join(TimeSpan.FromSeconds(5))); } } }