Введение
Я занимаюсь разработкой SilentPatch, исправляющего ошибки старых игр серии GTA и других игр. В issue tracker проекта на GitHub я получил недавно очень специфичный отчёт о баге:
Самолёта Skimmer нет в Windows 11 24H2
Когда я обновил Windows до версии 24H2, самолёт Skimmer полностью пропал из игры. Его невозможно создать с помощью трейнера или найти на обычных точках спауна. Я играю и в версию с модами (которая до обновления Windows была абсолютно нормальной), и в «ванильную» с единственным установленным silentpatch (я пробовал версии silentpatch за 2018 год, 2020 год и самую новую). Самолёт всё равно не спаунится в игре.
Если бы я услышал о подобном впервые, то посчитал бы сомнительным и заподозрил, что дело может быть в чём-то другом, а не конкретно в Windows 11 24H2. Однако на GTAForums я получал комментарии точно о такой же проблеме с ноября прошлого года. Некоторые из пользователей винили в ней SilentPatch, однако другие говорили, что то же самое происходит и в игре без модов:
Очевидно, Skimmer не может заспауниться при игре в Windows 11 24h2; надеюсь, этот баг устранят.
Дополнение: кажется, я подтвердил это — создал виртуальную машину с Windows 11 23h2, и этот чёртов самолёт замечательно спаунится; апдейт той же виртуальной машины до 24h2 ломает Skimmer. Остаётся только догадываться, почему небольшое обновление операционной системы в 2024 году ломает какой-то левый самолёт в игре 2005 года.
После нового обновления Silent patch из игры пропадает Skimmer, а когда я пытаюсь создать его с помощью RZL-Trainer или Cheat Menu пользователя Grinch, игра зависает и приходится закрывать её через Диспетчер задач.
[…] Я был вынужден обновиться до 24H2, и после апдейта у меня возникла та же проблема со Skimmer в GTA SA, что и у остальных. Это значит, что проблему вызывают не моды или что-то другое: она возникла после свежего обновления Windows.
На моём домашнем PC по-прежнему стоит Windows 10 22H2, а на рабочем компьютере — Windows 11 23H2, поэтому неудивительно, что ни на одной из машин не удалось воссоздать проблему — Skimmer отлично спаунился на воде; его можно было создать через скрипт и CJ мог забираться в кресло пилота.
Тем не менее, я попросил нескольких людей, обновившихся до 24H2, протестировать это на их машинах, и у них всех возник этот баг. Попытки «удалённой» отладки общением через чат ни к чему не привели, поэтому я создал собственную виртуальную машину с 24H2. Я скопировал игру на машину, настроил удалённую отладку из операционной системы хоста, отправился в привычное место спауна Skimmer и, разумеется, его там не было. Все остальные самолёты и лодки создавались правильно, но не он:


Затем я попытался создать Skimmer скриптом и залезть в него, но меня забросило в небо на 1.0287648030984853e+0031
= 10,3 нониллиона метров, или 10,3 октиллиона километров, или 1,087 квадриллиона световых лет.

С установленным SilentPatch игра вскоре после запуска игрока в небо зависала, потому что код игры застревал в цикле. Без SilentPatch игра не зависала, но была подвержена знаменитому эффекту «выгорания», возникающему, когда камера запускается в бесконечность или близкое к нему значение. Забавно, что мы всё равно можем опознать форму самолёта, хотя анимации полностью отключаются из-за погрешностей значений с плавающей запятой:


