Galgame汉化中的逆向(七):动态汉化分析2_以AZsystem引擎为例 好久没发帖了,祝大家新年快乐~
by devseed , 本贴论坛和我的博客 同时发布
0x0 前言 上节 Galgame汉化中的逆向(六):动态汉化分析_以MAJIROv3引擎为例 ,我们介绍了动态汉化。动态汉化不用分析封包结构,不用分析opcode
,看上去很方便,但是动态汉化解决同步问题会很麻烦,比如说改完文本后backlog文本仍是日文、返回主界面再载入文本没有变动等问题。动态汉化也有可能出现莫名其妙的崩溃bug,且这些bug不容易被调试。
针对动态汉化的上述缺点,本节我们将介绍一种这种半动态汉化
的方案。与上节的方法不同,本节不进行文本级替换,而是文件级别的替换。即去hook
相关函数,动态将解密后的缓冲区替换为我们汉化后的文件。适合于那种封包与加密特别麻烦或复杂 的游戏。
本文将以azsystem
为例,来分析:
引擎如何加载游戏脚本,如何定位关键点提取脚本
引擎如何加载图片,如何解压各通道数据,如何将图片数据送入帧缓存渲染
汉化如何用inline hook
对加载后的内容进行替换
0x1 脚本文件分析与提取 (1) asb文件的分析 和上节相同,第一步先分析文件,无论静态分析算法还是动态dump缓冲区,先把文件提取出来。
由于方法差不多,这里不再详细展开了。
这个游戏封包为.arc
文件,用文件长度哈希值来作为加密密钥,里面有若干个.asb
脚本文件。IDA里面直接搜.asb
字符串就能找到相关函数了,读取脚本文件函数如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 int __thiscall sub_43112A (_DWORD *this, char *script_name) { char *raw_data; int v4; unsigned int v5; _DWORD *v7[4 ]; int v8; unsigned int compressed_size; unsigned int raw_size; int v11; int (__thiscall **v12)(void *, char ); char *compressed_data; int v14; v7[0 ] = off_460A6C; sub_40BD95(v7); v14 = 1 ; v12 = &off_462CDC; v11 = 0 ; sub_430FC9((int )this); if ( fopen_40C102(v7, script_name, 0x80000000 ) != 1 ) { logprintf_407C41("CScript::Create" , byte_4679CC, script_name); goto LABEL_13; } readfile_40C03E(v7, (char *)&v8, 0xC ); if ( v8 == 0x1A425341 ) { compressed_data = (char *)operator new(compressed_size); raw_data = (char *)operator new(raw_size); readfile_40C03E(v7, compressed_data, compressed_size); if ( sub_430F6A(compressed_data, compressed_size, raw_size) ) { v4 = decompress_40AB65(compressed_data, compressed_size, raw_data, raw_size); v5 = raw_size; if ( v4 == raw_size ) { this[4 ] = 0 ; this[1 ] = raw_data; this[2 ] = v5; this[3 ] = raw_data; this[5 ] = raw_data; v11 = 1 ; LABEL_10: if ( compressed_data ) j__free(compressed_data); goto LABEL_13; } logprintf_407C41("CScript::Create" , byte_467A38, script_name); } else { logprintf_407C41("CScript::Create" , byte_467A0C, script_name); } if ( raw_data ) j__free(raw_data); goto LABEL_10; } LABEL_13: v14 = -1 ; v12 = &off_462CDC; v7[0 ] = off_460A6C; sub_40BFDD(v7); return v11; }
简单分析后,我们可以得到asb
的文件头结构、校验文本函数、解压函数以下结论,具体如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 typedef struct { s8 magic[4 ]; u32 comprlen; u32 uncomprlen; u32 unknown; } asb_header_t ; typedef struct { s8 magic[4 ]; u32 comprlen; u32 uncomprlen; } asb1a_header_t ; void *__thiscall sub_43277F (_DWORD *this) BOOL __stdcall sub_430F6A (char *compressed_data, int compressed_size, int raw_size) sub_40AB65 (char *compressed_data, int compressed_len, char *raw_data, int raw_len) 0043112A | B8 9EE54500 | mov eax,lamune.45E59E |load_script (char * name) 004311D4 | FF75 E4 | push dword ptr ss:[ebp-1C] | raw_len 004311D7 | 8D4D EC | lea ecx,dword ptr ss:[ebp-14] 004311DA | 57 | push edi | raw_data 004311DB | FF75 E0 | push dword ptr ss:[ebp-20] | compressed_len 004311DE | FF75 F0 | push dword ptr ss:[ebp-10] | compressed_data 004311E1 | E8 7F99FDFF| call lamune.40AB65| decompress
(2) asb文件的解密与提取 提取只需要hooksub_40AB65
,frida代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 function install_decompress_hook (outdir='./dump' ){ const addr_decompress = ptr (0x40AB65 ); var raw_asbname = "" ; var raw_asbdata = ptr (0 ); var raw_asbsize = 0 ; Interceptor .attach (addr_decompress, { onEnter : function (args ) { raw_asbdata = ptr (args[2 ]); raw_asbsize = args[3 ].toUInt32 (); raw_asbname = ptr (this .context .ebp ).add (8 ). readPointer ().readAnsiString (); }, onLeave : function (retval ) { var asbname = raw_asbname; console .log (asbname, ", raw_asbdata addr at" , raw_asbdata, ", raw_asbsize " , raw_asbsize) try { var fp = new File (outdir+"/" +asbname, 'wb' ); fp.write (raw_asbdata.readByteArray (raw_asbsize)); fp.close (); } catch (e) { console .log ("file error!" , e); } } }) } function dump_asbs (names, outdir="./dump" ){ const addr_loadscript = ptr (0x43112A ); const load_script = new NativeFunction (addr_loadscript, 'void' , ['pointer' , "pointer" ], 'thiscall' ); console .log ("load_script at:" , load_script) var pthis = ptr (0 ) Interceptor .attach (addr_loadscript, { onEnter : function (args ) { pthis = ptr (this .context .ecx ) } }) install_decompress_hook (outdir) while (!pthis.toInt32 ()) { Thread .sleep (0.2 ); } var name_buf = Memory .alloc (0x100 ); for (var i=0 ;i<names.length ;i++) { console .log ("try to dump" , names[i], ", this=" ,pthis); name_buf.writeAnsiString (names[i]); load_script (pthis, name_buf); } console .log ("dump asbs finished!\n" ); } function dump_scenario ( ){ var names_v103 = ["00suzuk.asb" ] dump_asbs (names_v103) }
用其他工具如arc unpack
可以得到arc
封包的文件名,把文件名录入frida脚本,即可dump出全部asb
脚本。
0x2 动态替换脚本文件 (1) 替换解密的asb缓冲区 结合上面文件分析,我们可以在004311E1| E8 7F99FDFF| call lamune.40AB65| decompress
进行inlinehook
,在此直接加载我们已经解密并汉化的asb
文件。解密的缓冲区是前面new
出来的,我们还需要修改缓冲区大小。另外还要nop
掉缓冲区crc
校验的函数。
上节我们用了detours
,这期我们来手动inlinehook
,步骤如下:
在需要hook
的位置用5字节call(E9)
或 jmp(E8)
进行相对跳转到我们的函数上,
机器码为E8 XXXXXXXX
, E9 XXXXXXXX
。
XXXXXXXX
为相对于下一条指令的偏移,即targetva - (va + 5)
执行完后hook
的函数后,结尾手动修复一下被我们修改5字节破坏的代码,跳转到下个指令处。
动态替换解密后的缓冲区脚本代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 const DWORD g_newrawbufi_4311A2 = 0x4311A2 ;const DWORD g_newrawbufo_4311A8 = 0x4311A8 ;const DWORD g_decompressasbi_4311E1 = 0x4311E1 ;const DWORD g_decompressasbo_40AB65 = 0x40AB65 ;void __declspec(naked) newrawbuf_hook_4311A2(){ __asm{ pushad; xor eax, eax; push eax; push [ebp+8 ]; call load_rawasb; test eax, eax; je newrawbuf_hook_end; mov [ebp-0x1c ], eax; newrawbuf_hook_end: popad; push dword ptr [ebp-0x1c ]; mov dword ptr [ebp-0x10 ], eax; jmp dword ptr ds:[g_newrawbufo_4311A8]; } } void __declspec(naked) decompressasb_hook_4311E1(){ __asm { push [esp+0xc ]; push [ebp+0x8 ]; call load_rawasb; test eax, eax; je decompress_origin; ret 0x10 ; decompress_origin: mov eax, 0x99E15CB4 ; mov dword ptr ds:[0x0047E718 ], eax; jmp dword ptr ds:[g_decompressasbo_40AB65]; } } void install_asbhook () { BYTE nop2[0x2 ]={0x90 , 0x90 }; winhook_patchmemory((LPVOID)0x4311d2 , nop2, sizeof (nop2)); winhook_patchmemory((LPVOID)0x40AB9E , nop2, sizeof (nop2)); BYTE jmpE8buf[0x5 ]={0xE9 }; *(DWORD*)(jmpE8buf+1 ) = (DWORD)newrawbuf_hook_4311A2- ((DWORD)g_newrawbufi_4311A2 + sizeof (jmpE8buf)); winhook_patchmemory((LPVOID)g_newrawbufi_4311A2, jmpE8buf, sizeof (jmpE8buf)); BYTE callE9buf[0x5 ]={0xE8 }; *(DWORD*)(callE9buf+1 ) =(DWORD)decompressasb_hook_4311E1- ((DWORD)g_decompressasbi_4311E1 + sizeof (jmpE8buf)); winhook_patchmemory((LPVOID)g_decompressasbi_4311E1, callE9buf, sizeof (callE9buf)); }
上面代码中load_rawasb
即为我们读取对应解密文件的代码,这里为了减少零碎文件,我采取了从zip
文件中读取的方法。
此处不再赘述,详见我的github 。
(2) 修改sjis检测字节支持gbk编码 导入中文文本后,经测试发现一大堆半角乱码。
这是因为有sjis
首字节字符编码范围检测,不在sjis
范围内的字符将被解析为单字节文本。
与其他游戏不同,此游戏不是用cmp ax, 0x81
等指令来检测sjis
字符,而且位置过多过于分散,修改起来很麻烦。
这部分定位我们可以在TextOutA
下断点,往上慢慢找,可以看到下图位置:
这里非常巧妙,用一条c^0x20 + 0x5f > 0x3B
就可以判断是否为sjis首字符了,具体分析如下:
1 2 3 4 5 6 7 8 .text:004340F6 loc_4340F6: .text:004340F6 mov ecx, [ebp+74h+var_4] .text:004340F9 mov cl, [ecx] .text:004340FB mov dl, cl .text:004340FD xor dl, 20h .text:00434100 add dl, 5Fh ; '_' .text:00434103 cmp dl, 3Bh ; ';' .text:00434106 ja loc_434215
修改方法也很简单,把上面xor
和add
用nop
patch,编码检测改为cmp dl, 0x80
即可。
修改完后,虽然文本框正确了,但我们发现backlog
中文本还有乱码。
这时候就要在搜索其他地方的检测字符函数了,可以试着搜cmp al|bl|cl|dl, 0x3b
,逐个下断点,启动backlog
看哪里断下。
(3) asb opcode分析 以0nana.asb
为例,这个opcode是对齐的,很工整,如下图:
总结起来就是optype 4, oplengh 4, payload n
结构,超长文本只需要修正一下oplengh
和jmp
相关的指令就行了,如下:
1 2 3 4 5 6 7 8 9 10 optype 4 , oplengh 4 , payload n [26 |27 00 00 00 ], oplengh 4 , [00 ]*0x10 , optext [0 d 00 00 00 ], oplengh 4 , [00 ]*4 , option_num 4 , [00 ] * 8 , text1, 00 , text2 ... [0 a 00 00 00 ], [18 00 00 00 ] , addr 4 , [00 ]*4 , unknow1 4 , unknow2 4 [0b 00 00 00 ], [1 c 00 00 00 ] , addr 4 , [00 ]*4 , unknow1 4 , unknow2 4 00 00 00 00 FF FF FF FF FF FF FF FF 00 00 00 00 00 00 00 00 00 00 00 00
将测试文本导入后,我们可以完成超长文本的汉化测试了~
0x3 图片文件的加载和渲染分析 (1) 定位图片显示缓冲区 这个游戏是通过Windows compatible DC
进行绘图的,我们可以在CreateDIBinfo
下断点,然后一层层往上跟,找到在缓冲区填充像素的函数,之后bitblt
到帧缓冲位置。这里有个麻烦事,这游戏有很多虚函数通过虚表来寻址,如v3=(*(**v7+12))(*v7, v5, v10,a3
这种。静态跟起来很费劲,可以尝试动态来看虚表。由于跟踪过于繁琐了,具体流程从略了,callback
和具体调用流程如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 0019F D80 0040 EF75 50 return to lamune.sub_40EE6F+106 from ??? User 0019F DD0 00401E77 0040 EE6F 34 return to lamune.sub_401D0F+168 from lamune.sub_40 User0019F E04 0040955 D 24 return to lamune.sub_40951B+42 from ??? User0019F E28 0040686 C 0040951B 24 return to lamune.sub_406813+59 from lamune.sub_409 User0019F E4C 004383 EA 00406813 A4 return to lamune.EntryPoint+184 from lamune.sub_40 User0019F EF0 0043827 E 0043F 210 84 return to lamune.EntryPoint+18 from lamune.sub_43F UserDWORD __thiscall sub_42A199(int *this) | loadimg_419E03(off_473088, "neko_logo.cpb" , this + 0x214 ); | readcpb_40C03E((_DWORD **)this + 1 , cpb_header, 0x10 ); | (*(_DWORD *)*v7+4 ))(*v7, cpb_header) | (*(v8 + 0x3C ))(v10[4 ], v10[5 ]) | v3=(*(**v7+12 ))(*v7, v5, v10,a3); |return (*(*v6 + 0x10 ))(v6, a2, a4); |decompress2_40AA38(char *compressed_buf, size_t |compressed_size, char *raw_buf, size_t raw_len) | sub_40C9C1(DWORD *this, int a2, int a3, int *a4, DWORD *a5) |sub_4101EB(v9 + 2 , a2, a3, a4, *a5, a5[1 ], a5[2 ], a5[3 ], 0 ); |(*(this[2 ] + 0x48 ))(this + 2 , a2, a3, a4, *a5, a5[1 ], a5[2 ], a5[3 ], 0xCC0020 );
(2) cpb图片加载 上面我们来讲了一下定位方法,和整体加载流程。在这节我们来分析一下cpb
文件如何读取和加载渲染到屏幕上的。
.1 cpb结构 cpb
中像素是分通道存储的,数据结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 00000000 cpb1a_header_t struc ; (sizeof =0x20 , mappedto_128)00000000 ; XREF: decompresscpb_41E36F/r00000000 magic db 4 dup(?) ; string (C)00000004 unknow1 db ?00000005 color_depth db ?00000006 unknow2 db ?00000007 version db ?00000008 width dw ? ; XREF: decompresscpb_41E36F+39 /r0000000 A height dw ? ; XREF: decompresscpb_41E36F+3 E/r0000000 C max_comprlen dd ? ; XREF: decompresscpb_41E36F+56 /r00000010 comprlen dd 4 dup(?); XREF: decompresscpb_41E36F+93 /r00000010 ; decompresscpb_41E36F+B7/r ...00000020 cpb1a_header_t ends
.2 prepare DC 在渲染图片之前,游戏引擎先进行DC
的初始化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 void *__thiscall sub_40FDC2 (void **this, LONG width, int height) { void *result; HBITMAP v5; HDC dc; int (__thiscall **v7)(void **, _DWORD); BITMAPINFO pbmi; if ( (void *)width == this[41 ] && (void *)height == this[42 ] ) return (void *)(*((int (__thiscall **)(void **, _DWORD))*this + 26 ))(this, 0 ); (*((void (__thiscall **)(void **))*this + 13 ))(this); if ( width > 0 && height > 0 ) { memset (&pbmi, 0 , sizeof (pbmi)); pbmi.bmiHeader.biHeight = -height; pbmi.bmiHeader.biSize = 0x28 ; pbmi.bmiHeader.biWidth = width; pbmi.bmiHeader.biPlanes = 1 ; pbmi.bmiHeader.biBitCount = 32 ; pbmi.bmiHeader.biCompression = 0 ; v5 = CreateDIBSection(0 , &pbmi, 0 , this + 0x28 , 0 , 0 ); this[37 ] = v5; if ( v5 ) { dc = CreateCompatibleDC(0 ); this[0x27 ] = dc; if ( dc ) { this[0x26 ] = SelectObject(dc, this[0x25 ]); this[0x29 ] = (void *)width; this[0x2A ] = (void *)height; this[0x2E ] = (void *)(height - 1 ); v7 = (int (__thiscall **)(void **, _DWORD))*this; this[0x2B ] = 0 ; this[0x2C ] = 0 ; this[0x2D ] = (void *)(width - 1 ); result = (void *)v7[0x1A ](this, 0 ); this[0x52 ] = result; return result; } } (*((void (__thiscall **)(void **))*this + 0xD ))(this); } return 0 ; }
.3 load cpb 这部分是读取cpb
到内存里,并检验文件头等信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 int __thiscall loadimg_419E03 (_DWORD *this, char *filename, int *a3) { int v3; _DWORD **v5; _DWORD *v7; int v8; int v9; int v10[7 ]; char cpb_header[16 ]; int i; v3 = 0 ; if ( !filename || !a3 ) return 0 ; v5 = (this + 1 ); if ( fopen_40C102(this + 1 , filename, 0x80000000 ) != 1 ) { logprintf_407C41("CGraphicLoader::GDILoad" , "指定されたファイルが見つかりません [%s]" , filename); return 0 ; } readcpb_40C03E(this + 1 , cpb_header, 0x10 ); i = 0 ; v7 = this + 5 ; do { if ( *v7 ) { if ( (*(**v7 + 4 ))(*v7, cpb_header) == 1 ) { sub_40C0A0(v5, 0 , 0 ); memset (v10, 0 , sizeof (v10)); v3 = (*(**v7 + 8 ))(*v7, v5, v10); if ( v3 == 1 ) { v8 = *a3; v9 = v10[3 ] == 1 ? (*(v8 + 0x3C ))(v10[4 ], v10[5 ]) : (*(v8 + 0x38 ))(v10[4 ], v10[5 ]); v3 = v9; if ( v9 == 1 ) { sub_40C0A0(v5, 0 , 0 ); v3 = (*(**v7 + 12 ))(*v7, v5, v10, a3); if ( v3 == 1 ) break ; } } } } ++i; ++v7; } while ( i < 4 ); sub_40BFDD(v5); return v3; }
加载后,会根据通道数不同调用不同的解压缩函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 int __thiscall sub_41D453 (_DWORD *this, int a2, int a3, int a4) { int v6; int v8; int v9; v9 = 0 ; if ( (*(*a4 + 0x2C ))(a4) != 1 ) return 0 ; v6 = this[*(a3 + 0x18 ) + 1 ]; if ( !v6 ) return 0 ; if ( (*(*a4 + 0x1C ))(a4) == 8 ) { if ( *(a3 + 4 ) == 8 ) return (*(*v6 + 4 ))(v6, a2, a4); } else if ( (*(*a4 + 0x1C ))(a4) == 32 ) { v8 = *(a3 + 4 ); if ( v8 == 8 ) return (*(*v6 + 8 ))(v6, a2, a4); if ( v8 == 24 ) return (*(*v6 + 0xC ))(v6, a2, a4); if ( v8 != 32 || (*(*a4 + 0x30 ))(a4) != 1 ) return v9; return (*(*v6 + 0x10 ))(v6, a2, a4); } return v9; }
.4 decompress cpb 这个游戏有多个cpb
解压函数,对应着不同通道数的文件,这里以32位图为例分析。
注意这里vv1 = (*(*obja + 0xC))(obja)
中的vv1
值为prepare dc
中的v5 = CreateDIBSection(0, &pbmi, 0, this + 0x28, 0, 0)
此句的DIB缓冲区。
我们可以替换decompress_channel_40AA38
后的缓冲区为汉化后的图片,然后让游戏引擎帮我们复制到DIB
缓冲区内。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 int __thiscall decompresscpb32_41E36F (void *this, int *obj) { int v3; size_t pixels; char *raw_buf; char *pchanel1; int vv2; char *pchannel0; _BYTE *v11; _BYTE *v12; int v13; cpb1a_header_t cpb_header; int v16; int v17; char *v18; int width; char *compressed_buf; int pcurvv2; int i; char *pchanel3; _BYTE *vv1; int j; int v27; char *obja; char *pchanel2; v27 = 0 ; j = 0 ; if ( readcpb_40C03E(obj, cpb_header.magic, 0x20 ) ) { v3 = *obja; width = cpb_header.width; i = cpb_header.height; pixels = cpb_header.width * cpb_header.height; v17 = (*(v3 + 0x24 ))(obja); compressed_buf = operator new(cpb_header.max_comprlen); raw_buf = operator new(4 * pixels); pchanel1 = &raw_buf[pixels]; pchanel2 = &raw_buf[pixels + pixels]; pchanel3 = &pchanel2[pixels]; vv1 = (*(*obja + 0xC ))(obja); vv2 = (*(*obja + 0x20 ))(obja); pcurvv2 = vv2; if ( readcpb_40C03E(obj, compressed_buf, cpb_header.comprlen[0 ]) && decompress_channel_40AA38(compressed_buf, cpb_header.comprlen[0 ], raw_buf, pixels) != -1 && readcpb_40C03E(obj, compressed_buf, cpb_header.comprlen[1 ]) && decompress_channel_40AA38(compressed_buf, cpb_header.comprlen[1 ], pchanel1, pixels) != -1 && readcpb_40C03E(obj, compressed_buf, cpb_header.comprlen[2 ]) && decompress_channel_40AA38(compressed_buf, cpb_header.comprlen[2 ], pchanel2, pixels) != -1 && readcpb_40C03E(obj, compressed_buf, cpb_header.comprlen[3 ]) && decompress_channel_40AA38(compressed_buf, cpb_header.comprlen[3 ], pchanel3, pixels) != -1 ) { if ( i > 0 ) { pchannel0 = &pchanel1[-pixels]; ++vv1; j = i; do { if ( width > 0 ) { v11 = vv1; v16 = pchanel2 - pchanel1; v18 = (pchanel3 - pchanel1); v12 = pchanel1; v13 = pcurvv2 - pchanel1; i = width; do { v11[1 ] = v12[pchannel0 - pchanel1]; *v11 = *v12; *(v11 - 1 ) = v12[v16]; v12[v13] = v12[v18]; ++v12; v11 += 4 ; --i; } while ( i ); } pchanel2 += width; pchanel3 += width; vv1 += 4 * width; pcurvv2 += v17; pchanel1 += width; pchannel0 += width; --j; } while ( j ); } j = 1 ; } if ( raw_buf ) j__free(raw_buf); if ( compressed_buf ) j__free(compressed_buf); } return j; }
解压各通道算法,看起来有点像lzss
改版?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 int __stdcall decompress_channel_40AA38 (char *compressed_buf, size_t compressed_size, char *raw_buf, size_t raw_len) { char *v5; char *v6; char *v7; char *v8; unsigned int v9; signed int v10; unsigned int v11; char *v12; char v13; bool v14; unsigned int v15; signed int dstsizea; if ( *(compressed_buf + 4 ) > raw_len ) return -1 ; v5 = compressed_buf + 20 ; v6 = &compressed_buf[*(compressed_buf + 1 ) + 20 ]; v7 = &v6[*(compressed_buf + 2 )]; dstsizea = *(compressed_buf + 4 ); v8 = raw_buf; v15 = 0x80808080 ; do { if ( (v15 & *v5) != 0 ) { v9 = *v6; v6 += 2 ; v10 = (v9 >> 13 ) + 3 ; qmemcpy(v8, &v8[-(v9 & 0x1FFF ) - 1 ], v10); v8 += v10; } else { v11 = *v7 + 1 ; v12 = v7 + 1 ; v10 = v11; qmemcpy(v8, v12, v11); v7 = &v12[v11]; v8 += v11; } v13 = v15 & 1 ; v15 = __ROR4__(v15, 1 ); if ( v13 ) ++v5; v14 = dstsizea <= v10; dstsizea -= v10; } while ( !v14 ); return v8 - raw_buf; }
.5 bitblt screen dc 最后再通过bitblt
到屏幕帧缓存中,至此整个游戏图片渲染分析完毕。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 BOOL __thiscall sub_4123E1 (void *this, int x, int y, int a4, int x1, int a6, int a7, int a8, DWORD rop) { int v10; int v11; int v12; int y1; int v14; int x_c; HDC hdc; HDC srchdc; v10 = a7 - x1 + 1 ; v11 = a8 - a6 + 1 ; if ( x >= 0 ) { if ( x + v10 > (*(*this + 16 ))(this) ) { if ( (*(*this + 16 ))(this) - x <= 0 ) return 0 ; a7 = (*(*this + 16 ))(this) + x1 - x - 1 ; } } else { if ( v10 + x <= 0 ) return 0 ; x1 -= x; v12 = (*(*this + 16 ))(this) + x1 - 1 ; if ( v12 < a7 ) a7 = v12; x = 0 ; } if ( y >= 0 ) { if ( y + v11 > (*(*this + 20 ))(this) ) { if ( (*(*this + 20 ))(this) - y <= 0 ) return 0 ; a8 = (*(*this + 20 ))(this) + a6 - y - 1 ; } y1 = a6; } else { if ( v11 + y <= 0 ) return 0 ; y1 = a6 - y; v14 = (*(*this + 20 ))(this) + a6 - 1 - y; if ( v14 < a8 ) a8 = v14; y = 0 ; } x_c = a7 - x1 + 1 ; if ( x_c > 0 && a8 - y1 + 1 > 0 ) { srchdc = (*(*a4 + 4 ))(a4); hdc = (*(*this + 4 ))(this); return BitBlt(hdc, x, y, x_c, a8 - y1 + 1 , srchdc, x1, y1, rop); } return 0 ;
0x4 动态替换图片文件 为了搞明白这个游戏游戏引擎图像如何渲染的,我把很多的虚函数都跟了一遍。
其实汉化图片只需要逆向到如何解压cpb
文件那里就足够了。这个游戏麻烦地方在于不同通道对应的不同处理函数,要依次来hook
替换缓冲区。另外在读取文件适合要记录一些文件名,用于缓冲区动态替换我们汉化的图片。
以24位图片代码替换为例,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 const char * g_curcpbname = NULL ;const DWORD g_copycpb24i_41E2DB = 0x41E2DB ;const DWORD g_copycpb24o_41E2E0 = 0x41E2E0 ;void __declspec(naked) loadcpb_hook_419E03(){ __asm { push eax; mov eax, dword ptr [esp+8 ]; mov g_curcpbname, eax; pop eax; push ebp; mov ebp, esp; sub esp, 0x2c ; jmp dword ptr ds:[g_loadcpbo_419E09]; } } void __declspec(naked) copycpb24_hook_41E2DB(){ __asm { pushad; push [ebp-0x20 ]; push g_curcpbname; call load_rawcpb; popad; mov edx,dword ptr [ebp+0xC ]; mov eax,edi; jmp dword ptr ds:[g_copycpb24o_41E2E0]; } } void install_cpbhook () { BYTE jmpE8buf[0x5 ]={0xE9 }; *(DWORD*)(jmpE8buf+1 ) = (DWORD)loadcpb_hook_419E03- ((DWORD)g_loadcpbi_419E03 + sizeof (jmpE8buf)); winhook_patchmemory((LPVOID)g_loadcpbi_419E03, jmpE8buf, sizeof (jmpE8buf)); *(DWORD*)(jmpE8buf+1 ) = (DWORD)copycpb24_hook_41E2DB- ((DWORD)g_copycpb24i_41E2DB + sizeof (jmpE8buf)); winhook_patchmemory((LPVOID)g_copycpb24i_41E2DB, jmpE8buf, sizeof (jmpE8buf)); }
这里采取的是png
格式存储的汉化图片,为了方便用了stb 进行加载。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 size_t __stdcall load_rawcpb (char *name, PBYTE buf) { char path[MAX_PATH] = {SYSGRAPH_DIR "/" "\0" }; strcat (path, name); strcpy (path + strlen (path)- strlen (SYSGRAPH_EXT),SYSGRAPH_EXT); int width, height, channel; printf ("load_rawcpb(%s, %p)" , path, buf); size_t entry_size = load_arc_entry(path, NULL ); const BYTE *tmpbuf = (BYTE*)malloc (entry_size); load_arc_entry(path, (PBYTE)tmpbuf); char * img = (char *)stbi_load_from_memory(tmpbuf, entry_size, &width, &height, &channel, 0 ); free ((void *)tmpbuf); if (!img) { printf (" not found!\n" ); return 0 ; } printf (" width=%d, heigth=%d, channel=%d\n" , width, height, channel); for (int y=0 ;y<height;y++) { for (int x=0 ;x<width;x++) { char r = *(img + channel * (width*y + x) + 0 ); char g = *(img + channel * (width*y + x) + 1 ); char b = *(img + channel * (width*y + x) + 2 ); *(buf + 0 *height*width + width*y+x) = r; *(buf + 1 *height*width + width*y+x) = g; *(buf + 2 *height*width + width*y+x) = b; if (channel==4 ) { char a = *(img + channel * (width*y + x) + 3 ); *(buf + 3 *height*width + width*y+x) = a; } } } stbi_image_free(img); return width*height*channel; }
加载后遇到渲染bug,我们把对应缓冲区dump出来放到ct2中进行查看,确定原因。
这里发现原来是stbi_load_from_memory
函数对于tga
格式有些问题,换成png
格式最后参数为0,问题解决。
至此,图片汉化问题全部解决。
0x5 后记 这个游戏我逆向了一周多把引擎的加载方式搞明白了,之后又测试导入翻译断断续续修复bug一个月,基本上汉化完美了。这里有个坑,通关后没法打开gallary
。这是官方的bug,下载了升级补丁可以修复。但是之前给我的文件是初版游戏,我说基于这个版本分析的。还得把旧版搬到新版上,非常麻烦。这个故事告诉我们,以后汉化要第一时间检查更新补丁。
整体来讲,这游戏有三大难点。难点之一在封包上,有加密和校验非常麻烦,因此我们采取了动态替换解密后的缓冲区;其二,图像缓冲区不好找,里面有大量虚函数,需要一点点跟;其三,sjis
字符检测过于分散,需要手动一个个调整,而且也是用非主流方式判断的。因此,我认为此游戏比较适合半动态汉化
。这种基于文件的替换方式可以免去复杂的封包,同时相比文本层面上的全动态汉化,可以更方便调试,少引发一些文本同步之类的问题。
另外我用stb 加载图片,这里遇到了问题,xp上运行会崩溃。
调试定位在了mov eax, large fs:2Ch
上,这是因为这个库用了__declspec(thread)
,在win xp
上LoadLibrary
遇到tls
就会崩,定义宏#define STBI_NO_THREAD_LOCALS
即可解决。
然后进行了若干测试,我这个汉化兼容补丁性还不错~ win xp
, win7
, win8
, win10
甚至连linux wine
,exagear
都测试了,可以说是全平台兼容了~ 完结撒花~