OneMoreRoundWeapon.Validation.cs
using Sandbox;
using System;
/// <summary>
/// Editor/runtime diagnostics for weapon prefab authoring.
///
/// This deliberately validates structure, not balance. A weird damage value may
/// be intentional; a missing muzzle attachment while a muzzle effect is enabled
/// is almost certainly not.
/// </summary>
public partial class OneMoreRoundWeapon
{
[Button, Title( "Validate Weapon" ), Description( "Validate OMR metadata, mechanics configuration, and view/world-model presentation references." )]
public void ValidateWeaponSetup()
{
int errors = 0;
int warnings = 0;
ValidateWeaponSetupInternal(
ref errors,
ref warnings,
GameObject?.Name ?? "Weapon"
);
if ( errors == 0 )
{
Log.Info(
$"WEAPON VALIDATION | PASS | {DisplayName} | ID:{WeaponId} | Class:{WeaponArchetype} | Warnings:{warnings}"
);
}
else
{
Log.Error(
$"WEAPON VALIDATION | FAIL | {DisplayName} | Errors:{errors} | Warnings:{warnings}"
);
}
}
internal void ValidateWeaponSetupInternal(
ref int errors,
ref int warnings,
string context
)
{
string prefix =
$"WEAPON VALIDATION | {context}";
if ( UsesWeaponDefinition )
{
ValidateDefinition(
Definition,
prefix,
ref errors,
ref warnings
);
}
else
{
warnings++;
Log.Warning(
$"{prefix} | No OMRWeaponDefinition assigned. Legacy prefab-authored values are still supported during migration, but this weapon is not yet using the canonical data pipeline."
);
}
if (
string.IsNullOrWhiteSpace(
WeaponId
)
)
{
errors++;
Log.Error(
$"{prefix} | WeaponId is empty."
);
}
if (
WeaponArchetype ==
OMRWeaponArchetype.Unknown
)
{
warnings++;
Log.Warning(
$"{prefix} | WeaponArchetype is Unknown."
);
}
if (
string.IsNullOrWhiteSpace(
DisplayName
)
)
{
warnings++;
Log.Warning(
$"{prefix} | DisplayName is empty."
);
}
if ( Ballistics.Damage <= 0f )
{
warnings++;
Log.Warning(
$"{prefix} | Ballistics.Damage is {Ballistics.Damage}; verify this is intentional."
);
}
if ( Ballistics.Pellets <= 0 )
{
errors++;
Log.Error(
$"{prefix} | Ballistics.Pellets must be at least 1."
);
}
if ( Ballistics.Range <= 0f )
{
errors++;
Log.Error(
$"{prefix} | Ballistics.Range must be greater than 0."
);
}
if (
MovementAccuracyModel == OMRMovementAccuracyModel.ContinuousSpeed &&
MovementAccuracyFullFraction < MovementAccuracyStartFraction
)
{
errors++;
Log.Error(
$"{prefix} | Continuous movement accuracy Full Fraction must be >= Start Fraction."
);
}
bool hasPatternAsset = RecoilPatternAsset is not null && RecoilPatternAsset.IsValid;
bool hasLegacyPattern = RecoilPattern is not null && RecoilPattern.Count > 0;
if (
RecoilModel == OMRGameplayRecoilModel.Pattern &&
!hasPatternAsset &&
!hasLegacyPattern
)
{
errors++;
Log.Error(
$"{prefix} | RecoilModel is Pattern but neither RecoilPatternAsset nor Legacy Pattern Steps are authored."
);
}
if ( hasPatternAsset )
{
ValidateRecoilPatternAsset( RecoilPatternAsset, prefix, ref errors, ref warnings );
if ( hasLegacyPattern )
{
warnings++;
Log.Warning(
$"{prefix} | Both RecoilPatternAsset and Legacy Pattern Steps are authored. The .omrcoil asset takes priority."
);
}
}
if (
RecoilRecoveryEnabled &&
RecoilRecoveryFraction <= 0f
)
{
warnings++;
Log.Warning(
$"{prefix} | Recoil recovery is enabled but Recovery Fraction is 0, so no gameplay recoil can return."
);
}
if (
RecoilRecoveryEnabled &&
RecoilRecoveryPitchSpeed <= 0f &&
RecoilRecoveryYawSpeed <= 0f
)
{
warnings++;
Log.Warning(
$"{prefix} | Recoil recovery is enabled but both recovery speeds are 0."
);
}
if (
RecoilPitch.x > 0.001f ||
RecoilPitch.y > 0.001f
)
{
warnings++;
Log.Warning(
$"{prefix} | RecoilPitch contains a positive value ({RecoilPitch}). OMR uses negative pitch for upward gameplay recoil; verify this is intentional."
);
}
ValidateOpticProfile( OpticProfile, prefix, ref errors, ref warnings );
ValidateCycleProfile( CycleProfile, PrimaryAutomatic, prefix, ref errors, ref warnings );
ValidateWeaponModelPrefab(
ViewModelPrefab,
"ViewModel",
prefix,
ref errors,
ref warnings
);
ValidateWeaponModelPrefab(
WorldModelPrefab,
"WorldModel",
prefix,
ref errors,
ref warnings
);
if ( HasCycleProfile )
{
ValidateCyclePresentationAdapter( ViewModelPrefab, "ViewModel", prefix, ref warnings );
ValidateCyclePresentationAdapter( WorldModelPrefab, "WorldModel", prefix, ref warnings );
}
}
private static void ValidateDefinition(
OMRWeaponDefinition definition,
string prefix,
ref int errors,
ref int warnings
)
{
if ( definition is null || !definition.IsValid )
{
errors++;
Log.Error( $"{prefix} | Assigned weapon definition is invalid." );
return;
}
if ( string.IsNullOrWhiteSpace( definition.WeaponId ) )
{
errors++;
Log.Error( $"{prefix} | Definition WeaponId is empty." );
}
if ( definition.Archetype == OMRWeaponArchetype.Unknown )
{
warnings++;
Log.Warning( $"{prefix} | Definition Archetype is Unknown." );
}
if ( definition.Damage <= 0f )
{
warnings++;
Log.Warning( $"{prefix} | Definition Damage is {definition.Damage}; verify this is intentional." );
}
if ( definition.Pellets < 1 )
{
errors++;
Log.Error( $"{prefix} | Definition Pellets must be at least 1." );
}
if ( definition.PrimaryDelay <= 0f )
{
errors++;
Log.Error( $"{prefix} | Definition Fire Interval must be greater than 0." );
}
if ( definition.ClipMaxSize < 1 )
{
errors++;
Log.Error( $"{prefix} | Definition Magazine Size must be at least 1." );
}
if ( definition.Range <= 0f )
{
errors++;
Log.Error( $"{prefix} | Definition Range must be greater than 0." );
}
if (
definition.MovementAccuracyModel == OMRMovementAccuracyModel.ContinuousSpeed &&
definition.MovementAccuracyFullFraction < definition.MovementAccuracyStartFraction
)
{
errors++;
Log.Error( $"{prefix} | Definition Continuous Accuracy Full Fraction must be >= Start Fraction." );
}
bool hasPatternAsset = definition.RecoilPatternAsset is not null && definition.RecoilPatternAsset.IsValid;
bool hasLegacyPattern = definition.RecoilPattern is not null && definition.RecoilPattern.Count > 0;
if (
definition.RecoilModel == OMRGameplayRecoilModel.Pattern &&
!hasPatternAsset &&
!hasLegacyPattern
)
{
errors++;
Log.Error( $"{prefix} | Definition uses Pattern recoil but neither RecoilPatternAsset nor Legacy Pattern Steps are authored." );
}
if ( hasPatternAsset )
{
ValidateRecoilPatternAsset( definition.RecoilPatternAsset, prefix, ref errors, ref warnings );
if ( hasLegacyPattern )
{
warnings++;
Log.Warning( $"{prefix} | Definition has both RecoilPatternAsset and Legacy Pattern Steps. The .omrcoil asset takes priority." );
}
}
if (
definition.RecoilRecoveryEnabled &&
definition.RecoilRecoveryFraction <= 0f
)
{
warnings++;
Log.Warning( $"{prefix} | Definition enables recoil recovery but Recovery Fraction is 0." );
}
if (
definition.RecoilRecoveryEnabled &&
definition.RecoilRecoveryPitchSpeed <= 0f &&
definition.RecoilRecoveryYawSpeed <= 0f
)
{
warnings++;
Log.Warning( $"{prefix} | Definition enables recoil recovery but both recovery speeds are 0." );
}
ValidateDefinitionPresentation( definition, prefix, ref errors, ref warnings );
ValidateOpticProfile( definition.OpticProfile, prefix, ref errors, ref warnings );
ValidateCycleProfile( definition.CycleProfile, definition.PrimaryAutomatic, prefix, ref errors, ref warnings );
}
private static void ValidateDefinitionPresentation(
OMRWeaponDefinition definition,
string prefix,
ref int errors,
ref int warnings
)
{
bool hasViewModel = IsValidDefinitionPrefab( definition.ViewModelPrefab );
bool hasWorldModel = IsValidDefinitionPrefab( definition.WorldModelPrefab );
if ( !hasViewModel )
{
warnings++;
Log.Warning( $"{prefix} | Definition has no valid ViewModelPrefab." );
}
if ( !hasWorldModel )
{
warnings++;
Log.Warning( $"{prefix} | Definition has no valid WorldModelPrefab." );
}
if ( definition.AttackSound is null )
{
warnings++;
Log.Warning( $"{prefix} | Definition has no AttackSound." );
}
if ( definition.DryFireSound is null )
{
warnings++;
Log.Warning( $"{prefix} | Definition has no DryFireSound." );
}
ValidateDefinitionCycleAudio( definition, prefix, ref warnings );
if ( definition.ReloadSoundCues is null )
return;
for ( int i = 0; i < definition.ReloadSoundCues.Count; i++ )
{
OMRReloadSoundCue cue = definition.ReloadSoundCues[i];
if ( cue.Time < 0f )
{
warnings++;
Log.Warning( $"{prefix} | Reload audio cue {i} has a negative time ({cue.Time:0.###}s). Runtime clamps it to 0." );
}
if ( cue.Sound is null )
{
warnings++;
Log.Warning( $"{prefix} | Reload audio cue {i} has no SoundEvent." );
}
if ( definition.ReloadTime > 0f && cue.Time > definition.ReloadTime + 0.05f )
{
warnings++;
Log.Warning( $"{prefix} | Reload audio cue {i} is scheduled at {cue.Time:0.###}s, after ReloadTime {definition.ReloadTime:0.###}s, so it may be cancelled before playback." );
}
}
}
private static void ValidateDefinitionCycleAudio(
OMRWeaponDefinition definition,
string prefix,
ref int warnings
)
{
if ( definition.CycleSoundCues is null || definition.CycleSoundCues.Count == 0 )
return;
if ( definition.CycleProfile is null )
{
warnings++;
Log.Warning( $"{prefix} | Definition has CycleSoundCues but no CycleProfile." );
}
float duration = definition.CycleProfile is null
? 0f
: MathF.Max( definition.CycleProfile.Duration, 0f );
for ( int i = 0; i < definition.CycleSoundCues.Count; i++ )
{
OMRCycleSoundCue cue = definition.CycleSoundCues[i];
if ( cue.Sound is null )
{
warnings++;
Log.Warning( $"{prefix} | Cycle audio cue {i} has no SoundEvent." );
}
if ( cue.Time < 0f )
{
warnings++;
Log.Warning( $"{prefix} | Cycle audio cue {i} has a negative time ({cue.Time:0.###}s). Runtime clamps it to 0." );
}
if ( duration > 0f && cue.Time > duration + 0.05f )
{
warnings++;
Log.Warning(
$"{prefix} | Cycle audio cue {i} is scheduled at {cue.Time:0.###}s, " +
$"after cycle Duration {duration:0.###}s, so it may be cancelled before playback."
);
}
}
}
private static bool IsValidDefinitionPrefab( PrefabFile prefab )
{
if ( prefab is null || !prefab.IsValid )
return false;
string path = prefab.ResourcePath;
GameObject template = string.IsNullOrWhiteSpace( path )
? null
: GameObject.GetPrefab( path );
if ( template is null || !template.IsValid )
template = SceneUtility.GetPrefabScene( prefab );
return template is not null && template.IsValid;
}
private static void ValidateOpticProfile(
OMROpticProfile optic,
string prefix,
ref int errors,
ref int warnings
)
{
if ( optic is null )
return;
if ( !optic.IsValid )
{
errors++;
Log.Error( $"{prefix} | Assigned optic profile is invalid." );
return;
}
if ( string.IsNullOrWhiteSpace( optic.OpticId ) )
{
warnings++;
Log.Warning( $"{prefix} | Optic profile '{optic.ResourceName}' has an empty Optic ID." );
}
if ( optic.AimFovScale <= 0f || optic.AimFovScale > 1f )
{
errors++;
Log.Error( $"{prefix} | Optic '{optic.ResourceName}' Aim FOV Scale must be > 0 and <= 1." );
}
if ( optic.PeripheralAimFovScale < 0.5f || optic.PeripheralAimFovScale > 1.5f )
{
errors++;
Log.Error( $"{prefix} | Optic '{optic.ResourceName}' Peripheral Aim FOV Scale must be within 0.5..1.5." );
}
if ( optic.ViewModelAimFovScale < 0.5f || optic.ViewModelAimFovScale > 1.5f )
{
errors++;
Log.Error( $"{prefix} | Optic '{optic.ResourceName}' ViewModel Aim FOV Scale must be within 0.5..1.5." );
}
if (
optic.UseAimProgressForAccuracy &&
optic.AccuracyFullThreshold < optic.AccuracyBlendStart
)
{
errors++;
Log.Error( $"{prefix} | Optic '{optic.ResourceName}' Accuracy Full Threshold must be >= Accuracy Blend Start." );
}
if (
optic.Mode == OMROpticMode.MagnifiedScope &&
optic.AimFovScale > 0.8f
)
{
warnings++;
Log.Warning( $"{prefix} | Magnified optic '{optic.ResourceName}' has a very mild FOV scale ({optic.AimFovScale:0.###}); verify this is intentional." );
}
bool usesPiP =
optic.PresentationMode == OMROpticPresentationMode.PictureInPicture ||
optic.PresentationMode == OMROpticPresentationMode.ModelLensPictureInPicture;
if (
!usesPiP &&
MathF.Abs( optic.PeripheralAimFovScale - 1f ) > 0.0001f
)
{
warnings++;
Log.Warning( $"{prefix} | Optic '{optic.ResourceName}' has Peripheral Aim FOV Scale {optic.PeripheralAimFovScale:0.###}, but this setting is only used by PiP magnified scopes." );
}
if ( usesPiP )
{
if (
optic.UseNominalMagnificationForPiP &&
optic.NominalMagnification <= 1f
)
{
warnings++;
Log.Warning( $"{prefix} | Optic '{optic.ResourceName}' uses nominal PiP magnification but Nominal Magnification is <= 1x." );
}
if (
optic.EnableLensSimulation &&
optic.LensClarityFull < optic.LensClarityStart
)
{
errors++;
Log.Error( $"{prefix} | Optic '{optic.ResourceName}' Lens Clarity Full must be >= Lens Clarity Start." );
}
if (
optic.EnableLensSimulation &&
optic.LensErectFull < optic.LensErectStart
)
{
errors++;
Log.Error( $"{prefix} | Optic '{optic.ResourceName}' Lens Erect Full must be >= Lens Erect Start." );
}
if ( optic.KeepPiPWarmWhileEquipped )
{
if ( optic.PiPPrewarmResolution < 128 || optic.PiPPrewarmResolution > 1024 )
{
warnings++;
Log.Warning( $"{prefix} | Optic '{optic.ResourceName}' PiP prewarm resolution ({optic.PiPPrewarmResolution}) is outside the intended 128-1024 range." );
}
if ( optic.PiPFullResolutionAimFraction < 0f || optic.PiPFullResolutionAimFraction > 1f )
{
errors++;
Log.Error( $"{prefix} | Optic '{optic.ResourceName}' PiP Full Resolution Aim Fraction must be within 0..1." );
}
}
if (
optic.PresentationMode == OMROpticPresentationMode.ModelLensPictureInPicture &&
optic.UseScreenSpaceHybridOverlay &&
optic.HybridOverlayFadeFull < optic.HybridOverlayFadeStart
)
{
errors++;
Log.Error( $"{prefix} | Optic '{optic.ResourceName}' Hybrid Overlay Fade Full must be >= Fade Start." );
}
if ( optic.Mode != OMROpticMode.MagnifiedScope )
{
warnings++;
Log.Warning( $"{prefix} | Optic '{optic.ResourceName}' uses PiP presentation while Mode is not MagnifiedScope." );
}
if ( optic.PiPResolutionMode == OMRPiPResolutionMode.Fixed )
{
if ( optic.PiPRenderResolution < 256 || optic.PiPRenderResolution > 2048 )
{
warnings++;
Log.Warning( $"{prefix} | Optic '{optic.ResourceName}' fixed PiP resolution ({optic.PiPRenderResolution}) is outside the recommended 256-2048 range." );
}
}
else
{
if ( optic.PiPMinimumResolution < 256 )
{
warnings++;
Log.Warning( $"{prefix} | Optic '{optic.ResourceName}' PiP minimum resolution is below 256." );
}
if ( optic.PiPMaximumResolution < optic.PiPMinimumResolution )
{
errors++;
Log.Error( $"{prefix} | Optic '{optic.ResourceName}' PiP maximum resolution must be >= minimum resolution." );
}
}
if (
optic.PresentationMode == OMROpticPresentationMode.PictureInPicture &&
optic.PiPLensDiameterVh <= 0f
)
{
errors++;
Log.Error( $"{prefix} | Optic '{optic.ResourceName}' screen PiP lens diameter must be greater than zero." );
}
if (
optic.PresentationMode == OMROpticPresentationMode.ModelLensPictureInPicture &&
optic.PiPCameraPoseMode != OMRPiPCameraPoseMode.MainCamera &&
string.IsNullOrWhiteSpace( optic.PiPCameraAnchorName )
)
{
errors++;
Log.Error( $"{prefix} | Optic '{optic.ResourceName}' uses a viewmodel-anchor PiP camera mode but has no camera anchor name." );
}
}
if (
optic.PresentationMode == OMROpticPresentationMode.SimpleOverlay &&
optic.Mode != OMROpticMode.MagnifiedScope
)
{
warnings++;
Log.Warning( $"{prefix} | Optic '{optic.ResourceName}' uses SimpleOverlay presentation while Mode is not MagnifiedScope." );
}
}
private static void ValidateCycleProfile(
OMRWeaponCycleProfile cycle,
bool primaryAutomatic,
string prefix,
ref int errors,
ref int warnings
)
{
if ( cycle is null )
return;
if ( !cycle.IsValid )
{
errors++;
Log.Error( $"{prefix} | Assigned weapon cycle profile is invalid." );
return;
}
if ( string.IsNullOrWhiteSpace( cycle.CycleId ) )
{
warnings++;
Log.Warning( $"{prefix} | Cycle profile '{cycle.ResourceName}' has an empty Cycle ID." );
}
if ( cycle.StartDelay < 0f || cycle.Duration < 0f )
{
errors++;
Log.Error( $"{prefix} | Cycle profile '{cycle.ResourceName}' cannot use negative timing values." );
}
if ( cycle.AutoCycleAfterPrimaryShot && cycle.TotalLockoutTime <= 0f )
{
warnings++;
Log.Warning( $"{prefix} | Cycle profile '{cycle.ResourceName}' has zero total lockout time." );
}
if ( cycle.AutoCycleAfterPrimaryShot && primaryAutomatic )
{
warnings++;
Log.Warning( $"{prefix} | A manual-cycle profile is assigned to an automatic primary fire mode. Holding attack will fire again as soon as the cycle/fire cooldown permits." );
}
}
private static void ValidateRecoilPatternAsset(
OMRRecoilPattern pattern,
string prefix,
ref int errors,
ref int warnings
)
{
if ( pattern is null || !pattern.IsValid )
{
errors++;
Log.Error( $"{prefix} | Recoil pattern asset is invalid." );
return;
}
if ( pattern.ShotCount < 1 )
{
errors++;
Log.Error( $"{prefix} | Recoil pattern '{pattern.ResourceName}' ShotCount must be at least 1." );
}
if ( pattern.VerticalRise.Length < 2 )
{
warnings++;
Log.Warning( $"{prefix} | Recoil pattern '{pattern.ResourceName}' Vertical Rise has fewer than 2 keys." );
}
if ( pattern.HorizontalDrift.Length < 2 )
{
warnings++;
Log.Warning( $"{prefix} | Recoil pattern '{pattern.ResourceName}' Horizontal Drift has fewer than 2 keys." );
}
}
private static void ValidateCyclePresentationAdapter(
GameObject prefab,
string label,
string prefix,
ref int warnings
)
{
if ( prefab is null || !prefab.IsValid )
return;
OMRWeaponModelPresentationAdapter adapter =
prefab.Components.Get<OMRWeaponModelPresentationAdapter>(
FindMode.InSelf |
FindMode.InDescendants
);
if ( adapter is not null )
return;
warnings++;
Log.Warning(
$"{prefix} | {label} '{prefab.Name}' has a cycle profile but no OMRWeaponModelPresentationAdapter. Mechanical cycling will work, but this model will not receive b_cycle/cycle_progress presentation parameters."
);
}
private static void ValidateWeaponModelPrefab(
GameObject prefab,
string label,
string prefix,
ref int errors,
ref int warnings
)
{
if (
prefab is null ||
!prefab.IsValid
)
{
errors++;
Log.Error(
$"{prefix} | {label} prefab is missing."
);
return;
}
BaseWeaponModel model =
prefab.Components.Get<BaseWeaponModel>(
FindMode.InSelf |
FindMode.InDescendants
);
if ( model is null )
{
errors++;
Log.Error(
$"{prefix} | {label} '{prefab.Name}' has no BaseWeaponModel."
);
return;
}
bool needsMuzzle =
model.MuzzleEffect is not null ||
model.TracerEffect is not null;
if (
needsMuzzle &&
(
model.MuzzleGameObject is null ||
!model.MuzzleGameObject.IsValid
)
)
{
warnings++;
Log.Warning(
$"{prefix} | {label} '{prefab.Name}' uses muzzle/tracer effects but MuzzleGameObject is not assigned."
);
}
if (
model.EjectBrass is not null &&
(
model.ShellEjectGameObject is null ||
!model.ShellEjectGameObject.IsValid
)
)
{
warnings++;
Log.Warning(
$"{prefix} | {label} '{prefab.Name}' ejects brass but ShellEjectGameObject is not assigned."
);
}
SkinnedModelRenderer skinnedRenderer =
prefab.Components.Get<SkinnedModelRenderer>(
FindMode.InSelf |
FindMode.InDescendants
);
if (
skinnedRenderer is not null &&
model.Renderer is null
)
{
warnings++;
Log.Warning(
$"{prefix} | {label} '{prefab.Name}' contains a SkinnedModelRenderer but BaseWeaponModel.Renderer is not assigned."
);
}
}
}