拆卸Qlie视觉小说引擎



与其他游戏的翻译相比,视觉短篇小说的业余翻译具有许多功能,并且涉及处理大量文本。 也许所有视觉小说中的绝大多数都是用日语发行的,只有少部分被翻译成英语(官方或业余),而很少被翻译成其他语言。

因此,在进行翻译时,您必须处理日语引擎,其中许多引擎对本地化人员不太友好。 因此,很快就会意识到翻译技能,语言知识,热情和空闲时间的存在并不意味着该游戏的翻译版本很快就会成为现实。

大致来说,翻译任何游戏(不仅仅是视觉短篇小说)的过程都意味着:

  • 解包游戏资源(如果它们不在公共领域中)
  • 必要部分的翻译
  • 转移反向包装

但是,对于日本的视觉短篇小说,通常如下所示:

  • 打开游戏资源
  • 游戏文字部分的翻译(游戏脚本)
  • 游戏图形部分的翻译
  • 转移反向包装
  • 修改引擎以使其能够处理翻译后的内容

希望我们的经验对某人有用。

早在2013年(可能还有更早的时候),我决定从日语中翻译视觉小说《美少女万华镜》(美少女万华镜-说の少女-)。 我已经有翻译游戏的经验,但是在翻译诸如Kirikiri之类的相对简单和知名的引擎之前,我只翻译过短篇小说。

在这里,我们的翻译团队不得不打开这个简短故事的引擎,甚至在我们不了解实际文本之前。

让我们从对.exe文件的描述开始,其中提到单词QLIE和IMOSURUME。 文件本身包含FastMM Borland Edition 2004、2005 Pierre le Riche行,这意味着该引擎最有可能用Delphi编写。



快速浏览后发现,Qlie是Warmth Entertainment发布的视觉小说引擎的名称。 显然,IMOSURUME是脚本引擎的内部名称,而Qlie是商业名称。 有一个网站qlie.net ,其中列出了在此引擎上发布的游戏以及Warmth Entertainment的官方网站。

但是,在公共领域中,没有任何地方可以使用该引擎的官方工具,也没有针对该引擎的文档。

因此,您必须自己依靠非官方的实用程序来处理游戏。 首先,您应该找到游戏中所有需要翻译的部分。

游戏存档位于\ GameData子文件夹中的data0.pack,data1.pack和data7.pack文件中。 屏幕保护程序位于\ GameData \ Movie文件夹中,但您仍然可以将其保留。


十六进制编辑器显示游戏.pack存档没有可识别的标头,但文件末尾有类似于目录和标签FilePackVer3.0的文件


幸运的是,对于这种格式,已经有一个解包器,甚至没有。 我们使用了asmodean的console exfp3_v3。

开箱并不像看起来那样容易。 由于引擎支持多种存档格式(FilePackVer1.0,FilePackVer1.0,FilePackVer3.0),并且在这种情况下使用FilePackVer3.0,因此为了正确解压缩,您还需要特殊的密钥文件key.fkey来对存档进行加密。 它位于\ Dll子文件夹中


另外,exfp3_v3应该阐明从哪个游戏中解压缩的档案。
因此,您还需要从解压缩程序建议的列表中指定游戏编号(Bishhoujo Mangekyou系列的游戏位于数字15下),或者将游戏可执行文件指定为解压缩程序的第三个参数。


解压缩游戏文件后,已经出现了一个合理的想法:将来如何将游戏打包成现成的翻译? 毕竟,解包程序不支持反向操作。
根据我们的要求,w8m(非常感谢您)增加了将游戏存档打包到其程序arc_conv.exe中的功能。 将所有更改的文件打包到一个新的存档中就足够了(例如,data8.pack),将其放置在GameData文件夹中,它们会自动将自己拉入游戏。

返回解压缩的资源。 data0.pack存档中的游戏脚本文件可以在\场景\ ks_01 \子文件夹中找到

所有扩展名为.s的脚本文件都以最便捷的Shift Jis编码进行编码,并且引擎不支持任何unicode编码。 翻译的行大致如下所示:

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

您可能会注意到,日语中的每个短语都在日语方括号中带有英雄名称。 (【】),该短语的发音(在游戏中,它会在窗口顶部显示文字)。 或者,如果这些是作者的话,则不会添加名称。


但是仍然有服务团队。

脚本中的引擎命令在某种程度上让人联想到TeX标记语言,但与KirikiriRenPy命令相比更加直观和不便。

以下是其中一些:

@@@是一只三重狗。 脚本文件通常以该命令开头。 显然是从第三方文件加载定义。

例如:

 @@@Library\Avg\header.s 

@@是双狗。 脚本文件中的标签。 您稍后可以切换到它。

%1_kiri1478% -播放语音文件。 这些命令将插入英雄名称和屏幕上显示的文本之间。 “ 1_kiri1478”-在这种情况下,是data1.pack文件的\ voice \文件夹中的文件名。有趣的是,团队使用的是日语百分比(%),而不是通常的百分比。

