[Перевод] Фиксим 21-летнюю игру

c3fd50983d97cd201ab76989e118315c.jpeg

Несколько недель назад я копался в своей коллекции старых CD и нашёл диск с Salt Lake 2002. В последний раз я играл в неё много лет назад, поэтому решил попробовать снова.

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

Вот очень краткое объяснение двух проблем и их быстрых решений:

  1. Недостаточно памяти при установке: из-за того, что установщик вызывает очень старую функцию Windows, в системах, где «слишком много» ОЗУ (больше 4 ГиБ), она считает, что доступно отрицательное количество физической памяти, что меньше минимально требуемых 64 МБ, поэтому установщик прекращает работу.

    Решение:
    Можно просто скопировать файлы игры на жёсткий диск без необходимости запуска установщика. Необходимы папки Movies/,  Music/,  SOUNDS/ и файлы FILES.IMG,  COMM.OGG и SaltLake2002.exe. Если у вас, как и у меня, американская сборка игры, то рекомендую заменить защищённый SafeDisc файл SaltLake2002.exe на исправленный EXE (мою версию или beha_r), чтобы вам не пришлось устанавливать драйвер SafeDisc (который всё равно не работает на новых системах и создаёт огромную дыру в безопасности). При этом вам не придётся монтировать диск, чтобы запускать игру.

    Если у вас нет американской версии или вы обязательно хотите пройти процедуру установки, позволяющую проникнуть в систему давно устаревшим и уязвимым драйверам, то можно исправить файл скрипта установщика (setup.inx), чтобы он не прерывал установку. Для этого можно воспользоваться приложенным ниже исправленным setup.inx — просто скопируйте все файлы с диска в локальную папку жёсткого диска, замените setup.inx моей версией и запустите установщик оттуда. Установщику не важно, что он запущен не с диска. Также мой модифицированный setup.inx можно скачать отсюда:
    https://mega.nz/file/IghmiQbI#IvfeRlTsICPajkN… YT02M-6j2SfC81Q

  2. Вылет на рабочий стол после запуска игры: когда игра проверяет количество доступной видеопамяти, она повреждает стек программ из-за бага, который срабатывает, только если у вас «слишком много» VRAM (больше, чем ожидали разработчики игры). Повреждённая память приводит к сбою, от которого не возможно восстановить программу.

    Решение:
    Просто использовать для запуска игры современный исправленный EXE. Мой можно скачать по ссылке в конце статьи или отсюда: https://mega.nz/file/Y1ASAAbJ#jJPr19th3pICz7Y… oOkMk7k-GDUSHr0

Это было краткое описание. Ниже приведены технические подробности для тех, кому они интересны:

Недостаточно памяти при установке

Когда установщик игры запрашивает у системы количество доступной физической ОЗУ, он использует древний, давно устаревший Windows API под названием GlobalMemoryStatus, который, по сути, не может считать выше 4 ГиБ, потому что хранит результат как 32-битный integer.

То есть если у вас ОЗУ больше, чем 4 ГиБ, этот API из-за переполнения integer вернёт отрицательное число, о чём даже упоминает Microsoft в документации к API. В свою очередь, когда скрипт установщика получает от Windows отрицательное число и сравнивает его с 64 МБ (потому что это минимально возможный объём памяти для игры), то он считает, что это отрицательное количество памяти меньше и прекращает установку с сообщением об ошибке «There is not enough memory available to install this application. Setup will now exit».

Скрипт установщика написан таким образом, что он, к сожалению, не понимает, что получил очевидно ошибочное отрицательное число ОЗУ. Скорее всего, разработчики игры не ожидали, что она будет запускаться на система с более чем 4 ГиБ памяти. А если даже они ожидали этого и могли бы использовать более готовый к будущему API (GlobalMemoryStatusEx), который на то время уже существовал, то они, вероятно, выбрали бы старый, потому что новый API не поддерживался в Windows 95 и 98.

Установщик выполняет эти проверки согласно скомпилированному файлу скрипта InstallShield (он же InstallScript), который находится на диске в файле setup.inx.

