среда, 29 июня 2016 г.

Создаём нативный лаунчер для Half-Life 1

Вступление

Недавно мне поступил заказ на реализацию лаунчера для Counter-Strike 1.6. В некоторых уже существующих игровых сборках мне доводилось видеть лаунчеры, но это были отдельные исполняемые файлы, которые дополняли уже имеющийся функционал файла "hl.exe" своим (автообновление файлов, например). Приняв решение не идти по такой дорожке, я решил создать свой аналог "hl.exe", которым можно будет заменить оригинал и который будет совмещать в себе функции оригинала и мои собственные. Лаунчер был написан, поэтому хотелось бы поделиться опытом, а также написать ещё один самый простой лаунчер для Windows прямо в этой статье.

Предупрежу, что язык программирования - Delphi. Если вы уверены в своих знаниях языка C/C++, то преобразовать Delphi код в C/C++ вам не составит труда, в противном случае продолжайте на свой страх и риск.

Как это работает?

Если говорить грубо, то оригинальный "hl.exe" лаунчер представляет собой обыкновенную обертку над библиотеками, так как всю основную работу выполняют "sw.dll" (software) и "hw.dll" (hardware). Какая конкретно библиотека будет работать - зависит от настроек игры. Эти два файла являются рендерером, то есть программой, которая выполняет всю графическую часть движка (отрисовка моделей, спрайтов и т.д.). Кроме графики здесь также происходит работа с сетью и прочие действия поменьше (создание окна игры, работа с памятью, буфером команд, и т.д.). Основная разница между этими файлами - способ рендеринга: "hw.dll" рендерит с помощью видеокарты, в то время как "sw.dll" - с помощью центрального процессора. Существует также файл под именем "swds.dll" - это тот же sw.dll, но заточен он конкретно под сервер. Именно эта библиотека используется, когда вы решаете запустить файл "hlds.exe".

Цель лаунчера - загрузить нужные файлы, чтобы игра начала свою работу. Вся необходимая информация о загрузке хранится в реестре по пути "HKEY_CURRENT_USER\Software\Valve\Half-Life\Settings\". Там можно найти следующие параметры:


  • CrashInitializingVideoMode - если 1, то предыдущий запуск игры был выполнен неудачно. Обрабатывается только самим лаунчером. Если данный параметр равен 1, то вы увидите сообщение, в котором говорится, что предыдущая попытка запустить игру не удалась, при этом в настройках реестра произойдёт сброс рендерера на "sw.dll". Если вы выберете кнопку "OK", то также произойдет сброс следующих параметров: ScreenBPP станет равен 16, ScreenHeight - 640, а ScreenWidth - 480. Если же вы выберете кнопку "Cancel", то сброс этих параметров не будет выполнен, и лаунчер завершит работу. Теоретически можно обойтись без данного обработчика, но на практике вы рискуете получить проблемы при запуске hardware видеорежима.
  • EngineD3D - если 1, то используется технология Direct3D, если 0 - OpenGL. Может быть равно 1 только в случае, когда "EngineDLL" равно "hw.dll".
  • EngineDLL - имя библиотеки-рендерера. Возможные значения - sw.dll или hw.dll.
  • ScreenBPP - глубина цвета.
  • ScreenHeight - высота окна.
  • ScreenWidth - ширина окна.
  • ScreenWindowed - если 1, то игру нужно запустить в окне.
  • User Token 2, User Token 3 - настройка крови и частей тел в игре.


Выполнив все необходимые проверки и обработки, лаунчер должен запустить игру. Для этой цели служит специальный интерфейс из библиотеки-рендерера. Если вкратце, то интерфейс в нашем случае - это что-то вроде API, но об этом позже.

Начинаем разработку

С основной теорией покончено, поэтому самое время перейти к разработке. Среда программирования, которую я буду использовать - Delphi 10 Seattle. Если вы используете Delphi версии 2007 или ниже, то я рекомендую обновиться минимум до версии XE2, так как на мой взгляд эта версия является более-менее стабильной, да и проблем с юникодом поможет избежать. Так, например, любимая конформистами Delphi 7, не работает с юникодом, поэтому с визуальным программированием будут сложности, если вы решите создать GUI для своего лаунчера. Хотя эта проблема должна решиться компонентом AlphaSkins, но я бы всё же предпочёл идти в ногу со временем и возвращаться в 2002 только при крайней необходимости.

