Flash/Sbox/FlashStage.cs
using System;
using System.Collections.Generic;
using System.Text;
using Flash;
using Sandbox;
using Sandbox.UI;
using FMatrix = Flash.Matrix;

/// <summary>
/// Runs Swords and Sandals 2 (the original SWF, decoded into Assets/flash) inside s&amp;box.
/// Put it on a GameObject with a ScreenPanel. The game keeps its original 640x420 shape inside a 4:3 frame.
/// Press F2 in game to move things around (see FlashLayout).
/// </summary>
[Title( "Flash Stage" ), Category( "Shoes and Daggers" ), Icon( "videogame_asset" )]
public sealed class FlashStage : PanelComponent, IFlashHost
{
	/// <summary>Folder (inside Assets) with ss2.json, zooms.json, hitmasks.bin and the art.</summary>
	[Property] public string DataPath { get; set; } = "flash";

	/// <summary>Width:height of the frame the game sits in (black bars fill the rest).</summary>
	[Property] public float FrameAspect { get; set; } = 4f / 3f;

	/// <summary>Swap if images come out mirrored/rotated wrong (matrix order differs between engine versions).</summary>
	[Property] public bool TransposeMatrix { get; set; }

	/// <summary>s&amp;box applies a transform matrix's translation in screen pixels; keep on unless things drift.</summary>
	[Property] public bool ScaleMatrixTranslation { get; set; } = true;

	/// <summary>Log ActionScript trace() output.</summary>
	[Property] public bool ShowTraces { get; set; } = true;

	[Property, Range( 0, 1 )] public float MasterVolume { get; set; } = 1f;
	/// <summary>Write every sound start/stop to the console (for tracking down audio problems).</summary>
	[Property] public bool LogSounds { get; set; } = true;

	public static FlashStage Instance { get; private set; }
	public Player Player { get; private set; }
	public Library Library { get; private set; }

	Panel stage;
	readonly Dictionary<DisplayObject, Node> nodes = new();
	readonly List<RenderItem> items = new();
	readonly Dictionary<string, Texture> textures = new();
	float scale = 1;
	float stageLeft, stageTop;
	Vector2 lastMouse = new( -1, -1 );
	int frameStamp;
	public FlashEditor Editor { get; private set; }

	sealed class Node
	{
		public Panel Outer; // clip container (or the panel itself)
		public Panel Panel;
		public TextEntry Entry;
		public int Stamp;
		public string Image;
		public string Mode;
		public Texture Tex;
		public readonly TexCache Cache = new();
		// last values written to the panel's style (only write when something changed: style writes force re-layout)
		public bool HasGeo;
		public FMatrix LastM;
		public double LastW, LastH;
		public float LastScale = -1, LastOpacity = -1, LastBorder = -2, LastBlur = -2;
		public Color LastBorderColor;
		public int LastZ = int.MinValue;
		public double LC0 = double.NaN, LC1, LC2, LC3;
		public float LastStroke = -2;
		public Color LastStrokeColor;
		public int TextVersion = -1;
		public string Text;
		public string StyleKey;
		public bool Clipped;
	}

	protected override void OnStart()
	{
		Instance = this;
		Panel.Style.Position = PositionMode.Absolute;
		Panel.Style.Left = 0;
		Panel.Style.Top = 0;
		Panel.Style.Width = Length.Percent( 100 );
		Panel.Style.Height = Length.Percent( 100 );
		Panel.Style.BackgroundColor = Color.Black;
		Panel.Style.PointerEvents = PointerEvents.All;

		stage = new Panel { Parent = Panel };
		stage.Style.Position = PositionMode.Absolute;
		stage.Style.Overflow = OverflowMode.Hidden;
		stage.Style.BackgroundColor = Color.Black;
		stage.Style.PointerEvents = PointerEvents.None;

		try
		{
			foreach ( var f in new[] { "ss2.json", "zooms.json", "pack.json" } )
				if ( !FileSystem.Mounted.FileExists( $"{DataPath}/{f}" ) )
					throw new Exception( $"{DataPath}/{f} is missing from the game files. Add \"{DataPath}/*\" to Resource Files in the project settings and publish again." );
			var json = FileSystem.Mounted.ReadAllText( $"{DataPath}/ss2.json" );
			var zooms = FileSystem.Mounted.ReadAllText( $"{DataPath}/zooms.json" );
			byte[] masks = null;
			if ( FileSystem.Mounted.FileExists( $"{DataPath}/hitmasks.bin" ) ) masks = FileSystem.Mounted.ReadAllBytes( $"{DataPath}/hitmasks.bin" ).ToArray();
			Library = Library.Load( json, zooms, masks );
		}
		catch ( Exception e )
		{
			Log.Error( $"FlashStage: couldn't load {DataPath}/ss2.json — {e.Message}" );
			return;
		}

		LoadPacks();
		Log.Info( $"FlashStage: loaded {Library.Chars.Count} symbols, {packIndex.Count} packed files" );
		Player = new Player( Library, this );
		Editor = new FlashEditor( this, Panel );
		Editor.LoadLayout();
		Player.Start();
	}

	protected override void OnDestroy()
	{
		if ( Instance == this ) Instance = null;
		StopEverySound();
	}

	protected override void OnUpdate()
	{
		if ( Player == null ) return;
		FitStage();
		Editor?.Update();
		if ( Editor == null || !Editor.Paused ) Player.Tick( Time.Delta );
		UpdateAudio( Time.Delta );
		PumpMouse();
		Render();
	}

	// ======================================================================= layout