^savedate, ^saveroute, ^savescene, -三个团队最有可能在游戏的保存系统中使用,并且应该输入有关玩家在保存游戏中保存的时间和地点的信息。

例如:

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

即,日期:现在,分支:Bishoujo Mangekyou -1,场景:Norowareshi Densetsu no Shoujo开幕。 该数据应该已经显示在保存槽中,但是显然开发人员决定放弃它。 结果, ^saveroute在脚本的所有部分中都是^saveroute的, ^savedate从“当前时刻”到“梦想”的更改,并且在^savescene中,游戏中的天数(或夜晚)发生了变化。

^facewindow, -屏幕上显示文本的文本框的状态。 (显示-1或不显示-0)

^sload, -在相应通道的\ sound \文件夹中播放游戏中的声音。

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

在Env1上播放蝉

团队有两个可选参数,第一个负责循环声音,第二个仍然是个谜,但在游戏中很少使用。

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

在通道SE1上播放回送声音。

^eeffect在屏幕上显示特殊效果达一定的秒数。 显然,它支持几种效果的顺序输出。

 ^eeffect,WhiteFlash 

白色闪光的效果。

^ffade切换屏幕时的过渡效果。
它有很多附加参数,但实际上只有几个有用:过渡效果的名称,必要时附加的图片以及过渡完成时间。

 ^ffade,Overlap,,1000 

1秒内将一张照片溶解在另一张照片中。

^iload将背景图像加载到屏幕上。 可以为该图像分配一个ID,以供将来参考。

 ^iload,BG1,0_black.png 

输出文件0_black.png作为ID为BG1的背景

^we^wd打开和关闭窗口中的图像。

^facewindow,1^facewindow,0在对话框中打开和关闭英雄图像。

^mload在特定频道上播放音乐。

 ^mload,BGM1,nbgm13 

在通道BGM1上播放曲目nbgm13

一些最重要的团队:
\jmp跳转到具有指定名称的标签。

^select select-在屏幕上显示选择窗口,玩家必须在其中选择选项之一。

例如:

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

在这里,转换将在回答问题之后执行,并且从ResultBtnInt [0]返回响应号(0或1)。 结果, \jmp故事移动到标签@@ route01a +响应号。 也就是说,@@ route01a0或@@ route01a1

一个不愉快的功能是这些命令中的常用逗号用作分隔符,不能在答案选项本身中使用。 日语没有这种问题,他们使用日语逗号(,)。 在这种情况下,我们可以用,(U + 201A单低9引号)代替逗号。

例如:

 ^select, ‚  , ‚  

其余的团队在第一个近似中并不那么重要。

当然,在转换脚本之前,您应该将其转换为更方便的格式,例如在UTF-8中,以结合西里尔字母和日语字符。

更换引擎后(关于下一部分),游戏将同时感知俄语和日语。 但是就目前而言,为了兼容,您需要使用Shift Jis编码日文字符,并使用cp1251编码对西里尔字母进行编码。

考虑到西里尔字母,我们迅速用Python绘制了一个程序进行转码:

UTF8至cp1251和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.") 


但是,有一些问题。 该程序在尝试重新编码“波浪号”符号U(U + FF5E FULLWIDTH TILDE)时,产生错误“ UnicodeEncodeError:'Shift Jis'编解码器无法在位置0编码字符'\ uff5e':非法的多字节序列”

起初,我在Python上犯了罪,但最后我发现了一个不寻常的细微差别。 取决于特定的实现方式,Unicode和非Unicode日语编码的相关方法之间不确定。

结果,Windows根据官方Unicode比率表将Shift Jis字符与带有unicode〜(U + FF5E FULLWIDTH TILDE)的0x8160代码相关联,其他转码器(例如iconv实用程序)将同一字符与〜(U + 301C WAVE DASH)关联起来。 -ftp://ftp.unicode.org/Public/MAPPINGS/OBSOLETE/EASTASIA/JIS/SHIFT JIS.TXT

为了确定字符之间的对应关系,Microsoft显然决定使用其cp932编码中的方案,该方案是Shift Jis的扩展版本。

对于字符代码0x817C,也会发生相同的情况,在Windows上将其转换为-(U + FF0D全幅连字符-减号),或者在iconv中将其转换为UTF8-(U + 2212减号)。

由于首先使用记事本++将所有脚本文件从Shift Jis转换为UTF8(并且他使用Windows中采用的对应表),因此在通过我们的Python程序从UTF8转换为Shift Jis时,出现了臭名昭著的转换错误。

因此,有必要考虑〜和-分开情况的发生。

还有其他一些小缺陷-例如,省略号...(U + 2026水平省略号)被cp1251的西里尔省略号代替,而不是Shift Jis的日语。

翻译文本后,您可以继续使用游戏图形。