Изучаем баг
Что поломалось?
Теперь можно не гадать: я знаю, что это реальный баг, и мне нужно найти его первопричину. Учитывая количество игр, у которых возникли проблемы с этой версией операционной системы, на этом этапе было невозможно сказать, виновата ли игра или я столкнулся с багом API, появившимся в 24H2.
Начинать мне было особо не с чего, но то, что игра зависала с установленным SilentPatch, дало мне точку отсчёта. После того, как игрок забирается в самолёт, игра зависает в очень маленьком цикле в CPlane::PreRender
, пытаясь нормализовать угол лопастей ротора в диапазоне 0-360 градусов:
this->m_fBladeAngle = CTimer::ms_fTimeStep * this->m_fBladeSpeed + this->m_fBladeAngle;
while (this->m_fBladeAngle > 6.2831855)
{
this->m_fBladeAngle = this->m_fBladeAngle - 6.2831855;
}
В режиме отладки this->m_fBladeSpeed
имела значение 3.73340132e+29
. Очевидно, это значение огромно, из-за чего уменьшать его на 6.2831855
становится совершенно неэффективно из-за экспонент этих двух значений1. Но почему скорость лопастей становится такой высокой? Скорость вычисляется по следующей формуле:
this->m_fBladeSpeed = (v34 - this->m_fBladeSpeed) * CTimer::ms_fTimeStep / 100.0 + this->m_fBladeSpeed;
где v34
пропорционально координате высоты самолёта. Это согласуется с первоначальными наблюдениями — как говорилось выше, эффект «выгорания» обычно происходит, когда камера находится очень далеко от центра карты или на огромной высоте.
Из-за чего самолёт взлетает так высоко? Есть два варианта:
-
Самолёт изначально спаунится высоко в небе.
-
Самолёт спаунится на уровне земли, а в следующем кадре взмывает в небо.
Для этого теста я создавал Skimmer сам при помощи скрипта, поэтому мог начать с функции, используемой в интерпретаторе SCM (скриптов) игры под названием CCarCtrl::CreateCarForScript
. Эта функция порождает транспортное средство с указанным ID в заданных координатах. Они берутся из моего тестового скрипта, поэтому я точно знаю, что они корректны. Однако эта функция немного изменяет переданную координату Z:
if (posZ <= 100.0)
{
posZ = CWorld::FindGroundZForCoord(posX, posY);
}
posZ += newVehicle->GetDistanceFromCentreOfMassToBaseOfModel();
В CEntity::GetDistanceFromCentreOfMassToBaseOfModel
содержится множество путей выполнения кода; используемый в данном случае просто получает обратное максимальное значение по Z ограничивающего параллелепипеда модели:
return -CModelInfo::ms_modelInfoPtrs[this->m_wModelIndex]->pColModel->bbox.sup.z;
Я начал подозревать, что значение некорректно, поэтому заглянул в значения параллелепипеда Skimmer и обнаружил, что максимальное значение по Z действительно повреждено:
- *(RwBBox**)0x00B2AC48 RwBBox *
- sup RwV3d
x -5.39924574 float
y -6.77431822 float
z -4.30747210e+33 float
- inf RwV3d
x 5.42313004 float
y 4.02343750 float
z 1.87021971 float
Если бы были искажены все компоненты параллелепипеда, то можно было бы заподозрить повреждение памяти, например, если другой код выходит за границы и перезаписывает эти значения, но повреждается именно sup.z
, а оно стоит не первым и не последним полем в параллелепипеде. У меня снова возникло два варианта:
-
Файл коллизий считывается некорректно и некоторые поля остаются неинициализированными или считывают несвязанные данные вместо значений параллелепипеда. Крайне маловероятно, но не невозможно, учитывая то, что проблема потенциально может быть вызвана багом операционной системы.
-
Ограничивающий параллелепипед считывается корректно, но затем полю присваивается совершенно некорректное значение.
Точка останова по доступу к данным в pColModel
показала, что в момент первоначальной настройки ограничивающий параллелепипед корректен, а значение координаты Z вполне приемлемо:
- *(RwBBox**)0x00B2AC48 RwBBox *
- sup RwV3d
x -5.39924574 float
y -6.77431822 float
z -2.21952772 float
- inf RwV3d
x 5.42313004 float
y 4.02343750 float
z 1.87021971 float
Оказалось, что при первой генерации транспортного средства с определённой моделью игра в функции SetupSuspensionLines
, задаёт его подвеску и изменяет координату Z параллелепипеда, чтобы она соответствовала естественной высоте подвески машины:
if (pSuspensionLines[0].p1.z < colModel->bbox.sup.z)
{
colModel->bbox.sup.z = pSuspensionLines[0].p1.z;
}
И здесь начинается первая ошибка. Строки подвески вычисляются с использованием координат из handling.cfg
и параметра масштаба колеса wheelScale из vehicles.ide
:
for (int i = 0; i < 4; i++)
{
CVector posn;
modelInfo->GetWheelPosn(i, posn);
posn.z += pHandling->fSuspensionUpperLimit;
colModel->lines[i].p0 = posn;
float wheelScale = i != 0 && i != 2 ? modelInfo->m_frontWheelScale : modelInfo->m_rearWheelScale;
posn.z += pHandling->fSuspensionLowerLimit - pHandling->fSuspensionUpperLimit;
posn.z -= wheelScale / 2.0;
colModel->lines[i].p1 = posn;
}
Я знал, что colModel->lines[0].p1
повреждено, поэтому виновником могла быть pHandling->fSuspensionLowerLimit
, pHandling->fSuspensionUpperLimit
, или wheelScale
. Значения handling.cfg
Skimmer не отличаются от значений любого другого самолёта в игре, но в vehicles.ide
я заметил нечто любопытное. Строка Skimmer выглядит так:
460, skimmer, skimmer, plane, SEAPLANE, SKIMMER, null, ignore, 5, 0, 0
Сравните её со строкой любого другого самолёта в игре, например, Rustler:
476, rustler, rustler, plane, RUSTLER, RUSTLER, rustler, ignore, 10, 0, 0, -1, 0.6, 0.3, -1
Строка короче и в ней отсутствуют последние четыре параметра; более того, два из отсутствующих параметров — это масштаб переднего и заднего колёс! Это нормально для водного транспорта, но Skimmer — единственный самолёт, у которого нет этих параметров.
Решает ли проблему с гидросамолётом добавление этих параметров? Как ни удивительно, да!