	void FitStage()
	{
		var sp = Components.Get<ScreenPanel>();
		float w = Screen.Width, h = Screen.Height;
		float boxW = MathF.Min( w, h * FrameAspect );
		scale = boxW / (float)Library.StageWidth;
		if ( sp != null )
		{
			sp.AutoScreenScale = false;
			sp.Scale = scale;
		}
		float cssW = w / scale, cssH = h / scale;
		stageLeft = (cssW - (float)Library.StageWidth) / 2f;
		stageTop = (cssH - (float)Library.StageHeight) / 2f;
		stage.Style.Left = stageLeft;
		stage.Style.Top = stageTop;
		stage.Style.Width = (float)Library.StageWidth;
		stage.Style.Height = (float)Library.StageHeight;
	}

	/// <summary>Screen pixels → Flash stage coordinates.</summary>
	public Vector2 ScreenToStage( Vector2 screen ) => new( screen.x / scale - stageLeft, screen.y / scale - stageTop );
	public Vector2 StageToScreen( Vector2 st ) => new( (st.x + stageLeft) * scale, (st.y + stageTop) * scale );
	public float StageScale => scale;
	public Panel StagePanel => stage;

	// ======================================================================= input

	void PumpMouse()
	{
		var m = Mouse.Position;
		if ( m == lastMouse ) return;
		lastMouse = m;
		var s = ScreenToStage( m );
		if ( Editor != null && Editor.Active ) { Editor.MouseMove( s ); return; }
		Player.MouseMove( s.x, s.y );
	}

	protected override void OnMouseDown( MousePanelEvent e )
	{
		if ( Player == null || e.MouseButton != MouseButtons.Left ) return;
		var s = ScreenToStage( Mouse.Position );
		if ( Editor != null && Editor.Active ) { Editor.MouseDown( s ); return; }
		Player.MouseMove( s.x, s.y );
		Player.MouseDown();
		FocusInputUnderMouse();
	}

	protected override void OnMouseUp( MousePanelEvent e )
	{
		if ( Player == null || e.MouseButton != MouseButtons.Left ) return;
		if ( Editor != null && Editor.Active ) { Editor.MouseUp(); return; }
		Player.MouseUp();
	}

	void FocusInputUnderMouse()
	{
		var t = Player.focusedText;
		if ( t != null && nodes.TryGetValue( t, out var n ) && n.Entry != null ) n.Entry.Focus();
	}

	// ======================================================================= IFlashHost

	public void Trace( string message ) { if ( ShowTraces ) Log.Info( $"[ss2] {message}" ); }

	readonly Dictionary<int, SoundFile> soundFiles = new();
	readonly List<SoundKey> sounds = new();
	double audioClock;
	float lastMaster = -1;

	/// <summary>One Flash sound instance. Playing state is tracked by time (a fresh SoundHandle reports
	/// IsPlaying = false until the mixer picks it up, which made loops restart and stack every frame).</summary>
	sealed class SoundKey
	{
		public SoundDef Def;
		public SoundFile File;
		public SoundHandle Handle;
		public int LoopsLeft;
		public float Volume;
		public double Length, IterStart;
		public bool Stopped;
	}

	SoundFile LoadSoundFile( SoundDef sound )
	{
		if ( soundFiles.TryGetValue( sound.Id, out var sf ) ) return sf;
		var mp3 = PackBytes( $"a/{sound.Id}.mp3" );
		var wav = mp3 == null ? PackBytes( $"a/{sound.Id}.wav" ) : null;
		if ( mp3 != null ) sf = SoundFile.FromMp3( $"ss2_{sound.Id}.mp3", mp3, new SoundFile.LoadOptions() );
		else if ( wav != null ) { sf = SoundFile.FromWav( $"ss2_{sound.Id}.wav", wav, new SoundFile.LoadOptions() ); soundLengths[sound.Id] = WavSeconds( wav ); }
		soundFiles[sound.Id] = sf;
		return sf;
	}

	readonly Dictionary<int, double> soundLengths = new();

	/// <summary>Exact length of a PCM wav (data bytes / byte rate).</summary>
	static double WavSeconds( byte[] b )
	{
		if ( b.Length < 44 ) return 0;
		int byteRate = BitConverter.ToInt32( b, 28 );
		int pos = 12;
		while ( pos + 8 <= b.Length )
		{
			int size = BitConverter.ToInt32( b, pos + 4 );
			if ( b[pos] == 'd' && b[pos + 1] == 'a' && b[pos + 2] == 't' && b[pos + 3] == 'a' ) return byteRate > 0 ? Math.Min( size, b.Length - pos - 8 ) / (double)byteRate : 0;
			pos += 8 + size + (size & 1);
		}
		return 0;
	}

	float Vol( float v ) => Math.Clamp( v, 0, 1 ) * MasterVolume;

	public object PlaySound( SoundDef sound, double volume, double pan, int loops, double offsetSeconds )
	{
		try
		{
			var sf = LoadSoundFile( sound );
			if ( sf == null ) return null;
			var k = new SoundKey { Def = sound, File = sf, LoopsLeft = Math.Max( 1, loops ), Volume = (float)volume, IterStart = audioClock };
			k.Length = soundLengths.TryGetValue( sound.Id, out var len ) && len > 0.01 ? len : sound.Seconds > 0.01 ? sound.Seconds : 1;
			k.Handle = Play2D( sf, Vol( k.Volume ) );
			sounds.Add( k );
			if ( LogSounds ) Log.Info( $"[ss2 sound] play {sound.Id} {sound.ExportName} {k.Length:0.00}s x{k.LoopsLeft} vol {volume:0.##}" );
			return k;
		}
		catch ( Exception e )
		{
			Log.Warning( $"FlashStage: sound {sound.Id} failed: {e.Message}" );
			return null;
		}
	}

