EmulatorComponent.LinkCable.cs
using Sandbox.Rendering;

namespace sGBA;

public sealed partial class EmulatorComponent
{
	private LinkCableSession _linkSession;
	private readonly List<LinkCableInstance> _linkInstances = new();
	private bool _linkedHost;
	private bool _linkedClient;
	private Texture _clientScreenTex;
	private long _clientLastVideoFrame = -1;
	private long _clientInputFrame;
	private bool _clientSaveUploaded;

	private byte[] _clientPendingVideo;
	private long _clientPendingFrame = -1;
	private byte[] _clientLatestState;
	private RealTimeSince _timeSinceClientPacket;
	private const float ClientHostTimeoutSeconds = 2f;
	private const float StateStreamIntervalSeconds = 0.5f;
	private const int StateRewarmFrames = 2;

	public bool IsLinkedHost => _linkedHost;
	public bool IsLinkedClient => _linkedClient;
	public bool IsLinked => _linkedHost || _linkedClient;

	public void BeginLinkedHost( IReadOnlyList<int> activeSlots )
	{
		if ( _linkedHost || _linkedClient )
			return;
		if ( string.IsNullOrEmpty( RomPath ) )
			return;

		byte[] romData = ReadRomBytes();
		if ( romData == null )
			return;

		GbaCoreThread soloThread = _coreThread;
		_coreThread = null;
		soloThread?.End();
		Gba soloCore = soloThread?.Core;

		_linkSession = new LinkCableSession();

		byte[] hostSave = ReadHostSave();

		if ( soloCore?.Savedata != null && soloCore.Savedata.Data.Length > 0 && _savePath != null )
			FileSystem.Data.WriteAllBytes( _savePath, soloCore.Savedata.Data );

		if ( _soundHandle is { IsValid: true } )
			_soundHandle.Volume = 0;
		_audioStream?.Dispose();
		_audioStream = null;

		Gba core0;
		if ( soloCore != null )
		{
			core0 = soloCore;
		}
		else
		{
			core0 = new();
			core0.LoadRom( romData );
			if ( hostSave != null )
				core0.Savedata.Load( hostSave );
			core0.Reset();
			core0.Video.InitGpu( scale: ComputeAutoScale() );
			core0.Video.SetReproduceClassicFeel( GamePreferences.ReproduceClassicFeel );

			if ( _camera.IsValid() && core0.Video.RenderCommandList != null )
				_camera.AddCommandList( core0.Video.RenderCommandList, Stage.AfterOpaque, 0 );
		}

		var inst0 = _linkSession.Attach( core0, 0 );
		_linkInstances.Add( inst0 );

		_appliedReproduceClassicFeel = GamePreferences.ReproduceClassicFeel;

		if ( activeSlots != null )
		{
			for ( int i = 0; i < activeSlots.Count; i++ )
			{
				int slot = activeSlots[i];
				if ( slot == 0 )
					continue;
				CreateHostInstance( slot, romData );
			}
		}

		ScreenTexture = inst0.Core.Video.OutputTexture;
		_stateBasePath = "states/" + System.IO.Path.GetFileNameWithoutExtension( RomPath );

		try { InitAudioStream(); }
		catch ( Exception audioEx ) { GbaLog.Write( LogCategory.GBAAudio, LogLevel.Warn, $"Audio init failed: {audioEx.Message}" ); }

		ResetVideoClock();
		_inputCooldown = 2;
		_linkSession.Start( LogCoreThreadError );
		_linkedHost = true;
		IsReady = true;

		var net = NetworkManager.Current;
		if ( net != null )
		{
			net.AttachLinkSession( _linkSession );
			net.ExternalAvDriver = true;
		}
	}

	public void BeginLinkedClient()
	{
		if ( _linkedHost || _linkedClient )
			return;

		TearDownCore();

		_clientScreenTex = Texture.Create( GbaConstants.ScreenWidth, GbaConstants.ScreenHeight )
			.WithFormat( ImageFormat.RGBA8888 )
			.WithDynamicUsage()
			.WithName( "sgba_client_screen" )
			.Finish();
		ScreenTexture = _clientScreenTex;
		_clientLastVideoFrame = -1;
		_clientInputFrame = 0;
		_clientSaveUploaded = false;
		_clientPendingVideo = null;
		_clientPendingFrame = -1;
		_clientLatestState = null;
		_timeSinceClientPacket = 0;

		try { InitAudioStream(); }
		catch ( Exception audioEx ) { GbaLog.Write( LogCategory.GBAAudio, LogLevel.Warn, $"Audio init failed: {audioEx.Message}" ); }

		_linkedClient = true;
		IsReady = true;

		var net = NetworkManager.Current;
		if ( net != null )
		{
			net.OnRemoteAv = OnRemoteAvPacket;
			net.OnRemoteState = OnRemoteStatePacket;
		}
	}

