EmulatorComponent.cs
using System.Diagnostics;
using System.Threading;
using Sandbox.Rendering;

namespace sGBA;

public sealed partial class EmulatorComponent : Component
{
	[Property, Title( "ROM Path" ), FilePath( Extension = "gba" )]
	public string RomPath { get; set; }

	public string GameCode { get; private set; }

	public static EmulatorComponent Current { get; private set; }
	public Gba Core => _coreThread?.Core;
	public Texture ScreenTexture { get; private set; }
	public bool IsReady { get; private set; }
	public string ErrorMessage { get; private set; }

	private SoundStream _audioStream;
	private SoundHandle _soundHandle;
	private string _savePath;
	private CameraComponent _camera;
	private object _coreLock = new();
	private GbaCoreThread _coreThread;
	private Stopwatch _videoClock;
	private double _nextVideoFrameDue;

	private const int AllKeysReleased = 0x03FF;
	private const int AudioPrefillFrames = 2;
	private const int AudioHighWaterFrames = 3;
	private const int GbaClockRate = 1 << 24;
	private const int GbaCyclesPerFrame = 228 * 1232;
	private const double GbaNativeFps = (double)GbaClockRate / GbaCyclesPerFrame;
	private const double GbaFrameTime = (double)GbaCyclesPerFrame / GbaClockRate;
	private const int MaxPendingFrames = 3;
	private const int SyncWaitMilliseconds = 50;
	private const float StickDeadzone = 0.3f;

	private int _inputKeys = AllKeysReleased;
	private bool _videoFramePending;
	private bool _paused;
	private bool _coreHalted;
	private int _inputCooldown;
	private bool _initCoreOnUpdate;
	private int _initDeferFrames;
	private string _stateBasePath;
	private bool _appliedReproduceClassicFeel;

	protected override void OnStart()
	{
		Current = this;
		GbaLog.SetBackend( LogBackend );
		_camera = Scene.Camera;
		if ( !string.IsNullOrEmpty( RomPath ) )
			_initCoreOnUpdate = true;
	}

	public void Restart( string romPath )
	{
		if ( _linkedHost || _linkedClient )
			EndLinkedMode();

		TearDownCore();
		RomPath = romPath;
		IsReady = false;
		ErrorMessage = null;
		_initCoreOnUpdate = false;
		_initDeferFrames = 0;
		InitCore();
	}

	public void Unload()
	{
		TearDownCore();
		RomPath = null;
		GameCode = null;
		_initCoreOnUpdate = false;
		_initDeferFrames = 0;
	}

	private void TearDownCore()
	{
		GbaCoreThread coreThread = _coreThread;
		_coreThread = null;
		coreThread?.End();

		lock ( CoreLock )
		{
			Gba core = coreThread?.Core;
			if ( core?.Savedata != null && core.Savedata.Data.Length > 0 && _savePath != null )
				FileSystem.Data.WriteAllBytes( _savePath, core.Savedata.Data );

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

			core?.Video?.DisposeGpu();
			ScreenTexture = null;
		}

		if ( _soundHandle is { IsValid: true } )
			_soundHandle.Volume = 0;
		_audioStream?.Dispose();
		_audioStream = null;
		_videoClock = null;
		_nextVideoFrameDue = 0;
		_videoFramePending = false;
		_inputCooldown = 0;
		_initDeferFrames = 0;
		_paused = false;
		_coreHalted = false;
		Interlocked.Exchange( ref _inputKeys, AllKeysReleased );
		IsReady = false;
	}

	private static string ReadGameCode( byte[] romData )
	{
		if ( romData == null || romData.Length < 0xB0 )
			return null;
		return System.Text.Encoding.ASCII.GetString( romData, 0xAC, 4 ).TrimEnd( '\0' );
	}

