В этой статье я расскажу о том, как я сделал систему автообновления клиентской онлайн-игры. Ссылка на исходники (Delphi) в конце статьи. На самом деле такую фичу я реализовал в двух своих играх, и если первый блин вышел немного комом (в игре Spectromancer), то вторая реализация получилась весьма удобной и эффективной. Это моя первая статья на Хабре, так что сильно не бейте, а лучше укажите на недостатки в комментариях :)

Алгоритм обновления игры


  • Проверка версии на необходимость обновления.
  • Скачивание списка файлов актуальной версии.
  • Скачивание новых или изменённых файлов во временную папку.
  • Установка обновления — приведение файлов установленного клиента в соответствие со списком.
  • Запуск обновлённого клиента.

Проверка версии


Первым делом при запуске клиент спрашивает у сервера номер актуальной версии (X) и номер минимально допустимой без обновления (Y). Если версия клиента не ниже Y, то обновление не требуется, в противном случае клиент запускает утилиту обновления "GetNewVersion.exe X", а сам завершает работу.

Как видим, номер версии передаётся параметром — это позволяет при желании обновить игру до любой доступной на сервере версии, и даже понизить её. Если параметр не передать — утилита сама запросит у сервера номер актуальной версии. Номер версии — это просто целое число, схема нумерации может быть любой, например у меня версия 1.12 соответствует номеру 1120.

Ответ от сервера не приходит мгновенно, а до его получения мы не можем создать окно игры, ведь возможно придётся его тут же закрыть, а непонятные мерцания на экране — это совсем не то, что нам нужно. Время ожидания ответа надо бы чем-то занять, и клиент занимает его загрузкой/распаковкой наиболее тяжелых JPEG'ов. Слишком долго ждать тоже нельзя: игрок запустил игру — а на экране ничего не происходит, непорядок. Поэтому если в течение 1.0 сек. ответ от сервера так и не поступил — загрузка игры продолжается в обычном порядке. В этом нет ничего страшного: как только игрок попытается залогиниться на сервер, он получит сообщение о необходимости обновить клиент, либо о том, что сервер недоступен.

Скачивание списка файлов


Зная номер версии, утилита обновления скачивает список файлов по адресу: [base_ur]>/[версия]/filelist
Это просто список файлов в формате CSV с указанием контрольных сумм, а также размеров в сжатом и несжатом виде, каждая строчка выглядит в нём примерно так:
18*Priest.tga;1053151921D9;91719;107372
Здесь «18*» означает, что 18 символов в имени файла такие же как и у предыдущего файла. Поскольку файлы обычно идут в алфафитном порядке, а пути могут быть длинными — это существенно экономит размер файла-списка. Для веб-сервера, на котором не включена компрессия, это означает, что файл скачается быстрее и обновление начнётся раньше.

Скачивание новых или изменённых файлов


Мы не знаем насколько устарел клиент игры, возможно какие-то файлы изменены или удалены вручную. Скачивать лишнее мы тоже не хотим, поэтому получив список файлов, утилита начинает проверять их по порядку на необходимость обновления: если в папке игры файл отсутствует или его контрольная сумма отличается — файл добавляется в очередь на скачивание. Параллельно может загружаться не более 2-х файлов — этого вполне достаточно, чтобы с одной стороны загрузка не тормозила, а с другой, происходила последовательно.



Особая тема — отображение прогресса. Пока не обработан весь список, мы точно не знаем сколько файлов предстоит скачать и какого они размера. Однако как только первый файл поставлен на загрузку, мы уже можем отобразить какую-то информацию. Фактически, прогресс отображает очередь загрузки: сколько всего предстоит скачать и сколько уже скачано.

Скачанные файлы сразу же распаковываются и сохраняются во временной папке, для сжатия я использую библиотеку zlib.

Когда весь список файлов обработан и все загрузки завершились, утилита проверяет наличие файла changes.txt и если он есть — отображает его. Пользователю предлагается начать процедуру обновления. До нажатия кнопки «Update» никаких изменений в папке игры ещё не сделано, так что можно без проблем отказаться.

Кстати, если пользователь прервёт загрузку или откажется от установки, то в следующий раз ему не придётся скачивать все файлы заново: перед скачиванием очередного файла утилита проверяет его наличие во временной папке и если контрольная сумма совпадает — загрузка считается состоявшейся.



А вот при нажатии на «Update» утилита запускает другую утилиту — "InstallUpdate.exe", а сама завершает работу.

Установка обновления


Зачем нужна ещё одна утилита? Всё просто: для обновления файлов игры нужно выполняться с правами администратора. А для скачивания обновления это, наоборот, противопоказано. Потому что, если только вы не счастливый обладатель EV-сертификата подписи кода, запуск процесса с правами администратора приводит к показу окна UAC. А если при запуске игры, вместо привычного интерфейса игрок видит такое:



… то это, как минимум, повод насторожиться, а то и вовсе отказаться от запуска. Другое дело, при ручном согласии на установку обновления — в таком контексте окно UAC воспринимается нормально. К сожалению, процесс в Windows не может повысить свои права во время выполнения — это свойство неизменно с момента запуска. Поэтому я использую два отдельных файла. На самом деле GetNewVersion.exe и InstallUpdate.exe — это и вовсе одна и та же утилита, файлы идентичны. А действие определяется передаваемыми параметрами и именем исполняемого файла.

Итак, будучи запущенным, InstallUpdate копирует файлы клиента игры из временной папки в папку игры, а затем запускает обновлённый клиент и завершает работу. При этом может быть обновлён и файл GetNewVersion.exe.

Все действия, а также возникающие ошибки, подробно логируются в журнале, это весьма полезно для отладки.

Процесс подготовки новой версии


Мы рассмотрели схему работы обновления с точки зрения клиента игры, но как заставить всё это работать? Для подготовки новых билдов я написал ещё одну утилиту — CompressBuild. Она рекурсивно сканирует папку, сжимает файлы методом Deflate, а информацию о них заносит в список файлов — filelist. После сжатия к имени файла дописывается символ "_". Сжатые файлы повторно не сжимаются, поэтому при необходимости в папке билда можно обновить лишь отдельные файлы, CompressBuild обновит только их.

Некоторые файлы в клиенте игры изменяются в процессе работы, например, содержат настройки. Такие файлы нужно игнорировать, соответствующие шаблоны утилита берет из файла exclude. То есть эти файлы просто не попадают в filelist и не портятся на клиенте при обновлении.

Таким образом, чтобы подготовить новый билд, мне нужно:

1. Скопировать папку \master в папку \[номер_версии]
2. Запустить CompressBuild, который запакует в ней файлы и составит их список.
3. Закачать всё это на сайт игры.
4. Изменить на игровом сервере номер актуальной версии на номер только что закачанной. Вуаля!

С этого момента при обновлении люди будут получать новую версию.

Ну а папки со старыми билдами на сервере можно удалить, чтобы не занимали место.

Заключение


Конечно, моя система обновления не идеальна и не лишена недостатков. Например, если в клиенте какой-то файл был удалён — у игроков он останется. Если файл был переименован — он будет загружен как новый, а старый экземпляр не будет удалён. Можно, конечно, доработать утилиту обновления, добавив в список файлов команды для удаления/переименования файлов, но вообще такие проблемы для моей игры неактуальны, так что я не стал заморачиваться.

Ну а исходники можно взять тут: astralheroes.com/files/UpdaterSrc.zip
(компилируется в Delphi-2006 / Turbo Delphi, за другие компиляторы не ручаюсь).

