Обратная разработка Spark Casual Engine: конвейер видеоигр начала 2010-х

Прокомментировать Просмотры: 0

Предпосылки и зарождение исследования

У меня в распоряжении находился файл Game.cub объемом 437 мегабайт, принадлежавший казуальному квесту 2011 года выпуска. Внутри него скрывалось 3559 файлов с неизвестными большинству редакторов расширениями: .cubebin, .stex, .sparkeffect, .binldinfo. Естественно, возникло непреодолимое желание выяснить их природу.

Речь идет об игре Enigmatis: The Ghosts of Maple Creek от польской студии Artifex Mundi, которую я когда-то честно приобрел в Steam. С самим проектом я познакомился еще около восьми лет назад, но вернемся к сути. Игровой движок носил название Spark Casual Engine, и в ходе анализа он оказался гораздо занимательнее самого произведения.

Главный вывод, к которому я пришел: в исполняемом файле практически нет игры — там содержится лишь C++ рантайм. Все уровни, игровая логика, анимации, диалоговая система, достижения и квесты хранятся в виде чистых данных внутри 306 файлов .cubebin, насчитывающих в общей сложности 43 609 объектов. Дизайнеры собирали проект визуально с помощью мыши в специальном редакторе, тогда как программисты практически не вмешивались в этот процесс.

В процессе декомпиляции обнаружилось множество артефактов, которым не положено было доживать до релиза: отладочная консоль с древовидной навигацией по объектам, напоминающая файловую систему, 72 внутриигровых чита, скрытое QA-меню для быстрого перемещения по сюжету, оригинальные пути к исходникам с CI-сервера и даже орфографические ошибки в недрах движка. Вдобавок выяснилось, что полноценный SDK этого движка целиком сохранился на archive.org вместе с редактором и XML-исходниками сцен, что позволило перепроверить результаты реверс-инжиниринга по первоисточникам.

Ниже пошагово описано, как вскрывался каждый формат, что скрывается в их недрах и почему польские разработчики в 2011 году предпочли написать собственный C++ движок вместо использования готовых решений.

Небольшая оговорка: на различных этапах реверс-инжиниринга активно задействовался искусственный интеллект (Claude Opus 5.5).

Шаг первый: вскрытие контейнера

Защита архива .cub обходится элементарно: весь файл зашифрован простым XOR с использованием одного байта, а ключ вычисляется по первому же байту, поскольку в начале всегда прописана сигнатура cub\0. В данном билде ключ равняется 0x96.

def unxor(d):
    if d[:3] == b'cub': return d
    k = d[0] ^ ord('c')
    return bytes(b ^ k for b in d)

Далее перед нами предстает простейший архив, лишенный какого-либо сжатия:

"cub\0"
[строка версии, завершающаяся nul]   "1.0"
u32  количество файлов
char[0x100]  описание              "Paczka danych CUBE"
затем следуют N записей:
    char[0x100]  имя файла
    u32 смещение
    u32 размер

Надпись «Paczka danych CUBE» переводится с польского как «пакет данных CUBE» — первый привет от авторов.

Строго говоря, это не столько защита, сколько маркировка «не для посторонних глаз», что вполне рационально. Движок Spark продавался сторонним студиям как коммерческий софт. Поскольку покупателем выступал другой разработчик, прятать от него ассеты не имело никакого смысла.

Внутри архива обнаружилось 18 разновидностей файлов:

Расширение

Количество

Объём

Описание

.stex

646

144 MB

Текстурные атласы: LZ4 поверх DXT5

.bmp

1157

4.8 MB

Монохромные хитмапы для точного определения кликов

.wav

628

139 MB

Звуковые сэмплы

.snd

592

2.3 MB

Аудиоскрипты в формате XML

.cubebin

306

3.4 MB

Сцены, уровни и вся игровая логика

.sparkeffect

92

368 KB

Системы визуальных частиц

.ogg

36

47 MB

Музыкальное сопровождение и озвучка

.png / .jpg

48

3 MB

Курсоры и обои, не вошедшие в атласы

.cubeimage

28

116 KB

Покадровая анимация (XML)

.webm

12

55 MB

Видеозаставки

.ttf / .ttc

4

27 MB

Шрифты, включая наборы для CJK

.cubebinproj

2

85 KB

Глобальное оглавление проекта

.cubefont

2

137 KB

Спецификации текстовых стилей

.xml

2

1.1 MB

Карты атласов и общие конфигурации

.sparkatlas

1

382 KB

Бинарный аналог карты атласов

.curves

1

114 B

Кривые математической интерполяции

.binldinfo

1

514 B

Скомпилированный эквивалент loadinfo.xml

Рядом с главным контейнером располагалось 12 языковых пакетов: от Game_de.cub до Game_zh.cub. Примечательно, что японский вариант весил вдвое больше остальных (10.9 MB против 4.4 MB), и причина этого кроется в специфике локализации.

Ядро игры: формат .cubebin

Именно эти 306 файлов с расширением .cubebin и представляют собой саму игру. Их расшифровка потребовала создания парсеров с чистого листа, и это стало наиболее захватывающим этапом работы.

Первый слой: алгоритм LZ4

Файлы начинаются с последовательности 55 5E AA C0. Сразу за шапкой идет байт 0xF0, однозначно указывающий на блочный формат LZ4: старший ниббл маркера задает длину литералов, а значение 15 предписывает чтение дополнительных байтов. Позже в теле бинарника отыскалась отладочная строка LZ4HC Compression fail, подтверждающая использование режима максимального сжатия.

смещение  тип     значение
0x00      u32     магическое число 0xC0AA5E55
0x04      u32     версия 0x00040000
0x08      u32     суммарный размер распакованных данных
0x0C      u32     размер первого сжатого блока
0x10      u32     размер распакованного блока (константа 262144)
0x14      ...     тело первого сжатого блока
          далее для каждого последующего блока:
              u32 размер_сжатого
              u32 размер_распакованного
              данные