Вот фрагмент того, как выглядит скрипт установщика — его пришлось декомпилировать из файла setup.inx при помощи инструмента для декодирования скриптов InstallShield. Я использовал для этого SID (sexy installshield decompiler разработчика sn00pee):

Код декомпилированного файла setup.inx находится здесь:
https://paste.gg/p/anonymous/ffedbaae71b5427a… b22c72f55be2b62

function_0 — это точка входа, в ней выполняются необходимые предварительные проверки для установщика. В строке 926 (или по смещению @00004D99:0007) находится следующий фрагмент:

@00004D99:0007   label_4d99:
@00004D9B:0021      GetSystemInfo(185, local_number1, local_string3);
@00004DAC:0009      local_number6 = (local_number1 < 64000);
@00004DBB:0004      if(local_number6) then // ref index: 1
@00004DC7:0021         function_234("ERROR_MEMORY");
@00004DDC:0006         local_string12 = LASTRESULT;
@00004DE6:0021         MessageBox(local_string12, -65534);
@00004DF4:0002         abort;
@00004DF8:000B      endif;

GetSystemInfo вызывается с первым аргументом 185 и исполняет следующий код:

@0000A495:0005   label_a495:
@0000A497:000D      local_number5 = (local_number1 = 185);
@0000A4A6:0004      if(local_number5) then // ref index: 1
@0000A4B2:0021         function_190(local_number2);
@0000A4BB:0006         local_number3 = LASTRESULT;
@0000A4C5:0005         goto label_aa2d;
@0000A4CE:0005      endif;

Что, в свою очередь, исполняет функцию function_190 и возвращает её результат. В function_190 при помощи устаревшего GlobalMemoryStatus API выполняется проверка физической ОЗУ:

@0000B3B4:0009   label_b3b4:
@0000B3B6:0022   function NUMBER function_190(local_number1)
@0000B3B6           NUMBER local_number2; 
@0000B3B6
@0000B3B6           OBJECT local_object1; 
@0000B3B6        begin
@0000B3BF:001A      local_number2 = &local_object1;
@0000B3C9:0020      GlobalMemoryStatus(local_number2); // dll: KERNEL32.dll
@0000B3D2:0035      local_object1.nTotalPhys;
@0000B3E6:0006      local_number2 = LASTRESULT;
@0000B3F0:0011      local_number1 = (local_number2 / 1024);
@0000B3FF:0027      // return coming
@0000B403:0023      return 0;
@0000B40C:0026   end; // checksum: 4d013b

Вызов GlobalMemoryStatus заполняет структуру (local_object1) информацией об ОЗУ машины. Член структуры nTotalPhys/dwTotalPhys содержит общую физическую память машины в байтах. Код выполняет деление на 1024, чтобы получить общее ОЗУ машины в килобайтах, а затем возвращает это значение. Вернёмся к коду в строке 926 (или по смещению @00004D99:0007): здесь общая установленная ОЗУ в КиБ сохраняется в переменную local_number1:

@00004D99:0007   label_4d99:
@00004D9B:0021      GetSystemInfo(185, local_number1, local_string3);

А затем сравнивается с 64 000 КиБ (это чуть меньше, чем 64 МиБ):

1@00004DAC:0009      local_number6 = (local_number1 < 64000);

Так как это сравнение выполняется со знаковыми числами и, как говорилось выше, GlobalMemoryStatus возвращает отрицательные числа при более чем 4 ГиБ ОЗУ, на современных машинах с большим количеством ОЗУ этот оператор if вернёт true. Что, в свою очередь, приведёт к отображению окна с сообщением «недостаточно свободной памяти» и прекращению установки:

@00004DBB:0004      if(local_number6) then // ref index: 1
@00004DC7:0021         function_234("ERROR_MEMORY");
@00004DDC:0006         local_string12 = LASTRESULT;
@00004DE6:0021         MessageBox(local_string12, -65534);
@00004DF4:0002         abort;
@00004DF8:000B      endif;

Это легко исправить при помощи SID. Я решил заменить это сравнение на то, что с гораздо меньшей вероятностью произойдёт на современных PC. Я пропатчил код, заменив оператор «меньше» на оператор «равно»:

@00004DAC:0009      local_number6 = (local_number1 = 64000);