	/// <summary>Called every frame: restart loops when an iteration ends, drop finished sounds.</summary>
	void UpdateAudio( double dt )
	{
		audioClock += dt;
		bool masterChanged = lastMaster != MasterVolume;
		lastMaster = MasterVolume;
		for ( int i = sounds.Count - 1; i >= 0; i-- )
		{
			var k = sounds[i];
			if ( k.Stopped ) { sounds.RemoveAt( i ); continue; }
			if ( masterChanged && k.Handle != null ) k.Handle.Volume = Vol( k.Volume );
			if ( audioClock - k.IterStart < k.Length ) continue;
			k.LoopsLeft--;
			if ( k.LoopsLeft <= 0 )
			{
				// finished: stop the handle explicitly so nothing can keep ringing
				k.Stopped = true;
				k.Handle?.Stop( 0.05f );
				k.Handle = null;
				sounds.RemoveAt( i );
				continue;
			}
			k.IterStart += k.Length;
			if ( audioClock - k.IterStart > k.Length ) k.IterStart = audioClock; // fell behind (game paused / hitch)
			k.Handle?.Stop( 0 );
			k.Handle = Play2D( k.File, Vol( k.Volume ) );
		}
	}

	/// <summary>Flash sounds are plain stereo: no 3D position, distance falloff, reverb, reflections or occlusion.</summary>
	static SoundHandle Play2D( SoundFile file, float volume )
	{
		var h = Sound.PlayFile( file, volume, 1f, 0f, 0f );
		if ( h == null ) return null;
		h.ListenLocal = true;
		h.SpacialBlend = 0f;
		h.DistanceAttenuation = false;
		h.ReverbEnabled = false;
		h.Reflections = false;
		h.OcclusionEnabled = false;
		h.Occlusion = false;
		h.AirAbsorption = false;
		h.Transmission = false;
		h.Position = Vector3.Zero;
		return h;
	}

	void StopEverySound()
	{
		foreach ( var k in sounds ) { k.Handle?.Stop( 0 ); k.Stopped = true; }
		sounds.Clear();
	}

	public void StopSound( object handle )
	{
		if ( handle is not SoundKey k || k.Stopped ) return;
		if ( LogSounds ) Log.Info( $"[ss2 sound] stop {k.Def.Id} {k.Def.ExportName}" );
		k.Stopped = true;
		k.Handle?.Stop( 0 );
		k.Handle = null;
		sounds.Remove( k );
	}

	public bool IsSoundPlaying( object handle ) => handle is SoundKey k && !k.Stopped;

	public double SoundPosition( object handle ) => handle is SoundKey k && !k.Stopped ? Math.Clamp( audioClock - k.IterStart, 0, k.Length ) : 0;

	public void SetSoundVolume( object handle, double volume, double pan )
	{
		if ( handle is not SoundKey k ) return;
		k.Volume = (float)volume;
		if ( k.Handle != null ) k.Handle.Volume = Vol( k.Volume );
	}

	public string LoadShared( string name )
	{
		var p = $"ss2/{name}.json";
		return FileSystem.Data.FileExists( p ) ? FileSystem.Data.ReadAllText( p ) : null;
	}

	public void SaveShared( string name, string data )
	{
		FileSystem.Data.CreateDirectory( "ss2" );
		FileSystem.Data.WriteAllText( $"ss2/{name}.json", data );
	}

	public void GetUrl( string url, string window ) => Log.Info( $"[ss2] getURL {url}" );

	public void SetFullScreen( bool on ) { }

	// ======================================================================= packed art

	int texLoaded, texFailures;
	readonly List<byte[]> packs = new();
	readonly Dictionary<string, (int pack, int offset, int length)> packIndex = new();

	void LoadPacks()
	{
		var idx = $"{DataPath}/pack.json";
		if ( !FileSystem.Mounted.FileExists( idx ) ) return;
		var root = (Dictionary<string, object>)Flash.Json.Parse( FileSystem.Mounted.ReadAllText( idx ) );
		foreach ( var f in (List<object>)root["files"] ) packs.Add( FileSystem.Mounted.ReadAllBytes( $"{DataPath}/{f}" ).ToArray() );
		foreach ( var kv in (Dictionary<string, object>)root["entries"] )
		{
			var a = Flash.Json.Doubles( kv.Value );
			packIndex[kv.Key] = ((int)a[0], (int)a[1], (int)a[2]);
		}
	}

	/// <summary>Packed images are "RGBA" + width + height + raw-deflated RGBA8 pixels.</summary>
	static (int w, int h, byte[] px) DecodeRaw( byte[] data )
	{
		if ( data.Length < 12 || data[0] != 'R' || data[1] != 'G' || data[2] != 'B' || data[3] != 'A' ) return (0, 0, null);
		int w = BitConverter.ToInt32( data, 4 ), h = BitConverter.ToInt32( data, 8 );
		var raw = new byte[w * h * 4];
		using ( var ms = new System.IO.MemoryStream( data, 12, data.Length - 12 ) )
		using ( var ds = new System.IO.Compression.DeflateStream( ms, System.IO.Compression.CompressionMode.Decompress ) )
		{
			int read = 0;
			while ( read < raw.Length )
			{
				int n = ds.Read( raw, read, raw.Length - read );
				if ( n <= 0 ) break;
				read += n;
			}
		}
		return (w, h, raw);
	}

	static Texture MakeTexture( int w, int h, byte[] raw, string name ) =>
		Texture.Create( w, h, ImageFormat.RGBA8888 ).WithData( raw ).WithMips().WithName( "ss2_" + name ).Finish();

	byte[] PackBytes( string path )
	{
		if ( !packIndex.TryGetValue( path, out var e ) || e.pack >= packs.Count ) return null;
		var b = new byte[e.length];
		Array.Copy( packs[e.pack], e.offset, b, 0, e.length );
		return b;
	}

	// ======================================================================= rendering