	private void InitCore()
	{
		try
		{
			if ( !FileSystem.Mounted.FileExists( RomPath ) && !FileSystem.Data.FileExists( RomPath ) )
			{
				ErrorMessage = $"ROM not found: {RomPath}";
				GbaLog.Write( LogCategory.GBA, LogLevel.Error, ErrorMessage );
				return;
			}

			BaseFileSystem romFs = FileSystem.Mounted.FileExists( RomPath ) ? FileSystem.Mounted : FileSystem.Data;
			byte[] romData = romFs.ReadAllBytes( RomPath ).ToArray();
			if ( romData.Length < 192 )
			{
				ErrorMessage = "ROM file is too small to be a valid GBA ROM.";
				GbaLog.Write( LogCategory.GBA, LogLevel.Error, ErrorMessage );
				return;
			}

			GameCode = ReadGameCode( romData );

			Gba core = new();
			core.LoadRom( romData );

			_savePath = "saves/" + System.IO.Path.GetFileNameWithoutExtension( RomPath ) + ".sav";
			if ( FileSystem.Data.FileExists( _savePath ) )
			{
				byte[] saveData = FileSystem.Data.ReadAllBytes( _savePath ).ToArray();
				core.Savedata.Load( saveData );
			}

			core.Reset();
			core.Video.InitGpu( scale: ComputeAutoScale() );
			core.Video.SetReproduceClassicFeel( GamePreferences.ReproduceClassicFeel );
			_appliedReproduceClassicFeel = GamePreferences.ReproduceClassicFeel;
			ScreenTexture = core.Video.OutputTexture;

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

			_stateBasePath = "states/" + System.IO.Path.GetFileNameWithoutExtension( RomPath );

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

			_coreThread = new GbaCoreThread( core, CoreLock, ReadInputKeysActive, LogCoreThreadError, LogCoreThreadReset );
			_coreThread.Sync.LoadCoreOptions( audioSync: true, videoSync: true, fpsTarget: (float)GbaNativeFps );
			_coreThread.Sync.AudioHighWater = GbaAudio.SamplesPerFrame * AudioHighWaterFrames;
			ResetVideoClock();
			_coreThread.Start();

			IsReady = true;
		}
		catch ( Exception ex )
		{
			ErrorMessage = $"Failed to load ROM: {ex.Message}";
			GbaLog.Write( LogCategory.GBA, LogLevel.Fatal, ErrorMessage );
		}
	}

	private void InitAudioStream()
	{
		if ( _audioStream != null )
		{
			if ( _soundHandle is { IsValid: true } )
				_soundHandle.Volume = 0;
			_audioStream.Dispose();
			_audioStream = null;
		}

		_audioStream = new SoundStream( GbaAudio.SampleRate, 2 );
		_audioStream.WriteData( new short[GbaAudio.SamplesPerFrame * 2 * AudioPrefillFrames] );
		_soundHandle = _audioStream.Play( volume: _paused ? 0f : 1.0f );
		_soundHandle.SpacialBlend = 0f;
		_soundHandle.OcclusionEnabled = false;
		_soundHandle.DistanceAttenuation = false;
		_soundHandle.AirAbsorption = false;
		_soundHandle.Transmission = false;
		_soundHandle.Stop( float.MaxValue );
	}

	private static int ComputeAutoScale()
	{
		int screenWidth = Screen.Width > 0 ? (int)Screen.Width : 1920;
		int screenHeight = Screen.Height > 0 ? (int)Screen.Height : 1080;
		return Math.Clamp( Math.Min( screenWidth / 240, screenHeight / 160 ), 1, 8 );
	}

	protected override void OnUpdate()
	{
		if ( _linkedClient )
		{
			if ( _timeSinceClientPacket > ClientHostTimeoutSeconds )
			{
				ResumeSoloAfterHostLost();
				return;
			}

			PollInput();
			TickLinkedClient();
			RestoreAudioStreamIfNeeded();
			return;
		}

		if ( _linkedHost )
		{
			RescaleGpuIfNeeded();
			if ( _appliedReproduceClassicFeel != GamePreferences.ReproduceClassicFeel )
				ApplyDisplaySettings();
			PollInput();
			TickLinkedHost();
			RestoreAudioStreamIfNeeded();
			return;
		}

		if ( !StartCoreWhenReady() )
			return;

		ReconcileCorePause();
		RescaleGpuIfNeeded();

		if ( _appliedReproduceClassicFeel != GamePreferences.ReproduceClassicFeel )
			ApplyDisplaySettings();

		PollInput();
		RestoreAudioStreamIfNeeded();
	}

	private bool StartCoreWhenReady()
	{
		if ( _initCoreOnUpdate )
		{
			if ( ShouldDeferInitialCore() )
				return false;

			_initCoreOnUpdate = false;
			_initDeferFrames = 0;
			InitCore();
		}

		return IsReady && _coreThread?.Core != null;
	}

	private void RestoreAudioStreamIfNeeded()
	{
		if ( _audioStream == null || _soundHandle is { IsValid: true } )
			return;

		try { InitAudioStream(); }
		catch { _audioStream = null; }
	}

	private bool ShouldDeferInitialCore()
	{
		if ( (int)Screen.Width != 1024 || (int)Screen.Height != 1024 )
			return false;

		if ( _initDeferFrames++ >= 5 )
			return false;

		return true;
	}

	private object CoreLock => _coreLock ??= new object();

	private ushort ReadInputKeysActive()
	{
		return (ushort)(AllKeysReleased ^ Interlocked.CompareExchange( ref _inputKeys, 0, 0 ));
	}

	private void ResetVideoClock()
	{
		_videoClock ??= new Stopwatch();
		_videoClock.Restart();
		_nextVideoFrameDue = 0;
	}

