我前两天在NetOA方面确实是有点懈怠了。不为别的,正是为了这篇将提到的脚本的分析。虽然没把分析做彻底,不过我觉得现在已经足够使用,顺便拿出来说说。
上个周末,汉公突然跟我提起FFDSystem的话题,然后有人联系我做Quartett!的汉化。自从跟汉公和明大合作参与汉化以来,我基本上就是做脚本处理的相关工作比较多;汉公解决破解的棘手问题,而明大主要完成打包问题,也兼做脚本编辑器,视具体分工而定。这次也不例外,汉公主攻了资源文件的破解和资源抽取,资源的打包还没做,脚本这块就暂时交给了我。一般,如果脚本是没经过处理的文本,那也就没我什么事了;这次遇到的果然还是经过处理了的二进制脚本。
一拿到已经从Script.dat中提取出来的脚本文件,我吓了一跳:文件名居然都是MD5……汉公那边果然还没把资源破解完善。不过没关系,只要文件内容是对的就能开工。可以确认的是,脚本(准确说是给到我手上的脚本)的后缀名是tkn。
打开其中的第一个文件,0a69b4afebd6d64527a21e3f1aa993f9.tkn。内容如下:
Offset 0 1 2 3 4 5 6 7 8 9 A B C D E F
00000000 54 4F 4B 45 4E 53 45 54 64 00 00 00 76 08 00 00 TOKENSETd...v...
00000010 0C 00 00 00 85 23 00 0C 00 00 00 81 62 61 73 65 ....?.....|ase
00000020 5F 70 61 74 68 00 0C 00 00 00 83 2E 2E 2F 00 16 _path.....?./..
00000030 00 00 00 85 23 00 16 00 00 00 81 69 6E 63 6C 75 ...?.....(nclu
00000040 64 65 00 16 00 00 00 83 53 63 72 69 70 74 2F 42 de.....ゴcript/B
00000050 61 73 65 49 6E 73 74 72 75 63 74 69 6F 6E 2E 74 aseInstruction.t
00000060 78 74 00 20 00 00 00 81 6D 6F 74 69 6F 6E 00 20 xt. ...[otion.
00000070 00 00 00 81 4D 61 69 6E 00 20 00 00 00 85 28 00 ...`ain. ...?.
读起来似乎很郁闷(?),其实看到有那么多ASCII字符我已经很开心了。可以辨认出最开头的TOKENSET(但此时还无法判断那个d是什么)、ase_path、nclude等等。进一步观察可以发现那些看似被剪掉了的字符都在,前面的base_path、include就是如此。编辑器里显示不出来只是因为大于0x7F的字节被解释成双字节字符编码(DBCS)中一个双字节字符的首字节,也就是例如说0x81把base_path中的b(0x62)给“吃”了。
在上述截图范围内,我总共识别出了这些:base_path、include、Script/BaseInstruction.txt、motion、Main等字串。观察它们前后的规律:这些字串总是以0结尾,是标准的C string;这些字串的前面总是有一个大于0x7F的字节(留意到0x81和0x83),而在那个字节之前似乎总是有3个00字节,前面又是一个非00的字节。
为了方便分析,我写了一个小程序来抽取出我感兴趣的信息,辅助分析。
对应上面内容而提出出来的内容:
(格式是:字符串起始地址 一个奇怪的数字 字符串之前的那个字节 字符串内容)
0x1C 0xC 0x81 base_path
0x2B 0xC 0x83 ../
0x3B 0x16 0x81 include
0x48 0x16 0x83 Script/BaseInstruction.txt
0x68 0x20 0x81 motion
0x74 0x20 0x81 Main
经观察,发现字符串之前的那个字节似乎是某种操作码或者类型,而再前面的那个似乎是个什么奇怪的数字,会连续有好几个相同的,然后又增大一点。
接下来,突然发觉原来0x85也是个重要的数值;也有以这个数值打头的字符串,但一般都是长度为一的符号,所以先前被忽略了。想了想,干脆把0x80开始到0x88开头的,其之前是三个00的东西全部都扫描一遍。于是在之前的程序上修改了一下判断条件,得到下面代码:
opcode_analysis.cs:
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace FFDSystemAnalysis
{
sealed class Analyzer
{
private static readonly byte[ ] SIGNATURE = {
( byte )0x54, ( byte )0x4F, ( byte )0x4B, ( byte )0x45,
( byte )0x4E, ( byte )0x53, ( byte )0x45, ( byte )0x54,
( byte )0x64, ( byte )0x0, ( byte )0x0, ( byte )0x0
};
static void Main( string[ ] args ) {
if ( !args[ 0 ].EndsWith( ".tkn" ) ) return;
if ( !File.Exists( args[ 0 ] ) ) return;
string infile = args[ 0 ];
string outfile = infile + ".txt";
Encoding utf16le = new UnicodeEncoding( false, true );
Encoding jis = Encoding.GetEncoding( 932 );
using ( BinaryReader reader = new BinaryReader( File.OpenRead( infile ), jis ) ) {
using ( BinaryWriter writer = new BinaryWriter( File.Create( outfile ), utf16le ) ) {
byte[ ] sig = reader.ReadBytes( SIGNATURE.Length );
if ( !Equals( sig, SIGNATURE ) ) {
Console.WriteLine( "Wrong signature" );
return;
}
// write UTF-16LE BOM
writer.Write( ( ushort ) 0xFEFF );
Queue<byte> queue = new Queue<byte>( 3 );
queue.Enqueue( reader.ReadByte( ) );
queue.Enqueue( reader.ReadByte( ) );
queue.Enqueue( reader.ReadByte( ) );
byte lastOpcode = 0;
while ( reader.BaseStream.Position < reader.BaseStream.Length ) {
byte currentByte = reader.ReadByte( );
if ( currentByte == 0x080
|| currentByte == 0x081
|| currentByte == 0x082
|| currentByte == 0x083
|| currentByte == 0x084
|| currentByte == 0x085
|| currentByte == 0x086
|| currentByte == 0x087
|| currentByte == 0x088 ) {
if ( MatchQueueData( queue ) ) {
long position = reader.BaseStream.Position;
string line = ReadCString( reader );
Entry e = new Entry( ) {
position = position,
opcode = currentByte,
lastOpcode = lastOpcode,
line = line
};
writer.Write(
utf16le.GetBytes(
string.Format( "{0}{1}",
e.ToString( ),
Environment.NewLine )
)
);
} // if
} // if
// re-initialize
lastOpcode = queue.Dequeue( );
queue.Enqueue( currentByte );
} // while
} // using
} // using
} // Main
static bool Equals( byte[ ] a, byte[ ] b ) {
int len = a.Length;
if ( len != b.Length ) return false;
for ( int i = 0; i < len; i++ ) {
if ( a[ i ] != b[ i ] ) return false;
}
return true;
}
static bool MatchQueueData( Queue<byte> queue ) {
byte[ ] array = queue.ToArray( );
return Equals( zeros, array );
}
static string ReadCString( BinaryReader reader ) {
StringBuilder builder = new StringBuilder( );
char c = '\0';
while ( ( c = reader.ReadChar( ) ) != '\0' ) {
builder.Append( c );
}
return builder.ToString( );
}
static readonly byte[ ] zeros
= new byte[ ] { 0, 0, 0 };
}
struct Entry
{
public long position;
public string line;
public byte opcode;
public byte lastOpcode;
public override string ToString( ) {
return string.Format( "0x{0:X} 0x{1:X} 0x{2:X} {3}",
this.position, this.lastOpcode, this.opcode, this.line );
}
}
}
这段代码本身没什么稀奇,只有第57行到62行的内容有点诡异:居然把变量赋值给自己了?
不不,再怎么说我也不可能犯这种错误。这其实是C# 3.0里的一个有趣语法,initializer。可以通过initializer,在使用new关键字构造新实例的时候指定其中一些字段的值;等号左边的是字段名,右边则是字面量或者变量名(或者表达式)。编译器能够正确识别出看似是同名字的token之间的区别,因而能够正确赋值。好吧我承认这不是好的编程习惯,大家看到了千万不要学,要引以为戒……
另外,那个if里一大堆对currentByte的判断后来也重构到外面一个单独的MatchOpcode()方法里去了。像上面这样写实在太恶心……也要引以为戒哦
虽然没什么稀奇,还是说下这个文件里的流程:
1、检查作为参数文件是否存在,并且是否后缀为tkn。检查不通过则退出程序。
2、获取一个Shift-JIS和一个UTF-16LE字符集的Encoding实例,并使用它们创建Shift-JIS的输入流和UTF-16LE的输出流。
3、校验脚本文件的特征码(signature)。这里假设头12个字节都是特征码。
4、校验成功后,给输出流写出一个字节序标记(BOM,Byte Order Mark)。这本来应该不需要手工做的,但我一直没弄清楚为什么我明明在创建utf16le时指定要BOM系统却不帮我自动做……
5、创建一个队列来记录最近的三个字节。使用一个变量(lastOpcode)来记录最近的第四个字节。
6、扫描文件直到遇到文件结束。如果遇到了连续的3个00,则读入其后的一个字节,并判断是否在[0x80, 0x88]的范围内;满足的话则读入一个C string并输出记录。
7、程序结束。
于是我得到了更新版的记录:
(格式与前面相同)
0x15 0xC 0x85 #
0x1C 0xC 0x81 base_path
0x2B 0xC 0x83 ../
0x34 0x16 0x85 #
0x3B 0x16 0x81 include
0x48 0x16 0x83 Script/BaseInstruction.txt
0x68 0x20 0x81 motion
0x74 0x20 0x81 Main
于是我恍然大悟:那“奇怪的数字”居然是脚本源文件行号!而被认为是操作码或者类型的那个字节,则用于指定后面字符串的类型:可以是符号、十进制数字、十六进制数字、标识符、字符串、符号等。形象点看,如下图:
(红色的是行号,黄色的是类型,绿色的是字符串内容)
但位于脚本的0xC到0xF的那个数字(上图紫色部分)是什么意思还让我伤了下脑筋。观察了一下,发现从0a69b4afebd6d64527a21e3f1aa993f9.tkn提取出来的“东西”一共有1237个,而那意义不明的数字是0x876 = 2166,还差了不少。但总觉得它们应该有关系。突然想起我前面是用了个很糟糕的办法来提取记录,有连续的3个00字节才满足条件。但假如行号超过了0xFF = 255行的话这个条件就不成立了。赶紧把程序修改为第三版,按照新的理解去读入“行号”和“类型”两个数据,确认那个数字确实就是文件里总的token数。
然后我才理解了signature里那TOKENSET的含义……这看似是二进制的脚本其实根本没有编译过的二进制脚本之魂。
编译的前端至少有两部,scan和parse。Scan阶段处理词法分析,会把源文件切分成一个个token,而parse阶段处理文法分析,会根据上下文无关文法来尝试“理解”这些token,构造语法树(进而构造抽象语法树)。但这里我所看到的脚本只对脚本源文件做了scan,然后直接把scan的结果保存成“二进制脚本”了。真够OTL的。
简单点说,这个“二进制脚本”完整保留了脚本源文件的文本信息,而且还多加了些行号、类型等信息进去。缺少的是被去除了的注释。
那就很好办了不是么。于是把所谓的反编译程序写了出来:
ScriptDecompiler.cs
// ScriptDecompiler.cs, 2007/12/18
// by RednaxelaFX
/*
* Copyright (c) 2007 着作权由RednaxelaFX所有。着作权人保留一切权利。
*
* 这份授权条款,在使用者符合以下三条件的情形下,授予使用者使用及再散播本
* 软件包装原始码及二进位可执行形式的权利,无论此包装是否经改作皆然:
*
* * 对于本软件源代码的再散播,必须保留上述的版权宣告、此三条件表列,以
* 及下述的免责声明。
* * 对于本套件二进位可执行形式的再散播,必须连带以文件以及/或者其他附
* 于散播包装中的媒介方式,重制上述之版权宣告、此三条件表列,以及下述
* 的免责声明。
* * 未获事前取得书面许可,不得使用RednaxelaFX之名称,
* 来为本软件之衍生物做任何表示支持、认可或推广、促销之行为。
*
* 免责声明:本软件是由RednaxelaFX以现状("as is")提供,
* 本软件包装不负任何明示或默示之担保责任,包括但不限于就适售性以及特定目
* 的的适用性为默示性担保。RednaxelaFX无论任何条件、
* 无论成因或任何责任主义、无论此责任为因合约关系、无过失责任主义或因非违
* 约之侵权(包括过失或其他原因等)而起,对于任何因使用本软件包装所产生的
* 任何直接性、间接性、偶发性、特殊性、惩罚性或任何结果的损害(包括但不限
* 于替代商品或劳务之购用、使用损失、资料损失、利益损失、业务中断等等),
* 不负任何责任,即在该种使用已获事前告知可能会造成此类损害的情形下亦然。
*/
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace FFDSystemAnalysis
{
enum TokenType
{
Decimal = 0x080,
Identifier = 0x081,
Hexadecimal = 0x082,
String = 0x083,
Operator = 0x085
}
sealed class ScriptDecompiler
{
private static readonly byte[ ] SIGNATURE = {
( byte )0x54, ( byte )0x4F, ( byte )0x4B, ( byte )0x45,
( byte )0x4E, ( byte )0x53, ( byte )0x45, ( byte )0x54,
( byte )0x64, ( byte )0x0, ( byte )0x0, ( byte )0x0
};
static void Main( string[ ] args ) {
if ( !args[ 0 ].EndsWith( ".tkn" ) ) return;
if ( !File.Exists( args[ 0 ] ) ) return;
string infile = args[ 0 ];
string outfile = Path.GetFileNameWithoutExtension( infile ) + ".txt";
Encoding utf16le = new UnicodeEncoding( false, true );
Encoding jis = Encoding.GetEncoding( 932 );
using ( BinaryReader reader = new BinaryReader( File.OpenRead( infile ), jis ) ) {
using ( BinaryWriter writer = new BinaryWriter( File.Create( outfile ), utf16le ) ) {
byte[ ] sig = reader.ReadBytes( SIGNATURE.Length );
if ( !Equals( sig, SIGNATURE ) ) {
Console.WriteLine( "Wrong signature" );
return;
}
// write UTF-16LE BOM
writer.Write( ( ushort ) 0xFEFF );
// process each token
int lineNum = 1;
int lastLineNum = 1;
TokenType tokenType = TokenType.Operator;
TokenType lastTokenType = TokenType.Operator;
int tabCount = 0;
int tokenCount = reader.ReadInt32( );
for ( int tokenNum = 0; tokenNum < tokenCount; ++tokenNum ) {
// deal with line numbers, insert empty new lines if needed
lineNum = reader.ReadInt32( );
if ( lastLineNum < lineNum ) { // should write on a newline
// write empty lines
for ( int i = lastLineNum; i < lineNum; ++i ) {
writer.Write( utf16le.GetBytes( Environment.NewLine ) );
}
// write tabs as indent
for ( int tabs = 0; tabs < tabCount; ++tabs ) {
writer.Write( utf16le.GetBytes( "\t" ) );
}
// put a dummy value into tokenType
lastTokenType = TokenType.Operator;
}
// get token tokenType
tokenType = ( TokenType ) ( reader.ReadByte( ) & 0x0FF );
// get token value
string tokenString = ReadCString( reader );
// deal with different token types
if ( !( lastTokenType == TokenType.Operator
|| lastTokenType == TokenType.String
|| lastTokenType == TokenType.Decimal
|| lastTokenType == TokenType.Hexadecimal ) ) {
writer.Write( utf16le.GetBytes( " " ) );
}
switch ( tokenType ) {
case TokenType.Decimal:
case TokenType.Identifier:
case TokenType.Hexadecimal:
writer.Write( utf16le.GetBytes( tokenString ) );
break;
case TokenType.String:
writer.Write(
utf16le.GetBytes( string.Format( "\"{0}\"", tokenString ) ) );
break;
case TokenType.Operator:
switch ( tokenString ) {
case "#":
case "%":
case "-":
case "@":
writer.Write( utf16le.GetBytes( tokenString ) );
break;
case "{":
++tabCount;
writer.Write( utf16le.GetBytes( tokenString ) );
break;
case "}":
--tabCount;
writer.BaseStream.Position -= 2; // delete the last tab
writer.Write( utf16le.GetBytes( tokenString ) );
break;
case "(":
case ",":
case ";":
case "=":
writer.Write(
utf16le.GetBytes( string.Format( "{0} ", tokenString ) ) );
break;
case ")":
writer.Write(
utf16le.GetBytes( string.Format( " {0}", tokenString ) ) );
break;
} // switch tokenString
break;
default:
Console.WriteLine( "Unexpected token type {0} at 0x{1}.",
tokenType.ToString( "X" ),
reader.BaseStream.Position.ToString( "X" ) );
return;
} // switch tokenType
// re-initialize
lastLineNum = lineNum;
lastTokenType = tokenType;
} // for
}
}
}
static bool Equals( byte[ ] a, byte[ ] b ) {
int len = a.Length;
if ( len != b.Length ) return false;
for ( int i = 0; i < len; i++ ) {
if ( a[ i ] != b[ i ] ) return false;
}
return true;
}
static string ReadCString( BinaryReader reader ) {
StringBuilder builder = new StringBuilder( );
char c = '\0';
while ( ( c = reader.ReadChar( ) ) != '\0' ) {
builder.Append( c );
}
return builder.ToString( );
}
}
}
中间有些代码是为了插入缩进的,忽略那部分吧……
得到的脚本看起来像是这样:
#base_path "../"
#include "Script/BaseInstruction.txt"
motion Main ( )
中间是有很多空行没错。那些原本应该是有注释的地方,或者本身就是空行(为了让代码好看)。这里我只是把原始脚本的状态尽量复原了出来而已。
==========================================================
暂时来说,这样就够用了。这个脚本处理已经让我们能做很多事。要进一步做的话,我可以把文法分析也做出来,方便对脚本更仔细的分析。但这两天肯定是没时间做那种事情咯……
Until then...
==========================================================
P.S. 上述代码皆以BSD许可证的形式发布。请有兴趣的人在遵循BSD许可证的前提下自由使用这些代码。
P.S.S. 其实上面代码值得吐槽的地方N多。例如说我完全没使用try-catch语句来处理可能出现的异常,又例如我在第一份代码里把一个Queue转变成数组再做相等性比较(极其恶心,本来自己写个循环数组就解决了)。……这些都是所谓的“原型代码”,目标是尽可能快的写出代码来验证自己的一些设想是否正确。偷懒不加异常处理、宁可别扭的使用标准库里的容器也不自己封装一个,都是出于同一原因。看倌们请多多包涵这些地方 XD
P.S.S.S. 唉,不过我偷懒也真是过分了。后一份代码里居然没把BinaryWriter改回用StreamWriter……