Demontage der Qlie Visual Novel Engine



Eine Amateurübersetzung von visuellen Kurzgeschichten hat im Vergleich zu Übersetzungen anderer Spiele eine Reihe von Funktionen und beinhaltet die Arbeit mit viel Text. Vielleicht wurde die überwiegende Mehrheit aller Bildromane auf Japanisch veröffentlicht, nur wenige wurden ins Englische übersetzt (offiziell oder von Amateuren) und noch weniger wurden in andere Sprachen übersetzt.

Wenn Sie mit Übersetzungen arbeiten, müssen Sie sich daher mit japanischen Engines auseinandersetzen, von denen viele für Lokalisierer nicht sehr freundlich sind. Aus diesem Grund wird schnell klar, dass das Vorhandensein von Übersetzungsfähigkeiten, Sprachkenntnissen, viel Enthusiasmus und Freizeit keineswegs bedeutet, dass die übersetzte Version des Spiels bald das Licht der Welt erblicken wird.

Sehr grob impliziert der Prozess der Übersetzung eines Spiels (nicht nur visueller Kurzgeschichten):

  • Auspacken von Spielressourcen (falls diese nicht gemeinfrei sind)
  • Übersetzung der notwendigen Teile
  • Rückverpackung übertragen

Bei japanischen visuellen Kurzgeschichten sieht dies jedoch normalerweise so aus:

  • Spielressourcen auspacken
  • Übersetzung des Textteils des Spiels (Spieleskript)
  • Übersetzung des grafischen Teils des Spiels
  • Rückverpackung übertragen
  • Änderung der Engine, damit sie mit übersetzten Inhalten funktioniert

Ich hoffe, unsere Erfahrung wird jemandem nützlich sein.

Bereits 2013 (und möglicherweise früher) habe ich mich entschlossen, den Bildroman Bishoujo Mangekyou-Norowareshi Densetsu no Shoujo aus dem Japanischen zu übersetzen. Ich hatte bereits Erfahrung mit dem Übersetzen von Spielen, musste aber vorher nur Kurzgeschichten auf relativ einfachen und bekannten Engines wie Kirikiri übersetzen .

Hier musste unser Übersetzerteam den Motor dieser Kurzgeschichte öffnen, noch bevor wir zum eigentlichen Text gelangen.

Beginnen wir mit einer Beschreibung der EXE-Datei, in der die Wörter QLIE und IMOSURUME erwähnt werden. Die Datei selbst enthält die Zeile FastMM Borland Edition 2004, 2005 Pierre le Riche, was bedeutet, dass die Engine höchstwahrscheinlich in Delphi geschrieben ist.



Ein kurzes Googeln zeigt, dass Qlie der Name der von Warmth Entertainment veröffentlichten Visual Novel Engine ist. Anscheinend ist IMOSURUME der interne Name der Skript-Engine und Qlie der kommerzielle Name. Es gibt eine Website qlie.net , auf der die auf dieser Engine veröffentlichten Spiele und die offizielle Website von Warmth Entertainment aufgelistet sind.

Nirgendwo im öffentlichen Bereich gibt es jedoch weder offizielle Tools für die Arbeit mit der Engine noch eine Dokumentation dafür, die erwartet wird.

Daher müssen Sie sich selbst mit dem Spiel befassen und sich auf inoffizielle Dienstprogramme verlassen. Zunächst sollten Sie alle Teile des Spiels finden, die übersetzt werden müssen.

Spielarchive befinden sich in den Dateien data0.pack, data1.pack und data7.pack im Unterordner \ GameData. Die Bildschirmschoner befinden sich im Ordner \ GameData \ Movie, können jedoch weiterhin in Ruhe gelassen werden.


Der Hex-Editor zeigt an, dass es keine erkennbaren Header für Game .pack-Archive gibt, aber am Ende der Datei befindet sich ein Teil ähnlich dem Inhaltsverzeichnis und der Bezeichnung FilePackVer3.0


Glücklicherweise gibt es für dieses Format bereits einen Entpacker und nicht einmal einen. Wir haben die Konsole exfp3_v3 von asmodean verwendet.

Das Auspacken ist nicht so einfach, wie es scheint. Da die Engine mehrere Archivformate unterstützt (FilePackVer1.0, FilePackVer1.0, FilePackVer3.0) und in diesem Fall FilePackVer3.0 verwendet wird, benötigen Sie zum ordnungsgemäßen Entpacken auch eine spezielle Schlüsseldatei key.fkey, die das Archiv verschlüsselt. Es befindet sich im Unterordner \ Dll


Außerdem sollte exfp3_v3 das Archiv klarstellen, aus welchem ​​Spiel es entpackt wird.
Daher müssen Sie auch die Spielnummer aus der vom Entpacker vorgeschlagenen Liste angeben (Spiele der Bishoujo Mangekyou-Serie finden Sie unter Nummer 15) oder die ausführbare Datei des Spiels als dritten Parameter für den Entpacker angeben.