	public void OnRemoteStatePacket( LinkCableStatePacket packet )
	{
		if ( _linkedClient && packet?.State != null )
			_clientLatestState = packet.State;
	}

	public void ResumeSoloAfterHostLost()
	{
		string rom = RomPath;
		byte[] state = _clientLatestState;
		bool wasPaused = _paused;

		var net = NetworkManager.Current;
		if ( string.IsNullOrEmpty( rom ) )
			rom = net?.ResolveSessionRomPath();
		net?.ClearSessionAfterHostLost();

		if ( !string.IsNullOrEmpty( rom ) )
		{
			RestartSoloWithState( rom, state, wasPaused );
		}
		else
		{
			EndLinkedMode();
			HomeScreen.Current?.Show();
		}
	}

	public void ContinueSoloFromLinkedHost()
	{
		if ( !_linkedHost )
			return;

		byte[] live = CaptureHostSlot0State();
		string rom = RomPath;
		bool wasPaused = _paused;

		EndLinkedMode();

		if ( !string.IsNullOrEmpty( rom ) )
			RestartSoloWithState( rom, live, wasPaused );
	}

	private void RestartSoloWithState( string rom, byte[] state, bool wasPaused )
	{
		Restart( rom );
		if ( state != null )
			LoadStateBytes( state, wasPaused );
		if ( wasPaused )
			SetPaused( true );
	}

	private byte[] CaptureHostSlot0State()
	{
		for ( int i = 0; i < _linkInstances.Count; i++ )
		{
			var inst = _linkInstances[i];
			if ( inst.Slot != 0 )
				continue;
			try
			{
				lock ( inst.CoreLock )
					return GbaSerialize.Save( inst.Core, null );
			}
			catch ( Exception e )
			{
				GbaLog.Write( LogCategory.GBAState, LogLevel.Warn, $"Failed to capture host slot0 state: {e.Message}" );
				return null;
			}
		}
		return null;
	}

	private void LoadStateBytes( byte[] data, bool renderFrame = false )
	{
		if ( data == null || data.Length == 0 )
			return;

		RunOnCoreThread( core =>
		{
			try
			{
				GbaSerialize.Load( core, data );
				if ( renderFrame )
				{
					for ( int i = 0; i < StateRewarmFrames; i++ )
						core.RunFrame();
					_videoFramePending = true;
				}
			}
			catch ( Exception e ) { GbaLog.Write( LogCategory.GBAState, LogLevel.Error, $"Failed to load streamed state: {e.Message}" ); }
		} );
	}

	public void EndLinkedMode()
	{
		if ( !_linkedHost && !_linkedClient )
			return;

		var net = NetworkManager.Current;
		if ( net != null )
		{
			net.DetachLinkSession();
			net.ExternalAvDriver = false;
			net.OnRemoteAv = null;
			net.OnRemoteState = null;
		}

		if ( _linkedHost )
		{
			var session = _linkSession;
			_linkSession = null;
			_linkedHost = false;

			session?.Stop();

			foreach ( var inst in _linkInstances )
			{
				var core = inst.Core;
				if ( _camera.IsValid() && core?.Video?.RenderCommandList != null )
					_camera.RemoveCommandList( core.Video.RenderCommandList );
				core?.Video?.DisposeGpu();
			}
			_linkInstances.Clear();
			ScreenTexture = null;
		}

		if ( _linkedClient )
		{
			_linkedClient = false;
			_clientPendingVideo = null;
			_clientPendingFrame = -1;
			_clientLatestState = null;
			_clientScreenTex?.Dispose();
			_clientScreenTex = null;
			ScreenTexture = null;
		}

		if ( _soundHandle is { IsValid: true } )
			_soundHandle.Volume = 0;
		_audioStream?.Dispose();
		_audioStream = null;
		IsReady = false;
	}

