Введение
Я занимаюсь разработкой 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 позволяют использовать моддинг, поэтому мы можем решать подобные проблемы и обеспечивать работоспособность игры в будущем.
Иными словами, из-за способа представления значений с плавающей запятой вычитание малого значения с плавающей запятой из огромного может вообще не изменить результат.