Рынок современных игровых движков постепенно расширяется, и всё больше студий выбирают не кого-то из двух гигантов (учитывая последние события, вообще одного), а движки поменьше. Сегодня поговорим про одного из новичков индустрии — S&Box. И это случай, когда новичок не такой простой, каким кажется. Подробнее о проекте и о том, какие ошибки мы смогли найти с помощью PVS-Studio, расскажем в статье.

Пара слов о проекте

S&Box — это новенький игровой движок от довольно известной студии Facepunch, которая подарила нам, можно сказать, культовые проекты — Rust и Garry’s Mod. Оба являются одними из самых продаваемых игр в Steam. Но Garry’s Mod играет тут гораздо более важную роль, чем просто одна из игр студии. S&Box — это духовный наследник и полноценная версия всех идей, которые хотел видеть Гари Ньюман — создатель обоих игр — в Garry’s mod.

S&Box не просто движок. Это развлекательная платформа для творчества, в которой можно одновременно играть, создавать и даже монетизировать свой контент.

Проекты в S&Box пишутся на новеньком C# 14, а сам движок разработан на базе .NET 10. В качестве фич можно выделить поддержку изменений в реальном времени, представление логики с помощью визуальных графов (no code) и графы для шейдеров (как в Blender).

Примечание. Если вы хотите узнать о нововведениях C# 14, то у нас есть обзорная статья.

Из особенностей хотелось бы выделить, что это первый сторонний проект (не от Valve), который использует Source 2, так он теперь ещё и открытый!

Естественно, мы не могли пройти мимо новенького C# проекта, использующего все мощности новейшего .NET! Если вдруг кто-то забыл или встречает нас впервые — мы команда статического анализатора PVS-Studio, и мы любим проверять открытые проекты. Это позволяет нам проверить способности анализатора и поделиться с вами результатами.

Сегодня мы узнаем, почему полезно использовать статические анализаторы кода при разработке, особенно когда вы планируете открывать ваш проект для всего интернета >:)

Код проекта доступен на GitHub (анализ проводился на одном из релизных коммитов — f69cd48).

Проверка проекта

Какие ошибки мы отобрали?

В статье мы будем разбирать срабатывания из вкладки BEST.

Этот функционал используется в основном для первого просмотра срабатываний при пробном запуске анализатора, но его также можно использовать для поиска приоритетных срабатываний или как точку старта при начале работы с отчётом. Выбор сценария за вами.

Функционал содержит достаточно простой, но эффективный механизм. Если кратко, то в анализаторе есть разделение на три уровня срабатываний (High, Medium и Low). Они отвечают за уровень достоверности.

А зачем он нужен? Всё просто. Анализатор — это инструмент, который старается понять весь контекст вашего проекта, но технические ограничения не дают ему это сделать до конца. Чтобы не перемешивать срабатывания, где анализатор почти на 100% уверен в наличии ошибки, и места, где он указывает на просто сомнительные части кода, которые могут не содержать ошибки, мы сделали разделение по трём разным уровням.

При стандартной работе с анализатором мы советуем оставлять включёнными уровни High и Medium, а Low оставлять на случаи, когда вы можете уделить время изучению этих предупреждений. В анализаторе заложен функционал, который всегда указывает на code smell, а это значит, что другой человек при работе с этим кодом тоже может принять его за ошибочный.

Если вы при работе с анализатором изучаете кодовую базу и проверяете, реально ли это ошибка или нет, то другой человек такое исследование проводить уже не будет :)

Время code review

Начнём с небольшой разминки:

