Code/Runtime/WeaponAnimationValidation.cs

Validation logic for weapon animations. It defines severity and issue/report types, checks calibration, materials, visibility parts, clips and rig rules, and produces a ValidationReport with errors and warnings.

Reflection
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Linq;

namespace SboxWeaponAnimator;

public enum ValidationSeverity
{
	Info,
	Warning,
	Error
}

public sealed class ValidationIssue
{
	public ValidationSeverity Severity { get; init; }
	public string Code { get; init; } = "";
	public string Message { get; init; } = "";
	public string Context { get; init; } = "";
	public bool Blocking => Severity == ValidationSeverity.Error;
}

public sealed class ValidationReport
{
	public List<ValidationIssue> Issues { get; } = [];
	public bool IsValid => Issues.All( x => !x.Blocking );
	public int ErrorCount => Issues.Count( x => x.Severity == ValidationSeverity.Error );
	public int WarningCount => Issues.Count( x => x.Severity == ValidationSeverity.Warning );

	public void Add( ValidationSeverity severity, string code, string message, string context = "" )
	{
		Issues.Add( new ValidationIssue
		{
			Severity = severity,
			Code = code,
			Message = message,
			Context = context
		} );
	}
}

public static class WeaponSourceFormatSupport
{
	public static bool CanGenerate( string path ) =>
		path.EndsWith( ".fbx", StringComparison.OrdinalIgnoreCase )
		|| path.EndsWith( ".dmx", StringComparison.OrdinalIgnoreCase )
		|| path.EndsWith( ".obj", StringComparison.OrdinalIgnoreCase )
		|| path.EndsWith( ".vmdl", StringComparison.OrdinalIgnoreCase );
}

public static class WeaponAnimationValidator
{
	private static readonly HashSet<string> ReservedArmBones = new( StringComparer.OrdinalIgnoreCase )
	{
		"root",
		"camera",
		"weapon_root",
		"hold_R",
		"hold_L",
		"hand_R",
		"hand_L",
		"wrist_R",
		"wrist_L",
		"elbow_R",
		"elbow_L",
		"arm_upper_R",
		"arm_upper_L",
		"clavicle_R",
		"clavicle_L"
	};

	public static ValidationReport ValidateCalibration( WeaponAnimationDocument document )
	{
		var report = new ValidationReport();

		if ( string.IsNullOrWhiteSpace( document.Source.SourcePath ) )
			report.Add( ValidationSeverity.Error, "source.missing", "Choose a rigged source model." );
		else if ( !document.Source.Compiled )
			report.Add( ValidationSeverity.Error, "source.not_compiled", "The source model has not compiled successfully." );

		if ( document.Source.Compiled && !document.Source.PreviewHostCompiled )
			report.Add( ValidationSeverity.Error, "host.not_compiled", "The generated preview animation host has not compiled and reloaded." );

		if ( document.Rig.Bones.Count == 0 )
			report.Add( ValidationSeverity.Error, "rig.empty", "The source model has no readable skeleton." );

		var roots = document.Rig.Bones.Count( x => x.Classification == WeaponBoneClassification.WeaponRoot );
		if ( roots != 1 || string.IsNullOrWhiteSpace( document.Rig.RootBone ) )
			report.Add( ValidationSeverity.Error, "rig.root", "Classify exactly one weapon root." );
		if ( document.Rig.ReviewRequired || !document.Rig.FilteredPreviewConfirmed )
		{
			report.Add(
				ValidationSeverity.Error,
				"rig.review_required",
				"Review the weapon subtree and confirm that the filtered preview contains only weapon bones." );
		}

		if ( !WeaponAnimationMath.IsFinite( document.Calibration.UniformScale )
			|| document.Calibration.UniformScale <= 0 )
		{
			report.Add( ValidationSeverity.Error, "scale.invalid", "Uniform scale must be finite and greater than zero." );
		}
		else
		{
			var physicalScale = document.Calibration.PhysicalTransform.Scale;
			var dimensions = document.Source.OriginalModelDimensions * new Vector3(
				MathF.Abs( physicalScale.x ),
				MathF.Abs( physicalScale.y ),
				MathF.Abs( physicalScale.z ) );
			var longest = MathF.Max( dimensions.x, MathF.Max( dimensions.y, dimensions.z ) );
			if ( longest > 0 && (longest < 2.0f || longest > 80.0f) )
			{
				report.Add(
					ValidationSeverity.Warning,
					"scale.implausible",
					$"The calibrated weapon is {longest:0.##} inches on its longest axis, which is unusual relative to the reference arms." );
			}
		}

		RequireAnchor( document, report, AnchorKind.Grip, "Choose a primary-hand grip anchor." );

		if ( document.Rig.AuditIssues.Any( x => x.Severity == ValidationSeverity.Error ) )
			report.Add( ValidationSeverity.Error, "rig.conflicts", "Resolve the blocking skeleton audit issues." );

		if ( document.Source.Compiled
			&& !string.IsNullOrWhiteSpace( document.Rig.RootBone )
			&& document.Rig.Bones.Count > 0 )
		{
			var duplicateNames = document.Rig.Bones
				.GroupBy( x => x.Name, StringComparer.OrdinalIgnoreCase )
				.Where( x => x.Count() > 1 )
				.Select( x => x.Key );

			foreach ( var duplicate in duplicateNames )
				report.Add( ValidationSeverity.Error, "rig.duplicate", $"Duplicate bone '{duplicate}'.", duplicate );

				foreach ( var bone in document.Rig.RetainedBones() )
				{
					if ( ReservedArmBones.Contains( bone.Name )
						&& !bone.Id.Equals(
							document.Rig.SourceSkeletonRootId,
							StringComparison.OrdinalIgnoreCase ) )
					{
					report.Add(
						ValidationSeverity.Error,
						"rig.reserved_name",
						$"Weapon bone '{bone.Name}' conflicts with the Facepunch arm skeleton.",
						bone.Name );
				}
			}
		}

		return report;
	}