Таким образом, установка прекратится, только если в машине будет установлено ровно 64000 КиБ ОЗУ, что крайне маловероятно, потому что даже если у вас 64 мегабайта системной памяти, function_190, скорее всего, вернёт примерно 65536 КиБ, и мы пройдём проверку. Пропатченный файл setup.inx можно найти в конце статьи, или по ссылке выше.

Вылет на рабочий стол после запуска игры

Эту проблему решить сложнее.

Сам вылет имеет тип EXCEPTION_ACCESS_VIOLATION (код ошибки 0xC0000005), а это значит, что программа пытается считать или записать память по недействительному адресу. В логе вылета также указано, что адрес проблемной команды 0×00000000. Это значит, что приложение ожидает, что команды CPU должны находиться по адресу памяти 0×00000000, но поскольку он ноль, она не сможет исполнить команды по этому адресу и просто прекращает свою работу.

На старых машинах таким вылетам определённо что-то препятствовало, я чётко помню, что без проблем играл в эту игру на своём старом десктопе с XP.

Разобраться с ним было сложно, поэтому я воспользовался VirtualBox для создания чистой виртуальной среды для изучения причин сбоя. Ещё мне сильно помогло то, что исправленный EXE разработчика beha_r не вылетал; при его запуске игра работала безупречно.

Сначала я подумал (как, возможно, и вы), что сбой как-то связан с версией операционной системы. Чтобы подтвердить это предположение, я создал виртуальную машину с Windows XP SP3 и попробовал запустить игру на ней. К моему удивлению, она всё равно вылетала! Чтобы убедиться, что проблему вызывает не DRM, я также скачал старый crack для игры (разработанный D°N $iMoN), но она вылетала с ним и в VM, и на моей современной машине с Windows 10.

Затем я начал искать параметры, которые можно было изменить. Поэкспериментировав, я обнаружил, что сбой можно контролировать объёмом выделенной виртуальной машине VRAM. Когда я выделил 128 МиБ, игра всегда вылетала. При 64 МиБ такого практически никогда не было.

Также на это влияло разрешение, с которым запускалась игра. При низких разрешениях для вылета игры нужно было меньше VRAM, при высоких — больше. Вот небольшая таблица с минимально необходимым для вылета количеством VRAM; она составлена на основании ручных тестов, проведённых в VM:

— 640×480: 68 МиБ
— 800×600: 70 МиБ
— 1024×768: 74 МиБ
— 1152×864: 76 МиБ

При любом значении, равном или большем этому количеству VRAM с указанным разрешением, игра вылетала. То есть если вы хотите запустить игру в 1024×768, то нужно установить в VM объём VRAM 73 МиБ, и игра будет работать без проблем.

И при этом возникает два вопроса — как увеличение свободного количества VRAM в GPU вызывает подобные вылеты? И как это излечил beha_r?

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

Кроме очевидного патчинга вызова функции, отвечающей за проверку CD, он также изменил стек другой функции. Убрав патч функции проверки CD и оставив в исполняемом файле только изменение стека, я убедился, что именно этот трюк со стеком устраняет вылет. Его изменение заключалось в том, что размер стека для функции по адресу 0×00531C30 был просто увеличен на 4 байта:

sub_531C30+0
	prev.: mov eax, 108Ch
	fixd.: mov eax, 1090h (+4)

sub_531C30+203 (531E33)
	prev.: add esp, 108Ch
	fixd.: add esp, 1090h (+4)

sub_531C30+214 (531E44)
	prev.: add esp, 108Ch
	fixd.: add esp, 1090h (+4)

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

В конечном итоге это себя оправдало, мне удалось полностью разобраться в её логике, что позволило показать эту функцию во всём её великолепии:

https://paste.gg/p/anonymous/45427fd1d16a4b5d… 73313e29c24f899

Вы даже можете скомпилировать её самостоятельно при помощи DirectX 8 SDK, если замените эти ссылки на константы и предоставите валидные объекты IDirect3DDevice8 и HWND.