public bool CollapseEdge(....)
{
  ....
  GetVerticesConnectedToHalfEdge(
    hFullEdge,
    out var hVertexA,
    out var hVertexB );

  var hEdgeA = hFullEdge;
  var hEdgeB = hFullEdge.OppositeEdge;

  var hFaceA = hEdgeA.Face;
  var hFaceB = hEdgeB.Face;
  ....
  // Disconnect the edge that is being collapsed
  // from the faces and other edges.
  Assert.True(hEdgeA.IsValid &&  hEdgeB.IsValid );
  if ( hEdgeA.IsValid && hEdgeA.IsValid )
  {
    var pNewVertex = hNewVertex;

    var hNextEdgeA = hEdgeA.NextEdge;
    var hPrevEdgeA =
      FindPreviousEdgeInFaceLoop( hEdgeA );

    var hNextEdgeB = hEdgeB.NextEdge;
    var hPrevEdgeB =
      FindPreviousEdgeInFaceLoop( hEdgeB );
  }
}

Попробуйте найти ошибку сами.

Ответ где-то рядом...

Предупреждение PVS-Studio: V3001 There are identical sub-expressions ‘hEdgeA.IsValid’ to the left and to the right of the ‘&&’ operator. HalfEdgeMesh.cs 2094

Если хорошо приглядеться, то ошибка становится очевидной.

В условии If происходит сравнение двух объектов со схожими названиями hEdgeA и hEdgeB. Но как бы не происходит... Из кода видно, что хотели именно так, но что-то случилось и сравнили уже hEdgeA и hEdgeA, а результат, я думаю, все понимают. Как раз в таких местах и происходят очепятки. Много переменных и операций над ними, так они ещё и очень похожи! Зачем мучить себя, если можно воспользоваться инструментом, который найдёт все подобные ошибки...

Расставляем приоритеты

private void RewriteTypeNames(....)
{
  ....
  foreach ( var attribute in node.Attributes )
  {
    ....
    if ( attribute.BoundAttribute?.IsGenericTypedProperty() 
         ?? false && attribute.TypeName != null ) {....}

    else if ( attribute.TypeName == null &&      
      (attribute.BoundAttribute?.IsDelegateProperty() ?? false) ) {....}

    else if ( attribute.TypeName == null &&
      (attribute.BoundAttribute?.IsEventCallbackProperty() ?? false) ) {....}

    else if ( attribute.TypeName == null ) {....}
  }
  ....
}

Предупреждение PVS-Studio: V3177 The ‘false’ literal belongs to the ‘&&’ operator with a higher priority. It is possible the literal was intended to belong to ‘??’ operator instead. ComponentGenericTypePass.cs 346

Если приглядеться, то увидеть проблему легко, особенно с подсказкой от анализатора. Давайте взглянем поближе и разберём, что произошло:

attribute.BoundAttribute?.IsGenericTypedProperty() 
  ?? false && attribute.TypeName != null

Скорее всего, разработчик предполагал такой порядок действий (изобразим скобочками):

(attribute.BoundAttribute?.IsGenericTypedProperty() 
  ?? false) && attribute.TypeName != null

Но вышло так:

attribute.BoundAttribute?.IsGenericTypedProperty() 
  ?? (false && attribute.TypeName != null)

Как можно догадаться из сообщения анализатора, дело в том, что оператор && имеет больший приоритет, чем оператор ??, и происходит сценарий, лишённый какого-либо смысла.

Странно то, что в остальных местах всё корректно и проверка происходит правильно (даже скобки используются!), но это скорее подтверждение ошибки, чем её оправдание.

И, несмотря на всё это, самое неприятное в том, что ошибка повторилась. Разработчик “скопипастил” кусок кода и тем самым распространил её дальше — это можно увидеть чуть ниже текущей ошибки:

private void RewriteTypeNames(....)
{
  ....
  foreach ( var childContent in node.ChildContents )
  {
    if ( childContent.BoundAttribute?.IsGenericTypedProperty()
         ?? false && childContent.TypeName != null ) {....}
    
     else if ( childContent.IsParameterized ){....}
     else{....}
  }  
  ....
}

Опасный сценарий

public partial class Hotload
{
  public void AddUpgrader( IInstanceUpgrader upgrader )
    {....}

  public void AddUpgrader<TUpgrader>()
    where TUpgrader : IInstanceUpgrader, new()
  {
    AddUpgrader( new TUpgrader() );
  }

  public IInstanceUpgrader GetUpgrader( Type upgraderType )
    {....}

