С чего всё началось

У меня лежал файл 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 продавался другим студиям как коммерческий продукт. Покупатель движка — разработчик, прятать от него ассеты незачем.

Внутри — восемнадцать типов файлов:

Расширение

Штук

Объём

Что это

.stex

646

144 MB

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

.bmp

1157

4.8 MB

Hitmap-маски точного клика

.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

Рядом с 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 тег:

Тег

Секция

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

0x0413

Классы

[ключ][len][имя]

0x00DA

События

[ключ][ключ класса][len][имя]

0x149A

Свойства

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

0x025B

резерв, всегда пуста

—

На секции событий я сперва споткнулся: принял её за терминатор и пропускал. Парсер справлялся с 256 файлами из 308 и падал на тех, где события были непустыми.

Коды типов свойств

Измерены по всем 43 609 объектам, имена типов позже подтвердились по RTTI движка:

Код

Тип

Длина

Встречается

0x02

bool

1

2 278

0x07

целое / enum

4

4 129

0x08

целое / enum

4

8 184

0x09

float

4

40 840

0x0B

string

переменная

~7 000

0x0C

TNone (спец)

—

2

0x0D

CUBE_GUID

20 либо 4+20·N

18 829

0x0E

vec2

8

18 808

0x10

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). Самые частые классы по всей игре:

Класс

Штук

Роль

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  {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 хранит только раскладку и картинки.

Класс

Что за головоломка

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_0.stex — фоны, двери, предметы одной локации в одной текстуре
Атлас 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 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).

Атлас 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, все — 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 с метриками — по одному на пару «шрифт + кегль».

Язык

.desc

font_*.stex

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). Она ходит по дереву объектов как по файловой системе:

Команда

Действие

cd <имя>

перейти к дочернему объекту

cdm

перейти к объекту под курсором мыши

ls / ls n

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

rm / rmi

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

listp / getallp / getp

свойства объекта

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

изменить свойство на лету

map <cheat>

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

savehierarchy

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

setgamespeed

множитель скорости игры

gach / clrach

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

all_items

выдать все предметы

genscrns

автоскриншоты по скрипту

crashgame

«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:

Оглавление 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 — вот три идеи, которые стоят внимания:

  1. Словарь типов внутри каждого файла. Не глобальная схема, а локальная таблица «класс → свойства → коды типов» в конце сцены. Даёт версионирование почти даром: движок ловит incompatible types и грузит остальное вместо того, чтобы падать на всёй сцене.

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

  3. Динамические свойства. Возможность добавить объекту произвольное поле из редактора, без правки кода. В 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 — сравнение с ними скорее всего всё раскроет.

  • Таблица EInputKeyCode — имена клавиш живут в DDL-функции редактора, по строкам не восстанавливаются.

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

  • Контрольная сумма .cubebin на смещении 0x0C — алгоритм не восстанавливался, для чтения он не нужен.

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


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

Спасибо за прочтение.

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