Комментарии (25)


  1. Suvitruf
    13.09.2018 18:28

    rip.zip )=


  1. Fox_exe
    13.09.2018 19:14

    Я както натыкался на «обновлялку», внутри которой был завернут GIT-клиент… Это было эпично, но работало великолепно! (Переименованные файлы именно переименовывались, удаленное удалялось, а измененное докачивало лиш измененные части, а не файл целиком).

    А ещё советую взглянуть в сторону Torrent-клиента. Можно использовать для более быстрого скачивания обновлений — Клиенты будут делиться друг с другом, снижая нагрузку на ваши сервера.

    Я-же писал более сложный алгоритм создания «Патчей»: Архивов, содержащих в себе .diff файлы (для текста и конфигов) и кое-что аналогичное для бинарных данных. Благо файлы на клиенте хранились не единым архивом, а практически в открытую.
    Бонусом была кнопочка «Перепроверить всё», заставляющая клиента просчитать CRC для всех файлов и сравнить с данными сервера (И скачивать, в случае различий).


    1. APXEOLOG
      13.09.2018 21:33

      А насколько имеет смысл заморачиваться с diff'ами ради текста и конфигов (я так понимаю ваших, а не тех, что бы изменены пользователем)? Обычно размер подобных файлов достаточно мал и можно скачать целиком


      1. Fox_exe
        14.09.2018 10:03

        Там были файлы локализации по 10-20 мегабайт. Такчто имело смысл, хотя и небольшой.
        Но основная причина — это механизм обновления бинарных файлов кусками, который использовал практически туже механику, что и diff-файлы.


        1. roryorangepants
          14.09.2018 11:14

          Я както натыкался на «обновлялку», внутри которой был завернут GIT-клиент… а измененное докачивало лиш измененные части, а не файл целиком).

          Но основная причина — это механизм обновления бинарных файлов кусками, который использовал практически туже механику, что и diff-файлы.

          Я надеюсь, вы в курсе, что Git как раз не докачивает диффы, а делает снепшоты файлов?


          1. playermet
            15.09.2018 15:32

            А вы сами в курсе, что то что git является snapshot-based а не diff-based VCS не значит что при работе он не использует дельта-компрессию для блобов? И снапшоты в git относятся не к файлам, а к коммитам.


    1. Taraflex
      13.09.2018 21:53

      Тогда уж сразу на основе aria2 делать, что не только torrent умеет, но и еще кучу всего.
      На ней например DriverPack Solution работает.


    1. Urvin
      14.09.2018 17:38

      С тех пор, как я узнал об SVN, так и не понял, почему люди не встраивают vcs, а продолжают лепить свои поделки.


      1. playermet
        15.09.2018 15:37

        Потому что у них совершенно разные цели.


        1. Urvin
          15.09.2018 17:26

          Скачать, разместить, проверить достоверность разложенного продукта.
          Другая цель только в том, что vcs умеет коммитить обратно.


          1. playermet
            16.09.2018 01:06

            Это не цели, это функционал. Если часть функционала совпадает, это еще не значит что инструмент подходит.


  1. Taraflex
    13.09.2018 21:51

    ещё одна утилита? Всё просто: для обновления файлов игры нужно выполняться с правами администратора.

    Программу можно научить перезапускать себя с запросом UAC
    Например так
    void execAdmin(const wchar_t *path, const wchar_t *params)
    {
        SHELLEXECUTEINFOW sei = {0};
    
        sei.cbSize       = sizeof (sei);
        sei.fMask        = SEE_MASK_NOCLOSEPROCESS;
        sei.hwnd         = 0;
        sei.lpVerb       = L"runas";
        sei.lpFile       = path;
        sei.lpParameters = params;
        sei.lpDirectory  = 0;
        sei.nShow        = SW_SHOW;
        sei.hInstApp     = 0;
    
        if (ShellExecuteExW (&sei))
        {
            WaitForSingleObject (sei.hProcess, INFINITE);
            CloseHandle (sei.hProcess);
        }
    }
    


    1. 0xf0a00
      13.09.2018 23:11

      Ммм Delphi, приятно видеть что ты не одинок. Как раз кстати обдумывал как реализовать автообновление в проекте.


    1. Cooler2 Автор
      13.09.2018 23:32

      Да, можно и так, спасибо за инфу. В следующий раз оставлю один файл.


      1. JediPhilosopher
        14.09.2018 12:47

        А как тогда обновлять сам исполняемый файл, если он же и запущен? В линуксе-то это можно сделать, а вот в виндовсе оно же скажет что «не могу записать файл, он занят, попробуйте снова». Поэтому наверное везде где я видел используются все-таки два файла и апдейтер отдельно от основного приложения.


        1. Fox_exe
          14.09.2018 15:45

          В *nix'ах приложение почти всегда целиком помещается в оперативку и с файлом на диске можно творить всё, что вздумается.
          В Винде, по любому, можно провернуть туже операцию. Просто это чуть сложнее.


          1. Virgo_Style
            14.09.2018 19:07
            +1

            В апдейтере можно переименовать свой собственный файл и сохранить новый под старым именем.


  1. zirix
    14.09.2018 02:40

    Попробуйте этим сжимать файлы:
    Port of LZMA SDK to Pascal (Delphi, Kylix and Freepascal) (https://www.7-zip.org/sdk.html)
    Разница такая же, как между zip и 7z


    1. Uranic2
      14.09.2018 19:07
      +1

      Замечу, что для 7z есть обертка для 7z.dll в JCL (JclCompression). Тоже сжимаем файлы для автообоновлений в Delphi


  1. ErgoZru
    14.09.2018 13:19

    очень напоминает апдейтер от клиентов к «неофициальным» серверам lineage 2, который был распространен в 2008-2009 годах


  1. sixeL
    14.09.2018 19:08

    К сожалению, процесс в Windows не может повысить свои права во время выполнения

    Может (2,3 ответы)


    1. Cooler2 Автор
      14.09.2018 19:11

      По ссылке там не процесс повышает права, а запускает новый процесс из того же exe-шника. Это как-раз то, что выше описал Taraflex Годный вариант, но всё же это не elevation самого процесса.


      1. sixeL
        14.09.2018 22:58

        Прошу вас обратить пристальное внимание на третий пункт. Приводится последовательность вызовов:
        CredUIPromptForCredentials() или CredUIPromptForWindowsCredentials()
        LogonUser()
        ImpersonateLoggedOnUser()
        RevertToSelf()
        CloseHandle()

        О запуске нового процесса здесь речи не идет. Магия заключается в том, что полученный в результате LogonUser токен может быть использован не только для запуска процесса но и для процесса имперсонации в текущем контексте выполнения и защиты процесса.
        Этот механизм чаще я применял в ASP.NET в случае если web приложение работает от одного пользователя, а действия в БД типа MS SQL нужно выполнить от имени другого, который работает с приложением на данный момент.
        .net пример и msdn


        1. Cooler2 Автор
          14.09.2018 23:12

          Ok, согласен. А как это визуально выглядит для пользователя?


          1. sixeL
            14.09.2018 23:28

            При использовании флага CREDUIWIN_SECURE_PROMPT в функции CredUIPromptForWindowsCredentials, для пользователя, запрос логина с паролем будет производиться в контексте UAC.