Emulator/Sio/LinkCableSession.cs
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;

namespace sGBA;

public sealed class LinkCableSession
{
	private const int MaxQueuedAvPackets = 4;
	private const int SyncWaitMilliseconds = 50;
	private const int WakeWaitMilliseconds = 2;
	private const int FrameCreditMax = 2;
	private const int YieldIntervalMilliseconds = 16;

	public LinkCableHost Host { get; } = new();

	private readonly ConcurrentDictionary<LinkCableInstance, ConcurrentQueue<LinkCableAvPacket>> _avQueues = new();
	private readonly ConcurrentDictionary<LinkCableInstance, CancellationTokenSource> _coreThreads = new();
	private readonly object _structLock = new();

	private SemaphoreSlim _frameCredit = new( 0, FrameCreditMax );

	private CancellationTokenSource _cts;
	private Action<string> _logError;

	public bool IsRunning => _cts != null;

	public void Start( Action<string> logError = null )
	{
		if ( _cts != null )
			return;

		_logError = logError;
		_cts = new CancellationTokenSource();
		_frameCredit = new SemaphoreSlim( 0, FrameCreditMax );

		foreach ( var inst in Host.Instances )
			StartCoreThread( inst );
	}

	public void Stop()
	{
		var cts = _cts;
		_cts = null;
		if ( cts == null )
			return;

		Signal( _frameCredit );

		foreach ( var kv in _coreThreads )
		{
			try { kv.Value.Cancel(); }
			catch ( ObjectDisposedException ) { }
			kv.Key.WakeSignal.Set();
		}

		foreach ( var inst in Host.Instances )
			inst.WakeSignal.Set();

		cts.Cancel();
		_coreThreads.Clear();
		cts.Dispose();
	}

	public LinkCableInstance Attach( Gba core, int requestedId )
	{
		LinkCableInstance inst;
		lock ( _structLock )
		{
			inst = Host.Attach( core, requestedId );
			inst.PaceMaster = requestedId == 0;
			_avQueues.TryAdd( inst, new ConcurrentQueue<LinkCableAvPacket>() );
		}

		if ( _cts != null )
			StartCoreThread( inst );

		return inst;
	}

	public void Detach( LinkCableInstance inst )
	{
		if ( inst == null )
			return;

		if ( _coreThreads.TryRemove( inst, out var threadCts ) )
		{
			try { threadCts.Cancel(); }
			catch ( ObjectDisposedException ) { }
			inst.WakeSignal.Set();
		}

		inst.WakeSignal.Set();

		lock ( inst.CoreLock )
		{
			lock ( _structLock )
			{
				_avQueues.TryRemove( inst, out _ );
				Host.Detach( inst );
			}
		}

		threadCts?.Dispose();
	}

	public void SetSlotInput( int slot, ushort keys )
	{
		var inst = Host.FindBySlot( slot );
		if ( inst != null )
			inst.Keys = keys;
	}

	public void LoadSlotSave( int slot, byte[] saveData )
	{
		if ( saveData == null || saveData.Length == 0 )
			return;

		var inst = Host.FindBySlot( slot );
		if ( inst == null )
			return;

		lock ( inst.CoreLock )
			inst.Core.Savedata.Load( saveData );
	}

	public bool TryDequeueAv( LinkCableInstance inst, out LinkCableAvPacket packet )
	{
		packet = null;
		if ( inst == null )
			return false;
		if ( !_avQueues.TryGetValue( inst, out var queue ) )
			return false;
		return queue.TryDequeue( out packet );
	}

	public void GrantFrameCredit()
	{
		Signal( _frameCredit );
	}

	private void StartCoreThread( LinkCableInstance inst )
	{
		var sessionCts = _cts;
		if ( sessionCts == null || inst == null )
			return;

		CancellationTokenSource linked;
		try
		{
			linked = CancellationTokenSource.CreateLinkedTokenSource( sessionCts.Token );
		}
		catch ( ObjectDisposedException )
		{
			return;
		}

		if ( !_coreThreads.TryAdd( inst, linked ) )
		{
			linked.Dispose();
			return;
		}

		var token = linked.Token;
		var task = GameTask.RunInThreadAsync( () => RunCoreThread( inst, token ) );
		_ = ObserveCoreAsync( task );
	}

	private async Task RunCoreThread( LinkCableInstance inst, CancellationToken token )
	{
		try
		{
			var core = inst.Core;
			var sinceYield = System.Diagnostics.Stopwatch.StartNew();

			while ( !token.IsCancellationRequested )
			{
				if ( sinceYield.ElapsedMilliseconds >= YieldIntervalMilliseconds )
				{
					await GameTask.Yield();
					sinceYield.Restart();
				}

				if ( !core.IsRunning )
				{
					inst.WakeSignal.Wait( SyncWaitMilliseconds );
					continue;
				}

				if ( core.Io.LockstepBlocked )
				{
					inst.WakeSignal.Wait( WakeWaitMilliseconds );
					continue;
				}

				if ( inst.PaceMaster && !core.FrameInProgress )
				{
					bool haveCredit;
					try
					{
						haveCredit = _frameCredit.Wait( SyncWaitMilliseconds, token );
					}
					catch ( OperationCanceledException )
					{
						break;
					}

					if ( !haveCredit )
						continue;
				}

				bool completedFrame;
				lock ( inst.CoreLock )
				{
					core.KeysActive = inst.Keys;
					completedFrame = core.StepFrame();
					if ( completedFrame )
					{
						inst.LastHarvestedFrame = core.FrameCounter;
						HarvestFrame( inst );
					}
				}

				if ( !completedFrame )
					await GameTask.Yield();
			}
		}
		catch ( OperationCanceledException )
		{
		}
		catch ( ObjectDisposedException )
		{
		}
		catch ( Exception ex )
		{
			LogError( $"core thread error: {ex.Message}\n{ex.StackTrace}" );
		}
	}

	private async Task ObserveCoreAsync( Task task )
	{
		try
		{
			await task;
		}
		catch ( OperationCanceledException ) { }
		catch ( ObjectDisposedException ) { }
		catch ( Exception ex )
		{
			LogError( $"core task error: {ex.Message}\n{ex.StackTrace}" );
		}
	}

	private void HarvestFrame( LinkCableInstance inst )
	{
		if ( inst.PlayerId < 0 )
			return;

		var core = inst.Core;

		int audioSamples = core.Audio.SamplesWritten;
		short[] audio = null;
		if ( audioSamples > 0 )
		{
			audio = new short[audioSamples * 2];
			Buffer.BlockCopy( core.Audio.OutputBuffer, 0, audio, 0, audioSamples * 2 * sizeof( short ) );
		}

		byte[] saveData = null;
		if ( core.Savedata.Clean() && core.Savedata.Data.Length > 0 )
			saveData = core.Savedata.Data.ToArray();

		var packet = new LinkCableAvPacket
		{
			PlayerId = inst.PlayerId,
			Frame = core.FrameCounter,
			Audio = audio,
			AudioSamples = audioSamples,
			SaveData = saveData,
		};

		if ( _avQueues.TryGetValue( inst, out var queue ) )
		{
			queue.Enqueue( packet );
			while ( queue.Count > MaxQueuedAvPackets && queue.TryDequeue( out _ ) )
			{
			}
		}
	}

	private void LogError( string message )
	{
		try { _logError?.Invoke( $"LinkCableSession {message}" ); }
		catch { }
	}

	private static void Signal( SemaphoreSlim semaphore )
	{
		try { semaphore.Release(); }
		catch ( SemaphoreFullException ) { }
	}
}