	public static ValidationReport ValidateForGeneration( WeaponAnimationDocument document )
	{
		var report = ValidateCalibration( document );
		if ( !string.IsNullOrWhiteSpace( document.Source.SourcePath )
			&& !WeaponSourceFormatSupport.CanGenerate( document.Source.SourcePath ) )
		{
			var format = SourceFormatName( document.Source.SourcePath );
			report.Add(
				ValidationSeverity.Error,
				"source.not_embeddable",
				$"The imported {format} can be audited and animated, but S&box ModelDoc cannot embed "
					+ $"{format} render geometry in the generated viewmodel. Re-export the source as "
					+ "FBX or DMX before generating." );
		}
		ValidateMaterials( document, report );
		ValidateVisibilityParts( document, report );

		if ( !document.Calibration.Confirmed || document.Calibration.Snapshot is null )
			report.Add( ValidationSeverity.Error, "calibration.unconfirmed", "Confirm calibration before generating." );

		var idle = document.Clips.FirstOrDefault( x => x.Role == WeaponClipRole.Idle );
		if ( idle is null || idle.Readiness is ClipReadiness.NotStarted or ClipReadiness.Warning )
			report.Add( ValidationSeverity.Error, "clip.idle", "Idle must be authored and marked Draft or Ready." );

		foreach ( var clip in document.Clips )
		{
			if ( clip.Duration <= 0 || !WeaponAnimationMath.IsFinite( clip.Duration ) )
				report.Add( ValidationSeverity.Error, "clip.duration", $"{clip.Name} has an invalid duration.", clip.Name );

			if ( clip.SampleRate <= 0 || !WeaponAnimationMath.IsFinite( clip.SampleRate ) )
				report.Add( ValidationSeverity.Error, "clip.sample_rate", $"{clip.Name} has an invalid sample rate.", clip.Name );

			foreach ( var track in clip.Tracks )
			{
				foreach ( var key in track.Keys )
				{
					if ( key.Time < 0 || key.Time > clip.Duration + 0.0001f )
						report.Add(
							ValidationSeverity.Warning,
							"key.outside_clip",
							$"{clip.Name}/{track.Target} has a key outside the clip duration.",
							track.Target );
				}

				var keyIds = track.Keys.Select( x => x.Id ).ToHashSet();
				foreach ( var span in track.CurveSpans )
				{
					if ( !keyIds.Contains( span.StartKeyId ) || !keyIds.Contains( span.EndKeyId ) )
					{
						report.Add(
							ValidationSeverity.Warning,
							"curve.span_orphan",
							$"{clip.Name}/{track.Target} contains curve data for missing keys; it will be ignored.",
							track.Target );
						continue;
					}

					if ( span.HasSpeedCurve
						&& WeaponAnimationMath.MotionRateArea( span.Speed ) <= 0.0001f )
					{
						report.Add(
							ValidationSeverity.Warning,
							"curve.speed_invalid",
							$"{clip.Name}/{track.Target} has a zero-area speed curve and will use linear timing.",
							track.Target );
					}
				}
			}

			foreach ( var track in clip.VisibilityTracks )
			{
				if ( document.Rig.VisibilityParts.All( x => x.Id != track.PartId ) )
				{
					report.Add(
						ValidationSeverity.Error,
						"visibility.part_missing",
						$"{clip.Name} contains a visibility track for a missing part.",
						clip.Name );
				}

				foreach ( var key in track.Keys )
				{
					if ( key.Time < 0 || key.Time > clip.Duration + 0.0001f )
					{
						report.Add(
							ValidationSeverity.Warning,
							"visibility.key_outside_clip",
							$"{clip.Name} has a visibility key outside the clip duration.",
							clip.Name );
					}
				}
			}
		}

		foreach ( var role in WeaponAnimationDocument.StandardClips() )
		{
			if ( role == WeaponClipRole.Idle )
				continue;

			var clip = document.Clips.FirstOrDefault( x => x.Role == role );
			if ( clip is null || clip.Readiness == ClipReadiness.NotStarted )
			{
				report.Add(
					ValidationSeverity.Warning,
					"clip.fallback",
					$"{WeaponAnimationNames.DisplayName( role )} will use the validated Idle fallback." );
			}
		}

		if ( document.Graph.ReloadProfile == ReloadProfile.Incremental )
		{
			var required = new[]
			{
				WeaponClipRole.ReloadEnter,
				WeaponClipRole.FirstShell,
				WeaponClipRole.InsertShell,
				WeaponClipRole.ReloadExit
			};

			foreach ( var role in required )
			{
				var clip = document.Clips.FirstOrDefault( x => x.Role == role );
				if ( clip is null || clip.Readiness == ClipReadiness.NotStarted )
					report.Add(
						ValidationSeverity.Warning,
						"reload.incremental_fallback",
						$"{WeaponAnimationNames.DisplayName( role )} is missing; incremental reload will fall back to Idle." );
			}
		}

		return report;
	}

	private static string SourceFormatName( string path )
	{
		var separator = Math.Max( path.LastIndexOf( '/' ), path.LastIndexOf( '\\' ) );
		var dot = path.LastIndexOf( '.' );
		return dot > separator && dot + 1 < path.Length
			? path[(dot + 1)..].ToUpperInvariant()
			: "source format";
	}

	private static void ValidateMaterials(
		WeaponAnimationDocument document,
		ValidationReport report )
	{
		if ( document.Source.Materials.Count == 0
			&& document.Source.NeedsModelDocWrapper )
		{
			report.Add(
				ValidationSeverity.Warning,
				"material.none",
				"No imported material slots are recorded. Reimport the source to discover nearby textures." );
			return;
		}

		foreach ( var duplicate in document.Source.Materials
			.Where( material => !string.IsNullOrWhiteSpace( material.SourceMaterialPath ) )
			.GroupBy(
				material => material.SourceMaterialPath,
				StringComparer.OrdinalIgnoreCase )
			.Where( group => group.Count() > 1 ) )
		{
			report.Add(
				ValidationSeverity.Error,
				"material.duplicate_slot",
				$"Source material slot '{duplicate.Key}' is mapped more than once.",
				duplicate.Key );
		}

		foreach ( var duplicate in document.Source.Materials
			.Where( material => !string.IsNullOrWhiteSpace( material.OutputName ) )
			.GroupBy( material => material.OutputName, StringComparer.OrdinalIgnoreCase )
			.Where( group => group.Count() > 1 ) )
		{
			report.Add(
				ValidationSeverity.Error,
				"material.duplicate_output",
				$"Generated material name '{duplicate.Key}' is not unique.",
				duplicate.Key );
		}

		foreach ( var material in document.Source.Materials )
		{
			if ( string.IsNullOrWhiteSpace( material.SourceMaterialPath ) )
			{
				report.Add(
					ValidationSeverity.Error,
					"material.slot_missing",
					$"Material '{material.Name}' has no source slot name.",
					material.Name );
			}
			if ( material.FindTexture( WeaponTextureChannel.PackedOrm ) is not null
				&& material.FindTexture( WeaponTextureChannel.Roughness ) is null
				&& material.FindTexture( WeaponTextureChannel.Metalness ) is null )
			{
				report.Add(
					ValidationSeverity.Warning,
					"material.packed_orm",
					$"{material.Name} uses a packed ORM map that cannot be assigned automatically.",
					material.Name );
			}
		}
	}

	private static void ValidateVisibilityParts(
		WeaponAnimationDocument document,
		ValidationReport report )
	{
		foreach ( var duplicate in document.Rig.VisibilityParts
			.GroupBy( x => x.Id )
			.Where( x => x.Count() > 1 ) )
		{
			report.Add(
				ValidationSeverity.Error,
				"visibility.duplicate_id",
				$"Visibility part ID '{duplicate.Key}' is duplicated." );
		}

		foreach ( var duplicate in document.Rig.VisibilityParts
			.Where( x => !string.IsNullOrWhiteSpace( x.Name ) )
			.GroupBy( x => x.Name, StringComparer.OrdinalIgnoreCase )
			.Where( x => x.Count() > 1 ) )
		{
			report.Add(
				ValidationSeverity.Warning,
				"visibility.duplicate_name",
				$"Multiple visibility parts are named '{duplicate.Key}'." );
		}

		foreach ( var part in document.Rig.VisibilityParts )
		{
			if ( string.IsNullOrWhiteSpace( part.Name ) )
				report.Add(
					ValidationSeverity.Error,
					"visibility.name_missing",
					"A visibility part needs a display name." );

			if ( part.RenderMode == VisibilityRenderMode.BodyGroup )
			{
				report.Add(
					ValidationSeverity.Error,
					"visibility.bodygroup_export",
					$"{part.Name} uses bodygroup visibility, which cannot be baked into a standard generated viewmodel yet.",
					part.Name );
				if ( string.IsNullOrWhiteSpace( part.BodyGroupName ) )
					report.Add(
						ValidationSeverity.Error,
						"visibility.bodygroup_missing",
						$"{part.Name} needs a bodygroup name.",
						part.Name );
				if ( part.VisibleBodyGroupValue == part.HiddenBodyGroupValue )
					report.Add(
						ValidationSeverity.Error,
						"visibility.bodygroup_values",
						$"{part.Name}'s visible and hidden bodygroup values must differ.",
						part.Name );
				continue;
			}

			var bone = document.Rig.RetainedBones().FirstOrDefault( x =>
				x.Id.Equals( part.BoneId, StringComparison.OrdinalIgnoreCase )
				|| x.Name.Equals( part.BoneName, StringComparison.OrdinalIgnoreCase ) );
			if ( bone is null )
			{
				report.Add(
					ValidationSeverity.Error,
					"visibility.bone_missing",
					$"{part.Name} references a missing or excluded weapon bone.",
					part.BoneName );
			}
		}
	}

	private static void RequireAnchor(
		WeaponAnimationDocument document,
		ValidationReport report,
		AnchorKind kind,
		string message )
	{
		var anchor = document.Calibration.GetAnchor( kind );
		if ( anchor is null || !WeaponAnimationMath.IsFinite( anchor.LocalPosition ) )
			report.Add( ValidationSeverity.Error, $"anchor.{kind.ToString().ToLowerInvariant()}", message );
	}
}