Editor tool utilities for authoring assets. Provides commands to create .vmat materials, write texture files from base64, create .sound event JSON, and play sounds (global or at a world position) in the editor.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using Editor;
using Sandbox;
using SboxMcp.Registry;
using static SboxMcp.Tools.AssetTools;
namespace SboxMcp.Tools;
/// <summary>
/// Material and sound-event authoring.
/// </summary>
public static class ContentTools
{
[McpTool( "material_create", "Creates a .vmat material using the standard complex shader, with optional texture maps. Edit further with asset_read_raw / asset_write_raw.", ToolCategory.Asset, Writes = true )]
public static object MaterialCreate(
[Desc( "Output path ending in .vmat, e.g. 'materials/crate.vmat'" )] string path,
[Desc( "Color/albedo texture asset path (png/jpg/tga)" )] string colorTexture = null,
[Desc( "Normal map texture path" )] string normalTexture = null,
[Desc( "Roughness texture path" )] string roughnessTexture = null,
[Desc( "Metalness texture path" )] string metalnessTexture = null )
{
if ( !path.EndsWith( ".vmat", StringComparison.OrdinalIgnoreCase ) )
throw new ArgumentException( "path must end in .vmat" );
var sb = new StringBuilder();
sb.AppendLine( "\"Layer0\"" );
sb.AppendLine( "{" );
sb.AppendLine( "\t\"shader\"\t\t\"shaders/complex.shader_c\"" );
void Slot( string key, string texture )
{
if ( !string.IsNullOrWhiteSpace( texture ) )
sb.AppendLine( $"\t\"{key}\"\t\t\"{texture}\"" );
}
Slot( "TextureColor", colorTexture );
Slot( "TextureNormal", normalTexture );
Slot( "TextureRoughness", roughnessTexture );
Slot( "TextureMetalness", metalnessTexture );
if ( !string.IsNullOrWhiteSpace( metalnessTexture ) )
sb.AppendLine( "\t\"F_METALNESS_TEXTURE\"\t\t\"1\"" );
sb.AppendLine( "}" );
return AssetTools.WriteRaw( path, sb.ToString() );
}
[McpTool( "sound_play", "Plays a sound event in the editor to preview it.", ToolCategory.Asset )]
public static object SoundPlay( [Desc( "Sound event asset path, e.g. 'sounds/jump.sound'" )] string soundEvent )
{
if ( AssetSystem.FindByPath( soundEvent ) is null )
throw new InvalidOperationException( $"No sound event at '{soundEvent}' - use asset_search with assetType 'sound'" );
Sound.Play( soundEvent );
return new { played = soundEvent };
}
[McpTool( "sound_play_at", "Plays a sound event at a 3D world position (spatialized) to preview positional audio.", ToolCategory.Asset )]
public static object SoundPlayAt(
[Desc( "Sound event asset path" )] string soundEvent,
[Desc( "World position [x, y, z]" )] float[] position )
{
if ( AssetSystem.FindByPath( soundEvent ) is null )
throw new InvalidOperationException( $"No sound event at '{soundEvent}' - use asset_search with assetType 'sound'" );
if ( position is null || position.Length != 3 )
throw new ArgumentException( "'position' must be [x, y, z]" );
Sound.Play( soundEvent, new Vector3( position[0], position[1], position[2] ), 0f );
return new { played = soundEvent, at = position };
}
[McpTool( "texture_write", "Saves base64-encoded image bytes (png/jpg/tga) as a texture asset - pipe in generated or downloaded images, then use them in material_create.", ToolCategory.Asset, Writes = true )]
public static object TextureWrite(
[Desc( "Output path ending in .png/.jpg/.tga, e.g. 'textures/crate_color.png'" )] string path,
[Desc( "The image file's bytes, base64-encoded" )] string base64Data )
{
var extension = Path.GetExtension( path ).ToLowerInvariant();
if ( extension is not (".png" or ".jpg" or ".jpeg" or ".tga") )
throw new ArgumentException( "path must end in .png, .jpg or .tga" );
byte[] bytes;
try
{
bytes = Convert.FromBase64String( base64Data );
}
catch ( FormatException )
{
throw new ArgumentException( "base64Data is not valid base64" );
}
var absolute = ResolveNewAssetPath( path );
Directory.CreateDirectory( Path.GetDirectoryName( absolute ) );
File.WriteAllBytes( absolute, bytes );
var asset = AssetSystem.RegisterFile( absolute );
asset?.Compile( true );
return new { written = path, bytes = bytes.Length, registered = asset is not null };
}
[McpTool( "soundevent_create", "Creates a .sound event resource referencing sound files (wav/mp3/ogg), playable via SoundEvent components or Sound.Play.", ToolCategory.Asset, Writes = true )]
public static object SoundEventCreate(
[Desc( "Output path ending in .sound, e.g. 'sounds/jump.sound'" )] string path,
[Desc( "Sound file asset paths; one is picked at random when playing" )] string[] soundFiles,
[Desc( "Volume 0-1" )] float volume = 1f,
[Desc( "Pitch multiplier" )] float pitch = 1f,
[Desc( "2D UI sound (no spatialization)" )] bool ui = false )
{
if ( !path.EndsWith( ".sound", StringComparison.OrdinalIgnoreCase ) )
throw new ArgumentException( "path must end in .sound" );
if ( soundFiles is null || soundFiles.Length == 0 )
throw new ArgumentException( "Pass at least one sound file path" );
var missing = soundFiles.Where( s => AssetSystem.FindByPath( s ) is null ).ToArray();
if ( missing.Length > 0 )
throw new InvalidOperationException( $"Sound file(s) not in the project: {string.Join( ", ", missing )} - use asset_search or cloud_install first" );
var json = JsonSerializer.Serialize( new Dictionary<string, object>
{
["Sounds"] = soundFiles,
["UI"] = ui,
["Volume"] = volume,
["Pitch"] = pitch
}, new JsonSerializerOptions { WriteIndented = true } );
return AssetTools.WriteRaw( path, json );
}
}