	Texture Tex( string path )
	{
		if ( textures.TryGetValue( path, out var t ) ) return t;
		var bytes = PackBytes( path );
		if ( bytes != null )
		{
			try
			{
				var (w, h, px) = DecodeRaw( bytes );
				if ( px != null ) t = MakeTexture( w, h, px, path );
				if ( t != null && texLoaded++ < 2 ) Log.Info( $"FlashStage: texture {path} {t.Width}x{t.Height} valid={t.IsValid}" );
			}
			catch ( Exception e ) { if ( texFailures++ < 5 ) Log.Warning( $"FlashStage: can't decode {path}: {e.Message}" ); }
		}
		else if ( FileSystem.Mounted.FileExists( $"{DataPath}/{path}" ) )
			t = Texture.LoadFromFileSystem( $"{DataPath}/{path}", FileSystem.Mounted, false );
		textures[path] = t;
		return t;
	}

	// ---- colour-transformed copies of images (Flash colour transforms / colour-matrix filters done on the CPU)

	readonly Dictionary<string, (int w, int h, byte[] px)> rawCache = new();
	readonly Dictionary<string, Texture> variants = new();
	long rawBytes;
	/// <summary>Last frame each texture was on screen; textures unused for a while are freed.</summary>
	readonly Dictionary<Texture, int> texUsed = new();

	void EvictTextures()
	{
		if ( frameStamp % 120 != 0 ) return;
		List<string> drop = null;
		foreach ( var kv in variants )
			if ( kv.Value == null || !texUsed.TryGetValue( kv.Value, out var f ) || frameStamp - f > 300 ) (drop ??= new()).Add( kv.Key );
		if ( drop != null ) foreach ( var k in drop ) { var t = variants[k]; variants.Remove( k ); Free( t ); }
		drop = null;
		foreach ( var kv in textures )
			if ( kv.Value != null && texUsed.TryGetValue( kv.Value, out var f ) && frameStamp - f > 1800 ) (drop ??= new()).Add( kv.Key );
		if ( drop != null ) foreach ( var k in drop ) { var t = textures[k]; textures.Remove( k ); Free( t ); }
	}

	void Free( Texture t )
	{
		if ( t == null ) return;
		texUsed.Remove( t );
		foreach ( var n in nodes.Values ) if ( n.Cache.Tex == t ) { n.Cache.Tex = null; n.Cache.Image = null; }
		t.Dispose();
	}

	(int w, int h, byte[] px) Raw( string path )
	{
		if ( rawCache.TryGetValue( path, out var r ) ) return r;
		var bytes = PackBytes( path );
		r = bytes != null ? DecodeRaw( bytes ) : (0, 0, null);
		if ( r.px != null )
		{
			rawBytes += r.px.Length;
			if ( rawBytes > 192_000_000 ) { rawCache.Clear(); rawBytes = r.px.Length; } // decoded pixels are only a cache
		}
		rawCache[path] = r;
		return r;
	}

	static int Q( double v, double step ) => (int)Math.Round( v / step );

	/// <summary>The image with colour transform cx (rgb part) and then any colour-matrix filters applied.</summary>
	/// <summary>A mask shape that cuts an image: its picture, bounds, and the map from the image's local space to the mask's.</summary>
	struct MaskRef { public string Image; public double X0, Y0, X1, Y1; public FMatrix R; }

	Texture Variant( string image, ColorTransform cx, List<float[]> matrices, List<MaskRef> masks = null, double ix0 = 0, double iy0 = 0, double ix1 = 0, double iy1 = 0 )
	{
		bool cxIdentity = Math.Abs( cx.Rm - 1 ) < 0.004 && Math.Abs( cx.Gm - 1 ) < 0.004 && Math.Abs( cx.Bm - 1 ) < 0.004
			&& Math.Abs( cx.Ra ) < 0.5 && Math.Abs( cx.Ga ) < 0.5 && Math.Abs( cx.Ba ) < 0.5 && Math.Abs( cx.Aa ) < 0.5;
		if ( cxIdentity && (matrices == null || matrices.Count == 0) && masks == null ) return Tex( image );
		// quantise so animated tints reuse textures
		var sb = new StringBuilder( image );
		sb.Append( '|' ).Append( Q( cx.Rm, 1 / 128.0 ) ).Append( ',' ).Append( Q( cx.Gm, 1 / 128.0 ) ).Append( ',' ).Append( Q( cx.Bm, 1 / 128.0 ) )
			.Append( ',' ).Append( Q( cx.Ra, 2 ) ).Append( ',' ).Append( Q( cx.Ga, 2 ) ).Append( ',' ).Append( Q( cx.Ba, 2 ) ).Append( ',' ).Append( Q( cx.Aa, 2 ) );
		if ( matrices != null )
			foreach ( var m in matrices ) { sb.Append( '|' ); foreach ( var v in m ) sb.Append( Q( v, 1 / 256.0 ) ).Append( ',' ); }
		if ( masks != null )
			foreach ( var mr in masks )
				sb.Append( "|m" ).Append( mr.Image ).Append( ',' ).Append( Q( mr.R.A, 0.002 ) ).Append( ',' ).Append( Q( mr.R.B, 0.002 ) ).Append( ',' )
					.Append( Q( mr.R.C, 0.002 ) ).Append( ',' ).Append( Q( mr.R.D, 0.002 ) ).Append( ',' ).Append( Q( mr.R.Tx, 0.25 ) ).Append( ',' ).Append( Q( mr.R.Ty, 0.25 ) );
		var key = sb.ToString();
		if ( variants.TryGetValue( key, out var t ) ) return t;
		var (w, h, src) = Raw( image );
		if ( src == null ) return Tex( image );
		// spread big recolours over frames (null = keep showing the previous texture this frame)
		if ( pixelBudget < w * h && pixelBudget < PixelBudgetPerFrame ) return null;
		pixelBudget -= w * h;
		var px = new byte[src.Length];
		double rm = Q( cx.Rm, 1 / 128.0 ) / 128.0, gm = Q( cx.Gm, 1 / 128.0 ) / 128.0, bm = Q( cx.Bm, 1 / 128.0 ) / 128.0;
		double ra = Q( cx.Ra, 2 ) * 2.0, ga = Q( cx.Ga, 2 ) * 2.0, ba = Q( cx.Ba, 2 ) * 2.0, aa = Q( cx.Aa, 2 ) * 2.0;
		for ( int i = 0; i < src.Length; i += 4 )
		{
			double a = src[i + 3];
			if ( a == 0 && aa <= 0 ) continue;
			double r = src[i] * rm + ra, g = src[i + 1] * gm + ga, b = src[i + 2] * bm + ba;
			a = Math.Clamp( a + aa * (a > 0 ? 1 : 0), 0, 255 );
			r = Math.Clamp( r, 0, 255 ); g = Math.Clamp( g, 0, 255 ); b = Math.Clamp( b, 0, 255 );
			if ( matrices != null )
				foreach ( var m in matrices )
				{
					double nr = m[0] * r + m[1] * g + m[2] * b + m[3] * a + m[4];
					double ng = m[5] * r + m[6] * g + m[7] * b + m[8] * a + m[9];
					double nb = m[10] * r + m[11] * g + m[12] * b + m[13] * a + m[14];
					double na = m[15] * r + m[16] * g + m[17] * b + m[18] * a + m[19];
					r = Math.Clamp( nr, 0, 255 ); g = Math.Clamp( ng, 0, 255 ); b = Math.Clamp( nb, 0, 255 ); a = Math.Clamp( na, 0, 255 );
				}
			px[i] = (byte)r; px[i + 1] = (byte)g; px[i + 2] = (byte)b; px[i + 3] = (byte)a;
		}
		if ( masks != null ) ApplyMasks( px, w, h, masks, ix0, iy0, ix1, iy1 );
		t = MakeTexture( w, h, px, key.GetHashCode().ToString( "x" ) );
		variants[key] = t;
		return t;
	}

