Главная
Блог разработчиков phpBB
 
+ 17 предустановленных модов
+ SEO-оптимизация форума
+ авторизация через соц. сети
+ защита от спама

Долгожданная проверка Unreal Engine 4

Anna | 24.06.2014 | нет комментариев

9 марта 2014 года Unreal Engine 4 стал доступен для всех желающих. Цена подписки каждого 19$ в месяц. Начальные коды также выложены на github репозиторий. С этого момента нам поступила масса сообщений на почту, в твиттер и так дальше, с просьбой проверить данный игровой движок. Мы удовлетворяем просьбу наших читателей. Давайте посмотрим, что увлекательного дозволено обнаружить в начальном коде с поддержкой статического анализатора кода PVS-Studio.

Unreal Engine 4 and PVS-Studio

Unreal Engine

Unreal Engine — игровой движок, разрабатываемый и поддерживаемый компанией Epic Games. Написан на языке C . Разрешает создавать игры для большинства операционных систем и платформ: Microsoft Windows, Linux, Mac OS и Mac OS X, консолей Xbox, Xbox 360, PlayStation 2, PlayStation Portable, PlayStation 3, Wii, Dreamcast и Nintendo GameCube.

Формальный сайт: https://www.unrealengine.com/

Изложение на сайте Wikipedia: Unreal Engine.

Метод проверки nmake-based плана

С проверкой плана Unreal Engine не всё так легко. Дабы проверить данный план, нам пришлось воспользоваться новой вероятностью, которую мы незадолго реализовали в PVS-Studio Standalone. Из-за этого нам пришлось немножко задержать публикацию статьи. Мы решили опубликовать её позже релиза PVS-Studio, где теснее будет эта новая вероятность. Допустимо, многим захочется ее испробовать. Она значительно облегчает проверку планов, где применяются трудные либо нетрадиционные системы сборки.

Типичный правило работы PVS-Studio таков:

  • Вы открываете план в Visual Studio.
  • Нажимаете кнопку «проверить».
  • Плагин, интегрированный в Visual Studio, собирает всю нужную информацию: какие файлы нужно проверять, какие макросы применять, где лежат заголовочные файлы и так дальше.
  • Плагин запускает собственно анализатор и отображает полученные итоги.

Нюанс в том, что Unreal Engine 4 — это nmake-based план, следственно проверить его с поддержкой плагина PVS-Studio невозможно.

Поясню. Есть план для среды Visual Studio. Но сборка осуществляется с поддержкой nmake. Это значит, что плагин не знает, какие файлы и с какими ключами компилируются. Соответственно, обзор немыслим. Правильней, обзор допустим, но процесс трудоёмок (смотри раздел документации: “Прямая интеграция анализатора в системы автоматизации сборки“).

И тут на выручку приходит PVS-Studio Standalone! Он может трудиться в 2-х вариантах:

  1. Вы каким-то образом генерируете препроцессированные файлы, и он проверят теснее их.
  2. Он отслеживает запуски компилятора и «подсматривает» всю нужную информацию.

Теперь нас волнует именно 2-й сценарий применения. Вот как происходила проверка Unreal Engine:

  1. Запустили PVS-Studio Standalone.
  2. Нажали кнопку «Compiler Monitoring».
  3. Нажали на кнопку «Start Monitoring». Увидели, что включился режим слежения за вызовами компилятора.
  4. Открыли план Unreal Engine в Visual Studio. Запустили сборку плана. При этом в окне мониторинга видно, что перехватываются вызовы компилятора.
  5. Когда сборка в Visual Studio закончилась, мы нажали кнопку Stop Monitoring. Позже этого начал работу анализатор PVS-Studio.

В итоге, диагностические сообщения возникают в окне утилиты PVS-Studio Standalone.

Hint. Для комфорта, отличнее применять Visual Studio, а не встроенный в PVS-Studio Standalone редактор. Довольно сберечь итоги обзора в файл, а после этого открыть его из среды Visual Studio (Menu->PVS-Studio->Open/Save->Open Analysis Report).

Всё это и многое другое описано в статье “PVS-Studio сейчас поддерживает всякую сборочную систему на Windows и всякий компилятор. Легко и «из коробки». Умоляю непременно прочитать эту статью, раньше чем начинать эксперименты с PVS-Studio Standalone!

Итоги проверки