Bereits nach dem Auspacken der Spieledateien kam ein logischer Gedanke auf: Wie kann man das Spiel in Zukunft mit einer fertigen Übersetzung zurückpacken? Schließlich unterstützt der Entpacker den umgekehrten Vorgang nicht.
Auf unsere Anfrage hin hat w8m (vielen Dank dafür) die Möglichkeit hinzugefügt, Spielearchive in sein Programm arc_conv.exe zu packen. Es reicht aus, alle geänderten Dateien in ein neues Archiv (z. B. data8.pack) zu packen, es in den GameData-Ordner zu legen und sie ziehen sich automatisch ins Spiel.

Zurück zu den entpackten Ressourcen. Spieleskriptdateien aus dem data0.pack-Archiv befinden sich im Unterordner \ Szenario \ ks_01 \

Alle Skriptdateien mit der Erweiterung .s sind weit entfernt von der bequemsten Shift Jis-Codierung codiert, und die Engine unterstützt keine Unicode-Codierungen. Die Zeilen für die Übersetzung sehen ungefähr so ​​aus:

【キリエ】 %1_kiri1478% 「へえ……分かっているじゃない」 私が献上したロシアンティーを見て、キリエは嬉しそうに目を細める。 ^cface,,赤目微笑01 【キリエ】 %1_kiri1479% 「日本人は、ジャムを紅茶に入れて飲むのが、ロシアンティーだと勘違いしている人が多いのだけれど……」 

Möglicherweise stellen Sie fest, dass vor jeder japanischen Phrase der Name des Helden in japanischen Klammern steht. (【】), Der diese Phrase ausspricht (im Spiel wird sie oben im Fenster mit Text angezeigt). Wenn dies die Wörter des Autors sind, wird der Name nicht hinzugefügt.


Aber es gibt immer noch Serviceteams.

Die Engine-Befehle im Skript erinnern etwas an die TeX-Markup-Sprache, sind jedoch im Vergleich zu den Kirikiri oder RenPy-Befehlen viel intuitiver und unpraktischer.

Hier sind einige davon:

@@@ ist ein dreifacher Hund. Oft beginnen Skriptdateien mit diesem Befehl. Anscheinend werden Definitionen aus Dateien von Drittanbietern geladen.

Zum Beispiel:

 @@@Library\Avg\header.s 

@@ ist ein Doppelhund. Die Bezeichnung in der Skriptdatei. Sie können später darauf umsteigen.

%1_kiri1478% - Spielen Sie die Sprachdatei ab. Diese Befehle werden zwischen dem Namen des Helden und dem auf dem Bildschirm angezeigten Text eingefügt. "1_kiri1478" - in diesem Fall der Name der Datei aus dem Ordner \ voice \ der Datei data1.pack Es ist interessant, dass das Team den japanischen Prozentsatz (%) anstelle des üblichen verwendet.

^savedate, ^saveroute, ^savescene, - drei Teams, die höchstwahrscheinlich im Speichersystem des Spiels verwendet werden und Informationen über den Ort und die Zeit eingeben sollten, zu der der Spieler im Speicherspiel gespeichert wurde.

Zum Beispiel:

 ^savedate,"現在" ^saveroute,"美少女万華鏡-1-" ^savescene,"呪われし伝説の少女 オープニング" 

Das heißt, Datum: Gegenwart, Zweig: Bishoujo Mangekyou -1-, Szene: Norowareshi Densetsu no Shoujo Eröffnung. Diese Daten sollten im Speicherbereich angezeigt werden, aber anscheinend haben die Entwickler beschlossen, sie aufzugeben. Infolgedessen ist ^saveroute in allen Teilen des Skripts gleich, ^savedate Änderungen von "dem gegenwärtigen Moment" zu "Träumen" und ^savescene in ^savescene Tage (oder vielmehr Nächte) im Spiel.

^facewindow, - Status des Textfelds mit dem auf dem Bildschirm angezeigten Text. (Gezeigt - 1 oder nicht - 0)

^sload, - Spielen Sie Sounds im Spiel aus dem Ordner \ sound \ auf dem entsprechenden Kanal ab.

 sload,Env1,◆セミ01アブラゼミ 

Zikaden auf Env1 spielen

Das Team hat zwei optionale Parameter, der erste ist für das Schleifen des Sounds verantwortlich, und der zweite bleibt ein Rätsel, wird aber im Spiel selten verwendet.

 ^sload,SE1,■クチュ音01,1 

Wiedergabe von Loopback-Sound auf Kanal SE1.

^eeffect - ^eeffect für eine bestimmte Anzahl von Sekunden einen Spezialeffekt auf dem Bildschirm an. Anscheinend unterstützt es die sequentielle Ausgabe mehrerer Effekte.

 ^eeffect,WhiteFlash 

Die Wirkung eines weißen Blitzes.

^ffade - Übergangseffekt beim Ändern des Bildschirms.
Es enthält eine ganze Reihe zusätzlicher Parameter, aber nur wenige sind wirklich nützlich: der Name des Übergangseffekts, gegebenenfalls ein zusätzliches Bild und die Abschlussabschlusszeit.

 ^ffade,Overlap,,1000 