  ....

  internal static string FormatAssemblyName( AssemblyName name )
  {
    return AssemblyNameFormatter?
             .Invoke( name ) ?? name.ToString();
  }

  internal static string FormatAssemblyName( Assembly asm )
  {
  return FormatAssemblyName(asm?.GetName());
  }
}

Как думаете, что не так с этим фрагментом? В одном из сценариев можно получить NRE. К сожалению, не самая редкая ошибка.

Давайте сократим код и взглянем поближе:

internal static string FormatAssemblyName( AssemblyName name )
{
  return AssemblyNameFormatter?
           .Invoke( name ) ?? name.ToString();
}

internal static string FormatAssemblyName( Assembly asm )
{
  return FormatAssemblyName(asm?.GetName());
}

Предупреждение PVS-Studio: V3105 The result of null-conditional operator is dereferenced inside the ‘FormatAssemblyName’ method. NullReferenceException is possible. Inspect the first argument ‘asm?.GetName()’. Hotload.cs 324

Разработчик изначально предполагает, что в методе FormatAssemblyName( Assembly asm ) параметр asm может быть null FormatAssemblyName(asm?.GetName()). Но уже в FormatAssemblyName( AssemblyName name ) такое не ожидается. У нас есть два сценария: первый — name = null, второй — AssemblyNameFormatter = null и name = null.

В первом случае всё будет нормально, т. к. в AssemblyNameFormatter есть обработка такого сценария. А во втором уже будет ошибка, поскольку name используется без проверки, и в момент вызова метода name.ToString()происходит NRE.

Про кого-то забыли

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;
    if ( source.IsMounted ){ PendingMounts.Add( ident ); }

    source.ShutdownInternal();
    Sources.Remove( ident );
  }
}

В этом коде скрыта грустная история о забытой переменной и невыполненном методе из-за отложенного выполнения.

Предупреждение PVS-Studio: V3220 The result of the ‘Where’ LINQ method with deferred execution is never used. The method will not be executed. MountHost.cs 114

Если взглянем на переменную types и на значение, которое ей задаёт разработчик, то увидим выборку из определённых объектов:

var types = assembly
                .GetTypes().Where( t => 
                  assetSourceType.IsAssignableFrom( t ) 
                  && !t.IsAbstract );

Но отбора не произойдёт, т. к. types больше нигде в коде не упоминается. Подобное поведение обусловлено тем, что при вызове Where фильтрация не выполняется в момент вызова, а является отложенной. Следовательно, до тех пор, пока не будет произведено итерирование по коллекции, код делегата не выполнится.

В представленном случае последствия не такие серьёзные, потому что от делегата в Where не ожидается дополнительных действий, но если код будет другим, то может потеряться важный функционал, например:

int it = 0;
var filteredNames = names.Where(name => 
                    {
                      ++it;
                      Console.WriteLine($"{it}) {name}");
                      return name.StartsWith(startLetter); 
                    });

В метод Where передаётся делегат, который, помимо условия фильтрации, выводит на консоль элементы и их позиции из исходной коллекции. Но, как вы уже поняли, filteredNames может не быть упомянут, по этой причине отфильтрованная коллекция не будет выведена на консоль. Более того, фильтрация в принципе не выполнится.

Чрезмерная проверка

public static bool IsIes( byte[] data )
{
  if ( data == null || data.Length < 10 )
    return false;

  var header = Encoding.ASCII.GetString
    ( data, 0, Math.Min( 20, data.Length ) ).Trim();
  
return 
   header.StartsWith( "IESNA" ) 
   || header.StartsWith( "IESNA:LM-63" );
}

Предупреждение PVS-Studio: V3053 An excessive expression. Examine the substrings ‘IESNA’ and ‘IESNA:LM-63’. Bitmap.Loading.Ies.cs 242

В случае, если подстрока IESNA будет найдена, то дальнейшая проверка не выполнится. Если подстрока IESNA не будет найдена, то и поиск более длинной подстроки IESNA:LM-63 не имеет смысла. Обычно мы советуем просто убрать излишний код, но тут может быть немного другая история.

