Хочу признаться: я обожаю визуальные новеллы. Кто не в курсе — это такие то ли интерактивные книжки, то ли игры-в-которых-надо-в-основном-читать-текст, то ли радиоспектакли-с-картинками, преимущественно японские. Последние лет 5, наверное, художественную литературу в другом формате толком не вопринимаю: у визуальных новелл по сравнению с бумажными книгами, аудио-книгами и даже сериалами — в разы большее погружение, да и сюжет регулярно куда интереснее.
А еще я с детства обожаю разбираться в том, как устроены вещи и что у них внутри. Начинал с первых сломанных игрушек, дорос до реверс-инжиниринга и вирусного аналитика в одной не самой маленькой компании. И пришло мне в голову — почему бы не попробовать совместить эти вещи? Посмотреть, что у визуальных новелл внутри и разобраться, на основе чего работает тот или иной движок?
А тут как раз недавно вышел релиз Kaitai Struct — это такой новый фреймворк для реверс-инжиниринга бинарных структур данных (хотя авторы активно отпираются, говоря, что они это все только в мирных целях). Идея там банальная, но подкупает: декларативно описываешь на некоем языке разметки структуру данных — и тут же получаешь готовую библиотеку классов для парсинга на любом поддерживаемом языке программирования. В комплекте прилагаются визуализатор (чтобы легче было проверять) и, собственно, компилятор. Что ж, попробуем, на что оно годится?
В качестве разминки, а заодно для того, чтобы показать типовые действия, с которыми придется столкнуться в реверс-инжиниринге, предлагаю начать с чего-нибудь не очень тривиального, а именно с чудесной визуальной новеллы Koisuru Shimai no Rokujuso (в оригинале — 恋する姉妹の六重奏) авторства широко известных в узких кругах PeasSoft. Это забавная, ненапрягающая романтическая комедия, с традиционно для PeasSoft красивой визуальной частью. С точки зрения реверсинга — меня она зацепила еще и тем, что использует не сильно распространенный и никем вроде бы еще не исследованный собственный движок. А это как минимум интереснее, чем ковыряться в уже напрочь замученных и растащенных по полочкам KiriKiri или Ren'Py.
Первым делом приведу список инструментов, которые нам потребуются:
Kaitai Struct — компилятор и визуализатор к нему
Java JRE — к сожалению, Kaitai Struct написан на Java и ее требует
любой из языков программирования, поддерживаемый KS (на данный момент это Java, JavaScript, Ruby, Python) — я буду использовать Ruby, но это не столь принципиально, на нем мы напишем листер и экстратор в самом конце
какой-нибудь хекс-редактор — по большому счету, не важно какой, они все неидеальные; в принципе, хекс-редактор есть внутри визуализатора KS, но он тоже не самый удобный; я лично пользуюсь okteta (просто потому, что там есть по-человечески удобный копи-паст) — но пойдет почти все, что угодно — была б возможность смотреть дамп, переходить по заданному адресу и быстро скопировать выделенный кусок в отдельный файл
Дальше нужен сам продукт исследования — дистрибутив визуальной новеллы. Здесь тоже повезло: PeasSoft на своем сайте выкладывает для совершенно легального бесплатного скачивания триальные версии, которых нам вполне хватит для ознакомления с форматами. На сайте нужно найти такую вот страничку:
Обведенное синим — на самом деле ссылки на мирроры (все одинаковые), где дают скачать триал.
Скачав и распаковав дистрибутив, мы начнем со сбора информации. Давай подумаем, что мы уже знаем о нашем экспонате. Во-первых, платформа. По крайней мере та версия, что раздается на сайте, работает под Windows на процессорах Intel (вообще есть еще версия под Android, но на практике она продается только в маркетах приложений у японских сотовых операторов, плюс ее нетривиально вытащить с телефона). Из того, что это Windows/Intel вытекают несколько весьма вероятных вещей:
целые числа в бинарных форматах будут кодироваться в little-endian
программисты под Windows в Японии до сих пор живут в каменном веке пользуются кодировкой Shift-JIS
концы строчек, если мы их когда-то встретим в явно виде, будут обозначаться "rn", а не просто "n"
Беглый осмотр распакованного и установленного показывает, что добыча состоит из:
data01.ykc — 8393294
data02.ykc — 560418878
data03.ykc — 219792804
sextet.exe — 978944
AVI/op.mpg — 122152964
Т.е. здесь один небольшой exe-шник (очевидно, с движком), нескольких гигантских файлов .ykc (вероятно, архивов с контентом) и op.mpg — видеофайл с заставкой (в чем можно убедиться сразу, открыв его любым плеером).
Полезно быстренько визуально осмотреть exe-файл в хекс-редакторе: современные разработчики даже достаточно адекватны и зачастую пользуются готовыми библиотеками для работы с изображениями, звуками и музыкой. А такие библиотеки, как правило, при компиляции оставляют некие видимые невооруженным глазом сигнатуры. Что стоит искать:
"libpng version 1.0.8 — July 24, 2000" — используется libpng, значит картинки будут в формате png
"zlib version error", "Unknown zlib error" — означает, что будет использоваться сжатие zlib; на самом деле может быть ложным следом, т.к. zlib-сжатие используется при кодировании png; пока не будем делать далекоидущих выводов
"Xiph.Org libVorbis I 20020717" — libvorbis, а это означает, что музыка, речь и звуки будут скорее всего в ogg/vorbis
"Corrupt JPEG data", "Premature end of JPEG file" — строчки из libjpeg; если есть — значит, движок скорее всего умеет еще и картинки в формате jpg
"D3DX8 Shader Assembler Version 0.91" — где-то внутри что-то использует шейдеры D3DX8
россыпь сообщений типа "Microsoft Visual C++ Runtime Library", "local vftable'", "eh vector constructor iterator'" и т.д. говорит нам о том, что компилировалось линковалось это с Microsoft'овской C++ библиотекой, и, скорее всего, написано на C++; при желании можно покопаться и узнать даже конкретную версию, но сейчас нам это особенно ни к чему — мы не собираемся ее дизассемблировать или что-то такое, у нас абсолютно честный clean room
Еще можно поискать строчки типа "version", "copyright", "compiler", "engine", "script" — причем, т.к. это Windows-файл, не забывать делать это еще и в двухбайтовых кодировках типа UTF16-LE — иногда может найтись еще что-нибудь интересное. У нас находится, например, "Yuka Compiler Error", "YukaWindowClass", "YukaApplicationWindowClass", "YukaSystemRunning", а также упоминания "start.yks", "system.ykg". Из всего этого логично предположить, что сами программисты называли свой движок как-то типа "Yuka", а всякие типы файлы, используемые им, все начинаются с yk — ykc, yks, ykg. Еще находится "CDPlayMode" и "CDPlayTime" — из чего можно предположить, что движок умеет играть музыку с дорожек Audio CD, а еще "MIDIStop" и "MIDIPlay" — из чего можно предположить поддержку музыки в MIDI.
По нашей новелле в сухом остатке получается достаточно оптимистичная картина:
картинки в форматах png и jpg, что сразу на порядок облегчает задачу — скорее всего не нужно будет копаться в кастомных форматах сжатия картинок
музыка, речь и звуки — скорее всего в ogg (но может быть и MIDI, и CDDA — хотя вряд ли, т.к. физического носителя нет)
Первоначальные сведения собраны — теперь можно засучить рукава и нырять в файлы. Файлов, на самом деле, несколько — и это тоже хорошо. Вообще в реверс-инжиниринге зачастую очень важным становится чисто статистический вопрос, когда изучаемого объекта можно достать более одного в различных вариациях и посмотреть, чем же они отличаются. Гораздо сложнее гадать, что же значит очередной 7F 02 00 00, когда он у тебя ровно один.
Проверяем, один ли и тот же формат у файлов. Судя по тому, что все они — data*.ykc — один. Смотрим внутрь на начало файла: все начинается с "YKC001" — очень хорошо, похоже, что это действительно архивы одного и того же формата.
Быстро эмпирически проверим, используется ли какое-то сжатие. Берем любой архиватор, пытаемся сжать файл и смотрим, сколько было до и сколько стало после. Я тупо использовал zip:
до — 8393294
после — 6758313
Сжимается, но не сильно. Скорее всего таки или сжатия нет, или оно есть не для всех файлов. С другой стороны, если в архиве какие-нибудь png или ogg —они же уже сжатые, zip'ом сжиматься они сильнее не будут.
Из общих соображений, у практически любого архива есть какой-то заголовок, есть, как правило, каталог содержимого архива (относительно небольшой), и 99% архивного файла занимает, собственно содержимое — вложенные файлы или блоки данных. Заголовок, кстати, не обязательно начинается прямо с начала файла — может быть с сколько-нибудь отступив от конца файла или сколько-нибудь (обычно немного) — от начала. Крайне редко можно встретить, чтобы файл начинали с места в карьер читать из середины.
Смотрим на начало файлов. Видим примерно следующую картину:
59 4B 43 30 │ 30 31 00 00 — это явно просто сигнатура файла, в ASCII это "YKC001", скорее всего обозначает что-нибудь типа "Yuka Container", версии 001
18 00 00 00 │ 00 00 00 00 │ 1A 00 80 00 │ 34 12 00 00 — видимо, это и есть заголовок
дальше явно начинается тело какого-то файла внутри архива; особенно хорошо это видно в data02.ykc — там идут строчки какого-то то ли конфига, то ли скриптового языка: "WindowSize = 1280, 800", "TransitionWaitType = 2" и т.д.; т.е. дальше смотреть бессмысленно
Смотрим на то, как выглядит заголовок во всех трех имеющихся у нас файлах:
Что такое "18 00 00 00 │ 00 00 00 00" — пока непонятно, но оно везде одинаковое. Гораздо интереснее остальные 2 поля — это явно два 4-байтовых целых числа. Кажется, пора доставать Kaitai Struct и начинать описывать полученные догадки в виде ksy-формата:
Вроде бы ничего сложного. ksy — на самом деле обычные YAML-файлы. Секция "meta" описывает, над чем мы собственно работаем и что мы собрали по итогам нашего предварительно расследования — т.е. что мы разбираем файлы "ykc", предположительно обрабатывает их приложение под названием "Yuka Engine" (поле "application" вроде бы ни на что не влияет, это как комментарий), и по умолчанию целые числа будут в little endian формате (endian: le).
Дальше идет описание того, как парсить файл и какие структуры данных мы в нем обнаружили — это задается массивом полей в секции "seq". Каждое поле обязано иметь id (и это логично, мы ради этого и работаем, чтобы понять, что где лежит) и описание содержимого. Тут мы использовали две конструкции:
Конструкция типа contents: [0x18, 0, 0, 0, 0, 0, 0, 0] задает поле с фиксированным содержимым. Работает это так: из содержимого автоматически следует его длина, плюс KS автоматически сделает проверку и выбросит exception, если при чтении такого поля содержимое не будет соответствовать ожидаемому
Конструкция типа type: u4 задает поле типа "целое беззнаковое (_u_nsigned) число, длина 4 байта". Endianness при этом такой, как мы указали в meta.
Загружаем теперь наш новоиспеченный формат в визуализатор:
ksv data01.ykc ykc.ksy
и лицезрим для data01.ykc:
Да, визуализатор работает в консоли, можно начинать ощущать себя хакером. Но сейчас нас интересует дерево структур:
В визуализаторе можно стрелочками ходить по полям, можно переключаться в hex viewer и обратно нажатием Tab, а нажатием Enter можно проваливаться в содержимое полей, раскрывать instances (о них будет ниже) и просматривать хекс-дампы на полный экран. Для экономии места и времени я ниже скриншоты визуализатора больше показывать не буду, буду показывать только часть с деревом текстом.
Негусто, в общем. Каталога файлов нет, где искать концы, на первый взгляд, непонятно. Тут самое время вспомнить еще раз, сколько же эти файлы занимают и прикинуть, не может ли одно из этих двух числе быть ссылкой куда-то еще:
data01.ykc — 8393294 @unknown1 = 8388634
data02.ykc — 560418878 @unknown1 = 560390478
data03.ykc — 219792804 @unknown1 = 219734364
Какое поразительное сходство. Давайте проверять, что там лежит по этому смещению:
Мы добавили описание еще этого поля "unknown3". На этот раз оно пошло не в секцию "seq", а в секцию "instances". По сути это примерно те же поля, что можно описать в "seq", но они идут не по порядку, а имеют явное указание, откуда их читать (по какому смещению, из какого потока и т.д.). Мы указали, что хотим иметь поле с названием "unknown3" (мы пока не знаем, что там), начинающееся от смещения unknown_ofs (pos: unknown_ofs) и длящееся до конца файла (size-eos: true). Как как ты не знаем, что там — нам прочитают просто поток байт. Да, пока негусто, но с этим уже можно пробовать жить. Пробуем:
Сразу обращаем внимание на то, что длина того, что у нас прочиталась поразительно похоже на содержимое поле unknown2. Т.е. похоже, что в начале YKC-файла лежит просто указание, по какому смещению и какого размера реальный заголовок файла. Исправляем наш файл формата, чтобы отразить эту догадку:
Изменения незначительные: мы переименовали все unknown-поля, чтобы отразить их смысл, и "size-eos: true" (читать все безусловно до конца файла) изменили на "size: header_len". Скорее всего это близко соответствует замыслу того, кто придумывал этот формат. Загружаем еще раз и теперь концентрируемся на том блоке данных, что мы назвали "header". Вот как выглядит его начало для data01.ykc:
На первый взгляд, ничего не понятно и общего ничего нет. На второй взгляд, на самом деле, глаз цепляется за последовательность повторяющихся байт. В первом файле это e7 7f, во втором — 2b 66, в третьем — 30 17 и 31 17. Очень похоже, что мы имеем дело с повторяющимися записями фиксированной длины и длина эта — 0x14 (т.е. 20) байт. Кстати, это хорошо согласуется с длинами header во всех трех файлах: и 4660, и 28400, и 58440 делятся на 20. Давайте проверять такую гипотезу:
Обратите внимание на то, что стало с instance "header". Оно все так же начинается с header_ofs и имеет длину header_len, но добавилось еще указание type: header. В отличие от "u4" (целое число), мы сейчас объявим и будем использовать пользовательский тип и весь блок "header" будет разбираться этим типом. Ниже идет описание типа "header" — см. "types: header: ". Как можно догадаться по внешнему виду и слову "seq" — дальше следует ровно такой же синтаксис как на самом верхнем уровне файла. Т.е. внутри нашего нового типа header можно точно так же задать поля, которые будут читать с самого начала блока последовательно (в "seq"), поля по определенным смещениям (в "instances"), свои подтипы (в "types") и т.д.
Итак, мы задали тип "header", состоящий пока из одного поля entries, имеющего размер 0x14 байт, зато повторенного столько раз, сколько возможно до конца потока (repeat: eos). Потоком, кстати, в этом случае считается уже блок header, который мы объявили явно как блок в header_len байт, начинающийся с header_ofs. Т.е. если бы там что-то было за ним еще — оно бы не прочиталось, все в порядке.
Кстати, "233" и "1420" записей вполне похоже на количество файлов в архиве. Первый архив у нас маленький (8 мегабайт), на 233 файла — получается в среднем по 36022 байта на файл. Вполне похоже на какие-то скрипты, конфиги, файлы сценариев и т.п. Второй архив у нас самый большой (560 мегабайт), на 1420 файлов — по 394661 байта на файл, вполне похоже на какие-нибудь картинки или файлы с записями голоса.
57 e7 7f 00, 61 e7 7f 00, 6c e7 7f 00 и т.д. — это явно последовательность увеличивающихся чисел, что бы она могла означать? Во втором файле это, соответственно, d1 2b 66 21, dd 2b 66 21, f1 2b 66 21. Стоп, где-то я уже видел эти цифры. Смотрим в самое начало нашего исследования — точно. Это же близко к длине всего архивного файла целиком. А значит это опять указатели на что-то внутри файла. Собственно, давайте уже опишем структуру этих 20-байтовых записей. Вроде бы из внешнего вида понятно, что это банально 5 целых чисел. Описываем еще один тип "file_entry". В вашего молчаливого разрешения я не буду приводить весь ksy файл целиком, а приведу только изменившуюся секцию types:
Здесь никаких новых конструкций вроде бы нет. Добавили "type" к полю entries и описали этот самый тип как 5 подряд идущих целых (типа u4). Смотрим, что получилось:
Вырисовывается еще одна гипотеза: unknown3 — указатель на начало тела файла в архиве, а unknown4 — его длина. Ведь 24 + 531 = 555, а 555 + 10971 = 11526. Т.е. это файлы внутри архива, которые тупо идут последовательно. Аналогичное наблюдение можно сделать и для unknown_ofs и unknown2: 8382295 + 10 = 8382305, 8382305 + 11 = 8382316. То есть unknown2 — длина каких-то записей, начинающихся с unknown_ofs. unknown5, похоже, всегда равен 0. Вперед, добавляем внутрь каждого file_entry еще два поля: зачитаем, контент (unknown_ofs; unknown2) и тело файла на (unknown3; unknown4). Привожу только описание file_entry:
Из нового и нетривиального здесь только одна конструкция: io: _root._io. Без нее pos: body_ofs, скажем, будет отсчитывать смещение body_ofs в текущем потоке, т.е. в потоке, состоящем из 20 байт file_entry. Разумеется, попытка прочитать 8-милионный байт из 20 приведет к ошибке. Поэтому и нужна эта самая специальная магия, которая говорит о том, что зачитывать мы будем не из текущего потока, а из того потока, который соответствует всему файлу в целом — _root._io.
Даже невооруженным глазом видно, что 73 74 61 72 74 2e 79 6b 73 00 — это ASCII-строка, а посмотрев в char-представление, можно убедиться, что это "start.yks" с терминирующим 0 байтом, а 73 79 73 74 65 6d 2e 79 6b 67 00 — это "system.ykg". Ура, это похоже на имена файлов. И про них мы точно знаем, что они — строки. Давайте отразим этот факт:
Из нововведений — type: str (означает, что захваченные байты надо интерпретировать, как строку) и encoding: ASCII (мы пока точно не знаем, что там за кодировка, поэтому пойдем по пути наименьшего сопротивления). Смотрим в визуализаторе:
Красота. Фактически, все, задачу мы уже выполнили, этого достаточно, чтобы извлекать файлы. А теперь — та магия, ради которой все затевалось. Давайте напишем скрипт, который выгрузит нам все файлы из одного архива. И для этого нам не придется писать парсинг всех этих полей еще раз. Мы просто берем компилятор ksc и делаем:
ksc -t ruby ykc.ksy
и получаем в текущей папке прекрасный файл ykc.rb, который можно тут же подключать, как библиотеку и использовать. Как? Давайте для разминки покажу, как вывести на экран листинг всех файлов архива:
Ykc.from_file(...) — порождает объект класса Ykc из указанного файла на диске; в поля объекта автоматически парсится то, что описано в формате
.header — выбирает поле "header" класса Ykc, тем самым возвращая экземпляр класса Ykc::Header
.entries — выбирает поле "entries" у заголовка, возвращает массив элементов класса Ykc::FileEntry
.each { |f| ... } — делает что-то с каждым элементом массива
puts f.filename — выводит поле "filename" объекта f (который класса Ykc::FileEntry)
Перед тем, как написать код, который будет извлекать из архива все файлы, обратим внимание еще на пару фактов:
Во-первых, в путях есть папки, а т.к. этот архив делали на Windows-системе, в качестве знака разделителя элементов пути используется обратный слэш (""). Не знаю как где, но как минимум в Ruby это приводит к тому, что создается папка с обратными слэшами в ней. Для корректной работы всяких там mkdir_p нужно будет поменять "" на "/".
Во-вторых, в именах файлов в конце есть терминирующий 0, доставшийся нам, видимо, как тяжелое наследие C. При выводе на печать его не видно, но по хорошему его бы тоже стоит убрать.
В-третьих, на самом деле вскрытие показало, что в data02.ykc находятся файлы, выглядящие как-то так:
Вспомнив гипотезу из самого начала статьи о том, что программисты явно были японцами и это может быть ShiftJIS, меняем encoding у filename на "SJIS", активно утешая себя мыслью о том, что для тех, кто использует не-ASCII символы в именах файлах приготовлен специальный круг в аду. Не забываем перекомпилировать ksy => rb, проверяем, теперь все в порядке:
Ну, не то, чтобы совсем в порядке, но это вполне похоже на японский. Воспользовавшись хотя бы гуглопереводчиком, можно проверить, что "電話" — это "телефон", и "SE0050" — видимо, звуковой эффект звонка телефона.
Уже не одна строчка, конечно, но, с другой стороны, комментировать тут тоже особенно нечего. Из аргументов командной строки получаем имена файлов с архивами (вполне можно запускать что-то типа ./extract-ykc *.ykc — сработает), приводим к нужному нам виду имя файла из f.filename, создаем, если нужно, папки в пути, берем само содержимое файла из f.body и записываем его в файл с нужным путем.
Наша задача выполнена — запустив скрипт, можно действительно убедиться в том, что файлы извлекаются. Как мы и предполагали, картинки будут преимущественно в png (и их можно сразу же смотреть), музыка и звуки — в ogg (и их можно сразу же слушать). Вот, например, что в папке BG явно получились все бэкграунды:
А в папке TA/ по разложены спрайты, которые на них накладываются. Так, например, выглядит Мика:
Отдельно обратите внимание на страшные названия файлов. Самое нетривиальное, что остается — это извлеченные файлы yks и ykg — видимо, в них и есть сценарий и текст новеллы. На первый взгляд — это бинарные файлы с непонятной структурой. Думаю, отложим их на следующий раз :)
Краткие выводы по итогам такого шапочного знакомства с KS:
Kaitai Struct в целом весьма годен, достаточно близко соответствует тем действиям, которые бы делались без него и экономит кучу времени. Если делать такое руками — то, как минимум, пришлось бы писать то же самое на каком-нибудь Ruby или Python: один раз, чтобы разобраться со структурой бинарника, другой — чтобы собственно сделать распаковщик. А если делать это средствами каких-нибудь "продвинутых" редакторов типа 101 или Hexinator — то и три раза (плюс еще один раз — описать в редакторе).
Визуализатор Kaitai Struct — весьма примитивен, и местами тормозит и подглючивает, но, в целом, это лучшее, что я видел в этой области. Ему бы еще хекс-редактор доделать до состояния okteta.
Хекс-редактор, собственно, можно выкидывать после того, как сделан первый скелет файла. Все равно ходить по дереву куда проще и нагляднее, чем вручную вбивать смещения. Но он все еще нужен и полезен для предварительного просмотра + просмотра exe-шника.
Экспрессивность языка ksy очень большая (в разы лучше и удобнее, чем те же языки разметки в редакторах — например, там, как правило, даже речь не идет о том, чтобы декларативно описать структуру в другом конце файла), но вот документация, мягко говоря, оставляет желать лучшего. Признаюсь честно — за последнюю неделю я, наверное, задолбал вопросами автора KS. Вот этот самый трюк с io: _root._io — вообще отстрел мозга, который самостоятельно не придумаешь. Надеюсь, к версии 1.0 документацию таки приведут в приличный вид.