Если у вас возникли какие-либо проблемы во время разработки или вы не хотите читать тонну текста ниже, то можете сразу скачать исходный код лаунчера и разобрать уже готовый проект самостоятельно.

Итак, начнём. Первое, что нам нужно сделать - создать консольное приложение. Для этого перейдём в "File" -> "New" -> "Other" -> "Delphi Projects" и выберем "Console Application". В созданный проект поместим следующий код, заменив весь имеющийся:

program Project1;

uses
  Windows,
  WinSock,
  SysUtils;

procedure Init;
begin

end;

begin
  Init;
end.
Теперь используя комбинацию клавиш Ctrl + Shift + S мы сохраним весь проект куда-нибудь. Подключенные в секцию uses модули нам понадобятся, когда мы доберёмся до реализации других функций.
Теперь перейдём в "File" -> "New" и выберем "Unit - Delphi". В появившемся окне удалим весь имеющийся код и поместим туда следующее:

unit Unit1;

interface

type
  TCreateInterfaceFn = function(Name: PAnsiChar; var ReturnCode: LongInt): Pointer; cdecl;

  PVEngine = ^VEngine;
  VEngine = record
    Create: procedure(Dispose: Boolean); stdcall;
    Run: function(Instance: THandle; BaseDir, CmdLine: PAnsiChar; Unk01: Pointer; ExeFactory, FSFactory: TCreateInterfaceFn): Boolean; stdcall;
  end;

  PIEngine = ^IEngine;
  IEngine = record
    VTable: PVEngine;
    Data: Pointer;
  end;

  PVFileSys = ^VFileSys;
  VFileSys = record
    Create: procedure(Dispose: Boolean); stdcall;
    Unknown01: procedure; stdcall;
    Unknown02: procedure; stdcall;
    Unknown04: procedure; stdcall;
    Unknown05: procedure(Dir: PAnsiChar; P: PAnsiChar); stdcall;
    // ...
  end;

  PIFileSys = ^IFileSys;
  IFileSys = record
    VTable: PVFileSys;
    Data: Pointer;
  end;

implementation

end.
Используя комбинацию клавиш Ctrl + S мы сохраним всё в файл под именем LauncherSDK. Этот модуль понадобится нам для работы с интерфейсами игры.
Теперь о реестре. Из параметра "EngineDLL" лаунчером берется нужная для загрузки библиотека. Пока что это единственный параметр, который нам нужен. Для его получения мы можем воспользоваться имеющимся в Delphi классом TRegistry, либо самим WinAPI напрямую. Я приведу два способа реализации, а вы выберите понравившийся.

uses
  Registry; // Не забываем подключить в uses необходимый модуль при работе с TRegistry

function ReadStringFromReg(const Name: string): AnsiString;
var
  R: TRegistry;