В коде идёт дополнительная проверка на IESNA:LM-63, и если разработчики хотели убедиться, что стандарт IESNA будет именно “LM-63”, нужно было написать так:

return header.StartsWith("IESNA:LM-63") 
       || header.StartsWith("IES:LM-63");

Это новый и старый форматы IESNA (в современных версиях префикс просто IES:), а текущая проверка не выполняет это условие и пропустит любую кодировку/версию стандарта.

Забытый Deserialize

Попробуйте найти ошибку в коде:

JsonSerializer.Deserialize<Vector3>( ref reader );

Получилось?

Давайте дам подсказку.

Сообщение PVS-Studio...

V3010 The return value of function ‘Deserialize’ is required to be utilized. PolygonMesh.Serialize.cs 54

Я в вас не сомневался!

Ладно, оставим шутки — надо разобраться. У нас есть модификатор ref и логика и так отработает, может проблемы-то и нет?

Дело в том, что это десериализация в этом фрагменте встречается часто. Давайте взглянем:

....
else if ( propertyName == "TextureOrigin" )
{
  if ( reader.TokenType == JsonTokenType.StartArray )
  {
    mesh
   .TextureOriginUnused
   .CopyFrom( JsonSerializer.Deserialize<Vector3[]>( ref reader ) );
  }
  else
  {
    JsonSerializer.Deserialize<Vector3>( ref reader );   // <=
  }
}
else if ( propertyName == nameof( TextureCoord )
{
  mesh
 .TextureCoord
 .CopyFrom( JsonSerializer.Deserialize<Vector2[]>( ref reader ) );
  
  hasTextureCoords = true;
}

else if ( propertyName == "TextureRotation" )
  mesh
  .TextureRotationUnused
  .CopyFrom( JsonSerializer.Deserialize<Rotation[]>( ref reader ) );

else if ( propertyName == nameof( TextureUAxis ) )
  mesh
  .TextureUAxis
  .CopyFrom( JsonSerializer.Deserialize<Vector3[]>( ref reader ) );

....

В коде ещё много такого. Смысл в том, что возвращаемое значение используется для копирования и других действий, но лишь в одном месте его пропустили. Очень подозрительный фрагмент, в котором, скорее всего, просто забыли ;_;.

Релизный TODO

public static ConCmdAttribute.AutoCompleteResult[]
  GetAutoComplete(....)
{
  var parts = partial.SplitQuotesStrings();

  List<ConCmdAttribute.AutoCompleteResult> results = new();

  // if we have more than one part, complete a specific command
  if ( parts.Length > 1 )
  {
    if ( !Members.TryGetValue( parts[0], out var command ) 
      return Array
             .Empty<ConCmdAttribute.AutoCompleteResult>();
  
  //results.Add( new ConCmd.AutoCompleteResult 
  //  { Command = command.Name, Description = command.Help } );
  
  // TODO - dig into it for auto complete
  return results.Take( count ).ToArray();
}

Предупреждение PVS-Studio: V3191 Iteration through the ‘results’ collection makes no sense because it is always empty. ConVarSystem.AutoComplete.cs 23

Анализатор указывает, что итерация по results не имеет смысла, т. к. коллекция всегда пустая. И с ним тяжело не согласиться! Ведь функционал, добавляющий что-либо в эту коллекцию, был закомментирован.

Учитывая остальные комментарии, можно понять две вещи: какая-то логика присутствует и в коде смысл есть; часть функционала отправили в TODO.

Казалось бы, тогда это и не ошибка, ведь всё осмысленно сделано! Но у меня вопрос: считать ли такой случай плохой практикой? Нужно учитывать, что этот проект — база для многих других. Логика и работа многих игр будет зависеть от того, как хорошо написаны исходники движка. Этот фрагмент лежит с самого релиза, а на момент написания статьи прошло уже более полугода.

Как вы считаете, это плохо или в этом ничего такого нет? Напишите своё мнение в комментариях.

Мёртвая зона

internal static void OnGameControllerAxis(
  int deviceId,
  GameControllerAxis axis,
  int value )
{

  controller.SetAxis( axis, value );

  ....

  const float triggerDeadzone = 0.75f;

  // I hate this but okay
  GamepadCode code =
    axis switch
    {
      GameControllerAxis.TriggerLeft =>
        GamepadCode.LeftTrigger,

      GameControllerAxis.TriggerRight =>
        GamepadCode.RightTrigger,

      _ =>
        GamepadCode.None,
    };

  OnGamepadCode(deviceId, code, value >= triggerDeadzone );
}

Предупреждение PVS-Studio: V3040 The ‘triggerDeadzone’ constant of the ‘float’ type is compared to a value of the ‘int’ type. InputRouter.Input.cs 207

Здесь переменная value имеет тип int, и сравнение этой переменной со значением 0.75f выглядит подозрительно, особенно с использованием оператора >=, т. к. ближайшие значения это 0 и 1. Тем более странно видеть это в коде, связанном с кнопками контроллера, где важна точность. И как раз тут мы работаем с “мёртвой зоной” кнопки, — важно понимать, где ТОЧНО она начинается.

Сложно сказать, что именно хотел разработчик, учитывая, что переменная value имеет тип int и используется в других конструкциях. Так что ошибка это или нет, решать уже авторам проекта.

Секунда Шрёдингера

public static string FormatSecondsLong(
  this long secs )
{
  var m =System.Math.Floor( (float)secs / 60.0f );

  var h =System.Math.Floor( (float)m / 60.0f );

  var d =System.Math.Floor( (float)h / 24.0f );

  var w =System.Math.Floor( (float)d / 7.0f );

  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 );

  return string.Format(
    "{4} weeks, {3} days, {2} hours and {1} minutes",
      secs % 60,
      m % 60,
      h % 24,
      d % 7,
      w );
}

Предупреждение PVS-Studio: V3025 Incorrect format. A different number of format items is expected while calling ‘Format’ function. Arguments not used: secs % 60. NumberExtensions.cs 102

Анализатор утверждает, что количество параметров не соответствует строке формата, в которую передаётся. И мы можем в этом убедиться.

В первой строке передаются секунды, и это происходит без проблем, всё корректно:

if ( secs < 60 )return string.Format("{0} seconds",secs );

Но затем мы можем заметить, что, начиная с третьей проверки, секунды передаются, но не используются:

if ( h < 48 )
  return string.Format(
    "{2} hours and {1} minutes", 
      secs % 60, 
      m % 60, 
      h );

Если посмотреть на код целиком, то секунды передаются везде, и есть вероятность, что это было сделано для читаемости, т. к. секунды используются в получении минут, часов и т. д.

Можно сказать, что анализатор ошибся, но он же прав, не так ли? :)

Он не соврал, и в строку формата правда передаётся больше аргументов, чем ожидается. Если в этом месте это было сделано специально, то какова вероятность, что в остальных случаях будет так же? Мы советуем вам не рисковать и лишний раз проверять код на случай непредусмотренных моментов.

Ну а если вы на 100% уверены в своём коде, то всегда можете просто дать понять анализатору с помощью комментария, что тут можно не ругаться:

if ( h < 48 ) return string.Format( 
  "{2} hours and {1} minutes", secs % 60, m % 60, h ); //-V3025

Заключение

Несмотря на найденные ошибки, код проекта выглядит довольно качественно, а его команда старается исправлять проблемы или потенциальные ошибки как можно скорее (если судить по GitHub).

Если вам интересно, что ещё нашёл PVS-Studio в S&Box (в отчёте более 1000 ошибок), или вы хотите проверить свой собственный проект, то скачать и попробовать анализатор можно по ссылке.

Примечание. А для тех, кто дочитал статью до конца, у нас есть бонус: возможность записаться на программу раннего доступа к нашим новым анализаторам! EAP доступно по направлениям JavaScript, TypeScript и Go.

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Gleb Aslamov. S&Box game engine: looking closer at grains of sand.