Ein Bild in einem anderen in 1 Sekunde auflösen.

^iload - lädt das Hintergrundbild auf den Bildschirm. Dem Bild kann eine ID zugewiesen werden, auf die in Zukunft verwiesen werden soll.

 ^iload,BG1,0_black.png 

Ausgabedatei 0_black.png als Hintergrund mit der ID BG1

^we und ^wd - schalten das Bild im Fenster ein und aus.

^facewindow,1 und ^facewindow,0 das Heldenbild im Dialogfeld ein und aus.

^mload - Musik auf einem bestimmten Kanal abspielen.

 ^mload,BGM1,nbgm13 

Titel nbgm13 auf Kanal BGM1 abspielen

Einige der wichtigsten Teams:
\jmp - Springe zum Label mit dem angegebenen Namen.

^select - Zeigt das Auswahlfenster auf dem Bildschirm an, in dem der Spieler eine der Optionen auswählen muss.

Zum Beispiel:

 ^select, ,  \jmp,"@@route01a"+ResultBtnInt[0] @@route01a0 

Hier wird der Übergang nach der Antwort auf die Frage durchgeführt und die Antwortnummer (0 oder 1) von ResultBtnInt [0] zurückgegeben. Infolgedessen verschiebt \jmp die Story auf das Label @@ route01a + Antwortnummer. Das heißt, @@ route01a0 oder @@ route01a1

Eine unangenehme Eigenschaft ist, dass das übliche Komma in diesen Befehlen als Trennzeichen dient und nicht in den Antwortoptionen selbst verwendet werden kann. Die Japaner haben kein solches Problem, sie verwenden das japanische Komma (、). In diesem Fall können wir das Komma durch ‚ersetzen (U + 201A SINGLE LOW-9 QUOTATION MARK).

Zum Beispiel:

 ^select, ‚  , ‚  

Die verbleibenden Teams sind in erster Näherung nicht so wichtig.

Bevor Sie das Skript übersetzen, sollten Sie es natürlich in etwas Bequemeres umcodieren, z. B. in UTF-8, um kyrillische und japanische Zeichen zu kombinieren.

Nach dem Ändern der Engine (zu diesem nächsten Teil) erkennt das Spiel sowohl den russischen als auch den japanischen Text. Aus Kompatibilitätsgründen müssen Sie jedoch japanische Zeichen in Shift Jis und kyrillische Zeichen in cp1251-Codierung codieren.

Wir haben in Python schnell ein Programm zum Transcodieren unter Berücksichtigung des kyrillischen Alphabets entworfen:

UTF8 bis cp1251 und ShiftJIS
 # -*- coding: utf-8 -*- # UTF8 to cp1251 and ShiftJIS recoder # by Chtobi and Nazon, 2016 import codecs import argparse from os import path JAPANESE_CODEPAGE = 'shift_jis' UTF_CODEPAGE = 'utf-8' RUS_CODEPAGE = 'cp1251' def nonrus_handler(e): if e.object[e.start:e.end] == '~': # UTF-8: 0xEFBD9E -> SHIFT-JIS: 0x8160 japstr_byte = b'\x81\x60' elif e.object[e.start:e.end] == '-': # UTF-8: 0xEFBC8D -> SHIFT-JIS: 0x817C japstr_byte = b'\x81\x7c' else: japstr_byte = (e.object[e.start:e.end]).encode(JAPANESE_CODEPAGE) return japstr_byte, e.end if __name__ == '__main__': arg_parser = argparse.ArgumentParser(prog="Recode to cp1251 and ShiftJIS", description="Program to encode UTF8 text file to " "cp1251 for all cyrillic symbols and ShiftJIS for others. " "Output file will be inputfilename.s", usage="recode_to_cp1251_shiftjis.py file_name") arg_parser.add_argument('file_name', nargs=1, type=argparse.FileType(mode='r', bufsize=-1), help="Input text file name. Only files coded in UTF8 are allowed.\n") codecs.register_error('nonrus_handler', nonrus_handler) input_name = arg_parser.parse_args().file_name[0].name output_name = path.splitext(input_name)[0] + ".s" with open(input_name, 'rt', encoding=UTF_CODEPAGE) as input_file: with open(output_name, 'wb') as output_file: for line in input_file: for char1 in line: bytes_out = bytes(line, UTF_CODEPAGE) output_file.write(char1.encode(RUS_CODEPAGE, "nonrus_handler")) print("Done.") 


Es gab jedoch einige Probleme. Das Programm hat beim Versuch, das "Tilde" -Symbol ~ (U + FF5E FULLWIDTH TILDE) neu zu codieren, den Fehler "UnicodeEncodeError: 'Shift Jis'-Codec kann das Zeichen' \ uff5e 'in Position 0: unzulässige Multibyte-Sequenz nicht codieren" generiert.

Zuerst habe ich auf Python gesündigt, aber am Ende habe ich eine ziemlich ungewöhnliche Nuance herausgefunden. Abhängig von der spezifischen Implementierung besteht eine Unsicherheit zwischen den Korrelationsmethoden für japanische Unicode- und Nicht-Unicode-Codierungen.

