805 results

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Editor;
using Sandbox;

namespace HammerTextureBrowser;

[Dock( "Editor", "OG Texture Browser", "texture" )]
public sealed class HammerTextureBrowserDock : Widget
{
	private static readonly Regex QuotedMaterialProperty = new( "\"(?<key>[^\"]+)\"\\s+\"(?<value>[^\"]+)\"", RegexOptions.Compiled | RegexOptions.CultureInvariant );
	private static readonly string[] PrimaryTextureKeys =
	[
		"TextureColor",
		"TextureBaseColor",
		"TextureAlbedo",
		"AlbedoTexture",
		"BaseTexture",
		"BaseColorTexture",
		"g_tColor",
		"g_tBaseColor",
		"g_tAlbedo"
	];

	private readonly HammerTextureList TextureList;
	private readonly ComboBox SizeCombo;
	private readonly LineEdit FilterEdit;
	private readonly ComboBox KeywordsCombo;
	private readonly Checkbox OnlyUsedTextures;
	private readonly Label SelectedTextureLabel;
	private readonly Label TextureSizeLabel;
	private readonly Label CountLabel;
	private readonly Button MarkButton;
	private readonly Button ReplaceButton;
	private readonly Button ReloadButton;
	private readonly Button OpenSourceButton;

	private List<Asset> AllMaterials = new();
	private Asset SelectedMaterial;
	private string KeywordFilter = string.Empty;
	private int PreviewSize = 128;

	public HammerTextureBrowserDock( Widget parent ) : base( parent )
	{
		MinimumSize = new( 420, 320 );
		WindowTitle = "OG Texture Browser";
		SetWindowIcon( "texture" );

		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Spacing = 0;

		TextureList = Layout.Add( new HammerTextureList( this ), 1 );
		TextureList.OnTextureSelected = SelectMaterial;
		TextureList.OnTextureActivated = SelectMaterial;
		TextureList.OnOpenInEditor = OpenMaterialSource;
		TextureList.OnOpenInAssetBrowser = OpenInAssetBrowser;

		var bottom = Layout.Add( new Widget( this ) );
		bottom.Layout = Layout.Column();
		bottom.Layout.Margin = 4;
		bottom.Layout.Spacing = 3;
		bottom.FixedHeight = Theme.RowHeight * 2 + 12;
		bottom.OnPaintOverride = () =>
		{
			Paint.ClearPen();
			Paint.SetBrush( Theme.ControlBackground );
			Paint.DrawRect( bottom.LocalRect );
			return false;
		};

		var topRow = bottom.Layout.AddRow();
		topRow.Spacing = 5;

		var sizeBlock = topRow.Add( new Widget( this ) );
		sizeBlock.FixedWidth = 138;
		sizeBlock.MinimumWidth = 138;
		sizeBlock.MaximumWidth = 138;
		sizeBlock.Layout = Layout.Row();
		sizeBlock.Layout.Margin = 0;
		sizeBlock.Layout.Spacing = 5;

		AddSmallLabel( sizeBlock.Layout, "Size:" );
		SizeCombo = sizeBlock.Layout.Add( new ComboBox( sizeBlock ) );
		SizeCombo.FixedWidth = 88;
		SizeCombo.MinimumWidth = 88;
		SizeCombo.MaximumWidth = 88;
		SizeCombo.AddItem( "32x32", null, () => SetPreviewSize( 32 ) );
		SizeCombo.AddItem( "64x64", null, () => SetPreviewSize( 64 ) );
		SizeCombo.AddItem( "128x128", null, () => SetPreviewSize( 128 ) );
		SizeCombo.AddItem( "256x256", null, () => SetPreviewSize( 256 ) );
		SizeCombo.AddItem( "1:1", null, () => SetPreviewSize( 128 ) );
		SizeCombo.CurrentIndex = 2;
		StyleBottomInput( SizeCombo, 24 );

		AddSmallLabel( topRow, "Filter:", 58 );
		FilterEdit = topRow.Add( new LineEdit( this ) );
		FilterEdit.FixedWidth = 176;
		FilterEdit.MinimumWidth = 176;
		FilterEdit.MaximumWidth = 176;
		FilterEdit.PlaceholderText = "texture name";
		FilterEdit.TextChanged += _ => RefreshList();
		StyleBottomInput( FilterEdit );

		SelectedTextureLabel = topRow.Add( new Label( this ) );
		SelectedTextureLabel.MinimumWidth = 180;
		SelectedTextureLabel.Alignment = TextFlag.LeftCenter;
		SelectedTextureLabel.Text = "";

		OpenSourceButton = topRow.Add( new Button( "Open Source" ) );
		OpenSourceButton.FixedWidth = 96;
		OpenSourceButton.Clicked = OpenSelectedSource;

		var bottomRow = bottom.Layout.AddRow();
		bottomRow.Spacing = 5;
		OnlyUsedTextures = bottomRow.Add( new Checkbox( "Only used textures" ) );
		OnlyUsedTextures.FixedWidth = 138;
		OnlyUsedTextures.StateChanged += _ => RefreshList();

		AddSmallLabel( bottomRow, "Keywords:", 58 );
		KeywordsCombo = bottomRow.Add( new ComboBox( this ) );
		KeywordsCombo.FixedWidth = 176;
		KeywordsCombo.MinimumWidth = 176;
		KeywordsCombo.MaximumWidth = 176;
		KeywordsCombo.AddItem( "All Keywords", null, () => SetKeywordFilter( string.Empty ) );
		StyleBottomInput( KeywordsCombo, 24 );

		MarkButton = bottomRow.Add( new Button( "Mark" ) );
		MarkButton.FixedWidth = 76;
		MarkButton.Clicked = MarkSelectedMaterial;

		ReplaceButton = bottomRow.Add( new Button( "Replace" ) );
		ReplaceButton.FixedWidth = 76;
		ReplaceButton.Clicked = ReplaceSelectedMaterial;

		ReloadButton = bottomRow.Add( new Button( "Reload" ) );
		ReloadButton.FixedWidth = 76;
		ReloadButton.Clicked = Reload;

		TextureSizeLabel = bottomRow.Add( new Label( this ) );
		TextureSizeLabel.MinimumWidth = 0;
		TextureSizeLabel.MaximumWidth = 68;
		TextureSizeLabel.Alignment = TextFlag.RightCenter;

		bottomRow.AddStretchCell( 1 );

		CountLabel = bottomRow.Add( new Label( this ) );
		CountLabel.MinimumWidth = 0;
		CountLabel.MaximumWidth = 96;
		CountLabel.Alignment = TextFlag.RightCenter;

		ReloadMaterials();
		SetPreviewSize( PreviewSize );
		RefreshList();
		UpdateSelectedMaterialUi();
	}

	private void Reload()
	{
		ReloadMaterials();
		RefreshList();
		UpdateSelectedMaterialUi();
	}

	private static void AddSmallLabel( Layout row, string text, int fixedWidth = 0 )
	{
		var label = row.Add( new Label( text ) );
		label.Alignment = fixedWidth > 0 ? TextFlag.RightCenter : TextFlag.LeftCenter;
		label.FixedWidth = fixedWidth > 0 ? fixedWidth : text.Length * 6 + 4;
	}

	private static void StyleBottomInput( Widget widget, int fixedHeight = 22 )
	{
		widget.FixedHeight = fixedHeight;
		widget.SetStyles(
			"background-color: #2d3136; " +
			"border: 1px solid #555c64; " +
			"border-radius: 2px; " +
			"color: #f2f2f2; " +
			"padding-top: 0px; " +
			"padding-bottom: 0px; " +
			"padding-left: 5px; " +
			"padding-right: 5px;" );
	}

	private void ReloadMaterials()
	{
		var menuPath = EditorUtility.Projects.GetAll()
			.FirstOrDefault( x => x.Config.Ident == "menu" )
			?.GetAssetsPath()
			.NormalizeFilename( false );

		AllMaterials = AssetSystem.All
			.Where( IsBrowsableMaterial )
			.Where( x => !IsMenuAsset( x, menuPath ) )
			.OrderBy( TextureDisplayName, StringComparer.OrdinalIgnoreCase )
			.ToList();

		RebuildKeywords();
	}

	private static bool IsBrowsableMaterial( Asset asset )
	{
		if ( asset is null )
			return false;

		if ( asset.AssetType != AssetType.Material )
			return false;

		if ( asset.AbsolutePath?.Contains( ".sbox/cloud/", StringComparison.OrdinalIgnoreCase ) ?? false )
			return false;

		return true;
	}

	private static bool IsMenuAsset( Asset asset, string menuPath )
	{
		if ( string.IsNullOrEmpty( menuPath ) )
			return false;

		return asset.AbsolutePath?.NormalizeFilename( false ).StartsWith( menuPath, StringComparison.OrdinalIgnoreCase ) ?? false;
	}

	private void RebuildKeywords()
	{
		var tags = AllMaterials
			.SelectMany( x => x.Tags )
			.Where( x => !string.IsNullOrWhiteSpace( x ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.OrderBy( x => x, StringComparer.OrdinalIgnoreCase )
			.ToList();

		KeywordsCombo.Clear();
		KeywordsCombo.AddItem( "All Keywords", null, () => SetKeywordFilter( string.Empty ) );

		foreach ( var tag in tags )
		{
			var tagValue = tag;
			KeywordsCombo.AddItem( tagValue, null, () => SetKeywordFilter( tagValue ) );
		}

		KeywordsCombo.CurrentIndex = 0;
	}

	private void SetKeywordFilter( string tag )
	{
		KeywordFilter = tag ?? string.Empty;
		RefreshList();
	}

	private void SetPreviewSize( int size )
	{
		PreviewSize = size;
		TextureList.SetPreviewSize( PreviewSize );
		RefreshList();
	}

	private void RefreshList()
	{
		if ( TextureList is null )
			return;

		IEnumerable<Asset> materials = AllMaterials;

		var query = FilterEdit?.Text ?? string.Empty;
		if ( !string.IsNullOrWhiteSpace( query ) )
		{
			var parts = query.Split( ' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries );
			materials = materials.Where( x => MatchesQuery( x, parts ) );
		}

		if ( !string.IsNullOrWhiteSpace( KeywordFilter ) )
		{
			materials = materials.Where( x => x.Tags.Any( tag => tag.Equals( KeywordFilter, StringComparison.OrdinalIgnoreCase ) ) );
		}

		if ( OnlyUsedTextures?.Value ?? false )
		{
			var used = HammerMaterialSelection.GetUsedMaterialKeys();
			materials = materials.Where( x => used.Contains( x.RelativePath ) || used.Contains( x.AbsolutePath ) || used.Contains( TextureDisplayName( x ) ) );
		}

		var entries = materials
			.OrderBy( TextureDisplayName, StringComparer.OrdinalIgnoreCase )
			.Select( x => new HammerTextureEntry( x ) )
			.ToList();

		TextureList.SetItems( entries );
		if ( CountLabel is not null )
		{
			CountLabel.Text = $"{entries.Count:n0} textures";
			CountLabel.Update();
		}

		if ( SelectedMaterial is not null && entries.All( x => x.Asset != SelectedMaterial ) )
			TextureList.UnselectAll();
	}

	private static bool MatchesQuery( Asset asset, IEnumerable<string> parts )
	{
		var name = TextureDisplayName( asset );
		var type = asset.AssetType?.FriendlyName ?? string.Empty;
		IEnumerable<string> tags = asset.Tags;

		foreach ( var part in parts )
		{
			var search = part;
			var negated = false;
			if ( search.StartsWith( "-" ) )
			{
				search = search[1..];
				negated = true;
			}

			var matched =
				name.Contains( search, StringComparison.OrdinalIgnoreCase ) ||
				type.Contains( search, StringComparison.OrdinalIgnoreCase ) ||
				tags.Any( x => x.Contains( search, StringComparison.OrdinalIgnoreCase ) );

			if ( !negated && !matched )
				return false;

			if ( negated && matched )
				return false;
		}

		return true;
	}

	private void SelectMaterial( Asset material )
	{
		if ( material?.AssetType != AssetType.Material )
			return;

		SelectedMaterial = material;
		EditorUtility.InspectorObject = material;
		HammerMaterialSelection.SetCurrentMaterial( material );
		UpdateSelectedMaterialUi();
	}

	private void UpdateSelectedMaterialUi()
	{
		var hasMaterial = SelectedMaterial is not null;

		SelectedTextureLabel.Text = hasMaterial ? TextureDisplayName( SelectedMaterial ) : "";
		SelectedTextureLabel.ToolTip = SelectedMaterial?.RelativePath ?? string.Empty;

		TextureSizeLabel.Text = GetTextureSizeText( SelectedMaterial );
		TextureSizeLabel.ToolTip = SelectedMaterial?.AbsolutePath ?? string.Empty;

		MarkButton.Enabled = hasMaterial;
		ReplaceButton.Enabled = hasMaterial;
		OpenSourceButton.Enabled = hasMaterial;

		SelectedTextureLabel.Update();
		TextureSizeLabel.Update();
		MarkButton.Update();
		ReplaceButton.Update();
		OpenSourceButton.Update();
	}

	private static string GetTextureSizeText( Asset asset )
	{
		if ( asset is null )
			return "";

		var texturePath = GetPrimaryMaterialTexturePath( asset );
		if ( string.IsNullOrWhiteSpace( texturePath ) )
			return "";

		try
		{
			var texture = Texture.Load( texturePath );
			if ( texture is null || !texture.IsValid() || texture.Width <= 0 || texture.Height <= 0 )
				return "";

			return $"{texture.Width}x{texture.Height}";
		}
		catch
		{
			return "";
		}
	}

	private static string GetPrimaryMaterialTexturePath( Asset asset )
	{
		if ( string.IsNullOrWhiteSpace( asset?.AbsolutePath ) || !File.Exists( asset.AbsolutePath ) )
			return null;

		try
		{
			var properties = QuotedMaterialProperty.Matches( File.ReadAllText( asset.AbsolutePath ) )
				.Select( x => (Key: x.Groups["key"].Value, Value: NormalizeTexturePath( x.Groups["value"].Value )) )
				.Where( x => IsTexturePath( x.Value ) )
				.ToList();

			foreach ( var key in PrimaryTextureKeys )
			{
				var match = properties.FirstOrDefault( x => x.Key.Equals( key, StringComparison.OrdinalIgnoreCase ) );
				if ( !string.IsNullOrWhiteSpace( match.Value ) )
					return match.Value;
			}

			var colorMatch = properties.FirstOrDefault( x =>
				(x.Key.Contains( "color", StringComparison.OrdinalIgnoreCase ) ||
				x.Key.Contains( "albedo", StringComparison.OrdinalIgnoreCase ) ||
				x.Key.Contains( "base", StringComparison.OrdinalIgnoreCase )) &&
				!x.Key.Contains( "tint", StringComparison.OrdinalIgnoreCase ) );

			if ( !string.IsNullOrWhiteSpace( colorMatch.Value ) )
				return colorMatch.Value;

			return properties.FirstOrDefault().Value;
		}
		catch
		{
			return null;
		}
	}

	private static string NormalizeTexturePath( string path )
	{
		return path?.Trim().Replace( '\\', '/' );
	}

	private static bool IsTexturePath( string path )
	{
		var extension = Path.GetExtension( path );
		return extension.Equals( ".vtex", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".tga", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".png", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".jpg", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".jpeg", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".tif", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".tiff", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".psd", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".exr", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".hdr", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".bmp", StringComparison.OrdinalIgnoreCase ) ||
			extension.Equals( ".webp", StringComparison.OrdinalIgnoreCase );
	}

	private void MarkSelectedMaterial()
	{
		if ( SelectedMaterial is null )
			return;

		HammerMaterialSelection.SelectFacesUsingMaterial( SelectedMaterial );
	}

	private void ReplaceSelectedMaterial()
	{
		if ( SelectedMaterial is null )
			return;

		HammerMaterialSelection.AssignAssetToSelection( SelectedMaterial );
	}

	private void OpenSelectedSource()
	{
		OpenMaterialSource( SelectedMaterial );
	}

	private void OpenMaterialSource( Asset asset )
	{
		if ( asset is null )
			return;

		if ( asset.CanOpenInEditor )
		{
			asset.OpenInEditor();
			return;
		}

		EditorUtility.OpenFileFolder( asset.AbsolutePath );
	}

	private void OpenInAssetBrowser( Asset asset )
	{
		if ( asset is null )
			return;

		LocalAssetBrowser.OpenTo( asset, true );
	}

	internal static string TextureDisplayName( Asset asset )
	{
		var path = asset?.RelativePath ?? asset?.Name ?? "";
		path = path.NormalizeFilename( false, false );

		if ( path.StartsWith( "materials/", StringComparison.OrdinalIgnoreCase ) )
			path = path["materials/".Length..];

		var extension = Path.GetExtension( path );
		if ( !string.IsNullOrEmpty( extension ) )
			path = path[..^extension.Length];

		return path;
	}
}

internal sealed class HammerTextureList : ListView
{
	private int PreviewSize = 128;

	public Action<Asset> OnTextureSelected;
	public Action<Asset> OnTextureActivated;
	public Action<Asset> OnOpenInEditor;
	public Action<Asset> OnOpenInAssetBrowser;

	public HammerTextureList( Widget parent ) : base( parent )
	{
		MultiSelect = false;
		FocusMode = FocusMode.TabOrClick;
		Margin = 2;
		ItemSpacing = 2;
		ItemPaint = PaintTextureItem;
		ItemSelected = item => SelectTexture( item, false );
		ItemActivated = item => SelectTexture( item, true );
		ItemDrag = StartTextureDrag;
		ItemContextMenu = OpenTextureContextMenu;
		ItemScrollEnter = item => (item as HammerTextureEntry)?.OnScrollEnter();
		ItemScrollExit = item => (item as HammerTextureEntry)?.OnScrollExit();
		OnPaintOverride = PaintBackground;
	}

	public void SetPreviewSize( int previewSize )
	{
		PreviewSize = previewSize;
		ItemSize = new Vector2( PreviewSize + 4, PreviewSize + 18 );
		Update();
	}

	public void SetItems( IEnumerable<HammerTextureEntry> entries )
	{
		base.SetItems( entries.Cast<object>() );
		Update();
	}

	private bool PaintBackground()
	{
		Paint.ClearPen();
		Paint.SetBrush( Color.Black );
		Paint.DrawRect( LocalRect );
		return false;
	}

	private void SelectTexture( object item, bool activated )
	{
		if ( item is not HammerTextureEntry entry )
			return;

		if ( activated )
			OnTextureActivated?.Invoke( entry.Asset );
		else
			OnTextureSelected?.Invoke( entry.Asset );
	}

	private bool StartTextureDrag( object item )
	{
		if ( item is not HammerTextureEntry entry || entry.Asset is null )
			return false;

		SelectItem( entry );
		SelectTexture( entry, false );

		var drag = new Drag( this );
		drag.Data.Object = entry.Asset;
		drag.Data.Text = entry.Asset.RelativePath;
		drag.Data.Url = new Uri( "file:///" + entry.Asset.AbsolutePath );

		foreach ( var selected in SelectedItems.OfType<HammerTextureEntry>() )
		{
			if ( selected == entry || selected.Asset is null )
				continue;

			drag.Data.Text += "\n" + selected.Asset.RelativePath;
		}

		drag.Execute();
		return true;
	}

	private void OpenTextureContextMenu( object item )
	{
		if ( item is not HammerTextureEntry entry )
			return;

		SelectItem( entry );
		SelectTexture( entry, false );

		var menu = new ContextMenu( this );
		menu.AddOption( "Open in Editor", "edit", () => OnOpenInEditor?.Invoke( entry.Asset ) )
			.Enabled = entry.Asset is not null;
		menu.AddOption( "Open in Asset Browser", "search", () => OnOpenInAssetBrowser?.Invoke( entry.Asset ) )
			.Enabled = entry.Asset is not null;
		menu.OpenAtCursor( false );
	}

	private void PaintTextureItem( VirtualWidget item )
	{
		if ( item.Object is not HammerTextureEntry entry )
			return;

		var rect = item.Rect.Shrink( 1 );
		var active = Paint.HasPressed;
		var selected = Paint.HasSelected || Paint.HasPressed;
		var hover = !selected && Paint.HasMouseOver;

		Paint.ClearPen();
		Paint.SetBrush( Color.Black );
		Paint.DrawRect( rect );

		if ( selected )
		{
			Paint.SetPen( Theme.Primary, 2 );
			Paint.ClearBrush();
			Paint.DrawRect( rect.Grow( -1 ) );
		}
		else if ( hover )
		{
			Paint.SetPen( Theme.ControlBackground.Lighten( 0.35f ), 1 );
			Paint.ClearBrush();
			Paint.DrawRect( rect.Grow( -1 ) );
		}

		var previewRect = rect;
		previewRect.Width = PreviewSize;
		previewRect.Height = PreviewSize;
		previewRect.Left += MathF.Max( 0, (rect.Width - PreviewSize) * 0.5f );

		entry.DrawPreview( previewRect, active );

		var nameRect = previewRect;
		nameRect.Top = previewRect.Bottom;
		nameRect.Height = 12;

		Paint.ClearPen();
		Paint.SetBrush( selected ? Theme.Primary.Lighten( active ? -0.1f : 0.05f ) : new Color( 0.0f, 0.05f, 0.85f ) );
		Paint.DrawRect( nameRect );

		Paint.SetDefaultFont( 6 );
		Paint.SetPen( Color.White );
		var label = Paint.GetElidedText( entry.DisplayName, nameRect.Width - 4, ElideMode.Middle );
		Paint.DrawText( nameRect.Shrink( 2, 0 ), label, TextFlag.LeftCenter | TextFlag.SingleLine );
	}
}

internal sealed class HammerTextureEntry
{
	public Asset Asset { get; }
	public string DisplayName { get; }

	public HammerTextureEntry( Asset asset )
	{
		Asset = asset;
		DisplayName = HammerTextureBrowserDock.TextureDisplayName( asset );
	}

	public void OnScrollEnter()
	{
		if ( Asset is null || Asset.HasCachedThumbnail )
			return;

		EditorEvent.Register( this );
		Asset.GetAssetThumb( true );
	}

	public void OnScrollExit()
	{
		if ( Asset is null )
			return;

		Asset.CancelThumbBuild();
		EditorEvent.Unregister( this );
	}

	public void DrawPreview( Rect rect, bool active )
	{
		Paint.ClearPen();
		Paint.SetBrush( active ? Color.Black.Lighten( 0.08f ) : Color.Black );
		Paint.DrawRect( rect );

		var thumb = Asset?.GetAssetThumb( true ) ?? AssetType.Material?.Icon128;
		if ( thumb is null )
			return;

		var drawRect = rect;
		var scale = MathF.Min( rect.Width / thumb.Width, rect.Height / thumb.Height );
		if ( scale > 0 && float.IsFinite( scale ) )
		{
			drawRect.Width = MathF.Min( rect.Width, thumb.Width * scale );
			drawRect.Height = MathF.Min( rect.Height, thumb.Height * scale );
			drawRect.Left = rect.Left + (rect.Width - drawRect.Width) * 0.5f;
			drawRect.Top = rect.Top + (rect.Height - drawRect.Height) * 0.5f;
		}

		Paint.BilinearFiltering = true;
		Paint.Draw( drawRect, thumb );
		Paint.BilinearFiltering = false;
	}
}

internal static class HammerMaterialSelection
{
	private const BindingFlags StaticFlags = BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
	private const BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;

	private static Type HammerType;
	private static bool HasResolvedHammerType;

	public static void SetCurrentMaterial( Asset asset ) => InvokeHammerAssetMethod( "SetCurrentMaterial", asset );
	public static void SelectFacesUsingMaterial( Asset asset ) => InvokeHammerAssetMethod( "SelectFacesUsingMaterial", asset );
	public static void AssignAssetToSelection( Asset asset ) => InvokeHammerAssetMethod( "AssignAssetToSelection", asset );

	public static HashSet<string> GetUsedMaterialKeys()
	{
		var keys = new HashSet<string>( StringComparer.OrdinalIgnoreCase );

		try
		{
			var activeMap = GetHammerType()?.GetProperty( "ActiveMap", StaticFlags )?.GetValue( null );
			if ( activeMap is null )
				return keys;

			var world = activeMap.GetType().GetProperty( "World", InstanceFlags )?.GetValue( activeMap );
			if ( world is null )
				return keys;

			CollectUsedMaterialKeys( world, keys );
		}
		catch
		{
			// Hammer throws when no map is open; in that case "Only used textures" should simply show none.
			return keys;
		}

		return keys;
	}

	private static void CollectUsedMaterialKeys( object node, HashSet<string> keys )
	{
		if ( node is null )
			return;

		var materialMethod = node.GetType().GetMethod( "GetFaceMaterialAssets", InstanceFlags );
		if ( materialMethod is not null && materialMethod.Invoke( node, null ) is IEnumerable materials )
		{
			foreach ( var item in materials )
			{
				if ( item is not Asset asset )
					continue;

				keys.Add( asset.RelativePath );
				keys.Add( asset.AbsolutePath );
				keys.Add( HammerTextureBrowserDock.TextureDisplayName( asset ) );
			}
		}

		var children = node.GetType().GetProperty( "Children", InstanceFlags )?.GetValue( node ) as IEnumerable;
		if ( children is null )
			return;

		foreach ( var child in children )
		{
			CollectUsedMaterialKeys( child, keys );
		}
	}

	private static void InvokeHammerAssetMethod( string methodName, Asset asset )
	{
		if ( asset is null )
			return;

		var method = GetHammerType()?.GetMethod( methodName, StaticFlags, binder: null, types: new[] { typeof( Asset ) }, modifiers: null );
		if ( method is null )
			return;

		try
		{
			method.Invoke( null, new object[] { asset } );
		}
		catch
		{
			// This dock still works as a browser if Hammer is not the active editor surface.
		}
	}

	private static Type GetHammerType()
	{
		if ( HasResolvedHammerType )
			return HammerType;

		HasResolvedHammerType = true;

		foreach ( var assembly in AppDomain.CurrentDomain.GetAssemblies() )
		{
			HammerType = assembly.GetType( "Editor.MapEditor.Hammer" );
			if ( HammerType is not null )
				break;
		}

		return HammerType;
	}
}
global using Sandbox;
global using Editor;
global using System.Collections.Generic;
global using System.Linq;
global using Dreams.UltimateLightManager;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Editor;
using Sandbox;

namespace Sandbox.AssetBrowserAddon;

/// <summary>
/// Dialog that captures the settings for a new custom Asset Browser location.
/// </summary>
public sealed class LocationEditor : Dialog
{
    private readonly Action<CustomLocationDefinition> _onConfirm;
    private readonly CustomLocationDefinition _initialDefinition;
    private readonly LineEdit _nameInput;
    private readonly LineEdit _iconInput;
    private readonly LineEdit _includeInput;
    private readonly LineEdit _excludeInput;
    private readonly AssetTypeSelector _assetTypeSelector;
    private readonly ToggleSwitch _projectOnlyToggle;

    public LocationEditor(Action<CustomLocationDefinition> onConfirm, CustomLocationDefinition initialDefinition = null)
    {
        _onConfirm = onConfirm;
        _initialDefinition = initialDefinition;

        Window.Title = initialDefinition is null ? "Add Bookmark" : "Edit Bookmark";
        Window.Size = new Vector2(500, 750);

        Layout = Layout.Column();
        Layout.Margin = 16f;
        Layout.Spacing = 12f;

        _nameInput = AddTextRow("Title", "My Bookmark");
        _iconInput = AddIconRow("Icon", "bookmark");

        Layout.Add( new Label( this ) { Text = "Asset Types" } );
        _assetTypeSelector = Layout.Add( new AssetTypeSelector( this, StyleInput ) );

        _projectOnlyToggle = Layout.Add( new ToggleSwitch( "Only include assets from this project", this ) );
        _projectOnlyToggle.MinimumHeight = Theme.RowHeight;
        _projectOnlyToggle.Value = true;

        _includeInput = AddTextArea("Include Folders", "Separate folders with ;");
        _excludeInput = AddTextArea("Exclude Folders", "Separate folders with ;");
        if ( _initialDefinition is not null )
        {
            _nameInput.Text = _initialDefinition.Name;
            _iconInput.Text = _initialDefinition.Icon;
            _assetTypeSelector.SetSelected( _initialDefinition.AssetTypes );
            _includeInput.Text = string.Join( ';', _initialDefinition.IncludeFolders ?? new List<string>() );
            _excludeInput.Text = string.Join( ';', _initialDefinition.ExcludeFolders ?? new List<string>() );
            _projectOnlyToggle.Value = _initialDefinition.ProjectAssetsOnly;

        }

        var buttonRow = Layout.AddRow();
        buttonRow.Spacing = 8f;
        buttonRow.AddStretchCell();
        buttonRow.Add( new Button( "Cancel", this ) { Clicked = Close } );
        var buttonLabel = _initialDefinition is null ? "Add Bookmark" : "Save Bookmark";
        var buttonIcon = _initialDefinition is null ? "add" : "save";
        buttonRow.Add( new Button.Primary( buttonLabel, buttonIcon, this ) { Clicked = Submit } );
    }

    private LineEdit AddTextRow(string label, string placeholder)
    {
        var row = Layout.AddRow();
        row.Spacing = 8f;
        row.Add( new Label( this ) { Text = label, FixedWidth = 130 } );

        var input = row.Add( new LineEdit( this ) );
        input.PlaceholderText = placeholder;
        StyleInput( input );
        return input;
    }

    private LineEdit AddIconRow(string label, string placeholder)
    {
        var row = Layout.AddRow();
        row.Spacing = 8f;
        row.Add( new Label( this ) { Text = label, FixedWidth = 130 } );

        var input = row.Add( new LineEdit( this ) );
        input.PlaceholderText = placeholder;
        StyleInput( input );

        var button = row.Add( new IconButton( "search", () => ShowIconPicker( input ), this ) );
        button.ToolTip = "Browse material icons";
        button.MinimumWidth = Theme.RowHeight;

        return input;
    }

    private LineEdit AddTextArea(string label, string placeholder)
    {
        var column = Layout.Add( Layout.Column() );
        column.Spacing = 4f;
        column.Add( new Label( this ) { Text = label } );

        var input = column.Add( new LineEdit( this ) );
        input.PlaceholderText = placeholder;
        StyleInput( input );
        return input;
    }

    private void Submit()
    {
        var name = _nameInput.Text?.Trim();
        if ( string.IsNullOrWhiteSpace( name ) )
        {
            EditorUtility.DisplayDialog( "Missing Title", "Please enter a title for the bookmark." );
            return;
        }

        var icon = string.IsNullOrWhiteSpace( _iconInput.Text ) ? "extension" : _iconInput.Text.Trim();

        var definition = _initialDefinition is null
            ? new CustomLocationDefinition()
            : new CustomLocationDefinition { Id = _initialDefinition.Id };

        definition.Name = name;
        definition.Icon = icon;
        definition.AssetTypes = _assetTypeSelector.SelectedTags.Select( NormalizeExtension ).Where( x => !string.IsNullOrWhiteSpace( x ) ).ToList();
        definition.IncludeFolders = SplitToList( _includeInput.Text );
        definition.ExcludeFolders = SplitToList( _excludeInput.Text );
        definition.ProjectAssetsOnly = _projectOnlyToggle.Value;

        _onConfirm?.Invoke( definition );
        Close();
    }

    private void ShowIconPicker( LineEdit target )
    {
        var pickerType = AppDomain.CurrentDomain.GetAssemblies()
            .Select( asm => asm.GetType( "Editor.IconPickerWidget", false ) )
            .FirstOrDefault( t => t is not null );

        var openPopup = pickerType?.GetMethod( "OpenPopup", BindingFlags.Public | BindingFlags.Static );
        if ( openPopup is null )
        {
            EditorUtility.DisplayDialog( "Icon Picker", "Unable to locate the icon picker widget." );
            return;
        }

        openPopup.Invoke( null, new object[]
        {
            this,
            target.Text ?? string.Empty,
            (Action<string>)(value => target.Text = value)
        } );
    }

    private static List<string> SplitToList(string raw)
    {
        if ( string.IsNullOrWhiteSpace( raw ) )
            return new List<string>();

        return raw
            .Split( new[] { ';', ',', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries )
            .Select( part => part.Trim() )
            .Where( part => part.Length > 0 )
            .ToList();
    }

    private static string NormalizeExtension( string value )
    {
        if ( string.IsNullOrWhiteSpace( value ) )
            return string.Empty;

        return value.Trim().TrimStart( '.' ).ToLowerInvariant();
    }

    private static void StyleInput( LineEdit input )
    {
        var background = Theme.ControlBackground.Darken( 0.25f ).Hex;
        var border = Theme.Border.Hex;
        input.SetStyles( $"background-color: {background}; border-color: {border};" );
    }
}
global using Sandbox;
global using Editor;
global using System.Collections.Generic;
global using System.Linq;
using System;
using System.Linq;
using Sandbox;

namespace Editor.TerrainConvert;

/// <summary>
/// Which source channel to read the height value from.
/// </summary>
public enum HeightChannel
{
	/// <summary> Red channel only. Standard for grayscale heightmaps. </summary>
	Red,
	Green,
	Blue,
	Alpha,
	/// <summary> Rec.709 weighted luminance of RGB. </summary>
	Luminance,
	/// <summary> Average of the RGB channels. </summary>
	Average,
	/// <summary> The largest of the RGB channels. </summary>
	Max,
}

/// <summary>
/// Bit depth of the output .raw file. s&box terrain stores heights as 16-bit,
/// so 16-bit is recommended for the best precision.
/// </summary>
public enum HeightBitDepth
{
	/// <summary> 16-bit per height sample (recommended). 2 bytes per pixel. </summary>
	Bit16,
	/// <summary> 8-bit per height sample. 1 byte per pixel. </summary>
	Bit8,
}

/// <summary>
/// How the source values are remapped into the 0..1 height range before quantizing.
/// </summary>
public enum HeightNormalize
{
	/// <summary> Use values as-is, clamped to 0..1. </summary>
	Clamp,
	/// <summary> Stretch the min..max range of the image to fill 0..1 (good for HDR/EXR). </summary>
	MinMaxStretch,
}

/// <summary>
/// Byte order of multi-byte (16-bit) samples in the output file.
/// </summary>
public enum HeightByteOrder
{
	/// <summary> Little-endian. What the s&box terrain importer reads by default ("Windows"). </summary>
	LittleEndian,
	/// <summary> Big-endian ("Mac"). </summary>
	BigEndian,
}

/// <summary>
/// Settings for converting an image into a terrain heightmap .raw file.
/// </summary>
public class HeightmapConvertSettings
{
	/// <summary>
	/// Which channel of the source image holds the height. Most heightmaps are
	/// grayscale, where every channel is identical - <see cref="HeightChannel.Red"/> works for those.
	/// </summary>
	[Property]
	public HeightChannel Channel { get; set; } = HeightChannel.Red;

	/// <summary>
	/// 16-bit gives the smoothest terrain and matches what s&box stores internally.
	/// </summary>
	[Property]
	public HeightBitDepth BitDepth { get; set; } = HeightBitDepth.Bit16;

	/// <summary>
	/// Output heightmaps are always square. If 0, the smaller of the source
	/// dimensions is used. The image is resampled to this resolution.
	/// </summary>
	[Property, Range( 0, 8192 )]
	public int Resolution { get; set; } = 0;

	/// <summary>
	/// Round the output resolution down to the nearest power of two (e.g 1024, 2048).
	/// The terrain system expects power-of-two heightmaps, so leave this on.
	/// </summary>
	[Property]
	public bool PowerOfTwo { get; set; } = true;

	/// <summary>
	/// How source values are mapped to the height range. Use <see cref="HeightNormalize.MinMaxStretch"/>
	/// for HDR images that don't already fill 0..1.
	/// </summary>
	[Property]
	public HeightNormalize Normalize { get; set; } = HeightNormalize.Clamp;

	/// <summary>
	/// Flip the image vertically. Image and terrain coordinate origins often differ;
	/// toggle this if your terrain comes out mirrored north/south.
	/// </summary>
	[Property]
	public bool FlipVertical { get; set; } = false;

	/// <summary>
	/// Invert the heights (high becomes low). Useful for depth/inverted maps.
	/// </summary>
	[Property]
	public bool Invert { get; set; } = false;

	/// <summary>
	/// Byte order of 16-bit samples. The s&box importer reads little-endian by default.
	/// </summary>
	[Property, ShowIf( nameof( BitDepth ), HeightBitDepth.Bit16 )]
	public HeightByteOrder ByteOrder { get; set; } = HeightByteOrder.LittleEndian;
}

/// <summary>
/// Converts loaded images into raw single-channel heightmaps compatible with the
/// s&box terrain importer (square, 8 or 16 bit, raw samples with no header).
/// </summary>
public static class HeightmapConverter
{
	/// <summary>
	/// Convert a bitmap into a raw heightmap byte buffer.
	/// </summary>
	/// <param name="bitmap">Source image.</param>
	/// <param name="settings">Conversion settings.</param>
	/// <param name="resolution">The square resolution of the produced heightmap.</param>
	/// <returns>Raw heightmap bytes ready to write to a .raw file.</returns>
	public static byte[] Convert( Bitmap bitmap, HeightmapConvertSettings settings, out int resolution )
	{
		ArgumentNullException.ThrowIfNull( bitmap );
		ArgumentNullException.ThrowIfNull( settings );

		var pixels = bitmap.GetPixels();
		int count = bitmap.Width * bitmap.Height;

		// Extract the chosen channel as a float per pixel, then run the shared pipeline.
		var values = new float[count];
		for ( int i = 0; i < count; i++ )
			values[i] = SampleChannel( pixels[i], settings.Channel );

		return BuildRaw( values, bitmap.Width, bitmap.Height, settings, out resolution );
	}

	/// <summary>
	/// Convert a decoded EXR into a raw heightmap byte buffer, reading height from the
	/// chosen channel (falling back to Y/R/first channel if the exact one is absent).
	/// </summary>
	public static byte[] Convert( ExrImage exr, HeightmapConvertSettings settings, out int resolution )
	{
		ArgumentNullException.ThrowIfNull( exr );
		ArgumentNullException.ThrowIfNull( settings );

		var values = SampleChannel( exr, settings.Channel );
		return BuildRaw( values, exr.Width, exr.Height, settings, out resolution );
	}

	/// <summary>
	/// Shared pipeline: resample a single-channel float plane to a square power-of-two,
	/// normalize into 0..1, optionally invert, and quantize to raw bytes.
	/// </summary>
	static byte[] BuildRaw( float[] values, int srcWidth, int srcHeight, HeightmapConvertSettings settings, out int resolution )
	{
		resolution = ResolveResolution( srcWidth, srcHeight, settings );

		// Bilinear resample to a square of the target resolution, working entirely in float
		// so we don't lose precision on high bit-depth / HDR sources. Resampling produces a
		// fresh array; otherwise clone so the in-place normalize/invert never mutates a
		// caller-owned buffer (e.g. an EXR's cached channel plane).
		values = srcWidth != resolution || srcHeight != resolution
			? ResampleBilinear( values, srcWidth, srcHeight, resolution, resolution )
			: (float[])values.Clone();

		ApplyNormalize( values, settings.Normalize );

		if ( settings.Invert )
		{
			for ( int i = 0; i < values.Length; i++ )
				values[i] = 1f - values[i];
		}

		return Quantize( values, resolution, settings );
	}

	static float[] ResampleBilinear( float[] src, int srcW, int srcH, int dstW, int dstH )
	{
		var dst = new float[dstW * dstH];

		for ( int y = 0; y < dstH; y++ )
		{
			float fy = dstH > 1 ? (float)y / (dstH - 1) * (srcH - 1) : 0f;
			int y0 = (int)fy;
			int y1 = Math.Min( y0 + 1, srcH - 1 );
			float ty = fy - y0;

			for ( int x = 0; x < dstW; x++ )
			{
				float fx = dstW > 1 ? (float)x / (dstW - 1) * (srcW - 1) : 0f;
				int x0 = (int)fx;
				int x1 = Math.Min( x0 + 1, srcW - 1 );
				float tx = fx - x0;

				float top = MathX.Lerp( src[y0 * srcW + x0], src[y0 * srcW + x1], tx );
				float bottom = MathX.Lerp( src[y1 * srcW + x0], src[y1 * srcW + x1], tx );
				dst[y * dstW + x] = MathX.Lerp( top, bottom, ty );
			}
		}

		return dst;
	}

	static float[] SampleChannel( ExrImage exr, HeightChannel channel )
	{
		// For multi-channel EXRs, blend RGB the same way the bitmap path does. For the
		// common single-channel (Y) heightmap, every option resolves to that one plane.
		switch ( channel )
		{
			case HeightChannel.Red: return exr.GetHeightChannel( "R" );
			case HeightChannel.Green: return exr.GetHeightChannel( "G" );
			case HeightChannel.Blue: return exr.GetHeightChannel( "B" );
			case HeightChannel.Alpha: return exr.GetHeightChannel( "A" );
		}

		var r = exr.GetChannel( "R" );
		var g = exr.GetChannel( "G" );
		var b = exr.GetChannel( "B" );

		// No RGB set - it's a luminance/single-channel image, use it directly.
		if ( r is null || g is null || b is null )
			return exr.GetHeightChannel();

		var outv = new float[r.Length];
		for ( int i = 0; i < outv.Length; i++ )
		{
			outv[i] = channel switch
			{
				HeightChannel.Luminance => 0.2126f * r[i] + 0.7152f * g[i] + 0.0722f * b[i],
				HeightChannel.Average => (r[i] + g[i] + b[i]) / 3f,
				HeightChannel.Max => Math.Max( r[i], Math.Max( g[i], b[i] ) ),
				_ => r[i],
			};
		}
		return outv;
	}

	static int ResolveResolution( int width, int height, HeightmapConvertSettings settings )
	{
		int res = settings.Resolution > 0 ? settings.Resolution : Math.Min( width, height );

		if ( settings.PowerOfTwo )
			res = RoundDownToPowerOfTwo( res );

		return Math.Clamp( res, 4, 16384 );
	}

	static float SampleChannel( Color c, HeightChannel channel ) => channel switch
	{
		HeightChannel.Red => c.r,
		HeightChannel.Green => c.g,
		HeightChannel.Blue => c.b,
		HeightChannel.Alpha => c.a,
		HeightChannel.Luminance => 0.2126f * c.r + 0.7152f * c.g + 0.0722f * c.b,
		HeightChannel.Average => (c.r + c.g + c.b) / 3f,
		HeightChannel.Max => Math.Max( c.r, Math.Max( c.g, c.b ) ),
		_ => c.r,
	};

	static void ApplyNormalize( float[] values, HeightNormalize mode )
	{
		if ( mode == HeightNormalize.MinMaxStretch )
		{
			float min = values.Min();
			float max = values.Max();
			float range = max - min;

			if ( range > 1e-6f )
			{
				for ( int i = 0; i < values.Length; i++ )
					values[i] = (values[i] - min) / range;
				return;
			}
			// Flat image - fall through to clamp.
		}

		for ( int i = 0; i < values.Length; i++ )
			values[i] = Math.Clamp( values[i], 0f, 1f );
	}

	static byte[] Quantize( float[] values, int resolution, HeightmapConvertSettings settings )
	{
		bool flip = settings.FlipVertical;

		if ( settings.BitDepth == HeightBitDepth.Bit8 )
		{
			var bytes = new byte[resolution * resolution];
			for ( int y = 0; y < resolution; y++ )
			{
				int srcY = flip ? resolution - 1 - y : y;
				for ( int x = 0; x < resolution; x++ )
				{
					float v = Math.Clamp( values[srcY * resolution + x], 0f, 1f );
					bytes[y * resolution + x] = (byte)MathF.Round( v * byte.MaxValue );
				}
			}
			return bytes;
		}
		else
		{
			bool little = settings.ByteOrder == HeightByteOrder.LittleEndian;
			var bytes = new byte[resolution * resolution * 2];
			for ( int y = 0; y < resolution; y++ )
			{
				int srcY = flip ? resolution - 1 - y : y;
				for ( int x = 0; x < resolution; x++ )
				{
					float v = Math.Clamp( values[srcY * resolution + x], 0f, 1f );
					ushort h = (ushort)MathF.Round( v * ushort.MaxValue );

					int o = (y * resolution + x) * 2;
					if ( little )
					{
						bytes[o] = (byte)(h & 0xFF);
						bytes[o + 1] = (byte)(h >> 8);
					}
					else
					{
						bytes[o] = (byte)(h >> 8);
						bytes[o + 1] = (byte)(h & 0xFF);
					}
				}
			}
			return bytes;
		}
	}

	/// <summary>
	/// Rounds a value down to the nearest power of two.
	/// </summary>
	public static int RoundDownToPowerOfTwo( int value )
	{
		if ( value < 1 ) return 1;
		value |= value >> 1;
		value |= value >> 2;
		value |= value >> 4;
		value |= value >> 8;
		value |= value >> 16;
		return value - (value >> 1);
	}
}
using System;
using Sandbox;

namespace Editor.SuperShot;

public sealed class SettingsPanel : Widget
{
	readonly SuperShotWindow _window;

	public SettingsPanel( SuperShotWindow window ) : base( null )
	{
		_window = window;
		Name = "Settings";
		WindowTitle = "Settings";
		SetWindowIcon( "settings" );
		Layout = Layout.Column();
		Layout.Margin = 8;
		Layout.Spacing = 8;

		Build();
	}

	void Build()
	{
		SuperShotUI.AddBanner( Layout, "Settings", "Where shots are saved and how they're named.", "settings" );

		var scroll = new ScrollArea( this );
		scroll.Canvas = new Widget( scroll );
		scroll.Canvas.Layout = Layout.Column();
		scroll.Canvas.Layout.Spacing = 8;
		var body = scroll.Canvas.Layout;

		var captureSo = _window.Settings.Capture.GetSerialized();
		captureSo.OnPropertyChanged += _ =>
		{
			_window.Settings.Save();
			_window.NotifyChanged();
		};
		var resCard = SuperShotUI.AddCard( body, "Default Capture Resolution", "aspect_ratio" );
		resCard.Body.Add( new Label( "Used by the main Capture button. Quick presets and package thumbnails override this per click." ) );
		resCard.Body.Add( SuperShotUI.SheetWidget( captureSo, IsResolutionProp ) );

		var content = SuperShotUI.AddCard( body, "Capture Content", "visibility" );
		content.Body.Add( new Label( "Controls what non-world content is included when Supershot renders a capture." ) { WordWrap = true } );
		content.Body.Add( SuperShotUI.SheetWidget( captureSo, IsCaptureContentProp ) );

		var outputSo = _window.Settings.Output.GetSerialized();
		outputSo.OnPropertyChanged += _ => _window.Settings.Save();

		var output = SuperShotUI.AddCard( body, "Output", "folder" );
		output.Body.Add( SuperShotUI.SheetWidget( outputSo, IsOutputEssential ) );

		SuperShotUI.AddSection( body, "Advanced Output", "tune",
			SuperShotUI.SheetWidget( outputSo, p => !IsOutputEssential( p ) ),
			cookie: "supershot.settings.outputadvanced" );

		body.AddStretchCell();
		Layout.Add( scroll, 1 );

		var row = Layout.AddRow();
		row.Spacing = 4;
		row.Add( new Button( "Open Output Folder", "folder_open" )
		{
			Clicked = () => SuperShotService.RevealInExplorer( _window.Settings.Output.ResolveFolder() )
		} );
		row.AddStretchCell();
	}

	static bool IsResolutionProp( SerializedProperty p )
	{
		return p.Name is nameof( CaptureSettings.Resolution )
			or nameof( CaptureSettings.CustomWidth )
			or nameof( CaptureSettings.CustomHeight );
	}

	static bool IsCaptureContentProp( SerializedProperty p )
	{
		return p.Name is nameof( CaptureSettings.ShowGameUI )
			or nameof( CaptureSettings.TransparentBackground )
			or nameof( CaptureSettings.HideTags );
	}

	static bool IsOutputEssential( SerializedProperty p )
	{
		return p.Name is nameof( OutputSettings.OutputFolder )
			or nameof( OutputSettings.Format )
			or nameof( OutputSettings.Quality );
	}
}
using System;
using Sandbox;

namespace Editor.SuperShot;

public static class SuperShotUI
{
	public static Color Accent => new( 0.36f, 0.55f, 0.95f );
	public static Color AccentDim => new( 0.22f, 0.30f, 0.46f );
	public static Color CardBackground => Theme.ControlBackground.Lighten( 0.03f );
	public static Color CardBorder => Theme.ControlBackground.Lighten( 0.12f );
	public static Color Muted => Theme.TextControl.WithAlpha( 0.6f );

	public static Banner AddBanner( Layout layout, string title, string subtitle, string icon )
	{
		var banner = new Banner( title, subtitle, icon );
		layout.Add( banner );
		return banner;
	}

	public static Card AddCard( Layout layout, string title = null, string icon = null )
	{
		var card = new Card( title, icon );
		layout.Add( card );
		return card;
	}

	public static ExpandGroup AddSection( Layout layout, string title, string icon, Widget content, string cookie = null, bool defaultOpen = false )
	{
		var group = new ExpandGroup( null );
		group.Title = title;
		group.Icon = icon;
		group.SetWidget( content );

		if ( !string.IsNullOrEmpty( cookie ) )
			group.StateCookieName = cookie;
		else
			group.SetOpenState( defaultOpen );

		layout.Add( group );
		return group;
	}

	public static Widget SheetWidget( SerializedObject so, Func<SerializedProperty, bool> filter = null )
	{
		var widget = new Widget( null );
		widget.Layout = Layout.Column();

		var sheet = new ControlSheet();
		if ( filter is null )
			sheet.AddObject( so );
		else
			sheet.AddObject( so, filter );

		widget.Layout.Add( sheet );
		return widget;
	}
}

public sealed class Banner : Widget
{
	public string TitleText { get; set; }
	public string SubtitleText { get; set; }
	public string Icon { get; set; }

	public Banner( string title, string subtitle, string icon ) : base( null )
	{
		TitleText = title;
		SubtitleText = subtitle;
		Icon = icon;
		FixedHeight = 56;
		MinimumSize = new Vector2( 0, 56 );
	}

	protected override void OnPaint()
	{
		var rect = LocalRect;

		Paint.ClearPen();
		Paint.SetBrush( SuperShotUI.AccentDim.WithAlpha( 0.35f ) );
		Paint.DrawRect( rect, 6f );

		Paint.ClearBrush();
		Paint.SetBrush( SuperShotUI.Accent );
		Paint.DrawRect( new Rect( rect.Left, rect.Top, 4f, rect.Height ), 2f );

		var content = rect.Shrink( 16, 0 );

		if ( !string.IsNullOrEmpty( Icon ) )
		{
			Paint.SetPen( SuperShotUI.Accent );
			Paint.DrawIcon( new Rect( content.Left, content.Top, 32, content.Height ), Icon, 26, TextFlag.LeftCenter );
			content.Left += 44;
		}

		Paint.SetDefaultFont( 11, 600 );
		Paint.SetPen( Theme.Text );
		Paint.DrawText( new Rect( content.Left, content.Top + 8, content.Width, 22 ), TitleText, TextFlag.LeftTop );

		if ( !string.IsNullOrEmpty( SubtitleText ) )
		{
			Paint.SetDefaultFont( 8, 400 );
			Paint.SetPen( SuperShotUI.Muted );
			Paint.DrawText( new Rect( content.Left, content.Top + 30, content.Width, 18 ), SubtitleText, TextFlag.LeftTop );
		}
	}
}

public sealed class Card : Widget
{
	public Layout Body { get; private set; }

	public Card( string title = null, string icon = null ) : base( null )
	{
		Layout = Layout.Column();
		Layout.Margin = 12;
		Layout.Spacing = 8;

		if ( !string.IsNullOrEmpty( title ) )
		{
			var header = Layout.AddRow();
			header.Spacing = 6;

			if ( !string.IsNullOrEmpty( icon ) )
				header.Add( new IconLabel( icon ) );

			header.Add( new Label.Subtitle( title ) );
			header.AddStretchCell();
		}

		var bodyWidget = new Widget( this );
		bodyWidget.Layout = Layout.Column();
		bodyWidget.Layout.Spacing = 6;
		Body = bodyWidget.Layout;
		Layout.Add( bodyWidget );
	}

	protected override void OnPaint()
	{
		Paint.ClearPen();
		Paint.SetBrush( SuperShotUI.CardBackground );
		Paint.DrawRect( LocalRect, 6f );

		Paint.ClearBrush();
		Paint.SetPen( SuperShotUI.CardBorder, 1f );
		Paint.DrawRect( LocalRect.Shrink( 0.5f ), 6f );
	}
}

public sealed class IconLabel : Widget
{
	public string Icon { get; set; }

	public IconLabel( string icon ) : base( null )
	{
		Icon = icon;
		FixedSize = new Vector2( 20, 20 );
	}

	protected override void OnPaint()
	{
		Paint.SetPen( SuperShotUI.Accent );
		Paint.DrawIcon( LocalRect, Icon, 18, TextFlag.Center );
	}
}
using Sandbox;
using Editor;

namespace RedSnail.RoadTool.Editor;

/// <summary>
/// Create and manage road and road intersection.
/// </summary>
[Title("Create Road/Intersection")]
[Icon("roundabout_left")]
[Alias("intersection")]
[Group("1")]
[Order(0)]
public class IntersectionTool : EditorTool
{
	public override void OnEnabled()
	{

	}

	public override Widget CreateToolSidebar()
	{
		ToolSidebarWidget sidebar = new ToolSidebarWidget();
		sidebar.AddTitle("Intersection", "roundabout_left");

		Layout group = sidebar.AddGroup("Create");
		Layout row = Layout.Row();

		IconButton road = sidebar.CreateButton("Create Road", "route", null, CreateRoad, true, row);
		IconButton inter = sidebar.CreateButton("Create Intersection", "roundabout_left", null, CreateIntersection, true, row);

		row.Spacing = 5;
		row.AddStretchCell();

		group.Add(row);

		sidebar.Layout.Add(group);
		sidebar.Layout.AddStretchCell();
		return sidebar;
	}

	private static void CreateRoad()
	{
		GameObject go = SceneEditorSession.Active.Scene.CreateObject();
		go.Name = "Road";
		go.AddComponent<RoadComponent>();
	}

	private static void CreateIntersection()
	{
		GameObject go = SceneEditorSession.Active.Scene.CreateObject();
		go.Name = "Road Intersection";
		go.AddComponent<RoadIntersectionComponent>();
	}
}
#if DEBUG

using Sandbox.Reactivity.Internals;
using Sandbox.UI;

namespace Sandbox.Reactivity.Editor.Debugger;

// ReSharper disable once ClassNeverInstantiated.Global
[Dock("Editor", "Reactivity Debugger", "local_fire_department")]
internal sealed partial class DebuggerWidget : Widget
{
	private readonly TreeView _tree;

	public DebuggerWidget(Widget? parent)
		: base(parent)
	{
		Layout = new Column
		{
			Spacing = 2,
			Children =
			[
				new Widget
				{
					Layout = new Row
					{
						Spacing = 2,
						Children =
						[
							// TODO: search bar
							new Widget
							{
								HorizontalSizeMode = SizeMode.Flexible,
							},
							new Widget
							{
								BackgroundColor = Theme.ControlBackground,
								BorderRadius = Theme.ControlRadius,
								Layout = new Row
								{
									Children =
									[
										new ToolButton("Settings", "more_vert", this)
										{
											MouseLeftPress = () => Settings<DebuggerWidget>.OpenContextMenu(this),
										},
									],
								},
							},
						],
					},
				},
				new Widget
				{
					BackgroundColor = Theme.ControlBackground,
					BorderRadius = Theme.ControlRadius,
					Layout = new Column
					{
						Children =
						[
							_tree = new TreeView
							{
								Margin = new Margin(8, 0),
								SelectionOverride = () => EditorUtility.InspectorObject,
							},
						],
					},
				},
			],
		};

		Effect.OnEffectRootCreated += OnEffectRootCreated;
	}

	public override void OnDestroyed()
	{
		foreach (var item in _tree.Items) // .Items makes a copy
		{
			if (item is EffectTreeNode node)
			{
				node.Dispose();
			}
		}

		_tree.Clear();

		Effect.OnEffectRootCreated -= OnEffectRootCreated;
	}

	private void OnEffectRootCreated(Effect root)
	{
		if (root.IsDisposed)
		{
			// just in case - effects from other scenes (e.g. prefab editors) might cause this to happen
			return;
		}

		if (!ShowUiEffects && root.Parent is IReactivePanel)
		{
			return;
		}

		if (!ShowGameplayEffects && root.Parent is (Component or GameObject) and not IReactivePanel)
		{
			return;
		}

		var node = new EffectTreeNode(root);
		_tree.AddItem(node);

		if (AutoExpand)
		{
			_tree.Open(node, true);
		}
	}
}

#endif
#if DEBUG

using Sandbox.Reactivity.Internals;

namespace Sandbox.Reactivity.Editor.Inspector;

internal class SerializedReactiveObjectProperty(IReactiveObject reactive) : SerializedProperty
{
	protected readonly IReactiveObject ReactiveObject = reactive;

	public override string Name { get; } = reactive.Name ?? "Reactive Object";

	public override string DisplayName => Name;

	public override Type PropertyType => ReactiveObject.GetType();

	public override bool IsEditable => false;

	public override bool IsValid => ReactiveObject is not Effect { IsDisposed: true } && base.IsValid;

	public override void SetValue<T>(T value)
	{
	}

	public override T GetValue<T>(T defaultValue = default!)
	{
		return ValueToType(ReactiveObject, defaultValue);
	}
}

#endif
using System.Text.Json.Serialization;

namespace Grains.RazorDesigner.Document;

public sealed record CheckboxPayload : Payload
{
    [JsonIgnore]
    public override ControlType Kind => ControlType.Checkbox;

    // Checkbox label text. Overrides Payload.Content (neutral default "").
    public override string Content { get; init; } = "";

    public override Length CheckboxSize { get; init; } = Length.Px( 16 );
}
using Editor;
using Grains.RazorDesigner.Document;
using Sandbox;

namespace Grains.RazorDesigner.Inspector;

[CustomEditor( typeof( Edges ) )]
public sealed class EdgesControlWidget : ControlWidget
{
	private const string LogPrefix = "[Grains.RazorDesigner]";

	public override bool SupportsMultiEdit => true;

	private readonly EdgesProxy _proxy;
	private readonly SerializedObject _proxySerialized;
	// Synchronous change events on both sides; without this guard SetValue would loop.
	private bool _syncing;

	private sealed class EdgesProxy
	{
		public Length Top    { get; set; } = Length.Px( 0 );
		public Length Right  { get; set; } = Length.Px( 0 );
		public Length Bottom { get; set; } = Length.Px( 0 );
		public Length Left   { get; set; } = Length.Px( 0 );
	}

	public EdgesControlWidget( SerializedProperty property ) : base( property )
	{
		Log.Info( $"{LogPrefix} EdgesControlWidget ctor for {property.Name}" );

		Layout = Layout.Column();
		Layout.Spacing = 2;

		_proxy = new EdgesProxy();
		_proxySerialized = EditorTypeLibrary.GetSerializedObject( _proxy );

		var topRow = Layout.Add( Layout.Row() );
		topRow.Spacing = 2;
		AddSide( topRow, nameof( EdgesProxy.Top    ), "border_top"    );
		AddSide( topRow, nameof( EdgesProxy.Right  ), "border_right"  );

		var bottomRow = Layout.Add( Layout.Row() );
		bottomRow.Spacing = 2;
		AddSide( bottomRow, nameof( EdgesProxy.Bottom ), "border_bottom" );
		AddSide( bottomRow, nameof( EdgesProxy.Left   ), "border_left"   );

		_proxySerialized.OnPropertyChanged += OnProxyChanged;

		SyncFromProperty();
	}

	private void AddSide( Layout row, string propName, string icon )
	{
		var prop = _proxySerialized.GetProperty( propName );
		var lengthWidget = new LengthControlWidget( prop, icon );
		row.Add( lengthWidget, 1 );
	}

	protected override void PaintControl()
	{
		// nothing
	}

	private void SyncFromProperty()
	{
		if ( _syncing ) return;
		_syncing = true;

		try
		{
			var e = SerializedProperty.GetValue<Edges>( Edges.Zero );

			_proxySerialized.GetProperty( nameof( EdgesProxy.Top    ) ).SetValue( e.Top );
			_proxySerialized.GetProperty( nameof( EdgesProxy.Right  ) ).SetValue( e.Right );
			_proxySerialized.GetProperty( nameof( EdgesProxy.Bottom ) ).SetValue( e.Bottom );
			_proxySerialized.GetProperty( nameof( EdgesProxy.Left   ) ).SetValue( e.Left );
		}
		finally
		{
			_syncing = false;
		}
	}

	private void OnProxyChanged( SerializedProperty property )
	{
		if ( _syncing ) return;
		if ( ReadOnly || !SerializedProperty.IsEditable )
			return;

		_syncing = true;
		try
		{
			var newValue = new Edges( _proxy.Top, _proxy.Right, _proxy.Bottom, _proxy.Left );

			Log.Info( $"{LogPrefix} EdgesControlWidget OnProxyChanged {newValue}" );

			PropertyStartEdit();
			SerializedProperty.SetValue( newValue );
			SignalValuesChanged();
			PropertyFinishEdit();
		}
		finally
		{
			_syncing = false;
		}
	}

	protected override void OnValueChanged()
	{
		base.OnValueChanged();
		SyncFromProperty();
	}
}
using System;
using System.Collections.Generic;
using Editor;
using Grains.RazorDesigner.Common;
using Grains.RazorDesigner.Contracts;
using Grains.RazorDesigner.Document;
using Grains.RazorDesigner.Templates;
using Sandbox;

namespace Grains.RazorDesigner.Palette;

public class PalettePanel : Widget
{
	private const string LogPrefix = "[Grains.RazorDesigner]";
	private const string CookiePrefix = "razordesigner.palette.";

	// Click-to-add target. Window decides where the new record goes (typically active selection or root).
	public event Action<ControlType> TypeAddRequested;

	// Click-to-add a saved template. Window decides where to insert.
	public event Action<PaletteTemplate> TemplateAddRequested;

	private readonly PaletteTemplateStore _templateStore = new();
	private CollapsibleSection _templatesSection;
	private WrapPanel _templatesWrap;
	public PaletteTemplateStore TemplateStore => _templateStore;

	public PalettePanel( Widget parent ) : base( parent )
	{
		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Spacing = 0;
		MinimumWidth = 180;
		VerticalSizeMode = SizeMode.CanGrow;

		var byCategory = new Dictionary<ControlCategory, List<ControlType>>();
		foreach ( ControlType type in Enum.GetValues( typeof( ControlType ) ) )
		{
			var cat = ControlDefaults.For( type ).Category;
			if ( !byCategory.TryGetValue( cat, out var list ) )
			{
				list = new List<ControlType>();
				byCategory[cat] = list;
			}
			list.Add( type );
		}

		// Templates section (top of palette). Hidden when store is empty; rebuilt on Changed.
		_templatesSection = new CollapsibleSection( this, "Templates", "bookmark" );
		_templatesWrap = new WrapPanel( null )
		{
			MinItemWidth = 92,
			ItemHeight = (int)( Theme.RowHeight + 4 ),
			HSpacing = 4,
			VSpacing = 4,
			PaddingLeft = 4,
			PaddingTop = 4,
			PaddingRight = 14,
			PaddingBottom = 4,
		};
		_templatesSection.BodyLayout.Add( _templatesWrap );

		var templatesCookie = $"{CookiePrefix}templates.expanded";
		_templatesSection.Expanded = EditorCookie.Get<bool>( templatesCookie, true );
		_templatesSection.ExpandedChanged += expanded =>
		{
			EditorCookie.Set( templatesCookie, expanded );
			Log.Info( $"{LogPrefix} Palette Templates {(expanded ? "expanded" : "collapsed")}" );
		};

		Layout.Add( _templatesSection );

		_templateStore.Changed += RebuildTemplatesSection;
		_templateStore.Scan(); // initial fill (also fires Changed and rebuilds the section)

		foreach ( ControlCategory cat in Enum.GetValues( typeof( ControlCategory ) ) )
		{
			if ( !byCategory.TryGetValue( cat, out var list ) ) continue;

			var section = new CollapsibleSection(
				this,
				ControlDefaults.CategoryDisplayName( cat ),
				CategoryIcon( cat ) );

			var wrap = new WrapPanel( null )
			{
				MinItemWidth = 92,
				ItemHeight = (int)( Theme.RowHeight + 4 ),
				HSpacing = 4,
				VSpacing = 4,
				PaddingLeft = 4,
				PaddingTop = 4,
				PaddingRight = 14,    // clear the ScrollArea's vertical scrollbar
				PaddingBottom = 4,
			};
			section.BodyLayout.Add( wrap );

			foreach ( var t in list )
				new PaletteTypeButton( wrap, this, t );

			var cookieKey = $"{CookiePrefix}{cat}.expanded";
			section.Expanded = EditorCookie.Get<bool>( cookieKey, DefaultExpanded( cat ) );
			section.ExpandedChanged += expanded =>
			{
				EditorCookie.Set( cookieKey, expanded );
				Log.Info( $"{LogPrefix} Palette category {cat} {(expanded ? "expanded" : "collapsed")}" );
			};

			Layout.Add( section );
		}

		Layout.AddStretchCell();

		Log.Info( $"{LogPrefix} PalettePanel ctor (icon grid, {byCategory.Count} categories)" );
	}

	internal void NotifyTypeClicked( ControlType type )
	{
		Log.Info( $"{LogPrefix} PalettePanel.NotifyTypeClicked: {type}" );
		TypeAddRequested?.Invoke( type );
	}

	internal void NotifyTemplateClicked( PaletteTemplate template )
	{
		Log.Info( $"{LogPrefix} PalettePanel.NotifyTemplateClicked: \"{template.Name}\"" );
		TemplateAddRequested?.Invoke( template );
	}

	internal void RequestTemplateDelete( PaletteTemplate template )
	{
		var dialog = new Editor.Dialog( this );
		dialog.Window.WindowTitle = "Delete template";
		dialog.Window.SetWindowIcon( "delete" );
		dialog.Window.SetModal( true, true );
		dialog.Window.MinimumWidth = 320;

		dialog.Layout = Layout.Column();
		dialog.Layout.Margin = 16;
		dialog.Layout.Spacing = 10;

		dialog.Layout.Add( new Editor.Label( dialog )
		{
			Text = $"Delete template \"{template.Name}\"?",
		} );

		var hint = new Editor.Label( dialog )
		{
			Text = "Already-instantiated copies in open documents are unaffected.",
		};
		hint.SetStyles( "color: #888; font-size: 11px;" );
		dialog.Layout.Add( hint );

		var buttonRow = dialog.Layout.Add( Layout.Row() );
		buttonRow.Spacing = 6;
		buttonRow.AddStretchCell();

		var cancel = new Editor.Button( dialog ) { Text = "Cancel", MinimumWidth = 72 };
		cancel.MouseLeftPress += () => dialog.Close();
		buttonRow.Add( cancel );

		var del = new Editor.Button( dialog ) { Text = "Delete", MinimumWidth = 72 };
		del.SetStyles( "color: #e07070;" );
		del.MouseLeftPress += () =>
		{
			Log.Info( $"{LogPrefix} Palette delete confirmed: \"{template.Name}\"" );
			_templateStore.Delete( template );
			dialog.Close();
		};
		buttonRow.Add( del );

		dialog.Window.AdjustSize();
		dialog.Show();
	}

	private void RebuildTemplatesSection()
	{
		var templates = _templateStore.All;

		// Hide the entire section (header + body) when there are no templates.
		_templatesSection.Visible = templates.Count > 0;

		using ( Editor.SuspendUpdates.For( _templatesWrap ) )
		{
			_templatesWrap.DestroyChildren();
			foreach ( var t in templates )
				new PaletteTemplateButton( _templatesWrap, this, t );
		}

		_templatesWrap.Relayout();
		_templatesWrap.UpdateGeometry();
		_templatesSection.UpdateGeometry();
		UpdateGeometry();

		Log.Info( $"{LogPrefix} PalettePanel.RebuildTemplatesSection: {templates.Count} tile(s), section.Visible={_templatesSection.Visible}" );
	}

	private static bool DefaultExpanded( ControlCategory cat ) =>
		cat is ControlCategory.Layout or ControlCategory.Display or ControlCategory.Input;

	private static string CategoryIcon( ControlCategory cat ) => cat switch
	{
		ControlCategory.Layout   => "view_quilt",
		ControlCategory.Display  => "visibility",
		ControlCategory.Input    => "edit",
		ControlCategory.Form     => "list_alt",
		_ => "category",
	};

	private sealed class PaletteTypeButton : Widget
	{
		private readonly PalettePanel _owner;
		private readonly ControlType _type;
		// InspectorIcon comes from the contract (engine-fidelity); drag defaults from ControlDefaults.
		private readonly string _icon;

		public PaletteTypeButton( Widget parent, PalettePanel owner, ControlType type ) : base( parent )
		{
			_owner = owner;
			_type = type;
			_icon = ContractScanner.Table.Get( type ).InspectorIcon;

			ToolTip = type.ToString();
			Cursor = CursorShape.Finger;
			MouseTracking = true;
			IsDraggable = true;
		}

		protected override void OnPaint()
		{
			var rect = LocalRect.Shrink( 1 );
			Paint.Antialiasing = true;
			Paint.TextAntialiasing = true;

			var tint = ControlPresentation.IconTint( _type );
			var fillAlpha = Paint.HasMouseOver ? 0.35f : 0.15f;
			var borderAlpha = Paint.HasMouseOver ? 0.55f : 0.25f;
			Paint.SetBrush( tint.WithAlpha( fillAlpha ) );
			Paint.SetPen( tint.WithAlpha( borderAlpha ) );
			Paint.DrawRect( rect, 3 );

			var hoverOpacity = Paint.HasMouseOver ? 1f : 0.85f;
			var iconRect = new Rect( rect.Left + 4, rect.Top, 20, rect.Height );
			Paint.SetPen( tint.WithAlphaMultiplied( hoverOpacity ) );
			Paint.DrawIcon( iconRect, _icon, 16, TextFlag.Center );

			var textRect = rect;
			textRect.Left = iconRect.Right + 2;
			textRect.Right -= 4;
			Paint.SetPen( Theme.Text.WithAlphaMultiplied( hoverOpacity ) );
			Paint.SetDefaultFont();
			Paint.DrawText( textRect, _type.ToString(), TextFlag.LeftCenter );
		}

		protected override void OnMouseClick( MouseEvent e )
		{
			base.OnMouseClick( e );
			if ( e.LeftMouseButton )
				_owner.NotifyTypeClicked( _type );
		}

		protected override void OnDragStart()
		{
			base.OnDragStart();

			var drag = new Drag( this );
			drag.Data.Object = _type;
			drag.Data.Text = $"palette:{_type}";
			drag.Execute();

			Log.Info( $"{LogPrefix} PaletteTypeButton.OnDragStart: {_type}" );
		}
	}

	private sealed class PaletteTemplateButton : Widget
	{
		private readonly PalettePanel _owner;
		private readonly PaletteTemplate _template;

		public PaletteTemplateButton( Widget parent, PalettePanel owner, PaletteTemplate template ) : base( parent )
		{
			_owner = owner;
			_template = template;

			ToolTip = template.Name;
			Cursor = CursorShape.Finger;
			MouseTracking = true;
			IsDraggable = true;
		}

		protected override void OnPaint()
		{
			var rect = LocalRect.Shrink( 1 );
			Paint.Antialiasing = true;
			Paint.TextAntialiasing = true;

			var tint = ControlPresentation.TemplateTint;
			var fillAlpha = Paint.HasMouseOver ? 0.18f : 0.08f;
			var borderAlpha = Paint.HasMouseOver ? 0.55f : 0.25f;
			Paint.SetBrush( tint.WithAlpha( fillAlpha ) );
			Paint.SetPen( tint.WithAlpha( borderAlpha ) );
			Paint.DrawRect( rect, 3 );

			var hoverOpacity = Paint.HasMouseOver ? 1f : 0.85f;
			var iconRect = new Rect( rect.Left + 4, rect.Top, 20, rect.Height );
			var icon = string.IsNullOrEmpty( _template.IconName ) ? "bookmark" : _template.IconName;
			Paint.SetPen( tint.WithAlphaMultiplied( hoverOpacity ) );
			Paint.DrawIcon( iconRect, icon, 16, TextFlag.Center );

			var textRect = rect;
			textRect.Left = iconRect.Right + 2;
			textRect.Right -= 4;
			Paint.SetPen( Theme.Text.WithAlphaMultiplied( hoverOpacity ) );
			Paint.SetDefaultFont();
			Paint.DrawText( textRect, _template.Name, TextFlag.LeftCenter );
		}

		protected override void OnMouseClick( MouseEvent e )
		{
			base.OnMouseClick( e );
			if ( e.LeftMouseButton )
				_owner.NotifyTemplateClicked( _template );
		}

		protected override void OnContextMenu( ContextMenuEvent e )
		{
			base.OnContextMenu( e );
			var menu = new Menu( this );
			menu.AddOption( "Delete…", "delete", () => _owner.RequestTemplateDelete( _template ) );
			menu.OpenAtCursor();
			e.Accepted = true;
		}

		protected override void OnDragStart()
		{
			base.OnDragStart();
			var drag = new Drag( this );
			drag.Data.Object = _template;
			drag.Data.Text = $"template:{_template.Name}";
			drag.Execute();
			Log.Info( $"{LogPrefix} PaletteTemplateButton.OnDragStart: \"{_template.Name}\"" );
		}
	}
}
namespace Grains.RazorDesigner.Projection.CSharp;

public abstract record CSharpOp;

// File-level scaffold
public sealed record HeaderBanner( string ClassName, string Namespace ) : CSharpOp;
public sealed record UsingDirective( string Namespace ) : CSharpOp;
public sealed record NamespaceOpen( string Namespace ) : CSharpOp;
public sealed record ClassOpen( string ClassName, string BaseClass ) : CSharpOp;
public sealed record ClassClose() : CSharpOp;

public sealed record FieldDecl(
    string Visibility, string Type, string Name, string InitialExpr,
    bool IsParameter, bool IsProperty = false ) : CSharpOp;

public sealed record MethodOpen(
    string Visibility, bool IsOverride, bool IsAsync,
    string ReturnType, string Name, string ParameterList ) : CSharpOp;

public sealed record MethodClose() : CSharpOp;

// Body-level
public sealed record Statement( string Code ) : CSharpOp;          // single `;`-terminated line
public sealed record BlockOpen( string Header ) : CSharpOp;        // e.g. `if ( <cond> )` — applier writes "<header> {\n" and indents
public sealed record BlockClose() : CSharpOp;
public sealed record BlankLine() : CSharpOp;
public sealed record Comment( string Text ) : CSharpOp;            // `// <text>`
using System.Collections.Generic;
using Grains.RazorDesigner.Projection.CSharp.Projectors;

namespace Grains.RazorDesigner.Projection.CSharp;

public static class CSharpProjector
{
    public static CSharpResult Project(
        IReadOnlyWiring wiring,
        bool documentHasAnyBindings )
    {
        if ( wiring.Symbols.Count == 0 && !documentHasAnyBindings )
            return new CSharpResult( System.Array.Empty<CSharpOp>(), null );

        var ctx = new CSharpProjectorContext( wiring );
        var ops = new List<CSharpOp>( 64 );

        ops.Add( new HeaderBanner( wiring.ClassName, wiring.Namespace ) );
        ops.Add( new UsingDirective( "Sandbox" ) );
        ops.Add( new UsingDirective( "Sandbox.UI" ) );
        foreach ( var u in wiring.Usings )
            ops.Add( new UsingDirective( u ) );
        ops.Add( new NamespaceOpen( wiring.Namespace ) );
        ops.Add( new ClassOpen( wiring.ClassName, wiring.BaseClass ) );

        // Step 3: body. SymbolProjector handles grouping + sorting + per-kind dispatch.
        SymbolProjector.EmitAll( wiring, ops, ctx );

        ops.Add( new ClassClose() );

        var source = CSharpApplier.Apply( ops ).Replace( "\r\n", "\n" );

        return new CSharpResult( ops, source );
    }
}
using System.Collections.Generic;
using Grains.RazorDesigner.Wiring;

namespace Grains.RazorDesigner.Projection.CSharp.Projectors;

public static class ParameterSymbolProjector
{
    public static void Emit( ParameterSymbol s, List<CSharpOp> ops, CSharpProjectorContext ctx )
    {
        var initial = s.Initial is null ? "default" : ExpressionEmitter.Emit( s.Initial, ctx );
        ops.Add( new FieldDecl(
            Visibility: "public", Type: s.Type, Name: s.Name,
            InitialExpr: initial, IsParameter: true ) );
    }
}
namespace Grains.RazorDesigner.Projection;

public static class Escape
{
    public static string Html( string s )
    {
        if ( string.IsNullOrEmpty( s ) ) return "";
        return s
            .Replace( "&", "&amp;" )
            .Replace( "<", "&lt;" )
            .Replace( ">", "&gt;" )
            .Replace( "\"", "&quot;" );
    }
}
using System;
using System.Collections.Generic;
using Sandbox; // Color

namespace Grains.RazorDesigner.Projection;

public interface IReadOnlyStateRule
{
    Document.PseudoKind State { get; }
    Document.NthChildMode NthChildMode { get; }
    int NthChildArg { get; }
    IAppearance Delta { get; }

    public static int CompareCanonical( IReadOnlyStateRule a, IReadOnlyStateRule b )
    {
        int c = ((int)a.State).CompareTo( (int)b.State );
        if ( c != 0 ) return c;
        c = ((int)a.NthChildMode).CompareTo( (int)b.NthChildMode );
        if ( c != 0 ) return c;
        return a.NthChildArg.CompareTo( b.NthChildArg );
    }
}

public interface IReadOnlyNode
{
    Guid Id { get; }
    string Kind { get; }         // == ControlType.ToString()
    string ClassName { get; }
    IAppearance Appearance { get; }
    IPayload Payload { get; }
    IReadOnlyList<IReadOnlyNode> Children { get; }                              // non-slot children
    IReadOnlyDictionary<string, IReadOnlyList<IReadOnlyNode>> Slots { get; }    // slot-name -> slot children (only SplitContainer populates)
    IReadOnlyList<IReadOnlyStateRule> StateRules { get; }                       // per-state style deltas; canonical order not guaranteed here (the Applier sorts)
}

public interface IAppearance
{
    // Layout
    Document.Length Width { get; }
    Document.Length Height { get; }

    // Flex container
    Document.FlexDirection Direction { get; }
    Document.JustifyContent Justify { get; }
    Document.AlignItems Align { get; }
    float Gap { get; }
    Document.Edges Padding { get; }
    Document.FlexWrap Wrap { get; }

    // Positioning (grd-7t2z)
    Document.PositionKind Position { get; }
    Document.Length Top { get; }
    Document.Length Left { get; }
    Document.Length Right { get; }
    Document.Length Bottom { get; }

    // Flex self
    float FlexGrow { get; }
    float FlexShrink { get; }
    Document.Length FlexBasis { get; }
    Document.AlignSelfKind AlignSelf { get; }

    // Typography + OverrideTypography
    bool OverrideTypography { get; }
    string FontFamily { get; }
    Document.Length FontSize { get; }
    int FontWeight { get; }
    Color Color { get; }
    Document.TextAlignment TextAlign { get; }
    bool FontStyleItalic { get; }
    Document.TextTransformKind TextTransform { get; }
    Document.Length LetterSpacing { get; }
    Document.Length LineHeight { get; }

    // Background + OverrideBackground
    bool OverrideBackground { get; }
    Color BackgroundColor { get; }
    string BackgroundImage { get; }
    string BackgroundSize { get; }
    string BackgroundPosition { get; }
    string BackgroundRepeat { get; }

    // Border + OverrideBorder
    bool OverrideBorder { get; }
    Document.Length BorderRadius { get; }
    Color BorderColor { get; }
    Document.Length BorderWidth { get; }

    // Effects + OverrideEffects
    bool OverrideEffects { get; }
    Document.Length BoxShadowX { get; }
    Document.Length BoxShadowY { get; }
    Document.Length BoxShadowBlur { get; }
    Color BoxShadowColor { get; }
    bool BoxShadowInset { get; }
    float Opacity { get; }

    // Constraints + OverrideConstraints
    bool OverrideConstraints { get; }
    Document.Edges Margin { get; }
    Document.Length MinWidth { get; }
    Document.Length MaxWidth { get; }
    Document.Length MinHeight { get; }
    Document.Length MaxHeight { get; }

    // Interaction + OverrideInteraction
    bool OverrideInteraction { get; }
    Document.CursorKind Cursor { get; }
    Document.OverflowKind Overflow { get; }
    int ZIndex { get; }
    bool PointerEvents { get; }
}

public interface IPayload
{
    string Content { get; }       // Label/Button text; Checkbox label
    string Placeholder { get; }   // TextEntry
    string Source { get; }        // Image src
    string IconName { get; }      // IconPanel glyph
    Document.Length CheckboxSize { get; }  // Checkbox box size
}
using System.Collections.Generic;
using Grains.RazorDesigner.Projection.Appearance;
using Grains.RazorDesigner.Projection.Razor;

namespace Grains.RazorDesigner.Projection.Projectors;

[Projector( "Button" )]
public sealed class ButtonProjector : IControlProjector
{
    public string Kind => "Button";

    public ProjectionResult Project( IReadOnlyNode node, IAppearance a, IPayload p, ProjectionContext ctx )
    {
        var scss = AppearanceScss.Emit(
            a,
            isRoot:       node.ClassName == Document.DesignerDocument.RootClassName,
            isContainer:  false,
            childCount:   0,
            isLabel:      false,
            isCheckbox:   false,
            checkboxSize: default );

        var nodeId = node.Id.ToString();
        var ops = new PanelOp[]
        {
            new SetAttribute( "data-grd-node-id", nodeId ),
            new SetInnerText( p.Content ?? "" ),
        };

        var razorAttrs = new[] { RazorEmit.Attr( "data-grd-node-id", nodeId ) };

        return new ProjectionResult(
            PanelOps:        ops,
            ScssLines:       scss,
            RazorAttributes: razorAttrs,
            RazorInnerText:  Escape.Html( p.Content ?? "" ) );
    }
}
using System.Collections.Generic;
using Grains.RazorDesigner.Projection.Appearance;
using Grains.RazorDesigner.Projection.Razor;

namespace Grains.RazorDesigner.Projection.Projectors;

[Projector( "Field" )]
public sealed class FieldProjector : IControlProjector
{
    public string Kind => "Field";

    public ProjectionResult Project( IReadOnlyNode node, IAppearance a, IPayload p, ProjectionContext ctx )
    {
        var scss = AppearanceScss.Emit(
            a,
            isRoot:       node.ClassName == Document.DesignerDocument.RootClassName,
            isContainer:  true,
            childCount:   node.Children.Count,
            isLabel:      false,
            isCheckbox:   false,
            checkboxSize: default );

        var nodeId = node.Id.ToString();
        var ops = new PanelOp[]
        {
            new SetAttribute( "data-grd-node-id", nodeId ),
        };

        var razorAttrs = new[] { RazorEmit.Attr( "data-grd-node-id", nodeId ) };

        return new ProjectionResult(
            PanelOps:        ops,
            ScssLines:       scss,
            RazorAttributes: razorAttrs,
            RazorInnerText:  null );
    }
}
namespace Grains.RazorDesigner.Projection.Razor;

public static class RazorEmit
{
    public static string Attr( string name, string value ) => $"{name}=\"{Escape.Html( value )}\"";
}
using System;

namespace Grains.RazorDesigner.Projection.Tests;

public static class PanelOpExhaustivenessTest
{
    public static (bool pass, string message) Run()
    {
        var ops = new PanelOp[]
        {
            new SetClass( "" ),
            new SetStyle( "", "" ),
            new SetAttribute( "", "" ),
            new SetInnerText( "" ),
        };
        try
        {
            foreach ( var op in ops )
                Applier.ApplyOpToScratch( op );
            return (true, $"PanelOpExhaustivenessTest: {ops.Length} variants OK");
        }
        catch ( Exception e )
        {
            return (false, $"PanelOpExhaustivenessTest FAILED: {e.GetType().Name}: {e.Message}");
        }
    }
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Grains.RazorDesigner.Document;

namespace Grains.RazorDesigner.Serialization.IR;

public static class IRWriter
{
	private const string LogPrefix = "[Grains.RazorDesigner]";

	// Empty collections reused for nodes that have no slots/children/metadata.
	private static readonly IReadOnlyDictionary<string, object> _emptyMetadata = new Dictionary<string, object>();
	private static readonly IReadOnlyList<IRNodeEnvelope>      _emptyChildren = System.Array.Empty<IRNodeEnvelope>();
	private static readonly IReadOnlyDictionary<string, IRNodeEnvelope> _emptySlots = new Dictionary<string, IRNodeEnvelope>();

	public static string WriteDocument( DesignerDocument doc )
	{
		if ( doc is null )
			throw new ArgumentNullException( nameof( doc ) );

		Log.Info( $"{LogPrefix} IRWriter.WriteDocument: serialising document (root children: {doc.RootRecord.Children.Count})" );

		var envelope = new IRDocumentEnvelope
		{
			Root   = ToNode( doc.RootRecord ),
			Wiring = doc.Wiring ?? Grains.RazorDesigner.Wiring.WiringEnvelope.Empty,
		};

		var json = JsonSerializer.Serialize( envelope, DesignerIRJson.Options );

		// Normalise CRLF → LF (canonical form; .gitattributes also pins LF as a backstop).
		if ( json.Contains( '\r' ) )
			json = json.Replace( "\r\n", "\n" ).Replace( "\r", "\n" );

		Log.Info( $"{LogPrefix} IRWriter.WriteDocument: OK ({json.Length} chars)" );
		return json;
	}

	public static string CanonicalHash( string json )
	{
		if ( json is null )
			throw new ArgumentNullException( nameof( json ) );

		var bytes = Encoding.UTF8.GetBytes( json );
		var hash  = SHA256.HashData( bytes );
		return Convert.ToHexString( hash ).ToLowerInvariant();
	}

	// Recursively converts a ControlRecord to its IRNodeEnvelope representation.
	private static IRNodeEnvelope ToNode( ControlRecord r )
	{
		Dictionary<string, IRNodeEnvelope> slotDict  = null;
		List<IRNodeEnvelope>               childList = null;

		foreach ( var child in r.Children )
		{
			if ( child.IsSlot )
			{
				slotDict ??= new Dictionary<string, IRNodeEnvelope>();
				slotDict[child.SlotName] = ToNode( child );
			}
			else
			{
				childList ??= new List<IRNodeEnvelope>();
				childList.Add( ToNode( child ) );
			}
		}

		return new IRNodeEnvelope
		{
			Id         = r.Id,
			Kind       = r.Type,
			ClassName  = r.ClassName,
			Appearance = r.Appearance,
			Payload    = r.Payload,
			Slots      = slotDict  is not null
				? (IReadOnlyDictionary<string, IRNodeEnvelope>)slotDict
				: _emptySlots,
			Children   = childList is not null
				? (IReadOnlyList<IRNodeEnvelope>)childList
				: _emptyChildren,
			States = r.StateRules.Count == 0
				? null
				: r.StateRules
					.OrderBy( rule => rule, Comparer<StateRule>.Create( StateRule.CompareCanonical ) )
					.Select( rule => new IRStateEnvelope
					{
						State       = rule.State,
						NthChildMode = rule.NthChildMode,
						NthChildArg  = rule.NthChildArg,
						Delta        = rule.Delta,
					} )
					.ToList(),
			Bindings   = r.Bindings.Count == 0
				? System.Array.Empty<Grains.RazorDesigner.Wiring.Binding>()
				: r.Bindings.ToArray(),

				CustomStyles = r.CustomStyles.Count == 0 
				? null 
				: new Dictionary<string, string>( r.CustomStyles ),
		};
	}
}
using System;
using System.Text.Json.Serialization;

namespace Grains.RazorDesigner.Wiring;

[JsonPolymorphic( TypeDiscriminatorPropertyName = "$type" )]
[JsonDerivedType( typeof( SetAction ),              "Set" )]
[JsonDerivedType( typeof( CallAction ),             "Call" )]
[JsonDerivedType( typeof( IfAction ),               "If" )]
[JsonDerivedType( typeof( StateHasChangedAction ),  "StateHasChanged" )]
[JsonDerivedType( typeof( LogAction ),              "Log" )]
[JsonDerivedType( typeof( ReturnAction ),           "Return" )]
[JsonDerivedType( typeof( InlineAction ),           "Inline" )]
public abstract record Action
{
    public Guid Id { get; init; } = Guid.NewGuid();
}
namespace Grains.RazorDesigner.Wiring;

public sealed record InlineAction : Action
{
    public string Code { get; init; } = "";
}
namespace Grains.RazorDesigner.Wiring;

// `Target = Value;` — assignment to a Symbol field.
public sealed record SetAction : Action
{
    public TargetRef Target { get; init; }
    public Expression Value { get; init; }
}
using System.Collections.Generic;

namespace Grains.RazorDesigner.Wiring;

public sealed record EventBinding : Binding
{
    public string Event { get; init; } = "";
    public IReadOnlyList<Action> Body { get; init; } = System.Array.Empty<Action>();
}
namespace Grains.RazorDesigner.Wiring;

public sealed record VisibleBinding : Binding
{
    public Expression Condition { get; init; }
}
namespace Grains.RazorDesigner.Wiring;

public enum SymbolVisibility
{
    Private,
    Public,
    Internal,
    Protected,
}
// Unattended editor-process gate.
//
// Runs ONLY when AUTORIG_GATE_RESULT is set AND its .arm marker file exists (the
// driver script dev/editor-rig/run_editor_gate.ps1 writes the marker immediately
// before launch and this hook consumes it - an env var leaked into Steam's
// environment must never arm the gate in a user's session; pattern proven in
// humanoid-retargeter's M0Gate).
//
// Flow inside a real sbox-dev.exe session:
//   1. wait for the project + asset system
//   2. refuse to run unless the open project is the autorig-editor-rig scratch
//   3. load the input model (AUTORIG_GATE_INPUT), analyze, rig, export
//   4. write fbx + vmdl into the scratch Assets, register + compile
//   5. load the compiled model, record bone count, write JSON, quit

using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using AutoRig.Analyze;
using AutoRig.Formats;
using AutoRig.Solve;
using Editor;
using Sandbox;

namespace AutoRig.Editor.Gate;

public static class AutoRigGate
{
    static bool _started;
    static readonly GateResult Result = new();
    static string _resultPath;

    [EditorEvent.Frame]
    public static void Tick()
    {
        if ( _started )
            return;
        _started = true;

        _resultPath = Environment.GetEnvironmentVariable( "AUTORIG_GATE_RESULT" );
        if ( string.IsNullOrWhiteSpace( _resultPath ) )
            return; // not a gate run

        var marker = _resultPath + ".arm";
        try
        {
            if ( !File.Exists( marker ) )
            {
                Log.Info( "[autorig-gate] AUTORIG_GATE_RESULT set but no arming marker - ignoring (leaked env var)" );
                return;
            }
            File.Delete( marker );
        }
        catch
        {
            return; // cannot verify the marker - never run
        }

        _ = RunAsync();
    }

    static async Task RunAsync()
    {
        Note( "gate starting" );
        Result.engineBooted = true;
        Flush();

        try
        {
            await RunGateAsync();
        }
        catch ( Exception e )
        {
            Note( $"EXCEPTION: {e}" );
        }

        Result.completed = true;
        Result.passed = Result.vmdlCompiled && Result.modelLoads && Result.boneCount >= 2;
        Flush();
        Note( $"gate finished, passed={Result.passed}" );

        if ( Result.refusedWrongProject )
            return;

        await Task.Delay( 1000 );
        try
        {
            EditorUtility.Quit( true );
        }
        catch ( Exception e )
        {
            Note( $"EditorUtility.Quit threw: {e.Message}" );
            Flush();
        }
        await Task.Delay( 10_000 );
        Environment.Exit( Result.passed ? 0 : 1 );
    }

    static async Task RunGateAsync()
    {
        Result.assetSystemReady = await WaitUntil(
            () => Project.Current is not null && AssetSystem.All.Any(),
            timeoutSeconds: 120 );
        Flush();
        if ( !Result.assetSystemReady )
            return;

        var project = Project.Current;
        Result.projectPath = project.GetRootPath();

        // Never touch a real session's project.
        if ( (Result.projectPath ?? "").IndexOf( "autorig-editor-rig", StringComparison.OrdinalIgnoreCase ) < 0 )
        {
            Result.refusedWrongProject = true;
            Note( $"REFUSING to run: open project '{Result.projectPath}' is not the autorig-editor-rig scratch" );
            Flush();
            return;
        }

        // A fresh scratch project spends its first minute scanning/importing template
        // content; compiles queued during that window can stall. Let it settle.
        Note( "waiting for the initial asset scan to settle" );
        await Task.Delay( 30_000 );

        var inputPath = Environment.GetEnvironmentVariable( "AUTORIG_GATE_INPUT" );
        if ( string.IsNullOrWhiteSpace( inputPath ) || !File.Exists( inputPath ) )
        {
            Note( $"input model not found (AUTORIG_GATE_INPUT='{inputPath}')" );
            Flush();
            return;
        }

        // ---- analyze + rig + export (the same code path the window uses) ----
        // Unique per-run name: compiled artifacts of previous runs can neither be
        // deleted (the engine holds them open) nor trusted (Model.Load caches by
        // path) - fresh names sidestep both.
        var runTag = (Environment.TickCount & 0xFFFFF).ToString();
        var bytes = await Task.Run( () => File.ReadAllBytes( inputPath ) );
        var fileName = Path.GetFileName( inputPath );
        var (rigSolver, jointCount, bundle) = await Task.Run( () =>
        {
            var mesh = MeshLoader.Load( bytes, fileName );
            var analysis = MeshAnalyzer.Analyze( mesh );
            var rig = AutoRigger.Rig( analysis );
            var exported = AutoRig.Export.RigExporter.Export(
                mesh, rig, $"{Path.GetFileNameWithoutExtension( fileName )}_{runTag}",
                "autorig_gate" );
            return (rig.SolverName, rig.Skeleton.Joints.Count, exported);
        } );
        Result.solver = rigSolver;
        Result.rigJointCount = jointCount;
        Result.rigged = true;
        Note( $"rigged via {rigSolver}: {jointCount} joints" );
        Flush();

        var gateDir = Path.Combine( project.GetAssetsPath(), "autorig_gate" );
        Directory.CreateDirectory( gateDir );
        var fbxPath = Path.Combine( gateDir, bundle.FbxFileName );
        var vmdlPath = Path.Combine( gateDir, bundle.VmdlFileName );
        File.WriteAllBytes( fbxPath, bundle.Fbx );
        File.WriteAllText( vmdlPath, bundle.Vmdl.Replace(
            bundle.FbxFileName, $"autorig_gate/{bundle.FbxFileName}" ) );
        foreach ( var (extraName, extraBytes) in bundle.ExtraFiles )
        {
            var extraPath = Path.Combine( gateDir, extraName );
            File.WriteAllBytes( extraPath, extraBytes );
            AssetSystem.RegisterFile( extraPath );
            Note( $"wrote companion {extraName}" );
        }
        Result.filesWritten = true;
        Note( $"wrote {fbxPath} + {vmdlPath}" );
        Flush();

        // ---- register + load (compile-on-demand) ----
        // Model.Load compiles uncompiled assets synchronously and honestly;
        // Asset.Compile(full) queues externally-written files but never processes
        // them in this headless flow (observed: fresh compiles time out while
        // Model.Load of the same asset succeeds in milliseconds).
        AssetSystem.RegisterFile( fbxPath );
        var asset = AssetSystem.RegisterFile( vmdlPath );
        Result.assetRegistered = asset is not null;
        Flush();
        if ( asset is null )
            return;

        Note( "loading model (compile-on-demand)" );
        Flush();
        var model = Model.Load( asset.Path );
        Result.vmdlCompiled = model is not null && !model.IsError;
        Note( $"vmdlCompiled={Result.vmdlCompiled}" );
        Flush();
        if ( !Result.vmdlCompiled )
            return;
        Result.modelLoads = model is not null && !model.IsError;
        if ( model is not null )
        {
            Result.boneCount = model.BoneCount;
            Result.meshBoundsSize = model.Bounds.Size.Length;
        }
        Note( $"modelLoads={Result.modelLoads} bones={Result.boneCount} "
            + $"boundsSize={Result.meshBoundsSize:0.###} (rig had {Result.rigJointCount})" );
        Flush();

        // ---- control experiments: pinpoint which layer loses the mesh ----
        // (a) the ORIGINAL corpus fbx (Blender-exported, known-good format)
        Result.controlOriginalBounds = await CompileControl( $"ctl_original_{runTag}",
            File.ReadAllBytes( inputPath ) );
        // (b) the original PARSED by our tokenizer and REWRITTEN by our writer
        try
        {
            var rewritten = AutoRig.Formats.Fbx.FbxWriter.Write(
                AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) ) );
            Result.controlRewriteBounds = await CompileControl( $"ctl_rewrite_{runTag}", rewritten );
        }
        catch ( Exception e )
        {
            Note( $"rewrite control failed: {e.Message}" );
        }
        // (c) our writer, mesh+material only (no skeleton/skin objects)
        try
        {
            var meshOnly = await Task.Run( () =>
            {
                var m = MeshLoader.Load( bytes, fileName );
                var a = MeshAnalyzer.Analyze( m );
                var r = AutoRigger.Rig( a );
                return AutoRig.Export.FbxRigWriter.Write( m, r, "ctl_meshonly", includeSkeleton: false );
            } );
            Result.controlMeshOnlyBounds = await CompileControl( $"ctl_meshonly_{runTag}", meshOnly );
        }
        catch ( Exception e )
        {
            Note( $"meshonly control failed: {e.Message}" );
        }
        // (d)/(e) surgical swaps INTO the working original: which scaffolding
        // section of ours poisons the import?
        try
        {
            var parsedOriginal = AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) );
            Result.controlDocSwapBounds = await CompileControl( $"ctl_docswap_{runTag}",
                SwapSection( parsedOriginal, "Documents", BuildOurDocuments() ) );

            parsedOriginal = AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) );
            Result.controlDefsSwapBounds = await CompileControl( $"ctl_defswap_{runTag}",
                SwapSection( parsedOriginal, "Definitions", BuildMinimalDefinitions() ) );
        }
        catch ( Exception e )
        {
            Note( $"swap controls failed: {e.Message}" );
        }

        // (f) original scaffolding + OUR geometry node transplanted into the original
        // (keeps the original's geometry id so connections stay valid).
        try
        {
            var host = AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) );
            var ourFile = await Task.Run( () =>
            {
                var m = MeshLoader.Load( bytes, fileName );
                var a = MeshAnalyzer.Analyze( m );
                var r = AutoRigger.Rig( a );
                return AutoRig.Formats.Fbx.FbxTokenizer.Parse(
                    AutoRig.Export.FbxRigWriter.Write( m, r, "donor", includeSkeleton: false ) );
            } );
            var hostObjects = host.Child( "Objects" );
            var ourGeometry = ourFile.Child( "Objects" ).Children.First( c => c.Name == "Geometry" );
            for ( var i = 0; i < hostObjects.Children.Count; i++ )
            {
                if ( hostObjects.Children[i].Name != "Geometry" )
                    continue;
                var originalId = hostObjects.Children[i].Properties[0];
                ourGeometry.Properties[0] = originalId; // preserve identity for connections
                hostObjects.Children[i] = ourGeometry;
                break; // first geometry only
            }
            Result.controlGeoSwapBounds = await CompileControl( $"ctl_geoswap_{runTag}",
                AutoRig.Formats.Fbx.FbxWriter.Write( host ) );
        }
        catch ( Exception e )
        {
            Note( $"geoswap control failed: {e.Message}" );
        }

        // (g) original file, first geometry STRIPPED to our writer's node set
        // (their data, our structure): distinguishes "we omit a required node"
        // from "our array data is rejected".
        try
        {
            var host = AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) );
            var hostGeometry = host.Child( "Objects" ).Children.First( c => c.Name == "Geometry" );
            var keep = new[]
            {
                "Properties70", "GeometryVersion", "Vertices", "PolygonVertexIndex",
                "LayerElementNormal", "LayerElementMaterial",
            };
            hostGeometry.Children.RemoveAll( c => !keep.Contains( c.Name ) && c.Name != "Layer" );
            var hostLayer = hostGeometry.Children.FirstOrDefault( c => c.Name == "Layer" );
            hostLayer?.Children.RemoveAll( c =>
            {
                if ( c.Name != "LayerElement" )
                    return false;
                var type = c.Children.FirstOrDefault( t => t.Name == "Type" );
                var typeName = type?.Properties.Count > 0 ? type.Properties[0] as string : null;
                return typeName is not ("LayerElementNormal" or "LayerElementMaterial");
            } );
            Result.controlStripBounds = await CompileControl( $"ctl_strip_{runTag}",
                AutoRig.Formats.Fbx.FbxWriter.Write( host ) );
        }
        catch ( Exception e )
        {
            Note( $"strip control failed: {e.Message}" );
        }

        // (h) the stripped host again, but with OUR generated arrays transplanted
        // into THEIR geometry node (Vertices/PolygonVertexIndex/Normals): if this
        // fails, our array DATA is what the importer rejects; if it imports, only
        // node names/metadata remain as suspects.
        try
        {
            var host = AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) );
            var hostGeometry = host.Child( "Objects" ).Children.First( c => c.Name == "Geometry" );
            var keep = new[]
            {
                "Properties70", "GeometryVersion", "Vertices", "PolygonVertexIndex",
                "LayerElementNormal", "LayerElementMaterial", "Layer",
            };
            hostGeometry.Children.RemoveAll( c => !keep.Contains( c.Name ) );
            TrimLayerNode( hostGeometry );

            // Build our tank arrays.
            var tankBytes = await Task.Run( () =>
            {
                var m = MeshLoader.Load( bytes, fileName );
                var a = MeshAnalyzer.Analyze( m );
                var r = AutoRigger.Rig( a );
                return AutoRig.Export.FbxRigWriter.Write( m, r, "arrays", includeSkeleton: false );
            } );
            var ourGeometry = AutoRig.Formats.Fbx.FbxTokenizer.Parse( tankBytes )
                .Child( "Objects" ).Children.First( c => c.Name == "Geometry" );

            foreach ( var arrayName in new[] { "Vertices", "PolygonVertexIndex" } )
            {
                var source = ourGeometry.Children.First( c => c.Name == arrayName );
                var target = hostGeometry.Children.First( c => c.Name == arrayName );
                target.Properties[0] = source.Properties[0];
            }
            // Scale x3 so success is unambiguous: imported → bounds ~2.7, dropped → 0.897.
            var transplantedVertices = (double[])hostGeometry.Children
                .First( c => c.Name == "Vertices" ).Properties[0];
            for ( var i = 0; i < transplantedVertices.Length; i++ )
                transplantedVertices[i] *= 3.0;
            var ourNormals = ourGeometry.Children.First( c => c.Name == "LayerElementNormal" )
                .Children.First( c => c.Name == "Normals" );
            hostGeometry.Children.First( c => c.Name == "LayerElementNormal" )
                .Children.First( c => c.Name == "Normals" ).Properties[0] = ourNormals.Properties[0];

            Result.controlDataSwapBounds = await CompileControl( $"ctl_g1_{runTag}",
                AutoRig.Formats.Fbx.FbxWriter.Write( host ) );

            // (i) our meshonly WITHOUT the UV layer - the UV layer is present in every
            // failing file and absent from every passing one.
            var noUv = AutoRig.Formats.Fbx.FbxTokenizer.Parse( tankBytes );
            var noUvGeometry = noUv.Child( "Objects" ).Children.First( c => c.Name == "Geometry" );
            noUvGeometry.Children.RemoveAll( c => c.Name == "LayerElementUV" );
            var noUvLayer = noUvGeometry.Children.FirstOrDefault( c => c.Name == "Layer" );
            noUvLayer?.Children.RemoveAll( c =>
            {
                if ( c.Name != "LayerElement" )
                    return false;
                var type = c.Children.FirstOrDefault( t => t.Name == "Type" );
                return type?.Properties.Count > 0 && (string)type.Properties[0] == "LayerElementUV";
            } );
            Result.controlNoUvBounds = await CompileControl( $"ctl_nouv_{runTag}",
                AutoRig.Formats.Fbx.FbxWriter.Write( noUv ) );

            // (j) our meshonly with the geometry renamed to the original's name -
            // the last remaining metadata delta.
            var renamed = AutoRig.Formats.Fbx.FbxTokenizer.Parse( tankBytes );
            renamed.Child( "Objects" ).Children.First( c => c.Name == "Geometry" )
                .Properties[1] = "Cube.006\0\x01Geometry";
            Result.controlRenameBounds = await CompileControl( $"ctl_name_{runTag}",
                AutoRig.Formats.Fbx.FbxWriter.Write( renamed ) );

            // (k) THEIR file + our geometry WITHOUT its UV layer (geoswap minus UV):
            // isolates the UV layer as an in-geometry poison.
            var host2 = AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) );
            var ourGeoNoUv = AutoRig.Formats.Fbx.FbxTokenizer.Parse( tankBytes )
                .Child( "Objects" ).Children.First( c => c.Name == "Geometry" );
            ourGeoNoUv.Children.RemoveAll( c => c.Name == "LayerElementUV" );
            TrimLayerNode( ourGeoNoUv ); // drop the UV entry from the Layer node too
            var host2Objects = host2.Child( "Objects" );
            for ( var i = 0; i < host2Objects.Children.Count; i++ )
            {
                if ( host2Objects.Children[i].Name != "Geometry" )
                    continue;
                ourGeoNoUv.Properties[0] = host2Objects.Children[i].Properties[0];
                host2Objects.Children[i] = ourGeoNoUv;
                break;
            }
            Result.controlGeoNoUvBounds = await CompileControl( $"ctl_geonouv_{runTag}",
                AutoRig.Formats.Fbx.FbxWriter.Write( host2 ) );

            // (l) THEIR file + OUR Model node in place of their mesh model
            // (id preserved): isolates our Model node construction.
            var host3 = AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) );
            var ourModel = AutoRig.Formats.Fbx.FbxTokenizer.Parse( tankBytes )
                .Child( "Objects" ).Children.First(
                    c => c.Name == "Model" && (string)c.Properties[2] == "Mesh" );
            var host3Objects = host3.Child( "Objects" );
            for ( var i = 0; i < host3Objects.Children.Count; i++ )
            {
                if ( host3Objects.Children[i].Name != "Model" )
                    continue;
                ourModel.Properties[0] = host3Objects.Children[i].Properties[0];
                ourModel.Properties[1] = host3Objects.Children[i].Properties[1];
                host3Objects.Children[i] = ourModel;
                break;
            }
            Result.controlModelSwapBounds = await CompileControl( $"ctl_modelswap_{runTag}",
                AutoRig.Formats.Fbx.FbxWriter.Write( host3 ) );

            // (m) THEIR scaffold + OUR Objects and Connections wholesale: splits the
            // remaining suspects into header-sections vs object-graph.
            var host4 = AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) );
            var ourDocument = AutoRig.Formats.Fbx.FbxTokenizer.Parse( tankBytes );
            for ( var i = 0; i < host4.Children.Count; i++ )
            {
                if ( host4.Children[i].Name is "Objects" or "Connections" )
                    host4.Children[i] = ourDocument.Child( host4.Children[i].Name );
            }
            Result.controlHybridBounds = await CompileControl( $"ctl_hybrid_{runTag}",
                AutoRig.Formats.Fbx.FbxWriter.Write( host4 ) );

            // (n)-(p) scaffold bisect: swap ONE of our scaffold groups into their file.
            async Task<float> ScaffoldSwap( string tag, params string[] sections )
            {
                var target = AutoRig.Formats.Fbx.FbxTokenizer.Parse( File.ReadAllBytes( inputPath ) );
                var source = AutoRig.Formats.Fbx.FbxTokenizer.Parse( tankBytes );
                for ( var i = 0; i < target.Children.Count; i++ )
                {
                    if ( sections.Contains( target.Children[i].Name ) )
                        target.Children[i] = source.Child( target.Children[i].Name );
                }
                return await CompileControl( $"ctl_{tag}_{runTag}",
                    AutoRig.Formats.Fbx.FbxWriter.Write( target ) );
            }
            Result.controlScaffoldHeaderBounds = await ScaffoldSwap( "s1",
                "FBXHeaderExtension", "FileId", "CreationTime", "Creator" );
            Result.controlScaffoldSettingsBounds = await ScaffoldSwap( "s2", "GlobalSettings" );
            Result.controlScaffoldDocsBounds = await ScaffoldSwap( "s3",
                "Documents", "References", "Definitions", "Takes" );
        }
        catch ( Exception e )
        {
            Note( $"dataswap control failed: {e.Message}" );
        }

        Note( $"controls: original={Result.controlOriginalBounds:0.###} "
            + $"rewrite={Result.controlRewriteBounds:0.###} meshonly={Result.controlMeshOnlyBounds:0.###} "
            + $"docswap={Result.controlDocSwapBounds:0.###} defswap={Result.controlDefsSwapBounds:0.###} "
            + $"geoswap={Result.controlGeoSwapBounds:0.###} strip={Result.controlStripBounds:0.###} "
            + $"dataswap={Result.controlDataSwapBounds:0.###} nouv={Result.controlNoUvBounds:0.###} "
            + $"rename={Result.controlRenameBounds:0.###} geonouv={Result.controlGeoNoUvBounds:0.###} "
            + $"modelswap={Result.controlModelSwapBounds:0.###} hybrid={Result.controlHybridBounds:0.###} "
            + $"s1={Result.controlScaffoldHeaderBounds:0.###} s2={Result.controlScaffoldSettingsBounds:0.###} "
            + $"s3={Result.controlScaffoldDocsBounds:0.###}" );
        Flush();
    }

    static void TrimLayerNode( AutoRig.Formats.Fbx.FbxNode geometry )
    {
        var layer = geometry.Children.FirstOrDefault( c => c.Name == "Layer" );
        layer?.Children.RemoveAll( c =>
        {
            if ( c.Name != "LayerElement" )
                return false;
            var type = c.Children.FirstOrDefault( t => t.Name == "Type" );
            var typeName = type?.Properties.Count > 0 ? type.Properties[0] as string : null;
            return typeName is not ("LayerElementNormal" or "LayerElementMaterial");
        } );
    }

    static byte[] SwapSection( AutoRig.Formats.Fbx.FbxNode document, string sectionName,
        AutoRig.Formats.Fbx.FbxNode replacement )
    {
        for ( var i = 0; i < document.Children.Count; i++ )
        {
            if ( document.Children[i].Name == sectionName )
            {
                document.Children[i] = replacement;
                break;
            }
        }
        return AutoRig.Formats.Fbx.FbxWriter.Write( document );
    }

    static AutoRig.Formats.Fbx.FbxNode BuildOurDocuments()
    {
        var documents = new AutoRig.Formats.Fbx.FbxNode( "Documents" );
        var count = new AutoRig.Formats.Fbx.FbxNode( "Count" );
        count.Properties.Add( 1 );
        documents.Children.Add( count );
        var document = new AutoRig.Formats.Fbx.FbxNode( "Document" );
        document.Properties.Add( 999999L );
        document.Properties.Add( "" );
        document.Properties.Add( "Scene" );
        var rootNode = new AutoRig.Formats.Fbx.FbxNode( "RootNode" );
        rootNode.Properties.Add( 0L );
        document.Children.Add( rootNode );
        documents.Children.Add( document );
        return documents;
    }

    static AutoRig.Formats.Fbx.FbxNode BuildMinimalDefinitions()
    {
        var definitions = new AutoRig.Formats.Fbx.FbxNode( "Definitions" );
        var version = new AutoRig.Formats.Fbx.FbxNode( "Version" );
        version.Properties.Add( 100 );
        definitions.Children.Add( version );
        var count = new AutoRig.Formats.Fbx.FbxNode( "Count" );
        count.Properties.Add( 4 );
        definitions.Children.Add( count );
        foreach ( var (typeName, typeCount) in new (string, int)[]
        {
            ("GlobalSettings", 1), ("Model", 2), ("Geometry", 2), ("Material", 2),
        } )
        {
            var objectType = new AutoRig.Formats.Fbx.FbxNode( "ObjectType" );
            objectType.Properties.Add( typeName );
            var typeCountNode = new AutoRig.Formats.Fbx.FbxNode( "Count" );
            typeCountNode.Properties.Add( typeCount );
            objectType.Children.Add( typeCountNode );
            definitions.Children.Add( objectType );
        }
        return definitions;
    }

    /// <summary>Writes an fbx + vmdl pair, compiles, returns the loaded model's bounds size.</summary>
    static async Task<float> CompileControl( string name, byte[] fbxBytes )
    {
        try
        {
            var gateDir = Path.Combine( Project.Current.GetAssetsPath(), "autorig_gate" );
            Directory.CreateDirectory( gateDir );
            var fbxPath = Path.Combine( gateDir, $"{name}.fbx" );
            var vmdlPath = Path.Combine( gateDir, $"{name}.vmdl" );
            File.WriteAllBytes( fbxPath, fbxBytes );
            File.WriteAllText( vmdlPath, AutoRig.Export.VmdlGenerator.Generate(
                $"autorig_gate/{name}.fbx", name ) );
            AssetSystem.RegisterFile( fbxPath );
            var asset = AssetSystem.RegisterFile( vmdlPath );
            if ( asset is null )
                return -1f;
            await Task.Yield();
            var model = Model.Load( asset.Path ); // compile-on-demand
            return model is null || model.IsError ? -2f : model.Bounds.Size.Length;
        }
        catch ( Exception e )
        {
            Log.Info( $"[autorig-gate] control '{name}' failed: {e.Message}" );
            return -3f;
        }
    }

    // ---- plumbing (donor M0Gate pattern) ----

    static async Task<bool> WaitUntil( Func<bool> condition, float timeoutSeconds )
    {
        var sw = Stopwatch.StartNew();
        while ( sw.Elapsed.TotalSeconds < timeoutSeconds )
        {
            bool ok = false;
            try { ok = condition(); }
            catch { /* not ready yet */ }
            if ( ok )
                return true;
            await Task.Delay( 250 );
        }
        return false;
    }

    static T TryGet<T>( Func<T> getter )
    {
        try { return getter(); }
        catch { return default; }
    }

    static void Note( string message )
    {
        Result.log.Add( $"[{DateTime.UtcNow:HH:mm:ss.fff}] {message}" );
        Log.Info( $"[autorig-gate] {message}" );
    }

    static void Flush()
    {
        try
        {
            File.WriteAllText( _resultPath, JsonSerializer.Serialize( Result,
                new JsonSerializerOptions { WriteIndented = true } ) );
        }
        catch
        {
            // never let result IO take the editor down
        }
    }

    class GateResult
    {
        public bool engineBooted { get; set; }
        public bool assetSystemReady { get; set; }
        public string projectPath { get; set; }
        public bool rigged { get; set; }
        public string solver { get; set; }
        public int rigJointCount { get; set; }
        public bool filesWritten { get; set; }
        public bool assetRegistered { get; set; }
        public bool vmdlCompiled { get; set; }
        public bool modelLoads { get; set; }
        public int boneCount { get; set; }
        public float meshBoundsSize { get; set; }
        public float controlOriginalBounds { get; set; }
        public float controlRewriteBounds { get; set; }
        public float controlMeshOnlyBounds { get; set; }
        public float controlDocSwapBounds { get; set; }
        public float controlDefsSwapBounds { get; set; }
        public float controlGeoSwapBounds { get; set; }
        public float controlStripBounds { get; set; }
        public float controlDataSwapBounds { get; set; }
        public float controlNoUvBounds { get; set; }
        public float controlRenameBounds { get; set; }
        public float controlGeoNoUvBounds { get; set; }
        public float controlModelSwapBounds { get; set; }
        public float controlHybridBounds { get; set; }
        public float controlScaffoldHeaderBounds { get; set; }
        public float controlScaffoldSettingsBounds { get; set; }
        public float controlScaffoldDocsBounds { get; set; }
        public bool refusedWrongProject { get; set; }
        public bool completed { get; set; }
        public bool passed { get; set; }
        public System.Collections.Generic.List<string> log { get; set; } = new();
    }
}
using System;
using System.Collections.Generic;
using System.Linq;
using AutoRig.Mesh;
using AutoRig.Rig;
using Editor;
using Sandbox;
using VecN = System.Numerics.Vector3;
using QuatN = System.Numerics.Quaternion;

namespace AutoRig.Editor;

/// <summary>
/// Pre-compile rig preview: the source mesh as a capped wireframe and the generated
/// skeleton as stick bones with joint markers, in an orbitable editor scene (same
/// idiom as humanoid-retargeter's PreviewWidget). The wiggle test rotates each joint
/// ±15° in sequence and CPU-skins the wireframe so a novice can SEE whether the rig
/// deforms sensibly before exporting.
/// </summary>
public sealed class RigPreviewWidget : SceneRenderingWidget
{
    const int MaxWireSegments = 9000;
    const int MaxSkinnedVertices = 150_000;
    const float WiggleDegrees = 15f;
    const float SecondsPerJoint = 0.9f;

    SceneLineObject _wire;
    SceneLineObject _bones;

    RigMesh _mesh;
    RigResult _rig;

    // Precomputed FK/skin state.
    VecN[] _bindWorld;          // joint bind positions
    VecN[] _posedWorld;         // joint posed positions (wiggle)
    QuatN[] _poseRotation;      // per-joint world rotation during wiggle
    int[] _wireVertexIndices;   // mesh vertex ids used by the capped wireframe, unique
    Vector3[] _wirePositions;   // posed positions for those vertices
    Dictionary<int, int> _wireSlotOf;
    (int A, int B)[] _wireSegments;

    float _yaw = 35f;
    float _zoom = 1f;
    int _wiggleFrame;
    Vector2 _lastMouse;
    float _wiggleTime = -1f;    // < 0 = not wiggling
    bool _skinnedWiggle;

    // ComputeBounds is O(all vertices); it was being called every frame from
    // both UpdateCamera and RedrawWire, which pegged a core with a big mesh in
    // the preview. Cache the extent + center once per SetRig instead.
    float _boundsLength = 1f;
    Vector3 _boundsCenter;      // scene-space
    int _dragJoint = -1;        // joint being dragged (move/deform preview), or -1
    bool _dragMoved;            // this gesture actually moved the joint
    bool _posedActive;          // the live pose diverges from the bind pose

    /// <summary>Fired once, at the first movement of a joint-drag, BEFORE the
    /// pose changes - so the dialog can snapshot for undo.</summary>
    public Action BeforeJointMove { get; set; }

    /// <summary>Ctrl+Z inside the viewport (the widget usually holds focus after
    /// a click/drag) - the dialog runs its undo.</summary>
    public Action UndoRequested { get; set; }

    protected override void OnKeyPress( KeyEvent e )
    {
        if ( e.Key == KeyCode.Z && e.HasCtrl )
        {
            UndoRequested?.Invoke();
            return;
        }
        base.OnKeyPress( e );
    }

    /// <summary>The currently highlighted joint name ("" when none).</summary>
    public string SelectedJoint => _highlight;

    /// <summary>Current live joint positions (mesh space), for undo snapshots.</summary>
    public VecN[] GetPose() => _posedWorld is null ? null : (VecN[])_posedWorld.Clone();

    /// <summary>Restores a joint pose captured by <see cref="GetPose"/>.</summary>
    public void SetPose( VecN[] pose )
    {
        if ( pose is null || _posedWorld is null || pose.Length != _posedWorld.Length )
            return;
        Array.Copy( pose, _posedWorld, pose.Length );
        _posedActive = false;
        for ( var i = 0; i < _posedWorld.Length && !_posedActive; i++ )
            if ( _bindWorld is not null && _posedWorld[i] != _bindWorld[i] )
                _posedActive = true;
        if ( _skinnedWiggle )
            RebuildSolid( posed: _posedActive );
        RebuildSkeletonGeometry();
    }

    /// <summary>True while the wiggle test runs.</summary>
    public bool Wiggling => _wiggleTime >= 0f;

    /// <summary>Name of the joint currently wiggling ("" when idle).</summary>
    public string WigglingJoint { get; private set; } = "";

    SceneObject _solid;

    public RigPreviewWidget( Widget parent ) : base( parent )
    {
        MinimumSize = new Vector2( 320, 320 );
        MouseTracking = true;

        Scene = Scene.CreateEditorScene();
        using ( Scene.Push() )
        {
            Camera = new GameObject( true, "camera" ).GetOrAddComponent<CameraComponent>( false );
            Camera.BackgroundColor = Theme.ControlBackground;
            Camera.ZNear = 0.01f;
            Camera.ZFar = 8192f;
            Camera.FieldOfView = 45f;
            Camera.Enabled = true;
        }

        // Same lighting rig as humanoid-retargeter's preview: a warm key and a
        // cooler fill, no shadows (lines don't cast; the solid mesh reads cleanly).
        var world = Scene.SceneWorld;
        new ScenePointLight( world, new Vector3( 120, 100, 120 ), 600, Color.White * 3.5f ).ShadowsEnabled = false;
        new ScenePointLight( world, new Vector3( -120, -100, 90 ), 600, Color.White * 2.0f ).ShadowsEnabled = false;

        _wire = new SceneLineObject( world ) { Opaque = false, Lighting = false };
        // No overlay-layer tricks: that pass is not rendered by this widget's
        // camera (bones vanished entirely). Rigged view hides the solid instead,
        // so the skeleton inside the translucent wireframe is always visible.
        _bones = new SceneLineObject( world ) { Opaque = false, Lighting = false };
    }

    /// <summary>Builds a lit solid model from the raw mesh (the wireframe alone was
    /// nearly invisible: fixed-width translucent lines, unlit scene).</summary>
    /// <param name="posed">CPU-skin against the current wiggle pose (the solid IS
    /// the model the user watches - the line wireframe never renders here).</param>
    void RebuildSolid( bool posed = false )
    {
        _solid?.Delete();
        _solid = null;
        if ( _mesh is null || _mesh.TriangleCount == 0 )
            return;

        try
        {
            var material = TexturedMaterial()
                ?? Material.Load( "materials/dev/reflectivity_30.vmat" )
                ?? Material.Load( "materials/default/white.vmat" );
            var sceneMesh = new Sandbox.Mesh( material );
            var vertices = new List<SimpleVertex>( _mesh.Triangles.Length );
            for ( var t = 0; t < _mesh.TriangleCount; t++ )
            {
                for ( var k = 0; k < 3; k++ )
                {
                    var v = _mesh.Triangles[t * 3 + k];
                    var p = posed && _rig is not null ? SkinVertex( v ) : _mesh.Positions[v];
                    var n = v < _mesh.Normals.Length ? _mesh.Normals[v] : System.Numerics.Vector3.UnitZ;
                    var uv = v < _mesh.Uvs.Length ? _mesh.Uvs[v] : default;
                    vertices.Add( new SimpleVertex(
                        new Vector3( p.X, p.Y, p.Z ),
                        new Vector3( n.X, n.Y, n.Z ),
                        Vector3.Zero,
                        new Vector2( uv.X, uv.Y ) ) );
                }
            }
            sceneMesh.CreateVertexBuffer( vertices.Count, SimpleVertex.Layout, vertices );
            var model = Model.Builder.AddMesh( sceneMesh ).Create();
            _solid = new SceneObject( Scene.SceneWorld, model,
                new Transform( Vector3.Zero, Rotation.FromAxis( new Vector3( 1, 0, 0 ), 90f ) ) )
            {
                ColorTint = new Color( 0.62f, 0.66f, 0.72f ),
            };
            if ( _rig is not null )
            {
                // Ghost immediately (per-frame rebuilds must not flicker opaque).
                _solid.ColorTint = new Color( 0.62f, 0.66f, 0.72f, 0.3f );
                _solid.Flags.IsTranslucent = true;
                _solid.Flags.IsOpaque = false;
            }
        }
        catch ( Exception )
        {
            _solid?.Delete();
            _solid = null;   // wireframe fallback still draws below
        }
    }

    Material _texturedMaterial;
    bool _texturedTried;

    /// <summary>The mesh's base-color image (glTF/FBX embedded texture) as a
    /// preview material, decoded once. Null when absent/undecodable - the flat
    /// dev material stands in.</summary>
    Material TexturedMaterial()
    {
        if ( _texturedTried )
            return _texturedMaterial;
        _texturedTried = true;
        var image = _mesh?.Materials?.FirstOrDefault( m => m.BaseColorImage is not null )
            ?.BaseColorImage;
        if ( image is null )
            return null;
        try
        {
            var bitmap = Bitmap.CreateFromBytes( image );
            if ( bitmap is null )
                return null;
            var texture = bitmap.ToTexture();
            var material = Material.Create( $"autorig_preview_{GetHashCode()}", "simple" );
            material.Set( "Color", texture );
            _texturedMaterial = material;
            return material;
        }
        catch ( Exception )
        {
            return null;
        }
    }

    /// <summary>Installs the mesh + rig to display (null rig = mesh only).</summary>
    public void SetRig( RigMesh mesh, RigResult rig )
    {
        _mesh = mesh;
        _rig = rig;
        _wiggleTime = -1f;
        WigglingJoint = "";

        if ( mesh is null )
        {
            _wire.Clear();
            _bones.Clear();
            _solid?.Delete();
            _solid = null;
            _skeletonObject?.Delete();
            _skeletonObject = null;
            _boneObject?.Delete();
            _boneObject = null;
            _highlightObject?.Delete();
            _highlightObject = null;
            return;
        }

        var meshBounds = mesh.ComputeBounds();
        _boundsLength = MathF.Max( meshBounds.Size.Length(), 1e-3f );
        _boundsCenter = ToVector3( meshBounds.Center );
        RebuildSolid();
        BuildWireTopology();
        if ( rig is not null )
        {
            _bindWorld = new VecN[rig.Skeleton.Joints.Count];
            _posedWorld = new VecN[rig.Skeleton.Joints.Count];
            _poseRotation = new QuatN[rig.Skeleton.Joints.Count];
            for ( var i = 0; i < rig.Skeleton.Joints.Count; i++ )
            {
                _bindWorld[i] = rig.Skeleton.Joints[i].Position;
                _posedWorld[i] = _bindWorld[i];
                _poseRotation[i] = QuatN.Identity;
            }
            // No vertex cap: a static model during the wiggle test defeats the test.
            // Heavy meshes throttle the rebuild rate instead (see PreFrame).
            _skinnedWiggle = rig.Weights is not null;
        }

        RedrawWire( bindPose: true );
        RedrawBones();
        RebuildSkeletonGeometry();
        UpdateLineBounds();
    }

    /// <summary>SceneLineObjects keep tiny default bounds and get frustum-culled -
    /// the reason neither the wireframe nor the skeleton ever showed. Give both the
    /// mesh's (scene-space) bounds, padded.</summary>
    void UpdateLineBounds()
    {
        if ( _mesh is null )
            return;
        var bounds = _mesh.ComputeBounds();
        var a = ToVector3( bounds.Min );
        var b = ToVector3( bounds.Max );
        var box = new BBox( Vector3.Min( a, b ), Vector3.Max( a, b ) );
        box = box.Grow( bounds.Size.Length() * 0.5f + 1f );
        _wire.Bounds = box;
        _bones.Bounds = box;
    }

    /// <summary>Starts the wiggle test (each joint ±15° in sequence; root skipped).</summary>
    public void StartWiggle()
    {
        if ( _rig is null )
            return;
        _wiggleTime = 0f;
        _wiggleFrame = 0;
        Log.Info( $"[auto-rig] wiggle: {_mesh?.Positions.Length ?? 0} verts, "
            + $"skinned deform = {_skinnedWiggle}" );
    }

    /// <summary>Stops the wiggle test and returns to the bind pose.</summary>
    public void StopWiggle()
    {
        _wiggleTime = -1f;
        WigglingJoint = "";
        if ( _rig is not null )
        {
            for ( var i = 0; i < _poseRotation.Length; i++ )
            {
                _poseRotation[i] = QuatN.Identity;
                _posedWorld[i] = _bindWorld[i];
            }
        }
        RedrawWire( bindPose: true );
        RedrawBones();
        RebuildSkeletonGeometry();
        RebuildSolid();   // back to the bind pose
    }

    protected override void PreFrame()
    {
        Scene.EditorTick( RealTime.Now, RealTime.Delta );
        UpdateCamera();

        // The wire/skeleton are SceneLineObjects - they must be re-submitted each
        // frame or they vanish. Submission is cheap (bounded by the wire cap); it
        // was the two O(all-verts) ComputeBounds() calls per frame (here + in
        // UpdateCamera) that pegged a core with a preview open - both now cached.
        if ( _mesh is not null && _wiggleTime < 0f )
        {
            RedrawWire( bindPose: !_posedActive );
            RedrawBones();
        }

        if ( _wiggleTime >= 0f && _rig is not null )
        {
            _wiggleTime += RealTime.Delta;
            var jointCount = _rig.Skeleton.Joints.Count;
            var wigglable = Math.Max( 1, jointCount - 1 ); // skip the root
            var slot = (int)(_wiggleTime / SecondsPerJoint);
            if ( slot >= wigglable )
            {
                StopWiggle();
                return;
            }
            var joint = slot + 1; // joints are parent-before-child; 0 is root
            WigglingJoint = _rig.Skeleton.Joints[joint].Name;

            var phase = (_wiggleTime % SecondsPerJoint) / SecondsPerJoint; // 0..1
            var angle = MathF.Sin( phase * MathF.Tau ) * WiggleDegrees * (MathF.PI / 180f);
            ApplyWigglePose( joint, angle );
            RedrawWire( bindPose: false );
            RedrawBones();
            RebuildSkeletonGeometry();
            // The solid IS what the user watches - deform it with the pose. Heavy
            // meshes rebuild every 3rd frame (choppier but MOVING).
            _wiggleFrame++;
            if ( _skinnedWiggle
                && (_mesh.Positions.Length <= 80_000 || _wiggleFrame % 3 == 0) )
                RebuildSolid( posed: true );
        }
    }

    /// <summary>FK pose with a single joint rotated about its hinge axis (or X).</summary>
    void ApplyWigglePose( int wiggleJoint, float angle )
    {
        var joints = _rig.Skeleton.Joints;
        var axis = joints[wiggleJoint].HingeAxis;
        var axisN = axis == VecN.Zero ? VecN.UnitX : VecN.Normalize( axis );
        var spin = QuatN.CreateFromAxisAngle( axisN, angle );

        for ( var i = 0; i < joints.Count; i++ )
        {
            var parent = joints[i].Parent;
            if ( parent < 0 )
            {
                _poseRotation[i] = i == wiggleJoint ? spin : QuatN.Identity;
                _posedWorld[i] = _bindWorld[i];
                continue;
            }
            var localOffset = _bindWorld[i] - _bindWorld[parent];
            var parentRotation = _poseRotation[parent];
            _posedWorld[i] = _posedWorld[parent] + VecN.Transform( localOffset, parentRotation );
            _poseRotation[i] = i == wiggleJoint
                ? parentRotation * spin
                : parentRotation;
        }
    }

    /// <summary>Collects a capped set of triangle edges (stride over triangles) plus the
    /// unique vertex list they reference, so wiggle re-skins only what is drawn.</summary>
    void BuildWireTopology()
    {
        var segments = new List<(int A, int B)>();
        _wireSlotOf = new Dictionary<int, int>();
        var vertexIds = new List<int>();

        int SlotOf( int vertex )
        {
            if ( !_wireSlotOf.TryGetValue( vertex, out var slot ) )
            {
                slot = vertexIds.Count;
                vertexIds.Add( vertex );
                _wireSlotOf.Add( vertex, slot );
            }
            return slot;
        }

        var stride = Math.Max( 1, _mesh.TriangleCount * 3 / MaxWireSegments );
        for ( var t = 0; t < _mesh.TriangleCount; t += stride )
        {
            var a = _mesh.Triangles[t * 3];
            var b = _mesh.Triangles[t * 3 + 1];
            var c = _mesh.Triangles[t * 3 + 2];
            segments.Add( (SlotOf( a ), SlotOf( b )) );
            segments.Add( (SlotOf( b ), SlotOf( c )) );
            segments.Add( (SlotOf( c ), SlotOf( a )) );
        }

        _wireSegments = segments.ToArray();
        _wireVertexIndices = vertexIds.ToArray();
        _wirePositions = new Vector3[_wireVertexIndices.Length];
    }

    void RedrawWire( bool bindPose )
    {
        if ( _mesh is null || _wireSegments is null )
            return;

        for ( var s = 0; s < _wireVertexIndices.Length; s++ )
        {
            var v = _wireVertexIndices[s];
            var p = (!bindPose && _skinnedWiggle && _rig is not null)
                ? SkinVertex( v )
                : _mesh.Positions[v];
            _wirePositions[s] = ToVector3( p );
        }

        // Width relative to the model (a fixed width is dust on big models, a blob
        // on small ones). During a skinned wiggle the wire is the star: brighter,
        // and the static solid hides so the deformation reads.
        var scale = _boundsLength;
        var width = scale * 0.0012f;
        // The solid model stays visible ALWAYS (an empty viewport is worse than an
        // occluded bone); wire is a subtle shell, brighter while wiggling.
        var color = bindPose
            ? new Color( 0.65f, 0.75f, 0.85f, 0.2f )
            : new Color( 0.55f, 0.85f, 1f, 0.85f );
        if ( _solid is not null )
        {
            _solid.RenderingEnabled = true;   // the solid is the model, always shown
            // With a rig: ghost the body so the skeleton reads through it.
            var ghosted = _rig is not null;
            _solid.ColorTint = ghosted
                ? new Color( 0.62f, 0.66f, 0.72f, 0.3f )
                : new Color( 0.62f, 0.66f, 0.72f, 1f );
            _solid.Flags.IsTranslucent = ghosted;
            _solid.Flags.IsOpaque = !ghosted;
        }

        _wire.Clear();
        foreach ( var (a, b) in _wireSegments )
        {
            _wire.StartLine();
            _wire.AddLinePoint( _wirePositions[a], color, width );
            _wire.AddLinePoint( _wirePositions[b], color, width );
            _wire.EndLine();
        }
    }

    /// <summary>Linear-blend skin of one vertex against the current wiggle pose.</summary>
    VecN SkinVertex( int v )
    {
        var bind = _mesh.Positions[v];
        var result = VecN.Zero;
        float total = 0;
        for ( var k = 0; k < 4; k++ )
        {
            var w = _rig.Weights.Weights[v * 4 + k];
            if ( w <= 0f )
                continue;
            var bone = _rig.Weights.BoneIndices[v * 4 + k];
            var local = bind - _bindWorld[bone];
            result += (_posedWorld[bone] + VecN.Transform( local, _poseRotation[bone] )) * w;
            total += w;
        }
        return total > 1e-4f ? result / total : bind;
    }

    SceneObject _skeletonObject;   // joint octahedra (red, the traditional idiom)
    SceneObject _boneObject;       // bone bipyramids (blue)
    SceneObject _highlightObject;
    string _highlight = "";

    /// <summary>Marks one joint (by name) with a bigger yellow marker - driven by
    /// the adjust panel's selection.</summary>
    public void SetHighlight( string jointName )
    {
        _highlight = jointName ?? "";
        RebuildSkeletonGeometry();
    }

    /// <summary>Raised when the user clicks a joint in the viewport (joint name),
    /// or clicks empty space (null - selection cleared).</summary>
    public Action<string> JointPicked { get; set; }

    /// <summary>The joint under the given widget-local position, or -1. Joints
    /// are projected through the same orbit camera the viewport renders with;
    /// the nearest projected joint within a forgiving radius wins.</summary>
    int PickJoint( Vector2 local )
    {
        if ( _rig is null || _posedWorld is null || !Camera.IsValid() )
            return -1;

        var forward = Camera.WorldRotation.Forward;
        var right = Camera.WorldRotation.Right;
        var up = Camera.WorldRotation.Up;
        var origin = Camera.WorldPosition;

        // Vertical-FOV pinhole projection. Even if the engine's FOV convention
        // differs slightly, nearest-projected-joint keeps picks on target.
        var tanHalf = MathF.Tan( MathX.DegreeToRadian( Camera.FieldOfView ) * 0.5f );
        var aspect = Width > 0 ? (float)Width / Math.Max( (float)Height, 1f ) : 1f;

        var best = -1;
        var bestDistance = float.MaxValue;
        for ( var i = 0; i < _posedWorld.Length; i++ )
        {
            var world = ToVector3( _posedWorld[i] );
            var toJoint = world - origin;
            var depth = Vector3.Dot( toJoint, forward );
            if ( depth <= 0.001f )
                continue;   // behind the camera
            var ndcX = Vector3.Dot( toJoint, right ) / (depth * tanHalf * aspect);
            var ndcY = Vector3.Dot( toJoint, up ) / (depth * tanHalf);
            var px = (ndcX * 0.5f + 0.5f) * Width;
            var py = (0.5f - ndcY * 0.5f) * Height;
            var d = new Vector2( px, py ).Distance( local );
            if ( d < bestDistance )
            {
                bestDistance = d;
                best = i;
            }
        }

        // Forgiving click target: ~4% of the viewport's smaller side, min 14px.
        var tolerance = MathF.Max( MathF.Min( (float)Width, (float)Height ) * 0.04f, 14f );
        return bestDistance <= tolerance ? best : -1;
    }

    /// <summary>Right-clicked a joint (name) - the dialog raises its context menu.</summary>
    public Action<string> JointContextRequested { get; set; }

    /// <summary>Re-applies the (edited) rig after a delete/rename, rebuilding all
    /// preview geometry and clearing any live move-pose.</summary>
    public void Reload( RigResult rig )
    {
        _posedActive = false;
        SetRig( _mesh, rig );
    }

    /// <summary>Snaps the live move-pose back to the rest skeleton.</summary>
    public void ResetPose()
    {
        if ( _rig is null || _bindWorld is null )
            return;
        for ( var i = 0; i < _posedWorld.Length; i++ )
        {
            _posedWorld[i] = _bindWorld[i];
            _poseRotation[i] = QuatN.Identity;
        }
        _posedActive = false;
        RebuildSolid();
        RebuildSkeletonGeometry();
    }

    protected override void OnMousePress( MouseEvent e )
    {
        base.OnMousePress( e );
        _lastMouse = e.LocalPosition;
        if ( _rig is null )
            return;

        var picked = PickJoint( e.LocalPosition );

        if ( e.RightMouseButton && picked >= 0 )
        {
            SetHighlight( _rig.Skeleton.Joints[picked].Name );
            JointPicked?.Invoke( _rig.Skeleton.Joints[picked].Name );
            JointContextRequested?.Invoke( _rig.Skeleton.Joints[picked].Name );
            return;
        }

        if ( e.LeftMouseButton )
        {
            if ( picked >= 0 )
            {
                SetHighlight( _rig.Skeleton.Joints[picked].Name );
                JointPicked?.Invoke( _rig.Skeleton.Joints[picked].Name );
                _dragJoint = picked;   // a left-drag from a joint MOVES it (deform preview)
                _dragMoved = false;
            }
            else
            {
                _dragJoint = -1;
                if ( _highlight.Length > 0 )
                {
                    SetHighlight( "" );
                    JointPicked?.Invoke( null );
                }
            }
        }
    }

    protected override void OnMouseReleased( MouseEvent e )
    {
        base.OnMouseReleased( e );
        _dragJoint = -1;
        _dragMoved = false;
    }

    /// <summary>Mesh-space delta from a scene-space delta (inverse of ToVector3).</summary>
    static VecN FromVector3( Vector3 v ) => new( v.x, v.z, -v.y );

    /// <summary>Translates a joint AND its whole subtree in the LIVE pose (not the
    /// rest skeleton), so the mesh deforms via the existing skin path - a
    /// translation analogue of the wiggle test. Not persisted: it is a
    /// "does this joint drive the right vertices?" check.</summary>
    void MoveJointSubtree( int root, VecN meshDelta )
    {
        if ( _rig is null || _posedWorld is null )
            return;
        if ( !_dragMoved )
        {
            BeforeJointMove?.Invoke();   // snapshot the pre-move pose for undo
            _dragMoved = true;
        }
        var joints = _rig.Skeleton.Joints;
        var stack = new Stack<int>();
        stack.Push( root );
        while ( stack.Count > 0 )
        {
            var j = stack.Pop();
            _posedWorld[j] += meshDelta;
            for ( var c = 0; c < joints.Count; c++ )
                if ( joints[c].Parent == j )
                    stack.Push( c );
        }
        _posedActive = true;
        if ( _skinnedWiggle )
            RebuildSolid( posed: true );
        RebuildSkeletonGeometry();
    }

    /// <summary>
    /// The skeleton as REAL geometry (SceneLineObject never renders in this widget;
    /// SceneObject+Model provably does): each bone an elongated bipyramid, each
    /// joint an octahedron, ivory-tinted, rebuilt from the current pose.
    /// </summary>
    void RebuildSkeletonGeometry()
    {
        _skeletonObject?.Delete();
        _skeletonObject = null;
        _boneObject?.Delete();
        _boneObject = null;
        _highlightObject?.Delete();
        _highlightObject = null;
        if ( _rig is null || _mesh is null )
            return;

        try
        {
            var joints = _rig.Skeleton.Joints;
            var scale = _boundsLength;
            var jointRadius = scale * 0.008f;
            var boneRadius = scale * 0.004f;

            var jointVertices = new List<SimpleVertex>();
            var boneVertices = new List<SimpleVertex>();
            var vertices = jointVertices;   // Triangle() writes into this target
            void Triangle( Vector3 a, Vector3 b, Vector3 c )
            {
                var normal = Vector3.Cross( b - a, c - a ).Normal;
                vertices.Add( new SimpleVertex( a, normal, Vector3.Zero, Vector2.Zero ) );
                vertices.Add( new SimpleVertex( b, normal, Vector3.Zero, Vector2.Zero ) );
                vertices.Add( new SimpleVertex( c, normal, Vector3.Zero, Vector2.Zero ) );
                // Backface too - bones must read from every side.
                vertices.Add( new SimpleVertex( a, -normal, Vector3.Zero, Vector2.Zero ) );
                vertices.Add( new SimpleVertex( c, -normal, Vector3.Zero, Vector2.Zero ) );
                vertices.Add( new SimpleVertex( b, -normal, Vector3.Zero, Vector2.Zero ) );
            }
            void Octahedron( Vector3 center, float radius )
            {
                foreach ( var sx in new[] { -1f, 1f } )
                    foreach ( var sy in new[] { -1f, 1f } )
                        foreach ( var sz in new[] { -1f, 1f } )
                            Triangle(
                                center + Vector3.Forward * radius * sx,
                                center + Vector3.Left * radius * sy,
                                center + Vector3.Up * radius * sz );
            }
            void Bone( Vector3 from, Vector3 to, float radius )
            {
                var direction = to - from;
                if ( direction.Length < 1e-6f )
                    return;
                var side = Vector3.Cross( direction.Normal, Vector3.Up );
                if ( side.Length < 1e-4f )
                    side = Vector3.Cross( direction.Normal, Vector3.Forward );
                var s1 = side.Normal * radius;
                var s2 = Vector3.Cross( direction.Normal, side.Normal ).Normal * radius;
                var hub = from + direction * 0.15f;
                foreach ( var (a, b) in new[] { (s1, s2), (s2, -s1), (-s1, -s2), (-s2, s1) } )
                {
                    Triangle( from, hub + a, hub + b );
                    Triangle( hub + a, to, hub + b );
                }
            }

            var highlighted = _highlight.Length > 0
                ? joints.FindIndex( j => j.Name == _highlight ) : -1;

            for ( var i = 0; i < joints.Count; i++ )
            {
                var pos = ToVector3( _posedWorld[i] );
                if ( i != highlighted )   // the selected joint is drawn GREEN below
                {
                    vertices = jointVertices;
                    Octahedron( pos, jointRadius );
                }
                if ( joints[i].Parent >= 0 )
                {
                    vertices = boneVertices;
                    Bone( ToVector3( _posedWorld[joints[i].Parent] ), pos, boneRadius );
                }
            }

            var material = Material.Load( "materials/dev/reflectivity_30.vmat" )
                ?? Material.Load( "materials/default/white.vmat" );

            // Traditional rig colors: RED joints, BLUE bones (user request).
            if ( jointVertices.Count > 0 )
            {
                var jointMesh = new Sandbox.Mesh( material );
                jointMesh.CreateVertexBuffer( jointVertices.Count, SimpleVertex.Layout, jointVertices );
                _skeletonObject = new SceneObject(
                    Scene.SceneWorld, Model.Builder.AddMesh( jointMesh ).Create(), Transform.Zero )
                {
                    ColorTint = new Color( 0.92f, 0.18f, 0.15f ),
                };
            }
            if ( boneVertices.Count > 0 )
            {
                var boneMesh = new Sandbox.Mesh( material );
                boneMesh.CreateVertexBuffer( boneVertices.Count, SimpleVertex.Layout, boneVertices );
                _boneObject = new SceneObject(
                    Scene.SceneWorld, Model.Builder.AddMesh( boneMesh ).Create(), Transform.Zero )
                {
                    ColorTint = new Color( 0.20f, 0.45f, 0.95f ),
                };
            }

            // The selected joint IS the marker: its own octahedron drawn in
            // fluorescent green (slightly enlarged so it pops) instead of the red
            // one - a highlight of the joint, not a second shape covering it.
            if ( highlighted >= 0 )
            {
                vertices = new List<SimpleVertex>();
                Octahedron( ToVector3( _posedWorld[highlighted] ), jointRadius * 1.35f );
                var highlightMesh = new Sandbox.Mesh( material );
                highlightMesh.CreateVertexBuffer( vertices.Count, SimpleVertex.Layout, vertices );
                _highlightObject = new SceneObject( Scene.SceneWorld,
                    Model.Builder.AddMesh( highlightMesh ).Create(), Transform.Zero )
                {
                    ColorTint = new Color( 0.30f, 1f, 0.15f ),   // fluorescent green
                };
            }
        }
        catch ( Exception )
        {
            _skeletonObject?.Delete();
            _skeletonObject = null;
            _boneObject?.Delete();
            _boneObject = null;
        }
    }

    void RedrawBones()
    {
        _bones.Clear();
        if ( _rig is null )
            return;

        var joints = _rig.Skeleton.Joints;
        // Soft bone tones (no fluorescent green): warm ivory bones, blue joints.
        var boneColor = new Color( 0.93f, 0.82f, 0.6f, 0.95f );
        var jointColor = Theme.Blue.WithAlpha( 0.95f );
        var activeColor = Theme.Yellow;
        var scale = MathF.Max( _boundsLength, 1f );
        var tick = scale * 0.006f;

        for ( var i = 0; i < joints.Count; i++ )
        {
            var pos = ToVector3( _posedWorld[i] );
            var parent = joints[i].Parent;
            if ( parent >= 0 )
            {
                _bones.StartLine();
                _bones.AddLinePoint( ToVector3( _posedWorld[parent] ), boneColor, tick * 0.9f );
                _bones.AddLinePoint( pos, boneColor, tick * 0.9f );
                _bones.EndLine();
            }

            // Joint = a 3-axis star so it reads as a node from any angle.
            var isActive = joints[i].Name == WigglingJoint;
            var markerColor = isActive ? activeColor : jointColor;
            foreach ( var axis in new[] { Vector3.Up, Vector3.Left, Vector3.Forward } )
            {
                _bones.StartLine();
                _bones.AddLinePoint( pos - axis * tick * 1.5f, markerColor, tick * 1.6f );
                _bones.AddLinePoint( pos + axis * tick * 1.5f, markerColor, tick * 1.6f );
                _bones.EndLine();
            }
        }
    }

    /// <summary>Mesh space (Y-up, as our whole pipeline assumes) → s&box scene
    /// space (Z-up): rotate +90° about X. Without this everything lies sideways.</summary>
    static Vector3 ToVector3( VecN v ) => new( v.X, -v.Z, v.Y );

    void UpdateCamera()
    {
        if ( !Camera.IsValid() || _mesh is null )
            return;

        // Cached (ComputeBounds is O(verts) and this runs every frame).
        var center = _boundsCenter;
        var radius = MathF.Max( _boundsLength * 0.5f, 1f );
        var distance = MathX.SphereCameraDistance( radius, Camera.FieldOfView ) * 1.1f * _zoom;

        var yawRad = MathX.DegreeToRadian( _yaw );
        var dir = new Vector3( MathF.Cos( yawRad ), MathF.Sin( yawRad ), 0.35f ).Normal;
        Camera.WorldPosition = center + dir * distance;
        Camera.WorldRotation = Rotation.LookAt( -dir, Vector3.Up );
    }

    protected override void OnWheel( WheelEvent e )
    {
        base.OnWheel( e );
        _zoom = Math.Clamp( _zoom * (e.Delta > 0 ? 0.88f : 1.14f), 0.15f, 6f );
        e.Accept();
    }

    protected override void OnMouseMove( MouseEvent e )
    {
        base.OnMouseMove( e );
        var delta = e.LocalPosition - _lastMouse;
        _lastMouse = e.LocalPosition;
        if ( (e.ButtonState & MouseButtons.Left) == 0 )
            return;

        // Dragging a selected joint moves it in the camera-facing plane and the
        // mesh follows; dragging empty space orbits.
        if ( _dragJoint >= 0 && _posedWorld is not null && Camera.IsValid() )
        {
            var jointScene = ToVector3( _posedWorld[_dragJoint] );
            var depth = Vector3.Dot( jointScene - Camera.WorldPosition, Camera.WorldRotation.Forward );
            var tanHalf = MathF.Tan( MathX.DegreeToRadian( Camera.FieldOfView ) * 0.5f );
            var worldPerPixel = 2f * tanHalf * MathF.Max( depth, 0.01f ) / MathF.Max( (float)Height, 1f );
            var sceneDelta = Camera.WorldRotation.Right * (delta.x * worldPerPixel)
                + Camera.WorldRotation.Up * (-delta.y * worldPerPixel);
            MoveJointSubtree( _dragJoint, FromVector3( sceneDelta ) );
            return;
        }

        _yaw -= delta.x * 0.4f;
    }

    public override void OnDestroyed()
    {
        base.OnDestroyed();
        _solid?.Delete();
        _solid = null;
        _skeletonObject?.Delete();
        _skeletonObject = null;
        _boneObject?.Delete();
        _boneObject = null;
        _highlightObject?.Delete();
        _highlightObject = null;
        Scene?.Destroy();
        Scene = null;
    }
}
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using AutoRig.Vast;

namespace Editor.AutoRig.Vast;

/// <summary>
/// Thin transport for the vast.ai REST API (console.vast.ai, Bearer key). Each
/// call names its own API version because the migration is selective: the
/// /instances family is v1 (v0 returns 410 Gone), while offer search and rental
/// creation are still v0. All request/response shapes live in whitelist-safe
/// Code/AutoRig/Vast so this class stays dumb and the logic stays unit-tested.
/// </summary>
public sealed class VastClient : IDisposable
{
    public const string BaseUrl = "https://console.vast.ai/";
    readonly HttpClient _http;

    public VastClient( string apiKey, HttpMessageHandler handler = null )
    {
        if ( string.IsNullOrWhiteSpace( apiKey ) )
            throw new ArgumentException( "vast.ai API key is required.", nameof( apiKey ) );
        _http = handler is null ? new HttpClient() : new HttpClient( handler );
        _http.BaseAddress = new Uri( BaseUrl );
        _http.Timeout = TimeSpan.FromSeconds( 60 );
        _http.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue( "Bearer", apiKey.Trim() );
    }

    public async Task<List<VastOffer>> SearchOffers( int minGpuRamMb, int minDiskGb )
    {
        var body = new StringContent(
            VastOffers.BuildSearchBody( minGpuRamMb, minDiskGb ),
            Encoding.UTF8, "application/json" );
        // Offer search is v0 /search/asks/ (PUT). v0 /bundles/ is gone.
        var response = await _http.PutAsync( "api/v0/search/asks/", body );
        var json = await response.Content.ReadAsStringAsync();
        if ( !response.IsSuccessStatusCode )
            throw new FormatException( $"vast.ai offer search failed ({(int)response.StatusCode}): {Truncate( json )}" );
        return VastOffers.ParseOffers( json );
    }

    /// <summary>Rents the offer. The caller MUST persist the returned id to the
    /// ownership ledger before doing anything else with it.</summary>
    public async Task<long> CreateInstance( long offerId, string image, string onstart, int diskGb )
    {
        var payload = System.Text.Json.JsonSerializer.Serialize( new Dictionary<string, object>
        {
            ["client_id"] = "me",
            ["image"] = image,
            ["disk"] = diskGb,
            ["onstart"] = onstart,
            ["runtype"] = "ssh",
        } );
        // Rental creation is v0 /asks/{offerId}/ (PUT) - not an /instances path.
        var response = await _http.PutAsync( $"api/v0/asks/{offerId}/",
            new StringContent( payload, Encoding.UTF8, "application/json" ) );
        var json = await response.Content.ReadAsStringAsync();
        if ( !response.IsSuccessStatusCode )
            throw new FormatException( $"vast.ai rental failed ({(int)response.StatusCode}): {Truncate( json )}" );
        return VastInstances.ParseCreateResponse( json );
    }

    /// <summary>Lists the user's current instances (read-only) so a rig can be
    /// OFFLOADED to one already running. This only enumerates for display - the
    /// destroy path still takes its id from the ledger alone, so listing here
    /// can never lead to touching an instance we did not create.</summary>
    public async Task<List<VastInstances.InstanceSummary>> ListInstances()
    {
        var response = await _http.GetAsync( "api/v1/instances/" );
        var json = await response.Content.ReadAsStringAsync();
        if ( !response.IsSuccessStatusCode )
            throw new FormatException( $"vast.ai instance list failed ({(int)response.StatusCode}): {Truncate( json )}" );
        return VastInstances.ParseInstanceList( json );
    }

    /// <summary>Null when the instance no longer exists (verified-destroy signal).</summary>
    public async Task<VastInstances.InstanceState> GetInstance( long id )
    {
        // Instances are v1 (v0 /instances/ returns 410 Gone). owner=me matches
        // the official CLI - harmless and accepted by v1.
        var response = await _http.GetAsync( $"api/v1/instances/{id}/?owner=me" );
        if ( response.StatusCode == System.Net.HttpStatusCode.NotFound )
            return null;
        var json = await response.Content.ReadAsStringAsync();
        if ( !response.IsSuccessStatusCode )
            throw new FormatException( $"vast.ai instance query failed ({(int)response.StatusCode}): {Truncate( json )}" );
        return VastInstances.ParseInstance( json );
    }

    /// <summary>DELETE exactly this id - never enumerates, never touches others.
    /// Verified by re-GET and retried; returns true only when the instance is
    /// confirmed gone.</summary>
    public async Task<bool> DestroyVerified( long id, int attempts = 5 )
    {
        for ( var attempt = 0; attempt < attempts; attempt++ )
        {
            try
            {
                // Instances are v1; the official CLI sends an empty JSON body.
                using var request = new HttpRequestMessage( HttpMethod.Delete, $"api/v1/instances/{id}/" )
                {
                    Content = new StringContent( "{}", Encoding.UTF8, "application/json" ),
                };
                await _http.SendAsync( request );
                await Task.Delay( TimeSpan.FromSeconds( 3 + attempt * 3 ) );
                if ( await GetInstance( id ) is null )
                    return true;
            }
            catch
            {
                await Task.Delay( TimeSpan.FromSeconds( 5 ) );
            }
        }
        return await GetInstance( id ) is null;
    }

    static string Truncate( string s )
        => s is null ? "" : s.Length > 300 ? s[..300] : s;

    public void Dispose() => _http.Dispose();
}
using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using AutoRig.Analyze;
using AutoRig.Rig;
using AutoRig.Vast;
using global::Editor;
using Sandbox;

namespace Editor.AutoRig.Vast;

/// <summary>
/// One cloud rig, cradle to grave: rent → ledger → boot → upload → rig →
/// download → DESTROY (verified, in finally). The ledger records exactly the
/// instance we created; if verified destruction fails the ledger survives so
/// the stale-rental prompt can finish the job next time - the user's other
/// instances are never enumerated, never touched.
/// </summary>
public sealed class VastRigSession
{
    public static string LedgerPath => Path.Combine(
        Project.Current?.GetAssetsPath() ?? ".", "autorig_dl", "vast_rented.json" );

    /// <summary>Adjustable via the settings dialog (VastSettings.Apply on load).</summary>
    public static TimeSpan BootTimeout { get; set; } = TimeSpan.FromMinutes( 12 );
    public static TimeSpan RigTimeout { get; set; } = TimeSpan.FromMinutes( 30 );

    readonly VastClient _client;
    readonly Action<string> _progress;

    public VastRigSession( VastClient client, Action<string> progress )
    {
        _client = client;
        _progress = progress ?? (_ => { });
    }

    /// <summary>Null when no rental is outstanding.</summary>
    public static VastRental StaleRental()
        => File.Exists( LedgerPath ) ? VastRental.Parse( File.ReadAllText( LedgerPath ) ) : null;

    /// <summary>Destroys a stale rental (verified); clears the ledger on success.</summary>
    public async Task<bool> DestroyStale( VastRental rental )
    {
        var gone = await _client.DestroyVerified( rental.InstanceId );
        if ( gone )
            ClearLedger();
        return gone;
    }

    public async Task<RigResult> RigAsync(
        AnalysisResult analysis, string modelId, string modelTitle, VastOffer offer )
    {
        var objBytes = ObjWriter.Write( analysis.Mesh );

        _progress( $"renting {offer.GpuName} (${offer.DollarsPerHour:0.00}/hr)…" );
        var instanceId = await _client.CreateInstance(
            offer.Id, VastProtocol.DockerImage,
            VastProtocol.BuildOnStart( modelId ), VastProtocol.DiskGb );

        // Ownership ledger BEFORE anything else can fail.
        Directory.CreateDirectory( Path.GetDirectoryName( LedgerPath )! );
        File.WriteAllText( LedgerPath, new VastRental
        {
            InstanceId = instanceId,
            OfferId = offer.Id,
            CreatedUtc = DateTime.UtcNow.ToString( "o" ),
            Label = modelTitle,
        }.Serialize() );

        try
        {
            var endpoint = await WaitForBoot( instanceId );
            using var http = new HttpClient { Timeout = TimeSpan.FromMinutes( 5 ) };

            _progress( "uploading mesh…" );
            var upload = await http.PostAsync(
                $"{endpoint}/rig?model={Uri.EscapeDataString( modelId )}",
                new ByteArrayContent( objBytes ) );
            upload.EnsureSuccessStatusCode();

            _progress( "rigging remotely…" );
            var deadline = DateTime.UtcNow + RigTimeout;
            while ( true )
            {
                if ( DateTime.UtcNow > deadline )
                    throw new TimeoutException( "remote rig timed out." );
                await Task.Delay( TimeSpan.FromSeconds( 10 ) );
                var status = await http.GetStringAsync( $"{endpoint}/status" );
                if ( status.Contains( "\"done\"" ) || status.Contains( "\"error\"" ) )
                    break;
            }

            _progress( "downloading result…" );
            var resultJson = await http.GetStringAsync( $"{endpoint}/result" );
            var remote = VastProtocol.ParseResult( resultJson );   // throws w/ remote log on error
            return VastProtocol.ToRigResult( analysis, remote, modelTitle );
        }
        finally
        {
            _progress( $"destroying instance {instanceId}…" );
            var destroyed = await _client.DestroyVerified( instanceId );
            if ( destroyed )
            {
                ClearLedger();
                _progress( $"instance {instanceId} DESTROYED (verified)." );
            }
            else
            {
                _progress( $"WARNING: could not verify destruction of instance {instanceId} - "
                    + "it stays in the ledger; you will be prompted to retry. "
                    + "Check console.vast.ai to avoid charges." );
            }
        }
    }

    /// <summary>Offloads a rig onto an instance the user ALREADY has running:
    /// upload → rig → download. By default it NEVER rents, NEVER destroys, and
    /// NEVER writes the ledger - the box keeps running afterwards (the point of
    /// offload is to reuse a machine that already has the model installed).
    /// When <paramref name="destroyAfter"/> is set the caller has explicitly
    /// opted in to destroying THIS instance (verified) once the rig is done -
    /// still only ever the exact id passed here.</summary>
    public async Task<RigResult> RigOnExisting(
        AnalysisResult analysis, string modelId, string modelTitle, long instanceId,
        bool destroyAfter = false )
    {
        var objBytes = ObjWriter.Write( analysis.Mesh );
        try
        {
            _progress( $"connecting to instance {instanceId}…" );
            var endpoint = await WaitForBoot( instanceId );   // health-poll, reused as-is
            using var http = new HttpClient { Timeout = TimeSpan.FromMinutes( 5 ) };

            // The box auto-provisions the model if it isn't installed yet.
            _progress( "uploading mesh…" );
            var upload = await http.PostAsync(
                $"{endpoint}/rig?model={Uri.EscapeDataString( modelId )}",
                new ByteArrayContent( objBytes ) );
            upload.EnsureSuccessStatusCode();

            _progress( "rigging on your instance…" );
            var deadline = DateTime.UtcNow + RigTimeout;
            while ( true )
            {
                if ( DateTime.UtcNow > deadline )
                    throw new TimeoutException( "remote rig timed out." );
                await Task.Delay( TimeSpan.FromSeconds( 10 ) );
                var status = await http.GetStringAsync( $"{endpoint}/status" );
                if ( status.Contains( "\"done\"" ) || status.Contains( "\"error\"" ) )
                    break;
            }

            _progress( "downloading result…" );
            var resultJson = await http.GetStringAsync( $"{endpoint}/result" );
            var remote = VastProtocol.ParseResult( resultJson );   // throws w/ remote log on error
            return VastProtocol.ToRigResult( analysis, remote, modelTitle );
        }
        finally
        {
            if ( destroyAfter )
            {
                _progress( $"destroying instance {instanceId} (destroy-after-rig)…" );
                _progress( await _client.DestroyVerified( instanceId )
                    ? $"instance {instanceId} destroyed (verified)."
                    : $"WARNING: could not verify destruction of {instanceId} - check console.vast.ai." );
            }
            else
            {
                _progress( $"done - instance {instanceId} left running (offload never destroys it)." );
            }
        }
    }

    /// <summary>Installs a model onto an instance the user already has running
    /// (POST /provision), polling until the box reports it provisioned. Never
    /// rents, never destroys - it just makes the box ready to offload that model
    /// (a box can hold several). Downloads can be slow, so it waits generously.</summary>
    public async Task ProvisionOnExisting( long instanceId, string modelId )
    {
        _progress( $"connecting to instance {instanceId}…" );
        var endpoint = await WaitForBoot( instanceId );
        using var http = new HttpClient { Timeout = TimeSpan.FromMinutes( 5 ) };

        _progress( $"provisioning {modelId} (cloning repo + downloading checkpoints)…" );
        var post = await http.PostAsync(
            $"{endpoint}/provision?model={Uri.EscapeDataString( modelId )}",
            new ByteArrayContent( Array.Empty<byte>() ) );
        post.EnsureSuccessStatusCode();

        var deadline = DateTime.UtcNow + RigTimeout;
        while ( true )
        {
            if ( DateTime.UtcNow > deadline )
                throw new TimeoutException( "remote provision timed out." );
            await Task.Delay( TimeSpan.FromSeconds( 10 ) );
            var s = await http.GetStringAsync( $"{endpoint}/status" );
            if ( s.Contains( "\"provisioned" ) )
            {
                _progress( $"{modelId} provisioned - you can offload rigs to this box now." );
                return;
            }
            if ( s.Contains( "\"error\"" ) )
                throw new FormatException( $"remote provision failed: {s}" );
        }
    }

    async Task<string> WaitForBoot( long instanceId )
    {
        _progress( "waiting for the instance to boot…" );
        var deadline = DateTime.UtcNow + BootTimeout;
        using var http = new HttpClient { Timeout = TimeSpan.FromSeconds( 10 ) };
        while ( DateTime.UtcNow < deadline )
        {
            await Task.Delay( TimeSpan.FromSeconds( 10 ) );
            var state = await _client.GetInstance( instanceId );
            if ( state is null || state.ActualStatus != "running"
                || state.PublicIp.Length == 0
                || !state.Ports.TryGetValue( VastProtocol.ServerPort, out var hostPort ) )
                continue;

            var endpoint = $"http://{state.PublicIp}:{hostPort}";
            try
            {
                if ( (await http.GetStringAsync( $"{endpoint}/health" )).Contains( "ready" ) )
                {
                    _progress( "instance is up." );
                    return endpoint;
                }
            }
            catch { /* server not up yet */ }
        }
        throw new TimeoutException( "vast.ai instance did not become ready in time." );
    }

    static void ClearLedger()
    {
        if ( File.Exists( LedgerPath ) )
            File.Delete( LedgerPath );
    }
}
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
using Sandbox.Diagnostics;

namespace ProtonMcpBridge;

/// <summary>
/// Loopback reachability check (mcp_bridge_selftest): connects to the listener from inside the editor
/// and does one round trip, confirming the transport is reachable under Wine/Proton.
/// </summary>
internal static class BridgeDiagnostics
{
	static readonly Logger log = new("ProtonMcpBridge");

	public static async Task SelfTest()
	{
		if (!TcpMcpServer.IsRunning)
		{
			log.Info("[selftest] server not running - nothing to probe");
			return;
		}

		log.Info($"[selftest] bound endpoint: {TcpMcpServer.BoundEndpoint ?? "(null)"}");

		await Probe(IPAddress.Loopback, "127.0.0.1");
	}

	static async Task Probe(IPAddress address, string label)
	{
		try
		{
			using var client = new TcpClient(address.AddressFamily);

			var connect = client.ConnectAsync(address, TcpMcpServer.Port);

			if (await Task.WhenAny(connect, Task.Delay(3000)) != connect)
			{
				log.Warning($"[selftest] {label}:{TcpMcpServer.Port} - connect timed out (3s)");
				return;
			}

			await connect; // surface any connect exception

			var body = """{"jsonrpc":"2.0","id":"selftest","method":"ping"}""";
			var request =
				"POST /mcp HTTP/1.1\r\n"
				+ $"Host: {label}:{TcpMcpServer.Port}\r\n"
				+ "Content-Type: application/json\r\n"
				+ $"Content-Length: {Encoding.UTF8.GetByteCount(body)}\r\n"
				+ "Connection: close\r\n\r\n"
				+ body;

			using var stream = client.GetStream();
			await stream.WriteAsync(Encoding.ASCII.GetBytes(request));
			await stream.FlushAsync();

			var buffer = new byte[4096];
			int read = await stream.ReadAsync(buffer);
			var responseLine = Encoding.UTF8.GetString(buffer, 0, read).Split("\r\n")[0];

			log.Info(
				$"[selftest] {label}:{TcpMcpServer.Port} - reachable, round trip ok: {responseLine}"
			);
		}
		catch (Exception e)
		{
			log.Warning(
				$"[selftest] {label}:{TcpMcpServer.Port} - failed: {e.GetType().Name}: {e.Message}"
			);
		}
	}
}
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;

namespace GeneralGame.Editor;

/// <summary>
/// Helper for building asset context menus with Create Material/Texture options.
/// </summary>
public static class AssetContextMenuHelper
{
    // Builds the right-click menu for a single file/asset. Shared by the tree view and the icon grid
    // so both show identical options. Rename UI differs per view, so it is passed in via onRename.
    public static void BuildFileMenu(Menu menu, string fullPath, Asset asset, Action onRename, Action onChanged)
    {
        var fileName = Path.GetFileName(fullPath);

        if (asset != null)
            menu.AddOption("Open in Editor", "edit", () => asset.OpenInEditor());
        else
            menu.AddOption("Open", "open_in_new", () => EditorUtility.OpenFolder(fullPath));

        menu.AddOption("Show in Explorer", "folder_open", () => EditorUtility.OpenFileFolder(fullPath));

        menu.AddSeparator();

        if (asset != null)
            menu.AddOption("Copy Relative Path", "content_paste_go", () => EditorUtility.Clipboard.Copy(asset.RelativePath));
        menu.AddOption("Copy Absolute Path", "content_paste", () => EditorUtility.Clipboard.Copy(fullPath));

        // Asset-type specific options (Create Material, Create Texture, etc.)
        AddAssetTypeOptions(menu, asset);

        menu.AddSeparator();

        menu.AddOption("Rename", "edit", () => onRename?.Invoke());
        menu.AddOption("Duplicate", "file_copy", () =>
        {
            DuplicateFileWithRename(fullPath, () => onChanged?.Invoke());
        });

        menu.AddSeparator();

        var parentFolder = Path.GetDirectoryName(fullPath);
        if (!string.IsNullOrEmpty(parentFolder))
        {
            var createMenu = menu.AddMenu("Create", "add");
            AssetCreator.AddOptions(createMenu, parentFolder);
            menu.AddSeparator();
        }

        menu.AddOption("Delete", "delete", () =>
        {
            var confirm = new PopupWindow(
                "Delete File",
                $"Are you sure you want to delete '{fileName}'?",
                "Cancel",
                new Dictionary<string, Action>()
                {
                    { "Delete", () =>
                        {
                            try
                            {
                                DeleteFileWithCompiled(fullPath, asset);
                                onChanged?.Invoke();
                            }
                            catch (Exception ex)
                            {
                                Log.Error($"Failed to delete file: {ex.Message}");
                            }
                        }
                    }
                }
            );
            confirm.Show();
        });
    }

    // Builds the right-click menu for a folder. Shared by the tree view and the icon grid.
    public static void BuildFolderMenu(Menu menu, string fullPath, string displayName, bool isRoot,
        Action onOpen, Action onRename, Action onRefresh, Action onDeleted)
    {
        if (onOpen != null)
            menu.AddOption("Open", "folder_open", () => onOpen());

        menu.AddOption("Open in Explorer", "launch", () => EditorUtility.OpenFolder(fullPath));

        menu.AddSeparator();

        var createMenu = menu.AddMenu("Create", "add");
        AssetCreator.AddOptions(createMenu, fullPath);

        // Paste files copied from Windows Explorer into this folder
        if (WindowsClipboard.HasFiles())
        {
            menu.AddOption("Paste", "content_paste", () =>
            {
                PasteFromClipboard(fullPath);
                onRefresh?.Invoke();
            });
        }

        menu.AddSeparator();

        if (!isRoot)
            menu.AddOption("Rename", "edit", () => onRename?.Invoke());

        menu.AddOption("Copy Path", "content_copy", () => EditorUtility.Clipboard.Copy(fullPath));
        menu.AddOption("Copy Relative Path", "content_copy", () =>
        {
            var relativePath = Path.GetRelativePath(Project.Current?.GetRootPath() ?? "", fullPath);
            EditorUtility.Clipboard.Copy(relativePath);
        });

        menu.AddSeparator();

        menu.AddOption("Refresh", "refresh", () => onRefresh?.Invoke());

        if (!isRoot)
        {
            menu.AddSeparator();
            menu.AddOption("Delete", "delete", () =>
            {
                var confirm = new PopupWindow(
                    "Delete Folder",
                    $"Are you sure you want to delete '{displayName}'?\nAll contents will be deleted.",
                    "Cancel",
                    new Dictionary<string, Action>()
                    {
                        { "Delete", () =>
                            {
                                try
                                {
                                    Directory.Delete(fullPath, recursive: true);
                                    onDeleted?.Invoke();
                                }
                                catch (Exception ex)
                                {
                                    Log.Error($"Failed to delete folder: {ex.Message}");
                                }
                            }
                        }
                    }
                );
                confirm.Show();
            });
        }
    }

    // Duplicates a file next to itself, finding a free "_copy" name.
    public static string DuplicateFile(string filePath)
    {
        try
        {
            var directory = Path.GetDirectoryName(filePath);
            var nameWithoutExt = Path.GetFileNameWithoutExtension(filePath);
            var extension = Path.GetExtension(filePath);

            var newName = $"{nameWithoutExt}_copy{extension}";
            var newPath = Path.Combine(directory, newName);

            var counter = 1;
            while (File.Exists(newPath))
            {
                newName = $"{nameWithoutExt}_copy{counter++}{extension}";
                newPath = Path.Combine(directory, newName);
            }

            File.Copy(filePath, newPath);

            // Register the new file so it gets compiled and recognised as an asset immediately
            // (without this it shows up uncompiled until an editor restart or rename).
            AssetSystem.RegisterFile(newPath);

            return newPath;
        }
        catch (Exception ex)
        {
            Log.Error($"Failed to duplicate file: {ex.Message}");
            return null;
        }
    }

    // Duplicates a file and immediately opens a rename modal pre-filled with the copy's name.
    public static void DuplicateFileWithRename(string filePath, Action onComplete = null)
    {
        var newPath = DuplicateFile(filePath);
        if (string.IsNullOrEmpty(newPath))
            return;

        onComplete?.Invoke();

        var extension = Path.GetExtension(newPath);
        var dialog = new RenameDialog("Rename Duplicate", Path.GetFileNameWithoutExtension(newPath));
        dialog.OnConfirm = (newName) =>
        {
            if (string.IsNullOrWhiteSpace(newName))
                return;

            // Preserve extension if the user didn't type one
            if (!Path.HasExtension(newName))
                newName += extension;

            if (newName == Path.GetFileName(newPath))
                return;

            var renamedPath = Path.Combine(Path.GetDirectoryName(newPath), newName);
            if (File.Exists(renamedPath))
            {
                Log.Warning($"A file named '{newName}' already exists");
                return;
            }

            try
            {
                File.Move(newPath, renamedPath);

                // Drop the freshly compiled _c file so it doesn't linger under the old name
                var oldCompiled = newPath + "_c";
                if (File.Exists(oldCompiled))
                    File.Delete(oldCompiled);

                AssetSystem.RegisterFile(renamedPath);
                onComplete?.Invoke();
            }
            catch (Exception ex)
            {
                Log.Error($"Failed to rename duplicate: {ex.Message}");
            }
        };
        dialog.Show();
    }

    // Registers a freshly created/copied/moved path with the asset system so it compiles right away.
    // Accepts a single file or a directory (registers every file inside, recursively).
    public static void RegisterNewPath(string path)
    {
        try
        {
            if (Directory.Exists(path))
            {
                foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
                {
                    if (ShouldRegister(file))
                        AssetSystem.RegisterFile(file);
                }
            }
            else if (File.Exists(path) && ShouldRegister(path))
            {
                AssetSystem.RegisterFile(path);
            }
        }
        catch (Exception ex)
        {
            Log.Warning($"Failed to register '{path}': {ex.Message}");
        }
    }

    // Copies the files currently on the Windows clipboard into the target folder (always copy, never move).
    public static void PasteFromClipboard(string targetFolder)
    {
        if (string.IsNullOrEmpty(targetFolder) || !Directory.Exists(targetFolder))
            return;

        foreach (var file in WindowsClipboard.GetFiles())
        {
            if (string.IsNullOrEmpty(file))
                continue;

            try
            {
                var name = Path.GetFileName(file.TrimEnd('\\', '/'));
                var destPath = Path.Combine(targetFolder, name);

                // Don't paste a folder into itself
                if (Path.GetFullPath(file).Equals(Path.GetFullPath(destPath), StringComparison.OrdinalIgnoreCase))
                    destPath = MakeUniquePath(destPath);
                else if (File.Exists(destPath) || Directory.Exists(destPath))
                    destPath = MakeUniquePath(destPath);

                if (Directory.Exists(file))
                    CopyDirectory(file, destPath);
                else if (File.Exists(file))
                    File.Copy(file, destPath, overwrite: false);
                else
                    continue;

                RegisterNewPath(destPath);
            }
            catch (Exception ex)
            {
                Log.Error($"Failed to paste '{file}': {ex.Message}");
            }
        }
    }

    private static string MakeUniquePath(string path)
    {
        if (!File.Exists(path) && !Directory.Exists(path))
            return path;

        var directory = Path.GetDirectoryName(path);
        var name = Path.GetFileNameWithoutExtension(path);
        var ext = Path.GetExtension(path);

        int counter = 1;
        string candidate;
        do
        {
            var suffix = counter == 1 ? "_copy" : $"_copy{counter}";
            candidate = Path.Combine(directory, $"{name}{suffix}{ext}");
            counter++;
        }
        while (File.Exists(candidate) || Directory.Exists(candidate));

        return candidate;
    }

    private static void CopyDirectory(string sourceDir, string destDir)
    {
        Directory.CreateDirectory(destDir);

        foreach (var file in Directory.GetFiles(sourceDir))
        {
            File.Copy(file, Path.Combine(destDir, Path.GetFileName(file)));
        }

        foreach (var dir in Directory.GetDirectories(sourceDir))
        {
            CopyDirectory(dir, Path.Combine(destDir, Path.GetFileName(dir)));
        }
    }

    // True when the path lives outside the current project (e.g. dragged in from Windows Explorer).
    // Such sources must be copied, never moved, so we don't delete files from their original location.
    public static bool IsExternalSource(string path)
    {
        try
        {
            var root = Project.Current?.GetRootPath();
            if (string.IsNullOrEmpty(root))
                return false;

            var full = Path.GetFullPath(path);
            var rootFull = Path.GetFullPath(root);
            return !full.StartsWith(rootFull, StringComparison.OrdinalIgnoreCase);
        }
        catch
        {
            return false;
        }
    }

    // Create a prefab from GameObject(s) dragged in from the scene hierarchy.
    // A single object becomes the prefab root directly; multiple objects are
    // wrapped under a new root, mirroring the engine's "Convert to Prefab".
    public static void CreatePrefabFromGameObjects(GameObject[] gameObjects, string targetFolder, Action onComplete = null)
    {
        if (gameObjects == null || string.IsNullOrEmpty(targetFolder)) return;

        var selection = gameObjects.Where(g => g != null).ToArray();
        var first = selection.FirstOrDefault();
        if (first == null) return;

        try
        {
            var session = SceneEditorSession.Resolve(first);
            if (session == null) return;

            var baseName = string.IsNullOrWhiteSpace(first.Name) ? "Prefab" : first.Name;
            var location = GetUniquePrefabPath(targetFolder, baseName);

            using var scene = session.Scene.Push();
            using (session.UndoScope("Create Prefab from Hierarchy").WithGameObjectChanges(selection, GameObjectUndoFlags.All).WithGameObjectCreations().Push())
            {
                GameObject prefabRoot;

                if (selection.Length == 1)
                {
                    prefabRoot = first;
                }
                else
                {
                    prefabRoot = new GameObject();
                    prefabRoot.WorldTransform = first.WorldTransform;

                    foreach (var go in selection)
                        go.SetParent(prefabRoot, true);
                }

                prefabRoot.Name = Path.GetFileNameWithoutExtension(location);

                EditorUtility.Prefabs.ConvertGameObjectToPrefab(prefabRoot, location);
                EditorUtility.InspectorObject = prefabRoot;
            }
        }
        catch (Exception ex)
        {
            Log.Error($"Failed to create prefab: {ex.Message}");
        }

        onComplete?.Invoke();
    }

    // Build a non-colliding "name.prefab" path inside the target folder.
    private static string GetUniquePrefabPath(string folder, string baseName)
    {
        var path = Path.Combine(folder, $"{baseName}.prefab");
        int n = 1;
        while (File.Exists(path) || AssetSystem.FindByPath(path) != null)
        {
            path = Path.Combine(folder, $"{baseName} ({n}).prefab");
            n++;
        }
        return path;
    }

    // ===== Backward compatibility: move an asset and optionally fix references to its old path =====

    private static readonly HashSet<string> NonTextExtensions = new(StringComparer.OrdinalIgnoreCase)
    {
        ".png", ".jpg", ".jpeg", ".tga", ".psd", ".bmp", ".gif", ".dds", ".hdr",
        ".fbx", ".obj", ".gltf", ".glb", ".dmx", ".blend",
        ".wav", ".mp3", ".ogg", ".flac",
        ".vtex", ".vsnd", ".vmdl", ".dll", ".pdb", ".exe", ".zip", ".bin"
    };

    // True if this is a single registered asset being moved inside the project - the case the
    // backward-compatibility reference check applies to.
    public static bool IsBackwardCompatMove(IReadOnlyList<string> files, bool isCopy)
    {
        if (!BrowserSettings.BackwardCompatibility) return false;
        if (isCopy) return false;
        if (files == null || files.Count != 1) return false;

        var file = files[0];
        if (string.IsNullOrEmpty(file) || IsExternalSource(file) || Directory.Exists(file)) return false;
        if (!File.Exists(file)) return false;

        var asset = AssetSystem.FindByPath(file);
        return asset != null && !asset.IsDeleted;
    }

    // Moves an asset, first asking (via a modal) whether to update references to its old path.
    public static void MoveAssetWithReferenceCheck(string sourceFile, string targetFolder, Action onComplete)
    {
        var asset = AssetSystem.FindByPath(sourceFile);
        if (asset == null || asset.IsDeleted)
            return;

        // Don't bother if it's already in the target folder
        if (string.Equals(Path.GetFullPath(Path.GetDirectoryName(sourceFile)), Path.GetFullPath(targetFolder), StringComparison.OrdinalIgnoreCase))
            return;

        var assetsRoot = Project.Current?.GetAssetsPath();
        var oldRef = asset.RelativePath?.Replace('\\', '/');
        var newAbs = Path.Combine(targetFolder, Path.GetFileName(sourceFile));
        var newRef = string.IsNullOrEmpty(assetsRoot)
            ? null
            : Path.GetRelativePath(assetsRoot, newAbs).Replace('\\', '/');

        void JustMove()
        {
            EditorUtility.MoveAssetToDirectory(asset, targetFolder);
            onComplete?.Invoke();
        }

        // Can't compute a clean reference (asset outside the assets mount) - just move normally
        if (string.IsNullOrEmpty(oldRef) || string.IsNullOrEmpty(newRef) || newRef.StartsWith(".."))
        {
            JustMove();
            return;
        }

        var affected = FindFilesReferencing(oldRef);
        affected.RemoveAll(f => string.Equals(Path.GetFullPath(f), Path.GetFullPath(asset.AbsolutePath), StringComparison.OrdinalIgnoreCase));

        var dialog = new MoveReferencesDialog(oldRef, newRef, affected,
            onJustMove: JustMove,
            onMoveAndUpdate: () =>
            {
                EditorUtility.MoveAssetToDirectory(asset, targetFolder);
                RewriteReferences(affected, oldRef, newRef);
                onComplete?.Invoke();
            });
        dialog.Show();
    }

    // Matches the reference only at a path boundary, so "models/foo.vmdl" doesn't match inside
    // "othermodels/foo.vmdl" or "sub/models/foo.vmdl". A trailing "_c" (compiled form) is still matched.
    private static Regex BuildReferenceRegex(string reference)
    {
        return new Regex(@"(?<![\w./\\-])" + Regex.Escape(reference), RegexOptions.IgnoreCase);
    }

    // Finds every text-based project file that mentions the given reference path.
    public static List<string> FindFilesReferencing(string reference)
    {
        var results = new List<string>();
        if (string.IsNullOrEmpty(reference))
            return results;

        var root = Project.Current?.GetAssetsPath();
        if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
            return results;

        var regex = BuildReferenceRegex(reference);

        foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
        {
            if (!IsScannableTextFile(file))
                continue;

            try
            {
                var info = new FileInfo(file);
                if (info.Length == 0 || info.Length > 16_000_000)
                    continue;

                var text = File.ReadAllText(file);
                if (regex.IsMatch(text))
                    results.Add(file);
            }
            catch
            {
                // Ignore unreadable files
            }
        }

        return results;
    }

    // Rewrites the old reference to the new one in each file (covers compiled "_c" forms too, since
    // they share the prefix). Re-registers the file afterwards.
    public static void RewriteReferences(IEnumerable<string> files, string oldRef, string newRef)
    {
        var regex = BuildReferenceRegex(oldRef);

        foreach (var file in files)
        {
            try
            {
                var text = File.ReadAllText(file);
                var updated = regex.Replace(text, _ => newRef);

                if (updated != text)
                {
                    File.WriteAllText(file, updated);
                    AssetSystem.RegisterFile(file);
                }
            }
            catch (Exception ex)
            {
                Log.Error($"Failed to update references in '{file}': {ex.Message}");
            }
        }
    }

    private static bool IsScannableTextFile(string file)
    {
        if (!ShouldRegister(file))
            return false;

        var ext = Path.GetExtension(file);
        if (NonTextExtensions.Contains(ext))
            return false;

        return true;
    }

    private static bool ShouldRegister(string file)
    {
        var name = Path.GetFileName(file);
        if (name.StartsWith(".")) return false;
        if (name.EndsWith("_c", StringComparison.OrdinalIgnoreCase)) return false;
        if (name.EndsWith(".meta", StringComparison.OrdinalIgnoreCase)) return false;
        if (name.Contains(".generated", StringComparison.OrdinalIgnoreCase)) return false;
        return true;
    }

    // Deletes a file, also removing its compiled "_c" sibling. Uses Asset.Delete when registered.
    public static void DeleteFileWithCompiled(string fullPath, Asset asset)
    {
        if (asset != null)
        {
            asset.Delete();
            return;
        }

        File.Delete(fullPath);

        var compiledPath = fullPath + "_c";
        if (File.Exists(compiledPath))
        {
            File.Delete(compiledPath);
        }
    }

    private static readonly HashSet<string> MeshExtensions =
        new(StringComparer.OrdinalIgnoreCase) { ".fbx", ".obj", ".dmx", ".gltf", ".glb" };

    // Builds the right-click menu shown when several files are selected at once.
    // Shared by the tree view and the icon grid so both behave identically.
    public static void BuildMultiFileMenu(Menu menu, List<(string Path, Asset Asset)> items, Action onChanged)
    {
        if (items == null || items.Count == 0) return;

        int count = items.Count;
        var assets = items.Where(i => i.Asset != null).Select(i => i.Asset).ToList();

        // Asset-type batch options (Create Material (N), Create Texture (N), etc.)
        bool addedTypeOptions = AddMultiAssetTypeOptions(menu, assets);

        if (addedTypeOptions)
            menu.AddSeparator();

        menu.AddOption($"Duplicate ({count})", "file_copy", () =>
        {
            foreach (var it in items)
                DuplicateFile(it.Path);
            onChanged?.Invoke();
        });

        menu.AddOption($"Delete ({count})", "delete", () =>
        {
            var confirm = new PopupWindow(
                "Delete Files",
                $"Are you sure you want to delete {count} item(s)?",
                "Cancel",
                new Dictionary<string, Action>()
                {
                    { "Delete", () =>
                        {
                            foreach (var it in items)
                            {
                                try
                                {
                                    DeleteFileWithCompiled(it.Path, it.Asset);
                                }
                                catch (Exception ex)
                                {
                                    Log.Error($"Failed to delete '{it.Path}': {ex.Message}");
                                }
                            }
                            onChanged?.Invoke();
                        }
                    }
                }
            );
            confirm.Show();
        });
    }

    // Adds asset-type specific batch options with a count suffix, e.g. "Create Material (3)".
    // Each option auto-creates the result next to every source asset (no save dialog).
    // Returns true if any option was added.
    public static bool AddMultiAssetTypeOptions(Menu menu, List<Asset> assets)
    {
        if (assets == null || assets.Count == 0) return false;

        var images = assets.Where(a => a.AssetType == AssetType.ImageFile).ToList();
        var shaders = assets.Where(a => a.AssetType == AssetType.Shader).ToList();
        var meshes = assets.Where(a => MeshExtensions.Contains(Path.GetExtension(a.AbsolutePath))).ToList();

        bool added = false;

        if (images.Count > 0)
        {
            menu.AddOption($"Create Material ({images.Count})", "image", () =>
            {
                foreach (var a in images) CreateMaterialFromImageAuto(a);
                Log.Info($"Created {images.Count} material(s)");
            });
            menu.AddOption($"Create Texture ({images.Count})", "texture", () =>
            {
                foreach (var a in images) CreateTextureFromImageAuto(a);
            });
            menu.AddOption($"Create Sprite ({images.Count})", "emoji_emotions", () =>
            {
                foreach (var a in images) CreateSpriteFromImageAuto(a);
            });
            added = true;
        }

        if (shaders.Count > 0)
        {
            if (added) menu.AddSeparator();
            menu.AddOption($"Create Material ({shaders.Count})", "image", () =>
            {
                foreach (var a in shaders) CreateMaterialFromShaderAuto(a);
            });
            added = true;
        }

        if (meshes.Count > 0)
        {
            if (added) menu.AddSeparator();
            menu.AddOption($"Create Model ({meshes.Count})", "view_in_ar", () =>
            {
                foreach (var a in meshes) CreateModelFromMeshAuto(a);
            });
            added = true;
        }

        var sounds = assets.Where(a => a.AssetType == AssetType.SoundFile).ToList();
        if (sounds.Count > 0)
        {
            if (added) menu.AddSeparator();

            // One sound event per file
            menu.AddOption($"Create Sound Event ({sounds.Count})", "graphic_eq", () =>
            {
                foreach (var a in sounds) CreateSoundEventFromAudioAuto(a);
                Log.Info($"Created {sounds.Count} sound event(s)");
            });

            // A single sound event that randomly picks between all the selected sounds
            menu.AddOption($"Create Random Sound Event ({sounds.Count})", "shuffle", () =>
            {
                CreateRandomSoundEventFromAudios(sounds);
            });

            added = true;
        }

        return added;
    }

    /// <summary>
    /// Add asset-type specific options like Create Material, Create Texture, etc.
    /// </summary>
    public static void AddAssetTypeOptions(Menu menu, Asset asset)
    {
        if (asset == null) return;

        var assetType = asset.AssetType;
        if (assetType == null) return;

        // Image files - can create Material, Texture, Sprite
        if (assetType == AssetType.ImageFile)
        {
            menu.AddSeparator();
            menu.AddOption("Create Material", "image", () => CreateMaterialFromImage(asset));
            menu.AddOption("Create Texture", "texture", () => CreateTextureFromImage(asset));
            menu.AddOption("Create Sprite", "emoji_emotions", () => CreateSpriteFromImage(asset));
        }

        // Shader files - can create Material
        if (assetType == AssetType.Shader)
        {
            menu.AddSeparator();
            menu.AddOption("Create Material", "image", () => CreateMaterialFromShader(asset));
        }

        // Mesh files (FBX, OBJ) - can create Model
        if (MeshExtensions.Contains(Path.GetExtension(asset.AbsolutePath)))
        {
            menu.AddSeparator();
            menu.AddOption("Create Model", "view_in_ar", () => CreateModelFromMesh(asset));
        }

        // Sound files - can create a Sound Event
        if (assetType == AssetType.SoundFile)
        {
            menu.AddSeparator();
            menu.AddOption("Create Sound Event", "graphic_eq", () => CreateSoundEventFromAudio(asset));
        }
    }

    private static void CreateTextureFromImage(Asset asset)
    {
        var fd = new FileDialog(null);
        fd.Title = "Create Texture from Image..";
        fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
        fd.DefaultSuffix = ".vtex";
        fd.SelectFile($"{asset.Name}.vtex");
        fd.SetFindFile();
        fd.SetModeSave();
        fd.SetNameFilter("Texture File (*.vtex)");

        if (!fd.Execute())
            return;

        File.WriteAllText(fd.SelectedFile, BuildVtexContent(asset));
        AssetSystem.RegisterFile(fd.SelectedFile);
    }

    private static void CreateMaterialFromImage(Asset asset)
    {
        var fd = new FileDialog(null);
        fd.Title = "Create Material from Image..";
        fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
        fd.DefaultSuffix = ".vmat";
        fd.SelectFile($"{GetMaterialBaseName(asset)}.vmat");
        fd.SetFindFile();
        fd.SetModeSave();
        fd.SetNameFilter("Material File (*.vmat)");

        if (!fd.Execute())
            return;

        File.WriteAllText(fd.SelectedFile, BuildImageMaterialContent(asset));
        AssetSystem.RegisterFile(fd.SelectedFile);
    }

    private static async void CreateSpriteFromImage(Asset asset)
    {
        var fd = new FileDialog(null);
        fd.Title = "Create Sprite from Image..";
        fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
        fd.DefaultSuffix = ".sprite";
        fd.SelectFile($"{asset.Name}.sprite");
        fd.SetFindFile();
        fd.SetModeSave();
        fd.SetNameFilter("Sprite File (*.sprite)");

        if (!fd.Execute())
            return;

        File.WriteAllText(fd.SelectedFile, BuildSpriteContent(asset));

        var resultAsset = AssetSystem.RegisterFile(fd.SelectedFile);
        while (!resultAsset.IsCompiledAndUpToDate)
        {
            await Task.Delay(10);
        }
    }

    private static void CreateMaterialFromShader(Asset asset)
    {
        var fd = new FileDialog(null);
        fd.Title = "Create Material from Shader..";
        fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
        fd.DefaultSuffix = ".vmat";
        fd.SelectFile($"{asset.Name}.vmat");
        fd.SetFindFile();
        fd.SetModeSave();
        fd.SetNameFilter("Material File (*.vmat)");

        if (!fd.Execute())
            return;

        File.WriteAllText(fd.SelectedFile, BuildShaderMaterialContent(asset));
        AssetSystem.RegisterFile(fd.SelectedFile);
    }

    private static void CreateModelFromMesh(Asset asset)
    {
        var targetPath = EditorUtility.SaveFileDialog("Create Model..", "vmdl", Path.ChangeExtension(asset.AbsolutePath, "vmdl"));
        if (targetPath == null)
            return;

        EditorUtility.CreateModelFromMeshFile(asset, targetPath);
    }

    private static void CreateSoundEventFromAudio(Asset asset)
    {
        var fd = new FileDialog(null);
        fd.Title = "Create Sound Event..";
        fd.Directory = Path.GetDirectoryName(asset.AbsolutePath);
        fd.DefaultSuffix = ".sound";
        fd.SelectFile($"{asset.Name}.sound");
        fd.SetFindFile();
        fd.SetModeSave();
        fd.SetNameFilter("Sound Event (*.sound)");

        if (!fd.Execute())
            return;

        File.WriteAllText(fd.SelectedFile, BuildSoundEventContent(new[] { GetVsndReference(asset) }));
        AssetSystem.RegisterFile(fd.SelectedFile);
    }

    // ===== Batch creators: auto-name next to each source asset, skip if it already exists =====

    private static void CreateMaterialFromImageAuto(Asset asset)
    {
        var directory = Path.GetDirectoryName(asset.AbsolutePath);
        var destPath = Path.Combine(directory, $"{GetMaterialBaseName(asset)}.vmat");
        if (File.Exists(destPath))
            return;

        File.WriteAllText(destPath, BuildImageMaterialContent(asset));
        AssetSystem.RegisterFile(destPath);
    }

    private static void CreateTextureFromImageAuto(Asset asset)
    {
        var directory = Path.GetDirectoryName(asset.AbsolutePath);
        var destPath = Path.Combine(directory, $"{asset.Name}.vtex");
        if (File.Exists(destPath))
            return;

        File.WriteAllText(destPath, BuildVtexContent(asset));
        AssetSystem.RegisterFile(destPath);
    }

    private static async void CreateSpriteFromImageAuto(Asset asset)
    {
        var directory = Path.GetDirectoryName(asset.AbsolutePath);
        var destPath = Path.Combine(directory, $"{asset.Name}.sprite");
        if (File.Exists(destPath))
            return;

        File.WriteAllText(destPath, BuildSpriteContent(asset));

        var resultAsset = AssetSystem.RegisterFile(destPath);
        while (!resultAsset.IsCompiledAndUpToDate)
        {
            await Task.Delay(10);
        }
    }

    private static void CreateMaterialFromShaderAuto(Asset asset)
    {
        var directory = Path.GetDirectoryName(asset.AbsolutePath);
        var destPath = Path.Combine(directory, $"{asset.Name}.vmat");
        if (File.Exists(destPath))
            return;

        File.WriteAllText(destPath, BuildShaderMaterialContent(asset));
        AssetSystem.RegisterFile(destPath);
    }

    private static void CreateModelFromMeshAuto(Asset asset)
    {
        var destPath = Path.ChangeExtension(asset.AbsolutePath, "vmdl");
        if (File.Exists(destPath))
            return;

        EditorUtility.CreateModelFromMeshFile(asset, destPath);
    }

    private static void CreateSoundEventFromAudioAuto(Asset asset)
    {
        var directory = Path.GetDirectoryName(asset.AbsolutePath);
        var destPath = Path.Combine(directory, $"{asset.Name}.sound");
        if (File.Exists(destPath))
            return;

        File.WriteAllText(destPath, BuildSoundEventContent(new[] { GetVsndReference(asset) }));
        AssetSystem.RegisterFile(destPath);
    }

    // Creates a single sound event that randomly picks between all the given audio files.
    private static void CreateRandomSoundEventFromAudios(List<Asset> assets)
    {
        if (assets == null || assets.Count == 0)
            return;

        var first = assets[0];
        var directory = Path.GetDirectoryName(first.AbsolutePath);
        var destPath = FindFreePath(directory, GetSoundBaseName(first), ".sound");

        var references = assets.Select(GetVsndReference).ToList();
        File.WriteAllText(destPath, BuildSoundEventContent(references));
        AssetSystem.RegisterFile(destPath);
    }

    // ===== Content builders shared by single and batch creators =====

    // Strips trailing texture-role suffixes (_color, _normal, ...) to get the material base name.
    private static string GetMaterialBaseName(Asset asset)
    {
        string[] suffixes = { "color", "ao", "normal", "metallic", "rough", "diff", "diffuse", "nrm", "spec", "selfillum", "mask" };

        var assetName = asset.Name;
        foreach (var t in suffixes)
        {
            if (assetName.EndsWith($"_{t}"))
                assetName = assetName.Substring(0, assetName.Length - (t.Length + 1));
        }
        return assetName;
    }

    // Builds a complex.shader material, auto-wiring sibling textures (normal/ao/rough/...) by name.
    private static string BuildImageMaterialContent(Asset asset)
    {
        var assetName = GetMaterialBaseName(asset);

        var assetPath = Path.GetDirectoryName(asset.AbsolutePath).NormalizeFilename(false);
        var assetPeers = AssetSystem.All
            .Where(x => x.AssetType == AssetType.ImageFile)
            .Where(x => x.AbsolutePath.StartsWith(assetPath))
            .ToArray();

        var assetPeersWithSameBaseName = assetPeers
            .Where(x => x.Name == assetName || x.Name.StartsWith(assetName + "_"))
            .ToArray();

        if (assetPeersWithSameBaseName.Length > 0)
        {
            assetPeers = assetPeersWithSameBaseName;
        }

        string texColor = assetPeers.Where(x => x.Name.Contains("_color") || x.Name.Contains("_diff")).Select(x => x.RelativePath).FirstOrDefault();
        texColor ??= asset.RelativePath;

        string texNormal = assetPeers.Where(x => x.Name.Contains("_nrm") || x.Name.Contains("_normal") || x.Name.Contains("_amb")).Select(x => x.RelativePath).FirstOrDefault() ?? "materials/default/default_normal.tga";
        string texAo = assetPeers.Where(x => x.Name.Contains("_ao") || x.Name.Contains("_occ") || x.Name.Contains("_amb")).Select(x => x.RelativePath).FirstOrDefault() ?? "materials/default/default_ao.tga";
        string texRough = assetPeers.Where(x => x.Name.Contains("_rough")).Select(x => x.RelativePath).FirstOrDefault() ?? "materials/default/default_rough.tga";

        string texMetallic = assetPeers.Where(x => x.Name.Contains("_metallic")).Select(x => x.RelativePath).FirstOrDefault();
        if (texMetallic != null)
        {
            texMetallic = $"\n\tF_METALNESS_TEXTURE 1\n\tF_SPECULAR 1\n\tTextureMetalness \"{texMetallic}\"";
        }

        string texSelfIllum = assetPeers.Where(x => x.Name.Contains("_selfillum")).Select(x => x.RelativePath).FirstOrDefault();
        if (texSelfIllum != null)
        {
            texSelfIllum = $"\n\tF_SELF_ILLUM 1\n\tTextureSelfIllumMask \"{texSelfIllum}\"";
        }

        string tintMask = assetPeers.Where(x => x.Name.Contains("_mask")).Select(x => x.RelativePath).FirstOrDefault();
        if (tintMask != null)
        {
            tintMask = $"\n\tF_TINT_MASK 1\n\tTextureTintMask \"{tintMask}\"";
        }

        return $@"
Layer0
{{
	shader ""shaders/complex.shader_c""

	TextureColor ""{texColor}""
	TextureAmbientOcclusion ""{texAo}""
	TextureNormal ""{texNormal}""
	TextureRoughness ""{texRough}""{texMetallic}{texSelfIllum}{tintMask}

}}
";
    }

    private static string BuildShaderMaterialContent(Asset asset)
    {
        var shaderPath = asset.GetCompiledFile();

        return $@"
Layer0
{{
	shader ""{shaderPath}""

}}
";
    }

    private static string BuildVtexContent(Asset asset)
    {
        var vtexContent = new Dictionary<string, object>
        {
            { "Sequences", new object[]
                {
                    new Dictionary<string, object>
                    {
                        { "Source", asset.RelativePath },
                        { "IsLooping", true }
                    }
                }
            }
        };

        return Json.Serialize(vtexContent);
    }

    private static string BuildSpriteContent(Asset asset)
    {
        var path = Path.ChangeExtension(asset.Path, Path.GetExtension(asset.AbsolutePath));
        var sprite = Sprite.FromTexture(Texture.Load(path));
        return sprite.Serialize().ToJsonString();
    }

    // Builds a .sound (SoundEvent) resource that plays a random sound from the given references.
    private static string BuildSoundEventContent(IEnumerable<string> vsndReferences)
    {
        var content = new Dictionary<string, object>
        {
            { "Volume", "1" },
            { "Pitch", "1" },
            { "SelectionMode", "Random" },
            { "Sounds", vsndReferences.ToArray() },
            { "__version", 1 }
        };

        return Json.Serialize(content);
    }

    // Sound events reference the compiled .vsnd, regardless of the source file extension.
    private static string GetVsndReference(Asset asset)
    {
        return Path.ChangeExtension(asset.RelativePath, "vsnd").Replace('\\', '/');
    }

    // Strips a trailing numeric index so "footstep_01" -> "footstep" for naming a combined event.
    private static string GetSoundBaseName(Asset asset)
    {
        var name = asset.Name;
        var underscore = name.LastIndexOf('_');
        if (underscore > 0 && int.TryParse(name.Substring(underscore + 1), out _))
            name = name.Substring(0, underscore);
        return name;
    }

    private static string FindFreePath(string directory, string baseName, string extension)
    {
        var path = Path.Combine(directory, $"{baseName}{extension}");

        int counter = 1;
        while (File.Exists(path))
            path = Path.Combine(directory, $"{baseName}_{counter++}{extension}");

        return path;
    }
}
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;

// ═══════════════════════════════════════════════════════════════════════════
// debug_draw_* / debug_clear — visualize debug primitives in the scene.
//
// Ported from the Claude Bridge for Unity's debug_draw_* family. s&box has no
// bridge debug-viz; this fills the gap so a raycast hit / physics_overlap
// volume / trigger_zone bounds / NPC sight cone / patrol path can be SEEN
// (and screenshot-verified) instead of reasoned about blind.
//
// ONE component, dual render path:
//   • EDIT scene → Gizmo.Draw.* inside ClaudeDebugDraw.DrawGizmos()
//   • PLAY scene → Game.ActiveScene.DebugOverlay.* re-emitted each OnUpdate()
// A single NotSaved holder GameObject ("__ClaudeDebugDraw") stores the prim
// list; the draw handlers append, debug_clear destroys it.
//
// APIs reflected live on this SDK (describe_type, 2026-06-18):
//   Gizmo.Draw: Line(a,b) · Arrow(from,to,len,width) · LineBBox(bbox) ·
//               LineSphere(Sphere,rings) · Color/LineThickness/IgnoreDepth
//   Scene.DebugOverlay (DebugOverlaySystem):
//               Line(from,to,color,dur,tx,overlay) · Box(BBox,color,dur,tx,overlay) ·
//               Sphere(Sphere,color,dur,tx,overlay)
//
// Must work WHILE playing → these are NOT added to _sceneMutatingCommands.
// ═══════════════════════════════════════════════════════════════════════════

public enum DebugDrawKind { Line, Ray, Box, Sphere }

public sealed class DebugDrawPrim
{
	public DebugDrawKind Kind;
	public Vector3 A;            // line/ray start · box/sphere center
	public Vector3 B;            // line/ray end
	public Vector3 Size;         // box full extents
	public float Radius;         // sphere
	public Color Color = Color.Yellow;
	public float Thickness = 2f;
}

/// <summary>
/// Holds bridge-issued debug primitives and renders them in both the editor
/// (DrawGizmos) and play mode (DebugOverlay). One per scene, NotSaved.
/// </summary>
public sealed class ClaudeDebugDraw : Component
{
	public List<DebugDrawPrim> Prims { get; set; } = new();

	protected override void DrawGizmos()
	{
		if ( Prims == null ) return;
		foreach ( var p in Prims )
		{
			Gizmo.Draw.Color = p.Color;
			Gizmo.Draw.LineThickness = p.Thickness;
			Gizmo.Draw.IgnoreDepth = true;
			switch ( p.Kind )
			{
				case DebugDrawKind.Line:   Gizmo.Draw.Line( p.A, p.B ); break;
				case DebugDrawKind.Ray:    Gizmo.Draw.Arrow( p.A, p.B, 8f, 3f ); break;
				case DebugDrawKind.Box:    Gizmo.Draw.LineBBox( new BBox( p.A - p.Size * 0.5f, p.A + p.Size * 0.5f ) ); break;
				case DebugDrawKind.Sphere: Gizmo.Draw.LineSphere( new Sphere( p.A, p.Radius ), 16 ); break;
			}
		}
	}

	protected override void OnUpdate()
	{
		if ( !Game.IsPlaying || Prims == null ) return;
		var ov = Scene?.DebugOverlay;
		if ( ov == null ) return;
		const float dur = 0.1f;                       // refreshed every frame while in the list
		var tx = global::Transform.Zero;               // identity → world-space coords (Transform is global-namespace, not Sandbox.*)
		foreach ( var p in Prims )
		{
			switch ( p.Kind )
			{
				case DebugDrawKind.Line:
				case DebugDrawKind.Ray:
					ov.Line( p.A, p.B, p.Color, dur, tx, true );
					break;
				case DebugDrawKind.Box:
					ov.Box( new BBox( p.A - p.Size * 0.5f, p.A + p.Size * 0.5f ), p.Color, dur, tx, true );
					break;
				case DebugDrawKind.Sphere:
					ov.Sphere( new Sphere( p.A, p.Radius ), p.Color, dur, tx, true );
					break;
			}
		}
	}
}

internal static class DebugDrawHelpers
{
	static readonly CultureInfo Inv = CultureInfo.InvariantCulture;

	// ponytail: one global holder per session, recreated if invalidated by a
	// scene change / hotload. Debug viz is inherently global, so a single
	// instance is correct — no per-call scene scan needed.
	static ClaudeDebugDraw _holder;

	public static Scene CurrentScene()
		=> Game.IsPlaying ? Game.ActiveScene : SceneEditorSession.Active?.Scene;

	public static ClaudeDebugDraw EnsureHolder()
	{
		var scene = CurrentScene();
		if ( scene == null ) return null;
		if ( _holder.IsValid() && _holder.Scene == scene ) return _holder;
		var go = scene.CreateObject( true );
		go.Name = "__ClaudeDebugDraw";
		go.Flags = GameObjectFlags.NotSaved;
		_holder = go.AddComponent<ClaudeDebugDraw>();
		return _holder;
	}

	public static int ClearHolder()
	{
		int n = 0;
		// cached holder — reliable for the common same-scene case
		if ( _holder.IsValid() )
		{
			n += _holder.Prims?.Count ?? 0;
			_holder.GameObject?.Destroy();
		}
		// plus any holders orphaned by an edit↔play scene switch (the static ref
		// only tracks the most recent scene's holder)
		var scene = CurrentScene();
		if ( scene != null )
		{
			foreach ( var c in scene.GetAllComponents<ClaudeDebugDraw>().ToList() )
			{
				if ( c == _holder ) continue;
				n += c.Prims?.Count ?? 0;
				c.GameObject?.Destroy();
			}
		}
		_holder = null;
		return n;
	}

	public static bool TryVec( JsonElement p, string key, out Vector3 v )
	{
		v = Vector3.Zero;
		if ( !p.TryGetProperty( key, out var e ) ) return false;
		switch ( e.ValueKind )
		{
			case JsonValueKind.String:
				var s = e.GetString().Split( ',' );
				if ( s.Length < 3 ) return false;
				v = new Vector3( F( s[0] ), F( s[1] ), F( s[2] ) );
				return true;
			case JsonValueKind.Array:
				if ( e.GetArrayLength() < 3 ) return false;
				v = new Vector3( (float)e[0].GetDouble(), (float)e[1].GetDouble(), (float)e[2].GetDouble() );
				return true;
			case JsonValueKind.Object:
				v = new Vector3(
					(float)e.GetProperty( "x" ).GetDouble(),
					(float)e.GetProperty( "y" ).GetDouble(),
					(float)e.GetProperty( "z" ).GetDouble() );
				return true;
			default:
				return false;
		}
	}

	public static Color Col( JsonElement p, string key, Color def )
	{
		if ( !p.TryGetProperty( key, out var e ) || e.ValueKind != JsonValueKind.String ) return def;
		var s = e.GetString().Split( ',' );
		if ( s.Length < 3 ) return def;
		float a = s.Length >= 4 ? F( s[3] ) : 1f;
		return new Color( F( s[0] ), F( s[1] ), F( s[2] ), a );
	}

	public static float Flt( JsonElement p, string key, float def )
		=> p.TryGetProperty( key, out var e ) && e.ValueKind == JsonValueKind.Number ? (float)e.GetDouble() : def;

	static float F( string s ) => float.Parse( s.Trim(), Inv );
}

// ── handlers ────────────────────────────────────────────────────────────────

public class DebugDrawLineHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !DebugDrawHelpers.TryVec( p, "from", out var a ) || !DebugDrawHelpers.TryVec( p, "to", out var b ) )
				return Task.FromResult<object>( new { error = "from and to are required (\"x,y,z\")" } );
			var h = DebugDrawHelpers.EnsureHolder();
			if ( h == null ) return Task.FromResult<object>( new { error = "no active scene" } );
			h.Prims.Add( new DebugDrawPrim
			{
				Kind = DebugDrawKind.Line, A = a, B = b,
				Color = DebugDrawHelpers.Col( p, "color", Color.Yellow ),
				Thickness = DebugDrawHelpers.Flt( p, "thickness", 2f )
			} );
			return Task.FromResult<object>( new { drawn = "line", count = h.Prims.Count, mode = Game.IsPlaying ? "play" : "edit" } );
		}
		catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_draw_line failed: {ex.Message}" } ); }
	}
}

public class DebugDrawRayHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !DebugDrawHelpers.TryVec( p, "origin", out var o ) || !DebugDrawHelpers.TryVec( p, "direction", out var d ) )
				return Task.FromResult<object>( new { error = "origin and direction are required (\"x,y,z\")" } );
			float len = DebugDrawHelpers.Flt( p, "length", 64f );
			var h = DebugDrawHelpers.EnsureHolder();
			if ( h == null ) return Task.FromResult<object>( new { error = "no active scene" } );
			h.Prims.Add( new DebugDrawPrim
			{
				Kind = DebugDrawKind.Ray, A = o, B = o + d.Normal * len,
				Color = DebugDrawHelpers.Col( p, "color", Color.Yellow ),
				Thickness = DebugDrawHelpers.Flt( p, "thickness", 2f )
			} );
			return Task.FromResult<object>( new { drawn = "ray", count = h.Prims.Count, mode = Game.IsPlaying ? "play" : "edit" } );
		}
		catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_draw_ray failed: {ex.Message}" } ); }
	}
}

public class DebugDrawBoxHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !DebugDrawHelpers.TryVec( p, "center", out var c ) )
				return Task.FromResult<object>( new { error = "center is required (\"x,y,z\")" } );
			Vector3 size = DebugDrawHelpers.TryVec( p, "size", out var sz ) ? sz : new Vector3( 32f, 32f, 32f );
			var h = DebugDrawHelpers.EnsureHolder();
			if ( h == null ) return Task.FromResult<object>( new { error = "no active scene" } );
			h.Prims.Add( new DebugDrawPrim
			{
				Kind = DebugDrawKind.Box, A = c, Size = size,
				Color = DebugDrawHelpers.Col( p, "color", Color.Green ),
				Thickness = DebugDrawHelpers.Flt( p, "thickness", 2f )
			} );
			return Task.FromResult<object>( new { drawn = "box", count = h.Prims.Count, mode = Game.IsPlaying ? "play" : "edit" } );
		}
		catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_draw_box failed: {ex.Message}" } ); }
	}
}

public class DebugDrawSphereHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !DebugDrawHelpers.TryVec( p, "center", out var c ) )
				return Task.FromResult<object>( new { error = "center is required (\"x,y,z\")" } );
			float r = DebugDrawHelpers.Flt( p, "radius", 32f );
			var h = DebugDrawHelpers.EnsureHolder();
			if ( h == null ) return Task.FromResult<object>( new { error = "no active scene" } );
			h.Prims.Add( new DebugDrawPrim
			{
				Kind = DebugDrawKind.Sphere, A = c, Radius = r,
				Color = DebugDrawHelpers.Col( p, "color", Color.Red ),
				Thickness = DebugDrawHelpers.Flt( p, "thickness", 2f )
			} );
			return Task.FromResult<object>( new { drawn = "sphere", count = h.Prims.Count, mode = Game.IsPlaying ? "play" : "edit" } );
		}
		catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_draw_sphere failed: {ex.Message}" } ); }
	}
}

public class DebugClearHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			int removed = DebugDrawHelpers.ClearHolder();
			return Task.FromResult<object>( new { cleared = true, removed } );
		}
		catch ( Exception ex ) { return Task.FromResult<object>( new { error = $"debug_clear failed: {ex.Message}" } ); }
	}
}
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

// =============================================================================
//  Economy & Save family (Track E) -- six Tier-2 scaffolds (code-gen; scene-mutating):
//
//    create_currency_account    audited host-authoritative ledger: [Sync(FromHost)]
//                               balance + Deposit/Withdraw/TryTransfer + fixed-size
//                               transaction ring buffer (Time.Now, reason, amount)
//    create_idle_economy        geometric bulk-buy: BaseCost * Growth^Owned, closed-form
//                               Buy 1 / Buy N / Buy Max, income tick auto-wired to a
//                               sibling wallet via TypeLibrary reflection
//    create_signed_save         tamper-evident save: FNV-1a signature over payload+salt,
//                               verify-on-load, clamp Sanitize() hook, forced reset on
//                               mismatch, versioned
//    create_meta_progression    between-runs roguelite meta: persistent meta-currency +
//                               unlock flags, Grant/TrySpend/Unlock/IsUnlocked,
//                               OnUnlocked static event, BankRun(int) run-end seam
//    add_steam_stat_currency    currency persisted over Sandbox.Services.Stats
//                               (SetValue/Flush; read-back via GetLocalPlayerStats)
//    create_loot_table_resource GameResource-based loot tables ([AssetType], .loot files)
//                               with nested-table entries + depth-capped resolver component
//
//  Compiles into the SAME editor assembly as MyEditorMenu.cs / ScaffoldHandlers.cs,
//  so it reuses the shared statics on ClaudeBridge (TryResolveProjectPath,
//  SanitizeIdentifier, SerializeGo) and ScaffoldHelpers (PrepareCodeFile /
//  WriteCode / Utf8NoBom). Handler code here is UNSANDBOXED editor code (System.* fine).
//
//  The C# *strings these handlers WRITE TO DISK* are SANDBOXED game code:
//    - sealed Component classes, no virtual members.
//    - [Sync(SyncFlags.FromHost)] for host-auth state (create_economy_wallet-verified);
//      IsProxy guards on every mutation.
//    - System.Math/MathF compile on this SDK; Array.Clone() is blocked (not used).
//    - FileSystem.Data.ReadJsonOrDefault<T>/WriteJson + ReadAllText/WriteAllText/
//      FileExists/DeleteFile all verified live via describe_type BaseFileSystem.
//    - Sandbox.Json.Serialize(object)/Deserialize<T>(string) verified live.
//    - Sandbox.Services.Stats: Increment(string,double), SetValue(string,double,string,object),
//      Flush(), GetLocalPlayerStats(string packageIdent) -> Stats.PlayerStats (NESTED type;
//      .Get(name) returns Stats.PlayerStat with .Value) -- all verified live. There is NO
//      Stats.LocalPlayer on this SDK.
//    - GameResourceAttribute is [Obsolete] on this SDK -- generated resources use
//      [AssetType( Name=..., Extension=..., Category=... )] (the modern corpus pattern).
//    - TypeLibrary wallet wiring copies the compile-verified create_idle_income shape:
//      Game.TypeLibrary.GetType(comp.GetType()) -> Methods.FirstOrDefault(...) ->
//      Invoke / InvokeWithReturn<bool> (both verified on MethodDescription);
//      PropertyDescription.GetValue(object) verified live.
//
//  Register(...) lines + the _sceneMutatingCommands additions live in MyEditorMenu.cs
//  (orchestrator integration) to keep the files decoupled -- see the handoff summary.
// =============================================================================

// -----------------------------------------------------------------------------
// create_currency_account -- the audited sibling of create_economy_wallet.
// Wallet = simple money (AddMoney/TrySpend). Account = money + a fixed-size
// transaction ring buffer (timestamp, reason, amount, balance-after) with
// GetRecentTransactions() for ledger UIs / audit trails, plus TryTransfer
// between accounts. Folds the corpus asks create_economy_ledger / create_currency.
// -----------------------------------------------------------------------------
public class CreateCurrencyAccountHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !ScaffoldHelpers.PrepareCodeFile( p, "CurrencyAccount", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			long start = p.TryGetProperty( "startingBalance", out var sv ) && sv.TryGetInt64( out var sl ) ? sl : 0L;
			int history = p.TryGetProperty( "historySize", out var hv ) && hv.TryGetInt32( out var hi ) ? hi : 32;
			if ( history < 1 ) history = 1;
			if ( history > 4096 ) history = 4096;

			var code = BuildCode( className, start, history );
			ScaffoldHelpers.WriteCode( fullPath, code );

			object placedOn = null; string note = null;
			if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
				placedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				startingBalance = start,
				historySize = history,
				placedOn,
				note,
				nextSteps = new[]
				{
					$"trigger_hotload to compile {className} into the game assembly.",
					placedOn != null
						? $"{className} was attached to the target GameObject."
						: $"Place it on a per-player or bank GameObject: add_component_to_new_object (component=\"{className}\") after the hotload, or re-run with targetId.",
					$"Move money host-side: GetComponent<{className}>()?.Deposit( 100, \"quest reward\" ); .Withdraw( 50, \"shop\" ); .TryTransfer( other, 25, \"trade\" );",
					$"Read the ledger (host-side, newest first): foreach ( var t in GetComponent<{className}>().GetRecentTransactions() ) Log.Info( $\"{{t.Time}} {{t.Amount}} {{t.Reason}} -> {{t.BalanceAfter}}\" );",
					$"Bind a HUD: GetComponent<{className}>().OnBalanceChanged = bal => {{ /* update label */ }}; Balance is [Sync(FromHost)] so clients can read it directly.",
					$"History keeps the last {history} transactions (HistorySize, fixed once the first transaction is recorded); older entries are overwritten silently. The ledger itself is host-side only -- it does not replicate."
				}
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_currency_account failed: {ex.Message}" } );
		}
	}

	static string BuildCode( string className, long start, int history )
	{
		var ci = System.Globalization.CultureInfo.InvariantCulture;
		string st = start.ToString( ci );
		string hs = history.ToString( ci );

		return $@"using Sandbox;
using System;
using System.Collections.Generic;

/// <summary>
/// {className} -- a host-authoritative currency ACCOUNT: an audited ledger.
///
/// Use create_economy_wallet's Wallet when you just need money; use this when you need
/// money PLUS an audit trail. Balance is [Sync(SyncFlags.FromHost)] so only the host
/// writes it (clients can't author their own balance); every Deposit / Withdraw /
/// TryTransfer records a Transaction (Time.Now, reason, signed amount, balance-after)
/// into a fixed-size ring buffer, newest overwriting oldest past HistorySize.
///
/// The ledger is HOST-SIDE ONLY -- it does not replicate. Balance replicates; feed a
/// client-side ledger UI over an RPC if you need remote history. Single-player safe
/// (IsProxy is false with no networking active).
///
/// Usage (host-side):
///   GetComponent&lt;{className}&gt;()?.Deposit( 100, ""quest reward"" );
///   if ( GetComponent&lt;{className}&gt;().Withdraw( 50, ""shop"" ) ) {{ /* grant the item */ }}
///   GetComponent&lt;{className}&gt;().TryTransfer( otherAccount, 25, ""trade"" );
///   foreach ( var t in GetComponent&lt;{className}&gt;().GetRecentTransactions() ) {{ /* newest first */ }}
/// </summary>
public sealed class {className} : Component
{{
	/// Balance the account opens with (host seeds it in OnStart).
	[Property] public long StartingBalance {{ get; set; }} = {st}L;

	/// Ring-buffer capacity. Fixed once the first transaction is recorded.
	[Property] public int HistorySize {{ get; set; }} = {hs};

	// Host-authoritative balance -- replicates to clients, only the host writes.
	[Sync( SyncFlags.FromHost )] public long Balance {{ get; set; }}

	/// One ledger line. Amount is signed: positive = deposit, negative = withdrawal.
	public struct Transaction
	{{
		public float Time;          // Time.Now when recorded
		public long Amount;         // signed delta
		public string Reason;       // free-form audit string
		public long BalanceAfter;   // balance after applying the delta
	}}

	/// Fired (on the writing machine) whenever the balance changes -- bind a HUD here.
	public Action<long> OnBalanceChanged {{ get; set; }}

	// Host-side ring buffer. _head = next write slot, _count = filled slots.
	private Transaction[] _history;
	private int _head;
	private int _count;

	protected override void OnStart()
	{{
		if ( IsProxy ) return;            // only the authority seeds the balance
		Balance = StartingBalance;
		if ( StartingBalance != 0 ) Record( StartingBalance, ""opening balance"" );
		OnBalanceChanged?.Invoke( Balance );
	}}

	public bool CanAfford( long amount ) => Balance >= amount;

	/// <summary>Deposit (host-authoritative). Non-positive amounts are ignored.</summary>
	public void Deposit( long amount, string reason = ""deposit"" )
	{{
		if ( IsProxy || amount <= 0 ) return;
		Balance += amount;
		Record( amount, reason );
		OnBalanceChanged?.Invoke( Balance );
	}}

	/// <summary>Withdraw if affordable; returns false and changes nothing if not (host-authoritative).</summary>
	public bool Withdraw( long amount, string reason = ""withdraw"" )
	{{
		if ( IsProxy || amount <= 0 ) return false;
		if ( Balance < amount ) return false;
		Balance -= amount;
		Record( -amount, reason );
		OnBalanceChanged?.Invoke( Balance );
		return true;
	}}

	/// <summary>
	/// Atomically move money into another account (host-authoritative). Both legs are
	/// recorded in their respective ledgers. Returns false (nothing moves) when the
	/// target is missing/self, the amount is non-positive, or funds are short.
	/// </summary>
	public bool TryTransfer( {className} to, long amount, string reason = ""transfer"" )
	{{
		if ( IsProxy || to == null || to == this || amount <= 0 ) return false;
		if ( Balance < amount ) return false;
		Balance -= amount;
		Record( -amount, reason );
		OnBalanceChanged?.Invoke( Balance );
		to.ReceiveTransfer( amount, reason );
		return true;
	}}

	// The receiving leg of TryTransfer -- runs on the host alongside the sending leg.
	private void ReceiveTransfer( long amount, string reason )
	{{
		Balance += amount;
		Record( amount, reason );
		OnBalanceChanged?.Invoke( Balance );
	}}

	/// <summary>
	/// The most recent transactions, NEWEST FIRST. max = 0 returns everything retained
	/// (up to HistorySize). Host-side only -- proxies always get an empty list.
	/// </summary>
	public List<Transaction> GetRecentTransactions( int max = 0 )
	{{
		var list = new List<Transaction>();
		if ( _history == null || _count == 0 ) return list;
		int take = _count;
		if ( max > 0 && max < take ) take = max;
		for ( int i = 0; i < take; i++ )
		{{
			int idx = ( _head - 1 - i + _history.Length * 2 ) % _history.Length;
			list.Add( _history[idx] );
		}}
		return list;
	}}

	private void Record( long amount, string reason )
	{{
		if ( _history == null )
			_history = new Transaction[HistorySize < 1 ? 1 : HistorySize];

		_history[_head] = new Transaction
		{{
			Time = Time.Now,
			Amount = amount,
			Reason = reason ?? """",
			BalanceAfter = Balance
		}};
		_head = ( _head + 1 ) % _history.Length;
		if ( _count < _history.Length ) _count++;
	}}
}}
";
	}
}

// -----------------------------------------------------------------------------
// create_idle_economy -- geometric bulk-buy purchasing. Generators follow the
// classic BaseCost * Growth^Owned curve; Buy 1 / Buy N / Buy Max use the
// closed-form geometric series (no loops). Income ticks grant into a sibling
// wallet's AddMoney via TypeLibrary reflection (the compile-verified
// create_idle_income pattern); purchases spend via the sibling's TrySpend and
// Buy Max reads its Money property the same way.
// -----------------------------------------------------------------------------
public class CreateIdleEconomyHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			var ci = System.Globalization.CultureInfo.InvariantCulture;

			if ( !ScaffoldHelpers.PrepareCodeFile( p, "IdleEconomy", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			float tick = p.TryGetProperty( "tickSeconds", out var tv ) && tv.TryGetSingle( out var tf ) ? tf : 1f;
			if ( tick < 0.1f ) tick = 0.1f;

			var gens = ParseGenerators( p );

			var code = BuildCode( className, gens, tick, ci );
			ScaffoldHelpers.WriteCode( fullPath, code );

			object placedOn = null; string note = null;
			if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
				placedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				generators = gens.Select( g => g.Name ).ToArray(),
				tickSeconds = tick,
				placedOn,
				note,
				nextSteps = new[]
				{
					$"trigger_hotload to compile {className} into the game assembly.",
					placedOn != null
						? $"{className} was attached to the target GameObject."
						: $"Place it NEXT TO a wallet component (create_economy_wallet / create_currency_account) on the same GameObject: add_component_to_new_object (component=\"{className}\") after the hotload, or re-run with targetId.",
					"It auto-wires the sibling wallet by reflection: income invokes AddMoney(long|int), purchases invoke TrySpend(long|int), Buy Max reads the Money property. No wallet sibling = purchases refused with a Log.Warning (never silent).",
					$"Buy from game code: GetComponent<{className}>().TryBuy( 0, 1 ); .TryBuy( 0, 10 ); int n = GetComponent<{className}>().BuyMax( 0 );",
					$"Show prices: double cost = GetComponent<{className}>().CostOf( 0, 10 ); int max = GetComponent<{className}>().MaxAffordable( 0 ); -- both closed-form geometric series, no loops.",
					$"React to events: {className}.OnPurchased += ( index, count, cost ) => {{ }}; {className}.OnIncomeTick += ( amount, total ) => {{ }};",
					"Tune GeneratorNames / BaseCosts / Growths / IncomesPerSecond (parallel lists) in the inspector or with set_property. Owned counts are host-side state (not replicated); pair with create_offline_progress for away-time earnings."
				}
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_idle_economy failed: {ex.Message}" } );
		}
	}

	internal struct GeneratorDef { public string Name; public float BaseCost; public float Growth; public float IncomePerSecond; }

	static List<GeneratorDef> ParseGenerators( JsonElement p )
	{
		var result = new List<GeneratorDef>();
		if ( p.TryGetProperty( "generators", out var gv ) && gv.ValueKind == JsonValueKind.Array )
		{
			foreach ( var item in gv.EnumerateArray() )
			{
				var g = new GeneratorDef
				{
					Name = item.TryGetProperty( "name", out var nv ) && !string.IsNullOrWhiteSpace( nv.GetString() ) ? nv.GetString() : "Generator",
					BaseCost = item.TryGetProperty( "baseCost", out var bv ) && bv.TryGetSingle( out var bf ) ? bf : 15f,
					Growth = item.TryGetProperty( "growth", out var grv ) && grv.TryGetSingle( out var grf ) ? grf : 1.15f,
					IncomePerSecond = item.TryGetProperty( "incomePerSecond", out var iv ) && iv.TryGetSingle( out var inf ) ? inf : 0.5f
				};
				// Escape-strip: the name is baked into a generated string literal.
				g.Name = ( g.Name ?? "Generator" ).Replace( "\\", "" ).Replace( "\"", "" );
				if ( g.BaseCost <= 0f ) g.BaseCost = 1f;
				if ( g.Growth < 1f ) g.Growth = 1f;
				if ( g.IncomePerSecond < 0f ) g.IncomePerSecond = 0f;
				result.Add( g );
			}
		}
		if ( result.Count == 0 )
		{
			result.Add( new GeneratorDef { Name = "Cursor", BaseCost = 15f, Growth = 1.15f, IncomePerSecond = 0.5f } );
			result.Add( new GeneratorDef { Name = "Farm", BaseCost = 200f, Growth = 1.15f, IncomePerSecond = 4f } );
			result.Add( new GeneratorDef { Name = "Factory", BaseCost = 3000f, Growth = 1.12f, IncomePerSecond = 30f } );
		}
		return result;
	}

	static string BuildCode( string className, List<GeneratorDef> gens, float tick, System.Globalization.CultureInfo ci )
	{
		string nameLits = string.Join( ", ", gens.Select( g => $"\"{g.Name}\"" ) );
		string costLits = string.Join( ", ", gens.Select( g => g.BaseCost.ToString( ci ) + "f" ) );
		string growthLits = string.Join( ", ", gens.Select( g => g.Growth.ToString( ci ) + "f" ) );
		string incomeLits = string.Join( ", ", gens.Select( g => g.IncomePerSecond.ToString( ci ) + "f" ) );
		string tk = tick.ToString( ci ) + "f";

		return $@"using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// {className} -- a geometric idle economy: generators, bulk buying, passive income.
///
/// COST CURVE: buying copy k of generator i costs BaseCosts[i] * Growths[i]^k -- the
/// classic incremental-game curve. CostOf / MaxAffordable / TryBuy all use the CLOSED-FORM
/// geometric series (no per-copy loops), so Buy 1000 is the same math as Buy 1:
///   cost(n)  = c0 * (g^n - 1) / (g - 1)        where c0 = BaseCost * g^Owned
///   buyMax   = floor( log_g( funds*(g-1)/c0 + 1 ) )
///
/// WALLET WIRING (TypeLibrary reflection -- no compile-time wallet dependency): income
/// invokes AddMoney(long|int) on the first sibling component that has one; purchases
/// invoke TrySpend(long|int); Buy Max reads the sibling's Money property. Works out of
/// the box next to a create_economy_wallet or create_currency_account scaffold. No wallet
/// sibling = purchases are REFUSED with a Log.Warning (never silent).
///
/// HOST-AUTHORITATIVE: all mutation is IsProxy-guarded; owned counts are host-side state
/// (not replicated -- replicate via your own [Sync]/RPC if clients need them). TotalEarned
/// is [Sync(FromHost)]. Single-player safe.
///
/// Usage:
///   GetComponent&lt;{className}&gt;().TryBuy( 0, 1 );          // Buy 1
///   GetComponent&lt;{className}&gt;().TryBuy( 0, 10 );         // Buy N
///   int bought = GetComponent&lt;{className}&gt;().BuyMax( 0 ); // Buy Max
///   {className}.OnPurchased += ( i, count, cost ) => {{ /* refresh shop UI */ }};
///   {className}.OnIncomeTick += ( amount, total ) => {{ /* +N popup */ }};
/// </summary>
public sealed class {className} : Component
{{
	/// Generator display names -- parallel to BaseCosts / Growths / IncomesPerSecond.
	[Property] public List<string> GeneratorNames {{ get; set; }} = new List<string> {{ {nameLits} }};

	/// Cost of the FIRST copy of each generator (curve: BaseCost * Growth^Owned).
	[Property] public List<float> BaseCosts {{ get; set; }} = new List<float> {{ {costLits} }};

	/// Per-copy cost multiplier (1.15 = the classic curve). Values below 1 are treated as 1 (flat cost).
	[Property] public List<float> Growths {{ get; set; }} = new List<float> {{ {growthLits} }};

	/// Income each owned copy produces per second.
	[Property] public List<float> IncomesPerSecond {{ get; set; }} = new List<float> {{ {incomeLits} }};

	/// Seconds between income grants.
	[Property] public float TickSeconds {{ get; set; }} = {tk};

	/// Total income ever granted (host-authoritative, replicates to clients).
	[Sync( SyncFlags.FromHost )] public float TotalEarned {{ get; set; }}

	/// Fires host-side after a purchase: (generatorIndex, countBought, totalCost).
	public static Action<int, int, double> OnPurchased {{ get; set; }}

	/// Fires host-side after each income grant: (amount, newTotalEarned).
	public static Action<float, float> OnIncomeTick {{ get; set; }}

	// Host-side owned counts, parallel to the property lists.
	private int[] _owned;
	private TimeUntil _nextTick;

	protected override void OnStart()
	{{
		_nextTick = TickSeconds;
	}}

	protected override void OnFixedUpdate()
	{{
		if ( IsProxy ) return;
		if ( !_nextTick ) return;
		_nextTick = TickSeconds;

		EnsureOwned();
		float amount = 0f;
		for ( int i = 0; i < _owned.Length; i++ )
			amount += _owned[i] * IncomeOf( i ) * TickSeconds;
		if ( amount <= 0f ) return;

		TotalEarned += amount;
		GrantIncome( amount );
		OnIncomeTick?.Invoke( amount, TotalEarned );
	}}

	/// <summary>Copies of a generator owned (host-side state; 0 on proxies).</summary>
	public int GetOwned( int index )
	{{
		EnsureOwned();
		return index >= 0 && index < _owned.Length ? _owned[index] : 0;
	}}

	/// <summary>
	/// Closed-form cost of the next `count` copies of generator `index` from the current
	/// owned count. 0 for an invalid index or non-positive count.
	/// </summary>
	public double CostOf( int index, int count )
	{{
		if ( count <= 0 || !ValidIndex( index ) ) return 0.0;
		double g = GrowthOf( index );
		double c0 = BaseCosts[index] * Math.Pow( g, GetOwned( index ) );
		if ( Math.Abs( g - 1.0 ) < 0.0001 ) return c0 * count;
		return c0 * ( Math.Pow( g, count ) - 1.0 ) / ( g - 1.0 );
	}}

	/// <summary>
	/// Closed-form Buy-Max count against the sibling wallet's current Money.
	/// 0 when nothing is affordable or no wallet sibling exposes a Money property.
	/// </summary>
	public int MaxAffordable( int index )
	{{
		if ( !ValidIndex( index ) ) return 0;
		double funds = ReadWalletBalance();
		if ( funds <= 0.0 ) return 0;
		double g = GrowthOf( index );
		double c0 = BaseCosts[index] * Math.Pow( g, GetOwned( index ) );
		if ( c0 <= 0.0 ) return 0;
		if ( Math.Abs( g - 1.0 ) < 0.0001 ) return (int) Math.Floor( funds / c0 );
		return (int) Math.Floor( Math.Log( funds * ( g - 1.0 ) / c0 + 1.0 ) / Math.Log( g ) );
	}}

	/// <summary>
	/// Buy `count` copies if the sibling wallet's TrySpend accepts the closed-form cost
	/// (rounded up to whole currency). Host-only; false when unaffordable or no wallet.
	/// </summary>
	public bool TryBuy( int index, int count )
	{{
		if ( IsProxy || count <= 0 || !ValidIndex( index ) ) return false;
		EnsureOwned();
		double cost = CostOf( index, count );
		if ( !SpendFromWallet( cost ) ) return false;
		_owned[index] += count;
		OnPurchased?.Invoke( index, count, cost );
		return true;
	}}

	/// <summary>
	/// Buy as many copies as the wallet can afford. Returns the count bought (0 = none).
	/// Steps down once past a whole-currency rounding edge rather than failing.
	/// </summary>
	public int BuyMax( int index )
	{{
		int n = MaxAffordable( index );
		while ( n > 0 )
		{{
			if ( TryBuy( index, n ) ) return n;
			n--;   // ceil-rounding edge: the closed form said n, the wallet said no -- step down
		}}
		return 0;
	}}

	private bool ValidIndex( int index )
		=> BaseCosts != null && index >= 0 && index < BaseCosts.Count;

	private double GrowthOf( int index )
	{{
		float g = Growths != null && index < Growths.Count ? Growths[index] : 1.15f;
		return g < 1f ? 1.0 : g;
	}}

	private float IncomeOf( int index )
		=> IncomesPerSecond != null && index < IncomesPerSecond.Count && index >= 0 ? IncomesPerSecond[index] : 0f;

	private void EnsureOwned()
	{{
		int size = BaseCosts?.Count ?? 0;
		int names = GeneratorNames?.Count ?? 0;
		if ( names > size ) size = names;
		if ( size < 1 ) size = 1;

		if ( _owned == null )
		{{
			_owned = new int[size];
		}}
		else if ( _owned.Length < size )
		{{
			var grown = new int[size];
			for ( int i = 0; i < _owned.Length; i++ ) grown[i] = _owned[i];
			_owned = grown;
		}}
	}}

	// ---- sibling-wallet wiring (TypeLibrary reflection; no hard wallet dependency) ----

	// Deliver income: AddMoney(long|int) on the first sibling that has one.
	private void GrantIncome( float amount )
	{{
		foreach ( var comp in Components.GetAll() )
		{{
			if ( comp == this || comp is null ) continue;
			var type = Game.TypeLibrary?.GetType( comp.GetType() );
			var method = type?.Methods?.FirstOrDefault( m => m.Name == ""AddMoney"" );
			if ( method == null ) continue;
			try {{ method.Invoke( comp, new object[] {{ (long) amount }} ); return; }}
			catch {{ }}
			try {{ method.Invoke( comp, new object[] {{ (int) amount }} ); return; }}
			catch {{ /* wrong signature -- keep looking */ }}
		}}
		// No wallet sibling -- TotalEarned still accumulates; read it directly.
	}}

	// Spend: TrySpend(long|int) on the first sibling that has one. Never silent on failure.
	private bool SpendFromWallet( double cost )
	{{
		if ( cost <= 0.0 ) return false;
		long rounded = (long) Math.Ceiling( cost );
		foreach ( var comp in Components.GetAll() )
		{{
			if ( comp == this || comp is null ) continue;
			var type = Game.TypeLibrary?.GetType( comp.GetType() );
			var method = type?.Methods?.FirstOrDefault( m => m.Name == ""TrySpend"" );
			if ( method == null ) continue;
			try {{ return method.InvokeWithReturn<bool>( comp, new object[] {{ rounded }} ); }}
			catch {{ }}
			try {{ return method.InvokeWithReturn<bool>( comp, new object[] {{ (int) rounded }} ); }}
			catch {{ /* wrong signature -- keep looking */ }}
		}}
		Log.Warning( $""[{className}] No sibling wallet with TrySpend found -- add a create_economy_wallet / create_currency_account component next to it. Purchase refused."" );
		return false;
	}}

	// Read funds for Buy Max: the first sibling exposing a numeric Money property.
	private double ReadWalletBalance()
	{{
		foreach ( var comp in Components.GetAll() )
		{{
			if ( comp == this || comp is null ) continue;
			var type = Game.TypeLibrary?.GetType( comp.GetType() );
			var prop = type?.Properties?.FirstOrDefault( pp => pp.Name == ""Money"" || pp.Name == ""Balance"" );
			if ( prop == null ) continue;
			try
			{{
				object v = prop.GetValue( comp );
				if ( v is long l ) return l;
				if ( v is int i ) return i;
				if ( v is float f ) return f;
				if ( v is double d ) return d;
			}}
			catch {{ /* unreadable -- keep looking */ }}
		}}
		return 0.0;
	}}
}}
";
	}
}

// -----------------------------------------------------------------------------
// create_signed_save -- tamper-evident save file. The payload POCO is serialized
// to JSON (Sandbox.Json), FNV-1a-64 hashed together with a salt + version, and
// written inside a signed envelope via FileSystem.Data. Load verifies the
// signature; a mismatch = forced reset (delete + defaults) + OnTampered event.
// Clamp-on-load Sanitize() hook + versioning copy create_save_system's shape.
// -----------------------------------------------------------------------------
public class CreateSignedSaveHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !ScaffoldHelpers.PrepareCodeFile( p, "SignedSave", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			var ci = System.Globalization.CultureInfo.InvariantCulture;
			string fileName = p.TryGetProperty( "fileName", out var fn ) && !string.IsNullOrWhiteSpace( fn.GetString() ) ? fn.GetString() : "save_signed.json";
			int version = p.TryGetProperty( "version", out var vv ) && vv.TryGetInt32( out var vi ) ? vi : 1;
			float autosave = p.TryGetProperty( "autosaveSeconds", out var av ) && av.TryGetSingle( out var af ) ? af : 10f;
			string salt = p.TryGetProperty( "salt", out var sv ) && !string.IsNullOrWhiteSpace( sv.GetString() )
				? sv.GetString()
				: Guid.NewGuid().ToString( "N" );   // unique per generated file by default

			// These are baked into generated string literals -- strip escape characters.
			fileName = fileName.Replace( "\\", "" ).Replace( "\"", "" );
			salt = salt.Replace( "\\", "" ).Replace( "\"", "" );

			var code = BuildCode( className, fileName, version.ToString( ci ), autosave.ToString( ci ) + "f", salt );
			ScaffoldHelpers.WriteCode( fullPath, code );

			object placedOn = null; string note = null;
			if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
				placedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				fileName,
				version,
				autosaveSeconds = autosave,
				placedOn,
				note,
				nextSteps = new[]
				{
					$"trigger_hotload to compile {className} into the game assembly.",
					placedOn != null
						? $"{className} was attached to the target GameObject."
						: $"Place it on your save-manager GameObject: add_component_to_new_object (component=\"{className}\") after the hotload, or re-run with targetId.",
					$"Add your game fields to the SaveData inner class in {className}.cs, extend Sanitize() to clamp them, and bump Version when the shape changes.",
					$"Use it: GetComponent<{className}>().Data.Money += 100; GetComponent<{className}>().MarkDirty(); -- the dirty-flag autosave (or OnDestroy) writes and re-signs.",
					$"React: {className}.OnLoaded += d => {{ }}; {className}.OnSaved += d => {{ }}; {className}.OnTampered += reason => {{ /* tell the player their save was reset */ }};",
					"TAMPER = FORCED RESET: an edited payload fails the FNV-1a signature check on load, the file is DELETED and defaults are used (OnTampered fires with the reason). This is tamper-EVIDENT, not cryptographically secure -- the salt ships in the game code, so a determined user can re-sign; it stops casual notepad edits, not reverse engineers."
				}
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_signed_save failed: {ex.Message}" } );
		}
	}

	static string BuildCode( string className, string fileName, string version, string autosave, string salt )
	{
		return $@"using Sandbox;
using System;

/// <summary>
/// {className} -- a tamper-evident, versioned save system.
///
/// The SaveData payload is serialized to JSON, hashed with FNV-1a-64 over
/// payload + version + salt, and written inside a signed envelope to
/// FileSystem.Data. Load re-computes the signature: a mismatch (hand-edited or
/// corrupt file) triggers a FORCED RESET -- the file is deleted, defaults are
/// used, and the static OnTampered event fires. A version mismatch starts fresh
/// (add migrations in Load if you need them). Loaded values pass through the
/// Sanitize() clamp hook so even a re-signed save can't smuggle absurd values.
///
/// NOT cryptography: the salt ships inside the game assembly, so this is
/// tamper-EVIDENT (stops notepad edits), not tamper-PROOF.
///
/// Host/owner-only (IsProxy-guarded). Dirty-flag autosave every AutosaveSeconds
/// plus a final save in OnDestroy.
///
/// Usage:
///   var save = GetComponent&lt;{className}&gt;();
///   save.Data.Money += 100; save.MarkDirty();
///   {className}.OnTampered += reason => Log.Warning( $""save reset: {{reason}}"" );
/// </summary>
public sealed class {className} : Component
{{
	/// FileSystem.Data path the signed envelope is written to.
	[Property] public string FileName {{ get; set; }} = ""{fileName}"";

	/// Autosave cadence in seconds. 0 disables the heartbeat (OnDestroy still saves).
	[Property] public float AutosaveSeconds {{ get; set; }} = {autosave};

	/// Save-shape version -- bump when SaveData changes so old files start fresh.
	public const int Version = {version};

	// Baked-in signing salt (unique to this generated file). Changing it invalidates existing saves.
	private const string Salt = ""{salt}"";

	/// The save payload. Add your own fields here; clamp them in Sanitize().
	public class SaveData
	{{
		public int Money {{ get; set; }}
		public int Day {{ get; set; }} = 1;
		// Add game fields here.
	}}

	/// The envelope actually written to disk: version + raw payload JSON + signature.
	public class SaveEnvelope
	{{
		public int Version {{ get; set; }}
		public string Payload {{ get; set; }}
		public ulong Signature {{ get; set; }}
	}}

	public SaveData Data {{ get; private set; }} = new SaveData();
	public bool IsDirty {{ get; private set; }}

	/// Fires after a successful Load() with the loaded (sanitized) data.
	public static Action<SaveData> OnLoaded {{ get; set; }}
	/// Fires after every Save().
	public static Action<SaveData> OnSaved {{ get; set; }}
	/// Fires when the signature check fails and the save is force-reset. Arg = reason.
	public static Action<string> OnTampered {{ get; set; }}

	private TimeUntil _nextAutosave;

	protected override void OnStart()
	{{
		if ( IsProxy ) return;   // only the owning machine loads
		Load();
		_nextAutosave = AutosaveSeconds;
	}}

	protected override void OnUpdate()
	{{
		if ( IsProxy || AutosaveSeconds <= 0f ) return;
		if ( _nextAutosave )
		{{
			_nextAutosave = AutosaveSeconds;
			if ( IsDirty ) Save();
		}}
	}}

	protected override void OnDestroy()
	{{
		if ( !IsProxy && IsDirty ) Save();
	}}

	/// Mark the data changed so the next autosave tick (or OnDestroy) writes + re-signs it.
	public void MarkDirty() => IsDirty = true;

	public void Load()
	{{
		var envelope = FileSystem.Data.ReadJsonOrDefault<SaveEnvelope>( FileName, null );
		if ( envelope == null )
		{{
			// Missing or unreadable envelope: start fresh (not treated as tampering).
			Data = new SaveData();
			IsDirty = true;
		}}
		else if ( envelope.Version != Version )
		{{
			// Old save shape: start fresh (add migrations here later).
			Data = new SaveData();
			IsDirty = true;
		}}
		else if ( envelope.Payload == null || ComputeSignature( envelope.Payload ) != envelope.Signature )
		{{
			ForceReset( ""signature mismatch -- save file was modified outside the game"" );
			return;   // ForceReset already fired OnLoaded
		}}
		else
		{{
			SaveData loaded = null;
			try {{ loaded = Json.Deserialize<SaveData>( envelope.Payload ); }}
			catch {{ }}
			if ( loaded == null )
			{{
				ForceReset( ""payload failed to parse despite a valid signature"" );
				return;
			}}
			Data = Sanitize( loaded );
			IsDirty = false;
		}}
		OnLoaded?.Invoke( Data );
	}}

	public void Save()
	{{
		var payload = Json.Serialize( Data );
		var envelope = new SaveEnvelope
		{{
			Version = Version,
			Payload = payload,
			Signature = ComputeSignature( payload )
		}};
		FileSystem.Data.WriteJson( FileName, envelope );
		IsDirty = false;
		OnSaved?.Invoke( Data );
	}}

	/// <summary>Delete the save file and reset to defaults. Fires OnTampered then OnLoaded.</summary>
	public void ForceReset( string reason )
	{{
		try
		{{
			if ( FileSystem.Data.FileExists( FileName ) )
				FileSystem.Data.DeleteFile( FileName );
		}}
		catch {{ }}
		Data = new SaveData();
		IsDirty = true;
		OnTampered?.Invoke( reason ?? ""forced reset"" );
		OnLoaded?.Invoke( Data );
	}}

	/// Clamp-on-load: keep loaded values inside sane ranges so even a re-signed
	/// save can't smuggle absurd values. Extend per field you add.
	private SaveData Sanitize( SaveData d )
	{{
		if ( d.Money < 0 ) d.Money = 0;
		if ( d.Day < 1 ) d.Day = 1;
		return d;
	}}

	// FNV-1a 64-bit over payload + version + salt. Deterministic, allocation-light.
	private static ulong ComputeSignature( string payload )
	{{
		const ulong offsetBasis = 14695981039346656037UL;
		const ulong prime = 1099511628211UL;

		ulong hash = offsetBasis;
		string material = payload + ""|"" + Version + ""|"" + Salt;
		for ( int i = 0; i < material.Length; i++ )
		{{
			hash ^= material[i];
			hash *= prime;
		}}
		return hash;
	}}
}}
";
	}
}

// -----------------------------------------------------------------------------
// create_meta_progression -- the between-runs roguelite meta layer: persistent
// meta-currency + unlock-flag dictionary saved to FileSystem.Data JSON.
// Grant/TrySpend/Unlock/IsUnlocked + a BankRun(int) run-end seam + a static
// OnUnlocked event. Persistence copies create_save_system's dirty-flag shape.
// -----------------------------------------------------------------------------
public class CreateMetaProgressionHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !ScaffoldHelpers.PrepareCodeFile( p, "MetaProgression", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			var ci = System.Globalization.CultureInfo.InvariantCulture;
			string fileName = p.TryGetProperty( "fileName", out var fn ) && !string.IsNullOrWhiteSpace( fn.GetString() ) ? fn.GetString() : "meta.json";
			int version = p.TryGetProperty( "version", out var vv ) && vv.TryGetInt32( out var vi ) ? vi : 1;
			float autosave = p.TryGetProperty( "autosaveSeconds", out var av ) && av.TryGetSingle( out var af ) ? af : 10f;

			fileName = fileName.Replace( "\\", "" ).Replace( "\"", "" );

			var code = BuildCode( className, fileName, version.ToString( ci ), autosave.ToString( ci ) + "f" );
			ScaffoldHelpers.WriteCode( fullPath, code );

			object placedOn = null; string note = null;
			if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
				placedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				fileName,
				version,
				placedOn,
				note,
				nextSteps = new[]
				{
					$"trigger_hotload to compile {className} into the game assembly.",
					placedOn != null
						? $"{className} was attached to the target GameObject."
						: $"Place it on a persistent manager GameObject (one that exists in your hub/menu scene): add_component_to_new_object (component=\"{className}\") after the hotload, or re-run with targetId.",
					$"At run end, bank the earnings: GetComponent<{className}>().BankRun( runCurrencyEarned ); -- it grants and saves immediately.",
					$"Gate content: if ( GetComponent<{className}>().TrySpend( 50 ) ) GetComponent<{className}>().Unlock( \"double_jump\" ); then check IsUnlocked( \"double_jump\" ) when building the player.",
					$"React to unlocks anywhere: {className}.OnUnlocked += key => {{ /* flash the new item in the meta shop */ }};",
					"MetaCurrency and the unlock flags persist to FileSystem.Data across sessions (dirty-flag autosave + OnDestroy). IsProxy-guarded: in multiplayer each machine banks only its own meta file."
				}
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_meta_progression failed: {ex.Message}" } );
		}
	}

	static string BuildCode( string className, string fileName, string version, string autosave )
	{
		return $@"using Sandbox;
using System;
using System.Collections.Generic;

/// <summary>
/// {className} -- the between-runs roguelite meta layer.
///
/// Persists a meta-currency plus an unlock-flag dictionary to FileSystem.Data JSON
/// (dirty-flag autosave + OnDestroy, create_save_system's shape). During a run you earn
/// normal run-currency; at run end call BankRun(earned) to convert it into persistent
/// meta-currency. Spend meta-currency on permanent Unlock() flags and gate content with
/// IsUnlocked(). The static OnUnlocked event fires on every new unlock.
///
/// Owner-only (IsProxy-guarded): each machine banks only its own meta file.
///
/// Usage:
///   GetComponent&lt;{className}&gt;().BankRun( 120 );                    // run over
///   if ( GetComponent&lt;{className}&gt;().TrySpend( 50 ) )
///       GetComponent&lt;{className}&gt;().Unlock( ""double_jump"" );
///   if ( GetComponent&lt;{className}&gt;().IsUnlocked( ""double_jump"" ) ) {{ /* enable it */ }}
///   {className}.OnUnlocked += key => {{ /* celebrate */ }};
/// </summary>
public sealed class {className} : Component
{{
	/// FileSystem.Data path the meta state is written to.
	[Property] public string FileName {{ get; set; }} = ""{fileName}"";

	/// Autosave cadence in seconds. 0 disables the heartbeat (OnDestroy still saves).
	[Property] public float AutosaveSeconds {{ get; set; }} = {autosave};

	/// The persisted payload. Bump Version when the shape changes so old files start fresh.
	public class MetaData
	{{
		public int Version {{ get; set; }} = {version};
		public long MetaCurrency {{ get; set; }}
		public int RunsBanked {{ get; set; }}
		public Dictionary<string, bool> Unlocks {{ get; set; }} = new Dictionary<string, bool>();
	}}

	public MetaData Data {{ get; private set; }} = new MetaData();
	public bool IsDirty {{ get; private set; }}

	/// Fires (on the owning machine) when a key is unlocked for the FIRST time.
	public static Action<string> OnUnlocked {{ get; set; }}

	/// Fires whenever MetaCurrency changes -- bind the meta-shop balance label here.
	public Action<long> OnCurrencyChanged {{ get; set; }}

	private TimeUntil _nextAutosave;

	protected override void OnStart()
	{{
		if ( IsProxy ) return;   // only the owning machine loads
		Load();
		_nextAutosave = AutosaveSeconds;
	}}

	protected override void OnUpdate()
	{{
		if ( IsProxy || AutosaveSeconds <= 0f ) return;
		if ( _nextAutosave )
		{{
			_nextAutosave = AutosaveSeconds;
			if ( IsDirty ) Save();
		}}
	}}

	protected override void OnDestroy()
	{{
		if ( !IsProxy && IsDirty ) Save();
	}}

	/// <summary>Add meta-currency. Non-positive amounts are ignored.</summary>
	public void Grant( long amount )
	{{
		if ( IsProxy || amount <= 0 ) return;
		Data.MetaCurrency += amount;
		IsDirty = true;
		OnCurrencyChanged?.Invoke( Data.MetaCurrency );
	}}

	/// <summary>Spend meta-currency if affordable; false and no change otherwise.</summary>
	public bool TrySpend( long amount )
	{{
		if ( IsProxy || amount <= 0 ) return false;
		if ( Data.MetaCurrency < amount ) return false;
		Data.MetaCurrency -= amount;
		IsDirty = true;
		OnCurrencyChanged?.Invoke( Data.MetaCurrency );
		return true;
	}}

	/// <summary>Set a permanent unlock flag. Idempotent; OnUnlocked fires only the first time. Saves immediately.</summary>
	public void Unlock( string key )
	{{
		if ( IsProxy || string.IsNullOrEmpty( key ) ) return;
		if ( Data.Unlocks.TryGetValue( key, out var already ) && already ) return;
		Data.Unlocks[key] = true;
		Save();   // unlocks are precious -- write through immediately
		OnUnlocked?.Invoke( key );
	}}

	/// <summary>True when a key has been permanently unlocked.</summary>
	public bool IsUnlocked( string key )
		=> !string.IsNullOrEmpty( key ) && Data.Unlocks.TryGetValue( key, out var v ) && v;

	/// <summary>
	/// Run-end seam: convert this run's earnings into persistent meta-currency and
	/// save immediately. Call it from your round machine's end-of-run transition.
	/// </summary>
	public void BankRun( int earned )
	{{
		if ( IsProxy ) return;
		if ( earned > 0 ) Data.MetaCurrency += earned;
		Data.RunsBanked += 1;
		Save();
		OnCurrencyChanged?.Invoke( Data.MetaCurrency );
	}}

	/// Mark the data changed so the next autosave tick (or OnDestroy) writes it.
	public void MarkDirty() => IsDirty = true;

	public void Load()
	{{
		var loaded = FileSystem.Data.ReadJsonOrDefault<MetaData>( FileName, null );
		if ( loaded == null || loaded.Version != {version} )
		{{
			Data = new MetaData();
			IsDirty = true;
		}}
		else
		{{
			if ( loaded.MetaCurrency < 0 ) loaded.MetaCurrency = 0;
			if ( loaded.RunsBanked < 0 ) loaded.RunsBanked = 0;
			if ( loaded.Unlocks == null ) loaded.Unlocks = new Dictionary<string, bool>();
			Data = loaded;
			IsDirty = false;
		}}
		OnCurrencyChanged?.Invoke( Data.MetaCurrency );
	}}

	public void Save()
	{{
		FileSystem.Data.WriteJson( FileName, Data );
		IsDirty = false;
	}}
}}
";
	}
}

// -----------------------------------------------------------------------------
// add_steam_stat_currency -- currency persisted over Sandbox.Services.Stats.
// Verified live on this SDK: static Stats.Increment(string,double),
// Stats.SetValue(string,double,string,object), Stats.Flush(), and
// Stats.GetLocalPlayerStats(string packageIdent) returning the NESTED
// Stats.PlayerStats (Get(name) -> Stats.PlayerStat with .Value). There is
// NO Stats.LocalPlayer property on this SDK.
// -----------------------------------------------------------------------------
public class AddSteamStatCurrencyHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !ScaffoldHelpers.PrepareCodeFile( p, "SteamStatCurrency", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			string statName = p.TryGetProperty( "statName", out var sv ) && !string.IsNullOrWhiteSpace( sv.GetString() ) ? sv.GetString() : "currency";
			string packageIdent = p.TryGetProperty( "packageIdent", out var pv ) && !string.IsNullOrWhiteSpace( pv.GetString() ) ? pv.GetString() : "";
			bool flushEveryChange = p.TryGetProperty( "flushEveryChange", out var fv ) && fv.ValueKind == JsonValueKind.True;

			// Baked into generated string literals -- strip escape characters.
			statName = statName.Replace( "\\", "" ).Replace( "\"", "" );
			packageIdent = packageIdent.Replace( "\\", "" ).Replace( "\"", "" );

			var code = BuildCode( className, statName, packageIdent, flushEveryChange );
			ScaffoldHelpers.WriteCode( fullPath, code );

			object placedOn = null; string note = null;
			if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
				placedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), className, out note );

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				statName,
				packageIdent = string.IsNullOrEmpty( packageIdent ) ? "(Game.Ident -- the running package)" : packageIdent,
				flushEveryChange,
				placedOn,
				note,
				nextSteps = new[]
				{
					$"trigger_hotload to compile {className} into the game assembly.",
					placedOn != null
						? $"{className} was attached to the target GameObject."
						: $"Place it on the LOCAL player's GameObject (each player writes only their own Steam stat): add_component_to_new_object (component=\"{className}\") after the hotload, or re-run with targetId.",
					$"Use it: GetComponent<{className}>().Add( 25 ); if ( GetComponent<{className}>().TrySpend( 10 ) ) {{ }} -- Balance is the in-session truth; every change pushes Stats.SetValue.",
					$"React: {className}.OnBalanceLoaded += bal => {{ }}; and instance OnBalanceChanged for HUD labels. Wait for IsLoaded before showing the balance -- the read-back is async.",
					"CLOUD SEMANTICS: stats writes are buffered by the backend (Flush() pushes; the component flushes on destroy) and only apply to the LOCAL Steam user -- calling it for another player silently does nothing. Read-back is eventually consistent and can lag minutes; the in-session Balance property is authoritative while playing.",
					"Stats persist per Steam account per package ident -- dev sessions without a real published ident may read back nothing (you'll get balance 0 + a log line). This is Steam-cloud persistence, not a local save file; pair with create_signed_save if you need offline saves."
				}
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"add_steam_stat_currency failed: {ex.Message}" } );
		}
	}

	static string BuildCode( string className, string statName, string packageIdent, bool flushEveryChange )
	{
		string flushLit = flushEveryChange ? "true" : "false";

		return $@"using Sandbox;
using Sandbox.Services;
using System;

/// <summary>
/// {className} -- a currency persisted over Sandbox.Services.Stats (Steam cloud).
///
/// The stat named StatName stores the ABSOLUTE balance (Stats.SetValue on every change);
/// on start the component reads it back asynchronously via
/// Stats.GetLocalPlayerStats(ident).Refresh() -> Get(StatName).Value and fires
/// OnBalanceLoaded. While playing, the in-session Balance property is the authoritative
/// value -- the cloud read-back is eventually consistent and can lag behind writes.
///
/// SCOPE: stats writes apply only to the LOCAL Steam user (writes for other players
/// silently no-op) and persist per package ident. Attach this to the local player's
/// GameObject; IsProxy guards keep remote copies inert. Dev sessions without a real
/// published ident may read back nothing (balance starts at 0).
///
/// Usage:
///   GetComponent&lt;{className}&gt;().Add( 25 );
///   if ( GetComponent&lt;{className}&gt;().TrySpend( 10 ) ) {{ /* grant the thing */ }}
///   {className}.OnBalanceLoaded += bal => {{ /* show the wallet */ }};
/// </summary>
public sealed class {className} : Component
{{
	/// The Sandbox.Services stat that stores the balance.
	[Property] public string StatName {{ get; set; }} = ""{statName}"";

	/// Package ident to read stats from. Empty = the running package (Game.Ident).
	[Property] public string PackageIdent {{ get; set; }} = ""{packageIdent}"";

	/// Push Stats.Flush() after every change (rate-limited by the backend) instead of
	/// relying on the buffered flush + the OnDestroy flush.
	[Property] public bool FlushEveryChange {{ get; set; }} = {flushLit};

	/// In-session balance -- authoritative while playing. Cloud value catches up on flush.
	public double Balance {{ get; private set; }}

	/// True once the async cloud read-back has completed (successfully or not).
	public bool IsLoaded {{ get; private set; }}

	/// Fires once after the cloud read-back completes, with the loaded balance.
	public static Action<double> OnBalanceLoaded {{ get; set; }}

	/// Fires on every balance change (including the initial load) -- bind a HUD here.
	public Action<double> OnBalanceChanged {{ get; set; }}

	protected override void OnStart()
	{{
		if ( IsProxy ) return;   // only the local player's machine touches their stats
		_ = LoadAsync();
	}}

	protected override void OnDestroy()
	{{
		if ( !IsProxy && IsLoaded ) Stats.Flush();
	}}

	/// <summary>Re-read the balance from the stats backend (async; also runs on start).</summary>
	public async System.Threading.Tasks.Task LoadAsync()
	{{
		double loaded = 0.0;
		try
		{{
			string ident = string.IsNullOrWhiteSpace( PackageIdent ) ? Game.Ident : PackageIdent;
			var stats = Stats.GetLocalPlayerStats( ident );
			await stats.Refresh();
			loaded = stats.Get( StatName ).Value;
		}}
		catch ( Exception ex )
		{{
			Log.Warning( $""[{className}] Stat read-back failed ({{ex.Message}}) -- starting at 0. Stats need a valid package ident + Steam session."" );
		}}
		Balance = loaded;
		IsLoaded = true;
		OnBalanceLoaded?.Invoke( Balance );
		OnBalanceChanged?.Invoke( Balance );
	}}

	public bool CanAfford( double amount ) => Balance >= amount;

	/// <summary>Add currency and push the new balance to the stats backend. Non-positive ignored.</summary>
	public void Add( double amount )
	{{
		if ( IsProxy || amount <= 0.0 ) return;
		Balance += amount;
		Push();
	}}

	/// <summary>Spend if affordable; returns false and changes nothing if not.</summary>
	public bool TrySpend( double amount )
	{{
		if ( IsProxy || amount <= 0.0 ) return false;
		if ( Balance < amount ) return false;
		Balance -= amount;
		Push();
		return true;
	}}

	/// <summary>Force-push buffered stat writes to the backend now (rate-limited upstream).</summary>
	public void Flush() => Stats.Flush();

	// Write the absolute balance to the stat and notify listeners.
	private void Push()
	{{
		Stats.SetValue( StatName, Balance, null, null );
		if ( FlushEveryChange ) Stats.Flush();
		OnBalanceChanged?.Invoke( Balance );
	}}
}}
";
	}
}

// -----------------------------------------------------------------------------
// create_loot_table_resource -- the data-asset sibling of create_weighted_loot_table.
// Generates ONE .cs containing: an entry POCO (name, weight, optional nested table
// reference), a GameResource loot-table asset type ([AssetType] -- the modern
// attribute; GameResourceAttribute is [Obsolete] on this SDK), and a resolver
// Component that rolls a table by cumulative weight with a resolve depth cap.
// Designers author .loot files in the asset browser; code rolls them.
// -----------------------------------------------------------------------------
public class CreateLootTableResourceHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !ScaffoldHelpers.PrepareCodeFile( p, "LootTableResource", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			string extension = p.TryGetProperty( "extension", out var ev ) && !string.IsNullOrWhiteSpace( ev.GetString() ) ? ev.GetString() : "loot";
			string title = p.TryGetProperty( "title", out var tv ) && !string.IsNullOrWhiteSpace( tv.GetString() ) ? tv.GetString() : "Loot Table";
			int maxDepth = p.TryGetProperty( "maxDepth", out var mv ) && mv.TryGetInt32( out var mi ) ? mi : 4;
			if ( maxDepth < 0 ) maxDepth = 0;
			if ( maxDepth > 16 ) maxDepth = 16;

			// Extension: lowercase alphanumerics only.
			var extChars = new StringBuilder();
			foreach ( var c in extension.ToLowerInvariant() )
				if ( ( c >= 'a' && c <= 'z' ) || ( c >= '0' && c <= '9' ) ) extChars.Append( c );
			extension = extChars.Length > 0 ? extChars.ToString() : "loot";

			// Title is baked into an attribute string literal.
			title = title.Replace( "\\", "" ).Replace( "\"", "" );

			var resolverClass = className + "Resolver";
			var code = BuildCode( className, resolverClass, extension, title, maxDepth );
			ScaffoldHelpers.WriteCode( fullPath, code );

			// Placement attaches the RESOLVER component (the resource itself is an asset type, not a component).
			object placedOn = null; string note = null;
			if ( p.TryGetProperty( "targetId", out var tid ) && tid.ValueKind == JsonValueKind.String )
				placedOn = EconomySaveHelpers.PlaceOnTarget( tid.GetString(), resolverClass, out note );

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				resolverClass,
				extension,
				maxDepth,
				placedOn,
				note,
				nextSteps = new[]
				{
					$"trigger_hotload to compile {className} + {resolverClass} into the game assembly -- the '.{extension}' asset type registers on compile.",
					$"Author tables as ASSETS: in the editor asset browser, New > {title} creates a .{extension} file; fill Entries (Name, Weight, optional NestedTable reference to another .{extension}) in the inspector.",
					placedOn != null
						? $"{resolverClass} was attached to the target GameObject -- assign its Table property to a .{extension} asset (set_property with the asset path)."
						: $"Attach the resolver: add_component_to_new_object (component=\"{resolverClass}\") after the hotload, then set its Table property to a .{extension} asset path.",
					$"Roll from game code (host-side): string drop = GetComponent<{resolverClass}>().Roll(); {resolverClass}.OnLoot += ( go, item ) => {{ }};",
					$"Nested tables: an entry with a NestedTable rolls INTO that table instead of dropping its Name -- capped at MaxDepth ({maxDepth}) with a self-reference guard, so cycles terminate.",
					"Use create_weighted_loot_table instead when you want a single inline component with no asset files; use create_gacha_drop_table for pity + duplicate mechanics. Pick an extension that is NOT a suffix of a built-in one (e.g. avoid 'cfg') or ResourceLibrary.GetAll will pick up engine files as phantom instances."
				}
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_loot_table_resource failed: {ex.Message}" } );
		}
	}

	static string BuildCode( string className, string resolverClass, string extension, string title, int maxDepth )
	{
		var ci = System.Globalization.CultureInfo.InvariantCulture;
		string md = maxDepth.ToString( ci );

		return $@"using Sandbox;
using System;
using System.Collections.Generic;

/// <summary>
/// One row of a {className} asset. Amount is picked by cumulative weight; when
/// NestedTable is set the roll continues INTO that table instead of dropping Name.
/// </summary>
public sealed class {className}Entry
{{
	/// What drops when this entry wins (ignored when NestedTable is set).
	[Property] public string Name {{ get; set; }} = """";

	/// Relative chance. Bigger = more likely. Entries with weight &lt;= 0 never win.
	[Property] public float Weight {{ get; set; }} = 1f;

	/// Optional: roll this table instead of dropping Name (depth-capped on resolve).
	[Property] public {className} NestedTable {{ get; set; }}
}}

/// <summary>
/// {className} -- a designer-authored loot table ASSET (.{extension} files).
///
/// Each .{extension} file holds weighted entries; entries may reference other
/// .{extension} assets as nested tables (rarity tiers, per-biome sub-tables).
/// Resolve() rolls by cumulative weight and follows nested references up to a
/// depth cap, so cyclic references terminate. Author the files in the editor
/// asset browser; roll them with {resolverClass} or call Resolve() directly.
/// </summary>
[AssetType( Name = ""{title}"", Extension = ""{extension}"", Category = ""Game"" )]
public sealed class {className} : GameResource
{{
	/// The weighted rows of this table.
	[Property] public List<{className}Entry> Entries {{ get; set; }} = new List<{className}Entry>();

	/// <summary>
	/// Roll once: pick an entry by cumulative weight; if it references a nested table,
	/// keep rolling into it until a plain entry wins or maxDepth is exhausted (then the
	/// deepest entry's Name is returned). Null when the table is empty. HOST-authoritative:
	/// roll on the host and replicate the result -- clients rolling their own loot is the
	/// classic economy exploit.
	/// </summary>
	public string Resolve( int maxDepth = {md} )
	{{
		var entry = RollEntry();
		if ( entry == null ) return null;
		if ( entry.NestedTable != null && entry.NestedTable != this && maxDepth > 0 )
			return entry.NestedTable.Resolve( maxDepth - 1 );
		return entry.Name;
	}}

	// Cumulative-weight pick over Entries. Null when empty; first entry when all weights are zero.
	private {className}Entry RollEntry()
	{{
		if ( Entries == null || Entries.Count == 0 ) return null;

		float total = 0f;
		foreach ( var e in Entries )
			if ( e != null && e.Weight > 0f ) total += e.Weight;
		if ( total <= 0f ) return Entries[0];

		float roll = Game.Random.Float( 0f, total );
		float cumulative = 0f;
		{className}Entry winner = null;
		foreach ( var e in Entries )
		{{
			if ( e == null || e.Weight <= 0f ) continue;
			winner = e;
			cumulative += e.Weight;
			if ( roll < cumulative ) break;
		}}
		return winner;
	}}
}}

/// <summary>
/// {resolverClass} -- rolls a {className} asset from the scene.
///
/// Assign Table to a .{extension} asset in the inspector (or via set_property with the
/// asset path). Roll() resolves through nested tables up to MaxDepth and fires the
/// static OnLoot event with the winning item name. Call it host-side and replicate
/// the result yourself ([Sync] or an [Rpc.Broadcast]).
///
/// Usage:
///   string drop = GetComponent&lt;{resolverClass}&gt;().Roll();
///   {resolverClass}.OnLoot += ( go, item ) => Log.Info( $""{{go.Name}} got {{item}}"" );
/// </summary>
public sealed class {resolverClass} : Component
{{
	/// The loot table asset this resolver rolls.
	[Property] public {className} Table {{ get; set; }}

	/// How deep nested-table references may chain before the roll settles.
	[Property] public int MaxDepth {{ get; set; }} = {md};

	/// Fires (on the rolling machine) when Roll() picks a winner: (roller, itemName).
	public static Action<GameObject, string> OnLoot {{ get; set; }}

	/// <summary>
	/// Roll the assigned table once. Null (with a warning) when no Table is assigned or
	/// the table is empty. HOST-authoritative by convention -- see the class summary.
	/// </summary>
	public string Roll()
	{{
		if ( Table == null )
		{{
			Log.Warning( $""[{resolverClass}] No Table assigned on {{GameObject.Name}} -- assign a .{extension} asset."" );
			return null;
		}}

		var drop = Table.Resolve( MaxDepth );
		if ( drop != null ) OnLoot?.Invoke( GameObject, drop );
		return drop;
	}}
}}
";
	}
}

/// <summary>
/// Shared placement helper for the economy/save handlers -- mirrors the standard scaffold
/// placement (create_economy_wallet / create_weighted_loot_table / LootEconomyHelpers).
/// </summary>
internal static class EconomySaveHelpers
{
	public static object PlaceOnTarget( string targetId, string className, out string note )
	{
		note = null;
		var scene = SceneEditorSession.Active?.Scene;
		if ( scene == null ) { note = "No active scene to place into."; return null; }
		if ( !Guid.TryParse( targetId, out var guid ) ) { note = "Invalid targetId GUID."; return null; }
		var go = scene.Directory.FindByGuid( guid );
		if ( go == null ) { note = $"Target GameObject not found: {targetId}"; return null; }
		var typeDesc = Game.TypeLibrary.GetType( className );
		if ( typeDesc == null )
		{
			note = $"Generated {className}.cs but it is not in the TypeLibrary yet -- trigger_hotload, then add it with add_component_with_properties.";
			return null;
		}
		try { go.Components.Create( typeDesc ); return ClaudeBridge.SerializeGo( go ); }
		catch ( Exception ex ) { note = $"Placement failed ({ex.Message})."; return null; }
	}
}
// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs — DO NOT EDIT.
// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs
// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) → scripts/tools-manifest.json

using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Editor.Mcp;

/// <summary>
/// Project info and config (.sbproj), file read/write, C# script create/edit/delete, hotload, input
/// actions, and publishing metadata.
/// </summary>
[McpToolset( "bridge_project", "Project info and config (.sbproj), file read/write, C# script create/edit/delete, hotload, input actions, and publishing metadata." )]
public static class BridgeProjectTools
{
	/// <summary>
	/// Create a new C# component script in the project — a minimal s&amp;box Component class (name is
	/// sanitized to a valid identifier), or your exact code when content is provided. Errors if the
	/// file already exists. Returns { path, created, className } — the new type is NOT live until a
	/// recompile, so call trigger_hotload, then attach it with add_component_with_properties
	/// (component=className).
	/// </summary>
	/// <param name="name">Class name for the component (e.g. 'PlayerController'). Will also be the filename.</param>
	/// <param name="directory">Subdirectory under code/ to place the script (e.g. 'Components'). Defaults to 'code/'.</param>
	/// <param name="description">Description of what this component does — used to generate appropriate code.</param>
	/// <param name="properties">List of [Property] fields to include in the component. JSON array.</param>
	/// <param name="content">Full C# file content. If provided, ignores name/properties and writes this directly.</param>
	[McpTool( "create_script" )]
	public static Task<object> CreateScript( string name, string directory = null, string description = null, JsonNode properties = null, string content = null )
		=> McpGate.Run( "create_script", McpGate.Args( ( "name", name ), ( "directory", directory ), ( "description", description ), ( "properties", properties ), ( "content", content ) ) );

	/// <summary>
	/// Permanently delete a file from the project by its project-relative path (built for C# scripts,
	/// but removes any file; no recycle bin, and editor undo cannot restore it). Errors if the file
	/// doesn't exist. Returns a confirmation with the path — follow with trigger_hotload so the removed
	/// class actually leaves the compiled assembly.
	/// </summary>
	/// <param name="path">Relative path to the script file to delete.</param>
	[McpTool( "delete_script" )]
	public static Task<object> DeleteScript( string path )
		=> McpGate.Run( "delete_script", McpGate.Args( ( "path", path ) ) );

	/// <summary>
	/// One-call project orientation: identity (name/ident/org/type), the open scene with object count,
	/// scene and prefab file lists (capped at 50 each, Libraries/.sbox excluded), code footprint
	/// (.cs/.razor counts), custom Component types (up to 100, engine types excluded), and installed
	/// libraries. Returns a structured summary — orient here first, then get_scene_hierarchy for the
	/// scene, describe_type for components, find_broken_references for project health. Read-only.
	/// </summary>
	[McpTool.ReadOnly( "describe_project" )]
	public static Task<object> DescribeProject()
		=> McpGate.Run( "describe_project", McpGate.Args() );

	/// <summary>
	/// Edit an existing C# script in place via exact-text find/replace or a full-content overwrite.
	/// Errors if the file or the find text isn't found (find/replace replaces ALL occurrences). Returns
	/// { path, edited, operation } where operation is 'find_replace' or 'overwrite' — follow with
	/// trigger_hotload so the change compiles, then get_compile_errors if in doubt.
	/// </summary>
	/// <param name="path">Relative path to the script file (e.g. 'code/PlayerController.cs').</param>
	/// <param name="operations">List of edit operations to apply in order. JSON array.</param>
	[McpTool( "edit_script" )]
	public static Task<object> EditScript( string path, JsonNode operations )
		=> McpGate.Run( "edit_script", McpGate.Args( ( "path", path ), ( "operations", operations ) ) );

	/// <summary>
	/// Register a custom named INPUT ACTION in the project so a generated game's custom verbs work in
	/// play mode. Writes to &lt;project&gt;.sbproj → Metadata.InputSettings.Actions[]. Idempotent: if
	/// the action already exists it is left alone (pass update=true to rebind its key). If the project
	/// has no InputSettings yet, the full DEFAULT action set
	/// (Forward/Back/Left/Right/Jump/Use/attack1/...) is seeded first so player movement/use are
	/// preserved — the engine only auto-injects defaults when a game defines NONE. After adding, call
	/// it from game code with Input.Pressed("name") / Input.Down("name") / Input.Released("name").
	/// Note: input config is read at project load, so restart_editor (or reload the project) for a new
	/// action to take effect in play mode.
	/// </summary>
	/// <param name="name">The action verb game code will call, e.g. "interact", "sprint", "drop". Matches Input.Pressed("interact").</param>
	/// <param name="keyboardKey">Default keyboard binding, e.g. "e", "f", "space", "mouse1", "shift". Omit to add the action with no default key (player can bind it).</param>
	/// <param name="group">UI group the action is listed under in the bindings menu (e.g. "Actions", "Movement", "Other"). Defaults to "Actions".</param>
	/// <param name="update">If the action already exists, rebind its keyboardKey to the provided value instead of leaving it untouched. Default false (idempotent no-op when present).</param>
	[McpTool( "ensure_input_action" )]
	public static Task<object> EnsureInputAction( string name, string keyboardKey = null, string group = null, bool? update = null )
		=> McpGate.Run( "ensure_input_action", McpGate.Args( ( "name", name ), ( "keyboardKey", keyboardKey ), ( "group", group ), ( "update", update ) ) );

	/// <summary>
	/// Fetch package information from the s&amp;box package backend (Package.FetchAsync) by ident.
	/// Returns { fullIdent, title, summary, description, org } — no download/rating/dependency data is
	/// included. Use it to confirm a package exists and what it is before install_asset.
	/// </summary>
	/// <param name="ident">Package identifier (e.g. 'facepunch.flatgrass', 'myorg.mygame').</param>
	[McpTool.ReadOnly( "get_package_details" )]
	public static Task<object> GetPackageDetails( string ident )
		=> McpGate.Run( "get_package_details", McpGate.Args( ( "ident", ident ) ) );

	/// <summary>
	/// Read the full project configuration from the .sbproj file including title, description, version,
	/// type, package references, metadata, and raw JSON.
	/// </summary>
	[McpTool.ReadOnly( "get_project_config" )]
	public static Task<object> GetProjectConfig()
		=> McpGate.Run( "get_project_config", McpGate.Args() );

	/// <summary>
	/// Get information about the current s&amp;box project — path, name, game type, dependencies, and
	/// configuration.
	/// </summary>
	[McpTool.ReadOnly( "get_project_info" )]
	public static Task<object> GetProjectInfo()
		=> McpGate.Run( "get_project_info", McpGate.Args() );

	/// <summary>
	/// Browse the project file tree. Optionally filter by directory path and/or file extension (e.g.
	/// '.cs', '.scene'). Returns { path, count, files } as project-root-relative paths — CAPPED AT 500
	/// files (count reflects the truncated list, with no marker that more exist), so on large projects
	/// narrow with path/extension or use find_in_project. Recursive by default.
	/// </summary>
	/// <param name="path">Relative directory path to list (e.g. 'code/Components'). Defaults to project root.</param>
	/// <param name="extension">Filter by file extension, including the dot (e.g. '.cs', '.scene').</param>
	/// <param name="recursive">Whether to list files recursively. Defaults to true.</param>
	[McpTool.ReadOnly( "list_project_files" )]
	public static Task<object> ListProjectFiles( string path = null, string extension = null, bool? recursive = null )
		=> McpGate.Run( "list_project_files", McpGate.Args( ( "path", path ), ( "extension", extension ), ( "recursive", recursive ) ) );

	/// <summary>
	/// Read the contents of a file in the s&amp;box project (scripts, scenes, configs, etc.).
	/// </summary>
	/// <param name="path">Relative path to the file within the project (e.g. 'code/PlayerController.cs').</param>
	[McpTool.ReadOnly( "read_file" )]
	public static Task<object> ReadFile( string path )
		=> McpGate.Run( "read_file", McpGate.Args( ( "path", path ) ) );

	/// <summary>
	/// Update project configuration fields for publishing: title, description, version, type, package
	/// ident, summary, visibility. Only provided fields are changed — edits string values in the
	/// .sbproj file in place. Returns { updated, path } (the .sbproj path); read the result back with
	/// get_project_config to confirm what actually changed.
	/// </summary>
	/// <param name="title">Project display title.</param>
	/// <param name="description">Project description for publishing.</param>
	/// <param name="version">Version string (e.g. '1.0.0', '2.1.3').</param>
	/// <param name="type">Project type: 'game', 'addon', 'library', or 'template'.</param>
	/// <param name="packageIdent">Package identifier (e.g. 'myorg.mygame').</param>
	/// <param name="summary">Short summary for asset.party listing.</param>
	/// <param name="isPublic">Whether the project is publicly visible.</param>
	[McpTool( "set_project_config" )]
	public static Task<object> SetProjectConfig( string title = null, string description = null, string version = null, string type = null, string packageIdent = null, string summary = null, bool? isPublic = null )
		=> McpGate.Run( "set_project_config", McpGate.Args( ( "title", title ), ( "description", description ), ( "version", version ), ( "type", type ), ( "packageIdent", packageIdent ), ( "summary", summary ), ( "isPublic", isPublic ) ) );

	/// <summary>
	/// Set or update the project thumbnail image (thumb.png) used for publishing. Provide either a
	/// source path or base64 image data.
	/// </summary>
	/// <param name="sourcePath">Relative path to an image file within the project to use as thumbnail.</param>
	/// <param name="base64">Base64-encoded image data to write as thumbnail.</param>
	/// <param name="format">Image format when using base64 mode. Defaults to 'png'. One of: png | jpg.</param>
	[McpTool( "set_project_thumbnail" )]
	public static Task<object> SetProjectThumbnail( string sourcePath = null, string base64 = null, string format = null )
		=> McpGate.Run( "set_project_thumbnail", McpGate.Args( ( "sourcePath", sourcePath ), ( "base64", base64 ), ( "format", format ) ) );

	/// <summary>
	/// Force s&amp;box to recompile and hotload all C# scripts immediately. Use after creating or
	/// editing scripts to see changes in real-time.
	/// </summary>
	[McpTool( "trigger_hotload" )]
	public static Task<object> TriggerHotload()
		=> McpGate.Run( "trigger_hotload", McpGate.Args() );

	/// <summary>
	/// Write or overwrite a file in the s&amp;box project (SILENTLY replaces existing content —
	/// read_file first if you need to preserve it). Creates parent directories as needed; paths are
	/// confined to the project root (traversal outside it is denied). Returns a confirmation with the
	/// path — for C# follow with trigger_hotload so it compiles; for assets (.vmat etc.) follow with
	/// recompile_asset.
	/// </summary>
	/// <param name="path">Relative path for the file (e.g. 'code/Components/Health.cs').</param>
	/// <param name="content">The full file content to write.</param>
	[McpTool( "write_file" )]
	public static Task<object> WriteFile( string path, string content )
		=> McpGate.Run( "write_file", McpGate.Args( ( "path", path ), ( "content", content ) ) );
}
// AUTO-GENERATED by scripts/emit-mcp-wrappers.mjs — DO NOT EDIT.
// Regenerate: node scripts/extract-manifest.mjs && node scripts/emit-mcp-wrappers.mjs
// Source of truth: sbox-mcp-server/src/tools/ (zod schemas) → scripts/tools-manifest.json

using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Editor.Mcp;

/// <summary>
/// Lint and validate the project: networking footguns, sandbox whitelist violations, Razor
/// transpiler footguns, scene setup issues, publishing readiness, save-file inspection, and
/// networked-object state dumps.
/// </summary>
[McpToolset( "bridge_validation", "Lint and validate the project: networking footguns, sandbox whitelist violations, Razor transpiler footguns, scene setup issues, publishing readiness, save-file inspection, and networked-object state dumps." )]
public static class BridgeValidationTools
{
	/// <summary>
	/// Scan the project for broken references, two layers in one call: (1) every GameObject in the open
	/// scene — renderers with no Model (missing_model), component properties pointing at DESTROYED
	/// GameObjects/Components (dead_gameobject_ref / dead_component_ref), null component entries whose
	/// type no longer exists (missing_component); (2) every .scene/.prefab FILE — prefab references to
	/// deleted/renamed files (missing_prefab_file). Returns { total, showing, truncated,
	/// objectsScanned, filesScanned, issues } — each issue has { id, name, component, kind, detail }
	/// (file-level issues carry the file path in name). Fix missing models with assign_model, dead refs
	/// with set_property/set_component_reference, missing prefab files by fixing the path or recreating
	/// via create_prefab. Read-only; safe any time. Results cap at `limit` (default 100, max 500).
	/// </summary>
	/// <param name="limit">Max issues to return (default 100, max 500). total still counts everything.</param>
	/// <param name="scanFiles">Include the .scene/.prefab file scan for missing prefab references. Default true.</param>
	[McpTool.ReadOnly( "find_broken_references" )]
	public static Task<object> FindBrokenReferences( int? limit = null, bool? scanFiles = null )
		=> McpGate.Run( "find_broken_references", McpGate.Args( ( "limit", limit ), ( "scanFiles", scanFiles ) ) );

	/// <summary>
	/// Inspect the live networking contract of a GameObject. Returns {id, name, network: {active,
	/// isProxy, isOwner, isCreator, ownerId, ownerSteamId, ownerTransfer, orphaned, flags}, components:
	/// [{component, fields: [{name, type, isSync, syncFlags, value}]}]} — by default only [Sync]-marked
	/// fields are listed (components with none are omitted). Unlike get_network_status (session-only),
	/// this is per-object — the way to verify a host-authoritative or ownership change actually
	/// replicated; works in edit or play mode. Follow up with set_ownership to change the owner, or
	/// networking_lint to find the code-level cause of a bad [Sync] value.
	/// </summary>
	/// <param name="id">GUID of the GameObject to inspect.</param>
	/// <param name="allProps">Include all component properties, not just [Sync]-marked ones.</param>
	[McpTool.ReadOnly( "inspect_networked_object" )]
	public static Task<object> InspectNetworkedObject( string id, bool allProps = false )
		=> McpGate.Run( "inspect_networked_object", McpGate.Args( ( "id", id ), ( "allProps", allProps ) ) );

	/// <summary>
	/// Static-scan the project's C# for the highest-frequency networking/authority bugs: a mutator that
	/// writes a [Sync] field with no IsProxy/Networking.IsHost guard; money/health/score-shaped fields
	/// marked plain [Sync] (should be SyncFlags.FromHost); List&lt;&gt;/Dictionary&lt;&gt; marked
	/// [Sync] (should be NetList/NetDictionary); [Sync] fields typed Connection/GameObject (sync a Guid
	/// instead); [Rpc.Host] methods that mutate without re-checking Rpc.Caller; and component swaps /
	/// reflection writes missing Network.Refresh(). Returns findings with file:line + the suggested
	/// fix.
	/// </summary>
	/// <param name="path">Optional sub-path under the project (e.g. 'Code/Player') to scope the scan; omit for the whole project.</param>
	[McpTool.ReadOnly( "networking_lint" )]
	public static Task<object> NetworkingLint( string path = null )
		=> McpGate.Run( "networking_lint", McpGate.Args( ( "path", path ) ) );

	/// <summary>
	/// Static-scan .razor and .razor.scss files for the silent footguns that crash the Razor transpiler
	/// or stylesheet engine with no useful error message: switch expressions inside @code blocks (use
	/// if/else instead), non-ASCII/emoji inside @code (move to markup or a string constant),
	/// PanelComponent subclasses missing a BuildHash override (panel never re-renders), and root
	/// uppercase type-selector rules in .razor.scss (silently skipped -- use a class selector like
	/// .my-panel). Returns { scanned, findings: [{file, line, match, advice}], clean } matching the
	/// sandbox_lint shape.
	/// </summary>
	/// <param name="directory">Subdirectory under the project root to scan (e.g. 'UI', 'Code'). Defaults to 'Code'.</param>
	[McpTool.ReadOnly( "razor_lint" )]
	public static Task<object> RazorLint( string directory = null )
		=> McpGate.Run( "razor_lint", McpGate.Args( ( "directory", directory ) ) );

	/// <summary>
	/// Static-scan the project's C# for s&amp;box sandbox whitelist violations BEFORE they cause
	/// compile errors: System.MathF (use MathX), System.Math (use MathX), Array.Clone() (use
	/// .ToArray()), System.Net / raw sockets (use Sandbox.Http), System.IO.File (use FileSystem.Data),
	/// and raw System.Threading.Thread (use async/Task or GameTask). Returns { scanned, findings:
	/// [{file, line, match, advice}], clean }. Scope to a subdirectory with the directory param.
	/// </summary>
	/// <param name="directory">Subdirectory under the project root to scan (e.g. 'Code', 'Code/Player'). Defaults to 'Code'.</param>
	[McpTool.ReadOnly( "sandbox_lint" )]
	public static Task<object> SandboxLint( string directory = null )
		=> McpGate.Run( "sandbox_lint", McpGate.Args( ( "directory", directory ) ) );

	/// <summary>
	/// Inspect the game's FileSystem.Data save files — the assistant is otherwise blind to persisted
	/// state. action='list' (default) returns `directories` and `files` [{name, path, size}] under
	/// `path` (omit path for the Data root); action='read' returns {path, length, content}, truncating
	/// content at 60,000 chars; action='diff' compares two save files key-by-key, returning `diffCount`
	/// and up to 200 `diffs` [{key, change: added|removed|changed}]. Use to verify a save actually
	/// wrote, debug a load/migration, or confirm a sanitize/clamp ran.
	/// </summary>
	/// <param name="action">'list' (default) enumerates a folder; 'read' dumps one file's JSON; 'diff' compares `path` vs `pathB`. One of: list | read | diff. Default: "list".</param>
	/// <param name="path">File or folder path under FileSystem.Data (e.g. 'lumber_corp2_progress' or '&lt;folder&gt;/steam_123.json').</param>
	/// <param name="pathB">Second file path for action='diff'.</param>
	[McpTool.ReadOnly( "save_inspect" )]
	public static Task<object> SaveInspect( string action = "list", string path = null, string pathB = null )
		=> McpGate.Run( "save_inspect", McpGate.Args( ( "action", action ), ( "path", path ), ( "pathB", pathB ) ) );

	/// <summary>
	/// Validate the active scene for the silent setup footguns that break controllers/physics/cameras:
	/// no CameraComponent, no player controller, multiple root Rigidbodies, a Rigidbody with
	/// MotionEnabled=false fighting a kinematic root, IsTrigger colliders that Scene.Trace will ignore,
	/// child Rigidbodies breaking collider binding, and missing required child anchors. Returns each
	/// issue with the GameObject and the exact fix.
	/// </summary>
	[McpTool.ReadOnly( "scene_validate" )]
	public static Task<object> SceneValidate()
		=> McpGate.Run( "scene_validate", McpGate.Args() );

	/// <summary>
	/// Read from Sandbox.Services — the cloud stats/leaderboard layer many games use as their real DB.
	/// action='stats' with `name` returns the local player's stat {ident, value, sum, min, max,
	/// lastValue, valueString}; without `name` it returns only the package ident plus a usage note (it
	/// does NOT list stat definitions). action='leaderboard' (name required) returns {board,
	/// displayName, totalEntries, count, entries} with at most `limit` entries (default 10). Read-only;
	/// use to verify a Stats.Increment/SetValue path or a leaderboard wired correctly.
	/// </summary>
	/// <param name="action">'stats' (default) reads a local-player stat by `name`; 'leaderboard' fetches a board's top entries. One of: stats | leaderboard. Default: "stats".</param>
	/// <param name="name">Stat name (action='stats') or leaderboard/board name (action='leaderboard').</param>
	/// <param name="limit">Max leaderboard entries to return.</param>
	[McpTool.ReadOnly( "services_query" )]
	public static Task<object> ServicesQuery( string action = "stats", string name = null, int limit = 10 )
		=> McpGate.Run( "services_query", McpGate.Args( ( "action", action ), ( "name", name ), ( "limit", limit ) ) );

	/// <summary>
	/// Validate that the project is ready for publishing. Runs four checks: .sbproj exists, at least
	/// one scene, project Ident set, project Title set. Returns { valid, issueCount, issues, checks } —
	/// issues are human-readable problems and each checks entry has { check, pass, detail }; fix
	/// metadata gaps with set_project_config (it does NOT check compile errors — use get_compile_errors
	/// for that).
	/// </summary>
	[McpTool.ReadOnly( "validate_project" )]
	public static Task<object> ValidateProject()
		=> McpGate.Run( "validate_project", McpGate.Args() );
}
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

// ═══════════════════════════════════════════════════════════════════════════
//  NPC Brains — Feature Wave #3 (Phase 1 + simulate_npc_perception)
//
//  Compiles into the SAME editor assembly as MyEditorMenu.cs, so it can use the
//  shared helpers there directly: ClaudeBridge.TryResolveProjectPath /
//  SanitizeIdentifier / ParseVector3, SceneToolHelpers.*, and the IBridgeHandler
//  interface. These handlers run in the UNSANDBOXED editor (System.Math/MathF/IO
//  are all fine here).
//
//  The C# *strings these handlers generate* run in the SANDBOX (the game). That
//  generated code is deliberately restricted to APIs already proven to compile in
//  the sandbox by the existing create_npc_controller / create_networked_player
//  generators: Component, [Property], [Sync], GetOrAddComponent<NavMeshAgent>(),
//  NavMeshAgent.MoveTo(Vector3), IsProxy, TimeSince, Vector3.Dot/.Normal/
//  .DistanceBetween, Scene.GetAllComponents<T>(), scene.Trace.Ray(a,b).Run(),
//  MathX.Clamp. MathX preferred in generated code; System.Math/MathF also compile on the current SDK (verified 2026-06-09). Array.Clone() still blocked.
//
//  Tools in this file:
//    create_npc_brain        (code-gen; scene-mutating)
//    place_patrol_route      (scene-mutating)
//    assign_patrol_route     (scene-mutating)
//    create_npc_spawner      (code-gen; scene-mutating)
//    simulate_npc_perception (READ-ONLY; not scene-mutating)
//
//  Register(...) lines + _sceneMutatingCommands additions are wired by the main
//  agent in MyEditorMenu.cs (see this wave's summary) to avoid a merge conflict.
// ═══════════════════════════════════════════════════════════════════════════

/// <summary>
/// Shared helpers for the NPC-brain generators. Kept internal to this file so it
/// does not collide with anything in MyEditorMenu.cs.
/// </summary>
internal static class NpcBrainHelpers
{
	/// <summary>
	/// Read an optional float param, falling back to <paramref name="fallback"/>.
	/// Tolerates the value arriving as a JSON number OR a numeric string.
	/// </summary>
	public static float Float( JsonElement p, string key, float fallback )
	{
		if ( !p.TryGetProperty( key, out var e ) ) return fallback;
		if ( e.ValueKind == JsonValueKind.Number && e.TryGetSingle( out var f ) ) return f;
		if ( e.ValueKind == JsonValueKind.String && float.TryParse( e.GetString(), out var fs ) ) return fs;
		return fallback;
	}

	public static int Int( JsonElement p, string key, int fallback )
	{
		if ( !p.TryGetProperty( key, out var e ) ) return fallback;
		if ( e.ValueKind == JsonValueKind.Number && e.TryGetInt32( out var i ) ) return i;
		if ( e.ValueKind == JsonValueKind.String && int.TryParse( e.GetString(), out var iss ) ) return iss;
		return fallback;
	}

	public static bool Bool( JsonElement p, string key, bool fallback )
	{
		if ( !p.TryGetProperty( key, out var e ) ) return fallback;
		if ( e.ValueKind == JsonValueKind.True ) return true;
		if ( e.ValueKind == JsonValueKind.False ) return false;
		if ( e.ValueKind == JsonValueKind.String && bool.TryParse( e.GetString(), out var b ) ) return b;
		return fallback;
	}

	public static string Str( JsonElement p, string key, string fallback )
	{
		if ( p.TryGetProperty( key, out var e ) && e.ValueKind == JsonValueKind.String )
		{
			var s = e.GetString();
			if ( !string.IsNullOrWhiteSpace( s ) ) return s;
		}
		return fallback;
	}

	/// <summary>
	/// Format a float as an invariant-culture C# literal with an 'f' suffix, e.g.
	/// 130 -> "130f", 0.25 -> "0.25f". Invariant culture matters so a comma-decimal
	/// locale on the editor machine cannot emit "0,25f" and break compilation.
	/// </summary>
	public static string F( float v )
	{
		var s = v.ToString( "0.0###", System.Globalization.CultureInfo.InvariantCulture );
		return s + "f";
	}

	/// <summary>
	/// Escape a user string for safe embedding inside a C# double-quoted verbatim
	/// string ( @"" ), where the only escape needed is doubling the quote char.
	/// TargetTag is also identifier-ish but tags can legitimately contain symbols,
	/// so we keep it a string literal rather than sanitizing it to an identifier.
	/// </summary>
	public static string EscVerbatim( string raw ) => ( raw ?? "" ).Replace( "\"", "\"\"" );

	/// <summary>
	/// cos( fovDegrees / 2 ) computed in the EDITOR (MathF is legal here). Baked as
	/// the default of the generated CosFovThreshold property so the sandbox brain
	/// never needs trig. Clamped to a sane FOV range first.
	/// </summary>
	public static float CosHalfFov( float fovDegrees )
	{
		var fov = Math.Clamp( fovDegrees, 1f, 360f );
		var halfRad = ( fov * 0.5f ) * ( MathF.PI / 180f );
		return MathF.Cos( halfRad );
	}

	/// <summary>
	/// Resolve the component on <paramref name="go"/> that exposes a property named
	/// <paramref name="property"/>, and SET that property to <paramref name="value"/>.
	/// Preferred match is a component literally named "NpcBrain"; otherwise the first
	/// component whose TypeLibrary description has that property. Returns the matched
	/// component (so the caller can report its name), or null if none matched.
	///
	/// We deliberately do the find+set inside one method so this file never has to
	/// name the reflection types (TypeDescription / PropertyDescription) — the rest
	/// of the addon always uses `var` for them, which means their namespace is not
	/// guaranteed to be importable here. Keeping it all behind `var` mirrors the
	/// proven SetPrefabRefHandler pattern exactly.
	/// </summary>
	public static Component SetComponentProperty( GameObject go, string property, object value )
	{
		Component fallbackComp = null;

		// Pass 1: prefer an NpcBrain. Pass 2: any component exposing the property.
		foreach ( var c in go.Components.GetAll() )
		{
			var td = Game.TypeLibrary.GetType( c.GetType().Name );
			var pd = td?.Properties.FirstOrDefault( pp => pp.Name == property );
			if ( pd == null ) continue;

			if ( c.GetType().Name.Equals( "NpcBrain", StringComparison.OrdinalIgnoreCase ) )
			{
				pd.SetValue( c, value );
				return c;
			}

			fallbackComp = fallbackComp ?? c;
		}

		if ( fallbackComp != null )
		{
			var td = Game.TypeLibrary.GetType( fallbackComp.GetType().Name );
			var pd = td?.Properties.FirstOrDefault( pp => pp.Name == property );
			pd?.SetValue( fallbackComp, value );
		}

		return fallbackComp;
	}

	/// <summary>
	/// Find the "perception brain" component on <paramref name="go"/> — the component
	/// simulate_npc_perception should read SightRange/FovDegrees/EyeHeight/TargetTag from.
	///
	/// Why not just match the type name "NpcBrain": a custom-named brain (e.g. BigfootBrain,
	/// generated via create_npc_brain with name="BigfootBrain") exposes the same perception
	/// [Property] surface but a different type name, so a literal name match silently falls
	/// back to spec defaults. We match by CAPABILITY instead:
	///   1. a component literally named "NpcBrain" (the default), else
	///   2. a component whose TypeLibrary description exposes BOTH SightRange and FovDegrees
	///      (the perception contract), else
	///   3. a component whose type name ends with "Brain".
	/// Returns null if none match (caller then uses defaults / explicit overrides).
	/// </summary>
	public static Component FindPerceptionBrain( GameObject go )
	{
		if ( go == null ) return null;

		Component byProps = null;
		Component byName  = null;

		foreach ( var c in go.Components.GetAll() )
		{
			var typeName = c.GetType().Name;

			// 1. Exact "NpcBrain" wins immediately (the generated default).
			if ( typeName.Equals( "NpcBrain", StringComparison.OrdinalIgnoreCase ) )
				return c;

			// 2. Capability match: exposes the perception property contract.
			if ( byProps == null )
			{
				var td = Game.TypeLibrary.GetType( typeName );
				if ( td != null
					&& td.Properties.Any( pp => pp.Name == "SightRange" )
					&& td.Properties.Any( pp => pp.Name == "FovDegrees" ) )
				{
					byProps = c;
				}
			}

			// 3. Name heuristic: "...Brain".
			if ( byName == null && typeName.EndsWith( "Brain", StringComparison.OrdinalIgnoreCase ) )
				byName = c;
		}

		return byProps ?? byName;
	}
}

// ═══════════════════════════════════════════════════════════════════════════
//  1. create_npc_brain  (code-gen; scene-mutating)
//     Generates an NpcBrain Component: a finite-state machine (Idle/Patrol/
//     Wander/Chase/Search/Flee/Ambush) driven by occlusion-aware perception
//     (FOV cone + range + LOS trace + hearing) with last-known-position memory.
// ═══════════════════════════════════════════════════════════════════════════
public class CreateNpcBrainHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			var name      = NpcBrainHelpers.Str( p, "name", "NpcBrain" );
			var directory = NpcBrainHelpers.Str( p, "directory", "Code" );

			var fileName = name.EndsWith( ".cs" ) ? name : $"{name}.cs";
			if ( !ClaudeBridge.TryResolveProjectPath( Path.Combine( directory, fileName ), out var fullPath, out var pathErr ) )
				return Task.FromResult<object>( new { error = pathErr } );

			if ( File.Exists( fullPath ) )
				return Task.FromResult<object>( new { error = $"File already exists: {directory}/{fileName}" } );

			var className = ClaudeBridge.SanitizeIdentifier( Path.GetFileNameWithoutExtension( fileName ) );

			// ── Preset → defaults. The generated file is identical shape; the preset
			//    only changes [Property] defaults (StartState, CanFlee).
			var behavior = NpcBrainHelpers.Str( p, "behavior", "hunter" ).ToLowerInvariant();
			string startState;
			bool presetCanFlee;
			switch ( behavior )
			{
				case "patrol":   startState = "Patrol"; presetCanFlee = false; break;
				case "guard":    startState = "Ambush"; presetCanFlee = false; break;
				case "swarm":    startState = "Wander"; presetCanFlee = false; break;
				case "skittish": startState = "Patrol"; presetCanFlee = true;  break;
				case "hunter":
				default:         behavior = "hunter"; startState = "Patrol"; presetCanFlee = false; break;
			}

			// ── Tunables (params override preset/spec defaults). ──
			var moveSpeed     = NpcBrainHelpers.Float( p, "moveSpeed",     130f );
			var chaseSpeed    = NpcBrainHelpers.Float( p, "chaseSpeed",    200f );
			var sightRange    = NpcBrainHelpers.Float( p, "sightRange",    1500f );
			var fovDegrees    = NpcBrainHelpers.Float( p, "fovDegrees",    110f );
			var eyeHeight     = NpcBrainHelpers.Float( p, "eyeHeight",     64f );
			var hearingRadius = NpcBrainHelpers.Float( p, "hearingRadius", 600f );
			var giveUpTime    = NpcBrainHelpers.Float( p, "giveUpTime",    6f );
			var searchRadius  = NpcBrainHelpers.Float( p, "searchRadius",  400f );
			var waypointStop  = NpcBrainHelpers.Float( p, "waypointStopDistance", 80f );
			var canFlee       = NpcBrainHelpers.Bool(  p, "canFlee",       presetCanFlee );
			var fleeHealth    = NpcBrainHelpers.Float( p, "fleeHealthFrac", 0.25f );
			var networked     = NpcBrainHelpers.Bool(  p, "networked",     true );
			var targetTag     = NpcBrainHelpers.Str(   p, "targetTag",     "player" );
			// Citizen locomotion animation: when on (default), the generated brain caches a
			// SkinnedModelRenderer + CitizenAnimationHelper in OnStart and drives walk/run/idle
			// from the NavMeshAgent each frame (so the NPC and every spawner clone animate
			// instead of sliding in bind pose). Proven approach ported from BigfootBrain.cs.
			var animate       = NpcBrainHelpers.Bool(  p, "animate",       true );

			var cosFov = NpcBrainHelpers.CosHalfFov( fovDegrees );

			var code = BuildSource(
				className, startState, networked, animate,
				NpcBrainHelpers.EscVerbatim( targetTag ),
				moveSpeed, chaseSpeed, sightRange, fovDegrees, cosFov, eyeHeight,
				hearingRadius, giveUpTime, searchRadius, waypointStop, canFlee, fleeHealth );

			Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
			File.WriteAllText( fullPath, code );

			var states = new[] { "Idle", "Patrol", "Wander", "Chase", "Search", "Flee", "Ambush" };
			var props = new[]
			{
				"StartState","MoveSpeed","ChaseSpeed","SightRange","FovDegrees","CosFovThreshold",
				"EyeHeight","HearingRadius","TargetTag","GiveUpTime","SearchRadius","WaypointStopDistance",
				"PingPong","CanFlee","FleeHealthFrac","CurrentHealthFrac","Waypoints","CurrentState"
			};

			return Task.FromResult<object>( new
			{
				created    = true,
				path       = $"{directory}/{fileName}",
				className,
				behavior,
				networked,
				animate,
				statesIncluded = states,
				propertyNames  = props,
				note = "NavMeshAgent is added automatically via GetOrAddComponent in OnStart. " +
				       "Requires bake_navmesh + a navmesh-walkable scene for movement. " +
				       "Assign a patrol route with place_patrol_route + assign_patrol_route. " +
				       "Verify perception in EDIT mode with simulate_npc_perception; verify chase/search by entering play mode " +
				       "(get_runtime_property CurrentState + timed screenshot_from). " +
				       ( animate
				         ? "Locomotion animation ON: caches a SkinnedModelRenderer + CitizenAnimationHelper in OnStart and drives walk/run/idle from the NavMeshAgent each frame — attach this brain to a GameObject with a Citizen (or any SkinnedModel) renderer (on it or a child) and it animates while moving instead of sliding. Spawner clones inherit it (each runs its own OnStart). Pass animate:false to disable. "
				         : "Locomotion animation OFF (animate:false): the NPC slides in bind pose; drive a CitizenAnimationHelper yourself if you want walk/run anims. " ) +
				       ( networked
				         ? "Networked: host-authoritative (if(IsProxy)return) + [Sync] CurrentState — needs a host session; a no-session solo playtest makes everything a proxy so the brain won't think (use networked:false to iterate solo)."
				         : "Solo/edit build: no IsProxy guard, so it ticks in a single-machine playtest." )
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_npc_brain failed: {ex.Message}" } );
		}
	}

	/// <summary>
	/// Build the NpcBrain component source. Everything here must be SANDBOX-LEGAL.
	/// Movement uses only the confirmed NavMeshAgent.MoveTo(Vector3); perception
	/// uses only Vector3.Dot/.Normal + scene.Trace.Ray(a,b).Run() + Scene.GetAllComponents.
	/// FOV uses a baked cosine threshold (no trig in the sandbox).
	/// When <paramref name="animate"/> is true the generated brain also caches a
	/// CitizenAnimationHelper (off a SkinnedModelRenderer) and feeds it the NavMeshAgent
	/// velocity each frame — sandbox-legal locomotion ported from BigfootBrain.cs (uses
	/// Sandbox.Citizen + MathX, never System.Math).
	/// </summary>
	private static string BuildSource(
		string className, string startState, bool networked, bool animate, string targetTagLiteral,
		float moveSpeed, float chaseSpeed, float sightRange, float fovDegrees, float cosFov,
		float eyeHeight, float hearingRadius, float giveUpTime, float searchRadius,
		float waypointStop, bool canFlee, float fleeHealth )
	{
		string F( float v ) => NpcBrainHelpers.F( v );

		// Host-authority guard line (networked) vs none (solo). The [Sync] on
		// CurrentState lets proxies read the host's state for client-side animation.
		var proxyGuard   = networked ? "\t\tif ( IsProxy ) return;   // host-authoritative — only the host thinks\n" : "";
		var stateAttr    = networked ? "[Sync] " : "";
		var headerNote   = networked
			? "// Host-authoritative AI brain. Only the host runs the FSM; CurrentState is [Sync]'d\n// so proxy clients can animate the NPC. Needs an active network session (a no-session\n// solo playtest makes everything a proxy — generate with networked:false to iterate solo).\n"
			: "// Solo / edit-scene AI brain (no networking guard). Ticks in a single-machine playtest.\n";

		// ── Citizen locomotion animation (ported verbatim from the proven BigfootBrain.cs).
		// Everything here is sandbox-legal: Sandbox.Citizen + GetOrAddComponent + the
		// NavMeshAgent's own Velocity/WishVelocity, no System.Math. When animate:false these
		// fragments are empty strings, so the generated brain is byte-for-byte the old one.
		var animUsing  = animate ? "using Sandbox.Citizen;\n" : "";
		var animFields = animate
			? "\n\t// Citizen locomotion. Drives the anim helper from the agent's velocity each frame so the\n" +
			  "\t// NPC walks/runs/idles instead of sliding in bind pose. Cached off the SkinnedModelRenderer\n" +
			  "\t// in OnStart (works for the source NPC AND its spawner clones — they each run OnStart).\n" +
			  "\tprivate CitizenAnimationHelper _anim;\n" +
			  "\tprivate SkinnedModelRenderer _renderer;\n"
			: "";
		// OnStart wiring. Wiring _anim.Target avoids a WithWishVelocity NRE (see SBOX_KNOWLEDGE.md).
		var animOnStart = animate
			? "\n\t\t// Locomotion animation. Find the SkinnedModelRenderer (this GO or a child), then\n" +
			  "\t\t// get-or-add a CitizenAnimationHelper and wire its Target — the helper NREs in\n" +
			  "\t\t// WithWishVelocity if Target is null. A Citizen .vmdl already has the locomotion\n" +
			  "\t\t// anim-graph, so once fed velocity it walks/runs/idles on its own.\n" +
			  "\t\t_renderer = GetComponent<SkinnedModelRenderer>() ?? GetComponentInChildren<SkinnedModelRenderer>();\n" +
			  "\t\tif ( _renderer.IsValid() )\n" +
			  "\t\t{\n" +
			  "\t\t\t_anim = GetOrAddComponent<CitizenAnimationHelper>();\n" +
			  "\t\t\t_anim.Target = _renderer;\n" +
			  "\t\t}\n"
			: "";
		// Per-frame drive call (placed at the end of OnUpdate) + the method body.
		var animUpdateCall = animate ? "\t\tDriveAnimation();\n" : "";
		var animMethod = animate
			? "\n\t// ── Locomotion animation ────────────────────────────────────────────────────\n" +
			  "\t/// <summary>Feed the Citizen anim helper from the NavMeshAgent each frame so the NPC\n" +
			  "\t/// plays walk/run/idle instead of sliding in bind pose. WithVelocity drives the\n" +
			  "\t/// locomotion blend; WithWishVelocity drives lean/start-stop; IsGrounded keeps it out\n" +
			  "\t/// of the fall pose. Glance toward the chased target, else toward travel direction.</summary>\n" +
			  "\tprivate void DriveAnimation()\n" +
			  "\t{\n" +
			  "\t\tif ( _anim == null || !_anim.IsValid() ) return;\n" +
			  "\n" +
			  "\t\tvar velocity = _agent.Velocity;\n" +
			  "\t\t_anim.WithVelocity( velocity );\n" +
			  "\t\t_anim.WithWishVelocity( _agent.WishVelocity );\n" +
			  "\t\t_anim.IsGrounded = true;\n" +
			  "\n" +
			  "\t\tVector3 lookDir;\n" +
			  "\t\tif ( CurrentState == BrainState.Chase && _target.IsValid() )\n" +
			  "\t\t\tlookDir = ( _target.WorldPosition - WorldPosition ).WithZ( 0f );\n" +
			  "\t\telse\n" +
			  "\t\t\tlookDir = velocity.WithZ( 0f );\n" +
			  "\n" +
			  "\t\tif ( lookDir.Length > 1f )\n" +
			  "\t\t\t_anim.WithLook( lookDir.Normal, 1f, 0.6f, 0.2f );\n" +
			  "\t}\n"
			: "";

		return
$@"using Sandbox;
{animUsing}using System;
using System.Collections.Generic;
using System.Linq;

{headerNote}public sealed class {className} : Component
{{
	public enum BrainState {{ Idle, Patrol, Wander, Chase, Search, Flee, Ambush }}

	// ── Tunables (all [Property] so the bridge can set_property / tune later) ──
	[Property] public BrainState StartState {{ get; set; }} = BrainState.{startState};
	[Property] public float MoveSpeed  {{ get; set; }} = {F( moveSpeed )};
	[Property] public float ChaseSpeed {{ get; set; }} = {F( chaseSpeed )};

	// Perception
	[Property] public float SightRange    {{ get; set; }} = {F( sightRange )};
	// FovDegrees is the human-readable full cone angle. The actual gate compares a
	// dot product against CosFovThreshold = cos(FovDegrees/2), which is baked here so
	// the sandbox needs no trig. If you change FovDegrees at runtime, also update
	// CosFovThreshold (tune_npc_perception / set_property), or call SetFov(...) below.
	[Property] public float FovDegrees      {{ get; set; }} = {F( fovDegrees )};
	[Property] public float CosFovThreshold {{ get; set; }} = {F( cosFov )};
	[Property] public float EyeHeight     {{ get; set; }} = {F( eyeHeight )};
	[Property] public float HearingRadius {{ get; set; }} = {F( hearingRadius )};
	[Property] public string TargetTag    {{ get; set; }} = @""{targetTagLiteral}"";

	// Memory / timing
	[Property] public float GiveUpTime   {{ get; set; }} = {F( giveUpTime )};
	[Property] public float SearchRadius {{ get; set; }} = {F( searchRadius )};
	[Property] public float WaypointStopDistance {{ get; set; }} = {F( waypointStop )};
	[Property] public bool  PingPong     {{ get; set; }} = false;

	// Flee (health source is generic: the game sets CurrentHealthFrac 0..1, or
	// override ShouldFlee() in a partial/subclass — no hard coupling to any HP comp).
	[Property] public bool  CanFlee           {{ get; set; }} = {( canFlee ? "true" : "false" )};
	[Property] public float FleeHealthFrac    {{ get; set; }} = {F( fleeHealth )};
	[Property] public float CurrentHealthFrac {{ get; set; }} = 1f;

	// Patrol route (placed + wired by assign_patrol_route, or hand-set in editor).
	[Property] public List<GameObject> Waypoints {{ get; set; }} = new();

	// ── Runtime state ──
	{stateAttr}public BrainState CurrentState {{ get; private set; }}
	private GameObject _target;
	private Vector3 _lastKnownPos;
	private TimeSince _timeSinceSeen;
	private Vector3 _wanderTarget;
	private TimeSince _timeSinceWanderPick;
	private int _waypointIndex;
	private int _waypointDir = 1;
	private NavMeshAgent _agent;
{animFields}
	protected override void OnStart()
	{{
		_agent = GetOrAddComponent<NavMeshAgent>();
{animOnStart}		CurrentState = StartState;
		_timeSinceSeen = 999f;
		_lastKnownPos = WorldPosition;
		_wanderTarget = WorldPosition;
	}}

	protected override void OnUpdate()
	{{
{proxyGuard}		if ( _agent == null ) return;

		Perceive();
		Think();
		Act();
{animUpdateCall}	}}
{animMethod}

	/// <summary>Recompute the FOV cosine from a degree value at runtime (no trig in
	/// the sandbox: cos(x) via the half-angle identity from a normalized sweep is
	/// overkill, so we keep it simple — set both together).</summary>
	public void SetFov( float degrees, float cosThreshold )
	{{
		FovDegrees = degrees;
		CosFovThreshold = cosThreshold;
	}}

	// ── Perception ────────────────────────────────────────────────────────────
	private void Perceive()
	{{
		var eye = WorldPosition + Vector3.Up * EyeHeight;
		var best = FindVisibleTarget( eye, out var sawSomething );

		if ( best.IsValid() )
		{{
			_target = best;
			_lastKnownPos = best.WorldPosition;
			_timeSinceSeen = 0f;
			return;
		}}

		// Passive hearing: a candidate within HearingRadius is ""heard"" (sets a
		// last-known position to investigate) but is NOT treated as seen — so the
		// NPC investigates rather than instantly aggroing.
		var heard = FindNearestCandidate( WorldPosition, HearingRadius );
		if ( heard.IsValid() )
			_lastKnownPos = heard.WorldPosition;

		// keep _target ref while it grows stale; _timeSinceSeen advances on its own.
	}}

	/// <summary>Pick the nearest candidate that passes range + FOV cone + LOS.</summary>
	private GameObject FindVisibleTarget( Vector3 eye, out bool any )
	{{
		any = false;
		GameObject bestGo = null;
		float bestDist = float.MaxValue;

		foreach ( var cand in Candidates() )
		{{
			var to = cand.WorldPosition - eye;
			float dist = to.Length;
			if ( dist > SightRange ) continue;
			if ( dist < 0.01f ) continue;

			var dir = to.Normal;
			// FOV cone gate (cheap): dot >= cos(half-fov). No trig needed.
			if ( Vector3.Dot( WorldRotation.Forward, dir ) < CosFovThreshold ) continue;

			// Occlusion trace from the eye to the candidate. IgnoreGameObjectHierarchy
			// excludes the NPC's own colliders so it can't ""see"" itself. Clear when the
			// ray hits the candidate directly, hits nothing, or the first hit is
			// essentially at the candidate (a child collider) — a distance test that
			// needs no extra API. Anything blocking earlier (a tree/wall) fails LOS.
			var tr = Scene.Trace.Ray( eye, cand.WorldPosition ).IgnoreGameObjectHierarchy( GameObject ).Run();
			bool clear = !tr.Hit || tr.GameObject == cand || tr.Distance >= dist - 8f;
			if ( !clear ) continue;

			any = true;
			if ( dist < bestDist ) {{ bestDist = dist; bestGo = cand; }}
		}}

		return bestGo;
	}}

	private GameObject FindNearestCandidate( Vector3 from, float maxDist )
	{{
		GameObject best = null;
		float bestDist = maxDist;
		foreach ( var cand in Candidates() )
		{{
			float d = Vector3.DistanceBetween( from, cand.WorldPosition );
			if ( d <= bestDist ) {{ bestDist = d; best = cand; }}
		}}
		return best;
	}}

	/// <summary>Candidate targets = GameObjects tagged TargetTag, excluding self.
	/// Uses Scene.GetAllComponents to enumerate, then filters by tag.</summary>
	private IEnumerable<GameObject> Candidates()
	{{
		foreach ( var c in Scene.GetAllComponents<Collider>() )
		{{
			var go = c.GameObject;
			if ( go == null || go == GameObject ) continue;
			if ( !go.Tags.Has( TargetTag ) ) continue;
			yield return go;
		}}
	}}

	// ── Transition table ────────────────────────────────────────────────────
	private void Think()
	{{
		bool canSee = _target.IsValid() && _timeSinceSeen < 0.1f;

		if ( CanFlee && ShouldFlee() ) {{ CurrentState = BrainState.Flee; return; }}

		switch ( CurrentState )
		{{
			case BrainState.Idle:
			case BrainState.Patrol:
			case BrainState.Wander:
			case BrainState.Ambush:
				if ( canSee ) CurrentState = BrainState.Chase;
				break;

			case BrainState.Chase:
				if ( !canSee && _timeSinceSeen > 0.25f ) CurrentState = BrainState.Search;
				break;

			case BrainState.Search:
				if ( canSee ) CurrentState = BrainState.Chase;
				else if ( _timeSinceSeen > GiveUpTime ) {{ _target = null; CurrentState = StartState; }}
				break;

			case BrainState.Flee:
				if ( !ShouldFlee() ) CurrentState = StartState;
				break;
		}}
	}}

	// ── Action per state ──────────────────────────────────────────────────────
	private void Act()
	{{
		// Apply the desired locomotion speed (chase is faster). NavMeshAgent.MaxSpeed
		// is the agent's speed cap (verified in the navmesh docs).
		_agent.MaxSpeed = ( CurrentState == BrainState.Chase || CurrentState == BrainState.Flee ) ? ChaseSpeed : MoveSpeed;

		switch ( CurrentState )
		{{
			case BrainState.Idle:
			case BrainState.Ambush:
				// Stand still and watch (perception still runs every tick).
				_agent.Stop();
				break;

			case BrainState.Patrol:
				PatrolStep();
				break;

			case BrainState.Wander:
				WanderStep( WorldPosition, SearchRadius );
				break;

			case BrainState.Chase:
				if ( _target.IsValid() )
					_agent.MoveTo( _target.WorldPosition );
				break;

			case BrainState.Search:
				if ( Vector3.DistanceBetween( WorldPosition, _lastKnownPos ) > WaypointStopDistance )
					_agent.MoveTo( _lastKnownPos );
				else
					WanderStep( _lastKnownPos, SearchRadius );
				break;

			case BrainState.Flee:
				FleeStep();
				break;
		}}
	}}

	private void PatrolStep()
	{{
		if ( Waypoints == null || Waypoints.Count == 0 ) return;
		_waypointIndex = (int)MathX.Clamp( _waypointIndex, 0, Waypoints.Count - 1 );

		var wp = Waypoints[_waypointIndex];
		if ( !wp.IsValid() ) {{ AdvanceWaypoint(); return; }}

		if ( Vector3.DistanceBetween( WorldPosition, wp.WorldPosition ) <= WaypointStopDistance )
			AdvanceWaypoint();
		else
			_agent.MoveTo( wp.WorldPosition );
	}}

	private void AdvanceWaypoint()
	{{
		if ( Waypoints == null || Waypoints.Count <= 1 ) return;

		if ( PingPong )
		{{
			if ( _waypointIndex + _waypointDir >= Waypoints.Count || _waypointIndex + _waypointDir < 0 )
				_waypointDir = -_waypointDir;
			_waypointIndex += _waypointDir;
		}}
		else
		{{
			_waypointIndex = ( _waypointIndex + 1 ) % Waypoints.Count;
		}}
	}}

	private void WanderStep( Vector3 home, float radius )
	{{
		bool reached = Vector3.DistanceBetween( WorldPosition, _wanderTarget ) <= WaypointStopDistance;
		if ( reached || _timeSinceWanderPick > 4f )
		{{
			// Pick a fresh point near home. Uses only confirmed APIs (Random.Shared
			// + Vector3). The agent paths toward the nearest reachable point, so an
			// occasional off-mesh pick is harmless. (For strictly-on-mesh wander,
			// swap to Scene.NavMesh.GetRandomPoint(home, radius) once its return type
			// is confirmed via describe_type.)
			var off = new Vector3(
				Random.Shared.Float( -radius, radius ),
				Random.Shared.Float( -radius, radius ),
				0f );
			_wanderTarget = home + off;
			_timeSinceWanderPick = 0f;
		}}
		_agent.MoveTo( _wanderTarget );
	}}

	private void FleeStep()
	{{
		// Move directly away from the last-known threat position.
		var away = ( WorldPosition - _lastKnownPos ).Normal;
		if ( away.Length < 0.01f ) away = WorldRotation.Forward;
		_agent.MoveTo( WorldPosition + away * MathX.Clamp( SearchRadius, 100f, 2000f ) );
	}}

	/// <summary>Generic flee predicate. Driven by CurrentHealthFrac (the game sets
	/// it 0..1). Override in a subclass/partial for game-specific logic (e.g. a
	/// bomb-timer panic in RUN, or a camper-HP check in Sasquatched).</summary>
	public bool ShouldFlee()
	{{
		return CanFlee && CurrentHealthFrac <= FleeHealthFrac;
	}}

	// ── Noise hook (pure C#; the game calls this where a noise happens) ─────────
	// Example: NpcBrain.ReportNoise(flashlightPos, 800f) when a camper clicks a
	// flashlight, or a gunshot in RUN. NPCs within radius investigate (Search).
	public static void ReportNoise( Scene scene, Vector3 pos, float radius )
	{{
		if ( scene == null ) return;
		foreach ( var brain in scene.GetAllComponents<{className}>() )
			brain.HearNoise( pos, radius );
	}}

	public void HearNoise( Vector3 pos, float radius )
	{{
		if ( Vector3.DistanceBetween( WorldPosition, pos ) > radius ) return;
		_lastKnownPos = pos;
		if ( CurrentState != BrainState.Chase )
			CurrentState = BrainState.Search;
	}}
}}
";
	}
}

// ═══════════════════════════════════════════════════════════════════════════
//  2. place_patrol_route  (scene-mutating)
//     Create N waypoint empties (tagged), grouped under a parent route object,
//     optionally snapped to the ground so they sit on the navmesh.
// ═══════════════════════════════════════════════════════════════════════════
public class PlacePatrolRouteHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		var scene = SceneEditorSession.Active?.Scene;
		if ( scene == null )
			return Task.FromResult<object>( new { error = "No active scene" } );

		if ( !p.TryGetProperty( "points", out var pts ) || pts.ValueKind != JsonValueKind.Array )
			return Task.FromResult<object>( new { error = "points (Vector3[]) is required" } );

		var rawPoints = new List<Vector3>();
		foreach ( var e in pts.EnumerateArray() )
			rawPoints.Add( ClaudeBridge.ParseVector3( e ) );

		if ( rawPoints.Count < 2 )
			return Task.FromResult<object>( new { error = "Provide at least 2 points for a patrol route" } );

		var routeName  = NpcBrainHelpers.Str( p, "name", "PatrolRoute" );
		var tag        = NpcBrainHelpers.Str( p, "tag", "waypoint" );
		var snap       = NpcBrainHelpers.Bool( p, "snapToGround", true );

		try
		{
			// Resolve or create the route parent.
			GameObject route = null;
			if ( p.TryGetProperty( "parentId", out var pid ) && Guid.TryParse( pid.GetString(), out var parentGuid ) )
				route = scene.Directory.FindByGuid( parentGuid );

			if ( route == null )
			{
				route = scene.CreateObject( true );
				route.Name = routeName;
				// Place the parent at the centroid for a tidy hierarchy + easy framing.
				var centroid = Vector3.Zero;
				foreach ( var pt in rawPoints ) centroid += pt;
				route.WorldPosition = centroid / rawPoints.Count;
			}

			var waypointIds = new List<string>( rawPoints.Count );
			int i = 0;
			foreach ( var pt in rawPoints )
			{
				var pos = pt;
				if ( snap )
				{
					try
					{
						var tr = scene.Trace.Ray( pos + Vector3.Up * 2000f, pos + Vector3.Down * 20000f ).Run();
						if ( tr.Hit ) pos = new Vector3( pos.x, pos.y, tr.HitPosition.z );
					}
					catch { /* keep the raw point on trace failure */ }
				}

				var wp = scene.CreateObject( true );
				wp.Name = $"{routeName}_WP{i}";
				wp.WorldPosition = pos;
				wp.Tags.Add( tag );
				wp.SetParent( route, keepWorldPosition: true );
				waypointIds.Add( wp.Id.ToString() );
				i++;
			}

			return Task.FromResult<object>( new
			{
				placed     = true,
				routeId    = route.Id.ToString(),
				routeName  = route.Name,
				waypointIds,
				count      = waypointIds.Count,
				snappedToGround = snap,
				note = "Wire these into an NpcBrain with assign_patrol_route (pass routeId or waypointIds). " +
				       "Validate connectivity with get_navmesh_path between consecutive waypoints (catches a point in a wall)."
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"place_patrol_route failed: {ex.Message}" } );
		}
	}
}

// ═══════════════════════════════════════════════════════════════════════════
//  3. assign_patrol_route  (scene-mutating)
//     Wire a placed route (or an arbitrary GUID list) into a List<GameObject>
//     property (default "Waypoints") on a target NPC's component. This is the
//     list-of-GameObject-refs case plain set_property can't express.
// ═══════════════════════════════════════════════════════════════════════════
public class AssignPatrolRouteHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		var scene = SceneEditorSession.Active?.Scene;
		if ( scene == null )
			return Task.FromResult<object>( new { error = "No active scene" } );

		if ( !p.TryGetProperty( "npcId", out var npcEl ) || !Guid.TryParse( npcEl.GetString(), out var npcGuid ) )
			return Task.FromResult<object>( new { error = "npcId (GameObject GUID holding the NpcBrain) is required" } );

		var npc = scene.Directory.FindByGuid( npcGuid );
		if ( npc == null )
			return Task.FromResult<object>( new { error = $"NPC GameObject not found: {npcEl.GetString()}" } );

		var property = NpcBrainHelpers.Str( p, "property", "Waypoints" );

		try
		{
			// ── Gather the ordered waypoint GameObjects: explicit waypointIds win,
			//    else the children (hierarchy order) of routeId.
			var waypoints = new List<GameObject>();

			if ( p.TryGetProperty( "waypointIds", out var wpArr ) && wpArr.ValueKind == JsonValueKind.Array )
			{
				foreach ( var e in wpArr.EnumerateArray() )
					if ( Guid.TryParse( e.GetString(), out var g ) )
					{
						var go = scene.Directory.FindByGuid( g );
						if ( go != null ) waypoints.Add( go );
					}
			}
			else if ( p.TryGetProperty( "routeId", out var routeEl ) && Guid.TryParse( routeEl.GetString(), out var routeGuid ) )
			{
				var route = scene.Directory.FindByGuid( routeGuid );
				if ( route == null )
					return Task.FromResult<object>( new { error = $"Route GameObject not found: {routeEl.GetString()}" } );
				foreach ( var child in route.Children )
					waypoints.Add( child );
			}
			else
			{
				return Task.FromResult<object>( new { error = "Provide waypointIds (GUID[]) or routeId (route parent GUID)" } );
			}

			if ( waypoints.Count == 0 )
				return Task.FromResult<object>( new { error = "No valid waypoints resolved from the given ids/route" } );

			// ── Resolve the component + property and set the List<GameObject>.
			//    SetValue accepts a List<GameObject>; we hand it the concrete list
			//    (matches how the editor serializes [Property] lists of refs).
			var comp = NpcBrainHelpers.SetComponentProperty( npc, property, waypoints );
			if ( comp == null )
				return Task.FromResult<object>( new { error = $"No component on the NPC exposes a '{property}' property (expected an NpcBrain with a List<GameObject> {property})" } );

			return Task.FromResult<object>( new
			{
				assigned  = true,
				npcId     = npcEl.GetString(),
				component = comp.GetType().Name,
				property,
				count     = waypoints.Count,
				note = "List<GameObject> refs may read back as handles/GUIDs via get_property — trust this count, or confirm patrol in play mode."
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"assign_patrol_route failed: {ex.Message}" } );
		}
	}
}

// ═══════════════════════════════════════════════════════════════════════════
//  4. create_npc_spawner  (code-gen; scene-mutating)
//     Generate a spawner Component that clones an NPC prefab over time / in
//     escalating waves at spawn points, capped by maxAlive. Host-authoritative
//     when networked (NetworkSpawn, guarded).
// ═══════════════════════════════════════════════════════════════════════════
public class CreateNpcSpawnerHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			var name      = NpcBrainHelpers.Str( p, "name", "NpcSpawner" );
			var directory = NpcBrainHelpers.Str( p, "directory", "Code" );

			var fileName = name.EndsWith( ".cs" ) ? name : $"{name}.cs";
			if ( !ClaudeBridge.TryResolveProjectPath( Path.Combine( directory, fileName ), out var fullPath, out var pathErr ) )
				return Task.FromResult<object>( new { error = pathErr } );

			if ( File.Exists( fullPath ) )
				return Task.FromResult<object>( new { error = $"File already exists: {directory}/{fileName}" } );

			var className = ClaudeBridge.SanitizeIdentifier( Path.GetFileNameWithoutExtension( fileName ) );

			var mode       = NpcBrainHelpers.Str( p, "mode", "waves" ).ToLowerInvariant();
			if ( mode != "continuous" && mode != "waves" && mode != "burst" ) mode = "waves";
			var modeEnum   = mode == "continuous" ? "Continuous" : ( mode == "burst" ? "Burst" : "Waves" );

			var count      = NpcBrainHelpers.Int(   p, "count", 5 );
			var interval   = NpcBrainHelpers.Float( p, "interval", 8f );
			var waveCount  = NpcBrainHelpers.Int(   p, "waveCount", 3 );
			var waveGrowth = NpcBrainHelpers.Float( p, "waveGrowth", 1f );
			var radius     = NpcBrainHelpers.Float( p, "radius", 200f );
			var maxAlive   = NpcBrainHelpers.Int(   p, "maxAlive", 12 );
			var networked  = NpcBrainHelpers.Bool(  p, "networked", true );

			var code = BuildSpawnerSource( className, modeEnum, networked,
				count, interval, waveCount, waveGrowth, radius, maxAlive );

			Directory.CreateDirectory( Path.GetDirectoryName( fullPath ) );
			File.WriteAllText( fullPath, code );

			var props = new[]
			{
				"NpcPrefab","SpawnPoints","Mode","Count","Interval","WaveCount",
				"WaveGrowth","Radius","MaxAlive","AutoStart"
			};

			return Task.FromResult<object>( new
			{
				created   = true,
				path      = $"{directory}/{fileName}",
				className,
				mode,
				networked,
				propertyNames = props,
				note = "Set NpcPrefab via set_prefab_ref. Add spawn points by reusing place_patrol_route (a route of empties) then " +
				       "assign_patrol_route with property=\"SpawnPoints\", or set SpawnPoints by hand. " +
				       ( networked
				         ? "Networked spawns use NetworkSpawn() and are host-only (guarded) — needs a host session."
				         : "Solo build: plain Clone() (no NetworkSpawn)." ) +
				       " Verify by watching GameObject count over time in play mode (get_scene_hierarchy deltas)."
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_npc_spawner failed: {ex.Message}" } );
		}
	}

	private static string BuildSpawnerSource(
		string className, string modeEnum, bool networked,
		int count, float interval, int waveCount, float waveGrowth, float radius, int maxAlive )
	{
		string F( float v ) => NpcBrainHelpers.F( v );

		var proxyGuard = networked ? "\t\tif ( IsProxy ) return;   // host spawns authoritatively\n" : "";
		var headerNote = networked
			? "// Host-authoritative spawner. Only the host spawns (NetworkSpawn so clients see the\n// NPCs). Needs an active network session.\n"
			: "// Solo / edit-scene spawner (plain Clone, no networking).\n";

		// Spawn idiom: clone the prefab, place it, and (networked) NetworkSpawn in a
		// try/catch — the verified solo-safe idiom (NetworkSpawn throws with no session).
		var spawnBody = networked
			?
@"		var go = NpcPrefab.Clone( pos );
		try { go.NetworkSpawn(); } catch { /* no session — fall back to a local object */ }
		_alive.Add( go );"
			:
@"		var go = NpcPrefab.Clone( pos );
		_alive.Add( go );";

		return
$@"using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

{headerNote}public sealed class {className} : Component
{{
	public enum SpawnMode {{ Continuous, Waves, Burst }}

	[Property] public GameObject NpcPrefab {{ get; set; }}
	[Property] public List<GameObject> SpawnPoints {{ get; set; }} = new();

	[Property] public SpawnMode Mode {{ get; set; }} = SpawnMode.{modeEnum};
	[Property] public int   Count      {{ get; set; }} = {count};   // per-wave (Waves) or total (Burst/Continuous batch)
	[Property] public float Interval   {{ get; set; }} = {F( interval )}; // seconds between spawns (Continuous) or waves (Waves)
	[Property] public int   WaveCount  {{ get; set; }} = {waveCount};
	[Property] public float WaveGrowth {{ get; set; }} = {F( waveGrowth )}; // multiply Count each wave (>1 = escalating)
	[Property] public float Radius     {{ get; set; }} = {F( radius )};  // random scatter around a spawn point
	[Property] public int   MaxAlive   {{ get; set; }} = {maxAlive};   // concurrency cap
	[Property] public bool  AutoStart  {{ get; set; }} = true;

	private readonly List<GameObject> _alive = new();
	private TimeSince _timeSinceSpawn;
	private int _wavesDone;
	private float _currentWaveCount;
	private bool _started;

	protected override void OnStart()
	{{
		_currentWaveCount = Count;
		_timeSinceSpawn = Interval; // fire promptly on the first eligible tick
		if ( AutoStart ) _started = true;
	}}

	protected override void OnUpdate()
	{{
{proxyGuard}		if ( !_started || NpcPrefab == null ) return;

		// Drop dead/destroyed NPCs from the live list so MaxAlive is accurate.
		_alive.RemoveAll( g => !g.IsValid() );

		switch ( Mode )
		{{
			case SpawnMode.Burst:
				SpawnBatch( (int)_currentWaveCount );
				_started = false; // one-shot
				break;

			case SpawnMode.Continuous:
				if ( _timeSinceSpawn >= Interval )
				{{
					_timeSinceSpawn = 0f;
					TrySpawnOne();
				}}
				break;

			case SpawnMode.Waves:
				if ( _wavesDone >= WaveCount ) {{ _started = false; break; }}
				if ( _timeSinceSpawn >= Interval )
				{{
					_timeSinceSpawn = 0f;
					SpawnBatch( (int)_currentWaveCount );
					_wavesDone++;
					_currentWaveCount = MathX.Clamp( _currentWaveCount * WaveGrowth, 1f, 9999f );
				}}
				break;
		}}
	}}

	private void SpawnBatch( int n )
	{{
		for ( int i = 0; i < n; i++ )
			if ( !TrySpawnOne() ) break;
	}}

	private bool TrySpawnOne()
	{{
		if ( _alive.Count >= MaxAlive ) return false;

		var pos = PickSpawnPos();
{spawnBody}
		return true;
	}}

	private Vector3 PickSpawnPos()
	{{
		var basePos = WorldPosition;
		if ( SpawnPoints != null && SpawnPoints.Count > 0 )
		{{
			var pick = SpawnPoints[Random.Shared.Next( 0, SpawnPoints.Count )];
			if ( pick.IsValid() ) basePos = pick.WorldPosition;
		}}

		var off = new Vector3(
			Random.Shared.Float( -Radius, Radius ),
			Random.Shared.Float( -Radius, Radius ),
			0f );
		return basePos + off;
	}}
}}
";
	}
}

// ═══════════════════════════════════════════════════════════════════════════
//  5. simulate_npc_perception  (READ-ONLY — NOT scene-mutating)
//     Run the EXACT LOS check an NpcBrain would, in edit mode, without play.
//     FOV cone (dot vs CosFovThreshold) + range + occlusion trace. Reports the
//     result AND why — the keystone edit-mode verifier for the perception layer.
// ═══════════════════════════════════════════════════════════════════════════
public class SimulateNpcPerceptionHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		var scene = SceneEditorSession.Active?.Scene;
		if ( scene == null )
			return Task.FromResult<object>( new { error = "No active scene" } );

		if ( !p.TryGetProperty( "npcId", out var npcEl ) || !Guid.TryParse( npcEl.GetString(), out var npcGuid ) )
			return Task.FromResult<object>( new { error = "npcId (GameObject GUID with an NpcBrain) is required" } );

		var npc = scene.Directory.FindByGuid( npcGuid );
		if ( npc == null )
			return Task.FromResult<object>( new { error = $"NPC GameObject not found: {npcEl.GetString()}" } );

		try
		{
			// ── Read perception params from the NPC's brain if present, else fall back
			//    to spec defaults / explicit overrides in the call. Matches the brain by
			//    CAPABILITY (exposes SightRange+FovDegrees) or a "...Brain" type name — NOT
			//    just the literal type name "NpcBrain" — so a custom-named brain
			//    (e.g. BigfootBrain) is read instead of silently using defaults.
			var brain = NpcBrainHelpers.FindPerceptionBrain( npc );
			// `var` (never name TypeDescription) — its namespace isn't guaranteed importable here.
			var brainTd = brain != null ? Game.TypeLibrary.GetType( brain.GetType().Name ) : null;

			float ReadBrainFloat( string name, float fallback )
			{
				if ( brain == null || brainTd == null ) return fallback;
				var pd = brainTd.Properties.FirstOrDefault( x => x.Name == name );
				if ( pd == null ) return fallback;
				try
				{
					var v = pd.GetValue( brain );
					if ( v is float f ) return f;
					if ( v != null && float.TryParse( v.ToString(), out var fp ) ) return fp;
				}
				catch { }
				return fallback;
			}
			string ReadBrainString( string name, string fallback )
			{
				if ( brain == null || brainTd == null ) return fallback;
				var pd = brainTd.Properties.FirstOrDefault( x => x.Name == name );
				try { return pd?.GetValue( brain )?.ToString() ?? fallback; } catch { return fallback; }
			}

			// Explicit overrides take precedence over brain-read values.
			float sightRange = NpcBrainHelpers.Float( p, "sightRange", ReadBrainFloat( "SightRange", 1500f ) );
			float fovDegrees = NpcBrainHelpers.Float( p, "fovDegrees", ReadBrainFloat( "FovDegrees", 110f ) );
			float eyeHeight  = NpcBrainHelpers.Float( p, "eyeHeight",  ReadBrainFloat( "EyeHeight", 64f ) );
			string targetTag = NpcBrainHelpers.Str(   p, "targetTag",  ReadBrainString( "TargetTag", "player" ) );

			// Use the brain's baked CosFovThreshold if available (keeps this query in
			// lockstep with the generated component); else compute it here.
			float cosFov = ReadBrainFloat( "CosFovThreshold", float.NaN );
			if ( float.IsNaN( cosFov ) ) cosFov = NpcBrainHelpers.CosHalfFov( fovDegrees );

			// ── Resolve the target point: explicit targetId or a raw point.
			GameObject targetGo = null;
			Vector3 targetPos;
			if ( p.TryGetProperty( "targetId", out var tEl ) && Guid.TryParse( tEl.GetString(), out var tGuid ) )
			{
				targetGo = scene.Directory.FindByGuid( tGuid );
				if ( targetGo == null )
					return Task.FromResult<object>( new { error = $"Target GameObject not found: {tEl.GetString()}" } );
				targetPos = targetGo.WorldPosition;
			}
			else if ( p.TryGetProperty( "point", out var ptEl ) )
			{
				targetPos = ClaudeBridge.ParseVector3( ptEl );
			}
			else
			{
				return Task.FromResult<object>( new { error = "Provide targetId (GameObject GUID) or point (Vector3)" } );
			}

			var eye = npc.WorldPosition + Vector3.Up * eyeHeight;
			var to  = targetPos - eye;
			float distance = to.Length;

			// Degenerate: target is essentially at the eye.
			if ( distance < 0.01f )
			{
				return Task.FromResult<object>( new
				{
					canSee = true, inRange = true, inFov = true, losBlocked = false,
					distance, angleDeg = 0.0,
					eye = new { eye.x, eye.y, eye.z },
					note = "Target coincides with the NPC eye position."
				} );
			}

			var dir = to.Normal;
			float dot = Vector3.Dot( npc.WorldRotation.Forward, dir );

			// angle (degrees) for human-readable output. MathF is fine here (editor).
			float angleDeg = MathF.Acos( Math.Clamp( dot, -1f, 1f ) ) * ( 180f / MathF.PI );

			bool inRange = distance <= sightRange;
			bool inFov   = dot >= cosFov;

			// Occlusion trace from the eye toward the target. IgnoreGameObjectHierarchy
			// drops the NPC's own colliders (confirmed builder), so any hit is an
			// external object. It blocks LOS only if it's clearly before the target
			// (hit on the target itself, or a hit at/after the target distance, is not
			// a blocker). Distance test only — no GameObject.Root needed.
			bool losBlocked = false;
			object blockedBy = null;
			var tr = scene.Trace.Ray( eye, targetPos ).IgnoreGameObjectHierarchy( npc ).Run();
			if ( tr.Hit )
			{
				bool hitIsTarget = ( targetGo != null && tr.GameObject == targetGo )
					|| tr.Distance >= distance - 8f; // a hit at/after the target point isn't a blocker
				if ( !hitIsTarget )
				{
					losBlocked = true;
					blockedBy = new { id = tr.GameObject?.Id.ToString(), name = tr.GameObject?.Name };
				}
			}

			bool tagMatch = targetGo == null || targetGo.Tags.Has( targetTag );
			bool canSee = inRange && inFov && !losBlocked && tagMatch;

			return Task.FromResult<object>( new
			{
				canSee,
				inRange,
				inFov,
				losBlocked,
				blockedBy,
				tagMatch,
				distance,
				angleDeg = (double)angleDeg,
				fovHalfAngleDeg = (double)( fovDegrees * 0.5f ),
				sightRange,
				targetTag,
				eye = new { eye.x, eye.y, eye.z },
				brainComponent = brain?.GetType().Name,
				note = brain == null
					? "No perception brain found on this GameObject — used spec defaults / call overrides for the perception params."
					: $"Read perception params from the '{brain.GetType().Name}' component's own SightRange/FovDegrees/EyeHeight/TargetTag (call params override). canSee mirrors what the generated brain computes."
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"simulate_npc_perception failed: {ex.Message}" } );
		}
	}
}
using Editor;
using Sandbox;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;

// ═══════════════════════════════════════════════════════════════════════════
// PLAYTEST HARNESS — playtest / playtest_status  (the gameplay-verification frontier)
//
// Same assembly as MyEditorMenu.cs (reuses IBridgeHandler + ClaudeBridge helpers).
// Unsandboxed editor code → System.Math / System.Reflection are fine here.
//
// WHY AN IN-ADDON RUNNER (not TS round-trips):
// Verifying a gameplay LOOP needs input + state-reads + assertions that time-align
// with the game's frames. Two facts (proven live on the Gravehold player) force this:
//   1. The facepunch PlayerController reads Input.AnalogMove each frame and OVERWRITES
//      a WishVelocity you set — UNLESS you set `UseInputControls=false` first. With it
//      off, setting WishVelocity moved the player 0→526u. So a move step must flip that
//      toggle, drive WishVelocity per frame, and ZERO it after (it persists otherwise).
//   2. Transient state (a jump's z-velocity) is gone by the time a SEPARATE bridge call
//      lands — so assertions must be evaluated IN-FRAME, inside the editor frame loop.
// => one async job, ticked by [EditorEvent.Frame], runs a step list and records a
//    pass/fail transcript. TS only starts it (playtest) and polls it (playtest_status).
//
// Step verbs: move · look · lookDelta · action · jump · set · wait · capture · assert
//   { "move": {"x":1}, "frames":60 }                 analog move (auto UseInputControls=false)
//   { "look": {"pitch":0,"yaw":90,"roll":0} }        set EyeAngles
//   { "lookDelta": {"yaw":2}, "frames":30 }          sweep EyeAngles
//   { "action": "use", "frames":20 }                 hold a named input action (rising-edge safe)
//   { "jump": "0,0,400" }                            invoke the controller's Jump(velocity)
//   { "set": {"component":"PlayerController","property":"UseInputControls","to":"false"} }
//   { "wait": 10 }                                   advance N frames
//   { "capture": "after-jump" }                      screenshot the live player POV → path in transcript
//   { "assert": {"read":"Displacement","op":">","value":50,"desc":"moved >50u from start"} }
//
// assert.read = "WorldPosition[.x|.y|.z]" (the controller's GameObject), "Displacement"
//               (scalar distance moved from job start — the facing-independent movement proof), OR
//               "<Component>.<Property>[.x|.y|.z|.Count]" (a component on the player).
// assert.op   = > < >= <= == != changed   (changed = differs from the value at job start)
// ═══════════════════════════════════════════════════════════════════════════

internal static class PlaytestRunner
{
	internal class StepSpec
	{
		public string Kind;
		public int Frames = 1;
		public Vector2 Move;
		public Angles Look; public bool HasLook;
		public Angles LookDelta;
		public string Action;
		public Vector3 JumpVel;
		public string SetComponent, SetProperty, SetValue;
		public string AssertRead, AssertOp, AssertValue, AssertDesc;
		public string CaptureLabel;
		public float MoveSpeed = 160f;
	}

	internal class Job
	{
		public Guid TargetId;
		public string ComponentType;
		public Component Controller;     // resolved once
		public GameObject Anchor;        // controller.GameObject — the player object
		public Vector3 StartPos;         // Anchor.WorldPosition at job start (for the "Displacement" read)
		public List<StepSpec> Steps;
		public int Index;
		public int FrameInStep;
		public List<object> Transcript = new();
		public int Passed, Failed;
		public bool DisabledInput;       // we flipped UseInputControls=false → restore at teardown
		public string HeldAction;        // currently-held action (release at step exit / teardown)
		public Dictionary<string, string> Baselines = new(); // read-key → value at job start (for "changed")
		public bool Done;
		public string EndReason;
		public bool Started;
	}

	private static Job _job;
	private static readonly object _lock = new();
	private static object _lastSummary;

	internal static void Start( Job job ) { lock ( _lock ) { _job = job; _lastSummary = null; } }
	internal static object ConsumeSummary() { lock ( _lock ) { return _lastSummary; } }
	internal static bool IsActive() { lock ( _lock ) { return _job != null; } }

	/// <summary>Stop the running job NOW: teardown (restore input state) + summarize as aborted.</summary>
	internal static object Abort()
	{
		lock ( _lock )
		{
			if ( _job == null )
				return new { aborted = false, note = "No playtest job is running. playtest_status shows the last summary." };
			var j = _job;
			Teardown( j );
			_lastSummary = Summarize( j, "aborted via playtest_abort" );
			_job = null;
			return new
			{
				aborted = true,
				stepsRun = j.Index,
				passed = j.Passed,
				failed = j.Failed,
				note = "Job stopped, input state restored. The partial transcript is available via playtest_status."
			};
		}
	}

	internal static object LiveSnapshot()
	{
		lock ( _lock )
		{
			if ( _job == null ) return null;
			return new { active = true, step = _job.Index, totalSteps = _job.Steps.Count, passed = _job.Passed, failed = _job.Failed };
		}
	}

	[EditorEvent.Frame]
	public static void OnFrame()
	{
		Job j;
		lock ( _lock ) { j = _job; }
		if ( j == null ) return;

		if ( !Game.IsPlaying )
		{
			Teardown( j );
			lock ( _lock ) { _lastSummary = Summarize( j, "play mode ended before completion" ); _job = null; }
			return;
		}

		try
		{
			// Resolve the controller + anchor once.
			if ( !j.Started )
			{
				ResolveAnchor( j );
				CaptureBaselines( j );
				j.Started = true;
			}

			if ( j.Index >= j.Steps.Count )
			{
				Teardown( j );
				lock ( _lock ) { _lastSummary = Summarize( j, "completed" ); _job = null; }
				return;
			}

			var step = j.Steps[j.Index];
			if ( j.FrameInStep == 0 ) StepEnter( j, step );
			StepTick( j, step );
			j.FrameInStep++;

			if ( j.FrameInStep >= System.Math.Max( 1, step.Frames ) )
			{
				StepExit( j, step );
				j.Index++;
				j.FrameInStep = 0;
			}
		}
		catch ( Exception ex )
		{
			// Never let the ticker throw (it'd spam every frame). Record + stop.
			j.Transcript.Add( new { step = j.Index, kind = j.Index < j.Steps.Count ? j.Steps[j.Index].Kind : "?", error = ex.Message } );
			Teardown( j );
			lock ( _lock ) { _lastSummary = Summarize( j, $"runner error: {ex.Message}" ); _job = null; }
		}
	}

	// ── Step lifecycle ─────────────────────────────────────────────────────────
	static void StepEnter( Job j, StepSpec s )
	{
		switch ( s.Kind )
		{
			case "move":
				EnsureInputDisabled( j );   // so WishVelocity isn't overwritten by the controller
				break;
			case "jump":
				DoJump( j, s );
				break;
			case "set":
				DoSet( j, s );
				break;
			case "assert":
				DoAssert( j, s );
				break;
			case "capture":
				DoCapture( j, s );
				break;
		}
	}

	static void StepTick( Job j, StepSpec s )
	{
		switch ( s.Kind )
		{
			case "move":
			{
				if ( j.Controller == null ) return;
				var td = Game.TypeLibrary.GetType( j.Controller.GetType() );
				var yaw = ( ReadAngles( j.Controller, td, "EyeAngles" ) ?? j.Controller.WorldRotation.Angles() ).yaw;
				var rot = Rotation.From( 0f, yaw, 0f );
				var wish = rot.Forward * s.Move.x + rot.Left * s.Move.y;
				if ( wish.Length > 1f ) wish = wish.Normal;
				wish *= s.MoveSpeed;
				TrySetVector3( j.Controller, td, "WishVelocity", wish );
				break;
			}
			case "look":
			{
				if ( j.Controller == null ) return;
				var td = Game.TypeLibrary.GetType( j.Controller.GetType() );
				var a = s.Look; a.pitch = System.Math.Clamp( a.pitch, -89f, 89f );
				TrySetAngles( j.Controller, td, "EyeAngles", a );
				break;
			}
			case "lookDelta":
			{
				if ( j.Controller == null ) return;
				var td = Game.TypeLibrary.GetType( j.Controller.GetType() );
				var cur = ReadAngles( j.Controller, td, "EyeAngles" ) ?? new Angles();
				cur.pitch = System.Math.Clamp( cur.pitch + s.LookDelta.pitch, -89f, 89f );
				cur.yaw += s.LookDelta.yaw;
				cur.roll += s.LookDelta.roll;
				TrySetAngles( j.Controller, td, "EyeAngles", cur );
				break;
			}
			case "action":
				try { Sandbox.Input.SetAction( s.Action, true ); } catch { }
				j.HeldAction = s.Action;
				break;
		}
	}

	static void StepExit( Job j, StepSpec s )
	{
		switch ( s.Kind )
		{
			case "move":
				if ( j.Controller != null )
				{
					var td = Game.TypeLibrary.GetType( j.Controller.GetType() );
					TrySetVector3( j.Controller, td, "WishVelocity", Vector3.Zero );  // stop — WishVelocity persists otherwise
				}
				j.Transcript.Add( new { step = j.Index, kind = "move", frames = s.Frames, move = $"{s.Move.x},{s.Move.y}" } );
				break;
			case "action":
				try { Sandbox.Input.SetAction( s.Action, false ); } catch { }
				j.HeldAction = null;
				j.Transcript.Add( new { step = j.Index, kind = "action", action = s.Action, frames = s.Frames } );
				break;
			case "look":
				j.Transcript.Add( new { step = j.Index, kind = "look", look = $"{s.Look.pitch},{s.Look.yaw},{s.Look.roll}" } );
				break;
			case "lookDelta":
				j.Transcript.Add( new { step = j.Index, kind = "lookDelta", frames = s.Frames } );
				break;
			case "wait":
				j.Transcript.Add( new { step = j.Index, kind = "wait", frames = s.Frames } );
				break;
			// jump/set/assert already recorded their result in StepEnter.
		}
	}

	// ── Actions ────────────────────────────────────────────────────────────────
	static void DoJump( Job j, StepSpec s )
	{
		if ( j.Controller == null )
		{
			j.Transcript.Add( new { step = j.Index, kind = "jump", ok = false, error = "no controller" } );
			j.Failed++;
			return;
		}
		try
		{
			var m = j.Controller.GetType().GetMethod( "Jump", new[] { typeof( Vector3 ) } );
			if ( m == null )
			{
				j.Transcript.Add( new { step = j.Index, kind = "jump", ok = false, error = "controller has no Jump(Vector3)" } );
				j.Failed++;
				return;
			}
			m.Invoke( j.Controller, new object[] { s.JumpVel } );
			j.Transcript.Add( new { step = j.Index, kind = "jump", ok = true, velocity = $"{s.JumpVel.x},{s.JumpVel.y},{s.JumpVel.z}" } );
		}
		catch ( Exception ex )
		{
			j.Transcript.Add( new { step = j.Index, kind = "jump", ok = false, error = ex.Message } );
			j.Failed++;
		}
	}

	static void DoSet( Job j, StepSpec s )
	{
		try
		{
			var comp = FindComponent( j, s.SetComponent );
			if ( comp == null )
			{
				j.Transcript.Add( new { step = j.Index, kind = "set", ok = false, error = $"component '{s.SetComponent}' not found" } );
				j.Failed++; return;
			}
			var td = Game.TypeLibrary.GetType( comp.GetType() );
			var pd = td?.Properties.FirstOrDefault( pp => pp.Name == s.SetProperty );
			if ( pd == null )
			{
				j.Transcript.Add( new { step = j.Index, kind = "set", ok = false, error = $"property '{s.SetProperty}' not found" } );
				j.Failed++; return;
			}
			object typed = CoerceTo( pd.PropertyType, s.SetValue );
			pd.SetValue( comp, typed );
			j.Transcript.Add( new { step = j.Index, kind = "set", ok = true, target = $"{s.SetComponent}.{s.SetProperty}", to = s.SetValue } );
		}
		catch ( Exception ex )
		{
			j.Transcript.Add( new { step = j.Index, kind = "set", ok = false, error = ex.Message } );
			j.Failed++;
		}
	}

	static void DoAssert( Job j, StepSpec s )
	{
		string actual = null;
		bool ok = false;
		string err = null;
		try
		{
			object val = ResolveRead( j, s.AssertRead, out err );
			if ( err == null )
			{
				actual = ValueToString( val );
				ok = Compare( j, s.AssertRead, val, s.AssertOp, s.AssertValue, out err );
			}
		}
		catch ( Exception ex ) { err = ex.Message; }

		if ( ok ) j.Passed++; else j.Failed++;
		j.Transcript.Add( new
		{
			step = j.Index,
			kind = "assert",
			ok,
			desc = s.AssertDesc,
			read = s.AssertRead,
			op = s.AssertOp,
			expected = s.AssertValue,
			actual,
			error = err,
		} );
	}

	// ── Capture: screenshot the live player-POV camera (diagnostic, never pass/fail) ──
	static void DoCapture( Job j, StepSpec s )
	{
		try
		{
			var scene = Game.ActiveScene;
			var cam = scene != null ? VisualHelpers.FindMainCamera( scene ) : null;
			if ( cam == null )
			{
				j.Transcript.Add( new { step = j.Index, kind = "capture", ok = false, label = s.CaptureLabel, error = "no main camera in the running scene" } );
				return;
			}
			var bmp = new Bitmap( 1280, 720 );
			cam.RenderToBitmap( bmp, true );   // renderUI=true → the running game incl. HUD
			string path = System.IO.Path.Combine( System.IO.Path.GetTempPath(), $"bridge_playtest_{System.Guid.NewGuid():N}.png" );
			System.IO.File.WriteAllBytes( path, bmp.ToPng() );
			j.Transcript.Add( new { step = j.Index, kind = "capture", ok = true, label = s.CaptureLabel, path } );
		}
		catch ( Exception ex )
		{
			j.Transcript.Add( new { step = j.Index, kind = "capture", ok = false, label = s.CaptureLabel, error = ex.Message } );
		}
	}

	// ── Read resolution: "WorldPosition.x" | "<Component>.<Prop>[.sub]" ──────────
	static object ResolveRead( Job j, string read, out string err )
	{
		err = null;
		if ( string.IsNullOrEmpty( read ) ) { err = "empty read"; return null; }
		var parts = read.Split( '.' );
		object cur;
		int sub;

		var head = parts[0];
		if ( head == "Displacement" )
		{
			if ( j.Anchor == null ) { err = "no player object resolved"; return null; }
			return (object) ( j.Anchor.WorldPosition - j.StartPos ).Length;   // scalar — facing-independent movement proof
		}
		if ( head == "WorldPosition" || head == "LocalPosition" || head == "WorldRotation" || head == "WorldScale" )
		{
			if ( j.Anchor == null ) { err = "no player object resolved"; return null; }
			cur = head switch
			{
				"WorldPosition" => (object) j.Anchor.WorldPosition,
				"LocalPosition" => j.Anchor.LocalPosition,
				"WorldRotation" => j.Anchor.WorldRotation.Angles(),
				"WorldScale"    => j.Anchor.WorldScale,
				_ => null,
			};
			sub = 1;
		}
		else
		{
			if ( parts.Length < 2 ) { err = $"read '{read}' needs <Component>.<Property>"; return null; }
			var comp = FindComponent( j, head );
			if ( comp == null ) { err = $"component '{head}' not found on player"; return null; }
			var td = Game.TypeLibrary.GetType( comp.GetType() );
			var pd = td?.Properties.FirstOrDefault( pp => pp.Name == parts[1] );
			if ( pd == null ) { err = $"property '{head}.{parts[1]}' not found"; return null; }
			cur = pd.GetValue( comp );
			sub = 2;
		}

		for ( int i = sub; i < parts.Length && cur != null; i++ )
			cur = SubAccess( cur, parts[i] );

		return cur;
	}

	static object SubAccess( object v, string sub )
	{
		if ( v is Vector3 v3 ) return sub switch { "x" => v3.x, "y" => v3.y, "z" => v3.z, _ => null };
		if ( v is Vector2 v2 ) return sub switch { "x" => v2.x, "y" => v2.y, _ => null };
		if ( v is Angles an ) return sub switch { "pitch" => an.pitch, "yaw" => an.yaw, "roll" => an.roll, _ => null };
		if ( sub == "Count" )
		{
			if ( v is ICollection col ) return col.Count;
			if ( v is IEnumerable en ) return en.Cast<object>().Count();
		}
		// generic property fallback
		try { return v.GetType().GetProperty( sub )?.GetValue( v ); } catch { return null; }
	}

	static bool Compare( Job j, string readKey, object actual, string op, string expected, out string err )
	{
		err = null;
		if ( op == "changed" )
			return j.Baselines.TryGetValue( readKey, out var b ) ? ValueToString( actual ) != b : true;

		// numeric comparison when both sides are numbers
		if ( TryNum( actual, out var an ) && float.TryParse( expected, NumberStyles.Float, CultureInfo.InvariantCulture, out var en ) )
		{
			return op switch
			{
				">"  => an > en, "<" => an < en, ">=" => an >= en, "<=" => an <= en,
				"==" => System.Math.Abs( an - en ) < 0.0001f, "!=" => System.Math.Abs( an - en ) >= 0.0001f,
				_ => SetErr( out err, $"bad numeric op '{op}'" ),
			};
		}

		// bool / string equality
		var astr = ValueToString( actual );
		return op switch
		{
			"==" => string.Equals( astr, expected, StringComparison.OrdinalIgnoreCase ),
			"!=" => !string.Equals( astr, expected, StringComparison.OrdinalIgnoreCase ),
			_ => SetErr( out err, $"op '{op}' needs numeric operands (got '{astr}' vs '{expected}')" ),
		};
	}

	static bool SetErr( out string err, string msg ) { err = msg; return false; }

	static bool TryNum( object v, out float f )
	{
		f = 0f;
		switch ( v )
		{
			case float ff: f = ff; return true;
			case double dd: f = (float) dd; return true;
			case int ii: f = ii; return true;
			case long ll: f = ll; return true;
			case short ss: f = ss; return true;
			case byte bb: f = bb; return true;
			default: return false;
		}
	}

	static string ValueToString( object v )
	{
		if ( v == null ) return "null";
		if ( v is bool b ) return b ? "True" : "False";
		if ( v is Vector3 v3 ) return $"{v3.x},{v3.y},{v3.z}";
		if ( v is float f ) return f.ToString( CultureInfo.InvariantCulture );
		return v.ToString();
	}

	// ── Setup / teardown ────────────────────────────────────────────────────────
	static void ResolveAnchor( Job j )
	{
		var scene = Game.ActiveScene;
		if ( scene == null ) return;
		Component c = null;

		if ( j.TargetId != Guid.Empty )
		{
			var go = ClaudeBridge.ResolveGameObject( scene, j.TargetId.ToString() );
			if ( go != null ) c = FindControllerOn( go, j.ComponentType );
		}
		if ( c == null )
		{
			foreach ( var obj in scene.GetAllObjects( true ) )
			{
				c = FindControllerOn( obj, j.ComponentType );
				if ( c != null ) break;
			}
		}
		j.Controller = c;
		j.Anchor = c?.GameObject;
	}

	static void CaptureBaselines( Job j )
	{
		// Anchor position at job start — the origin for the "Displacement" read.
		if ( j.Anchor != null ) j.StartPos = j.Anchor.WorldPosition;
		// Record the initial value of every "changed" read so we can diff later.
		foreach ( var s in j.Steps.Where( x => x.Kind == "assert" && x.AssertOp == "changed" ) )
		{
			var v = ResolveRead( j, s.AssertRead, out var e );
			if ( e == null ) j.Baselines[s.AssertRead] = ValueToString( v );
		}
	}

	static void EnsureInputDisabled( Job j )
	{
		if ( j.DisabledInput || j.Controller == null ) return;
		var td = Game.TypeLibrary.GetType( j.Controller.GetType() );
		var pd = td?.Properties.FirstOrDefault( pp => pp.Name == "UseInputControls" );
		if ( pd != null && pd.PropertyType == typeof( bool ) )
		{
			pd.SetValue( j.Controller, false );
			j.DisabledInput = true;
		}
	}

	static void Teardown( Job j )
	{
		try
		{
			if ( !string.IsNullOrEmpty( j.HeldAction ) )
				try { Sandbox.Input.SetAction( j.HeldAction, false ); } catch { }

			if ( j.Controller != null && j.Controller.IsValid() )
			{
				var td = Game.TypeLibrary.GetType( j.Controller.GetType() );
				TrySetVector3( j.Controller, td, "WishVelocity", Vector3.Zero );
				if ( j.DisabledInput )
				{
					var pd = td?.Properties.FirstOrDefault( pp => pp.Name == "UseInputControls" );
					pd?.SetValue( j.Controller, true );
				}
			}
		}
		catch { }
	}

	static object Summarize( Job j, string reason )
	{
		return new
		{
			finished = true,
			reason,
			verdict = j.Failed == 0 ? "PASS" : "FAIL",
			passed = j.Passed,
			failed = j.Failed,
			stepsRun = j.Index,
			totalSteps = j.Steps.Count,
			controller = j.Controller?.GetType().Name,
			controllerResolved = j.Controller != null,
			transcript = j.Transcript,
		};
	}

	// ── Reflection helpers (self-contained; mirror PlayInputDriver's idiom) ──────
	internal static Component FindControllerOn( GameObject go, string componentType )
	{
		if ( go == null ) return null;
		var all = go.Components.GetAll().ToList();
		if ( !string.IsNullOrEmpty( componentType ) )
			return all.FirstOrDefault( c => c.GetType().Name.Equals( componentType, StringComparison.OrdinalIgnoreCase ) );
		var exact = all.FirstOrDefault( c => c.GetType().Name == "PlayerController" );
		if ( exact != null ) return exact;
		return all.FirstOrDefault( c =>
		{
			var n = c.GetType().Name;
			if ( !n.EndsWith( "Controller", StringComparison.OrdinalIgnoreCase ) ) return false;
			var td = Game.TypeLibrary.GetType( c.GetType() );
			return td != null && td.Properties.Any( pp => pp.Name == "EyeAngles" || pp.Name == "WishVelocity" );
		} );
	}

	static Component FindComponent( Job j, string typeName )
	{
		if ( j.Anchor == null || string.IsNullOrEmpty( typeName ) ) return null;
		return j.Anchor.Components.GetAll().FirstOrDefault( c => c.GetType().Name.Equals( typeName, StringComparison.OrdinalIgnoreCase ) );
	}

	static Angles? ReadAngles( Component c, TypeDescription td, string member )
	{
		try
		{
			var pd = td?.Properties.FirstOrDefault( pp => pp.Name == member );
			if ( pd == null ) return null;
			var v = pd.GetValue( c );
			if ( v is Angles a ) return a;
			if ( v is Rotation r ) return r.Angles();
		}
		catch { }
		return null;
	}

	static bool TrySetAngles( Component c, TypeDescription td, string member, Angles value )
	{
		try
		{
			var pd = td?.Properties.FirstOrDefault( pp => pp.Name == member );
			if ( pd == null ) return false;
			if ( pd.PropertyType == typeof( Angles ) ) { pd.SetValue( c, value ); return true; }
			if ( pd.PropertyType == typeof( Rotation ) ) { pd.SetValue( c, Rotation.From( value ) ); return true; }
		}
		catch { }
		return false;
	}

	static bool TrySetVector3( Component c, TypeDescription td, string member, Vector3 value )
	{
		try
		{
			var pd = td?.Properties.FirstOrDefault( pp => pp.Name == member );
			if ( pd == null || pd.PropertyType != typeof( Vector3 ) ) return false;
			pd.SetValue( c, value );
			return true;
		}
		catch { return false; }
	}

	static object CoerceTo( Type t, string raw )
	{
		if ( t == typeof( bool ) ) return raw == "true" || raw == "True" || raw == "1";
		if ( t == typeof( float ) ) return float.Parse( raw, NumberStyles.Float, CultureInfo.InvariantCulture );
		if ( t == typeof( int ) ) return (int) float.Parse( raw, NumberStyles.Float, CultureInfo.InvariantCulture );
		if ( t == typeof( Vector3 ) ) return ClaudeBridge.ParseVector3Flexible( ParseElement( raw ) );
		return raw;
	}

	static JsonElement ParseElement( string raw )
	{
		// Wrap a bare "x,y,z" or scalar as a JSON string element for ParseVector3Flexible.
		using var doc = JsonDocument.Parse( JsonSerializer.Serialize( raw ) );
		return doc.RootElement.Clone();
	}
}

/// <summary>
/// playtest — run a scripted gameplay-verification sequence in play mode (async, in the
/// editor frame loop) and record a pass/fail transcript. Requires start_play first.
/// </summary>
public class PlaytestHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		if ( !Game.IsPlaying )
			return Task.FromResult<object>( new { error = "playtest requires play mode — call start_play first" } );
		if ( PlaytestRunner.IsActive() )
			return Task.FromResult<object>( new { error = "a playtest is already running — poll playtest_status until it finishes" } );
		if ( !p.TryGetProperty( "steps", out var stepsEl ) || stepsEl.ValueKind != JsonValueKind.Array )
			return Task.FromResult<object>( new { error = "steps (an array of step objects) is required" } );

		try
		{
			var job = new PlaytestRunner.Job { Steps = new List<PlaytestRunner.StepSpec>() };

			if ( p.TryGetProperty( "id", out var idEl ) && idEl.ValueKind == JsonValueKind.String
				 && Guid.TryParse( idEl.GetString(), out var gid ) )
				job.TargetId = gid;
			if ( p.TryGetProperty( "component", out var compEl ) && compEl.ValueKind == JsonValueKind.String )
				job.ComponentType = compEl.GetString();

			int idx = 0;
			foreach ( var stepEl in stepsEl.EnumerateArray() )
			{
				var spec = ParseStep( stepEl, idx, out var perr );
				if ( spec == null )
					return Task.FromResult<object>( new { error = $"step {idx}: {perr}" } );
				job.Steps.Add( spec );
				idx++;
			}
			if ( job.Steps.Count == 0 )
				return Task.FromResult<object>( new { error = "steps is empty" } );

			PlaytestRunner.Start( job );
			return Task.FromResult<object>( new
			{
				started = true,
				steps = job.Steps.Count,
				note = "Playtest running ASYNC in the editor frame loop. Poll playtest_status until finished:true, then read the transcript (pass/fail per step).",
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"playtest failed: {ex.Message}" } );
		}
	}

	static PlaytestRunner.StepSpec ParseStep( JsonElement e, int idx, out string err )
	{
		err = null;
		if ( e.ValueKind != JsonValueKind.Object ) { err = "not an object"; return null; }
		var s = new PlaytestRunner.StepSpec();
		int? framesOverride = ( e.TryGetProperty( "frames", out var fEl ) && fEl.TryGetInt32( out var fi ) )
			? System.Math.Clamp( fi, 1, 1800 ) : (int?) null;
		if ( e.TryGetProperty( "moveSpeed", out var msEl ) && msEl.TryGetSingle( out var ms ) ) s.MoveSpeed = ms;

		if ( e.TryGetProperty( "move", out var mEl ) )
		{
			s.Kind = "move"; s.Move = ParseMove( mEl ); s.Frames = framesOverride ?? 30;
		}
		else if ( e.TryGetProperty( "look", out var lEl ) )
		{
			s.Kind = "look"; s.Look = ParseAngles( lEl ); s.HasLook = true; s.Frames = framesOverride ?? 1;
		}
		else if ( e.TryGetProperty( "lookDelta", out var ldEl ) )
		{
			s.Kind = "lookDelta"; s.LookDelta = ParseAngles( ldEl ); s.Frames = framesOverride ?? 30;
		}
		else if ( e.TryGetProperty( "action", out var aEl ) && aEl.ValueKind == JsonValueKind.String )
		{
			s.Kind = "action"; s.Action = aEl.GetString(); s.Frames = framesOverride ?? 20;
		}
		else if ( e.TryGetProperty( "jump", out var jEl ) )
		{
			s.Kind = "jump"; s.JumpVel = ClaudeBridge.ParseVector3Flexible( jEl ); s.Frames = 1;
		}
		else if ( e.TryGetProperty( "set", out var setEl ) && setEl.ValueKind == JsonValueKind.Object )
		{
			s.Kind = "set"; s.Frames = 1;
			s.SetComponent = GetStr( setEl, "component" );
			s.SetProperty = GetStr( setEl, "property" );
			s.SetValue = GetStr( setEl, "to" ) ?? GetStr( setEl, "value" );
			if ( s.SetComponent == null || s.SetProperty == null ) { err = "set needs {component, property, to}"; return null; }
		}
		else if ( e.TryGetProperty( "wait", out var wEl ) && wEl.TryGetInt32( out var wf ) )
		{
			s.Kind = "wait"; s.Frames = System.Math.Clamp( wf, 1, 1800 );
		}
		else if ( e.TryGetProperty( "capture", out var capEl ) )
		{
			s.Kind = "capture"; s.Frames = 1;
			s.CaptureLabel = capEl.ValueKind == JsonValueKind.String ? capEl.GetString() : null;
		}
		else if ( e.TryGetProperty( "assert", out var asEl ) && asEl.ValueKind == JsonValueKind.Object )
		{
			s.Kind = "assert"; s.Frames = 1;
			s.AssertRead = GetStr( asEl, "read" );
			s.AssertOp = GetStr( asEl, "op" ) ?? "==";
			s.AssertDesc = GetStr( asEl, "desc" );
			if ( asEl.TryGetProperty( "value", out var vEl ) )
				s.AssertValue = vEl.ValueKind == JsonValueKind.String ? vEl.GetString() : vEl.GetRawText();
			if ( s.AssertRead == null ) { err = "assert needs {read, op, value}"; return null; }
		}
		else
		{
			err = "unknown step (expected one of: move, look, lookDelta, action, jump, set, wait, capture, assert)";
			return null;
		}
		return s;
	}

	static string GetStr( JsonElement o, string key )
		=> o.TryGetProperty( key, out var v ) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;

	static Vector2 ParseMove( JsonElement el )
	{
		float x = 0f, y = 0f;
		if ( el.ValueKind == JsonValueKind.Object )
		{
			if ( el.TryGetProperty( "x", out var xp ) && xp.TryGetSingle( out var xf ) ) x = xf;
			if ( el.TryGetProperty( "y", out var yp ) && yp.TryGetSingle( out var yf ) ) y = yf;
		}
		else if ( el.ValueKind == JsonValueKind.String )
		{
			var pr = ( el.GetString() ?? "" ).Split( ',' );
			if ( pr.Length > 0 ) float.TryParse( pr[0], NumberStyles.Float, CultureInfo.InvariantCulture, out x );
			if ( pr.Length > 1 ) float.TryParse( pr[1], NumberStyles.Float, CultureInfo.InvariantCulture, out y );
		}
		var v = new Vector2( x, y );
		if ( v.Length > 1f ) v = v.Normal;
		return v;
	}

	static Angles ParseAngles( JsonElement el )
	{
		float pitch = 0f, yaw = 0f, roll = 0f;
		if ( el.ValueKind == JsonValueKind.Object )
		{
			if ( el.TryGetProperty( "pitch", out var pp ) && pp.TryGetSingle( out var pf ) ) pitch = pf;
			if ( el.TryGetProperty( "yaw", out var yp ) && yp.TryGetSingle( out var yf ) ) yaw = yf;
			if ( el.TryGetProperty( "roll", out var rp ) && rp.TryGetSingle( out var rf ) ) roll = rf;
		}
		else if ( el.ValueKind == JsonValueKind.String )
		{
			var pr = ( el.GetString() ?? "" ).Split( ',' );
			if ( pr.Length > 0 ) float.TryParse( pr[0], NumberStyles.Float, CultureInfo.InvariantCulture, out pitch );
			if ( pr.Length > 1 ) float.TryParse( pr[1], NumberStyles.Float, CultureInfo.InvariantCulture, out yaw );
			if ( pr.Length > 2 ) float.TryParse( pr[2], NumberStyles.Float, CultureInfo.InvariantCulture, out roll );
		}
		return new Angles( pitch, yaw, roll );
	}
}

/// <summary>
/// playtest_status — poll the running/finished playtest: live progress while running,
/// or the full pass/fail transcript once finished.
/// </summary>
public class PlaytestStatusHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		var summary = PlaytestRunner.ConsumeSummary();
		if ( summary != null )
			return Task.FromResult<object>( summary );

		var live = PlaytestRunner.LiveSnapshot();
		if ( live != null )
			return Task.FromResult<object>( live );

		return Task.FromResult<object>( new { active = false, finished = false, note = "No playtest has run yet." } );
	}
}

/// <summary>
/// playtest_abort — stop the running playtest immediately, restoring input state.
/// The partial transcript stays available via playtest_status.
/// </summary>
public class PlaytestAbortHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
		=> Task.FromResult<object>( PlaytestRunner.Abort() );
}
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;

// ═══════════════════════════════════════════════════════════════════
// Batch 54 — bridge_vehicle (v2 wave 4): the corpus vehicles theme.
//   create_vehicle_controller — make any Rigidbody prop drivable (raycast car
//     with suspension, engine, steering, grip + built-in driver seat)
//   create_seat_system       — standalone generic seat (enter/exit/safe-exit)
//   tune_vehicle             — apply arcade/drift/offroad/race presets
//   create_physics_grab_tool — physgun-style spring grab + throw
// Generated code APIs verified live: Rigidbody.ApplyForceAt/GetVelocityAtPoint/
// ApplyTorque/Velocity/Mass (describe_type, 2026-07-09). Driving FEEL needs a
// human playtest — compiles+runs ≠ fun (BRIDGE_GOTCHAS #1).
// ═══════════════════════════════════════════════════════════════════

/// <summary>create_vehicle_controller — scaffold a drivable raycast-car component.</summary>
public class CreateVehicleControllerHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !ScaffoldHelpers.PrepareCodeFile( p, "VehicleController", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			float engine = p.TryGetProperty( "engineForce", out var ef ) && ef.TryGetSingle( out var eff ) ? eff : 900f;
			float steer  = p.TryGetProperty( "steerStrength", out var ss ) && ss.TryGetSingle( out var ssf ) ? ssf : 2.0f;
			float grip   = p.TryGetProperty( "grip", out var g ) && g.TryGetSingle( out var gf ) ? gf : 0.85f;

			ScaffoldHelpers.WriteCode( fullPath, BuildCode( className, engine, steer, grip ) );

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				nextSteps = new[]
				{
					"trigger_hotload, then check compile_status",
					$"Attach {className} + a Rigidbody + a collider to your vehicle prop (batch_add_component works)",
					"Enter play mode and press E on the vehicle to drive (WASD; E again to exit)",
					"tune_vehicle applies arcade/drift/offroad/race presets to the attached component",
					"HUMAN PLAYTEST REQUIRED for feel — tune EngineForce/SteerStrength/GripFactor from the inspector while playing"
				}
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_vehicle_controller failed: {ex.Message}" } );
		}
	}

	static string BuildCode( string className, float engine, float steer, float grip )
	{
		var ci = System.Globalization.CultureInfo.InvariantCulture;
		return $@"using Sandbox;
using System;

/// <summary>
/// {className} — makes a Rigidbody prop drivable: a 4-corner raycast car with
/// spring/damper suspension, engine force, yaw steering, and lateral grip
/// (lower grip = drift). Built-in driver seat: press E (use) to enter — the
/// driver is hidden while driving (no controller transform fights), the host
/// assigns them vehicle ownership, and a chase camera follows — E to exit.
/// Requires a Rigidbody + collider on the same GameObject. Tune from the
/// inspector while playing; tune_vehicle applies ready-made presets.
/// </summary>
public sealed class {className} : Component, Component.IPressable
{{
	[Property, Group( ""Engine"" )] public float EngineForce {{ get; set; }} = {engine.ToString( ci )}f;
	[Property, Group( ""Engine"" )] public float MaxSpeed {{ get; set; }} = 800f;
	[Property, Group( ""Steering"" )] public float SteerStrength {{ get; set; }} = {steer.ToString( ci )}f;   // yaw rate, rad/s at full speed factor
	[Property, Group( ""Handling"" ), Range( 0f, 1f )] public float GripFactor {{ get; set; }} = {grip.ToString( ci )}f;
	[Property, Group( ""Suspension"" )] public float SuspensionRest {{ get; set; }} = 24f;
	[Property, Group( ""Suspension"" )] public float SuspensionStrength {{ get; set; }} = 90f;
	[Property, Group( ""Suspension"" )] public float SuspensionDamping {{ get; set; }} = 8f;
	[Property, Group( ""Seat"" )] public Vector3 ExitOffset {{ get; set; }} = new( 0, 80, 20 );
	[Property, Group( ""Camera"" )] public float CameraDistance {{ get; set; }} = 260f;
	[Property, Group( ""Camera"" )] public float CameraHeight {{ get; set; }} = 110f;

	[Sync] public Guid DriverId {{ get; set; }}

	public bool HasDriver => DriverId != Guid.Empty;
	public static event Action<GameObject, bool> OnDriverChanged; // (vehicle, entered)

	Rigidbody _rb;
	Vector3[] _corners;
	TimeSince _sinceEnter;

	protected override void OnStart()
	{{
		_rb = GetComponent<Rigidbody>();
		if ( _rb == null )
		{{
			Log.Warning( $""{className} needs a Rigidbody on {{GameObject.Name}}"" );
			Enabled = false;
			return;
		}}
		var bounds = GameObject.GetBounds();
		var ext = ( bounds.Size * 0.4f ).WithZ( 0 );
		_corners = new[]
		{{
			new Vector3(  ext.x,  ext.y, 0 ), new Vector3(  ext.x, -ext.y, 0 ),
			new Vector3( -ext.x,  ext.y, 0 ), new Vector3( -ext.x, -ext.y, 0 ),
		}};
	}}

	// ── Seat (IPressable) ────────────────────────────────────────────
	public bool Press( Component.IPressable.Event e )
	{{
		var presser = e.Source?.GameObject;
		if ( presser == null ) return false;
		RequestSeat( presser.Id );
		return true;
	}}

	[Rpc.Host]
	void RequestSeat( Guid pressGuid )
	{{
		var presser = Scene.Directory.FindByGuid( pressGuid );
		if ( presser == null ) return;
		if ( HasDriver && DriverId != pressGuid ) return;

		if ( DriverId == pressGuid )
		{{
			Exit( presser );
			return;
		}}

		// Enter: hide the player entirely while driving — parenting a live
		// PlayerController to a moving vehicle makes two systems fight over the
		// transform (the classic seat jitter). Hidden driver + chase camera instead.
		DriverId = pressGuid;
		_sinceEnter = 0;
		var owner = presser.Network.Owner;
		if ( owner != null ) GameObject.Network.AssignOwnership( owner );
		presser.Enabled = false;
		OnDriverChanged?.Invoke( GameObject, true );
	}}

	void Exit( GameObject driver )
	{{
		DriverId = Guid.Empty;
		if ( driver != null )
		{{
			driver.WorldPosition = WorldPosition + WorldRotation * ExitOffset;
			driver.Enabled = true;   // their controller re-takes the camera next frame
		}}
		GameObject.Network.DropOwnership();
		OnDriverChanged?.Invoke( GameObject, false );
	}}

	// Chase camera while driving (runs on the driver's client — they own the vehicle).
	protected override void OnPreRender()
	{{
		if ( IsProxy || !HasDriver ) return;
		var cam = Scene.Camera;
		if ( cam == null ) return;

		var targetPos = WorldPosition - WorldRotation.Forward.WithZ( 0 ).Normal * CameraDistance + Vector3.Up * CameraHeight;
		cam.WorldPosition = cam.WorldPosition.LerpTo( targetPos, MathX.Clamp( Time.Delta * 6f, 0f, 1f ) );
		cam.WorldRotation = Rotation.LookAt( ( WorldPosition + Vector3.Up * 30f - cam.WorldPosition ).Normal, Vector3.Up );
	}}

	// ── Driving (vehicle owner only) ─────────────────────────────────
	protected override void OnFixedUpdate()
	{{
		if ( _rb == null || IsProxy || !HasDriver ) return;

		// E again to exit (edge-guarded so the entering press cannot instantly exit).
		if ( _sinceEnter > 0.4f && Input.Pressed( ""use"" ) )
		{{
			RequestSeat( DriverId );
			return;
		}}

		var dt = Time.Delta;
		var input = Input.AnalogMove;   // x = forward/back, y = left/right
		int grounded = 0;

		// Suspension: 4 corner rays, spring + damper applied at each corner.
		foreach ( var corner in _corners )
		{{
			var worldCorner = WorldPosition + WorldRotation * corner;
			var tr = Scene.Trace.Ray( worldCorner, worldCorner + Vector3.Down * SuspensionRest * 2f )
				.IgnoreGameObjectHierarchy( GameObject )
				.Run();
			if ( !tr.Hit ) continue;
			grounded++;
			var compression = 1f - ( tr.Distance / ( SuspensionRest * 2f ) );
			var pointVel = _rb.GetVelocityAtPoint( worldCorner );
			var force = Vector3.Up * ( compression * SuspensionStrength - pointVel.z * SuspensionDamping ) * _rb.Mass * dt * 50f;
			_rb.ApplyForceAt( worldCorner, force );
		}}

		if ( grounded == 0 ) return;   // airborne — no engine/steer/grip

		var forward = WorldRotation.Forward.WithZ( 0 ).Normal;
		var speed = _rb.Velocity.WithZ( 0 ).Length;

		// Engine (mass-scaled so feel survives different props).
		if ( MathF.Abs( input.x ) > 0.01f && speed < MaxSpeed )
			_rb.ApplyForce( forward * input.x * EngineForce * _rb.Mass );

		// Steering: set yaw angular velocity directly — arcade-reliable, immune to the
		// prop's moment of inertia (torque was far too weak on heavy boxes — playtested).
		var steerFactor = MathX.Clamp( speed / 150f, 0.25f, 1f );
		var direction = _rb.Velocity.Dot( forward ) < -10f ? -1f : 1f;   // reverse steers mirrored
		var yawRate = MathF.Abs( input.y ) > 0.01f
			? input.y * SteerStrength * steerFactor * direction
			: 0f;
		_rb.AngularVelocity = _rb.AngularVelocity.WithZ( MathX.Lerp( _rb.AngularVelocity.z, yawRate, MathX.Clamp( dt * 12f, 0f, 1f ) ) );

		// Lateral grip: kill a fraction of sideways velocity each tick. Low grip = drift.
		var right = WorldRotation.Right.WithZ( 0 ).Normal;
		var lateral = right * _rb.Velocity.Dot( right );
		_rb.Velocity -= lateral * GripFactor * MathX.Clamp( dt * 10f, 0f, 1f );
	}}
}}
";
	}
}

/// <summary>create_seat_system — scaffold a standalone enter/exit seat component.</summary>
public class CreateSeatSystemHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !ScaffoldHelpers.PrepareCodeFile( p, "Seat", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			ScaffoldHelpers.WriteCode( fullPath, BuildCode( className ) );

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				nextSteps = new[]
				{
					"trigger_hotload, then check compile_status",
					$"Attach {className} to any prop (chair, bench, turret mount) — press E to sit, E to stand",
					"SeatOffset positions the occupant; exit tries ExitOffsets in order and takes the first clear spot",
					$"Subscribe to {className}.OnOccupantChanged for camera/UI logic"
				}
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_seat_system failed: {ex.Message}" } );
		}
	}

	static string BuildCode( string className )
	{
		return $@"using Sandbox;
using System;
using System.Linq;

/// <summary>
/// {className} — a networked one-occupant seat: press E (use) to sit, E again
/// to stand. Claims route through the host so two players can't share a seat;
/// the occupant is parented to the seat with their controller input disabled
/// (UseInputControls=false, restored on exit). Exit tries each ExitOffsets
/// entry and takes the first spot with clearance. Works for chairs, benches,
/// turret mounts — anything sittable.
/// </summary>
public sealed class {className} : Component, Component.IPressable
{{
	[Property] public Vector3 SeatOffset {{ get; set; }} = new( 0, 0, 10 );
	[Property] public System.Collections.Generic.List<Vector3> ExitOffsets {{ get; set; }} = new()
		{{ new( 0, 60, 10 ), new( 0, -60, 10 ), new( 60, 0, 10 ), new( -60, 0, 10 ) }};

	[Sync] public Guid OccupantId {{ get; set; }}

	public bool IsOccupied => OccupantId != Guid.Empty;
	public static event Action<GameObject, GameObject, bool> OnOccupantChanged; // (seat, occupant, seated)

	public bool Press( Component.IPressable.Event e )
	{{
		var presser = e.Source?.GameObject;
		if ( presser == null ) return false;
		RequestSeat( presser.Id );
		return true;
	}}

	[Rpc.Host]
	void RequestSeat( Guid pressGuid )
	{{
		var presser = Scene.Directory.FindByGuid( pressGuid );
		if ( presser == null ) return;

		if ( OccupantId == pressGuid )
		{{
			SetControls( presser, true );
			presser.SetParent( null, true );
			presser.WorldPosition = FindExitSpot( presser );
			OccupantId = Guid.Empty;
			OnOccupantChanged?.Invoke( GameObject, presser, false );
			return;
		}}
		if ( IsOccupied ) return;

		OccupantId = pressGuid;
		presser.SetParent( GameObject, true );
		presser.LocalPosition = SeatOffset;
		SetControls( presser, false );
		OnOccupantChanged?.Invoke( GameObject, presser, true );
	}}

	Vector3 FindExitSpot( GameObject occupant )
	{{
		foreach ( var offset in ExitOffsets )
		{{
			var spot = WorldPosition + WorldRotation * offset;
			var tr = Scene.Trace.Ray( spot + Vector3.Up * 32f, spot )
				.IgnoreGameObjectHierarchy( GameObject )
				.IgnoreGameObjectHierarchy( occupant )
				.Run();
			if ( !tr.Hit ) return spot;
		}}
		return WorldPosition + Vector3.Up * 48f;   // all blocked — pop up top
	}}

	static void SetControls( GameObject occupant, bool enabled )
	{{
		foreach ( var comp in occupant.Components.GetAll() )
		{{
			if ( comp is null ) continue;
			var type = Game.TypeLibrary?.GetType( comp.GetType() );
			var prop = type?.Properties?.FirstOrDefault( pr => pr.Name == ""UseInputControls"" );
			prop?.SetValue( comp, enabled );
		}}
	}}
}}
";
	}
}

/// <summary>tune_vehicle — apply a handling preset to a vehicle controller component.</summary>
public class TuneVehicleHandler : IBridgeHandler
{
	static readonly Dictionary<string, Dictionary<string, float>> Presets = new( StringComparer.OrdinalIgnoreCase )
	{
		["arcade"]  = new() { ["EngineForce"] = 900f,  ["MaxSpeed"] = 800f,  ["SteerStrength"] = 2.0f, ["GripFactor"] = 0.85f, ["SuspensionStrength"] = 90f,  ["SuspensionDamping"] = 8f },
		["drift"]   = new() { ["EngineForce"] = 1100f, ["MaxSpeed"] = 900f,  ["SteerStrength"] = 2.8f, ["GripFactor"] = 0.35f, ["SuspensionStrength"] = 80f,  ["SuspensionDamping"] = 6f },
		["offroad"] = new() { ["EngineForce"] = 750f,  ["MaxSpeed"] = 600f,  ["SteerStrength"] = 1.5f, ["GripFactor"] = 0.7f,  ["SuspensionStrength"] = 130f, ["SuspensionDamping"] = 12f },
		["race"]    = new() { ["EngineForce"] = 1400f, ["MaxSpeed"] = 1400f, ["SteerStrength"] = 1.7f, ["GripFactor"] = 0.95f, ["SuspensionStrength"] = 110f, ["SuspensionDamping"] = 10f },
	};

	public Task<object> Execute( JsonElement p )
	{
		var scene = SceneEditorSession.Active?.Scene;
		if ( scene == null )
			return Task.FromResult<object>( new { error = "No active scene" } );

		var id = p.TryGetProperty( "id", out var idEl ) ? idEl.GetString() : null;
		var go = ClaudeBridge.ResolveGameObject( scene, id );
		if ( go == null )
			return Task.FromResult<object>( new { error = $"GameObject not found: {id}" } );

		var presetName = p.TryGetProperty( "preset", out var pr ) ? pr.GetString() : null;
		if ( presetName == null || !Presets.TryGetValue( presetName, out var preset ) )
			return Task.FromResult<object>( new { error = $"preset must be one of: {string.Join( " | ", Presets.Keys )}" } );

		var compName = p.TryGetProperty( "component", out var cn ) ? cn.GetString() : null;
		var component = go.Components.GetAll().FirstOrDefault( c => c != null &&
			( compName != null
				? c.GetType().Name.Equals( compName, StringComparison.OrdinalIgnoreCase )
				: c.GetType().Name.Contains( "Vehicle", StringComparison.OrdinalIgnoreCase ) ) );
		if ( component == null )
			return Task.FromResult<object>( new { error = compName != null
				? $"No '{compName}' component on the object"
				: "No component with 'Vehicle' in its type name found — pass component explicitly" } );

		var typeDesc = Game.TypeLibrary.GetType( component.GetType().Name );
		var applied = new List<object>();
		var missing = new List<string>();
		foreach ( var (propName, value) in preset )
		{
			var propDesc = typeDesc?.Properties.FirstOrDefault( pp => pp.Name == propName );
			if ( propDesc == null ) { missing.Add( propName ); continue; }
			try
			{
				propDesc.SetValue( component, value );
				applied.Add( new { property = propName, value } );
			}
			catch ( Exception ex ) { missing.Add( $"{propName} ({ex.Message})" ); }
		}

		return Task.FromResult<object>( new
		{
			tuned = applied.Count > 0,
			preset = presetName.ToLowerInvariant(),
			component = component.GetType().Name,
			applied,
			missing,
			note = missing.Count > 0
				? "Some preset properties don't exist on this component — presets target create_vehicle_controller scaffolds; others tune partially."
				: "Preset applied. Enter play mode and drive to feel it; fine-tune the same properties with set_property."
		} );
	}
}

/// <summary>create_physics_grab_tool — scaffold a physgun-style spring grab + throw.</summary>
public class CreatePhysicsGrabToolHandler : IBridgeHandler
{
	public Task<object> Execute( JsonElement p )
	{
		try
		{
			if ( !ScaffoldHelpers.PrepareCodeFile( p, "PhysicsGrabTool", out var fullPath, out var relPath, out var className, out var err ) )
				return Task.FromResult<object>( err );

			ScaffoldHelpers.WriteCode( fullPath, BuildCode( className ) );

			return Task.FromResult<object>( new
			{
				created = true,
				path = relPath,
				className,
				nextSteps = new[]
				{
					"trigger_hotload, then check compile_status",
					$"Attach {className} to the player object (needs a camera child or PlayerController for aim)",
					"Hold attack2 (right mouse) on a Rigidbody prop to grab; scroll-free: it follows at grab distance; attack1 throws",
					"ensure_input_action if your project lacks attack1/attack2 bindings"
				}
			} );
		}
		catch ( Exception ex )
		{
			return Task.FromResult<object>( new { error = $"create_physics_grab_tool failed: {ex.Message}" } );
		}
	}

	static string BuildCode( string className )
	{
		return $@"using Sandbox;
using System;
using System.Linq;

/// <summary>
/// {className} — a physgun-lite for the player: hold GrabAction (default
/// attack2) while looking at a Rigidbody prop to grab it; it spring-follows a
/// point in front of your view (physics stays LIVE — it collides and swings,
/// unlike a parented carry); press ThrowAction (default attack1) to launch it.
/// Grab requests route through the host, which assigns the grabber network
/// ownership of the prop. Owner-only logic; attach to the player object.
/// </summary>
public sealed class {className} : Component
{{
	[Property] public float Range {{ get; set; }} = 300f;
	[Property] public float SpringStrength {{ get; set; }} = 12f;
	[Property] public float ThrowForce {{ get; set; }} = 600f;
	[Property] public float MaxMass {{ get; set; }} = 2000f;
	[Property] public string GrabAction {{ get; set; }} = ""attack2"";
	[Property] public string ThrowAction {{ get; set; }} = ""attack1"";

	GameObject _held;
	float _holdDistance;

	public bool IsHolding => _held.IsValid();
	public static event Action<GameObject, GameObject, bool> OnGrabChanged; // (player, prop, grabbed)

	protected override void OnFixedUpdate()
	{{
		if ( IsProxy ) return;

		var eye = GetEye( out var dir );

		if ( IsHolding && Input.Pressed( ThrowAction ) )
		{{
			var rb = _held.GetComponent<Rigidbody>();
			rb?.ApplyImpulse( dir * ThrowForce * ( rb.Mass ) );
			Release();
			return;
		}}

		if ( Input.Down( GrabAction ) )
		{{
			if ( !IsHolding ) TryGrab( eye, dir );
			else Hold( eye, dir );
		}}
		else if ( IsHolding )
		{{
			Release();
		}}
	}}

	Vector3 GetEye( out Vector3 dir )
	{{
		var cam = Scene.Camera;
		if ( cam != null )
		{{
			dir = cam.WorldRotation.Forward;
			return cam.WorldPosition;
		}}
		dir = WorldRotation.Forward;
		return WorldPosition + Vector3.Up * 64f;
	}}

	void TryGrab( Vector3 eye, Vector3 dir )
	{{
		var tr = Scene.Trace.Ray( eye, eye + dir * Range )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();
		if ( !tr.Hit || tr.GameObject == null ) return;

		var rb = tr.GameObject.GetComponent<Rigidbody>();
		if ( rb == null || rb.Mass > MaxMass ) return;

		_held = tr.GameObject;
		_holdDistance = MathX.Clamp( tr.Distance, 60f, Range );
		RequestGrabOwnership( _held.Id );
		OnGrabChanged?.Invoke( GameObject, _held, true );
	}}

	void Hold( Vector3 eye, Vector3 dir )
	{{
		if ( !_held.IsValid() ) {{ _held = null; return; }}
		var rb = _held.GetComponent<Rigidbody>();
		if ( rb == null ) {{ Release(); return; }}

		var target = eye + dir * _holdDistance;
		// Velocity-set spring: stiff, stable, still collides with the world.
		rb.Velocity = ( target - _held.WorldPosition ) * SpringStrength;
		rb.AngularVelocity = rb.AngularVelocity.LerpTo( Vector3.Zero, Time.Delta * 5f );
	}}

	void Release()
	{{
		if ( _held.IsValid() )
			OnGrabChanged?.Invoke( GameObject, _held, false );
		_held = null;
	}}

	[Rpc.Host]
	void RequestGrabOwnership( Guid propId )
	{{
		var prop = Scene.Directory.FindByGuid( propId );
		var caller = Rpc.Caller;
		if ( prop == null || caller is null ) return;
		prop.Network.AssignOwnership( caller );
	}}
}}
";
	}
}
using System;
using Sandbox;
using Editor;

/// <summary>
/// Modal-style confirmation dialog with Overwrite/Cancel buttons.
/// Use ConfirmDialog.Show(title, message, onConfirm) to display.
/// </summary>
public class ConfirmDialog : PaintedWindow
{
	private readonly string _title;
	private readonly string _message;
	private readonly string _detail;
	private readonly Action _onConfirm;
	private Vector2 _mousePos;

	private ConfirmDialog( string title, string message, string detail, Action onConfirm )
	{
		_title = title;
		_message = message;
		_detail = detail;
		_onConfirm = onConfirm;
		Title = title;
		Size = new Vector2( 420, string.IsNullOrEmpty( detail ) ? 180 : 240 );
	}

	/// <summary>
	/// Show a confirmation dialog with Overwrite and Cancel buttons.
	/// </summary>
	public static void Show( string title, string message, Action onConfirm, string detail = null )
	{
		var dialog = new ConfirmDialog( title, message, detail, onConfirm );
		dialog.Show();
	}

	protected override void OnContentPaint()
	{
		base.OnContentPaint();

		var pad = 20f;
		var w = Width - pad * 2;
		var y = 20f;

		// Title
		Paint.SetDefaultFont( size: 13, weight: 700 );
		Paint.SetPen( Color.White );
		Paint.DrawText( new Rect( pad, y, w, 22 ), _title, TextFlag.LeftCenter );
		y += 32;

		// Message
		Paint.SetDefaultFont( size: 10 );
		Paint.SetPen( Color.White.WithAlpha( 0.85f ) );

		// Word-wrap the message manually
		var words = _message.Split( ' ' );
		var line = "";
		foreach ( var word in words )
		{
			var test = string.IsNullOrEmpty( line ) ? word : $"{line} {word}";
			if ( test.Length * 6.5f > w && !string.IsNullOrEmpty( line ) )
			{
				Paint.DrawText( new Rect( pad, y, w, 16 ), line, TextFlag.LeftCenter );
				y += 17;
				line = word;
			}
			else
			{
				line = test;
			}
		}
		if ( !string.IsNullOrEmpty( line ) )
		{
			Paint.DrawText( new Rect( pad, y, w, 16 ), line, TextFlag.LeftCenter );
			y += 20;
		}

		// Detail (smaller, dimmer)
		if ( !string.IsNullOrEmpty( _detail ) )
		{
			y += 4;
			Paint.SetDefaultFont( size: 9 );
			Paint.SetPen( Color.Orange.WithAlpha( 0.7f ) );
			Paint.DrawText( new Rect( pad, y, w, 14 ), _detail, TextFlag.LeftCenter );
			y += 22;
		}

		y += 8;

		// Buttons row
		var btnH = 32f;
		var btnW = ( w - 12 ) / 2;

		// Cancel button (left)
		var cancelRect = new Rect( pad, y, btnW, btnH );
		var cancelHovered = cancelRect.IsInside( _mousePos );
		Paint.SetBrush( Color.White.WithAlpha( cancelHovered ? 0.1f : 0.04f ) );
		Paint.SetPen( Color.White.WithAlpha( cancelHovered ? 0.3f : 0.15f ) );
		Paint.DrawRect( cancelRect, 4 );
		Paint.SetDefaultFont( size: 11, weight: 600 );
		Paint.SetPen( Color.White.WithAlpha( cancelHovered ? 0.9f : 0.6f ) );
		Paint.DrawText( cancelRect, "Cancel", TextFlag.Center );

		// Overwrite button (right)
		var overwriteRect = new Rect( pad + btnW + 12, y, btnW, btnH );
		var overwriteHovered = overwriteRect.IsInside( _mousePos );
		Paint.SetBrush( Color.Red.WithAlpha( overwriteHovered ? 0.25f : 0.12f ) );
		Paint.SetPen( Color.Red.WithAlpha( overwriteHovered ? 0.6f : 0.3f ) );
		Paint.DrawRect( overwriteRect, 4 );
		Paint.SetDefaultFont( size: 11, weight: 700 );
		Paint.SetPen( Color.Red.WithAlpha( overwriteHovered ? 1f : 0.8f ) );
		Paint.DrawText( overwriteRect, "Overwrite", TextFlag.Center );

		_cancelRect = cancelRect;
		_overwriteRect = overwriteRect;
	}

	private Rect _cancelRect;
	private Rect _overwriteRect;

	protected override void OnContentMousePress( MouseEvent e )
	{
		base.OnContentMousePress(e);

		if ( _cancelRect.IsInside( e.LocalPosition ) )
		{
			Close();
		}
		else if ( _overwriteRect.IsInside( e.LocalPosition ) )
		{
			Close();
			_onConfirm?.Invoke();
		}
	}

	protected override void OnContentMouseMove( MouseEvent e )
	{
		base.OnContentMouseMove(e);
		_mousePos = e.LocalPosition;
		Update();
	}
}
using Editor;

/// <summary>
/// Standalone editor window whose content is rendered and interacted with through
/// the window's central widget. Current s&amp;box windows reserve their root widget
/// for window chrome, so custom content must live on <see cref="Window.Canvas"/>.
/// </summary>
public abstract class PaintedWindow : Window
{
	private readonly ContentWidget _content;

	protected PaintedWindow()
	{
		_content = new ContentWidget( this );
		Canvas = _content;
	}

	/// <summary>
	/// Parent for native controls that must appear above the painted content.
	/// </summary>
	protected Widget Content => _content;

	public new float Width
	{
		get => _content.Width;
		set => base.Width = value;
	}

	public new float Height
	{
		get => _content.Height;
		set => base.Height = value;
	}

	public new bool MouseTracking
	{
		get => _content.MouseTracking;
		set
		{
			base.MouseTracking = value;
			_content.MouseTracking = value;
		}
	}

	public override void Update()
	{
		base.Update();
		_content.Update();
	}

	protected virtual void OnContentPaint()
	{
	}

	protected virtual void OnContentMousePress( MouseEvent e )
	{
	}

	protected virtual void OnContentMouseMove( MouseEvent e )
	{
	}

	protected virtual void OnContentMouseWheel( WheelEvent e )
	{
	}

	protected virtual void OnContentKeyPress( KeyEvent e )
	{
	}

	private sealed class ContentWidget : Widget
	{
		private readonly PaintedWindow _owner;

		public ContentWidget( PaintedWindow owner )
		{
			_owner = owner;
			MouseTracking = true;
			FocusMode = FocusMode.TabOrClickOrWheel;
		}

		protected override void OnPaint()
		{
			base.OnPaint();
			_owner.OnContentPaint();
		}

		protected override void OnMousePress( MouseEvent e )
		{
			base.OnMousePress( e );
			_owner.OnContentMousePress( e );
		}

		protected override void OnMouseMove( MouseEvent e )
		{
			base.OnMouseMove( e );
			_owner.OnContentMouseMove( e );
		}

		protected override void OnMouseWheel( WheelEvent e )
		{
			base.OnMouseWheel( e );
			_owner.OnContentMouseWheel( e );
		}

		protected override void OnKeyPress( KeyEvent e )
		{
			base.OnKeyPress( e );
			_owner.OnContentKeyPress( e );
		}
	}
}
namespace NodeEditorPlus.Blackboard;

public interface IBlackboardNodeGraph : INodeGraph
{
	IEnumerable<IBlackboardParameter> Parameters { get; }

	void AddParameter( IBlackboardParameter parameter );
	void RemoveParameter( IBlackboardParameter parameter );

	IBlackboardParameter FindParameter( Guid identifier );

	string SerializeParameters( IEnumerable<IBlackboardParameter> parameters );
	IEnumerable<IBlackboardParameter> DeserializeParameters( string serialized );
}
namespace ShaderGraphPlus;

public class ClassBlackboardParameterType : IBlackboardParameterType
{
	public virtual string Identifier => Type.FullName;
	public TypeDescription Type { get; }

	public DisplayInfo DisplayInfo { get; protected set; }

	protected virtual string DefaultBaseName => "Parameter";

	public ClassBlackboardParameterType( TypeDescription type )
	{
		Type = type;

		if ( Type is not null )
			DisplayInfo = DisplayInfo.ForType( Type.TargetType );
		else
			DisplayInfo = new DisplayInfo();
	}

	private string CheckName( ShaderGraphPlus graph, string name )
	{
		if ( string.IsNullOrWhiteSpace( name ) )
		{
			var id = 0;
			while ( graph.HasParameterWithName( $"{DefaultBaseName}{id}" ) )
			{
				id++;
			}

			return $"{DefaultBaseName}{id}";
		}
		else
		{
			return name;
		}
	}

	public virtual IBlackboardParameter CreateParameter( INodeGraph graph, string name = "" )
	{
		var sg = graph as ShaderGraphPlus;

		var parameter = Type.Create<BlackboardParameter>();
		parameter.Name = CheckName( sg, name );
		parameter.Graph = sg;

		return parameter;
	}

	internal static ClassBlackboardParameterType HookupParameterType( TypeDescription type )
	{
		var parameterType = new ClassBlackboardParameterType( type );

		// Use these specific ClassBlackboardParameterType's instead. Fallback to the default just in case.
		if ( type.TargetType.IsAssignableTo( typeof( IBlackboardMaterialParameter ) ) ||
			 type.TargetType.IsAssignableTo( typeof( BlackboardTextureMaterialParameter ) ) ||
			 type.TargetType.IsAssignableTo( typeof( SamplerStateParameter ) )
		)
		{
			parameterType = new MaterialParameterType( type );
		}
		else if ( type.TargetType.IsAssignableTo( typeof( IBlackboardSubgraphParameter ) ) )
		{
			parameterType = new SubgraphParameterType( type );
		}
		if ( type.TargetType.IsAssignableTo( typeof( IBlackboardShaderFeatureParameter ) ) )
		{
			parameterType = new ShaderFeatureParameterType( type );
		}

		return parameterType;
	}
}

public sealed class GroupParameterType : ClassBlackboardParameterType
{
	protected override string DefaultBaseName => "Group";

	public GroupParameterType( TypeDescription type ) : base( type )
	{
	}
}

public sealed class MaterialParameterType : ClassBlackboardParameterType
{
	protected override string DefaultBaseName => "MaterialParameter";

	public MaterialParameterType( TypeDescription type ) : base( type )
	{
	}
}

public sealed class SubgraphParameterType : ClassBlackboardParameterType
{
	protected override string DefaultBaseName => Type.TargetType.IsAssignableTo( typeof( IBlackboardSubgraphInputParameter ) ) ? "SubgraphInput" : "SubgraphOutput";

	public SubgraphParameterType( TypeDescription type ) : base( type )
	{
	}
}

public sealed class ShaderFeatureParameterType : ClassBlackboardParameterType
{
	protected override string DefaultBaseName => "ShaderFeature";

	public ShaderFeatureParameterType( TypeDescription type ) : base( type )
	{
	}

	public override IBlackboardParameter CreateParameter( INodeGraph graph, string name = "" )
	{
		var sg = graph as ShaderGraphPlus;
		var parameter = base.CreateParameter( graph );

		if ( parameter is ShaderFeatureEnumParameter shaderFeatureEnum )
		{
			shaderFeatureEnum.Options.Add( new ShaderFeatureEnumOption() { Name = "A" } );
			shaderFeatureEnum.Options.Add( new ShaderFeatureEnumOption() { Name = "B" } );
		}

		return parameter;
	}
}
namespace ShaderGraphPlus.Nodes;

[Title( "Boolean Combo Switch" ), Category( "Utility/Logic" ), Icon( "alt_route" )]
[InternalNode]
public sealed class BooleanFeatureSwitchNode : ShaderNodePlus, IParameterNode, IBlackboardNode
{
	[Hide, JsonIgnore, Browsable( false )]
	public override Color NodeTitleColor { get; set; } = ShaderGraphPlusTheme.NodeHeaderColors.LogicNode;

	[Hide, JsonIgnore, Browsable( false )]
	public override string Title => $"F_{Feature.Name.ToUpper().Replace( " ", "_" )}";

	[Hide, JsonIgnore, Browsable( false )]
	public string Name => $"F_{Feature.Name.ToUpper().Replace( " ", "_" )}";

	[Hide, Browsable( false )]
	public Guid ParameterIdentifier { get; set; }

	[Hide, JsonIgnore, Browsable( false )]
	public ShaderFeatureBoolean Feature => GetFeature();

	[Input, Hide]
	[Title( "True" )]
	public NodeInput InputTrue { get; set; }

	[Input, Hide]
	[Title( "False" )]
	public NodeInput InputFalse { get; set; }

	private ShaderFeatureBooleanParameter GetFeatureParameter()
	{
		if ( Graph is ShaderGraphPlus graph )
		{
			var parameter = graph.FindParameter<ShaderFeatureBooleanParameter>( ParameterIdentifier );

			if ( parameter != null )
			{
				return parameter;
			}
		}

		return new ShaderFeatureBooleanParameter();
	}

	private ShaderFeatureBoolean GetFeature()
	{
		var parameter = GetFeatureParameter();

		if ( parameter.IsValid )
		{
			var featureBoolean = new ShaderFeatureBoolean
			{
				Name = parameter.Name,
				Description = parameter.Description,
				HeaderName = parameter.HeaderName,
			};

			return featureBoolean;
		}

		return new ShaderFeatureBoolean();
	}

	[Output, Hide]
	public NodeResult.Func Result => ( GraphCompiler compiler ) =>
	{
		var inputs = new List<NodeInput>
		{
			InputTrue,
			InputFalse
		};

		var preview = GetFeatureParameter().Preview;
		var result = compiler.ResultFeatureSwitch( inputs, Feature, preview ? 1 : 0 );

		return result.IsValid ? result : new NodeResult( ResultType.Float, $"1.0f" );
	};
}
using Editor;
using ShaderGraphPlus.Nodes;

namespace ShaderGraphPlus;

[System.AttributeUsage( AttributeTargets.Class )]
internal class SubgraphOnlyAttribute : Attribute
{
	public SubgraphOnlyAttribute()
	{
	}
}

public abstract class BaseNodePlus : IGraphNode
{
	public event Action Changed;

	[Hide, Browsable( false )]
	public string Identifier { get; set; }

	[JsonIgnore, Hide, Browsable( false )]
	public virtual string Subtitle { get; }

	[JsonIgnore, Hide, Browsable( false )]
	public virtual DisplayInfo DisplayInfo { get; }

	[JsonIgnore, Hide, Browsable( false )]
	public bool CanClone => true;

	[JsonIgnore, Hide, Browsable( false )]
	public virtual bool CanRemove => true;

	[JsonIgnore, Hide, Browsable( false )]
	public virtual bool CanPreview => true;

	[JsonIgnore, Hide, Browsable( false )]
	public virtual bool CanAddToGraph => true;

	[Hide, Browsable( false )]
	public Vector2 Position { get; set; }

	[JsonIgnore, Hide]
	public INodeGraph _graph;

	[JsonIgnore, Hide, Browsable( false )]
	internal int PreviewID { get; set; }

	[JsonIgnore, Hide, Browsable( false )]
	public bool Processed { get; set; } = false;

	[JsonIgnore, Hide, Browsable( false )]
	public INodeGraph Graph
	{
		get => _graph;
		set
		{
			_graph = value;
			FilterInputsAndOutputs();
		}
	}

	[JsonIgnore, Hide, Browsable( false )]
	public Vector2 ExpandSize { get; set; }

	[JsonIgnore, Hide, Browsable( false )]
	public virtual bool AutoSize => false;

	[JsonIgnore, Hide, Browsable( false )]
	public virtual IEnumerable<IPlugIn> Inputs { get; protected set; }

	[JsonIgnore, Hide, Browsable( false )]
	public virtual IEnumerable<IPlugOut> Outputs { get; protected set; }

	[JsonIgnore, Hide, Browsable( false )]
	public string ErrorMessage { get; set; } = null;

	[JsonIgnore, Hide, Browsable( false )]
	public bool IsReachable => true;

	[Hide, Browsable( false )]
	public Dictionary<string, float> HandleOffsets { get; set; } = new();

	public BaseNodePlus()
	{
		DisplayInfo = DisplayInfo.For( this );
		NewIdentifier();

		(Inputs, Outputs) = GetPlugs( this );
	}

	public override string ToString()
	{
		return $"{DisplayInfo.Fullname}.{Identifier}";
	}

	public void Update()
	{
		Changed?.Invoke();
	}

	public virtual void OnFrame()
	{

	}
	public void ClearError()
	{
		HasError = false;
		ErrorMessage = null;
	}

	public string NewIdentifier()
	{
		Identifier = Guid.NewGuid().ToString();
		return Identifier;
	}

	public virtual NodeUI CreateUI( GraphView view )
	{
		return new NodeUI( view, this, false );
	}

	public Color GetNodeBodyTintColor( GraphView view )
	{
		return NodeBodyTintColor;
	}

	public Color GetNodeTitleColor( GraphView view )
	{
		return NodeTitleColor;
	}

	public virtual Menu CreateContextMenu( NodeUI node )
	{
		return null;
	}

	public virtual void DebugInfo( Menu menu )
	{
		var debugInfoHeading = menu.AddHeading( "Node Debug Info" );

		menu.AddWidget( new Label( $"Node ID : {this.Identifier}" ) );
		if ( this is IParameterNode blackboardSyncable )
		{
			menu.AddWidget( new Label( $"Blackboard ID : {blackboardSyncable.ParameterIdentifier}" ) ).AdjustSize();
		}
		menu.AddWidget( new Label( $"Preview ID : {this.PreviewID}" ) );
		menu.AddWidget( new Label( $"IsReachable? : {this.IsReachable}" ) );
		menu.AddWidget( new Label( $"CanPreview? : {this.CanPreview}" ) );
	}

	[JsonIgnore, Hide, Browsable( false )]
	public virtual Pixmap Thumbnail { get; }

	[JsonIgnore, Hide, Browsable( false )]
	public virtual Color NodeBodyTintColor { get; set; } = Color.Parse( "#303030" )!.Value.Lighten( 2.0f );

	[JsonIgnore, Hide, Browsable( false )]
	public virtual Color NodeTitleColor { get; set; } = Color.Gray;

	public virtual void OnPaint( Rect rect )
	{

	}

	public virtual void OnDoubleClick( MouseEvent e )
	{

	}

	[JsonIgnore, Hide, Browsable( false )]
	public bool HasTitleBar => true;

	[JsonIgnore, Hide, Browsable( false )]
	public bool HasSubtitle => !string.IsNullOrWhiteSpace( Subtitle );

	private bool _hasError;
	[JsonIgnore, Hide, Browsable( false )]
	public bool HasError
	{
		get => _hasError;
		set
		{
			_hasError = value;
			Update();
		}
	}

	[JsonIgnore, Hide, Browsable( false )]
	public bool HasWarning { get; set; } = false;

	[System.AttributeUsage( AttributeTargets.Property )]
	public class InputAttribute : Attribute
	{
		/// <summary>
		/// Type of the port.
		/// </summary>
		public System.Type Type;

		/// <summary>
		/// Order of the port.
		/// </summary>
		public int Order;

		public InputAttribute( Type type = null, int order = 0 )
		{
			Type = type;
			Order = order;
		}
	}

	[System.AttributeUsage( AttributeTargets.Property )]
	public class InputDefaultAttribute : Attribute
	{
		public string Input;

		public InputDefaultAttribute( string input )
		{
			Input = input;
		}
	}

	[System.AttributeUsage( AttributeTargets.Property )]
	public class OutputAttribute : Attribute
	{
		/// <summary>
		/// Type of the port.
		/// </summary>
		public System.Type Type;

		/// <summary>
		/// Order of the port.
		/// </summary>
		public int Order;

		public OutputAttribute( Type type = null, int order = 0 )
		{
			Type = type;
			Order = order;
		}
	}

	[System.AttributeUsage( AttributeTargets.Property )]
	public class HideOutputAttribute : Attribute
	{
		public System.Type Type;

		public HideOutputAttribute( Type type = null )
		{
			Type = type;
		}
	}

	[System.AttributeUsage( AttributeTargets.Property )]
	public class NodeValueEditorAttribute : Attribute
	{
		public string ValueName;

		public NodeValueEditorAttribute( string valueName )
		{
			ValueName = valueName;
		}
	}

	[System.AttributeUsage( AttributeTargets.Property )]
	public class RangeAttribute : Attribute
	{
		public string Min;
		public string Max;
		public string Step;

		public RangeAttribute( string min, string max, string step )
		{
			Min = min;
			Max = max;
			Step = step;
		}
	}

	/// <summary>
	/// Interface for nodes that want to setup anyting post node object deserializeation.
	/// </summary>
	public interface IInitializeNode
	{
		/// <summary>
		/// Called after the node has been deserialized.
		/// </summary>
		public void InitializeNode();
	}

	/// <summary>
	/// Connects a <see cref="NodeResult.Func"/> property from another node to the <see cref="NodeInput"/> property on this <see cref="BaseNodePlus"/> instance.
	/// </summary>
	/// <param name="targetInputName">The internal input name on this <see cref="BaseNodePlus"/> instance that will be connected to.</param>
	/// <param name="sourceNodeOutputName">The internal output name of the source <see cref="BaseNodePlus"/> that the new connection is coming from.</param>
	/// <param name="sourceNodeIdentifier">The Identifier of the souce <see cref="BaseNodePlus"/> that we are connecting from.</param>
	public void ConnectNode( string targetInputName, string sourceNodeOutputName, string sourceNodeIdentifier )
	{
		if ( Graph == null )
		{
			throw new Exception( "Graph is null!!!" );
		}

		var graph = Graph as ShaderGraphPlus;
		var targetOutputNode = graph.Nodes.Where( x => x.Identifier == sourceNodeIdentifier ).FirstOrDefault();

		if ( targetOutputNode != null )
		{
			var plugIn = Inputs.Where( x => x.Identifier == targetInputName ).FirstOrDefault();
			var targetOutputPlug = targetOutputNode.Outputs.FirstOrDefault( x => x.Identifier == sourceNodeOutputName );

			if ( plugIn == null )
			{
				throw new Exception( $"Cannot find input with name '{targetInputName}' on node '{this}'" );
			}

			if ( targetOutputPlug == null )
			{
				throw new Exception( $"Cannot find output with name '{sourceNodeOutputName}' on node '{targetOutputNode}'" );
			}


			plugIn.ConnectedOutput = targetOutputPlug;
		}
		else
		{
			throw new Exception( $"Cannot find node with Identifier '{sourceNodeIdentifier}'" );
		}
	}

	public static (IEnumerable<IPlugIn> Inputs, IEnumerable<IPlugOut> Outputs) GetPlugs( BaseNodePlus node )
	{
		var type = node.GetType();
		var inputs = new List<BasePlugIn>();
		var outputs = new List<BasePlugOut>();

		var inputProperties = type.GetProperties().OrderBy( x =>
			(x.GetCustomAttribute<InputAttribute>() is InputAttribute input) ? input.Order : 0 );

		foreach ( var propertyInfo in inputProperties )
		{
			if ( propertyInfo.GetCustomAttribute<InputAttribute>() is { } inputAttrib )
			{
				inputs.Add( new BasePlugIn( node, new( propertyInfo ), inputAttrib.Type ?? typeof( object ) ) );
			}
		}

		var outputProperties = type.GetProperties().OrderBy( x =>
			(x.GetCustomAttribute<OutputAttribute>() is OutputAttribute output) ? output.Order : 0 );

		foreach ( var propertyInfo in outputProperties )
		{
			if ( propertyInfo.GetCustomAttribute<OutputAttribute>() is { } outputAttrib )
			{
				outputs.Add( new BasePlugOut( node, new( propertyInfo ), outputAttrib.Type ?? typeof( object ) ) );
			}
		}

		return (inputs, outputs);
	}

	private void FilterInputsAndOutputs()
	{
		if ( _graph is not null )
		{
			if ( Graph is ShaderGraphPlus sgp && !sgp.IsSubgraph && this is not BooleanFeatureSwitchNode && this is IParameterNode )
			{
				Inputs = new List<IPlugIn>();
			}
		}
	}

}

public record BasePlug( BaseNodePlus Node, PlugInfo Info, Type Type ) : IPlug
{
	IGraphNode IPlug.Node => Node;

	Type IPlug.Type { get; set; } = Type;

	public string Identifier => Info.Name;
	public DisplayInfo DisplayInfo => Info.DisplayInfo;

	public ValueEditor CreateEditor( NodeUI node, NodePlug plug )
	{
		var editor = Info.CreateEditor( node, plug, Type );
		if ( editor is not null ) return editor;

		// Default
		{
			var defaultEditor = new DefaultEditor( plug );
		}

		return null;
	}

	public Menu CreateContextMenu( NodeUI node, NodePlug plug )
	{
		return null;
	}

	public void OnDoubleClick( NodeUI node, NodePlug plug, MouseEvent e )
	{

	}

	public bool ShowLabel => true;
	public bool AllowStretch => true;
	public bool ShowConnection => IsReachable;
	public bool InTitleBar => false;

	public bool IsReachable
	{
		get
		{
			var conditional = Info.Property?.GetCustomAttribute<ConditionalVisibilityAttribute>();
			if ( conditional is not null )
			{
				if ( conditional.TestCondition( Node.GetSerialized() ) ) return false;
			}

			return true;
		}
	}

	public string ErrorMessage => null;

	public override string ToString()
	{
		return $"{Node.Identifier}.{Identifier}";
	}

}

public record BasePlugIn( BaseNodePlus Node, PlugInfo Info, Type Type ) : BasePlug( Node, Info, Type ), IPlugIn
{
	IPlugOut IPlugIn.ConnectedOutput
	{
		get
		{
			if ( Info.Property is null )
			{
				return Info.ConnectedPlug;
			}

			if ( Info.Type != typeof( NodeInput ) )
			{
				return null;
			}

			var value = Info.GetInput( Node );

			if ( !value.IsValid )
			{
				return null;
			}


			var node = ((ShaderGraphPlus)Node.Graph).FindNode( value.Identifier );
			var output = node?.Outputs
				.FirstOrDefault( x => x.Identifier == value.Output );

			return output;
		}
		set
		{
			var property = Info.Property;
			if ( property is null )
			{
				Info.ConnectedPlug = value;
				return;
			}

			if ( property.PropertyType != typeof( NodeInput ) )
			{
				return;
			}

			if ( value is null )
			{
				property.SetValue( Node, default( NodeInput ) );
				return;
			}

			if ( value is not BasePlug fromPlug )
			{
				return;
			}

			property.SetValue( Node, new NodeInput
			{
				Identifier = fromPlug.Node.Identifier,
				Output = fromPlug.Identifier
			} );
		}
	}

	public float? GetHandleOffset( string name )
	{
		if ( Node.HandleOffsets.TryGetValue( name, out var value ) )
		{
			return value;
		}
		return null;
	}

	public void SetHandleOffset( string name, float? value )
	{
		if ( value is null ) Node.HandleOffsets.Remove( name );
		else Node.HandleOffsets[name] = value.Value;
	}
}

public record BasePlugOut( BaseNodePlus Node, PlugInfo Info, Type Type ) : BasePlug( Node, Info, Type ), IPlugOut;

public class PlugInfo
{
	public Guid Id { get; set; }
	public string Name { get; set; }
	public Type Type { get; set; }
	public DisplayInfo DisplayInfo { get; set; }
	public PropertyInfo Property { get; set; } = null;
	public IPlugOut ConnectedPlug { get; set; } = null;

	public PlugInfo()
	{
		DisplayInfo = new();
	}
	public PlugInfo( PropertyInfo property )
	{
		Name = property.Name;
		Type = property.PropertyType;
		var info = DisplayInfo.ForMember( Type );
		info.Name = property.Name;
		var titleAttr = property.GetCustomAttribute<TitleAttribute>();
		if ( titleAttr is not null )
		{
			info.Name = titleAttr.Value;
		}
		var descriptionAttr = property.GetCustomAttribute<DescriptionAttribute>();
		if ( descriptionAttr is not null )
		{
			info.Description = descriptionAttr.Value;
		}

		DisplayInfo = info;
		Property = property;
	}

	public NodeInput GetInput( BaseNodePlus node )
	{
		if ( Property is not null )
		{
			return (NodeInput)Property.GetValue( node )!;
		}

		return default;
	}

	public ValueEditor CreateEditor( NodeUI node, NodePlug plug, Type type )
	{
		var editor = Property?.GetCustomAttribute<BaseNodePlus.NodeValueEditorAttribute>();

		if ( editor is not null )
		{
			if ( type == typeof( int ) )
			{
				var slider = new IntValueEditor( plug ) { Title = DisplayInfo.Name, Node = node };
				slider.Bind( "Value" ).From( node.Node, editor.ValueName );

				var range = Property.GetCustomAttribute<BaseNodePlus.RangeAttribute>();
				if ( range != null )
				{
					slider.Bind( "Min" ).From( node.Node, range.Min );
					slider.Bind( "Max" ).From( node.Node, range.Max );
				}
				else if ( Property.GetCustomAttribute<MinMaxAttribute>() is MinMaxAttribute minMax )
				{
					slider.Min = (int)minMax.MinValue;
					slider.Max = (int)minMax.MaxValue;
				}

				return slider;
			}

			if ( type == typeof( float ) )
			{
				var slider = new FloatValueEditor( plug ) { Title = DisplayInfo.Name, Node = node };
				slider.Bind( "Value" ).From( node.Node, editor.ValueName );

				var range = Property.GetCustomAttribute<BaseNodePlus.RangeAttribute>();
				if ( range != null )
				{
					slider.Bind( "Min" ).From( node.Node, range.Min );
					slider.Bind( "Max" ).From( node.Node, range.Max );
					slider.Bind( "Step" ).From( node.Node, range.Step );
				}
				else if ( Property.GetCustomAttribute<MinMaxAttribute>() is MinMaxAttribute minMax )
				{
					slider.Min = minMax.MinValue;
					slider.Max = minMax.MaxValue;
				}

				return slider;
			}

			if ( type == typeof( Color ) )
			{
				var slider = new ColorValueEditor( plug ) { Title = DisplayInfo.Name, Node = node };
				slider.Bind( "Value" ).From( node.Node, editor.ValueName );

				return slider;
			}

			if ( type == typeof( Gradient ) )
			{
				var slider = new GradientValueEditor( plug ) { Title = DisplayInfo.Name, Node = node };
				slider.Bind( "Value" ).From( node.Node, editor.ValueName );

				return slider;
			}
		}
		return null;
	}
}

namespace ShaderGraphPlus.Nodes;

/// <summary>
///
/// </summary>
[Title( "Pixel Plot" ), Category( "Effects" ), Icon( "grid_on" )]
public sealed class PixelPlotNode : ShaderNodePlus
{
	[JsonIgnore, Hide, Browsable( false )]
	public override Color NodeTitleColor => ShaderGraphPlusTheme.NodeHeaderColors.FunctionNode;

	[Hide]
	public string PixelPlot => @"	
float4 PixelPlot( in Texture2D vColorTex, in SamplerState sSampler, float2 vUv , float2 vGridSize , float flBoarderThickness)
{
	float2 vGridBlock = 1.0f / vGridSize;
	float2 vUvGrid = floor( vUv * vGridSize ) / vGridSize; // Divide By Gridsize so that uvspace is clamped to  0 to 1.
	float2 vGridBoarder = step( 0.5f - flBoarderThickness, frac( vUv / vGridBlock ) ) *
						 step( frac( vUv / vGridBlock ), 0.5f + flBoarderThickness );

	return vColorTex.Sample( sSampler, vUvGrid ) * ( vGridBoarder.x * vGridBoarder.y );
}
";

	/// <summary>
	/// Texture object to apply the effect to.
	/// </summary>
	[Title( "Texture2D" )]
	[Input( typeof( Texture ) )]
	[Hide]
	public NodeInput Texture2D { get; set; }

	/// <summary>
	/// Coordinates to sample this texture
	/// </summary>
	[Title( "Coordinates" )]
	[Input( typeof( Vector2 ) )]
	[Hide]
	public NodeInput Coords { get; set; }

	/// <summary>
	/// How the effect is filtered and wrapped when sampled
	/// </summary>
	[Title( "Sampler" )]
	[Input( typeof( Sampler ) )]
	[Hide]
	public NodeInput Sampler { get; set; }

	[Input( typeof( Vector2 ) )]
	[Hide]
	public NodeInput GridSize { get; set; }

	[Input( typeof( float ) )]
	[Hide]
	public NodeInput BoarderThickness { get; set; }

	[InlineEditor( Label = false ), Group( "Sampler" )]
	[ShowIf( nameof( ShowDefaultSamplerState ), true )]
	public Sampler SamplerState { get; set; } = new Sampler();

	[InputDefault( nameof( GridSize ) )]
	public Vector2 DefaultGridSize { get; set; } = new Vector2( 24.0f, 24.0f );

	[InputDefault( nameof( BoarderThickness ) )]
	public float DefaultBoarderThickness { get; set; } = 0.420f;

	[JsonIgnore, Hide, Browsable( false )]
	private bool ShowDefaultSamplerState { get; set; } = false;

	[Output( typeof( Vector4 ) ), Title( "Result" )]
	[Hide]
	public NodeResult.Func Result => ( GraphCompiler compiler ) =>
	{
		var coordsResult = compiler.Result( Coords );
		var textureResult = compiler.Result( Texture2D );
		var samplerResult = compiler.ResultSamplerOrDefault( Sampler, SamplerState );
		var gridResult = compiler.ResultOrDefault( GridSize, DefaultGridSize );
		var boarderThicknessResult = compiler.ResultOrDefault( BoarderThickness, DefaultBoarderThickness );

		ShowDefaultSamplerState = !Sampler.IsValid;

		if ( !textureResult.IsValid )
		{
			return NodeResult.MissingInput( "Texture2D" );
		}
		else if ( textureResult.ResultType is not ResultType.Texture2D )
		{
			return NodeResult.Error( $"Input to TexObject is not a texture object!" );
		}

		string func = compiler.RegisterHLSLFunction( PixelPlot, "PixelPlot" );
		string funcCall = compiler.ResultHLSLFunction( func, $"{textureResult}, {samplerResult}, {(coordsResult.IsValid ? $"{coordsResult.Cast( 2 )}" : "i.vTextureCoords.xy")}, {gridResult}, {boarderThicknessResult}" );

		return new NodeResult( ResultType.Vector4, funcCall );
	};
}

using Facepunch.ActionGraphs;

namespace ShaderGraphPlus.Nodes;

/// <summary>
/// Get the dimensions of a Texture2D.
/// </summary>
[Title( "Get Texture2D Dimensions" ), Category( "Textures" ), Icon( "straighten" )]
public sealed class GetTexture2DDimensions : ShaderNodePlus
{
	[JsonIgnore, Hide, Browsable( false )]
	public override Color NodeTitleColor => ShaderGraphPlusTheme.NodeHeaderColors.FunctionNode;

	[Title( "Texture2D" )]
	[Input( typeof( Texture ) )]
	[Hide]
	public NodeInput TextureInput { get; set; }

	[Title( "Mip Level" )]
	[Input( typeof( int ) )]
	[Hide]
	public NodeInput MipLevelInput { get; set; }

	[JsonIgnore, Hide]
	public override bool CanPreview => false;

	[InputDefault( nameof( MipLevelInput ) )]
	public int DefaultMipLevel { get; set; } = 0;

	public GetTexture2DDimensions()
	{
		ExpandSize = new Vector2( 8, 0 );
	}

	[Output( typeof( Vector2 ) )]
	[Title( "Size" )]
	[Hide]
	public NodeResult.Func Result => ( GraphCompiler compiler ) =>
	{
		ClearError();

		var textureResult = compiler.Result( TextureInput );
		if ( !textureResult.IsValid )
		{
			return NodeResult.MissingInput( "Texture2D" );
		}
		else if ( textureResult.ResultType != ResultType.Texture2D )
		{
			return NodeResult.IncorrectInputType( "Texture2D", ResultType.Texture2D );
		}

		var miplevelResult = compiler.ResultOrDefault( MipLevelInput, DefaultMipLevel );

		return new NodeResult( ResultType.Vector2, $"TextureDimensions2D({textureResult}, {miplevelResult})", constant: false );
	};
}