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

Продовжуємо нашу серію статей про те, як влізти у нутрощі ігрових движків і витягувати з них всіляке вміст. Для тих, хто щойно приєднався, коротко нагадаю, що ми вивчали такий кумедний жанр, як візуальні новели.
Минуло вже багато часу з того моменту, як ми навчилися розбирати архіви движка візуальних новел 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 навіть неозброєним оком в людом hex-редакторі видно таблиця зростаючих (або майже завжди зростаючих чисел). Навряд чи це сам байт-код: для байт-коду характерно постійне повторення одних і тих же послідовності. Це теж швидше за все якісь зміщення — наприклад, це можуть бути зсуви, що визначають початку команд в байт-код, або які-небудь початку, кінці рядків змінної довжини або що-небудь ще таке. У нас є 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 00 01
[.] @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 00 3d 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. До зустрічі!
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.