Файлы размером свыше 256 КБ нарезаны на отдельные фрагменты. Все 306 файлов успешно распаковались с первой же попытки.

Второй слой: иерархический граф объектов

Внутри распакованных данных обнаружилась сериализованная структура объектов:

0x00    u32     0x12345678   ← маркер порядка байт (endianness)
0x04    u32     смещение до футера
0x08    u32     полный размер секции
0x0C    u32     контрольная сумма
0x10    u32     N — число идентификаторов GUID
0x14    N × 16  глобальная таблица GUID для всех объектов
        u32     число корневых узлов
        ...     тело дерева
футер   ...     внутренние словари типов

Запись отдельного объекта строится рекурсивно:

[u8 тег][размер]   если старший бит тега взведен (tag & 0x80), размер занимает 2 байта, иначе 3
                   длина вычисляется от байта тега до завершения объекта
[ключ класса]      1 либо 2 байта (если старший бит активен)
[u8 длина][имя]    строковое имя объекта в ASCII
[16 байт]          уникальный GUID
[u8 флаг]
[блок свойств]     0x24, u24 размер, u8 количество, u8 флаг, ...
[число дочерних]
[дочерние узлы...] рекурсивно

Благодаря чему удалось провести дешифровку

Здесь разработчики сделали поистине царский подарок: каждый файл содержит собственный локальный словарь типов, включающий исключительно те классы и свойства, которые реально задействованы в данном файле. Причем с удобочитаемыми человеческими именами.

k\x00\x00\x00\x13\x04\x00\x00 \x01\x0eCHierarchyRoot \x03\x12CHierarchyObject2D \x06\x11CParticleEffect2D \n\x06CGroup \x0b\tCScenario … \x80\xad\x03\x17Transformation.Position\x0e \x80\xb1\x03\x05Width\t \x81\x0e\x82A\x0cTexture Name\x0b

Структура состоит из четырех секций, каждая предваряется парой полей u32 количество и u32 тег:

Тег

Назначение секции

Формат записи

0x0413

Классы

[ключ][длина][имя]

0x00DA

События

[ключ][ключ класса][длина][имя]

149A

Свойства

[ключ][ключ класса][длина][имя][u8 тип]

0x025B

Резервная (всегда пуста)

—

Именно на секции событий я изначально споткнулся, приняв её за терминатор и ошибочно пропуская. Из-за этого парсер успешно обрабатывал 256 файлов из 308, аварийно завершаясь на тех, где присутствовали непустые списки событий.

Типизация свойств

Анализ всех 43 609 объектов позволил составить исчерпывающую таблицу типов, чьи наименования позже полностью подтвердились по RTTI-информации движка:

Код

Тип данных

Размер

Частота встречаемости

0x02

Логический (bool)

1 байт

2 278

0x07

Целое / перечисление

4 байта

4 129

0x08

Целое / перечисление

4 байта

8 184

0x09

Число с плавающей точкой (float)

4 байта

40 840

0x0B

Текстовая строка

Переменный

~7 000

0x0C

TNone (служебный)

—

2

0x0D

CUBE_GUID (ссылка)

20 байт (или 4 + 20·N)

18 829

0x0E

Двумерный вектор (vec2)

8 байт

18 808

0x10

Цвет (RGBA)

4 байта

3 015

Межобъектные связи осуществляются посредством ссылок на GUID. Примечательно, что свойство Target фигурирует в проекте 1637 раз — это абсолютный рекорд. Вся игровая интерактивность построена на принципе «вызвать определенное действие для вон того целевого объекта».

Архитектура игрового мира

Когда парсер заработал корректно для всех 308 файлов, перед глазами вырисовалась целостная картина. Вот пример типичной игровой локации, экспортированной написанной мной утилитой:

[CHierarchyRoot] Church_Basement
  [CNonWideScene2D] Scene2D   {Максимальный зум=1.25}
    [CGroup] Location
      [CLocation] SoundsAction   {Плейлист музыки=..., Смена немедленно=true}
        [CAmbientSound] Ambient Sounds   {Звуковой файл=ambient_base_tomb.snd}
      [CActiveElement] LevelActions
        [CTriggerAction] OnFirstEnter_ShowMapIcon
        [CDelayAction]   OnEnter_DiaryMapActions
          [CTriggerAction] Actions_HideAllMarkers
          [CShowAction]    Actions_ShowThisLocationMarker
    [CGroup] Background
      [CPanel] BKG              {1024×768, basement_bkg.png}
      [CPanel] obj_SecretDoor_UNLOCKED   {368,146  152×253}
      [CPanel] pnl_Clutter
        [CPanel] obj_Scroll, obj_Note_01...04, obj_Poster_01...04
      [CFader] ADD_blue   {Мин. затухание=0.5, Частота=0.5, Рандомизация=0.5, Режим смешивания=ADD}
      [CFader] ADD_yelow  {Мин. затухание=0.85, Частота=3.0}
    [CGroup] Items
      [CItem] item_Resin-Component-2 {Имя предмета=CHURCH_BASEMENT_ITEM_RESIN_COMPONENT2,
                                      Иконка=mini_rosin.png}
    [CGroup] HO
      [CHOSwitcher] GoTo_HO_Desk  {Курсор=Close Up, Цель=...}
        [CParticleEffect2D] FX_HO_Desk {Файл эффекта=ho_effect_5_big.cube_effect}
    [CGroup] GoTo
      [CLocationSwitcher] GoTo_SecretChamber
            {Курсор=Nav Forward Left, Подсказка=hint_add_cursor_forwardleft.png,
             Текст курсора=CHURCH_BASEMENT_TO_SECRET_CHAMBER}
        [CScenario] anim_Open   {Принудительное время сценария=1.0}
          [CActivateAction] / [CHideAction] / [CShowAction]
        [CPlaySoundAction] {Звуковой файл=walking_indoor.snd}
      [CZoomSwitcher] GoTo_Zoom_SacretChamberDoor

Структура любой локации стандартизирована: звуковое сопровождение → фон → интерактивные элементы → переходы. Вся игровая динамика сводится к различным разновидностям *Switcher, объединяющим курсор, подсказки и списки запускаемых триггеров.

Логика без исходного кода

В самой структуре уровней нет ни единой строчки скриптового кода. Его роль выполняют специализированные узлы действий и условий, привязанные к системным событиям (вроде OnMouseClick, OnShow или OnFirstEnter). Наиболее часто используемые классы:

Класс

Количество

Функциональное назначение

CPanel

5642

Базовый графический спрайт — кирпичик всего интерфейса

CTrack

3272

Анимационный трек

CTriggerAction

2143

Активация поименованного действия

CLabel

1552

Текстовая метка

CHideAction / CShowAction

1444 / 1312

Скрытие / отображение объектов

CKeyFireAction

1394

Инициирование действия по хоткею

CPlayAnimationAction

1236

Запуск сценического сценария

CPlaySoundAction

1109

Воспроизведение звука

CHOItem

904

Предмет в сцене поиска скрытых объектов (HO)

CInvokeCommentAction

827

Озвученная реплика главного героя

CIsActiveCondition

697

Условный оператор проверки

Анимационная подсистема

Анимация универсальна и позволяет изменять абсолютно любое свойство любого объекта:

[CScenario] anim-Tornado-Loop        ← корневой таймлайн
  [CTrack] CTrack_1  {Объект=ADD_Tornado_02, Свойство=Color}
    [CKeyColor] ...  {Смещение времени, Режим смешивания, Значение цвета}
  [CTriggerWaverAction] ...  {Волновой эффект=waver_Tornado-Move}

Режимы интерполяции, унаследованные из CubeEnums.cpp, включают: None, Linear, Jump, Smooth, Hermite, Sinerp, Coserp, Berp и SmoothStep.

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

  • CFader — мерцание прозрачности, управляемое параметрами Min Fade, Frequency, Randomize и Ampl Multipler. Применяется для имитации отсблесков пламени, свечения или дымки.

  • CWaver — волновые колебания с настройками Amplitude, Frequency, Phase shift и битовой маской Affect, определяющей подверженные искажению параметры. Используется для покачивания деревьев, стелющегося тумана и вращения торнадо.

Глобальный проектный файл

Файл game.cubebinproj построен по аналогичной схеме и содержит 688 узлов, описывающих общую архитектуру проекта:

[CProject] mysteria
  Имя игры = Enigmatis_TheGhostOfMapleCreek
  Родное разрешение = 2, Широкоэкранный проект = true
  Стартовая иерархия = Splashscreen

В структуру вошли 214 зон приближения (зумов), 70 локаций, 38 сцен поиска предметов (HO), 19 мини-игр и 97 музыкальных триггеров. Иерархическое дерево выглядит следующим образом: Проект → Глава → Локация → Зоны зума / HO / Головоломки.

Здесь же обнаружилась первая деталь, развернувшаяся позже в целый сюжет. У узлов проекта присутствует свойство Cheat, содержащее мнемоники для быстрого перемещения:

mm (главное меню), intro, pa, mo_ga, sandbox, hotest,
hill, sq, cha, yard, inside, cellar, pcroom, garage, garden,
churchinside, churchbasement, belfry, graveyard, crossroads,
cave1, cave2, sacred, ho_tomb, statue, turret,
mgb mgbc mgc mgg mgh mgm mgmap mgp mgs mgt mgtd mgtw (мини-игры),
remi1, remi2 (воспоминания), laba...labe (лаборатория/тесты)

В наличии даже тестовая локация fake и уровень sandbox с флагом «исключительно для демок».

Игровые механики: поиск предметов, головоломки и доска улик

Сцены Hidden Object (HO) устроены изощреннее, чем кажутся

Интуитивно ожидаешь увидеть статичное изображение и жесткий перечень спрятанных объектов. На деле же реализовано разделение между самой сценой и логикой её прохождения:

[CHierarchyRoot] HO_Shed
  [CPanel] HO_shed
    [CLocation] SoundsAction    ← четырехслойный фоновый эмбиент
    [CPanel] BKG
    [CPanel] HO_shed_HO
      [CHOItem] HO_shed_lugwrench  {Маска клика=ho_shed_lugwrench_hitmap.bmp,
                                    Текстура=ho_shed_lugwrench.png,
                                    Имя предмета=HO_ITEM_LUGWRENCH,
                                    Владелец экземляра=[CHOInstance2]}
      ... еще 47 элементов ...
  [CHOInstance] CHOInstance1 {Требуется предметов=12, Нужные элементы=[HO_shed_shovel]}
      [CAddItemAction] OnFinished → выдать квестовый инвентарь
  [CHOInstance] CHOInstance2 {... Порядковый номер=2, Нужные элементы=[HO_shed_cog]}
      [CDedFinishedCondition] ← разблокируется лишь после конкретной дедукции
  [CHOInstance] HO_Instance3 {... Нужные элементы=[HO_shed_lugwrench]}
      [CIsActiveCondition] Conditions_YM_in_Basement_Played

В локации хранится пул из 33–64 предметов, в то время как CHOInstance задает конкретную сессию: алгоритм выбирает 12 случайных элементов из общего пула плюс один обязательный сюжетный предмет, опираясь на собственные условия доступности и награды.

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

Также обнаружились скрытые механики штрафов за ошибочные клики, управляемые свойствами Click count to punish, Click time to punish, Punishment FX, Punishment Time и событиями OnHoPunish / OnHoPunishEnd.

Мини-игры ультимативно инкапсулированы

Здесь подход противоположен предыдущему: каждая головоломка представляет собой отдельный жестко зашитый C++ класс движка, тогда как файлы .cubebin хранят исключительно визуальную раскладку и графику.

Имя класса

Тип головоломки

CPicrossMinigame

Японский кроссворд (пикросс)

CJigsawMinigame

Классический пазл

CPicklockMinigame

Взлом замка отмычками

CRingsMinigame

Вращающиеся кольца

CSwapObjectsMinigame

Перестановка элементов местами

CTelescopeMinigame

Наведение телескопа

CTrapDoorMinigame

Механизм люка

CTableWareMinigame

Сервировка стола

CSymbolsMinigame

Сопоставление символов

CBookCaseMinigame

Книжный шкаф-головоломка

CBasementSafeMinigame

Открытие подвального сейфа

Каждой мини-игре сопутствует файл *_popup.cubebin, отвечающий за всплывающее окно с правилами перед стартом.

Доска дедукции — фирменный знак серии

Наиболее объемные файлы игры — diary.cubebin, CE_diary.cubebin и Mosaic.cubebin.

Класс CEvidenceObject определяет улику в игровом мире. У неё прописаны параметры Zoom (какую зону приближения открывать), Mosaic evidence (какой фрагмент мозаики разблокировать) и Zoom Params.Start Rotation, обеспечивающий эффект «влетания» улики в дневник с её реальной позиции на экране.

Собранные улики объединяются в логические выводы: Ded_Suspect, Ded_PlaceOfCrime, Ded_CrimeInstrument, Ded_Victims, Ded_Bell, Ded_Statue, Ded_The-Hermit-Is-A-Detective. Предусмотрены и служебные блокираторы Blocker_1...7, не позволяющие сделать преждевременные выводы.

Именно через эти логические заключения гейтится игровой прогресс — как отмечалось ранее, некоторые HO-сессии становятся доступны только после завершения определенных расследований (CDedFinishedCondition).

Самое любопытное заключается в том, что знаменитая «доска улик», выделявшая Enigmatis на фоне конкурентов по жанру HOPA, реализована на базе стандартных объектных сцен, без написания специализированного кода.

Графическая подсистема: формат STEX

Все графические активы игры упакованы в 646 файлов .stex общим объемом 144 мегабайта. Формат устроен предельно прозрачно:

0x00  сигнатура "STEX"
0x04  u16 версия = 1
0x08  u32 размер файла
0x0C  u32 смещение до данных = 0x1C
0x10  u16 ширина изображения
0x12  u16 высота изображения
0x14  4 байта флагов (константа 1, 2, 1, 0)
0x18  FourCC тег: "DXT5" либо "ARGB"
0x1C  u32 размер сжатого блока
0x20  u32 размер распакованного буфера
0x24  данные, сжатые алгоритмом LZ4

Используется всё тот же LZ4, что и для сцен. После распаковки извлекаются «сырые» блоки сжатия DXT5 либо несжатый поток BGRA8888. Все 646 файлов декодируются без сбоев. Я написал собственный DXT5-декодер и PNG-энкодер на базе библиотеки zlib, не прибегая к сторонним зависимостям.

Ниже представлен целый текстурный атлас локации «Подвал церкви» с разрешением 1024×1024 пикселя:

Обратная разработка Spark Casual Engine: конвейер видеоигр начала 2010-х
Атлас Game0_mysteria.InGame.Church.Basement_0.stex — фоновые изображения, двери и элементы локации на одной текстуре

Нарезка атласов по структуре проекта

Наименование файлов отражает путь в иерархическом дереве проекта:

Game0_mysteria.InGame.Church.Basement_1.stex
       ^проект  ^глава  ^район   ^локация ^номер

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

В сценических файлах при этом прописано Texture Name=basement_bkg.png, хотя на жестком диске файлы PNG отсутствуют. Связку обеспечивает файл atlas_Game.xml (1.1 МБ, содержащий 255 пакетов, 646 атласов и 5443 логических изображения):

Рядом располагается atlas_Game.sparkatlas — дубликат этой же таблицы в бинарном виде. Изначально я предполагал, что это резервная копия «для ускорения работы». Однако на деле первичным является именно бинарный файл, а XML используется в качестве фолбэка; в исполняемом файле зашито предупреждение:

File XXatlas.sparkatlas not found! Trying to load atlas.xml

Разделение по качеству

Префиксы Game0_ и Game3_ на первый взгляд напоминают уровни детализации (LOD), но это не так:

  • Game0_ — 578 атласов в формате DXT5: включают фоны, объекты и элементы сцен поиска.

  • Game3_ — 68 атласов в несжатом формате ARGB: содержат исключительно иконки инвентаря (mini_*.png).

Атлас Game3 — иконка инвентаря 72×72 в несжатом формате ARGB на текстуре 128×128
Атлас Game3 — иконка инвентаря 72×72 в несжатом формате ARGB на текстуре 128×128

Логика понятна: мелкие иконки на прозрачных подложках деградируют при блочном сжатии DXT, поэтому их хранят в несжатом виде. Плата за это — четырехкратное увеличение объема, но таких файлов немного.

Широкоэкранные панорамы

Базовое разрешение игры составляет 1024×768. Для адаптации под широкие экраны фоновое изображение собирается из трех текстур с суффиксами _left, _center и _right — за эту сборку отвечает метод GetWideTextureInfo. В стандартном режиме отображается лишь центральная часть.

Сам движок поддерживает форматы DXT1, DXT3, DXT5, PVRTC4, ETC1 и RGBA — по одному на целевую платформу. Предусмотрен даже аварийный путь обработки: DXT textures are not supported on this GPU - decompressing '%s' to RGBA !

1157 монохромных BMP-масок

Самая многочисленная группа файлов после звуковых сэмплов. Все они имеют суффикс *_hitmap.bmp и представляют собой однобитные монохромные маски (bpp=1, отрицательное значение высоты означает развертку сверху вниз).

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

Слева показан спрайт из атласа, справа — соответствующая ему маска (отмасштабированная до тех же пропорций):

Обратите внимание на размеры: спрайт имеет габариты 151×168, а маска — 57×64 пикселя. Сравнение 395 пар «спрайт — маска» показало, что медианное соотношение сторон ровно равно 2.0, причем в 216 случаях из 395 оно соблюдается тютелька в тютельку. Иными словами, маски хранятся в уменьшенном вдвое разрешении, чему в бинарнике соответствует свойство HitMap.DefaultLevel.

Самый миниатюрный файл — basement_safe_button_hitmap.bmp (размером 30×30 пикселей и весом всего 184 байта). Все 1157 масок суммарно занимают 4.8 МБ — меньше, чем один видеоролик.

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

Point · Line · Circle · Rectangle · Elipse · FromHitmap
· Outside · Inside · Custom · CircleTangent

Слово «Elipse» содержит опечатку, дожившую до релиза. Помимо неё, в коде встретились QUATERION в перечне типов и Expert (Lagacy mode) в настройках сложности.

Звуковое оформление как декларативный язык

592 файла с расширением .snd оказались не аудиоданными, а скриптами мини-аудиодвижка. Ниже приведен фрагмент фонового эмбиента подземелья, снабженный авторскими комментариями звукорежиссера:




  

    
      
      
        
      
      ...
    

    
      
         
        
        
      
      ...

Используются узлы CRandomContainer (случайный выбор с учетом весов), CSequenceContainer (последовательное воспроизведение), а также CSample / CSampleFile. Набор параметров включает Weight, Pan, Pitch, Volume, DelayMin/Max, WaitAfterMin/Max, а также тайминги затухания.

Из горстки исходных WAV-семплов генерируется бесконечный, не повторяющийся звуковой ландшафт: капли воды (одиночные, сдвоенные и тройные с варьирующейся громкостью и высотой тона), редкие скрипы и шорохи летучих мышей.

Внутри локации параллельно функционируют несколько объектов CAmbientSound — например, в HO_Shed их задействовано четыре: base_town, add_birds, add_bell_distant и add_cicada. Звуковая атмосфера конструируется послойно.

Для казуальной игры 2011 года это реализовано на недосягаемом для того времени уровне. То, что сегодня реализуется через middleware вроде FMOD или Wwise, здесь было написано силами студии и задекларировано в формате XML.

Локализация: пара неожиданных открытий

Текстовые данные хранятся в таблицах Excel

Файл text.xml размером 627 КБ неожиданно оказался таблицей Excel 2003 XML Spreadsheet:



 Me
 BALCONY_FARM_COMM
        The silhouette of a granary

Движок парсит этот формат напрямую — в бинарном коде содержится строковый литерал . Иными словами, переводчики выполняли свою работу в Excel, после чего файл напрямую отправлялся в игровой билд — вполне в духе производственных реалий тех лет.

Таблица насчитывает 3023 строки в две колонки: ключ и соответствующий текст. Из деревьев сцен я извлек 2885 ключей, из которых 2841 (98.5%) успешно нашелся в таблице. Цепочка сошлась:

CHURCH_BASEMENT_ITEM_RESIN_COMPONENT2  EN: Resin component   RU: Смола
HO_ITEM_LUGWRENCH                      EN: Lug wrench        RU: Бaллонный ключ
ITEM_NAME_METAL_FIGURINE               EN: Metal figurine    RU: Meтaлличecкaя фигyрка

В русской локализации 64% слов содержат латинские буквы

Присмотритесь к приведенным выше русским строкам: Бaллонный, Meтaлличecкaя. Это не баг рендеринга шрифтов — в строки реально подмешаны латинские символы. Статистика по всему текстовому файлу:

Слов с кириллицей

15 185

Из них содержат латинские символы

9 826 (64%)

Всего уникальных символов

153

Подставлены точнейшие визуальные гомоглифы:

Пара символов

Кириллических

Латинских

о / o

2 104

5 988

е / e

2 523

5 054

а / a

1 676

4 889

с / c

1 098

2 563

р / p

1 216

2 433

у / y

705

1 618

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

Следовательно, это артефакт пайплайна локализации: перевод выполнялся в Excel, текст многократно копировался между различными таблицами и раскладками клавиатуры, в результате чего «прилипли» посторонние символы. Глазу игрока это незаметно, но любой текстовый поиск по игре оказывается безнадежно сломан.

Предварительное запекание шрифтов

Внутри каждого языкового пакета находятся файл font_XX0.stex (текстурный атлас глифов размером 1024×1024 в формате ARGB, где сами начертания записаны в альфа-канале) и множество файлов с расширением .desc, содержащих метрики для каждой пары «шрифт + кегль».

Языковой пакет

Файлов .desc

Файлов font_*.stex

Английский (en)

85

1

Польский (pl)

81

1

Японский (ja)

72

4

Русский (ru)

52

1

Корейский / Китайский (ko / zh)

33

1

Вот и разгадка того, почему японский пакет весит вдвое больше. В языках группы CJK файлов меньше, но иероглифов слишком много, поэтому часть из них подгружается динамически из огромного шрифта uming.ttc весом 20 МБ.

Билд-система формирует атлас только из реально используемых в игре символов: в бинарном коде присутствуют маркеры z_charset, virtualfont, fontatlas, genSize и отчетный тег Charset%s:.

Что поведал исполняемый файл

До этого момента работа велась исключительно с данными. Переключившись на Steam-версию под macOS, я обнаружил, что исчерпывающая документация зашита прямо в бинарник.

Build: #12033_196353 version: 1.0.12033 Bundle: com.artifexmundi.enigmatis.mac.steam Бинарник: universal x86_64 + i386, 45.7 MB Скомпилирован: OS X 10.8.4 Mountain Lion, ориентировочно 2013 год

370 путей исходных файлов в релизной сборке

В теле бинарника сохранились абсолютные пути к исходникам сборочного сервера CI:

/Volumes/Data/TeamCityAgent/work/d330097117833f33/OSXBuild/Core/Unified/
    ../../../Cube/Cube/Core/Source/RTTI/GameSerialize.cpp

По этим путям полностью восстанавливается структура директорий движка:

Cube/
├── Core/            197 файлов — базовое ядро
│   ├── GameObjects/       101  игровые сущности
│   ├── Widgets/            11  элементы интерфейса (кнопки, чекбоксы, ползунки)
│   ├── RTTI/                5  собственная система типов и сериализации
│   ├── SchemaAndProperty/   2  описание свойств для редактора
│   ├── Effects/ Curves/ Cursor/ Physics/ JSON/ Localization/
│   └── Atlas/CubeAtlasManager.cpp
├── SparkMinigames/   59 — библиотека головоломок
├── NewRenderer/      40 — OpenGL бэкенд, SparkDXT.cpp
├── FeaturePack/      40 — система монетизации (классы CFP*)
├── SparkKernel/      13
└── Audio/             2 — AudioSystem, VorbisDecoder

Game/ObjectLibraries/Game/Source/   ← код, написанный специально для Enigmatis
├── Mosaic/    Mosaic.cpp, Deduction.cpp, Evidence.cpp
├── Actions/   AddEvidenceAction.cpp
└── Objects/   EnigmatisInventory.cpp

Ключевой вывод: доска дедукции — единственное, что было написано разработчиками эксклюзивно для этой игры (всего семь файлов). Всё остальное представляет собой универсальный движок, к которому игра подключается в качестве плагина (ObjectsLibraryMain.cpp).

1070 классов против 356 реально задействованных

Сопоставление классов, найденных в бинарнике, с теми, что фактически встречаются в файлах данных:

Количество

Классов в бинарном файле

1070

Задействовано в Enigmatis

356 (33%)

Классов мини-игр в ядре движка

99

Из них фактически использовано

18

Среди неиспользованных головоломок числятся: Sokoban, Mahjong, MagicSquare, Memo, Slider, Untangle, RollingBalls, ShootingRange, Beams (зеркала и лучи), Cables, Circuit, Gears в четырех вариациях, GearsLabyrinth, BallDrop, CatchPrey, FindPath, Portal, Snowdome, Starfish, Dice, Fish, Harbor, Knight, Padlock, Pipes и множество других.

Это не рудименты, а обширный каталог. Движок обслуживал всю продуктовую линейку казуальных игр студии, и каждый отдельный проект выбирал из этого пула необходимый набор компонентов.

Более того, в движок заложена полноценная трехмерная подсистема (CBase3DObject, CCamera, CLight, CMaterial, CModel, CEmitter3D), хотя сама игра выполнена в строгом 2D.

Строки редактора в потребительском релизе

В исполняемый файл попали фильтры файловых диалогов из уровня редактора:

Cube project (*.cubeproject)
CUBE map (*.cubemap)
Effect (*.sparkeffect)
Sounds (*.snd *.wav);; Sound script (*.snd);; WAV (*.wav)
Fonts definitions file (*.cubefont)

Вдобавок присутствуют классы CRenderWindow, CViewport, CViewportSplitter и CSelection. Редактор и рантайм компилировались из единой кодовой базы, и релизный бинарник тащит за собой весь этот багаж.

Еще одна полезная находка — встроенный логгер активируется через переменную окружения SK_LOG_CONFIG. Доступные приемники вывода: file, html, stream, console, stdout. HTML-отчет генерируется со встроенной темной темой и интерактивным JavaScript-переключателем уровня детализации.

Отладка, которую забыли удалить

Консоль с поддержкой навигации по иерархии объектов

В коммерческом релизе полностью сохранилась консоль разработчика (Cube/Core/Source/CmdExec.cpp). Она позволяет перемещаться по дереву объектов точно так же, как по каталогам файловой системы:

Команда

Выполняемое действие

cd <имя>

Перейти к указанному дочернему объекту

cdm

Перейти к объекту, находящемуся под курсором мыши

ls / ls n

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

rm / rmi

Удалить объект со всем его поддеревом

listp / getallp / getp

Отобразить свойства выбранного объекта

setp <имя> <знач>

Изменить значение свойства «на лету»

map <чит>

Мгновенный переход на уровень по чит-коду

savehierarchy

Выгрузить текущую иерархию в файл

setgamespeed

Множитель скорости игрового процесса

gach / clrach

Выдать / сбросить все внутриигровые достижения

all_items

Добавить в инвентарь все предметы

genscrns

Автоматическое создание скриншотов по скрипту

crashgame

«НЕ ИСПОЛЬЗОВАТЬ!» (принудительный краш)

Здесь кроется разгадка чит-кодов из файла проекта — команда map задокументирована как «switch to level with cheat equal to given string».

Однако активировать эту консоль в розничном билде не получится. Я проверил все возможные векторы: стандартный ввод не читается (нет вызовов fgets, getline или __stdinp), на горячие клавиши консоль не забинжена, параметры командной строки отсутствуют. Команды в коде остались, но точка входа к ним утеряна.

Зато в данных заложено 72 клавиатурных чита

Реальный механизм читерства завязан на объекты CKeyTrigger, расставленные непосредственно внутри сценических файлов. Класс содержит всего два поля: Character и Debug mode only.

Система оказалась поразительно упорядоченной:

Клавиша

Функциональное назначение

F1

Мгновенное решение текущей головоломки

F3

Автопрохождение мини-игр (Casket, Gate, Jigsaw, Telescope…)

F5

Продвижение по сюжету, запуск катсцен (доступно 12 триггеров)

F6

Следующий сюжетный шаг, выдача ключевого предмета

F7 / F8

Анимационные переходы (движение лодки туда/обратно, активация люков)

F11 / F12

Воспроизведение катсцен из издания Collector’s Edition

«1» / «2»

В диалогах — разблокировка бонусов Collector’s Edition и Extras

Буквенные клавиши в HUD

Мгновенная разблокировка всех 18 целей в дневнике

Коды клавиш были восстановлены по именам связанных объектов (Cheat_F1, Cheat_F5, cheat_F7): функциональные клавиши F1..F12 соответствуют кодам 0x52..0x5D, цифры — 0x3E..0x47, а буквы — 0x24..0x3D.

QA-меню в главном меню по клавише F7

В файле mainmenu.cubebin присутствует скрытая панель DEBUG размером 1442×882 пикселя, которая появляется по нажатию F7 и скрывается по F8. Она содержит 45 интерактивных кнопок:

  • MiniGames (14 штук) — прямой запуск: Chapel, BookCase, Casket, Telescope, Pipes, Table, Symbol, Map, Trapdoor, DoorLock, Gate Lock, Money, COA, Safe.

  • Locations (8 штук) — перемещение по локациям: Statue, Square, Hill, Chapel, Vestibule, Crossroads, HO StoreRoom, cellar.

  • FastForwards (22 штуки) — перемотка сюжета по контрольным точкам: P1 - Entering PC Room, P2 - Inv: Crowbar, P4 - FINISH, C1 - Hearts и т.д.

  • FxTest — отдельный тестовый стенд для проверки визуальных эффектов.

Почему в релизе работают всего два чита

Ключ к разгадке кроется в поле Debug mode only. Из 72 триггеров у 70 это поле вообще опущено, а у двух явно выставлено значение False.

Сначала это было доказано статистически: сериализатор опускает дефолтные значения свойств — например, из 597 объектов со свойством Transformation.Scale ни один не хранит стандартный масштаб [1.0, 1.0]. Следовательно, зафиксированное в файле значение False является нетипичным, а значение по умолчанию равно true.

Впоследствии, когда был обнаружен SDK, это подтвердилось на уровне ассемблерного кода:

__ZN5Spark11CKeyTriggerC2Ev:
  24ba1e:  calll  Spark::CWidget::CWidget()
  24ba2b:  movb   $0x1, 0x164(%edi)     ← Debug mode only = true

Итог: 70 читов, включая отладочное меню по F7, функционируют исключительно в Debug- и RD-сборках. А работать в релизе продолжают лишь два: переход к контенту Collector’s Edition из главного меню и автоматическое закрытие платного пейволл-диалога.

SDK, обретенный на задворках сети

Уже на завершающем этапе анализа выяснилось, что поскольку движок Spark активно продавался сторонним студиям, его публичные SDK благополучно сохранились на Internet Archive:

  • archive.org/details/spark-sdk — два архива общим весом по 206 МБ каждый.

  • Spark_SDK_20130308_1543.zip — содержит 769 файлов.

Содержимое ZIP-архива можно проиндексировать без скачивания целиком — Internet Archive поддерживает HTTP range-запросы, а центральный каталог архива располагается в его хвостовой части.

Библиотеки без зачистки символов (not stripped)

Файл Core.dylib размером 31 МБ содержит 346 852 символа, из которых 91 806 имеют полную отладку с оригинальными C++ сигнатурами. Для сравнения, в розничном исполняемом файле игры их всего 557, и почти все они являются внешними импортами.

Spark::CHOInstance::CheatFinish()
Spark::CClassTypeInfo::CheckDefaults(unsigned char*, unsigned int)
Spark::CClassField::AssignValueFromStr(Spark::CRttiClass*, std::string const&,
                                       unsigned int, Spark::CGuidReplacer*) const
Spark::CKeyTrigger::KeyboardKeyUp(Spark::EInputKeyCode::TYPE)

Метод CClassTypeInfo::CheckDefaults стал прямым подтверждением ранней гипотезы об игнорировании дефолтных значений при сериализации.

Наличие редактора в комплекте SDK

Внутри архива обнаружились Designer_d.exe (15 МБ) и графический интерфейс Designermac.app на базе Qt 4 — тот самый WYSIWYG-редактор, в котором создавалась игра. Вдобавок поставлялись рантаймы GameApp_d.exe / GameApp_rd.exe в конфигурациях Debug и RD, где все упомянутые 70 читов активны «из коробки».

Заодно прояснилось значение таинственного режима «RD mode» из строк движка — это RelWithDebInfo, что следует из файла RelWithDebInfo-Config.cube. Там же обнаружился параметр, отсутствующий в конфигурации розничной версии:


Исходники сцен в формате XML

39 файлов с расширением .cubemap представляют собой исходный код, компилируемый впоследствии в бинарные .cubebin. Их структура оказалась в точности такой, какой я восстановил её вслепую:


 
  
   
    384:288
    768
    mg_guide_base.png
    1
   
      ← дочерние узлы

Я извлек MiniGame_chapel_Popup.cubemap — ровно ту же сцену, что присутствует в игре в бинарном виде — и сопоставил деревья объектов. Они совпали. Различия носили сугубо содержательный характер: в исходниках SDK кнопка выхода собрана как CImageButton с отдельными панелями Out/Over/Press, тогда как в релизном билде она упрощена до базового класса CButton.

Одновременно выяснилось, что в процессе компиляции из файлов удаляются свойства из категории Editor Only — в XML-исходниках они присутствуют, но в бинарные файлы не попадают.

Динамические свойства как вершина архитектурной мысли

Дизайнеры уровней имели возможность добавлять объектам произвольные кастомные поля прямо в редакторе, не привлекая программистов к изменению исходного кода на C++:

Dynamic.Cutscene2WasPlayed        BOOL
Dynamic.CommYoungManWasExecuted   BOOL
Dynamic.PCCarTrunkOpen            BOOL
Dynamic.SpotlightIsActive         BOOL
Dynamic.BagSpotIsExpired          BOOL

Это флаги квестовых состояний, созданные силами визуального редактора. Именно эта гибкость делала движок по-настоящему «дизайнерским».

Любопытно, что в финальной версии Enigmatis нет ни одного такого свойства — для отслеживания состояний здесь применяется штатная механика CActiveElement совместно с CActiveElementState (насчитывающая 481 и 182 объекта соответственно). Вероятно, в продакшене предпочли консервативный подход, гарантирующий корректное сохранение прогресса.

SDK отражает состояние движка до эры Free-to-Play

SDK 2013 года

Финальная Enigmatis

Классы в модулях

923

Задействовано 355

Общие классы

293

Классы мини-игр

89

Задействовано 17

В игре присутствуют 62 класса, отсутствующие в публичном SDK. Из них 24 класса составляют весь модуль CFP*: системы монетизации, пейволлы, диалоги интеграции с IAP, предложения оценить приложение, почтовые рассылки, авторизация в игровых сервисах, а также кросс-промо карусель CRotor2.

Это свидетельствует о том, что попавший в сеть SDK застал движок в момент прямо перед переходом на модель free-to-play. Функционал монетизации прикрутили позже, и наружу он уже не отдавался — ровно в тот же период продажа SDK сторонним студиям была свернута.

Итоги: оправдан ли подобный оверинжиниринг?

Первоначальная реакция на подобную архитектуру бывает скептической: неужели ради разработки казуальных квестов потребовалось писать с нуля полноценный игровой движок на C++? Однако цифры говорят об обратном.

Перед нами классический производственный конвейер:

  • 99 разновидностей мини-игр в библиотеке, из которых Enigmatis задействует 18.

  • 9 целевых платформ в таблице перечислений: Windows, macOS, iOS, Android, Metro, Phone8, QNX, Tizen, Linux.

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

  • Автоматизированный генератор скриншотов по скриптам, генераторы отчетов об использовании ресурсов и инструменты создания обоев.

Artifex Mundi выпускала по несколько игр ежегодно под десяток платформ. При таких масштабах производства собственный технологический стек полностью окупался.

Кроме того, в те годы альтернатив попросту не существовало. Полноценная поддержка 2D появилась в Unity лишь в версии 4.3 в конце 2013 года, когда Enigmatis уже два года как красовалась на прилавках. Godot стал открытым только в 2014-м. Так что рассуждения в духе «сейчас проще взять Unity» справедливы для сегодняшнего дня, а в 2011 году у разработчиков просто не было выбора.

Вопросы безопасности и защиты

Защита активов отсутствует как класс, и это тоже вполне прагматично. Однобайтовый XOR, открытые словари типов в каждом файле, пути исходников и отладочные логи в релизных сборках — все это объясняется тем, что Spark изначально создавался как коммерческий продукт для сторонних студий-разработчиков. Покупателем движка выступал коллега-программист, и скрывать от него структуру ассетов не имело ни малейшего смысла.

Что полезно перенять из Spark сегодня

Если вы проектируете схожую систему — например, описываете игровые сцены с помощью JSON в Godot — стоит взять на вооружение три архитектурные идеи:

  1. Локальный словарь типов в каждом файле. Отказ от громоздкой глобальной схемы в пользу локальной таблицы «класс → свойства → кодовые обозначения» в хвосте каждого файла сцены. Это обеспечивает версионирование практически даром: движок фиксирует несоответствие типов и корректно пропускает нераспознанный узел вместо того, чтобы аварийно завершать работу всего приложения.

  2. Разделение статической сцены и динамического прохождения. В сцене заложен пул из 40–60 возможных предметов, тогда как конкретная игровая сессия задается отдельным объектом со своими условиями и наградами. Одни и те же базовые данные порождают разное поведение при каждом новом визите.

  3. Динамические свойства. Возможность наделять объекты произвольными полями прямо из интерфейса редактора, не затрагивая кодовую базу C++. Для JSON-форматов эта концепция ложится идеально.

А вот процедурные анимационные генераторы (CFader, CWaver) сегодня воссоздавать бессмысленно — в 2011 году они экономили массу ручного труда художникам, но в современных условиях ту же задачу эффективнее решают стандартные компоненты вроде AnimationPlayer в связке с процедурными шейдерами и шумом.

Использованный инструментарий

Все скрипты написаны на чистом Python 3 без привлечения сторонних зависимостей (даже собственный PNG-энкодер реализован прямо поверх встроенной zlib):

Скрипт

Назначение

cub.py

Распаковка архивов .cub (снятие XOR + разбор таблицы файлов)

lz4dec.py

Декомпрессор блоков LZ4

cubebin.py

Парсер сценических файлов: граф объектов, свойства, локальные словари

stex.py

Парсер текстурных атласов с экспортом в формат .dds

topng.py

Конвертер текстур DXT5/ARGB в формат PNG

xltext.py

Чтение таблиц text.xml (в формате Excel XML)

Результаты проверки: все 308 из 308 файлов .cubebin, 646 из 646 файлов .stex и 13 из 13 архивов .cub успешно обрабатываются без единой ошибки.

Что осталось за рамками исследования

Нейросетевому анализу и ручному реверсу поддалось не абсолютно всё:

  • Бинарная структура .sparkeffect — имена текстур считываются, но параметры эмиссии частиц остаются закрытыми, поскольку в отличие от .cubebin, здесь отсутствуют встроенные словари. В SDK присутствует 20 исходных файлов .cube_effect, сопоставление с которыми наверняка расставит все точки над i.

  • Таблица кодов клавиш EInputKeyCode — имена зашиты в DLL-функциях редактора и напрямую по строкам не восстанавливаются.

  • Заголовок файлов .desc — имеет переменную длину, а шаг записи глифов достоверно подтвержден лишь для английского языка.

  • Контрольная сумма .cubebin на смещении 0x0C — алгоритм ее вычисления не восстанавливался за ненадобностью для чтения данных.

  • Полная схема свойств всех 923 классов — извлекается путем дизассемблирования метода InitTypeInfo для каждого класса; схема вызовов известна и задача поддается автоматизации, но требует много времени из-за больших объемов.


Если у кого-то возникнет желание продолжить изыскания — SDK в открытом доступе лежит на archive.org, включая редактор, неизмельченные библиотеки и шаблоны сцен (sparkcasualengine.com). Там же доступна документация. Другой вопрос, запустится ли это ПО на современных версиях Windows — по идее, должно.

Благодарю за внимание к материалу.

 

Источник

Похожие статьи

Поиск по играм, новостям и статьям…

Введите не менее двух символов

Введите не менее двух символов