Pull to refresh

Реверс-инжиниринг визуальных новелл (часть 2)

Reading time12 min
Views21K

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


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


Перед тем, как погружаться в пучины бинарных дампов, давайте прикинем, как работают большинство движков визуальных новелл. Визуальная новелла сама по себе состоит из текста (реплик героев, диалогов, промежуточного повествования), графики и звуков. Для того, чтобы ее воспроизвести пользователю, явно нужно свести все это воедино с помощью какого-то управляющего воздействия. В теории можно было бы зашить это все прямо в exe-файл, но в 99% случаев (ладно, вру, в 100% виденных лично мной) так все-таки не делают, а хранят такие инструкции отдельно в виде отдельной программы-скрипта. Как правило, скрипт пишется на особенном языке программирования (специфичном для движка), который выглядит как-то так:



$ tarot = 0
$ memory = 0

scene bg01_1 with dissolve

play music "bgm/8.mp3" fadein (2.0)
play ambience "amb/forest.mp3" fadein (3.0)
"Morning."
"Not my favourite time of the day."
"The morning is when you're not awake enough to do anything..."

Это фрагмент исходника скрипта из одной VN на Ren'Py — одном из наиболее популярных свободных/бесплатных движков. Оставляя за рамками этой статьи вопрос, насколько хорош Ren'Py сам по себе, просто пока отметим, что же обычно входит в скрипт визуальной новеллы и что нам нужно будет найти:


  • текст — он еще бывает либо не приписан никакому персонажу (т.е. текст "от рассказчика" — как в нашем примере), либо таки произносится кем-то
  • команды, чтобы показать графику — бэкграунд / спрайт (scene bg01_1), иногда с каким-то спецэффектом (with dissolve)
  • команды, чтобы запустить играться музыку или звуки (play music, play ambience), иногда тоже с какими-то дополнительными параметрами, чаще всего длинами fade-in и fade-out (плавного нарастания громкости)
  • работа с переменными: установка ($ tarot = 0, проверка, ветвление)
  • еще бывают команды:


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


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


Итак, возвращаемся к нашей визуальной новелле, которую мы начали исследовать в прошлой статье — Koisuru Shimai no Rokujuso. Мы уже распаковали ее архивы и нашли внутри и графику, и звуки, и музыку, и, самое главное и непонятное пока — кучку файлов с расширением .yks. Предположительно, они и составляют скрипт новеллы. Файлов, кстати, много:


YKS/ScriptStart.yks
YKS/trial/Yoyaku.yks
YKS/trial/trial_00100.yks
YKS/trial/trial_00200.yks
YKS/all/all_00010.yks
...
YKS/all/all_02320.yks

Всего 103 файла в YKS/all/. Напомню, мы абсолютно честно скачали и исследуем триальную версию — но, судя по всему, разработчики несколько поленились и, видимо, в trial/ лежит скрипт для триальной версии, а в all/ — для полной.


Вообще, исходя из минимального опыта, у строителей движков визуальных новелл есть 2 подхода: либо все пакуется в один гигантский файл, либо файлов много и в каждом из них какая-то своя сцена или событие. Тут похоже, что второе. Кроме того, есть еще отдельный ScriptStart.yks — но он как таковой скорее всего будет нам практически неинтересен: дело в том, что зачастую разработчики хотят сделать движок как можно более универсальным и реализуют всякие пользовательские интерфейсы, менюшки-загрузки-сохранения-опции и т.д. тоже средствами своего скриптового языка. Разбираться с этим можно, но довольно скучно и неплодотворно: поэтому предлагаю брать быка за рога и начинать с собственно сценария игры.


Что мы можем сказать из поверхностного визуального осмотра? Во-первых, т.к. игра работает под Windows, то ее вполне реально запустить и посмотреть, как это выглядит. Тратим n-ное количество времени, находим машину с Windows, запускаем, смотрим, что происходит сразу после нажатия кнопки начала новой игры:


Сразу после старта игры