Код плана Unreal Engine показался мне дюже добротным. Скажем, разработчики применяют при разработке статический обзор кода. Об этом говорL && GUseStreamingPause ) { // @todo UE4 merge andrew // GStreamingPauseBackground = new FFrontBufferTexture(….); GStreamingPauseBackground->InitRHI(); } }
Предупреждение PVS-Studio: V522 Dereferencing of the null pointer ‘GStreamingPauseBackground’ might take place. streamingpauserendering.cpp 197

Ещё о нулевых указателях

Примерно в всякий программе я встречаю массу предупреждений с кодом V595 (примеры). Эти предупреждения говорят о дальнейшей обстановки:

Сначала указатель разыменовывается. Ниже данный указатель проверяется на равенство нулю. Это вдалеке не неизменно оплошность. Но это дюже подозрительный код, и его нужно проверить!

Диагностика V595 разрешает выявлять вот такие ляпы:

/**
 * Global engine pointer.
 * Can be 0 so don't use without checking.
 */
ENGINE_API UEngine* GEngine = NULL;

bool UEngine::LoadMap( FWorldContext& WorldContext,
  FURL URL, class UPendingNetGame* Pending, FString& Error )
{
  ....
  if (GEngine->GameViewport != NULL)
  {
    ClearDebugDisplayProperties();
  }

  if( GEngine )
  {
    GEngine->WorldDestroyed( WorldContext.World() );
  }
  ....
}

Предупреждение PVS-Studio: V595 The ‘GEngine’ pointer was utilized before it was verified against nullptr. Check lines: 9714, 9719. unrealengine.cpp 9714

Обратите внимание на комментарий. Глобальная переменная GEngine может быть равна нулю. Раньше чем её применять, её необходимо проверить.

В функции LoadMap() подлинно есть такая проверка:

if( GEngine )

Только вот незадача. Проверка расположена теснее позже того, как указателем попользовались:

if (GEngine->GameViewport != NULL)

Предупреждений V595 достаточно много (82 штуки). Думаю, многие из них окажутся ложными. Следственно не буду захламлять статью сообщениями и приведу их отдельным списком: ue-v595.txt.

Лишнее объявление переменной

Разглядим прекрасную ошибку. Она связана с тем, что нечаянно создаётся новая переменная, а не применяется ветхая.

void FStreamableManager::AsyncLoadCallback(....)
{
  ....
  FStreamable* Existing = StreamableItems.FindRef(TargetName);
  ....
  if (!Existing)
  {
    // hmm, maybe it was redirected by a consolidate
    TargetName = ResolveRedirects(TargetName);
    FStreamable* Existing = StreamableItems.FindRef(TargetName);
  }
  if (Existing && Existing->bAsyncLoadRequestOutstanding)
  ....
}

Предупреждение PVS-Studio: V561 It’s probably better to assign value to ‘Existing’ variable than to declare it anew. Previous declaration: streamablemanager.cpp, line 325. streamablemanager.cpp 332

Как я понимаю, на самом деле, должно быть написано так:

// hmm, maybe it was redirected by a consolidate
TargetName = ResolveRedirects(TargetName);
Existing = StreamableItems.FindRef(TargetName);

Ошибки при вызове функций

bool FRecastQueryFilter::IsEqual(
  const INavigationQueryFilterInterface* Other) const
{
  // @NOTE: not type safe, should be changed when
  // another filter type is introduced
  return FMemory::Memcmp(this, Other, sizeof(this)) == 0;
}

Предупреждение PVS-Studio: V579 The Memcmp function receives the pointer and its size as arguments. It is possibly a mistake. Inspect the third argument. pimplrecastnavmesh.cpp 172

Комментарий предупреждает, что применять Memcmp() небезопасно. Но, на самом деле, всё ещё дрянней, чем думает программист. Дело в том, что функция сопоставляет только часть объекта.

Оператор sizeof(this) возвращает размер указателя. То есть в 32-битной программе функция сравнит первые 4 байта. В 64-битной программе будут сравниваться 8 байт.

Верный код:

return FMemory::Memcmp(this, Other, sizeof(*this)) == 0;

На этом злоключения с функцией Memcmp() не заканчиваются. Рассмотрив дальнейший фрагмент кода:

D3D11_STATE_CACHE_INLINE void GetBlendState(
  ID3D11BlendState** BlendState, float BlendFactor[4],
  uint32* SampleMask)
{
  ....
  FMemory::Memcmp(BlendFactor, CurrentBlendFactor,
                  sizeof(CurrentBlendFactor));
  ....
}

Предупреждение PVS-Studio: V530 The return value of function ‘Memcmp’ is required to be utilized. d3d11statecacheprivate.h 547

Анализатор поразился, увидев, что итог работы функции Memcmp() никак не применяется. И подлинно, это оплошность. Как я понимаю, тут хотели копировать, а не сопоставлять данные. Тогда нужно применять функцию Memcpy():

FMemory::Memcpy(BlendFactor, CurrentBlendFactor,
                sizeof(CurrentBlendFactor));

Переменная присваивается сама себе

enum ECubeFace;
ECubeFace CubeFace;

friend FArchive& operator<<(
  FArchive& Ar,FResolveParams& ResolveParams)
{
  ....
  if(Ar.IsLoading())
  {
    ResolveParams.CubeFace = (ECubeFace)ResolveParams.CubeFace;
  }
  ....
}

Предупреждение PVS-Studio: V570 The ‘ResolveParams.CubeFace’ variable is assigned to itself. rhi.h 1279

Переменная ‘ResolveParams.CubeFace’ имеет тип ECubeFace. Применяется очевидное приведение переменной к типу ECubeFace. То есть ничего не происходит. Позже чего переменная присваивается сама себе. С этим кодом что-то не так.

Самая прекрасная из обнаруженных ошибок

Like

Огромнее каждого, мне понравилась вот эта оплошность:

bool VertInfluencedByActiveBone(
  FParticleEmitterInstance* Owner,
  USkeletalMeshComponent* InSkelMeshComponent,
  int32 InVertexIndex,
  int32* OutBoneIndex = NULL);

void UParticleModuleLocationSkelVertSurface::Spawn(....)
{
  ....
  int32 BoneIndex1, BoneIndex2, BoneIndex3;
  BoneIndex1 = BoneIndex2 = BoneIndex3 = INDEX_NONE;

  if(!VertInfluencedByActiveBone(
        Owner, SourceComponent, VertIndex[0], &BoneIndex1) &&
     !VertInfluencedByActiveBone(
        Owner, SourceComponent, VertIndex[1], &BoneIndex2) && 
     !VertInfluencedByActiveBone(
        Owner, SourceComponent, VertIndex[2]) &BoneIndex3)
  {
  ....
}

Предупреждение PVS-Studio: V564 The ‘&’ operator is applied to bool type value. You’ve probably forgotten to include parentheses or intended to use the ‘&&’ operator. particlemodules_location.cpp 2120

Подметить ошибку не легко. Я уверен, что вы бегло просмотрели код и не увидели в нём ничего подозрительного. Предупреждение анализатора, к сожалению, тоже необычное и кажется ложным срабатыванием. А между тем, мы имеем дело с дюже увлекательной оплошностью.

Давайте разберёмся. Обратите внимание, что конечный довод функции VertInfluencedByActiveBone() является необязательным.

В коде функция VertInfluencedByActiveBone() вызывается 3 раза. Два раза ей передаётся 4 довода. В последнем случае доводов каждого 3. В этом и есть оплошность.

Вследствие везению, код компилируется, и оплошность неприметна. Вот что тут получается:

  1. Вызывается функция с 3 доводами: «VertInfluencedByActiveBone(Owner, SourceComponent, VertIndex[2])»;
  2. К итогу функции используется оператор ‘!’;
  3. Итог выражения “!VertInfluencedByActiveBone(…)” имеет тип bool;
  4. К итогу используется оператор ‘&’ (bitwise AND);
  5. Всё удачно компилируется, так как слева от оператора ‘&’ находится выражение типа bool. Справа от ‘&’ находится целочисленная переменная BoneIndex3.

Анализатор заподозрил неладное, когда увидел, что один из доводов оператора ‘&’ является тип ‘bool’. Об этом он известил. И не напрасно.

Дабы поправить ошибку, необходимо добавить запятую и поместить закрывающуюся скобку в ином месте:

if(!VertInfluencedByActiveBone(
      Owner, SourceComponent, VertIndex[0], &BoneIndex1) &&
   !VertInfluencedByActiveBone(
      Owner, SourceComponent, VertIndex[1], &BoneIndex2) && 
   !VertInfluencedByActiveBone(
      Owner, SourceComponent, VertIndex[2], &BoneIndex3))

Позабыт оператор break

static void VerifyUniformLayout(....)
{
  ....
  switch(Member.GetBaseType())
  {
    case UBMT_STRUCT:  BaseTypeName = TEXT("struct"); 
    case UBMT_BOOL:    BaseTypeName = TEXT("bool"); break;
    case UBMT_INT32:   BaseTypeName = TEXT("int"); break;
    case UBMT_UINT32:  BaseTypeName = TEXT("uint"); break;
    case UBMT_FLOAT32: BaseTypeName = TEXT("float"); break;
    default:           
      UE_LOG(LogShaders, Fatal,
        TEXT("Unrecognized uniform ......"));
  };
  ....
}

Предупреждение PVS-Studio: V519 The ‘BaseTypeName’ variable is assigned values twice successively. Perhaps this is a mistake. Check lines: 862, 863. openglshaders.cpp 863

В самом начале позабыт оператор «break;». Думаю, комментарии и пояснения излишни.

Микрооптимизации

В анализаторе PVS-Studio имеется маленький комплект диагностических правил, которые помогают провести небольшие оптимизации в коде. Однако, изредка они крайнепригодны. Разглядим в качестве примера один из операторов присваивания:

FVariant& operator=( const TArray<uint8> InArray )
{
  Type = EVariantTypes::ByteArray;
  Value = InArray;
  return *this;
}

Предупреждение PVS-Studio: V801 Decreased performance. It is better to redefine the first function argument as a reference. Consider replacing ‘const… InArray’ with ‘const… &InArray’. variant.h 198

Не дюже отличная идея передавать массив по значению. Массив ‘InArray’ дозволено и необходимо передавать, применяя константную ссылку.

Анализатор выдаёт довольно много предупреждений, связанных с микрооптимизациями. Вдалеке не все из них окажутся пригодны, но на каждый случай приведу их списком: ue-v801-V803.txt.

Подозрительная сумма

uint32 GetAllocatedSize() const
{
  return UniformVectorExpressions.GetAllocatedSize()
      UniformScalarExpressions.GetAllocatedSize()
      Uniform2DTextureExpressions.GetAllocatedSize()
      UniformCubeTextureExpressions.GetAllocatedSize()
      ParameterCollections.GetAllocatedSize()
      UniformBufferStruct
        ?
        (sizeof(FUniformBufferStruct)  
         UniformBufferStruct->GetMembers().GetAllocatedSize())
        :
        0;
}

Предупреждение PVS-Studio: V502 Perhaps the ‘?:’ operator works in a different way than it was expected. The ‘?:’ operator has a lower priority than the ‘ ‘ operator. materialshared.h 224

Код крайне трудный. Дабы пояснить, что не так, я составлю упрощенный неестественный пример:

return A()   B()   C()   uniform ? UniformSize() : 0;

Вычисляется некоторый размер. В зависимости от значения переменной ‘uniform’, следует прибавлять ‘UniformSize()’ либо 0. На самом деле, данный код работает не так. Приоритет операторов сложения ‘ ‘ выше, чем приоритет оператора ‘?:’.

Вот, что получается:

return (A()   B()   C()   uniform) ? UniformSize() : 0;

Аналогичную обстановку мы отслеживаем и в коде Unreal Engine. Мне кажется, вычисляется не то, что хотел программист.

Путаница с enum

Я сначала не хотел описывать данный случай, так как необходимо привести довольно огромный фрагмент кода. Потом я всё-таки переборол лень. Умоляю потерпеть и читателей.

namespace EOnlineSharingReadCategory
{
  enum Type
  {
    None          = 0x00,
    Posts         = 0x01,
    Friends       = 0x02,
    Mailbox       = 0x04,
    OnlineStatus  = 0x08,
    ProfileInfo   = 0x10,  
    LocationInfo  = 0x20,
    Default       = ProfileInfo|LocationInfo,
  };
}

namespace EOnlineSharingPublishingCategory
{
  enum Type {
    None          = 0x00,
    Posts         = 0x01,
    Friends       = 0x02,
    AccountAdmin  = 0x04,
    Events        = 0x08,
    Default       = None,
  };

  inline const TCHAR* ToString
    (EOnlineSharingReadCategory::Type CategoryType)
  {
    switch (CategoryType)
    {
    case None:
    {
      return TEXT("Category undefined");
    }
    case Posts:
    {
      return TEXT("Posts");
    }
    case Friends:
    {
      return TEXT("Friends");
    }
    case AccountAdmin:
    {
      return TEXT("Account Admin");
    }
    ....
  }
}

Анализатор выдаёт тут сразу несколько предупреждений V556. Дело в том, что доводом оператора ‘switch()’ является переменная типа EOnlineSharingReadCategory::Type. При этом в операторах ‘case’ применяются значения от иного типа EOnlineSharingPublishingCategory::Type.

Логическая оплошность

const TCHAR* UStructProperty::ImportText_Internal(....) const
{
  ....
  if (*Buffer == TCHAR('"'))
  {
    while (*Buffer && *Buffer != TCHAR('"') &&
           *Buffer != TCHAR('n') && *Buffer != TCHAR('r'))
    {
      Buffer  ;
    }

    if (*Buffer != TCHAR('"'))
  ....
}

Предупреждение PVS-Studio: V637 Two opposite conditions were encountered. The second condition is always false. Check lines: 310, 312. propertystruct.cpp 310

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

  • Если находим двойную кавычку, то запускаем цикл.
  • В цикле попускаем символы до тех пор, пока не встретим следующую двойную кавычку.

Оплошность в том, что позже нахождения первой двойственный кавычки, мы не сдвигаем указатель на дальнейший символ. В итоге, сразу находится вторая кавычка. Цикл не запускается.

Поясню это на упрощенном коде:

if (*p == '"')
{
  while (*p && *p != '"')
      p  ;
}

Дабы поправить ошибку, нужно сделать следующие метаморфозы:

if (*p == '"')
{
  p  ;
  while (*p && *p != '"')
      p  ;
}

Подозрительный сдвиг

class FMallocBinned : public FMalloc
{
  ....
  /* Used to mask off the bits that have been used to
     lookup the indirect table */
  uint64 PoolMask;
  ....
  FMallocBinned(uint32 InPageSize, uint64 AddressLimit)
  {
    ....
    PoolMask = ( ( 1 << ( HashKeyShift - PoolBitShift ) ) - 1 );
    ....
  }
}

Предупреждение PVS-Studio: V629 Consider inspecting the ’1 << (HashKeyShift — PoolBitShift)’ expression. Bit shifting of the 32-bit value with a subsequent expansion to the 64-bit type. mallocbinned.h 800

Есть тут оплошность либо нет, зависит от того, необходимо ли сдвигать 1 больше чем на 31 разряд. Так как итог помещается в 64-битную переменную PoolMask, видимо такая надобность есть.

Если я угадал, то в библиотеке имеется оплошность в подсистеме выделения памяти.

Число 1 имеет тип int. Это значит, что сдвинуть 1, скажем на 35 разрядов, невозможно. Официально, возникнет неопределённое поведение (подробности). На практике, произойдёт переполнение и будет вычислено неправильное значение.

Верный код:

PoolMask = ( ( 1ull << ( HashKeyShift - PoolBitShift ) ) - 1 );

Устаревшие проверки

void FOculusRiftHMD::Startup()
{
  ....
  pSensorFusion = new SensorFusion();
  if (!pSensorFusion)
  {
    UE_LOG(LogHMD, Warning,
      TEXT("Error creating Oculus sensor fusion."));
    return;
  }
  ....
}  

Предупреждение PVS-Studio: V668 There is no sense in testing the ‘pSensorFusion’ pointer against null, as the memory was allocated using the ‘new’ operator. The exception will be generated in the case of memory allocation error. oculusrifthmd.cpp 1594

Теснее давным-давно, в случае ошибки выделения памяти, оператор ‘new’ генерирует исключение. Проверка «if (!pSensorFusion)» не необходима.

В крупных планах я, как правило, встречаю дюже много таких мест. На изумление, в Unreal Engine таких мест немного. Список: ue-V668.txt.

Copy-Paste

Следующие фрагменты кода, скорее каждого, получились из-за применения методологии Copy-Paste. Самостоятельно от данные, выполняется одно и то же действие:

FString FPaths::CreateTempFilename(....)
{
  ....  
  const int32 PathLen = FCString::Strlen( Path );
  if( PathLen > 0 && Path[ PathLen - 1 ] != TEXT('/') )
  {
    UniqueFilename =
      FString::Printf( TEXT("%s/%s%s%s"), Path, Prefix,
                       *FGuid::NewGuid().ToString(), Extension );
  }
  else
  {
    UniqueFilename =
      FString::Printf( TEXT("%s/%s%s%s"), Path, Prefix,
                       *FGuid::NewGuid().ToString(), Extension );
  }
  ....
}

Предупреждение PVS-Studio: V523 The ‘then’ statement is equivalent to the ‘else’ statement. paths.cpp 703

Ещё один такой фрагмент кода:

template< typename DefinitionType >            
FORCENOINLINE void Set(....)
{
  ....
  if ( DefinitionPtr == NULL )
  {
    WidgetStyleValues.Add( PropertyName,
      MakeShareable( new DefinitionType( InStyleDefintion ) ) );
  }
  else
  {
    WidgetStyleValues.Add( PropertyName,
      MakeShareable( new DefinitionType( InStyleDefintion ) ) );
  }
}

Предупреждение PVS-Studio: V523 The ‘then’ statement is equivalent to the ‘else’ statement. slatestyle.h 289

Различное

Осталась различная мелочь. Описывать её не увлекательно. Легко приведу фрагменты кода и диагностические сообщения.

void FNativeClassHeaderGenerator::ExportProperties(....)
{
  ....
  int32 NumByteProperties = 0;
  ....
  if (bIsByteProperty)
  {
    NumByteProperties;
  }
  ....
}

Предупреждение PVS-Studio: V607 Ownerless expression ‘NumByteProperties’. codegenerator.cpp 633

static void GetModuleVersion( .... )
{
  ....
  char* VersionInfo = new char[InfoSize];
  ....
  delete VersionInfo;
  ....
}

Предупреждение PVS-Studio: V611 The memory was allocated using ‘new T[]‘ operator but was released using the ‘delete’ operator. Consider inspecting this code. It’s probably better to use ‘delete [] VersionInfo;’. windowsplatformexceptionhandling.cpp 107

const FSlateBrush* FSlateGameResources::GetBrush(
  const FName PropertyName, ....)
{
  ....
  ensureMsgf(BrushAsset, TEXT("Could not find resource '%s'"),
             PropertyName);
  ....
}

Предупреждение PVS-Studio: V510 The ‘EnsureNotFalseFormatted’ function is not expected to receive class-type variable as sixth actual argument. slategameresources.cpp 49

Итоги

Применять статический анализатор, встроенный в Visual Studio, благотворно, но не довольно. Умно применять и специализированные инструменты, такие как наш анализатор PVS-Studio. Скажем, если сопоставлять PVS-Studio и статический анализатора из VS2013, то PVS-Studio находит в 6 раз огромнее ошибок. Дабы не быть голословным:

  1. Сопоставление анализаторов кода: CppCat, Cppcheck, PVS-Studio, Visual Studio;
  2. Методика сопоставления.

Приглашаю всех ценителей добротного кода испробовать наш анализатор кода.

P.S. Хочу подметить, что ошибки, описанные в статье (помимо микрооптимизаций), дозволено было бы обнаружить и с поддержкой облегченного анализатора CppCat. Цена годовой лицензии CppCat равна $250. Продление $200. Но CppCat не подойдёт как раз из-за того, что он облегченный. В нём отсутствует инструментарий для отслеживания запусков компилятора, что значимо при проверке Unreal Engine. Но для маленьких планов, анализатора CppCat может быть больше чем довольно.

Эта статья на английском

Если хотите поделиться этой статьей с англоязычной аудиторией, то умоляю применять ссылку на перевод: Andrey Karpov. A Long-Awaited Check of Unreal Engine 4.

Прочитали статью и есть вопрос?

Зачастую к нашим статьям задают одни и те же вопросы. Результаты на них мы собрали тут: Результаты на вопросы читателей статей про PVS-Studio и CppCat, версия 2014. Пожалуйста, ознакомьтесь со списком.

 

Источник: programmingmaster.ru

 

Оставить комментарий
Форум phpBB, русская поддержка форума phpBB
Рейтинг@Mail.ru 2008 - 2017 © BB3x.ru - русская поддержка форума phpBB