Infolgedessen ordnet Windows das Shift Jis-Zeichen dem 0x8160-Code dem Unicode ~ (U + FF5E FULLWIDTH TILDE) zu, und andere Transcoder (z. B. das Dienstprogramm iconv) korrelieren dasselbe Zeichen mit 〜 (U + 301C WAVE DASH) gemäß der offiziellen Unicode-Verhältnis-Tabelle - ftp://ftp.unicode.org/Public/MAPPINGS/OBSOLETE/EASTASIA/JIS/SHIFT JIS.TXT

Um die Entsprechung zwischen den Zeichen zu bestimmen, hat Microsoft offenbar beschlossen, die Schemata aus ihrer cp932-Codierung zu verwenden, bei der es sich um eine erweiterte Version von Shift Jis handelt.

Die gleiche Situation tritt mit dem Zeichencode 0x817C auf, der in UTF8 unter Windows als - (U + FF0D FULLWIDTH HYPHEN-MINUS) oder in iconv als - (U + 2212 MINUS SIGN) codiert ist.

Da alle Skriptdateien zuerst mit Notepad ++ von Shift Jis nach UTF8 konvertiert wurden (und er die in Windows verwendete Korrespondenztabelle verwendet), trat beim Zurückkonvertieren von UTF8 nach Shift Jis über unser Python-Programm der berüchtigte Konvertierungsfehler auf.

Daher war es notwendig, das Auftreten von ~ und - getrennten Bedingungen zu berücksichtigen.

Es gab andere kleinere Mängel - zum Beispiel wurde die Ellipse ... (U + 2026 HORIZONTAL ELLIPSIS) durch die kyrillische Ellipse von cp1251 ersetzt und nicht die Japaner von Shift Jis.

Nachdem Sie den Text übersetzt haben, können Sie mit der Spielgrafik fortfahren.

Grafikdateien des Spiels befinden sich in denselben Packarchiven, aber nach dem Entpacken müssen sie noch hart arbeiten. Beispielsweise werden fast alle PNG-Bilder als Dateien vom Typ sample + DPNG000 + x32y0.png entpackt. Mit anderen Worten, PNG-Bilder werden in horizontale Streifen mit einer Dicke von 88 cm geschnitten und jeder Streifen wird in eine separate Datei geschrieben. Der Dateiname zeigt die Seriennummer des Streifens (DPNG000 ... 009) und die x, y-Koordinaten.


Ich frage mich immer noch, warum das notwendig war. Wenn es schwierig ist, Ressourcen aus dem Spiel zu entfernen, ist dies eindeutig nicht die beste Methode.

Um die geschnittenen PNG-Dateien zu kleben, wurde gleichzeitig ein kleines Skript merge_dpng auf Pearl von asmodeus erstellt, das ImageMagick verwendet. Leider gab es Probleme mit ihm. Erstens brauchte ich Pearl, das ich nicht benutzte, und selbst nach der Installation stellte sich heraus, dass das Skript nicht richtig funktionierte.

Aus diesem Grund haben wir ein ähnliches Programm in Python geschrieben:

Qlie Engine dpng Dateien zusammenführen
 # -*- coding: utf-8 -*- # Qlie engine dpng files merger # by Chtobi and Nazon, 2016 # Requires ImageMagick magick.exe on the path. import os import glob import re import argparse import subprocess IMGMAGIC = os.path.dirname(os.path.abspath(__file__)) + '\\' + 'magick.exe' IMGMAGIC_PARAMS1 = ['-background', 'rgba(0,0,0,0)'] IMGMAGIC_PARAMS2 = ['-mosaic'] INPUT_FILES_MASK = '*+DPNG[0-9][0-9][0-9]+*.png' SPLIT_MASK = '+DPNG' x_y_ajusts_re = re.compile('(.+)\+DPNG[0-9][0-9][0-9]\+x(\d+)y(\d+)\.') if __name__ == '__main__': arg_parser = argparse.ArgumentParser(prog="DPNG Merger\n" "Program to merge sliced png files from QLIE engine. " "All files with mask *+DPNG[0-9][0-9][0-9]+*.png" "into the input directory will be merged and copied to the" "output directory.\n", usage="connect_png.py input_dir [output_dir]\n") arg_parser.add_argument("input_dir_param", nargs=1, help="Full path to the input directory.\n") arg_parser.add_argument("output_dir_param", nargs='?', default=os.path.dirname(os.path.abspath(__file__)), help="Full path to the output directory. " "It would be a script parent directory if not specified.\n") input_dir = arg_parser.parse_args().input_dir_param[0] output_dir = arg_parser.parse_args().output_dir_param[0] os.chdir(input_dir) all_append_files = glob.glob(INPUT_FILES_MASK) # Select only files with DPNG prep_bunches = [] for file_in_dir in all_append_files: # Check all files and put all splices that should be connected in separate list for num, bunch in enumerate(prep_bunches): name_first_part = bunch[0].partition(SPLIT_MASK)[0] # Part of the filename before +DPNG should be unique if name_first_part == file_in_dir.partition(SPLIT_MASK)[0]: prep_bunches[num].append(file_in_dir) break else: prep_bunches.append([file_in_dir]) os.chdir(os.path.dirname(os.path.abspath(__file__))) # Go to the script parent dir for prepared_bunch in prep_bunches: sorted_bunch = sorted(prepared_bunch) # Prepare -page params for imgmagic png_pages_params = [["(", "-page", "+{0}+{1}".format(*[(x_y_ajusts_re.match(part_file).group(2)), x_y_ajusts_re.match(part_file).group(3)]), input_dir+part_file, ")"] for part_file in sorted_bunch] connect_png_list = \ [imgmagick_page for imgmagick_pages in png_pages_params for imgmagick_page in imgmagick_pages] output_file = output_dir + sorted_bunch[0].partition(SPLIT_MASK)[0] + ".png" subprocess.check_output([IMGMAGIC] + IMGMAGIC_PARAMS1 + connect_png_list + IMGMAGIC_PARAMS2 + [output_file]) 