	public void SyncLinkedHostSlots( IReadOnlyList<int> activeSlots )
	{
		if ( !_linkedHost || _linkSession == null || activeSlots == null )
			return;

		for ( int i = _linkInstances.Count - 1; i >= 0; i-- )
		{
			var inst = _linkInstances[i];
			if ( inst.Slot <= 0 )
				continue;
			if ( !ContainsSlot( activeSlots, inst.Slot ) )
				DestroyHostInstance( inst );
		}

		byte[] romData = null;
		for ( int i = 0; i < activeSlots.Count; i++ )
		{
			int slot = activeSlots[i];
			if ( slot <= 0 || slot >= NetworkManager.MaxLinkPlayers )
				continue;
			if ( HasHostInstance( slot ) )
				continue;

			romData ??= ReadRomBytes();
			if ( romData == null )
				return;

			CreateHostInstance( slot, romData );
		}
	}

	private static bool ContainsSlot( IReadOnlyList<int> slots, int slot )
	{
		for ( int i = 0; i < slots.Count; i++ )
		{
			if ( slots[i] == slot )
				return true;
		}
		return false;
	}

	private bool HasHostInstance( int slot )
	{
		for ( int i = 0; i < _linkInstances.Count; i++ )
		{
			if ( _linkInstances[i].Slot == slot )
				return true;
		}
		return false;
	}

	private void CreateHostInstance( int slot, byte[] romData )
	{
		var core = new Gba();
		core.LoadRom( romData );
		core.Reset();
		core.Video.InitGpu( scale: 1 );
		core.Video.SetReproduceClassicFeel( GamePreferences.ReproduceClassicFeel );

		if ( _camera.IsValid() && core.Video.RenderCommandList != null )
			_camera.AddCommandList( core.Video.RenderCommandList, Stage.AfterOpaque, 0 );

		var inst = _linkSession.Attach( core, slot );
		_linkInstances.Add( inst );
	}

	private void DestroyHostInstance( LinkCableInstance inst )
	{
		_linkSession.Detach( inst );

		var core = inst.Core;
		if ( _camera.IsValid() && core?.Video?.RenderCommandList != null )
			_camera.RemoveCommandList( core.Video.RenderCommandList );
		core?.Video?.DisposeGpu();

		_linkInstances.Remove( inst );
	}

	public void OnRemoteAvPacket( LinkCableAvPacket packet )
	{
		if ( !_linkedClient || packet == null )
			return;

		_timeSinceClientPacket = 0;
		EnqueuePacketAudio( packet );

		if ( packet.Video != null && packet.Frame > _clientPendingFrame )
		{
			_clientPendingVideo = packet.Video;
			_clientPendingFrame = packet.Frame;
		}

		PersistSave( packet.SaveData );
	}

	private void PresentClientVideo()
	{
		if ( _clientScreenTex == null || _clientPendingFrame <= _clientLastVideoFrame )
			return;

		byte[] pixels = _clientPendingVideo;
		int expected = GbaConstants.ScreenWidth * GbaConstants.ScreenHeight * 4;
		if ( pixels != null && pixels.Length >= expected )
			_clientScreenTex.Update( pixels.AsSpan( 0, expected ) );

		_clientLastVideoFrame = _clientPendingFrame;
	}

	private void PersistSave( byte[] saveData )
	{
		if ( saveData == null || _savePath == null )
			return;

		try { FileSystem.Data.WriteAllBytes( _savePath, saveData ); }
		catch ( Exception e ) { GbaLog.Write( LogCategory.GBA, LogLevel.Warn, $"Save write failed: {e.Message}" ); }
	}

	private void EnqueuePacketAudio( LinkCableAvPacket packet )
	{
		if ( packet.AudioSamples <= 0 || packet.Audio == null || _audioStream == null )
			return;
		if ( _audioStream.QueuedSampleCount > GbaAudio.SamplesPerFrame * AudioHighWaterFrames )
			return;

		int shorts = Math.Min( packet.Audio.Length, packet.AudioSamples * 2 );
		if ( shorts > 0 )
			_audioStream.WriteData( packet.Audio.AsSpan( 0, shorts ) );
	}

	private void TickLinkedClient()
	{
		var net = NetworkManager.Current;
		if ( net == null )
			return;

		if ( !_clientSaveUploaded )
		{
			byte[] save = ReadHostSave();
			if ( save != null )
				net.SendLocalSave( save );
			_clientSaveUploaded = true;
		}

		ushort keys = ReadInputKeysActive();
		net.SendLocalInput( _clientInputFrame++, keys );
	}