	private bool IsVideoFrameDue( GbaCoreSync sync )
	{
		if ( _videoClock == null )
			ResetVideoClock();

		if ( sync == null || _nextVideoFrameDue <= 0 )
			return true;

		return _videoClock.Elapsed.TotalSeconds >= _nextVideoFrameDue;
	}

	private void AdvanceVideoClock( GbaCoreSync sync )
	{
		double frameTime = sync?.FpsTarget > 0 ? 1.0 / sync.FpsTarget : GbaFrameTime;
		double now = _videoClock?.Elapsed.TotalSeconds ?? 0;

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

	private void RunOnCoreThread( Action<Gba> action )
	{
		_coreThread?.RunFunction( action );
	}

	private void RescaleGpuIfNeeded()
	{
		if ( _paused )
			return;

		Gba core = Core;
		if ( core?.Video == null )
			return;

		int desiredScale = ComputeAutoScale();
		if ( core.Video.GpuScale == desiredScale )
			return;

		lock ( CoreLock )
		{
			if ( Core != core || core.Video == null )
				return;

			if ( core.Video.GpuScale == desiredScale )
				return;

			if ( _camera.IsValid() && core.Video.RenderCommandList != null )
				_camera.RemoveCommandList( core.Video.RenderCommandList );

			core.Video.InitGpu( desiredScale );
			core.Video.SetReproduceClassicFeel( GamePreferences.ReproduceClassicFeel );
			_appliedReproduceClassicFeel = GamePreferences.ReproduceClassicFeel;
			ScreenTexture = core.Video.OutputTexture;

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

			_videoFramePending = true;
			_coreThread?.Sync.ForceFrame();
		}
	}

	protected override void OnPreRender()
	{
		if ( _linkedHost )
		{
			CaptureAndSendHostVideo();
			return;
		}

		if ( _linkedClient )
		{
			PresentClientVideo();
			return;
		}

		WaitForPostedCoreFrame();
		UploadPendingVideoFrame();
	}

	private void WaitForPostedCoreFrame()
	{
		GbaCoreThread coreThread = _coreThread;
		GbaCoreSync sync = coreThread?.Sync;
		if ( sync == null || !IsVideoFrameDue( sync ) )
			return;

		if ( !sync.WaitFrameStart() )
			return;

		DrainPostedCoreFrames( coreThread );
		sync.WaitFrameEnd();
		AdvanceVideoClock( sync );
	}

	private void DrainPostedCoreFrames( GbaCoreThread coreThread )
	{
		bool hasFrame = false;

		while ( coreThread.PostedFrames.TryDequeue( out FramePacket frame ) )
		{
			if ( _audioStream != null && frame.AudioSamples > 0 && _audioStream.QueuedSampleCount <= GbaAudio.SamplesPerFrame * AudioHighWaterFrames )
				_audioStream.WriteData( frame.Audio.AsSpan( 0, frame.AudioSamples * 2 ) );

			coreThread.Sync.ConsumeAudio( frame.AudioSamples );

			if ( frame.SaveData != null )
				FileSystem.Data.WriteAllBytes( _savePath, frame.SaveData );

			hasFrame = true;
		}

		if ( hasFrame )
			_videoFramePending = true;
	}

	private void UploadPendingVideoFrame()
	{
		Gba core = Core;
		if ( core?.Video?.RenderCommandList == null )
			return;

		if ( !_videoFramePending )
			return;

		if ( core.Video.UploadAndBuildCommandList() )
			_videoFramePending = false;
	}

	private void PollInput()
	{
		if ( _paused )
			return;

		if ( _inputCooldown > 0 )
		{
			bool anyHeld = Input.Down( "GBA_A" ) || Input.Down( "GBA_B" ) ||
				Input.Down( "GBA_Start" ) || Input.Down( "GBA_Select" ) ||
				Input.Down( "GBA_L" ) || Input.Down( "GBA_R" ) ||
				Input.Down( "GBA_Up" ) || Input.Down( "GBA_Down" ) ||
				Input.Down( "GBA_Left" ) || Input.Down( "GBA_Right" ) ||
				MathF.Abs( Input.GetAnalog( InputAnalog.LeftStickX ) ) > StickDeadzone ||
				MathF.Abs( Input.GetAnalog( InputAnalog.LeftStickY ) ) > StickDeadzone;

			if ( anyHeld )
				return;

			_inputCooldown = 0;
		}

		int keys = AllKeysReleased;
		if ( Input.Down( "GBA_A" ) ) keys &= ~(int)GbaKey.A;
		if ( Input.Down( "GBA_B" ) ) keys &= ~(int)GbaKey.B;
		if ( Input.Down( "GBA_Start" ) ) keys &= ~(int)GbaKey.Start;
		if ( Input.Down( "GBA_Select" ) ) keys &= ~(int)GbaKey.Select;
		if ( Input.Down( "GBA_L" ) ) keys &= ~(int)GbaKey.L;
		if ( Input.Down( "GBA_R" ) ) keys &= ~(int)GbaKey.R;

		float stickX = Input.GetAnalog( InputAnalog.LeftStickX );
		float stickY = Input.GetAnalog( InputAnalog.LeftStickY );
		if ( Input.Down( "GBA_Up" ) || stickY < -StickDeadzone ) keys &= ~(int)GbaKey.Up;
		if ( Input.Down( "GBA_Down" ) || stickY > StickDeadzone ) keys &= ~(int)GbaKey.Down;
		if ( Input.Down( "GBA_Left" ) || stickX < -StickDeadzone ) keys &= ~(int)GbaKey.Left;
		if ( Input.Down( "GBA_Right" ) || stickX > StickDeadzone ) keys &= ~(int)GbaKey.Right;

		Interlocked.Exchange( ref _inputKeys, keys );
	}

	public void SetPaused( bool paused )
	{
		_paused = paused;

		if ( paused )
			Interlocked.Exchange( ref _inputKeys, AllKeysReleased );
		else
			_inputCooldown = 2;

		if ( _soundHandle is { IsValid: true } )
			_soundHandle.Volume = paused ? 0 : 1.0f;

		ReconcileCorePause();
	}

	private void ReconcileCorePause()
	{
		bool networked = NetworkManager.Current?.IsActive == true;
		bool shouldHalt = _paused && !networked;
		if ( shouldHalt == _coreHalted )
			return;

		_coreHalted = shouldHalt;
		_coreThread?.SetPaused( shouldHalt );
		if ( !shouldHalt )
			ResetVideoClock();
	}

	public string GetStatePath( int slot ) => $"{_stateBasePath}.ss{slot}";

	public void CreateSuspendPoint( int slot )
	{
		Gba core = Core;
		if ( core == null )
			return;

		string path = GetStatePath( slot );
		try
		{
			byte[] data;
			lock ( CoreLock )
			{
				if ( Core != core )
					return;

				byte[] screenshot = core.Video.CaptureScreenshot();
				data = GbaSerialize.Save( core, screenshot );
			}

			FileSystem.Data.WriteAllBytes( path, data );
			GbaLog.Write( LogCategory.GBAState, LogLevel.Info, $"Suspend point created in slot {slot}" );
		}
		catch ( Exception ex )
		{
			GbaLog.Write( LogCategory.GBAState, LogLevel.Error, $"Failed to create suspend point {slot}: {ex.Message}" );
		}
	}

	public void LoadSuspendPoint( int slot )
	{
		string path = GetStatePath( slot );
		RunOnCoreThread( core =>
		{
			if ( !FileSystem.Data.FileExists( path ) )
			{
				GbaLog.Write( LogCategory.GBAState, LogLevel.Warn, $"No suspend point in slot {slot}" );
				return;
			}

			byte[] data = FileSystem.Data.ReadAllBytes( path ).ToArray();
			GbaSerialize.Load( core, data );
			GbaLog.Write( LogCategory.GBAState, LogLevel.Info, $"Suspend point loaded from slot {slot}" );
		} );
	}

	public void ResetEmulator()
	{
		_coreThread?.Reset();
	}

	public void ApplyDisplaySettings()
	{
		bool reproduceClassicFeel = GamePreferences.ReproduceClassicFeel;
		Gba core = Core;
		if ( core?.Video != null )
		{
			lock ( CoreLock )
			{
				if ( Core != core || core.Video == null )
					return;

				core.Video.SetReproduceClassicFeel( reproduceClassicFeel );
			}
		}
		_appliedReproduceClassicFeel = reproduceClassicFeel;
	}

	protected override void OnDestroy()
	{
		if ( _linkedHost || _linkedClient )
			EndLinkedMode();
		TearDownCore();
		_camera = null;
		if ( Current == this )
			Current = null;
	}

	private void LogCoreThreadError( string message )
	{
		GbaLog.Write( LogCategory.GBA, LogLevel.Fatal, message );
	}

	private void LogCoreThreadReset()
	{
		GbaLog.Write( LogCategory.GBA, LogLevel.Info, "Emulator reset" );
	}

	private static void LogBackend( LogCategory category, LogLevel level, string message )
	{
		string formatted = $"{GbaLog.GetCategoryName( category )}: {message}";

		if ( (level & (LogLevel.Fatal | LogLevel.Error)) != 0 )
			Log.Error( formatted );
		else if ( (level & (LogLevel.Warn | LogLevel.GameError)) != 0 )
			Log.Warning( formatted );
		else
			Log.Info( formatted );
	}
}