Как видите, эта функция, по сути — тест ёмкости VRAM. Она возвращает доступное количество видеопамяти в МиБ. Теперь нам нужно вернуться к первому вопросу — каким образом большой объём VRAM вызывает вылет игры? На самом деле, в этой функции есть баг. Попробуйте найти его самостоятельно.

Готовы?

Итак, вот правильный ответ:

Разработчики игры совершили здесь две ошибки, и сочетание этих двух ошибок приводит к вылету.

Первая ошибка: очевидно, они не ожидали (или наплевали на это), что GPU могут превзойти ёмкость VRAM 512×128 КиБ == 64 МиБ. Они положились на то, что DirectX выбросит результат D3DERR_OUTOFVIDEOMEMORY при создании до 512 поверхностей и текстур, вычисляя доступный им объём VRAM; по сути, они всегда ожидали, что рано или поздно видеопамять исчерпается.

Они думали, что 64 МиБ будет вполне достаточно для теста перегрузки VRAM, потому что в большинстве графических карт потребительского уровня на момент разработки (2001 год) было 16–32 МиБ VRAM. Даже в системных требованиях игры было указано, что для оптимальной производительности нужен GPU на 32 МиБ. Любопытно, что в 2002 году уже выпустили карты верхнего ценового диапазона с 128 МиБ VRAM, поэтому этот баг мог уже возникать у некоторых уже всего спустя несколько месяцев после релиза.

Забавно, что вычисляемый этой функцией доступный объём VRAM, похоже, даже ни на что не влияет; он не сохраняется, не влияет на геймплей или возможность игры/стабильность, даже если значение равно 0. На самом деле, исправление beha_r убеждает игру, что ей доступен 0 МиБ VRAM, но она всё равно нормально работает.

Однако вторая ошибка гораздо серьёзнее: разработчики перепутали проверки границ для ОБОИХ массивов. Массивы текстур-заглушек и поверхностей определены, как способные хранить по 512 указателей каждый, однако когда доступно слишком много VRAM, их циклы выходят за границы обоих буферов на одну лишнюю итерацию. Причиной этого стало то, что в циклах do..while разработчики должны были использовать операторы < вместо <=:

do {2...3} while ( numOfCreatedSurfaces <= 512 && !createResult );

Массивы C/C++ начинаются с нулевого индекса, то есть первый элемент dummySurfaces — это dummySurfaces[0], а последний элемент — это dummySurfaces[511], а не dummySurfaces[512]; этот индекс указывает за пределы массива. С каждым успешным созданием текстуры/поверхности выполняется инкремент индекса, однако инкремент происходит один лишний раз, то есть 513 раз вместо 512 из-за проверки границ «меньше или равно» (<=).

Для массива dummyTextures это не так уж важно, элемент dummyTextures[512], который на самом деле указывает на 513-й элемент массива, то есть за его пределами, указывает на первый элемент следующего массива в памяти, то есть dummySurfaces[0]. Впрочем, он позже переписывается в собственном массиве.

Проблема возникает с dummySurfaces[512], потому что dummySurfaces — это последний массив в стеке функции. Когда цикл выходит за рамки этого массива, то есть его последнего элемента, собственный адрес возврата функции в стеке перезаписывается указателем на пустую поверхность.

Из-за структуры стека адрес возврата идёт после всех определённых в стеке переменных и буферов. Получается, что самый последний буфер, определённый в стеке, переполняется, повреждая адрес возврата, находящийся прямо после него.

Хотя эта последняя лишняя поверхность обнуляется (NULL), исходный адрес возврата (который должен сказать функции, куда возвращаться после завершения её выполнения) оказывается навсегда утерян и заменяется нулями. Поэтому когда функция завершает работу, она думает, что была вызвана из 0×00000000, поэтому пытается вернуться к этому адресу, чтобы продолжить выполнение, но так как это недействительная память, игра вылетает с EXCEPTION_ACCESS_VIOLATION.

(Если вы не совсем понимаете объяснение про адрес возврата, то посмотрите это видео, там эта тема объяснена гораздо лучше.)

Скорее всего, разработчики тестировали игру с GPU, имеющими до 64 МиБ VRAM. В этом случае, с каким бы разрешением ни была запущена игра, у неё всегда заканчивалась память при создании 512 текстур и поверхностей, то есть она не доходила до проблемной 513-й итерации.