Es scheint, dass wir jetzt die ganze Reihe von Bildern haben, die im Spiel erscheinen? Überhaupt nicht - wenn Sie sich alle verbundenen Bilder aus allen Archiven ansehen, werden Sie immer noch feststellen, dass einige fehlen, obwohl sie im Spiel sind. Tatsache ist, dass es in der Engine einen anderen Dateityp gibt - mit der Erweiterung .b. Es ist eine Art Animation mit darin aufgenommenen Bildern und Tönen.

Es ist ziemlich einfach, die Ressourcen darin zu speichern, aber leider hat in unserem Fall keiner der vorgefertigten .b-Datei-Entpacker so funktioniert, wie es sollte. Entweder blieben einige Dateien entpackt, oder es gab Fehler aufgrund japanischer Namen, und ich wollte nicht vom japanischen Gebietsschema booten.

Hier noch eines war unser Skript nützlich. Seitdem waren wir mit so etwas wie Kaitai Struct nicht vertraut, wir mussten fast von Grund auf neu handeln.

Das Format der .b-Dateien erwies sich als einfach, und außerdem musste unser Entpacker nur Ressourcen aus diesem Spiel entpacken können. In anderen Spielen der Qlie-Engine wurden zusätzliche Arten von Ressourcen in .b-Dateien angezeigt, auf die wir jedoch nicht näher eingehen werden.

Öffnen Sie also eine beliebige .b-Datei in einem Hex-Editor und schauen Sie zum Anfang. Beachten Sie vor der Auswertung, dass die Bytereihenfolge aller numerischen Werte Little-Endian ist.

  • Abmp12-Dateikopf
  • Zehn Bytes 0x00
  • Der Titel des ersten Abschnitts abdata12 mit Overhead-Informationen.
  • Acht Bytes 0x00
  • Abschnittsgröße abdata12, 4-Byte-Ganzzahl. Sie können es sicher überspringen.
  • Abimage10 Abschnittsüberschrift
  • Sieben Bytes 0x00
  • Anzahl der Dateien in einem Abschnitt, Einzelbyte-Ganzzahl. In diesem Fall befindet sich eine Datei im Abschnitt.
  • Abschnittsüberschrift abgimgdat13
  • Sechs Bytes 0x00
  • Die Länge des Dateinamens innerhalb des Abschnitts, eine Zwei-Byte-Ganzzahl. In diesem Fall beträgt die Länge 4 Bytes.
  • Shift Jis codierter Dateiname
  • Datensatzlänge der Dateiprüfsumme, Doppelbyte-Ganzzahl.
  • Die Prüfsumme der Datei selbst.
  • Das unbekannte Byte scheint immer 0x03 oder 0x02 zu sein
  • Zwölf unbekannte Bytes, möglicherweise im Zusammenhang mit Animation
  • Die Größe der PNG-Datei innerhalb des Abschnitts ist eine 4-Byte-Ganzzahl.

Und schließlich die PNG-Datei selbst.


Der Absoundabschnitt ist in seiner Struktur dem Abimage ähnlich.