begin
  R := TRegistry.Create;
  R.RootKey := HKEY_CURRENT_USER;
  R.OpenKey('Software\Valve\Half-Life\Settings\', True);
  Result := R.ReadString(Name);
  R.Free;
end;

function ReadStringFromRegViaWinAPI(const Name, Default: string): string;
const
  REG_DELETE   = $10000;
  READ_CONTROL = $20000;
  WRITE_DAC    = $40000;
  WRITE_OWNER  = $80000;

  ALL_ACCESS   = REG_DELETE or READ_CONTROL or WRITE_DAC or WRITE_OWNER or $3F;
var
  Settings: HKEY;
  DataType, DataSize, Disposition: Cardinal;
  Buf: array[0..255] of Char;
begin
  RegCreateKeyEx(HKEY_CURRENT_USER, 'Software\Valve\Half-Life\Settings\', 0, nil, 0, ALL_ACCESS, nil, Settings, @Disposition);
  RegQueryValueEx(Settings, PChar(Name), nil, @DataType, @Buf, @DataSize);

  if DataType <> 0 then
    Result := Buf
  else
    Result := Default;
end;

Также в будущем нам понадобится записывать некоторые данные в реестр, так что заранее сделаем и функции записи. Как обычно - два варианта реализации на выбор:

procedure WriteStringToReg(const Name, Value: string);
var
  R: TRegistry;
begin
  R := TRegistry.Create;
  R.RootKey := HKEY_LOCAL_MACHINE;
  R.OpenKey('Software\Valve\Half-Life\Settings\', True);
  R.WriteString(Name, Value);
  R.Free;
end;

procedure WriteStringToRegViaWinAPI(const Name, Value: string);
const
  REG_DELETE   = $10000;
  READ_CONTROL = $20000;
  WRITE_DAC    = $40000;
  WRITE_OWNER  = $80000;

  ALL_ACCESS   = REG_DELETE or READ_CONTROL or WRITE_DAC or WRITE_OWNER or $3F;

  NULL_SIZE = SizeOf(Char(#0));
var
  Settings: HKEY;
  Disposition: Cardinal;
begin
  RegCreateKeyEx(HKEY_CURRENT_USER, 'Software\Valve\Half-Life\Settings\', 0, nil, 0, ALL_ACCESS, nil, Settings, @Disposition);
  RegSetValueEx(Settings, PChar(Value), 0, REG_SZ, PChar(Value), Length(Value) * SizeOf(Char) + NULL_SIZE);
end;

В оригинальном лаунчере используется собственный класс для работы с реестром, который возволяет быстро записывать и читать данные. Мы не будем заморачиваться над написанием собственного класса, так как работа с ними и их синтаксис в разных языках могут сильно отличаться, а процедурное программирование, в целом, в каждом языке одинаково и интуитивно понятно, что позволит легко перенести Delphi код на тот же C++ без лишней возни.
Далее нам нужно сделать функцию, которая будет инициализировать файловую систему движка. Тут уже выбор ограничен только одной функцией:

function LoadFileSystem(IsSteam: Boolean): HMODULE;
var
  FileSysModule: PAnsiChar;
  FI: TWIN32FindDataA;
  h: THandle;
begin
  if IsSteam then
  begin
    LoadLibraryA('steam.dll');
    FileSysModule := 'filesystem_steam.dll';
  end
  else
    FileSysModule := 'filesystem_stdio.dll';

  Result := LoadLibraryA(FileSysModule);
  if (Result = 0) and IsSteam then
    Result := LoadLibraryA('filesystem_stdio.dll');

  if Result = 0 then
  begin
    h := FindFirstFileA(FileSysModule, FI);
    if h = LongWord(-1) then
      MessageBoxA(HWND_DESKTOP, 'Could not find filesystem dll to load.', 'Fatal Error', MB_ICONERROR or MB_SYSTEMMODAL)
    else
    begin
      MessageBoxA(HWND_DESKTOP, 'Could not load filesystem dll.'#10'FileSystem crashed during construction.', 'Fatal Error', MB_ICONERROR or MB_SYSTEMMODAL);
      Windows.FindClose(h);
    end;
  end;
end;

Разберём эту функцию.
Аргумент в этой функции всего один - IsSteam. Если он равен True, то загружаться должна файловая система программы Steam, если False - обычная система движка. Говоря откровенно - я не совсем понимаю, в чём разница между ними, так как по сути они выполняют одну и ту же функцию - работа с файлами, и отличий в бинарном коде там я особо не нашел (возможно, что я плохо искал), так что углубляться в их толкование я не буду.
В качестве результата функция возвращает хендл (адрес) файловой системы, которую она загрузила. Его необходимо будет сохранить, так как в будущем он нам ещё понадобится. Если функции не удалось загрузить ни одну файловую систему, то она выведет сообщение об ошибке, которое вы сможете обработать, когда функция завершит свою работу, вернув в результате ноль.
Стоит учитывать, что в более новых билдах не используется файловая система "filesystem_steam.dll", а используется только "filesystem_stdio.dll".

Затем, после успешной подгрузки файловой системы, нам необходимо подгрузить сам рендерер. Создадим такую функцию:

function LoadRenderer: HMODULE;
var
  DllName, CmdLine: PChar;
begin
  DllName := PChar(ReadStringFromRegViaWinAPI('EngineDLL', 'hw.dll'));

  CmdLine := GetCommandLine;
  if StrPos(CmdLine, '-soft') <> nil then
    DllName := 'sw.dll'
  else
  if (StrPos(CmdLine, '-gl') <> nil) or (StrPos(CmdLine, '-d3d') <> nil) then
    DllName := 'hw.dll';

  WriteStringToRegViaWinAPI('EngineDLL', DllName);

  Result := LoadLibrary(DllName);
end;

Теперь разберём и её.
Аргументов в этой функции нет, так как всю необходимую работу по загрузке библиотеки рендерера в память функция выполняет сама, читая реестр. В качестве результата - хендл (адрес) загруженной библиотеки рендерера. Если реестр удалось прочитать, то в переменную "DllName" будет записан последний использовавшийся игрой рендерер, а если чтение провалилось - вернётся значение, указанное во втором аргументе функции "ReadStringFromReg", то есть "hw.dll". Затем выполнится проверка, не указано ли в строке запуска параметров, влияющих на выбор рендерера. Если в ней встретится параметр "-soft", то будет загружена библиотека "sw.dll" вне зависимости от того, какое значение указано в реестре. Если же будут найдены "-gl" или "-d3d", то произойдёт загрузка "hw.dll". После всех этих проверок в реестр будет записан выбранный рендерер, а затем и загружен в память.

Далее нам понадобится функция, которая получает экспортируемую функцию "CreateInterface" указанного модуля. Для начала давайте напишем сам код:

function GetFactory(hModule: THandle): TCreateInterfaceFn;
begin
  if hModule <> 0 then
    Result := GetProcAddress(hModule, 'CreateInterface')
  else
    Result := nil;
end;

В принципе, он не должен вызвать каких-либо затруднений. Аргумент всего один - это хендл библиотеки, а сама функция просто вызывает GetProcAddress, пытаясь получить адрес экспортируемой из библиотеки функцию CreateInterface. Стоит прояснить ситуацию по поводу CreateInterface функции и для чего это нужно. Дело в том, что практически каждый модуль будь то движка Source, GoldSource, или даже программы Steam, содержит в себе функцию CreateInterface, которая возвращает нам экземпляр класса, имя которого указано в аргументе Name. Благодаря этому классу мы и сможем взаимодействовать с библиотекой, которая содержит в себе данный класс, предоставляющий нам набор необходимых методов (функций).

Теперь нам нужно создать новый модуль, в который мы поместим специальный код, который значительно упростит нам работу с классами языка C++. Для этого перейдём в "File" -> "New" и выберем "Unit - Delphi". После этого удалим в появившемся окне весь код и поместим туда вот это:

unit Unit1;

interface

type
  TThisCall = function(PClass, PFunc: Pointer): Pointer; stdcall varargs;

const
  ThisCall: TThisCall = nil;

implementation

function __ThisCall(PClass, PFunc: Pointer): Pointer; assembler;
asm
  cmp dword [esp + 4], 0 // PFunc = nil?
  jz @A
  cmp dword [esp + 8], 0 // PClass = nil?
  jz @A

  pop edx // return address
  pop ecx // class (this) pointer
  pop eax // function for call
  push edx

  jmp eax

@A:
  or eax, -1
  ret 8
end;

initialization
  ThisCall := @__ThisCall;
end.

Используя комбинацию клавиш Ctrl + S мы сохраним данный модуль под именем ThisWrap.
Данный код - простой и наглядный пример того, как можно обманным путём заставить компилятор вызывать функцию, написанную в среде Delphi, используя при этом директиву varargs. Данная директива говорит компилятору о том, что функция имеет переменное количество аргументов. Обычно она используется, чтобы вызывать экспортируемые функции типа printf из библиотек C/C++, а использовать её в собственных функциях языка Delphi компилятор запрещает (для таких целей есть тип данных "array of const"). Но, как мы видим, с помощью нехитрых махинаций можно заставить его выдать нам разрешение, причем эта директива работает на всех существующих в Delphi соглашениях вызовов, а не только на cdecl. Хотя мне сложно представить, как varargs можно использовать в сочетании с register, например, однако если сильно захотеть, то я думаю, что можно устроить и такое.
Задача функции ThisCall() - быть обёрткой для методов классов языка C++. В аргументе PClass располагается this (Self) класса, а в PFunc - указатель на метод, вызываемый из указаного в PClass this. Однако это ещё не всё. Если PFunc имеет аргументы, то вы можете передать и их, просто продолжив запись последних после PClass и PFunc.
Если функции не понравились переданные аргументы PClass и PFunc (если один из них равен nil), то функция вернёт -1. Однако лично мне это уведомление ещё ни разу не понадобилось, откровенно говоря.
Если же функцию всё устроило, то она немного помудрит со стеком и передаст управление PFunc. Если PFunc должна что-то возвращать, то с помощью приведения типов вы можете получить тот тип данных, который вам нужен. Делается это так:

 var
   I: LongInt;
 begin
   // ...
   I := LongInt(ThisCall(MyClass, MyFunc));
   // ...
 end;

Не стоит обращать внимание на то, что функция возвращает Pointer. Я решил использовать именно этот тип потому, что мне он кажется универсальным, потому что мы всё равно в итоге будем выполнять преобразование вроде того, что описано выше, и поэтому пользователь, который впервые взглянет на заголовок функции ThisCall, может немного растеряться от того, что функция возвращает, скажем, LongInt. Что за число она возвращает? Где его нужно будет применять? Pointer позволяет сохранять "нейтралитет" типов и не вызывать сильной путаницы, я считаю.
Теперь заменим процедуру Init в главном модуле "Launcher.dpr" вот этим код:

function GetFileDir(Buffer: PAnsiChar): PAnsiChar;
begin
  if GetModuleFileNameA(GetModuleHandleA(nil), Buffer, MAX_PATH) <> 0 then
  begin
    StrRScan(Buffer, '\')^ := #0;
    Result := Buffer;
  end
  else
    Result := nil;
end;

procedure Init;
var
  WSA: TWSAData;

  Data: array[0..4095] of Byte;
  Buf: array[0..MAX_PATH - 1] of AnsiChar;
  CmdLine: PAnsiChar;

  IsSteam: Boolean;
  FileSys, Renderer: HMODULE;

  EngineFactory, FileSysFactory: TCreateInterfaceFn;
  VEngineLauncherAPI002: PIEngine;
  VFileSystem009: PIFileSys;

  RunRes: LongInt;
begin
  WSAStartup($0202, WSA);
  CmdLine := GetCommandLineA;
  IsSteam := StrPos(CmdLine, '-steam') <> nil; // #0

  repeat
    FileSys := LoadFileSystem(IsSteam); // #1
    if FileSys = 0 then
      Break;

    Renderer := LoadRenderer; // #2
    if Renderer = 0 then
      Break;

    EngineFactory := GetFactory(Renderer);
    FileSysFactory := GetFactory(FileSys); // #3

    VEngineLauncherAPI002 := EngineFactory('VENGINE_LAUNCHER_API_VERSION002', PLongInt(nil)^);
    VFileSystem009 := FileSysFactory('VFileSystem009', PLongInt(nil)^); // #4
    ThisCall(VFileSystem009, @VFileSystem009.VTable.Unknown01);
    ThisCall(VFileSystem009, @VFileSystem009.VTable.Unknown04, GetFileDir(Buf), 'ROOT');

    RunRes := LongInt(ThisCall(VEngineLauncherAPI002, @VEngineLauncherAPI002.VTable.Run,
                               hInstance,
                               GetFileDir(Buf),
                               CmdLine,
                               @Data[0],
                               nil,
                               @FileSysFactory)); // #5

    FreeLibrary(Renderer);
    FreeLibrary(FileSys); // #6

 case RunRes of
    0: Break;
    1: ;
    2: Break;
  end;

  until False;
end;


Функция GetFileDir() нужна для того, чтобы получить путь, по которому находится наш лаунчер. Результат записывается в аргумент "Buffer", а сам "Buffer" возвращается функцией для более удобной работы.
Init() выполняет следующие действия в указанном порядке:

0. Инициализация сокетов и определение "-steam" или не "-steam".
1. Загрузка файловой системы движка.
2. Загрузка библиотеки рендерера.
3. Получение функций CreateInterface() файловой системы и рендерера.
4. Получение экземпляров класса файловой системы и рендерера.
5. Вызов функции Run() класса VEngineLauncherAPI002.
6. Освобождение ресурсов после выхода из игры.

Если результат функции Run() (в нашем случае - результат ThisCall()) равен нулю, то сама игра была просто закрыта, поэтому мы можем беспрепятственно покинуть тело цикла. Если результат равен 1, то цикл необходимо начать сначала. Обычно такая ситуация возникает, когда мы изменили разрешение в игре или выполнили команду "_restart". Если результат равен 2, то мы имеем проблемы с настройками рендерера. Оригинальный лаунчер просто сбрасывает значения реестра ScreenBPP, ScreenHeight, ScreenWidth и EngineDLL на стандартные (они указаны в начале статьи, в описании CrashInitializingVideoMode) и выводит сообщение об ошибке, но наш лаунчер этой проверки не делает. Вы можете реализовать её самостоятельно, если хотите.
В принципе - всё готово. Вам осталось только нажать Ctrl + F9 и поискать в папке со своим проектом исполняемый файл, который имеет имя вашего проекта и расширение "exe".

Если вы дошли до этого момента, прочитав и реализовав всю практическую часть, то поздравляю - вы только что написали свой лаунчер для Half-Life 1 под Windows. Теперь вы можете начинать доработку данной базы внесением собственных функций.


Нюансы разработки

За время разработки собственного лаунчера я заметил всего одну особенность родного лаунчера. Особенность заключается в том, что рендерер в один свой жизненный момент начинает работать с адресным пространством лаунчера, что может привести к его падению, если не подготовиться как следует. Причём падение происходит только тогда, когда идёт попытка запустить Half-Life, а с модами вроде Counter-Strike такого не происходит. Решение оказалось достаточно простым (возможно даже костыльным), хоть и искал я его долго. Для начала нужно записать в секцию interface (хотя и implementation тоже подойдёт) какого-нибудь нашего модуля вот такую строку:

var
  Reserved: array[0..$1FFFFFF - 1] of Byte;

Этим кодом мы создаем достаточно большой и неинициализированный участок памяти, который можно беспрепятственно редактировать. Казалось бы, что всё готово, но на самом деле нет. Компилятор Delphi прооптимизирует код и выбросит эту переменную, так как она в нашем коде нигде не используется. Для этого поступим очень просто и в то же время неординарно - перейдём в конец модуля, в котором мы поместили нашу переменную, и перед строкой "end." напишем это:

begin
  asm lea eax, [Reserved] end;

Теперь наша переменная используется и компилятор не будет её трогать, а игра спокойно запустится и не упадёт.
Если кто не понял, то сейчас мы задействовали встроенный в среду программирования ассемблер, чтобы создать в нашей программе место, где эта переменная будет использоваться, иначе компилятор просто выкинет её из нашего проекта во время сборки. Команда "lea" помещает в регистр (переменную) "eax" адрес переменной "Reserved". Для нас это не несёт никакой смысловой нагрузки, но благодаря этому мини-коду мы выполняем условие, которое не позволит компилятору убрать эту переменную из проекта.
По сути, мы могли бы обойтись и без ассемблера, но в результате нам пришлось бы написать код, который был бы на пару тактов медленнее, поэтому я решил немного посвятить читателя в ассемблер и амбиции низкоуровневого программирования.

И да, ещё одно замечание. Данный лаунчер не будет работать на 6*** и выше билдах, так как там необходимы дополнительные манипуляции с реестром. Решение - использование специального подгрузчика, который идёт вместе с каждой сборкой 6*** и выше билдов. Можно обойтись и без него, конечно, но придётся писать дополнительный код и подстраиваться под этот самый подгрузчик.

Завершение

Вот и всё. Теперь на основе полученных знаний вы можете создавать свои креативные лаунчеры для Half-Life 1 и его модификаций, используя визуальное программирование и кучу графических библиотек, не прибегая к созданию псевдолаунчеров, которые всё равно будут в итоге запускать нативный hl.exe. Возможно, что в будущем также будет опубликован мануал по созданию лаунчера для Counter-Strike: Source на языке Delphi, так как там всё куда проще и посему времени на написание статьи выделить труда не составит, благо его много не потребуется.

3 комментария: