Обратная разработка Spark Casual Engine: конвейер видеоигр начала 2010-х
Предпосылки и зарождение исследования
У меня в распоряжении находился файл 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 пикселя:

Нарезка атласов по структуре проекта
Наименование файлов отражает путь в иерархическом дереве проекта:
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).

Логика понятна: мелкие иконки на прозрачных подложках деградируют при блочном сжатии 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. Их структура оказалась в точности такой, какой я восстановил её вслепую:
Я извлек 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 — стоит взять на вооружение три архитектурные идеи:
-
Локальный словарь типов в каждом файле. Отказ от громоздкой глобальной схемы в пользу локальной таблицы «класс → свойства → кодовые обозначения» в хвосте каждого файла сцены. Это обеспечивает версионирование практически даром: движок фиксирует несоответствие типов и корректно пропускает нераспознанный узел вместо того, чтобы аварийно завершать работу всего приложения.
-
Разделение статической сцены и динамического прохождения. В сцене заложен пул из 40–60 возможных предметов, тогда как конкретная игровая сессия задается отдельным объектом со своими условиями и наградами. Одни и те же базовые данные порождают разное поведение при каждом новом визите.
-
Динамические свойства. Возможность наделять объекты произвольными полями прямо из интерфейса редактора, не затрагивая кодовую базу 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 — по идее, должно.
Благодарю за внимание к материалу.
Инсайдер Cyberleek пообещал прекратить сливы по GTA 6, но пригрозил раскрыть концовку игры
Capcom показала геймплей ремейка Resident Evil 3 на Nintendo Switch 2
Юристы Sony добились закрытия неофициального магазина взломанных игр для PS5
Rockstar официально высказалась о дате выхода GTA VI
The Elder Scrolls V: Skyrim получила три крупных дополнения на PlayStation 5 спустя почти 15 лет после релиза
Бренд Affliction выпустил коллекцию одежды по мотивам вселенной Gears of War
Приключенческому экшену Uncharted 2 исполнилось 17 лет, и многие игроки называют его лучшим проектом Naughty Dog
Звезда «Дома дракона» сообщила, когда начнутся съёмки финального сезона сериала