AnimatedBMP-Extraktor
 # -*- coding: utf-8 -*- # Extract b # AnimatedBMP extractor for Bishoujo Mangekyou game files # by Chtobi and Nazon, 2016 import glob import os import struct import argparse from collections import namedtuple b_hdr = b'abmp12'+bytes(10) signa_len = 16 b_abdata = (b'abdata10'+bytes(8), b'abdata11'+bytes(8), b'abdata12'+bytes(8), b'abdata13'+bytes(8)) b_imgdat = (b'abimgdat10'+bytes(6), b'abimgdat11'+bytes(6), b'abimgdat14'+bytes(6)) b_img = (b'abimage10'+bytes(7), b'abimage11'+bytes(7), b'abimage12'+bytes(7), b'abimage13'+bytes(7), b'abimage14'+bytes(7)) b_sound = (b'absound10'+bytes(7), b'absound11'+bytes(7), b'absound12'+bytes(7)) # not sure about structure of sound11 and sound12 b_snd = (b'absnddat11'+bytes(7), b'absnddat10'+bytes(7), b'absnddat12'+bytes(7)) Abimgdat13_pattern = namedtuple('Abimgdat13', ['signa', 'name_size_len', 'hash_size_len', 'unknown1_len', 'unknown2_len', 'data_size_len']) Abimgdat13 = Abimgdat13_pattern(signa=b'abimgdat13'+bytes(6), name_size_len=2, hash_size_len=2, unknown1_len=1, unknown2_len=12, data_size_len=4) Abimgdat14_pattern = namedtuple('Abimgdat14', ['signa', 'name_size_len', 'hash_size_len', 'unknown1_len', 'data_size_len']) Abimgdat14 = Abimgdat14_pattern(signa=b'abimgdat14'+bytes(6), name_size_len=2, hash_size_len=2, unknown1_len=77, data_size_len=4) Abimgdat_pattern = namedtuple('Abimgdat', ['name_size_len', 'hash_size_len', 'unknown1_len', 'data_size_len']) # probably, abimgdat10,abimgdat11 and others Other_imgdat = Abimgdat_pattern(name_size_len=2, hash_size_len=2, unknown1_len=1, data_size_len=4) Absnddat11_pattern = namedtuple('Absnddat11', ['signa', 'name_size_len', 'hash_size_len', 'unknown1_len', 'data_size_len']) Absnddat11 = Absnddat11_pattern(signa=b'absnddat11'+bytes(7), name_size_len=2, hash_size_len=2, unknown1_len=1, data_size_len=4) def create_parser(): arg_parser = argparse.ArgumentParser(prog='AnimatedBMP extractor\n', usage='extract_b input_file_name output_dir\n', description='AnimatedBMP extractor for QLIE engine *.b files.\n') arg_parser.add_argument('input_file_name', nargs='+', help="Input file with full path(wildcards are supported).\n") arg_parser.add_argument('output_dir', nargs=1, help="Output directory.\n") return arg_parser def check_type(file_buf): if file_buf.startswith(b'\x89' + b'PNG'): return '.png' elif file_buf.startswith(b'BM'): return '.bmp' elif file_buf.startswith(b'JFIF', 6): return '.jpg' elif file_buf.startswith(b'IMOAVI'): return '.imoavi' elif file_buf.startswith(b'OggS'): return '.ogg' elif file_buf.startswith(b'RIFF'): return '.wav' else: return '' def bytes_shiftjis_to_utf8(shiftjis_bytes): shiftjis_str = shiftjis_bytes.decode('shift_jis', 'strict') utf_str = shiftjis_str.encode('utf-8', 'strict').decode('utf-8', 'strict') return utf_str def check_signa(f_buffer): if f_buffer.endswith(b_abdata): return 'abdata' elif f_buffer.endswith(b_img): return 'abimgdat' elif f_buffer.endswith(b_sound): return 'absound' def prepare_filename(out_file_name, out_dir, postfix=''): ready_name = out_dir + os.path.basename(out_file_name) + postfix return ready_name def create_file(file_name_hndl, out_buffer): if len(out_buffer) != 0: with open(file_name_hndl, 'wb') as ext_file: ext_file.write(out_buffer) else: print("Zero file. Skipped.") def check_file_header(file_handle, bytes_num): file_handle.seek(0) readed_bytes = file_handle.read(bytes_num) if readed_bytes == b_hdr: print("File is valid abmp") return True else: print("Can't read header. Probably, wrong file...") return False if __name__ == '__main__': parser = create_parser() arguments = parser.parse_args() all_b_files = glob.glob(arguments.input_file_name[0]) output_dir = arguments.output_dir[0] for b_file in all_b_files: file_buffer = bytearray(b'') with open(b_file, 'rb') as bfile_h: check_file_header(bfile_h, len(b_hdr)) read_byte = bfile_h.read(1) file_buffer.extend(read_byte) while read_byte: read_byte = bfile_h.read(1) file_buffer.extend(read_byte) # Finding content sections signature check_result = check_signa(file_buffer) if check_result: if check_result == 'abdata': file_buffer = bytearray(b'') read_length = bfile_h.read(4) size = struct.unpack('<L', read_length)[0] file_buffer.extend(bfile_h.read(size)) # Adding _abdata to separate from other parts outfile_name = prepare_filename(b_file, output_dir, '_abdata') create_file(outfile_name, file_buffer) elif check_result == 'abimgdat': images_number = struct.unpack('B', bfile_h.read(1))[0] # Number of pictures in section for i1 in range(images_number): file_buffer = bytearray(b'') file_name = '' imgsec_hdr = bfile_h.read(signa_len) if imgsec_hdr == Abimgdat13.signa: file_name_size = struct.unpack('<H', bfile_h.read(Abimgdat13.name_size_len))[0] # Decode filename to utf8 file_name = bytes_shiftjis_to_utf8(bfile_h.read(file_name_size)) # CRC size hash_size = struct.unpack('<H', bfile_h.read(Abimgdat13.hash_size_len))[0] # Picture CRC (don't need it) pic_hash = bfile_h.read(hash_size) unknown1 = bfile_h.read(Abimgdat13.unknown1_len) unknown2 = bfile_h.read(Abimgdat13.unknown2_len) pic_size = struct.unpack('<L', bfile_h.read(Abimgdat13.data_size_len))[0] print("pic_size:", pic_size) file_buffer.extend(bfile_h.read(pic_size)) elif imgsec_hdr == Abimgdat14.signa: file_name_size = struct.unpack('<H', bfile_h.read(Abimgdat14.name_size_len))[0] file_name = bytes_shiftjis_to_utf8(bfile_h.read(file_name_size)) hash_size = struct.unpack('<H', bfile_h.read(Abimgdat14.hash_size_len))[0] pic_hash = bfile_h.read(hash_size) bfile_h.seek(Abimgdat14.unknown1_len, os.SEEK_CUR) pic_size = struct.unpack('<L', bfile_h.read(Abimgdat14.data_size_len))[0] file_buffer.extend(bfile_h.read(pic_size)) else: # probably abimgdat10, abimgdat11... file_name_size = struct.unpack('<H', bfile_h.read(Other_imgdat.name_size_len))[0] file_name = bytes_shiftjis_to_utf8(bfile_h.read(file_name_size)) hash_size = struct.unpack('<H', bfile_h.read(Other_imgdat.hash_size_len))[0] pic_hash = bfile_h.read(hash_size) bfile_h.seek(Other_imgdat.unknown1_len, os.SEEK_CUR) pic_size = struct.unpack('<L', bfile_h.read(Other_imgdat.data_size_len))[0] file_buffer.extend(bfile_h.read(pic_size)) for i, letter in enumerate(file_name): # Replace any unusable symbols from filename with _ if letter == '<' or letter == '>' or letter == '*' or letter == '/': file_name = file_name.replace(letter, "_") # Checking file signature and adding proper extension outfile_name = prepare_filename(b_file, output_dir, '_' + file_name + check_type(file_buffer)) create_file(outfile_name, file_buffer) file_buffer = bytearray(b'') elif check_result == 'absound': sound_files_number = struct.unpack('B', bfile_h.read(1))[0] for i2 in range(sound_files_number): file_buffer = bytearray(b'') file_name = '' sndsec_hdr = bfile_h.read(signa_len) if sndsec_hdr == Absnddat11.signa: file_name_size = struct.unpack('<H', bfile_h.read(Absnddat11.name_size_len))[0] file_name = bytes_shiftjis_to_utf8(bfile_h.read(file_name_size)) hash_size = struct.unpack('<H', bfile_h.read(Absnddat11.hash_size_len))[0] snd_hash = bfile_h.read(hash_size) unknown1 = bfile_h.read(Absnddat11.unknown1_len) snd_size = struct.unpack('<L', bfile_h.read(Absnddat11.data_size_len))[0] file_buffer.extend(bfile_h.read(snd_size)) else: file_name_size = struct.unpack('<H', bfile_h.read(Absnddat11.name_size_len))[0] file_name = bytes_shiftjis_to_utf8(bfile_h.read(file_name_size)) hash_size = struct.unpack('<H', bfile_h.read(Absnddat11.hash_size_len))[0] snd_hash = bfile_h.read(hash_size) unknown1 = bfile_h.read(Absnddat11.unknown1_len) snd_size = struct.unpack('<L', bfile_h.read(Absnddat11.data_size_len))[0] file_buffer.extend(bfile_h.read(snd_size)) for i, letter in enumerate(file_name): if letter == '<' or letter == '>' or letter == '*' or letter == '/': file_name[i] = '_' outfile_name = prepare_filename(b_file, output_dir, '_' + file_name + check_type(file_buffer)) print("create absound") create_file(outfile_name, file_buffer) file_buffer = bytearray(b'') 


Das Skript sollte die gefundenen PNG-, JPG-, BMP-, Ogg- und WAV-Dateien automatisch entpacken. Daneben befinden sich aber auch unbekannte Imoavi-Dateien im Inneren.

Das Fazit ist, dass im Spiel alle Animationen entweder als vollwertiges Video im ogv-Format oder als motoranimierte Bilder, die in .b-Dateien aufgezeichnet werden, oder als animierte Sequenzen von jpg-Dateien im imoavi-Format erstellt werden.

In diesem Fall waren wir auch an JPG-Bildern interessiert, also mussten wir uns auch damit befassen.

Es gibt zwei Abschnitte in imoavi: SOUND und MOVIE. Im Abschnitt FILM, 47 Bytes nach dem Header, befinden sich vier Bytes der JPG-Dateigröße. Dateien werden nacheinander in ihrer ursprünglichen Form geschrieben, getrennt durch eine Folge von 19 Bytes, wobei die Größe der nächsten Datei aufgezeichnet wird.

Das stimmhafte Imoavi im Spiel ist nicht aufgetaucht, daher ist der SOUND-Bereich immer leer.

Nun, da wir angefangen haben, alle Ressourcen des Spiels herauszuholen, wurde gleichzeitig ein kleines Skript geschrieben, um jpg aus imoavi herauszuholen.

