Введение
Я занимаюсь разработкой 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**)0x00B2AC48RwBBox * - supRwV3d x-5.39924574float y-6.77431822float z-4.30747210e+33float - infRwV3d x5.42313004float y4.02343750float z1.87021971float
Если бы были искажены все компоненты параллелепипеда, то можно было бы заподозрить повреждение памяти, например, если другой код выходит за границы и перезаписывает эти значения, но повреждается именно sup.z, а оно стоит не первым и не последним полем в параллелепипеде. У меня снова возникло два варианта:
-
Файл коллизий считывается некорректно и некоторые поля остаются неинициализированными или считывают несвязанные данные вместо значений параллелепипеда. Крайне маловероятно, но не невозможно, учитывая то, что проблема потенциально может быть вызвана багом операционной системы.
-
Ограничивающий параллелепипед считывается корректно, но затем полю присваивается совершенно некорректное значение.
Точка останова по доступу к данным в pColModel показала, что в момент первоначальной настройки ограничивающий параллелепипед корректен, а значение координаты Z вполне приемлемо:
- *(RwBBox**)0x00B2AC48RwBBox * - supRwV3d x-5.39924574float y-6.77431822float z-2.21952772float - infRwV3d x5.42313004float y4.02343750float z1.87021971float
Оказалось, что при первой генерации транспортного средства с определённой моделью игра в функции 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 позволяют использовать моддинг, поэтому мы можем решать подобные проблемы и обеспечивать работоспособность игры в будущем.
-
Иными словами, из-за способа представления значений с плавающей запятой вычитание малого значения с плавающей запятой из огромного может вообще не изменить результат.
ссылка на оригинал статьи https://habr.com/ru/articles/903878/
Добавить комментарий