Но почему и как?
У меня есть правдоподобное объяснение того, почему Rockstar совершила эту ошибку в данных — в Vice City самолёт Skimmer определён как водный транспорт (boat), а потому у него не заданы эти значения! Когда в San Andreas разработчики заменили тип транспортного средства Skimmer на самолёт (plane), кто-то забыл добавить эти теперь уже необходимые параметры. Так как игра редко проверяет полноту своих данных, эта ошибка осталась незамеченной.
Проблема решена? Не совсем: мне нужно устранить её через код SilentPatch. Изучив псевдокод CFileLoader::LoadVehicleObject
, я выяснил истинную природу бага: игра предполагает, что все параметры всегда присутствуют в строке определения и не использует никаких значений по умолчанию, за исключением двух параметров, а также не проверяет значение, возвращаемое sscanf
, поэтому в случае всех судов и Skimmer эти параметры остаются неинициализированными:
void CFileLoader::LoadVehicleObject(const char* line)
{
int objID = -1;
char modelName[24];
char texName[24];
char type[8];
char handlingID[16];
char gameName[32];
char anims[16];
char vehClass[16];
int frq;
int flags;
int comprules;
int wheelModelID; // Не инициализировано!
float frontWheelScale, rearWheelScale; // Не инициализировано!
int wheelUpgradeClass = -1; // Забавно, что ЭТО инициализировано
int TxdSlot = CTxdStore::FindTxdSlot("vehicle");
if (TxdSlot == -1)
{
TxdSlot = CTxdStore::AddTxdSlot("vehicle");
}
sscanf(line, "%d %s %s %s %s %s %s %s %d %d %x %d %f %f %d", &objID, modelName, texName, type, handlingID,
gameName, anims, vehClass, &frq, &flags, &comprules, &wheelModelID, &frontWheelScale, &rearWheelScale,
&wheelUpgradeClass);
// Другая обработка...
}
Судя по симптомам, эти неинициализированные значения принимали небольшие валидные значения с плавающей запятой вплоть до недавнего времени, когда в Windows 11 24H2 они взбрыкнули и перепутали вычисления ограничивающего параллелепипеда.
В SilentPatch устранить эту проблему было просто – я обернул этот вызов sscanf
и задал для готовых четырёх параметров приемлемые значения по умолчанию:
static int (*orgSscanf)(const char* s, const char* format, ...);
static int sscanf_Defaults(const char* s, const char* format, int* objID, char* modelName, char* texName, char* type,
char* handlingID, char* gameName, char* anims, char* vehClass, int* frequency, int* flags, int* comprules,
int* wheelModelID, float* frontWheelSize, float* rearWheelSize, int* wheelUpgradeClass)
{
*wheelModelID = -1;
// Ниже я объясню, почему здесь 0.7, а не 1.0
*frontWheelSize = 0.7;
*rearWheelSize = 0.7;
*wheelUpgradeClass = -1;
return orgSscanf(s, format, objID, modelName, texName, type, handlingID, gameName, anims, vehClass,
frequency, flags, comprules, wheelModelID, frontWheelSize, rearWheelSize, wheelUpgradeClass);
}
Проблема решена! Ещё одна победа патча, повышающая совместимость.
Если бы это был обычный баг, то на этом я бы закончил пост. Однако в данном случае решение вызвало ещё больше вопросов – почему всё это поломалось именно сейчас? Почему игра двадцать лет нормально работала, несмотря на эту проблему, но новый апдейт Windows 11 внезапно изменил статус-кво?
И ещё один вопрос: причиной стала какая-то проблема в Windows 11 24H2 или это просто неудачное стечение обстоятельств?
Здесь водятся драконы – истинная первопричина
Зарываемся глубже
На данный момент рабочая теория была такой: неинициализированные локальные переменные в CFileLoader::LoadVehicleObject
имели приемлемые значения до тех пор, пока в Windows 11 24H2 что-то не поменялось, и эти значения не стали «неприемлемыми». Я точно знал, что причина не в CRT (а значит, и не в вызове sscanf
) – San Andreas использует статически компилируемую CRT, а потому хотфиксы уровня операционной системы к ней не применяются. Однако учитывая множество улучшений в сфере безопасности в Windows 11, я не стал бы исключать того, что одно из таких улучшеий, например, Kernel-mode Hardware-enforced Stack Protection, перемешивает стек так, что это не нравится забагованной функции игры.
Я провёл эксперимент: установил в отладчике контрольную точку перед вызовом sscanf
при парсинге строки Skimmer (ID транспортного средства 460), и наблюдаемые значения подтвердили мою догадку. На моей машине с Windows 10 они оба были равны 0.7
:
frontWheelSize 0x01779f14 {0.699999988}
rearWheelSize 0x01779f10 {0.699999988}
А в виртуальной машине с Win11 24H2 они становились огромными, сравнимыми по порядку величин с ошибочными значениями, которые мы ранее видели у ограничивающего параллелепипеда. Кроме того, по какой-то причине указатель стека сместился на 4 байта, но вряд ли это связано с проблемой, вероятно, это вызвано некими изменениями в бойлерплейте запуска потоков внутри kernel32.dll
:
frontWheelSize 0x01779f18 {7.84421263e+33}
rearWheelSize 0x01779f14 {4.54809690e-38}
Мне стало любопытно – 0.7
это слишком уж хорошее значение для числа с плавающей запятой, полученного интерпретацией случайного мусора из стека; гораздо вероятнее, что это реальное значение с плавающей запятой, находящееся в стеке на своём месте. Затем я изучил в vehicles.ide
определение автомобиля TopFun – транспортного средства, идущего непосредственно перед Skimmer. И его значение масштаба колеса тоже оказалось равным 0.7
!
459, topfun, topfun, car, TOPFUN, TOPFUN, van, ignore, 1, 0, 0, -1, 0.7, 0.7, -1
vehicles.ide
парсится по порядку в функции, работающей примерно так (псевдокод):
void CFileLoader::LoadObjectTypes(const char* filename)
{
// Открываем файл...
while ((line = fgets(file)) != NULL)
{
// Парсим индикаторы разделов...
switch (section)
{
// Различные разделы...
case SECTION_CARS:
LoadVehicleObject(line);
break;
}
}
}
Похоже, код каким-то образом сохранил старые значения масштаба колеса, поэтому размер колёс Skimmer оказался таким же, как у Topfun. Чтобы убедиться в этом, я провёл ещё один эксперимент:
-
Снова установил контрольную точку перед вызовом
sscanf
, но на этот раз перед парсингом строки Topfun (ID транспортного средства 459). -
Установил контрольные точки записи в
frontWheelScale
иrearWheelScale
. -
Продолжил выполнение, пока игра не добиралась до парсинга определения следующего транспортного средства.
Windows 10 подтвердила мою гипотезу – между вызовами CFileLoader::LoadVehicleObject
в эти значения стека ничего не записывалось, поэтому функция, по сути, сохраняла (хоть и непреднамеренно) значения масштаба колеса между идущими по порядку вызовами!
При повторении того же теста в Windows 11 24H2 сработала контрольная точка записи! Однако она была никак не связана с функциями безопасности: значения стека переписывались… функцией LeaveCriticalSection
внутри fgets
:
> ntdll.dll!_RtlpAbFindLockEntry@4() Unknown
ntdll.dll!_RtlAbPostRelease@8() Unknown
ntdll.dll!_RtlLeaveCriticalSection@4() Unknown
gta_sa.exe!fgets() Unknown
Похоже, изменения в Windows 11 24H2 модифицировали внутреннюю работу Critical Section Object, и теперь код разблокировки критического раздела использует больше пространства стека, чем старый. Я провёл ещё один эксперимент, сравнив изменения пространства стека, происходящие после sscanf
внутри LoadVehicleObject
до следующего вызова этой функции. Изменившиеся значения выделены красным:

0x3F449BA6
= 0.768
(на скриншоте выделены). Они соответствуют масштабам колёс Landstalker.
Именно это доказательство мне и было нужно – обратите внимание, что в Windows 10 некоторые локальные переменные даже заметны глазом (например, класс транспортных средств normal
), а в Windows 11 они полностью исчезли. Также стоит отметить, что даже в Windows 10 следующая за масштабами колёс локальная переменная перезаписана LeaveCriticalSection
, то есть не хватило всего 4 байтов, чтобы этот баг не проявился ещё несколько лет назад! Нам безумно повезло.
Чей это стек?
Чтобы разобраться, почему игра могла так долго работать с этим багом, нужно показать, как стек меняется между вызовами. Допустим, после вызова LoadVehicleObject
стек выглядит так. Интересующие нас локальные переменные выделены:
адрес возврата из |
локальные переменные |
адрес возврата из |
локальные переменные |
frontWheelScale |
rearWheelScale |
другие локальные переменные… |
Вызов fgets
, а значит, и LeaveCriticalSection
, идущий за вызовом LoadVehicleObject
, использует пространство стека, ранее занятое этой функцией, потому что срок жизни функции стека ограничен длительностью выполнения самой функции и после её завершения пространство снова можно занимать. В Windows 10 после выполнения возврата из fgets
и LeaveCriticalSection
стек выглядел так:
адрес возврата из |
локальные переменные |
адрес возврата из |
🟨локальные переменные |
🟨адрес возврата из |
🟨локальные переменные |
frontWheelScale |
rearWheelScale |
другие локальные переменные… |
Части, помеченные 🟨, перезаписывают то, что было пространством стека LoadVehicleObject
, но обратите внимание, что они не достигают той области стека, где хранятся масштабы колёс. В Windows 11 24H2 LeaveCriticalSection
занимает большое пространства стека, поэтому это пространство выглядит так:
адрес возврата из |
локальные переменные |
адрес возврата из |
🟨локальные переменные |
🟨адрес возврата из |
🟨локальные переменные |
🟥frontWheelScale перезаписана! |
🟥rearWheelScale перезаписана! |
другие локальные переменные… |
Выделенные красным части стека теперь тоже повреждены, хотя в прошлом они оставались нетронутыми; к этим частям относятся и масштабы колёс, считанные предыдущим вызовом LoadVehicleObject
! Это, в свою очередь, выявляет баг, вызванный тем, что переменные не были инициализированы, а поскольку sscanf
не может считать эти значения из определения Skimmer в vehicles.ide
, они остаются в виде того же мусора и распространяются дальше на данные транспортных средств.
Какова была вероятность того, что это поломается только сейчас? Чёртова Windows 11!
Надо чётко сказать следующее: все эти открытия доказывают, что этот баг – НЕ проблема Windows 11 24H2, потому что такие аспекты, как способ использования стека внутренними функциями WinAPI, не относятся к контракту и могут меняться в любой момент без предупреждений. Истинная проблема здесь в том, что игра полагалась на неопределённое поведение (неинициализированные локальные переменные) и, откровенно говоря, я поражён тем, что этот баг не всплыл в таком количестве версий операционных систем, хотя, как и говорилось выше, был очень близок к этому. San Andreas поддерживала ещё Windows 98, то есть баг оставался незамеченным как минимум в дюжине разных версий Windows и в гораздо большем количестве релизов Wine!
…Впрочем, так ли это? Мне показалось очень маловероятным, что в игре не возникала эта проблема ни на одной из множества платформ, где она была выпущена, поэтому я поискал в двоичных файлах некоторых других релизов. Этот баг не был устранён в официальном патче 1.01 для PC, но устранён в релизе для первого Xbox, где, почти как и в моём фиксе, в код было добавлено «приемлемое значение по умолчанию», равное 1.0
. Это исправление было «унаследовано» многими последующими версиями San Andreas, в том числе:
-
Steam 3.0, newsteam и RGL, так как все они основаны на ветви кода для Xbox.
-
Всеми релизами War Drum Studios, в том числе для Android, X360 и PS3.
-
Definitive Edition.
Однако, в отличие от Rockstar, я решил по умолчанию использовать для масштаба колеса значение 0.7
, а не 1.0
. На то было несколько причин:
-
До моего исправления это был фактический масштаб колеса Skimmer на PC (и, возможно, на PS2), соответствующий масштабу колеса Topfun.
-
У двух других плавучих транспортных средств, не относящихся к лодкам, Sea Sparrow и Vortex, масштаб колеса тоже равен
0.7
. -
Многие легковые автомобили в игре имеют масштаб колеса
0.7
.
Я хочу, чтобы это исправили в моей игре!
Код с исправлением будет включён в следующий хотфикс SilentPatch, а пока вы можете легко устранить баг самостоятельно, отредактировав vehicles.ide
:
-
Найдите в папке San Andreas файл
data\vehicles.ide
и откройте его в Блокноте. -
Перейдите к строке Skimmer, начинающейся с
460, skimmer
. -
Замените исходную строку на следующую:
460, skimmer, skimmer, plane, SEAPLANE, SKIMMER, null, ignore, 5, 0, 0, -1, 0.7, 0.7, -1
-
Сохраните файл.
В заключение
Давно мне не встречался такой интересный баг. Поначалу я сильно сомневался, что подобный баг может быть связан с конкретным релизом операционной системы, но оказался не прав. В конечном итоге, это был простой баг San Andreas, и эта функция не должна была никогда работать правильно; тем не менее, на PC пряталась в течение двух десятков лет.
Это интересный урок с точки зрения совместимости: даже изменения в структуре стека внутренних реализаций могут влиять на совместимость, если приложение имеет баги и ненамеренно полагается на конкретное поведение. Я не в первый раз сталкиваюсь с подобными проблемами: мои постоянные читатели могут помнить Bully: Scholarship Edition, которая ломалась в Windows 10 по тем же самым причинам. Как и в этом случае, Bully изначально не должна была работать, но вместо этого он годами полагался на некорректные допущения, пока изменения в Windows 10 наконец не оборвали её полосу удач.
Это ещё раз стало нам напоминанием:
-
Валидируйте входящие данные – San Andreas справлялась с этим чудовищно плохо, и в конечном итоге именно из-за этого неполная строка конфигурации осталась незамеченной.
-
Не игнорируйте предупреждения компилятора – этот код с большой вероятностью вызывал предупреждения в коде игры, которые игнорировали или отключили!
В конечном итоге, игрокам в GTA повезло: во многих других играх подобные ошибки остались бы неустранёнными и превратились бы в легенду. К счастью, игры серии GTA позволяют использовать моддинг, поэтому мы можем решать подобные проблемы и обеспечивать работоспособность игры в будущем.
-
Иными словами, из-за способа представления значений с плавающей запятой вычитание малого значения с плавающей запятой из огромного может вообще не изменить результат.