Нас встречает вроде бы начало повествования. Здесь есть фон (после недолгих поисков в BG/ находится файл bg01_01.png с этим фоном), и есть текст. Этот текст нам еще понадобится, поэтому стоит его перенабрать с экрана:


恋する姉妹の六重奏「セクステット」体験版Ver2をダウンロード頂きありがとうございます。

Два замечания:


  1. Если есть проблемы с набиранием японского текста, могу порекомендовать освоить три-четыре приема, которые сильно упрощают это дело и при определенном терпении дают возможность набирать японские тексты тем, кто абсолютно не представляет, что же это за закорючки. Каждый значок рассматриваем по отдельности:


    • проверяем, не знак ли это препинания по такой таблице: 「…」、()。 — если повезло, то копируем; обратите внимание на то, что и "запятые", и "точки", и скобочки тут специфичные.
    • если нет, ищем в вот такой таблице: あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわを
    • потом ищем еще по вот такой: アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワ
    • если не помогло — например, попался 恋 — то это kanji; тогда увеличиваем шрифт на 300-500%, чтобы было хорошо видно все мелкие детали и идем на jisho.org в раздел "поиск по радикалам"; там смотрим на таблицу составных частей (радикалов) и ищем похожие на то, что видим; на примере 恋 — после недолгой медитации находим, что снизу у него составная часть 心 — зажимаем кнопку с этой составной частью и от многих тысяч у нас остается всего пара десятков значков; просматриваем их глазами и находим в выдаче в разделе "10" пятый с начала знак — это и будет искомый 恋.

  2. Я не уверен, будет ли там Ver2 или Ver2 — обратите внимание, это не разные шрифты, а внезапно так называемые full-width characters — в юникоде они где-то в районе U+FF01..U+FF5E).

Текст нужен нам будет для двух вещей. Во-первых, собственно как текст — понять, что происходит (даже если не владеете японским — можно вставить в гугл-переводчик и понять, что нас здесь благодарят за скачивание триальной-версии этой игры => т.е. это еще не реальное начало сюжета, а некое вступление, "от автора"). Во-вторых, этот текст или его кусочек мы можем взять, сконвертировать в ShiftJIS (а скорее всего, как мы выяснили в предыдущей заметке, все будет именно в этой кодировке) и поискать его в файлах. Возьмем кусочек с конца и подготовим то, что будем искать:


$ echo 'ダウンロード頂きありがとうございます' | iconv -t sjis | hd
00000000  83 5f 83 45 83 93 83 8d  81 5b 83 68 92 b8 82 ab  |._.E.....[.h....|
00000010  82 a0 82 e8 82 aa 82 c6  82 a4 82 b2 82 b4 82 a2  |................|
00000020  82 dc 82 b7                                       |....|

Ищем эту строчку в во всех наших файлах .yks и, разумеется, не находим. Не так все просто.


Сделаем еще одно лирическое отступление: давайте ознакомимся с тем, как работает кодировка ShiftJIS. В японском языке, очевидно, значков сильно больше, чем в европейских: в ShiftJIS каждый из значков кодируется минимум 1 байтом, максимум 2. Как видно из этой таблички, значения байтов 00..7F совпадают с ASCII, а вот байты 81..9F и E0..EA означают, что это двухбайтовая комбинация, причем для совместимости опять же с бинарным чтением, второй байт будет иметь не какое угодно значение, а что-то между 40 и FF.


Микроэкскурс в японский язык: в языке используются 3 группы значков:


  • hiragana — выглядит как-то так: ありがとうございます — т.е. округлые простые письменные формы; ~50 значков, но есть всякие вариации типа "большая i = い, маленькая i = ぃ"; 1 слог = 1 знак.
  • katakana — выглядит как-то так: ダウンロード — т.е. рублено-квадратные, простые печатные формы; звуки все примерно те же, что и изображаются hiragana, но используется для записи преимущественно заимствованных слов (ダウンロード = da-u-n-lo:-do = download).
  • kanji — выглядят как-то так: 体験版 — т.е. как правило, сложные квадратные конструкции из кучи разных частей и загогулек; с ними сложнее всего, вот как раз их много тысяч.

Плюс есть еще знаки препинания, плюс-минус такие же, как в европейских языках: точка , запятая , многоточие , кавычки 「」, восклицательный и вопросительный знаки и т.п. А вот пробелов обычно нет. Фокус в том, что в тексте постоянно чередуются "важные" слова, которые записываются kanji и частицы, которые записываются hiragana, в результате чего получается эту смесь хоть как-то можно разобрать. Например, возьмем название игры 恋する姉妹の六重奏:


  • 恋 — kanji
  • する — hiragana
  • 姉妹 — kanji
  • の — hiragana
  • 六重奏 — kanji

Что это дает нам в сухом остатке? Очень просто: частотную таблицу. Берем готовый скрипт первой попавшейся под руку визуальной новеллы на японском, быстренько подсматриваем в юникоде границы диапазонов всех трех групп и прогоняем на нем такой скрипт (простите за каламбур):


stats = {}
$stdin.each_char { |c|
  t = case c.ord
      when 0x3041..0x309F then :hiragana
      when 0x30A0..0x30FF then :katakana
      when 0x4E00..0x9FCC then :kanji
      end
  stats[t] ||= 0
  stats[t] += 1
}
p stats

и получаем на выходе что-то вроде:


{nil=>72384, :kanji=>5731, :hiragana=>15377, :katakana=>2241}

т.е. в типичном тексте будет ~25% kanji, 65% hiragana и 10% katakana.


Кажется, время расчехлять инструменты и погружаться с головой в работу. Совсем кратко напомню, что мы используем для анализа бинарных файлов с непонятной структурой новый open source инструмент Kaitai Struct — он позволяет описывать на языке разметки шаблоны, которые потом можно применять к файлам и быстро визуализировать их содержимое, разложенное по полочкам в виде дерева, а в качестве мега-бонуса потом — скомпилировать шаблон прямо в исходник на фактически любом популярном языке программирования (с момента написания предыдущей статьи Kaitai Struct стал поддерживать не только Java, JavaScript, Python и Ruby, но и C++, C#, Perl и PHP). То есть если смотреть по всяким спискам top-языков — топ 10 охвачен полностью, из топа 20, если не брать domain-specific вещи, не хватает Delphi, Visual Basic (хотя я слабо себе представляю, чтобы кто-то занимался реверс-инжинирингом на древнем Visual Basic не .NET), Swift и Go.


Базовый синтаксис шаблонов Kaitai Struct мы изучили в первой части статьи, поэтому, кто пропустил / подзабыл о чем речь — самое время с ним ознакомиться / освежить в памяти.


Итак, быстро смотрим на дампы 3-4 файлов и понимаем, что в качестве отправной точки нам подойдет такой шаблон:


meta:
  id: yks
  application: Yuka Engine
  endian: le
seq:
  - id: magic
    contents: ["YKS001", 1, 0]
  - id: magic2
    contents: [0x30, 0, 0, 0, 0, 0, 0, 0, 0x30, 0, 0, 0]
  - id: unknown1
    type: u4
  - id: unknown2
    type: u4
  - id: unknown3
    type: u4
  - id: unknown4
    type: u4
  - id: unknown5
    type: u4
  - id: unknown6
    type: u4
  - id: unknown7
    type: u4

Можно сразу провести аналогии с форматом YKC. Т.к. там в начале шло описание "заголовка", начиная с его длины, то с большой вероятностью фиксированные 0x30, встречающиеся в magic2 везде — это длина первоначального заголовка, поэтому предлагаю зачитывать сразу все до 0x30. Получается 7 чисел, сейчас будет пытаться угадывать, что это такое.


Для Yoyaku.yks (сам файл 27741 байт):


  [.] @unknown1 = 1845
  [.] @unknown2 = 7428
  [.] @unknown3 = 795
  [.] @unknown4 = 20148
  [.] @unknown5 = 7593
  [.] @unknown6 = 25
  [.] @unknown7 = 0

Для trial_00100.yks (файл 91267 байт):


  [.] @unknown1 = 6433
  [.] @unknown2 = 25780
  [.] @unknown3 = 2376
  [.] @unknown4 = 63796
  [.] @unknown5 = 27471
  [.] @unknown6 = 5
  [.] @unknown7 = 0

И, для сравнения, какой-нибудь файл из all, например all_00010.yks (12968 байт):


  [.] @unknown1 = 933
  [.] @unknown2 = 3780
  [.] @unknown3 = 353
  [.] @unknown4 = 9428
  [.] @unknown5 = 3540
  [.] @unknown6 = 1
  [.] @unknown7 = 0

Что видно? Во-первых, это все эпично похоже на смещения или размеры в файле, т.к. при размере файла в 91K числа плавают в районе 25-63K, а при размере в 12K — в районе 3-9K. При ближайшем рассмотрении, смещения и размеры скорее всего только unknown2, unknown4, unknown5 — они делятся на 4 и достаточно большие. Во-вторых, unknown7, кажется, всегда 0. В-третьих, unknown6, видимо, задает что-то очень штучно-считаемое. Это может быть, например, размер области зарезервированной памяти виртуальной машины под переменные, число сменяющихся сцен/спрайтов/бэкграундов или еще что-нибудь такое.


Сразу за 0x30 даже невооруженным глазом в людом хекс-редакторе видна таблица возрастающих (или почти всегда возрастающих чисел). Вряд ли это сам байт-код: для байт-кода характерно как раз постоянное повторение одних и тех же последовательностей. Это тоже скорее всего какие-нибудь смещения — например, это могут быть смещения, определяющие начала команд в байт-коде, или какие-нибудь начала-концы строк переменной длины или что-нибудь еще такое. У нас есть 7 unknown-значений, это не так много — давайте переберем и посмотрим, похоже ли одно из них:


  • либо на длину этого участка
  • либо на абсолютное смещение конца этого участка = начала нового
  • либо на количество 4-байтовых целых чисел в участке

Практически первая же попытка подходит очень неплохо: unknown1 оказывается количеством элементов в этом разделе, а unknown2 оказывается указателем на начало следующего раздела. И, таким образом, похоже, что на практике unknown2 = 0x30 + unknown1 * 4. Добавляем сразу описание, заодно перенеся заголов в явно выделенный тип header, а открываемые секции начинаем называть sect1..sectX:


seq:
  - id: header
    type: header
  - id: sect1
    size: header.sect2_ofs - 0x30
    type: sect1
types:
  header:
    seq:
      - id: magic
        contents: ["YKS001", 1, 0]
      - id: magic2
        contents: [0x30, 0, 0, 0, 0, 0, 0, 0, 0x30, 0, 0, 0]
      - id: sect1_qty
        type: u4
      - id: sect2_ofs
        type: u4
      - id: unknown3
        type: u4
      - id: unknown4
        type: u4
      - id: unknown5
        type: u4
      - id: unknown6
        type: u4
      - id: unknown7
        type: u4
  sect1:
    seq:
      - id: entries
        type: u4
        repeat: expr
        repeat-expr: _root.header.sect1_qty

В итоге trial_00100 начинает выглядеть вот так:


  [-] @header
    [.] @magic = 59 4b 53 30 30 31 01 00
    [.] @magic2 = 30 00 00 00 00 00 00 00 30 00 00 00
    [.] @sect1_qty = 6433
    [.] @sect2_ofs = 25780
    [.] @unknown3 = 2376
    [.] @unknown4 = 63796
    [.] @unknown5 = 27471
    [.] @unknown6 = 5
    [.] @unknown7 = 0
  [-] @sect1
    [-] @entries (6433 = 0x1921 entries)
      [.]    0 = 6
      [.]    1 = 7
      [.]    2 = 3
      [.]    3 = 3
      [.]    4 = 4
      ...
      [.] 6425 = 2371
      [.] 6426 = 2372
      [.] 6427 = 34
      [.] 6428 = 1
      [.] 6429 = 2373
      [.] 6430 = 2374
      [.] 6431 = 1
      [.] 6432 = 2375

На самом деле теперь уже заметно, что это не просто возрастающие значения — это вполне может быть байткодом. В этом файле заметные возрастающие числа идут, видимо, от 0 или 1 и в итоге увеличиваются до 2375. Внезапно, unknown3 = 2376 — очень похоже на число этих самых значений. Т.е. байткод ссылается на еще одну какую-то таблицу, в которой 2376 различных значений (видимо, от 0 до 2375 включительно). Что же это может быть?


Смотрим на следующую секцию, просмотрев что там бывает на экрана 3-4 вперед:




По-моему, более-менее очевидно, что это записи по 16 байт (1 строчку) длиной, причем в них есть опять же что-то поразительно похожее на явно постоянно неравномерно увеличивающиеся смещения или индексы. Будет ли таких записей 2376? Проверяем, переименовая unknown3 в sect2_qty и добавляя тривиальный кусочек, чтобы собрать sect2 из 16-байтовых записей:


  - id: sect2
    size: 16
    repeat: expr
    repeat-expr: header.sect2_qty

и, кажется, бинго, это оно и очень точно:




Невооруженным глазом хорошо видно, что эти самые стройные 16-байтовые записи действительно кончаются ровно после sect2_qty штук и дальше начинается уже что-то совсем другое. Что мы тут видим? Это явно не длинные 4-байтовые числа, примерно все ненулевое. Какой-то явно периодической структуры тоже не прослеживается, по крайней мере на первый взгляд. Обилие 0xaa. Много 0x28, чередующихся через раз. Смотрим в конец файла, пытаясь обнаружить еще какие-то секции — кажется, нет, в конце примерно такая же фактура:




То есть это третья и последняя секция файла, больше в нем ничего не будет. А что мы еще не видели? Текст и строчки. Видимо, это они и есть, но явно как-то покодированные. Сжатые? Нет, не похоже. Такого количества повторяющихся 0x28 и 0xaa не было бы. Да и повторяющиеся 0x28 во всяких там 28 08 28 1b 28 0e 28 6c 26 6f 28 07 3a 14 28 6b выглядят страшно подозрительно. Для сравнения, вспомним, как выглядит в ShiftJIS среднестатистический японский текст: 82 a0 82 e8 82 aa 82 c6 82 a4 82 b2 82 b4 82 a2. Сразу же напрашивается гипотеза, что это простейший подстановочный шифр, где каждый байт преобразуется всегда в один и тот же другой байт. Что это может быть, как получить из 0x82 => 0x28? Человечество придумало на самом деле не так много вариантов:


  • сложение/вычитание — прибавить (или вычесть, что одно и то же) к каждому байту одно и то же число, переполнение просто не учитывать
  • rol/ror — циклический сдвиг на какое-то число бит, в самом тупом варианте делается сдвиг ровно на 4 бита вправо или влево меняет 2 шестнадцатеричные цифры местами
  • исключающее "или" (xor) — с каждым байтом проделать операцию xor с каким-то другим, фиксированным байтом; один из самых тупых, банальных, как-то действующих и посему популярных способов

Вообще есть даже "тяжелая" артиллерия в виде программ типа XORSearch, которые пытаются угадывать такие преобразования перебором, но тут все еще банальнее и у меня получается угадать со второго раза. Обилие 0xaa позволяет предположить, что там много нулей, которые XOR'ятся с 0xaa, что дает 0xaa. А внезапно 0x82 ^ 0xaa как раз равно 0x28. 0xaa — вообще одно из самых банальных предположений, которые стоит проверять по хорошему в первую очередь, т.к. 0xaa = 0b10101010, т.е. xor с ним тупо переворачивает каждый второй бит.


К счастью, в Kaitai Struct есть встроенная поддержка таких преобразований, активизируется через process:. Достаточно написать вот так:


  - id: sect3
    size-eos: true
    process: xor(0xaa)

после чего мы, наконец, сможем наблюдать богатый внутренний мир строковых констант наших подопечных скриптов:


000000: 69 66 00 c8 00 00 00 47 6c 6f 62 61 6c 46 6c 61 | if.....GlobalFla
000010: 67 00 3d 00 ff ff 00 00 01 00 00 00 3d 00 7b 00 | g.=.........=.{.
000020: 0d 00 00 00 57 69 6e 64 6f 77 4e 61 6d 65 53 65 | ....WindowNameSe
000030: 74 00 97 f6 82 b7 82 e9 8e 6f 96 85 82 cc 98 5a | t........o.....Z
000040: 8f 64 91 74 28 83 66 83 6f 83 62 83 4f 29 81 7c | .d.t(.f.o.b.O).|
000050: 46 69 6c 65 20 3a 20 74 72 69 61 6c 68 5f 6d 61 | File : trialh_ma
000060: 79 75 2e 79 6b 73 00 7d 00 09 00 00 00 44 72 61 | yu.yks.}.....Dra
000070: 77 53 74 6f 70 00 47 72 61 70 68 69 63 48 69 64 | wStop.GraphicHid
000080: 65 00 0a 00 00 00 54 72 61 6e 73 69 74 69 6f 6e | e.....Transition
000090: 00 02 00 00 00 64 00 00 00 0a 00 00 00 0b 00 00 | .....d..........
0000a0: 00 47 72 61 70 68 69 63 4c 6f 61 64 00 00 00 00 | .GraphicLoad....

К счастью, там кроме всего прочего есть туча ASCII-строчек, что сильно упрощает жизнь. На первый взгляд кажется, что это просто C-style строчки, терминированные нулями, но при более внимательном рассмотрении оказывается, что это не совсем так. Тут есть и строчки, и всякая непонятные вкрапления констант, например: ff ff 00 00 01 00 00 00, или 02 00 00 00 64 00 00 00 0a 00 00 00 0b 00 00 00, которые, несмотря на наличие одного печатного ASCII-символа в центре (d = 0x64) скорее всего строчками не являются. Кроме того, что самое ценное — вот они — эти самые строчки на японском в ShiftJIS с 82.


Подытожим, что у нас получилось:


  1. sect1, состоящий из 4-байтовых целых чисел (предположительно это и есть байткод), частично ссылающийся этими числами на 16-байтовые записи в sect2
  2. sect2, состоящий из 16-байтовых записей с возрастающими числами внутри (предположительно — какие-то смещения)
  3. sect3, состоящий преимущественно из null-terminated строчек в ShiftJIS, но не совсем (предположительно — строковые ресурсы и всякие другие константы, на которые ссылается байткод)

На этой маленькой победе, думаю, мы завершим наши сегодняшние изыскания, так как статья очередной раз получается неприлично большой. В какой-то степени — если, например, стоит задача перевести визуальную новеллу — достигнутого сегодня уже достаточно для того, чтобы выдрать тексты и отдать их переводчикам. Берем sect3, находим в ней все, что похоже на SJIS, аккуратно выбрасываем все остальное — вуаля:


恋する姉妹の六重奏(デバッグ)-File : trialh_mayu.yks
まゆ
「きゃっ……!!」
教育的指導を兼ねて、お望み通りメチャクチャにしてやろうじゃないか!!
「あっ……お、おにぃっ……」
自分から誘っておきながら、不安そうな表情を浮かべるまゆ。
そんなまゆを、ソファーに押しつけて……胸を露出させ、股間が丸見えになる体勢を強いる。
「んぁっ……」

Спасибо всем, кто дочитал до этого места. В следующий раз мы доберемся до собственно байткода и попытаемся понять, как устроены sect1 и sect2. До встречи!

Tags:
Hubs:
Total votes 66: ↑64 and ↓2+62
Comments33

Articles