游戏的图形文件位于相同的压缩包中,但是解压缩后,它们仍然必须努力工作。 例如,几乎所有png图像都被解压缩为sample + DPNG000 + x32y0.png类型的文件。换句话说,将png图像切成88厘米厚的水平条,并将每个条写入单独的文件中。 文件名显示带的序列号(DPNG000 ... 009)和x,y坐标。


我仍然想知道为什么这是必要的。 如果由于难以从游戏中窃取资源,那么这显然不是最佳方法。

为了粘贴剪切的png文件,一次在asmodeus的Pearl上创建了一个小脚本merge_dpng,该脚本使用ImageMagick。 不幸的是,他有问题。 首先,我需要不使用的Pearl,即使安装了它,事实证明该脚本也无法正常工作。

因此,我们在python中编写了一个类似的程序:

Qlie Engine Dpng文件合并
 # -*- 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]) 


看来现在我们已经获得了游戏中出现的全部图片? 一点也不-如果您查看所有档案中的所有关联图片,尽管仍在游戏中,但仍会发现其中有些丢失。 事实是引擎中还有另一种文件-扩展名为.b。 这是一个动画,其中记录了图像和声音。

将资源存储在内部很容易,但是,可惜的是,在我们的案例中,没有一个现成的.b文件解压缩程序能够正常工作。 某些文件仍未解压缩,或者由于日语名称而导致错误,并且我不想从日语语言环境启动。

在这里,我们的脚本又是有用的。 从那时起,我们就不熟悉Kaitai Struct之类的东西,我们不得不从零开始。

.b文件的格式很简单,而且,我们的解压程序仅能解压缩此游戏中的资源。 在Qlie引擎上的其他游戏中,.b文件中还包含其他类型的资源,但我们将不对其进行详细介绍。

因此,在十六进制编辑器中打开任何.b文件,并寻找开始。 评估之前,请注意所有数字值的字节顺序均为Little-endian。

  • Abmp12文件头
  • 十字节0x00
  • 第一部分abdata12的标题以及开销信息。
  • 八个字节0x00
  • 段大小abdata12,四字节整数。 您可以安全地跳过它。
  • Abimage10节头
  • 七个字节0x00
  • 节中的文件数,单字节整数。 在这种情况下,该部分中只有一个文件。
  • 节标题abgimgdat13
  • 六个字节0x00
  • 节中文件名的长度,为两个字节的整数。 在这种情况下,长度为4个字节。
  • Shift Jis编码的文件名
  • 文件校验和记录长度,双字节整数。
  • 文件本身的校验和。
  • 未知字节似乎总是0x03或0x02
  • 十二个未知字节,可能与动画有关
  • 该部分内png文件的大小是一个四字节整数。

最后是png文件本身。


吸收区的结构与吸收区相似。

AnimatedBMP提取器
 # -*- 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'') 


该脚本应自动解压缩找到的png,jpg,bmp,ogg和wav文件。 但是除此之外,还可以在其中找到未知的imoavi文件。

最重要的是,在游戏中,所有动画均以ogv格式的完整视频,记录为.b文件的引擎动画图像或imoavi格式的jpg文件动画序列制成。

在这种情况下,我们也对jpg图像感兴趣,因此我们也必须处理它们。

imoavi中有两个部分:SOUND和MOVIE。 在“ MOVIE”部分中,标头之后的47个字节中,有四个jpg文件大小的字节。 文件以其原始格式一个接一个地写入,由19个字节的序列分隔,记录下一个文件的大小。

游戏中发声的imoavi没有出现,因此SOUND部分始终为空。

好吧,由于我们开始提取游戏的所有资源,因此同时编写了一个小脚本,从imoavi中提取jpg。

Imoavi提取器
 # -*- 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) 


解压缩后,可以确保菜单中启动画面中的动画仅以imoavi格式存储在文件1_ル画面。。。.b中。


这就是所有游戏资源。

不幸的是,翻译过程中发现了一些难以克服的细微差别。正如我已经写过的,该游戏不支持Unicode编码。因此,所有翻译的文本以错误的字母间距显示。在不将系统编码更改为日语的情况下,向后打包文件和启动游戏还有其他一些问题。

在某个时候,我们(或者更确切地说,是团队中负责翻译的技术部分的人)认为:也许我们不应该不使用旧引擎,而应该将小说移植到Renpy引擎上,同时获得跨平台的支持?
也许我们很着急,但是在某个时候,很遗憾退出我们开始的工作,除了完成翻译,别无他法。

在移植过程中我们遇到了什么?
在第二部分中对此进行讨论。

链接:

我们的bitbucket脚本

关于日语Qlie引擎

Shift Jis编码表

了解更多有关将Shift Jis转换为UTF-8的问题,请

参见asmodean exfp3_v3实用程序

Source: https://habr.com/ru/post/zh-CN426431/


All Articles