	readonly List<DisplayObject> deadNodes = new();
	readonly List<Panel> orderNow = new(), orderLast = new();
	readonly Dictionary<Panel, int> rank = new();

	void Render()
	{
		Player.CollectRender( items );
		frameStamp++;
		pixelBudget = PixelBudgetPerFrame;
		EvictTextures();
		for ( int i = 0; i < items.Count; i++ )
		{
			var it = items[i];
			if ( !nodes.TryGetValue( it.Obj, out var n ) ) { n = new Node(); nodes[it.Obj] = n; }
			n.Stamp = frameStamp;
			try { Draw( it, n, i ); }
			catch ( Exception e ) { Log.Warning( $"FlashStage draw: {e.Message}" ); }
		}
		// keep the panels' real child order = Flash draw order (z-index alone isn't re-sorted when a new
		// panel appears, e.g. a button's hover state, which then covered the text above it)
		orderNow.Clear();
		for ( int i = 0; i < items.Count; i++ ) if ( nodes.TryGetValue( items[i].Obj, out var on ) && on.Outer != null ) orderNow.Add( on.Outer );
		bool same = orderNow.Count == orderLast.Count;
		for ( int i = 0; same && i < orderNow.Count; i++ ) same = ReferenceEquals( orderNow[i], orderLast[i] );
		if ( !same )
		{
			rank.Clear();
			for ( int i = 0; i < orderNow.Count; i++ ) rank[orderNow[i]] = i;
			stage.SortChildren( (Panel c) => rank.TryGetValue( c, out var r ) ? r : int.MaxValue );
			orderLast.Clear();
			orderLast.AddRange( orderNow );
		}
		// panels for anything not drawn this frame (removed, hidden, or now a lit sprite) go away
		deadNodes.Clear();
		foreach ( var kv in nodes ) if ( kv.Value.Stamp != frameStamp ) deadNodes.Add( kv.Key );
		foreach ( var d in deadNodes ) { nodes[d].Outer?.Delete( true ); nodes.Remove( d ); }
		Editor?.Draw( items );
	}

	PanelTransform MakeTransform( FMatrix m )
	{
		var t = new PanelTransform();
		float k = ScaleMatrixTranslation ? scale : 1f;
		m.Tx *= k; m.Ty *= k;
		// row-vector layout: x' = x*M11 + y*M21 + M41
		global::Matrix mat = TransposeMatrix
			? new global::Matrix( (float)m.A, (float)m.C, 0, 0, (float)m.B, (float)m.D, 0, 0, 0, 0, 1, 0, (float)m.Tx, (float)m.Ty, 0, 1 )
			: new global::Matrix( (float)m.A, (float)m.B, 0, 0, (float)m.C, (float)m.D, 0, 0, 0, 0, 1, 0, (float)m.Tx, (float)m.Ty, 0, 1 );
		t.AddMatrix3D( mat );
		return t;
	}

	static FMatrix Translate( double x, double y ) => new( 1, 0, 0, 1, x, y );

	void EnsurePanels( Node n, bool clipped, bool entry )
	{
		if ( n.Panel != null && n.Clipped == clipped && (n.Entry != null) == entry ) return;
		n.Outer?.Delete( true );
		Panel p = entry ? new TextEntry() : (Panel)new Panel();
		if ( entry )
		{
			n.Entry = (TextEntry)p;
			p.Style.PointerEvents = PointerEvents.All;
		}
		else
		{
			n.Entry = null;
			p.Style.PointerEvents = PointerEvents.None;
		}
		p.Style.Position = PositionMode.Absolute;
		p.Style.Left = 0;
		p.Style.Top = 0;
		p.Style.TransformOriginX = 0;
		p.Style.TransformOriginY = 0;
		if ( clipped )
		{
			var c = new Panel { Parent = stage };
			c.Style.Position = PositionMode.Absolute;
			c.Style.Overflow = OverflowMode.Hidden;
			c.Style.PointerEvents = entry ? PointerEvents.All : PointerEvents.None;
			p.Parent = c;
			n.Outer = c;
		}
		else
		{
			p.Parent = stage;
			n.Outer = p;
		}
		n.Panel = p;
		n.Clipped = clipped;
		n.Image = null; n.Mode = null; n.Tex = null; n.HasGeo = false; n.LastOpacity = -1; n.LastBorder = -2; n.LastBlur = -2; n.LastZ = int.MinValue; n.LC0 = double.NaN; n.LastStroke = -2; n.StyleKey = null; n.TextVersion = -1;
	}