Возможно, вы зададитесь вопросом: как это связано с разрешением игры и почему точное количество VRAM, «необходимое» для вылета игры, увеличивается с повышением разрешения? Должен признаться, я провозился с этим вопросом гораздо больше, чем следовало, но ответ оказался мучительно простым: чем выше разрешение, тем больше VRAM используют Windows и другие задействующие GPU приложения. Текстуры и поверхности всегда занимают одинаковый объём VRAM; с ростом разрешения увеличивается размер всего остального (с чем у игры общая видеопамять).

Вернувшись к коду, мы увидим, что beha_r решил эту проблему довольно простым способом «в лоб»: он просто создал на 4 байта больше места в стеке, чтобы было место для записи этой лишней поверхности и в последнем массиве можно было хранить 513 указателей вместо 512, благодаря чему значение возврата функции не переписывалось:

  DDSURFACEDESC2 surfaceDescriptor; // [esp+5Ch] [ebp-1080h] BYREF
  IDirect3DTexture8 *dummyTextures[512]; // [esp+D8h] [ebp-1004h] BYREF
  IDirectDrawSurface7 *dummySurfaces[513]; // [esp+8D8h] [ebp-804h] BYREF

Буфер dummyTextures всё равно переполняется, но поскольку он переполняется в буфер dummySurfaces, это не вызывает проблем. А dummySurfaces больше не переполняется. Это можно было сделать без малейших трудностей, потому что в стеке между функциями есть несколько байтов паддинга, и заимствование этих 4 байтов из паддинга не вызывает никаких проблем для следующей функции в памяти.

На мой взгляд (и, вероятнее всего, по мнению разработчиков), лучше всего исправить это, сделав так, чтобы циклы do..while не выполняли итерации за пределы границ буфера:

do {2...3} while ( numOfCreatedSurfaces < 512 && !createResult );

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

До:

.text:0052D863                 mov     ecx, esi
.text:0052D865                 mov     [esi+604h], edi
.text:0052D86B                 mov     [esi+608h], edi
.text:0052D871                 call    sub_531C30 ; вызов проблемной функции тестирования VRAM
.text:0052D876                 cmp     eax, edi
.text:0052D878                 mov     [esi+594h], eax

После:

.text:0052D863                 mov     ecx, esi
.text:0052D865                 mov     [esi+604h], edi
.text:0052D86B                 mov     [esi+608h], edi
.text:0052D871                 mov     eax, 40h ; вызов функции полностью пропатчен, программе сообщается, что доступно 64 МиБ VRAM
.text:0052D876                 cmp     eax, edi
.text:0052D878                 mov     [esi+594h], eax

Я пропатчил исполняемый файл игры этим способом и не столкнулся ни с какими проблемами. Ссылка на него указана в начале и в конце статьи.

Кто-то может сказать, что разработчики сделали третью ошибку: они бы могли просто использовать встроенные функции DirectX для запроса VRAM, например, GetAvailableTextureMem или GetAvailableVidMem, а не пытаться запихивать во VRAM кучу пустых текстур и поверхностей. Предположу, что они пошли этим путём, потому что им он показался более надёжным, чем функции DirectX. Но точно я не знаю.

Возможно, об этом стоит спросить разработчиков игры, например, Дерека Петтигрю или Саймона Морриса, если они живы и у кого-то есть с ними связь.

Как бы то ни было, таков мой анализ этой проблемы. Ниже я приложил исправленный EXE с вырезанным вызовом функции проверки VRAM. Может быть, кому-то ценны эти несколько дополнительных миллисекунд, но во всём остальном патч beha_r работает столь же прекрасно.

Не уверен, что кому-то будет это интересно, потому что Salt Lake 2002 довольно неизвестная игра, к тому же не очень хорошая. Но если хотя бы один человек прочитает это и ему статья поможет/покажется интересной, то мои труды того стоили. Огромное спасибо beha_r за его исправленный EXE и за помощь, без него мой анализ длился бы гораздо дольше!

Файлы

  1. setup_inx.zip

  2. SaltLake2002.exe

© Habrahabr.ru