	private void TickLinkedHost()
	{
		var session = _linkSession;
		if ( session == null )
			return;

		ushort hostKeys = ReadInputKeysActive();
		if ( _linkInstances.Count > 0 )
			session.SetSlotInput( 0, hostKeys );

		if ( IsLinkVideoFrameDue() )
		{
			AdvanceLinkVideoClock();
			session.GrantFrameCredit();
		}
	}

	private bool IsLinkVideoFrameDue()
	{
		if ( _videoClock == null )
			ResetVideoClock();

		if ( _nextVideoFrameDue <= 0 )
			return true;

		return _videoClock.Elapsed.TotalSeconds >= _nextVideoFrameDue;
	}

	private void AdvanceLinkVideoClock()
	{
		double now = _videoClock?.Elapsed.TotalSeconds ?? 0;

		if ( _nextVideoFrameDue <= 0 || now - _nextVideoFrameDue > GbaFrameTime * MaxPendingFrames )
			_nextVideoFrameDue = now + GbaFrameTime;
		else
			_nextVideoFrameDue += GbaFrameTime;
	}

	private void CaptureAndSendHostVideo()
	{
		var session = _linkSession;
		var net = NetworkManager.Current;
		if ( session == null || net == null )
			return;

		for ( int i = 0; i < _linkInstances.Count; i++ )
		{
			var inst = _linkInstances[i];
			int slot = inst.Slot;
			if ( slot < 0 )
				continue;

			if ( slot == 0 )
			{
				long h0 = inst.LastHarvestedFrame;
				if ( h0 != inst.LastUploadedFrame )
				{
					inst.Core.Video.UploadAndBuildCommandList();
					inst.LastUploadedFrame = h0;
				}
				while ( session.TryDequeueAv( inst, out var packet ) )
				{
					EnqueuePacketAudio( packet );
					PersistSave( packet.SaveData );
				}
				continue;
			}

			while ( session.TryDequeueAv( inst, out var packet ) )
			{
				packet.Video = null;
				net.HostSendAv( slot, packet );
			}

			if ( inst.TimeSinceStateSent > StateStreamIntervalSeconds )
			{
				inst.TimeSinceStateSent = 0;
				byte[] state;
				lock ( inst.CoreLock )
					state = GbaSerialize.Save( inst.Core, null );
				net.HostSendState( slot, state );
			}

			if ( inst.PendingVideoFrame >= 0 )
			{
				long frame = inst.PendingVideoFrame;
				inst.PendingVideoFrame = -1;
				int capturedSlot = slot;
				int playerId = inst.PlayerId;
				inst.Core.Video.CaptureScreenshotAsync( video =>
				{
					net.HostSendAv( capturedSlot, new LinkCableAvPacket { PlayerId = playerId, Frame = frame, Video = video } );
				} );
			}

			long harvested = inst.LastHarvestedFrame;
			if ( harvested != inst.LastUploadedFrame )
			{
				inst.Core.Video.UploadAndBuildCommandList();
				inst.LastUploadedFrame = harvested;
				inst.PendingVideoFrame = harvested;
			}
		}
	}

	private byte[] ReadRomBytes()
	{
		try
		{
			if ( FileSystem.Mounted.FileExists( RomPath ) )
				return FileSystem.Mounted.ReadAllBytes( RomPath ).ToArray();
			if ( FileSystem.Data.FileExists( RomPath ) )
				return FileSystem.Data.ReadAllBytes( RomPath ).ToArray();
		}
		catch ( Exception e )
		{
			GbaLog.Write( LogCategory.GBA, LogLevel.Error, $"ReadRomBytes failed: {e.Message}" );
		}
		return null;
	}

	private byte[] ReadHostSave()
	{
		_savePath = "saves/" + System.IO.Path.GetFileNameWithoutExtension( RomPath ) + ".sav";
		try
		{
			if ( FileSystem.Data.FileExists( _savePath ) )
				return FileSystem.Data.ReadAllBytes( _savePath ).ToArray();
		}
		catch ( Exception e )
		{
			GbaLog.Write( LogCategory.GBA, LogLevel.Warn, $"ReadHostSave failed: {e.Message}" );
		}
		return null;
	}
}