	void Draw( RenderItem it, Node n, int index )
	{
		var clip = it.Clip;
		bool clipped = clip != null;
		bool entry = it.Obj is EditText e0 && e0.Edit.Input;
		EnsurePanels( n, clipped, entry );
		var p = n.Panel;

		FMatrix world = it.World;
		if ( clipped )
		{
			var c = n.Outer;
			if ( n.LC0 != clip[0] || n.LC1 != clip[1] || n.LC2 != clip[2] || n.LC3 != clip[3] )
			{
				n.LC0 = clip[0]; n.LC1 = clip[1]; n.LC2 = clip[2]; n.LC3 = clip[3];
				c.Style.Left = (float)clip[0];
				c.Style.Top = (float)clip[1];
				c.Style.Width = (float)Math.Max( 0, clip[2] - clip[0] );
				c.Style.Height = (float)Math.Max( 0, clip[3] - clip[1] );
			}
			world = Translate( -clip[0], -clip[1] ).Mul( world );
		}
		if ( n.LastZ != index ) { n.LastZ = index; n.Outer.Style.ZIndex = index; }

		switch ( it.Obj )
		{
			case ShapeObj s: DrawImage( n, s.Shape.Image, s.Shape.X0, s.Shape.Y0, s.Shape.X1, s.Shape.Y1, world, it ); break;
			case StaticTextObj t: DrawImage( n, t.Text.Image, t.Text.X0, t.Text.Y0, t.Text.X1, t.Text.Y1, world, it ); break;
			case MorphObj mo:
				{
					bool end = mo.T >= 0.5;
					var b = end ? mo.Morph.B1 : mo.Morph.B0;
					DrawImage( n, end ? mo.Morph.ImageEnd : mo.Morph.ImageStart, b[0], b[1], b[2], b[3], world, it );
					break;
				}
			case EditText et: DrawText( n, et, world, it ); break;
		}
	}

	void DrawImage( Node n, string image, double x0, double y0, double x1, double y1, FMatrix world, RenderItem it )
	{
		var p = n.Panel;
		var cx = it.Cx;
		Filter glow = null;
		float blur = 0;
		List<float[]> matrices = null;
		bool hasMatrix = false;
		if ( it.Filters != null )
		{
			foreach ( var f in it.Filters )
			{
				switch ( f.Kind )
				{
					case "cmatrix": hasMatrix = true; break;
					case "glow": if ( !f.Inner && !f.Knockout && glow == null ) glow = f; break;
					case "blur": blur = (float)Math.Max( f.BlurX, f.BlurY ) / 3f; break;
				}
			}
		}
		// colour matrices are only needed when the cached texture can't be reused
		if ( hasMatrix && !(n.Cache.Tex != null && n.Cache.Image == image && it.Masks == null && ReferenceEquals( n.Cache.LastFilters, it.Filters ) && SameCx( n.Cache.LastCx, cx )) )
			matrices = ColorMatrices( it );

		var tex = TextureFor( n.Cache, image, x0, y0, x1, y1, it, matrices );
		if ( tex != null ) texUsed[tex] = frameStamp;
		if ( n.Tex != tex )
		{
			n.Tex = tex;
			n.Image = image;
			p.Style.BackgroundImage = tex;
			p.Style.BackgroundSizeX = Length.Percent( 100 );
			p.Style.BackgroundSizeY = Length.Percent( 100 );
			p.Style.BackgroundRepeat = BackgroundRepeat.NoRepeat;
		}
		SetGeometry( n, p, world.Mul( Translate( x0, y0 ) ), x1 - x0, y1 - y0 );
		SetOpacity( n, p, (float)Math.Clamp( cx.Am, 0, 1 ) );
		float border = -1; Color borderColor = default;
		if ( glow != null && glow.Strength >= 1.5 && Math.Max( glow.BlurX, glow.BlurY ) <= 12 )
		{
			// a strong, tight glow is an outline
			var scaleAvg = Math.Sqrt( Math.Abs( world.A * world.D - world.B * world.C ) );
			border = (float)Math.Round( Math.Max( 0.5, Math.Max( glow.BlurX, glow.BlurY ) * 0.4 / Math.Max( 0.05, scaleAvg ) ), 2 );
			borderColor = new Color( (float)(glow.Color[0] / 255), (float)(glow.Color[1] / 255), (float)(glow.Color[2] / 255), (float)(glow.Color[3] / 255) );
		}
		if ( border != n.LastBorder || borderColor != n.LastBorderColor )
		{
			n.LastBorder = border; n.LastBorderColor = borderColor;
			if ( border > 0 ) { p.Style.FilterBorderWidth = border; p.Style.FilterBorderColor = borderColor; }
			else { p.Style.FilterBorderWidth = null; p.Style.FilterBorderColor = null; }
		}
		if ( blur != n.LastBlur ) { n.LastBlur = blur; p.Style.FilterBlur = blur > 0 ? blur : null; }
	}

	/// <summary>Size + transform, written only when they change (or the UI scale changes).</summary>
	void SetGeometry( Node n, Panel p, FMatrix m, double w, double h )
	{
		if ( n.HasGeo && n.LastScale == scale && n.LastW == w && n.LastH == h &&
			n.LastM.A == m.A && n.LastM.B == m.B && n.LastM.C == m.C && n.LastM.D == m.D && n.LastM.Tx == m.Tx && n.LastM.Ty == m.Ty ) return;
		if ( !n.HasGeo || n.LastW != w || n.LastH != h ) { p.Style.Width = (float)w; p.Style.Height = (float)h; }
		n.HasGeo = true; n.LastScale = scale; n.LastW = w; n.LastH = h; n.LastM = m;
		p.Style.Transform = MakeTransform( m );
	}

