С чего всё началось
У меня лежал файл Game.cub на 437 мегабайт от казуального квеста 2011 года. Внутри — 3559 файлов с расширениями, которых не знает ни один редактор: .cubebin, .stex, .sparkeffect, .binldinfo. Я решил разобраться, что это такое.
Игра — это Enigmatis: The Ghosts of Maple Creek от польской студии Artifex Mundi, которую я честно купил в Steam года два тому назад. Вообще, познакомился с ней я лет 8 тому назад. Но вернёмся к теме. Движок называется Spark Casual Engine, и он оказался куда интереснее самой игры.
Главный вывод, к которому я пришёл: в исполняемом файле нет игры. Там только C++-рантайм. Все уровни, вся логика, все анимации, диалоги, достижения и квесты лежат данными — в 306 файлах .cubebin, где 43 609 объектов. Дизайнеры собирали игру мышкой в визуальном редакторе, а программисты к этому почти не прикасались.
По ходу разбора нашлось много такого, что в релиз попасть не должно было: отладочная консоль с навигацией по дереву объектов как по файловой системе, 72 клавиатурных чита, скрытое QA-меню с перемоткой сюжета, пути исходников с CI-сервера и опечатки в коде движка. А под конец выяснилось, что SDK этого движка целиком сохранился на archive.org — вместе с редактором и XML-исходниками тех же сцен. Это позволило проверить весь реверс против оригинала.
Дальше — по порядку: как вскрывался каждый формат, что внутри, и почему поляки в 2011 году писали свой движок на C++ вместо того, чтобы взять готовый.
Сразу сделаю небольшую ремарку: при реверс-инжиниринге использовался ИИ (Claude Opus 5.5).
Шаг первый: вскрываем контейнер
Защита .cub снимается за одну строчку: весь файл проксорен одним байтом, а ключ выводится из первого же байта, потому что файл начинается с 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-terminated] "1.0" u32 количество файлов char[0x100] описание "Paczka danych CUBE" затем N записей: char[0x100] имя файла u32 offset u32 size
«Paczka danych CUBE» — по-польски «пакет данных CUBE». Первый привет от разработчиков.
Это не защита, а маркировка «не лезь» — и это рационально. Spark продавался другим студиям как коммерческий продукт. Покупатель движка — разработчик, прятать от него ассеты незачем.
Внутри — восемнадцать типов файлов:
Расширение |
Штук |
Объём |
Что это |
|---|---|---|---|
|
646 |
144 MB |
Текстурные атласы — LZ4 поверх DXT5 |
|
1157 |
4.8 MB |
Hitmap-маски точного клика |
|
628 |
139 MB |
Сэмплы звука |
|
592 |
2.3 MB |
Звуковые скрипты (XML) |
|
306 |
3.4 MB |
Сцены, уровни, вся логика |
|
92 |
368 KB |
Системы частиц |
|
36 |
47 MB |
Музыка и озвучка |
|
48 |
3 MB |
Курсоры, обои вне атласов |
|
28 |
116 KB |
Покадровые анимации (XML) |
|
12 |
55 MB |
Видеоролики |
|
4 |
27 MB |
Шрифты, в т.ч. CJK |
|
2 |
85 KB |
Оглавление всей игры |
|
2 |
137 KB |
Описания стилей текста |
|
2 |
1.1 MB |
Карта атласов + конфиг |
|
1 |
382 KB |
Бинарная карта атласов |
|
1 |
114 B |
Кривые интерполяции |
|
1 |
514 B |
Бинарная копия |
Рядом с Game.cub лежат двенадцать языковых пакетов от Game_de.cub до Game_zh.cub. Японский вдвое больше остальных — 10.9 MB против 4.4 MB. Почему — в разделе про локализацию.
Сердце игры: формат .cubebin
306 файлов .cubebin — это и есть игра. Разбирать их пришлось с нуля, и это самая интересная часть истории.
Слой первый: LZ4
Файл начинается с 55 5E AA C0. Сразу за заголовком идёт байт 0xF0, и это сразу выдаёт LZ4 block format: старший ниббл токена — длина литералов, 15 означает «читай дальше». Позже в бинарнике игры нашлась строка LZ4HC Compression fail — использовался high-compression режим.
offset тип значение 0x00 u32 магия 0xC0AA5E55 0x04 u32 версия 0x00040000 0x08 u32 полный размер распакованных данных 0x0C u32 размер первого сжатого блока 0x10 u32 размер блока распаковки (всегда 262144) 0x14 ... первый сжатый блок далее для каждого следующего блока: u32 compressed_size u32 uncompressed_size данные
Файлы больше 256 KB нарезаны на блоки. Все 306 файлов распаковались без ошибок с первой попытки.
Слой второй: дерево объектов
Внутри — сериализованный граф объектов:
0x00 u32 0x12345678 ← маркер порядка байт 0x04 u32 смещение footer'а 0x08 u32 полный размер 0x0C u32 контрольная сумма 0x10 u32 N — количество GUID 0x14 N × 16 таблица GUID всех объектов u32 количество корневых объектов ... дерево footer ... словари типов
Запись одного объекта рекурсивна:
[u8 tag][размер] tag & 0x80 → размер 2 байта, иначе 3 размер считается от байта tag до конца объекта [ключ класса] 1 байт, либо 2 если старший бит взведён [u8 len][имя] имя объекта 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 тег:
Тег |
Секция |
Формат записи |
|---|---|---|
|
Классы |
|
|
События |
|
|
Свойства |
|
|
резерв, всегда пуста |
— |
На секции событий я сперва споткнулся: принял её за терминатор и пропускал. Парсер справлялся с 256 файлами из 308 и падал на тех, где события были непустыми.
Коды типов свойств
Измерены по всем 43 609 объектам, имена типов позже подтвердились по RTTI движка:
Код |
Тип |
Длина |
Встречается |
|---|---|---|---|
|
bool |
1 |
2 278 |
|
целое / enum |
4 |
4 129 |
|
целое / enum |
4 |
8 184 |
|
float |
4 |
40 840 |
|
string |
переменная |
~7 000 |
|
TNone (спец) |
— |
2 |
|
CUBE_GUID |
20 либо 4+20·N |
18 829 |
|
vec2 |
8 |
18 808 |
|
color |
4 (RGBA) |
3 015 |
Ссылки на объекты хранятся по GUID. Свойство Target встречается 1637 раз — самое частое в игре. Вся логика построена на «сделай что-то вон с тем объектом».
Как устроена игра
Когда парсер заработал на всех 308 файлах, картина сложилась целиком. Вот реальная локация из игры, распечатанная моим инструментом:
[CHierarchyRoot] Church_Basement [CNonWideScene2D] Scene2D {Max zoom factor=1.25} [CGroup] Location [CLocation] SoundsAction {Music.Location Playlist=..., Replace Immediately=true} [CAmbientSound] Ambient Sounds {Sound=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 {Min Fade=0.5, Frequency=0.5, Randomize=0.5, Blend=ADD} [CFader] ADD_yelow {Min Fade=0.85, Frequency=3.0} [CGroup] Items [CItem] item_Resin-Component-2 {Item name=CHURCH_BASEMENT_ITEM_RESIN_COMPONENT2, Icon Image=mini_rosin.png} [CGroup] HO [CHOSwitcher] GoTo_HO_Desk {Cursor=Close Up, Target=...} [CParticleEffect2D] FX_HO_Desk {Effect File=ho_effect_5_big.cube_effect} [CGroup] GoTo [CLocationSwitcher] GoTo_SecretChamber {Cursor=Nav Forward Left, Hint Image=hint_add_cursor_forwardleft.png, Cursor Context Text=CHURCH_BASEMENT_TO_SECRET_CHAMBER} [CScenario] anim_Open {Forced scenario time=1.0} [CActivateAction] / [CHideAction] / [CShowAction] [CPlaySoundAction] {Sound=walking_indoor.snd} [CZoomSwitcher] GoTo_Zoom_SacretChamberDoor
Схема любой локации одна и та же: звук → фон → предметы → переходы. Вся интерактивность — это *Switcher с курсором, подсказкой и списком действий.
Логика без кода
Здесь нет ни строчки скриптов. Вместо них — узлы-действия и узлы-условия, подвешенные на события (OnMouseClick, OnShow, OnFirstEnter). Самые частые классы по всей игре:
Класс |
Штук |
Роль |
|---|---|---|
|
5642 |
спрайт — базовый кирпич всего |
|
3272 |
дорожка анимации |
|
2143 |
вызов именованного действия |
|
1552 |
текст |
|
1444 / 1312 |
спрятать / показать |
|
1394 |
ключ «выстрелить действием» |
|
1236 |
запуск сценария |
|
1109 |
звук |
|
904 |
предмет в HO-сцене |
|
827 |
реплика героини |
|
697 |
условие |
Анимация
Полностью универсальная — можно анимировать любое свойство любого объекта:
[CScenario] anim-Tornado-Loop ← таймлайн [CTrack] CTrack_1 {Object=ADD_Tornado_02, Property=Color} [CKeyColor] ... {Time Offset, Blend Mode, значение} [CTriggerWaverAction] ... {Waver=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 Game name = Enigmatis_TheGhostOfMapleCreek Native Resolution = 2, Widescreen Project = true StartUp Hierarchy = 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 с флагом «только для демо».
Механики: HO, мини-игры и доска улик
Hidden-object сцены устроены умнее, чем выглядят
Наивно ожидаешь список предметов и картинку. А там разделение сцены и прохождения:
[CHierarchyRoot] HO_Shed [CPanel] HO_shed [CLocation] SoundsAction ← 4 слоя эмбиента [CPanel] BKG [CPanel] HO_shed_HO [CHOItem] HO_shed_lugwrench {Hitmap file=ho_shed_lugwrench_hitmap.bmp, Texture=ho_shed_lugwrench.png, HO Item name=HO_ITEM_LUGWRENCH, InstanceOwner=[CHOInstance2]} ... ещё 47 предметов ... [CHOInstance] CHOInstance1 {HO Required Items count=12, Required items=[HO_shed_shovel]} [CAddItemAction] OnFinished → выдать квестовый предмет [CHOInstance] CHOInstance2 {... Order Number=2, Required items=[HO_shed_cog]} [CDedFinishedCondition] ← доступна только после определённой дедукции [CHOInstance] HO_Instance3 {... Required items=[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 хранит только раскладку и картинки.
Класс |
Что за головоломка |
|---|---|
|
японский кроссворд |
|
пазл |
|
отмычка |
|
вращающиеся кольца |
|
перестановки |
|
телескоп |
|
люк |
|
сервировка стола |
|
символы |
|
книжный шкаф-замок |
|
сейф |
У каждой есть парный *_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 MB, 255 пакетов, 646 атласов, 5443 логические картинки:
<Texture filename="close_up_cursor.png" left="1" top="1" right="49" bottom="49" uni_width="48" uni_height="48" flipped="false" atlas_width="512" atlas_height="64"/>
Рядом лежит atlas_Game.sparkatlas — та же таблица в бинарном виде. Сначала я решил, что это копия «для скорости». На самом деле бинарь первичен, а XML — фолбэк; в бинарнике есть строка:
File XXatlas.sparkatlas not found! Trying to load atlas.xml
Два набора качества
Префиксы Game0_ и Game3_ сперва выглядят как LOD-уровни, но это не так:
Game0_— 578 атласов в DXT5: фоны, объекты, HO-предметы.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, все — 1-битные монохромные маски (bpp=1, отрицательная высота = развёртка сверху вниз).
Это маски точного попадания: клик засчитывается только по силуэту предмета, а не по прямоугольнику спрайта. Для hidden-object игры это критично: предметы перекрываются десятками, и прямоугольные зоны превратили бы игру в лотерею.
Слева — спрайт из атласа, справа — его маска (растянута до того же размера):

Обратите внимание на размеры: спрайт 151×168, маска 57×64. Я прогнал сравнение по 395 парам «спрайт — маска»: медианное отношение ровно 2.0, у 216 из 395 — точно вдвое. То есть маски хранятся в половинном разрешении — в бинарнике этому соответствует свойство HitMap.DefaultLevel.
Самый мелкий файл — basement_safe_button_hitmap.bmp, 30×30 пикселей и 184 байта. Все 1157 масок вместе занимают 4.8 MB — меньше, чем один ролик.
Потом в движке нашёлся enum формы активной зоны, где всё это прямо названо:
Point · Line · Circle · Rectangle · Elipse · FromHitmap · Outside · Inside · Custom · CircleTangent
«Elipse» — опечатка в коде движка, дожившая до релиза. Таких там ещё пара: QUATERION в списке типов и Expert (Lagacy mode) в уровнях сложности.
Звук как декларативный язык
592 файла .snd оказались не звуками, а скриптами мини-аудиодвижка. Вот фрагмент эмбиента подземелья — с комментариями звуковика, как он его написал:
<!-- Base ambient soundscript for underground church & cave locations: - Church.Basement - Church.SecretChamber - Church.Tomb - Cave.Entrance - Cave.RitualHall --> <Sound Pan="0" Pitch="1" Volume="0.75" Looped="true" Ambient="true"> <CRandomContainer DelayMin="4" DelayMax="6" WaitAfterMin="3" WaitAfterMax="5"> <CRandomContainer Weight="5" Volume="0.6" FadeInTime="0.25" FadeOutTime="0.25"> <!-- Artificial Squeak [RARE] --> <CSample Weight="1"> <CSampleFile Pitch="0.5" FileName="05_Artificial_Squeak_2.wav"/> </CSample> ... </CRandomContainer> <CRandomContainer Weight="25"> <!-- All the water drops [MODERATELY OFTEN] --> <CSequenceContainer Weight="3" Volume="0.7"> <!-- Double, moderately loud --> <CSample><CSampleFile Pitch="1.0" FileName="27_Water_drop.wav"/></CSample> <CSample><CSampleFile Pitch="1.0" FileName="27_Water_drop.wav"/></CSample> </CSequenceContainer> ...
Узлы: CRandomContainer (выбор по весам), CSequenceContainer (подряд), CSample / CSampleFile. Параметры: Weight, Pan, Pitch, Volume, DelayMin/Max, WaitAfterMin/Max, FadeInTime/OutTime.
Из горстки WAV-сэмплов получается бесконечный неповторяющийся эмбиент: капли воды одиночные, двойные и тройные с разной громкостью и питчем, редкие скрипы, летучие мыши.
А в локации одновременно играет несколько CAmbientSound — в HO_Shed их четыре: base_town, add_birds, add_bell_distant, add_cicada. Звуковой ландшафт собирается послойно.
Для 2011 года в казуальной игре это неприлично хорошо. То, что сейчас делают в FMOD или Wwise, здесь написано своё и описано XML-ом.
Локализация: две неожиданности
Тексты живут в Excel
text.xml на 627 килобайт оказался файлом Excel 2003 XML Spreadsheet:
<?mso-application progid="Excel.Sheet"?> <Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"> <DocumentProperties><Author>Me</Author></DocumentProperties> <Worksheet><Table> <Row><Cell><Data ss:Type="String">BALCONY_FARM_COMM</Data></Cell> <Cell><Data ss:Type="String">The silhouette of a granary</Data></Cell></Row>
Движок парсит его напрямую — в бинарнике лежит литерал <Data ss:Type="String">. То есть переводчики работали в экселе, и файл шёл в игру как есть. Вполне в духе продакшена тех лет.
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 |
Первая мысль была — экономят глифы в атласе шрифта. Но замер это опроверг: кириллические варианты тоже есть, и раз они отображаются, оба глифа всё равно попали в атлас. Экономии нет.
Значит, это артефакт пайплайна локализации: перевод вёлся в Экселе, текст копировался между документами и раскладками, и гомоглифы «прилипли». Игроку незаметно, но поиск по тексту сломан.
Шрифты запекаются заранее
В каждом языковом пакете лежат font_XX0.stex (атлас глифов 1024×1024 в ARGB, сами глифы — в альфа-канале) и десятки .desc с метриками — по одному на пару «шрифт + кегль».
Язык |
|
|
|---|---|---|
en |
85 |
1 |
pl |
81 |
1 |
ja |
72 |
4 |
ru |
52 |
1 |
ko / zh |
33 |
1 |
Вот и ответ, почему японский пакет вдвое больше. У CJK-языков файлов меньше — иероглифов слишком много, часть рисуется динамически из uming.ttc на 20 MB.
Билд-система собирает атлас только из реально встречающихся символов: в бинарнике есть z_charset, virtualfont, fontatlas, genSize и отчёт Charset%s:.
Что рассказал исполняемый файл
До этого момента я работал только с данными. Потом взял Steam-версию для macOS — и оказалось, что настоящая документация лежит внутри бинарника.
Build: #12033_196353 version: 1.0.12033 Бандл: 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…
Это не остатки — это каталог. Движок обслуживал всю линейку HOPA-игр студии, и каждая игра брала из него нужное подмножество.
Ещё не задействована целая 3D-подсистема: CBase3DObject, CCamera, CLight, CMaterial, CModel, CEmitter3D. Движок умеет 3D, игра — чистое 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). Она ходит по дереву объектов как по файловой системе:
Команда |
Действие |
|---|---|
|
перейти к дочернему объекту |
|
перейти к объекту под курсором мыши |
|
список объектов на n уровней вглубь |
|
удалить объект со всем поддеревом |
|
свойства объекта |
|
изменить свойство на лету |
|
перейти на уровень по чит-коду |
|
выгрузить текущую иерархию в файл |
|
множитель скорости игры |
|
выдать / сбросить все достижения |
|
выдать все предметы |
|
автоскриншоты по скрипту |
|
«DO NOT USE !» |
Вот и объяснение чит-кодов из файла проекта — команда map описана как «switch to level with cheat equal to given string».
Но открыть эту консоль в розничной сборке нельзя. Я проверил все пути: stdin не читается (нет ни 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» |
в диалогах — разблокировать CE-контент и 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 из главного меню и закрытие paywall-диалога.
SDK с того света
Уже когда разбор был почти закончен, выяснилось, что Spark продавался другим студиям, а его публичные SDK сохранились на Internet Archive:
archive.org/details/spark-sdk — два билда по 206 MB
Spark_SDK_20130308_1543.zip— 769 файлов
Оглавление ZIP можно прочитать вообще не скачивая архив — archive.org поддерживает range-запросы, а central directory лежит в хвосте.
Библиотеки не stripped
Core.dylib на 31 MB содержит 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 MB и Designermac.app на Qt 4 — тот самый WYSIWYG-редактор, в котором собиралась игра. Плюс GameApp_d.exe / GameApp_rd.exe — рантайм в Debug и RD сборках, где все те 70 читов живые.
Заодно расшифровалось таинственное «RD mode» из строк движка — это RelWithDebInfo, видно по RelWithDebInfo-Config.cube. И там же нашлась опция, которой нет в розничном конфиге:
<OPTION NAME="Helpers"> <OPTION NAME="Enabled">0</OPTION> </OPTION>
Исходники сцен в XML
39 файлов .cubemap — то, во что компилируется .cubebin. И структура оказалась ровно той, что я восстановил вслепую:
<HIERARCHY> <OBJECT CLASSNAME="CScene2D" GUID="-1570937766:46409:20062:186:0:207" NAME="CScene2D"> <OBJECT CLASSNAME="CPopUpContent" GUID="..." NAME="MiniGame_Chapel_Popup"> <SCHEMA> <PROPERTY NAME="Transformation.Position">384:288</PROPERTY> <PROPERTY NAME="Width">768</PROPERTY> <PROPERTY NAME="Texture Name">mg_guide_base.png</PROPERTY> <PROPERTY CATEGORY="" DESCRIPTION="dynamic field" DYNAMIC="1" NAME="Dynamic.Force One Scene" TYPE="BOOL">1</PROPERTY> </SCHEMA> <OBJECT ...> ← дети
Я вытащил точечно 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*: paywall, IAP-диалоги, «оцените приложение», рассылка, логины в игровые сервисы, плюс кросс-промо карусель CRotor2.
То есть публичный SDK застал движок ровно до перехода на free-to-play. Монетизацию прикрутили позже и наружу уже не отдавали — продажу SDK свернули примерно тогда же.
Выводы: оверинжиниринг или нет
Первая реакция на всё это смешанная, мол, ради казуальных квестов писать целый движок на плюсах? Но цифры говорят о другом.
Это конвейер:
99 классов мини-игр, из которых Enigmatis использует 18
9 платформ в enum: Windows, macOS, iOS, Android, Metro, Phone8, QNX, Tizen, Linux
билд-система, собирающая из одних данных демо, розницу и Collector's Edition
генератор скриншотов по скрипту, отчёты по использованным ресурсам, генератор обоев
Artifex Mundi выпускала по несколько игр в год на десяток платформ. При таком объёме свой движок окупается.
И главное — альтернатив тогда не было. Unity получила нормальный 2D-режим только в 4.3 — конец 2013-го, когда Enigmatis уже два года как вышла. Godot стал публичным в 2014-м. Так что «сейчас проще взять Unity» — верно, а в 2011-м выбора попросту не существовало.
Про защиту
Её нет совсем, и это тоже рационально. XOR одним байтом, словари типов в каждом файле, пути исходников и отладочные логи в релизе. Потому что Spark был коммерческим продуктом для других студий — покупатель движка есть разработчик, и прятать от него ассеты было бы странно.
Что стоит украсть из Spark сегодня
Если вы делаете что-то похожее — например, описываете сцены JSON-ом в Godot — вот три идеи, которые стоят внимания:
Словарь типов внутри каждого файла. Не глобальная схема, а локальная таблица «класс → свойства → коды типов» в конце сцены. Даёт версионирование почти даром: движок ловит
incompatible typesи грузит остальное вместо того, чтобы падать на всёй сцене.Разделение сцены и прохождения. В сцене пул из 40–60 предметов, а «заход» — отдельный объект со своими условиями и наградой. Одни данные, разное поведение при каждом визите.
Динамические свойства. Возможность добавить объекту произвольное поле из редактора, без правки кода. В JSON это ложится идеально.
А вот процедурную анимацию (CFader, CWaver) повторять не стоит — в 2011-м это экономило дизайнерам много ручной работы, но сейчас то же самое даёт AnimationPlayer с шумом и шейдеры.
Инструменты
Все скрипты — Python 3 без внешних зависимостей (даже PNG-энкодер свой, на zlib):
Файл |
Назначение |
|---|---|
|
распаковка |
|
декодер LZ4-блоков |
|
парсер сцен: дерево, свойства, словари типов |
|
парсер текстур + экспорт в |
|
DXT5/ARGB → PNG |
|
чтение |
Проверено: 308/308 .cubebin, 646/646 .stex, 13/13 .cub разбираются без ошибок.
Что осталось неразобранным
Не все удалось разобрать нейронкой:
Бинарная раскладка
.sparkeffect— имена текстур читаются, параметры эмиттера — нет. Словаря внутри нет, в отличие от.cubebin. В SDK лежат 20 исходных.cube_effect— сравнение с ними скорее всего всё раскроет.Таблица
EInputKeyCode— имена клавиш живут в DDL-функции редактора, по строкам не восстанавливаются.Заголовок
.desc— переменной длины, шаг записи глифа подтверждён только для английского.Контрольная сумма
.cubebinна смещении0x0C— алгоритм не восстанавливался, для чтения он не нужен.Полная схема свойств всех 923 классов — достаётся дизассемблированием
InitTypeInfoкаждого; схема вызовов известна, задача автоматизируемая, но объёмная.
Если кому-то интересно копать дальше — SDK лежит на archive.org в открытом доступе, с редактором, не stripped библиотеками и шаблонами сцен (sparkcasualengine.com). Там и документация есть. Правда будет ли оно работать на современной винде — вопрос хороший. По идее, должно.
Спасибо за прочтение.