From 12e3ad13aa09a090b23c8adefd9999c2da734330 Mon Sep 17 00:00:00 2001 From: Lorenz Junglas <4759511+lolleko@users.noreply.github.com> Date: Fri, 8 May 2026 14:19:55 +0100 Subject: [PATCH] Fix issues reported in PVS analysis (#4406) * Fix issues reported in PVS analysis https://pvs-studio.com/en/blog/posts/csharp/1356/ * Initial version of concmd argument autocomplete/hints https://files.facepunch.com/lolleko/2026/March/27_09-38-ApprehensiveGermanshorthairedpointer.mp4 --- .../Sandbox.Mounting/MountHost/MountHost.cs | 3 - .../Textures/Bitmap/Bitmap.Loading.Ies.cs | 2 +- .../Mesh/HalfEdgeMesh/HalfEdgeMesh.cs | 2 +- .../Console/ConVarSystem.AutoComplete.cs | 121 +++++++++++++++--- .../Input/Controller/Controller.Input.cs | 2 +- .../Systems/Input/InputRouter.Input.cs | 3 +- .../Util/NumberExtensions.cs | 6 +- engine/Sandbox.Hotload/Hotload.cs | 2 +- .../Components/ComponentGenericTypePass.cs | 4 +- .../Sandbox.System/Extend/NumberExtensions.cs | 6 +- 10 files changed, 115 insertions(+), 36 deletions(-) diff --git a/engine/Mounting/Sandbox.Mounting/MountHost/MountHost.cs b/engine/Mounting/Sandbox.Mounting/MountHost/MountHost.cs index d48b2e5a..21b67a9b 100644 --- a/engine/Mounting/Sandbox.Mounting/MountHost/MountHost.cs +++ b/engine/Mounting/Sandbox.Mounting/MountHost/MountHost.cs @@ -110,9 +110,6 @@ internal class MountHost : IDisposable internal void UnregisterTypes( Assembly assembly ) { - var assetSourceType = typeof( BaseGameMount ); - var types = assembly.GetTypes().Where( t => assetSourceType.IsAssignableFrom( t ) && !t.IsAbstract ); - foreach ( var (ident, source) in Sources.ToArray() ) { if ( source.GetType().Assembly != assembly ) continue; diff --git a/engine/Sandbox.Engine/Resources/Textures/Bitmap/Bitmap.Loading.Ies.cs b/engine/Sandbox.Engine/Resources/Textures/Bitmap/Bitmap.Loading.Ies.cs index 87835eb3..43581ad5 100644 --- a/engine/Sandbox.Engine/Resources/Textures/Bitmap/Bitmap.Loading.Ies.cs +++ b/engine/Sandbox.Engine/Resources/Textures/Bitmap/Bitmap.Loading.Ies.cs @@ -239,6 +239,6 @@ public partial class Bitmap return false; var header = Encoding.ASCII.GetString( data, 0, Math.Min( 20, data.Length ) ).Trim(); - return header.StartsWith( "IESNA" ) || header.StartsWith( "IESNA:LM-63" ); + return header.StartsWith( "IESNA" ); } } diff --git a/engine/Sandbox.Engine/Scene/Components/Mesh/HalfEdgeMesh/HalfEdgeMesh.cs b/engine/Sandbox.Engine/Scene/Components/Mesh/HalfEdgeMesh/HalfEdgeMesh.cs index b3e26817..671222b6 100644 --- a/engine/Sandbox.Engine/Scene/Components/Mesh/HalfEdgeMesh/HalfEdgeMesh.cs +++ b/engine/Sandbox.Engine/Scene/Components/Mesh/HalfEdgeMesh/HalfEdgeMesh.cs @@ -2090,7 +2090,7 @@ internal sealed partial class Mesh // Disconnect the edge that is being collapsed from the faces and other edges. Assert.True( hEdgeA.IsValid && hEdgeB.IsValid ); - if ( hEdgeA.IsValid && hEdgeA.IsValid ) + if ( hEdgeA.IsValid && hEdgeB.IsValid ) { var pNewVertex = hNewVertex; var hNextEdgeA = hEdgeA.NextEdge; diff --git a/engine/Sandbox.Engine/Systems/Console/ConVarSystem.AutoComplete.cs b/engine/Sandbox.Engine/Systems/Console/ConVarSystem.AutoComplete.cs index 8913cf0a..56a9d811 100644 --- a/engine/Sandbox.Engine/Systems/Console/ConVarSystem.AutoComplete.cs +++ b/engine/Sandbox.Engine/Systems/Console/ConVarSystem.AutoComplete.cs @@ -2,39 +2,120 @@ internal static partial class ConVarSystem { + // [name=default] for optional params, for required. + static string FormatParamHint( System.Reflection.ParameterInfo p ) + => p.HasDefaultValue + ? $"[{p.Name}={p.DefaultValue}]" + : $"<{p.Name}:{p.ParameterType.Name}>"; + + // Space-separated hints for all params from fromIndex onward. + static string BuildRemainingHint( System.Reflection.ParameterInfo[] parameters, int fromIndex ) + { + if ( fromIndex >= parameters.Length ) return string.Empty; + return string.Join( " ", parameters[fromIndex..].Select( FormatParamHint ) ); + } + public static ConCmdAttribute.AutoCompleteResult[] GetAutoComplete( string partial, int count ) { var parts = partial.SplitQuotesStrings(); + return partial.Contains( ' ' ) + ? GetArgumentAutoComplete( partial, parts, count ) + : GetCommandAutoComplete( partial, parts, count ); + } + + // Completes argument values once a command name has been typed. + static ConCmdAttribute.AutoCompleteResult[] GetArgumentAutoComplete( string partial, string[] parts, int count ) + { + if ( !Members.TryGetValue( parts[0], out var command ) ) + return Array.Empty(); + + if ( command is not ManagedCommand managed ) + return Array.Empty(); + + // Connection is injected at call time, not supplied by the user. + var paramOffset = managed.parameters.Length > 0 && managed.parameters[0].ParameterType == typeof( Connection ) ? 1 : 0; + + // Trailing space → user started a new arg. No trailing space → still mid-token. + var startedNewArg = partial[^1] == ' '; + var argIndex = startedNewArg ? parts.Length - 1 : parts.Length - 2; + var partialArg = startedNewArg ? "" : parts[^1]; + var commandPrefix = startedNewArg ? partial.TrimEnd() : string.Join( " ", parts[..^1] ); + var paramIndex = paramOffset + argIndex; + + if ( paramIndex >= managed.parameters.Length ) + return Array.Empty(); + + var param = managed.parameters[paramIndex]; + var remainingHint = BuildRemainingHint( managed.parameters, paramIndex + 1 ); + + IEnumerable suggestions = param.ParameterType switch + { + var t when t == typeof( bool ) => ["true", "false"], + var t when t.IsEnum => Enum.GetNames( t ), + _ => null, + }; + List results = new(); - // - // if we have more than one part, complete a specific command - // - if ( parts.Length > 1 ) + if ( suggestions is not null ) { - if ( !Members.TryGetValue( parts[0], out var command ) ) - return Array.Empty(); - - //results.Add( new ConCmd.AutoCompleteResult { Command = command.Name, Description = command.Help } ); - - // TODO - dig into it for auto complete - - return results.Take( count ).ToArray(); + foreach ( var s in suggestions + .Where( s => s.StartsWith( partialArg, StringComparison.OrdinalIgnoreCase ) ) + .Take( count ) ) + { + var cmd = string.IsNullOrEmpty( remainingHint ) + ? $"{commandPrefix} {s}" + : $"{commandPrefix} {s} {remainingHint}"; + results.Add( new ConCmdAttribute.AutoCompleteResult + { + Command = cmd, + Description = $"{param.Name} ({param.ParameterType.Name})", + } ); + } + } + else + { + // Non-enum/bool: show a type hint so the user knows what to type. + var currentHint = FormatParamHint( param ); + var fullCmd = string.IsNullOrEmpty( remainingHint ) + ? $"{commandPrefix} {currentHint}" + : $"{commandPrefix} {currentHint} {remainingHint}"; + results.Add( new ConCmdAttribute.AutoCompleteResult + { + Command = fullCmd, + Description = $"{param.Name} ({param.ParameterType.Name})", + } ); } - // - // Find the command starting with this - // + return results.ToArray(); + } + + // Completes command names, and for an exact match shows the full parameter signature. + static ConCmdAttribute.AutoCompleteResult[] GetCommandAutoComplete( string partial, string[] parts, int count ) + { + List results = new(); foreach ( var option in Members.Values - .Where( x => !x.IsHidden ) - .Where( x => x.Name.StartsWith( partial, StringComparison.OrdinalIgnoreCase ) ) - .OrderBy( x => x.Name ) ) + .Where( x => !x.IsHidden ) + .Where( x => x.Name.StartsWith( partial, StringComparison.OrdinalIgnoreCase ) ) + .OrderBy( x => x.Name ) ) { - - if ( option.Name == partial ) + if ( string.Equals( option.Name, partial, StringComparison.OrdinalIgnoreCase ) + && option is ManagedCommand exactManaged ) + { + var paramOffset = exactManaged.parameters.Length > 0 && exactManaged.parameters[0].ParameterType == typeof( Connection ) ? 1 : 0; + var hint = BuildRemainingHint( exactManaged.parameters, paramOffset ); + if ( !string.IsNullOrEmpty( hint ) ) + { + results.Add( new ConCmdAttribute.AutoCompleteResult + { + Command = $"{option.Name} {hint}", + Description = option.BuildDescription(), + } ); + } continue; + } results.Add( new ConCmdAttribute.AutoCompleteResult { diff --git a/engine/Sandbox.Engine/Systems/Input/Controller/Controller.Input.cs b/engine/Sandbox.Engine/Systems/Input/Controller/Controller.Input.cs index adf9dc75..70924795 100644 --- a/engine/Sandbox.Engine/Systems/Input/Controller/Controller.Input.cs +++ b/engine/Sandbox.Engine/Systems/Input/Controller/Controller.Input.cs @@ -18,7 +18,7 @@ internal sealed partial class Controller internal Input.Context InputContext { get; set; } /// SDL reports values between this range - static readonly Vector2 AXIS_RANGE = new( -32768, 32767 ); + internal static readonly Vector2 AXIS_RANGE = new( -32768, 32767 ); List ControllerAxes { get; set; } = new(); diff --git a/engine/Sandbox.Engine/Systems/Input/InputRouter.Input.cs b/engine/Sandbox.Engine/Systems/Input/InputRouter.Input.cs index 93e02290..1668f945 100644 --- a/engine/Sandbox.Engine/Systems/Input/InputRouter.Input.cs +++ b/engine/Sandbox.Engine/Systems/Input/InputRouter.Input.cs @@ -200,7 +200,8 @@ internal static partial class InputRouter _ => GamepadCode.None, }; - OnGamepadCode( deviceId, code, value >= triggerDeadzone ); + // Normalize raw SDL axis value to 0-1 range before comparing against the normalized deadzone. + OnGamepadCode( deviceId, code, ((float)value).Remap( 0, Controller.AXIS_RANGE.y, 0, 1 ) >= triggerDeadzone ); } internal static void OnGameControllerConnected( int joystickId, int deviceId ) diff --git a/engine/Sandbox.Generator/Util/NumberExtensions.cs b/engine/Sandbox.Generator/Util/NumberExtensions.cs index 66f5493c..2e12aada 100644 --- a/engine/Sandbox.Generator/Util/NumberExtensions.cs +++ b/engine/Sandbox.Generator/Util/NumberExtensions.cs @@ -99,10 +99,10 @@ namespace Sandbox if ( secs < 60 ) return string.Format( "{0} seconds", secs ); if ( m < 60 ) return string.Format( "{1} minutes, {0} seconds", secs % 60, m ); - if ( h < 48 ) return string.Format( "{2} hours and {1} minutes", secs % 60, m % 60, h ); - if ( d < 7 ) return string.Format( "{3} days, {2} hours and {1} minutes", secs % 60, m % 60, h % 24, d ); + if ( h < 48 ) return string.Format( "{1} hours and {0} minutes", m % 60, h ); + if ( d < 7 ) return string.Format( "{2} days, {1} hours and {0} minutes", m % 60, h % 24, d ); - return string.Format( "{4} weeks, {3} days, {2} hours and {1} minutes", secs % 60, m % 60, h % 24, d % 7, w ); + return string.Format( "{3} weeks, {2} days, {1} hours and {0} minutes", m % 60, h % 24, d % 7, w ); } /// public static string FormatSecondsLong( this ulong secs ) { return FormatSecondsLong( (long)secs ); } diff --git a/engine/Sandbox.Hotload/Hotload.cs b/engine/Sandbox.Hotload/Hotload.cs index fb28de1c..5f7f0219 100644 --- a/engine/Sandbox.Hotload/Hotload.cs +++ b/engine/Sandbox.Hotload/Hotload.cs @@ -321,7 +321,7 @@ namespace Sandbox internal static string FormatAssemblyName( Assembly asm ) { - return FormatAssemblyName( asm?.GetName() ); + return asm is null ? string.Empty : FormatAssemblyName( asm.GetName() ); } } } diff --git a/engine/Sandbox.Razor/Microsoft.AspNetCore.Razor.Language/Components/ComponentGenericTypePass.cs b/engine/Sandbox.Razor/Microsoft.AspNetCore.Razor.Language/Components/ComponentGenericTypePass.cs index 18013e22..0ac55a1a 100644 --- a/engine/Sandbox.Razor/Microsoft.AspNetCore.Razor.Language/Components/ComponentGenericTypePass.cs +++ b/engine/Sandbox.Razor/Microsoft.AspNetCore.Razor.Language/Components/ComponentGenericTypePass.cs @@ -343,7 +343,7 @@ namespace Microsoft.AspNetCore.Razor.Language.Components attribute.GloballyQualifiedTypeName = globallyQualifiedTypeName; } - if ( attribute.BoundAttribute?.IsGenericTypedProperty() ?? false && attribute.TypeName != null ) + if ( (attribute.BoundAttribute?.IsGenericTypedProperty() ?? false) && attribute.TypeName != null ) { // If we know the type name, then replace any generic type parameter inside it with // the known types. @@ -368,7 +368,7 @@ namespace Microsoft.AspNetCore.Razor.Language.Components foreach ( var childContent in node.ChildContents ) { - if ( childContent.BoundAttribute?.IsGenericTypedProperty() ?? false && childContent.TypeName != null ) + if ( (childContent.BoundAttribute?.IsGenericTypedProperty() ?? false) && childContent.TypeName != null ) { // If we know the type name, then replace any generic type parameter inside it with // the known types. diff --git a/engine/Sandbox.System/Extend/NumberExtensions.cs b/engine/Sandbox.System/Extend/NumberExtensions.cs index 20715c52..ed7290e3 100644 --- a/engine/Sandbox.System/Extend/NumberExtensions.cs +++ b/engine/Sandbox.System/Extend/NumberExtensions.cs @@ -104,10 +104,10 @@ public static partial class SandboxSystemExtensions if ( secs < 60 ) return string.Format( "{0} seconds", secs ); if ( m < 60 ) return string.Format( "{1} minutes, {0} seconds", secs % 60, m ); - if ( h < 48 ) return string.Format( "{2} hours and {1} minutes", secs % 60, m % 60, h ); - if ( d < 7 ) return string.Format( "{3} days, {2} hours and {1} minutes", secs % 60, m % 60, h % 24, d ); + if ( h < 48 ) return string.Format( "{1} hours and {0} minutes", m % 60, h ); + if ( d < 7 ) return string.Format( "{2} days, {1} hours and {0} minutes", m % 60, h % 24, d ); - return string.Format( "{4} weeks, {3} days, {2} hours and {1} minutes", secs % 60, m % 60, h % 24, d % 7, w ); + return string.Format( "{3} weeks, {2} days, {1} hours and {0} minutes", m % 60, h % 24, d % 7, w ); } /// public static string FormatSecondsLong( this ulong secs ) { return FormatSecondsLong( (long)secs ); }