	static void SetOpacity( Node n, Panel p, float o )
	{
		if ( n.LastOpacity == o ) return;
		n.LastOpacity = o;
		p.Style.Opacity = o;
	}

	/// <summary>Flash masks: keep only the parts of the image covered by (any of) the mask shapes.</summary>
	void ApplyMasks( byte[] px, int w, int h, List<MaskRef> masks, double ix0, double iy0, double ix1, double iy1 )
	{
		var cover = new byte[w * h];
		double sx = (ix1 - ix0) / w, sy = (iy1 - iy0) / h;
		foreach ( var mr in masks )
		{
			var (mw, mh, mpx) = Raw( mr.Image );
			if ( mpx == null ) continue;
			double kx = mw / Math.Max( 1e-6, mr.X1 - mr.X0 ), ky = mh / Math.Max( 1e-6, mr.Y1 - mr.Y0 );
			var R = mr.R;
			for ( int y = 0; y < h; y++ )
			{
				double ly = iy0 + (y + 0.5) * sy;
				for ( int x = 0; x < w; x++ )
				{
					int i = y * w + x;
					if ( cover[i] == 255 || px[i * 4 + 3] == 0 ) continue;
					double lx = ix0 + (x + 0.5) * sx;
					double mx = R.A * lx + R.C * ly + R.Tx, my = R.B * lx + R.D * ly + R.Ty;
					int u = (int)((mx - mr.X0) * kx), v = (int)((my - mr.Y0) * ky);
					if ( u < 0 || v < 0 || u >= mw || v >= mh ) continue;
					byte a = mpx[(v * mw + u) * 4 + 3];
					if ( a > cover[i] ) cover[i] = a;
				}
			}
		}
		for ( int i = 0; i < cover.Length; i++ )
			px[i * 4 + 3] = (byte)(px[i * 4 + 3] * cover[i] / 255);
	}

	List<MaskRef> MaskRefs( RenderItem it )
	{
		if ( it.Masks == null || it.Masks.Count == 0 ) return null;
		var list = new List<MaskRef>();
		foreach ( var m in it.Masks )
		{
			string img; double[] b;
			switch ( m.Obj )
			{
				case ShapeObj s: img = s.Shape.Image; b = new[] { s.Shape.X0, s.Shape.Y0, s.Shape.X1, s.Shape.Y1 }; break;
				case MorphObj mo:
					{
						bool end = mo.T >= 0.5;
						img = end ? mo.Morph.ImageEnd : mo.Morph.ImageStart;
						b = end ? mo.Morph.B1 : mo.Morph.B0;
						break;
					}
				default: return null; // can't express it: fall back to the rectangle clip
			}
			if ( img == null ) return null;
			list.Add( new MaskRef { Image = img, X0 = b[0], Y0 = b[1], X1 = b[2], Y1 = b[3], R = m.World.Inverse().Mul( it.World ) } );
		}
		return list;
	}

	/// <summary>Remembers the last colour/mask variant an object used, so unchanged objects skip the lookup.</summary>
	sealed class TexCache
	{
		public Texture Tex;
		public string Image;
		public ColorTransform LastCx;
		public List<Filter> LastFilters;
		public List<MaskRef> LastMasks;
		public int MaskChurn, ColorChurn;
		public bool NoMask;
	}

	const int PixelBudgetPerFrame = 2_500_000;
	int pixelBudget = PixelBudgetPerFrame;

	Texture TextureFor( TexCache c, string image, double x0, double y0, double x1, double y1, RenderItem it, List<float[]> matrices )
	{
		if ( c.Tex != null && c.Image == image && it.Masks == null && ReferenceEquals( c.LastFilters, it.Filters ) && SameCx( c.LastCx, it.Cx ) ) { c.ColorChurn = 0; return c.Tex; }
		// a mask that moves every frame (scrolling, animated wipes) would need a new texture each frame: use the rectangle clip for those
		var masks = c.NoMask ? null : MaskRefs( it );
		// masked but nothing moved: same texture as last frame
		if ( masks != null && c.Tex != null && c.Image == image && ReferenceEquals( c.LastFilters, it.Filters ) && SameCx( c.LastCx, it.Cx ) && SameMasks( c.LastMasks, masks ) ) { c.MaskChurn = 0; return c.Tex; }
		// colour that animates every frame (flashes, fades to a tint): snap to coarse steps so a handful of textures get reused
		var cx = it.Cx;
		if ( c.Image == image && !SameCx( c.LastCx, cx ) ) c.ColorChurn++; else c.ColorChurn = 0;
		if ( c.ColorChurn > 6 ) cx = Coarse( cx );
		var tex = Variant( image, cx, matrices, masks, x0, y0, x1, y1 );
		if ( tex == null ) return c.Tex ?? Tex( image );
		if ( masks != null )
		{
			c.MaskChurn = tex != c.Tex && c.Tex != null ? c.MaskChurn + 1 : 0;
			if ( c.MaskChurn > 20 ) c.NoMask = true;
		}
		c.Tex = tex; c.Image = image; c.LastCx = it.Cx; c.LastFilters = it.Filters; c.LastMasks = masks;
		return tex;
	}

	static ColorTransform Coarse( ColorTransform c )
	{
		static double M( double v ) => Math.Round( v * 8 ) / 8;
		static double A( double v ) => Math.Round( v / 32 ) * 32;
		return new ColorTransform { Rm = M( c.Rm ), Gm = M( c.Gm ), Bm = M( c.Bm ), Am = c.Am, Ra = A( c.Ra ), Ga = A( c.Ga ), Ba = A( c.Ba ), Aa = A( c.Aa ) };
	}