Imoavi Extraktor
 # -*- coding: utf-8 -*- # Extract imoavi # Imoavi extractor for Bishoujo Mangekyou game files # by Chtobi and Nazon, 2016 import glob import os import struct import argparse imoavi_hdr = b'IMOAVI' hdr_len = len(imoavi_hdr) def create_file(file_name, out_buffer, wr_mode='wb'): if len(out_buffer) != 0: with open(file_name, wr_mode) as ext_file: ext_file.write(out_buffer) else: print("Zero file. Skipped.") def prepare_filename(file_name, out_dir, postfix=''): ready_name = out_dir + os.path.basename(file_name) + postfix return ready_name def create_parser(): arg_parser = argparse.ArgumentParser(prog='Imoavi extractor\n', usage='extract_imoavi input_file_name output_dir\n', description='Imoavi extractor for QLIE engine *.imoavi files.\n') arg_parser.add_argument('input_file_name', nargs='+', help="Input file with full path(wildcards are supported).\n") arg_parser.add_argument('output_dir', nargs='+', help="Output directory.\n") return arg_parser if __name__ == '__main__': parser = create_parser() arguments = parser.parse_args() all_imoavi = glob.glob(arguments.input_file_name[0]) output_dir = arguments.output_dir[0] for imoavi_f in all_imoavi: file_buffer = bytearray(b'') with open(imoavi_f, 'rb') as imoavi_h: # Read imoavi file header imoavi_h.read(hdr_len) imoavi_h.seek(2, os.SEEK_CUR) # 0x00 imoavi_h.seek(1, os.SEEK_CUR) # 0x64 imoavi_h.seek(3, os.SEEK_CUR) # 0x00 imoavi_h.seek(5, os.SEEK_CUR) # SOUND imoavi_h.seek(3, os.SEEK_CUR) # 0x00 imoavi_h.seek(1, os.SEEK_CUR) # 0x64 imoavi_h.seek(11, os.SEEK_CUR) imoavi_h.seek(5, os.SEEK_CUR) # Movie imoavi_h.seek(3, os.SEEK_CUR) # 00 ?? imoavi_h.seek(1, os.SEEK_CUR) # 0x64 imoavi_h.seek(3, os.SEEK_CUR) # 0x00 ?? imoavi_h.seek(4, os.SEEK_CUR) # ?? imoavi_h.seek(1, os.SEEK_CUR) # Number of jpg files in section imoavi_h.seek(4, os.SEEK_CUR) # 0x00 imoavi_h.seek(1, os.SEEK_CUR) # 0x05 ??? imoavi_h.seek(2, os.SEEK_CUR) # 0x00 ?? imoavi_h.seek(4, os.SEEK_CUR) # 720 ?? imoavi_h.seek(4, os.SEEK_CUR) # Full size without header? to_next_size = struct.unpack('<L', imoavi_h.read(4))[0] # Bytes till next header imoavi_h.seek(16, os.SEEK_CUR) # 0x00 jpg_size = struct.unpack('<L', imoavi_h.read(4))[0] imoavi_h.seek(4, os.SEEK_CUR) # 0x00 file_num = 0 file_buffer.extend(imoavi_h.read(jpg_size)) outfile_name = prepare_filename(imoavi_f, output_dir, '_' + (str(file_num)).zfill(3) + '.jpg') create_file(outfile_name, file_buffer) while to_next_size != 0: file_buffer = bytearray(b'') to_next_size = struct.unpack('<L', imoavi_h.read(4))[0] if to_next_size == 24: # 0x1C header for index part file_buffer.extend(imoavi_h.read(to_next_size)) outfile_name = prepare_filename(imoavi_f, output_dir, '_' + '.index') create_file(outfile_name, file_buffer, 'ab') # concatenate with index file else: imoavi_h.seek(2, os.SEEK_CUR) # unknown imoavi_h.seek(2, os.SEEK_CUR) # Unknown, almost always FF FF or FF FE file_num = struct.unpack('B', imoavi_h.read(1))[0] # File number imoavi_h.seek(11, os.SEEK_CUR) # 0x00 jpg_size = struct.unpack('<L', imoavi_h.read(4))[0] imoavi_h.seek(4, os.SEEK_CUR) # 0x00 file_buffer.extend(imoavi_h.read(jpg_size)) outfile_name = prepare_filename(imoavi_f, output_dir, '_' + (str(file_num)).zfill(3) + '.jpg') create_file(outfile_name, file_buffer) 


, , 1_タイトル画面ムービー.b imoavi.


.

, , . , , . , . .

- (, , ) : , , Renpy, ?
, , - , , .

?
.

Referenzen:

bitbucket

Qlie

Shift Jis

Shift Jis UTF-8

exfp3_v3 asmodean

Source: https://habr.com/ru/post/de426431/


All Articles