	static List<float[]> ColorMatrices( RenderItem it )
	{
		List<float[]> matrices = null;
		if ( it.Filters != null )
			foreach ( var f in it.Filters )
				if ( f.Kind == "cmatrix" && f.ColorMatrix != null && f.ColorMatrix.Length >= 20 ) (matrices ??= new()).Add( ToFloats( f.ColorMatrix ) );
		return matrices;
	}

	static bool SameMasks( List<MaskRef> a, List<MaskRef> b )
	{
		if ( a == null || b == null || a.Count != b.Count ) return false;
		for ( int i = 0; i < a.Count; i++ )
		{
			var x = a[i]; var y = b[i];
			if ( x.Image != y.Image ) return false;
			if ( Math.Abs( x.R.A - y.R.A ) > 0.001 || Math.Abs( x.R.B - y.R.B ) > 0.001 || Math.Abs( x.R.C - y.R.C ) > 0.001 || Math.Abs( x.R.D - y.R.D ) > 0.001 ) return false;
			if ( Math.Abs( x.R.Tx - y.R.Tx ) > 0.1 || Math.Abs( x.R.Ty - y.R.Ty ) > 0.1 ) return false;
		}
		return true;
	}

	static bool SameCx( ColorTransform a, ColorTransform b ) =>
		a.Rm == b.Rm && a.Gm == b.Gm && a.Bm == b.Bm && a.Ra == b.Ra && a.Ga == b.Ga && a.Ba == b.Ba && a.Aa == b.Aa;

	static float[] ToFloats( double[] a )
	{
		var r = new float[a.Length];
		for ( int i = 0; i < a.Length; i++ ) r[i] = (float)a[i];
		return r;
	}

	void DrawText( Node n, EditText et, FMatrix world, RenderItem it )
	{
		var d = et.Edit;
		var p = n.Panel;
		const double gutter = 2;
		double w = d.X1 - d.X0 - gutter * 2, h = d.Y1 - d.Y0 - gutter * 2;

		if ( n.Entry != null )
		{
			if ( n.TextVersion != et.Version && !n.Entry.HasFocus )
			{
				n.Entry.Text = et.Text;
				n.TextVersion = et.Version;
			}
			if ( n.Entry.OnTextEdited == null )
				n.Entry.OnTextEdited = s => { et.UserInput( s ); n.TextVersion = et.Version; };
		}
		else if ( n.TextVersion != et.Version || n.Text == null )
		{
			// a Label child holds the text
			if ( p.ChildrenCount == 0 ) new Label { Parent = p };
			var lbl = p.GetChild( 0 ) as Label;
			if ( lbl != null ) lbl.Text = d.Password ? new string( '*', et.Text.Length ) : et.Text;
			n.TextVersion = et.Version;
			n.Text = et.Text;
		}

		var col = et.TextColor;
		var cx = it.Cx;
		float r = (float)Math.Clamp( (col[0] * cx.Rm + cx.Ra) / 255, 0, 1 ), g = (float)Math.Clamp( (col[1] * cx.Gm + cx.Ga) / 255, 0, 1 ), b = (float)Math.Clamp( (col[2] * cx.Bm + cx.Ba) / 255, 0, 1 );
		string key = $"{d.FontId}|{d.Size}|{r},{g},{b}|{d.Align}|{d.Multiline}{d.WordWrap}";
		if ( n.StyleKey != key )
		{
			n.StyleKey = key;
			var family = Library.Fonts.TryGetValue( d.FontId, out var fd ) ? fd.Family : "Arial";
			Panel target = (Panel)n.Entry ?? (p.ChildrenCount > 0 ? p.GetChild( 0 ) : p);
			foreach ( var s in new[] { p.Style, target.Style } )
			{
				s.FontFamily = family;
				s.FontSize = (float)d.Size;
				s.FontColor = new Color( r, g, b, 1 );
				s.TextAlign = d.Align switch { 1 => TextAlign.Right, 2 => TextAlign.Center, _ => TextAlign.Left };
				s.WhiteSpace = d.Multiline || d.WordWrap ? WhiteSpace.PreLine : WhiteSpace.NoWrap;
			}
			if ( target != p )
			{
				target.Style.Width = Length.Percent( 100 );
				target.Style.Position = PositionMode.Absolute;
				target.Style.Left = 0; target.Style.Top = 0;
			}
			if ( n.Entry != null ) { n.Entry.Style.BackgroundColor = null; }
		}
		Filter glow = null;
		if ( it.Filters != null ) foreach ( var f in it.Filters ) if ( f.Kind == "glow" && !f.Inner ) { glow = f; break; }
		Panel tp = (Panel)n.Entry ?? (p.ChildrenCount > 0 ? p.GetChild( 0 ) : p);
		float stroke = -1; Color strokeColor = default;
		if ( glow != null )
		{
			stroke = (float)Math.Clamp( Math.Max( glow.BlurX, glow.BlurY ) * Math.Min( glow.Strength, 4 ) * 0.35, 1, 6 );
			strokeColor = new Color( (float)(glow.Color[0] / 255), (float)(glow.Color[1] / 255), (float)(glow.Color[2] / 255), (float)(glow.Color[3] / 255) );
		}
		if ( stroke != n.LastStroke || strokeColor != n.LastStrokeColor )
		{
			n.LastStroke = stroke; n.LastStrokeColor = strokeColor;
			if ( stroke > 0 ) { tp.Style.TextStrokeWidth = stroke; tp.Style.TextStrokeColor = strokeColor; }
			else { tp.Style.TextStrokeWidth = null; tp.Style.TextStrokeColor = null; }
		}

		SetGeometry( n, p, world.Mul( Translate( d.X0 + gutter, d.Y0 + gutter ) ), w, h );
		SetOpacity( n, p, (float)Math.Clamp( cx.Am + cx.Aa / 255.0, 0, 1 ) );
	}
}