0%

某上古 PS2 游戏逆向(三)游戏脚本初步分析与反编译

脚本代码分析

接下来就是要提取出每个声音所对应的文本,当然文本也不是凭空冒出来的,一定在某个文件中保存着文本,并且需要靠程序逻辑判断哪个文本播放哪个音频。

游戏中主要在以下几个地方存在执行逻辑:主程序在 CD 根目录 SLPS_258.97 中,位于扩展库 IOP 目录下的各个 .IRX 中,这两个都是 32 位的 MIPS,就算用 Ghidra 反编译也不太好逆,此外在提取 SYSTEM.BIN 的时候发现里面存在某种脚本语言的代码,并且用 Shift-JIS 编码写日语注释,相较于二进制文件,先看一看从 SYSTEM.BIN 中提取的脚本都干了什么。利用前文中的提取脚本,提取出了以下几个文件:

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
/Users/xxxxx/Desktop/xxxxx/SYSTEM_extracted
├── 0_0_131e.dat
├── 10_1f000_0.dat
├── 11_1f000_8c5.dat
├── 12_20000_1a60.dat
├── 13_22000_1f01.dat
├── 14_24000_56d1.dat
├── 15_29800_306.dat
├── 16_2a000_111d.dat
├── 17_2b800_2dc.dat
├── 18_2c000_1296.dat
├── 19_2d800_4083.dat
├── 1_1800_15e4.dat
├── 20_32000_b12.dat
├── 21_33000_4faa.dat
├── 22_38000_114f.dat
├── 23_39800_650.dat
├── 24_3a000_f02.dat
├── 25_3b000_8f7.dat
├── 26_3c000_126e.dat
├── 27_3d800_56f6.dat
├── 28_43000_d330.dat
├── 29_50800_288a.dat
├── 2_3000_6f4.dat
├── 30_53800_60e4.dat
├── 31_5a000_c69.dat
├── 32_5b000_8688.dat
├── 33_63800_9d29.dat
├── 34_6d800_796.dat
├── 35_6e000_2821.dat
├── 36_71000_527f6.dat
├── 37_c3800_11bf5.dat
├── 38_d5800_72ed.dat
├── 39_dd000_21d0.dat
├── 3_3800_4e02.dat
├── 40_df800_22b8.dat
├── 41_e2000_36f.dat
├── 4_8800_7736.dat
├── 5_10000_416e.dat
├── 6_14800_1540.dat
├── 7_16000_50ce.dat
├── 8_1b800_27ed.dat
└── 9_1e000_946.dat

1 directory, 42 files

首先看看 0_0_131e.dat 中的脚本:

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
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
// XXX 画面サイズ定義
SCWIDTH <- 640;
SCHEIGHT <- 448;

EMPTY <- {}; // 空の辞書

savef <- null; // ゲーム変数セーブ処理用
f <- {}; // ゲーム変数
sf <- {}; // システム変数
tf <- {}; // テンポラリ変数

variables <- null; // 変数情報
voiceConfigs <- null; // ボイスコンフィグ情報
vconfigs <- null;

VAR_GAME <- 0;
VAR_SYSTEM <- 1;
VAR_TEMP <- 2;
VAR_SYSTEMWORK <- 3;

/**
* 変数の初期化
* @param variables 変数情報
* @param zone 0: ゲーム変数 1: システム変数 2: テンポラリ
*/
function initVariables(variables, zone)
{
for (local i=0;i<variables.len();i++) {
local info = variables[i];
if (info.zone == zone) {
switch (info.zone) {
case VAR_SYSTEM:
sf[info.name] <- 0;
break;
case VAR_TEMP:
tf[info.name] <- 0;
break;
default:
f[info.name] <- 0;
break;
}
}
}
savef = clone f;
}

function storeVariables(variables, zone, ...)
{
local no = 0;
local store = null;
if (vargc > 0) {
no = vargv[0];
if (vargc > 1) {
store = vargv[1];
}
}
initFlag(zone, no);
if (zone == VAR_SYSTEMWORK) {
zone = VAR_SYSTEM;
}
for (local i=0;i<variables.len();i++) {
local info = variables[i];
if (info.zone == zone) {
local var;
switch (zone) {
case VAR_GAME:
var = savef[info.name];
break;
case VAR_SYSTEM:
var = sf[info.name];
break;
}
if (store != null) {
store[info.name] <- var;
} else {
switch(info.type) {
case 0: // 数値
if (var != null) {
dm(" セーブパラメータ:" + info.name);
setIntFlag(var.tointeger());
} else {
setIntFlag(0);
}
break;
case 1: // 数値 2
if (var != null) {
setFloatFlag(var.tofloat());
} else {
setFloatFlag(0);
}
break;
case 2: // 文字列
if (var != null) {
setStringFlag(var.tostring(), info.size);
} else {
setStringFlag("", info.size);
}
break;
}
}
}
}
}

function restoreVariables(variables, zone, ...)
{
local no = 0;
local store = null;
if (vargc > 0) {
no = vargv[0];
if (vargc > 1) {
store = vargv[1];
}
}
initFlag(zone, no);
if (zone == VAR_SYSTEMWORK) {
zone = VAR_SYSTEM;
}
for (local i=0;i<variables.len();i++) {
local info = variables[i];
if (info.zone == zone) {
local var;
switch(info.type) {
case 0: // 数値
var = getIntFlag();
break;
case 1: // 数値 2
var = getFloatFlag();
break;
case 2: // 数値
var = getStringFlag(info.size);
break;
}
if (store != null) {
store[info.name] <- var;
} else {
switch (zone) {
case 0:
savef[info.name] <- var;
break;
case 1:
sf[info.name] <- var;
break;
}
}
}
}
}

function showVariables(variables, zone)
{
for (local i=0;i<variables.len();i++) {
local info = variables[i];
if (info.zone == zone) {
local var;
switch (info.zone) {
case VAR_GAME:
var = f[info.name];
break;
case VAR_SYSTEM:
var = sf[info.name];
break;
case VAR_TEMP:
var = tf[info.name];
}
dm(" 名前:" + info.name + " 値:" + var);
}
}
}

// システムボイス情報
sysvoices <- dofile("sysvoices.nut");

/**
* システムボイスの再生
*/
function systemVoice(name) {
if (name in sysvoices) {
Talk(0, sysvoices[name]);
return TalkLength(0);
}
return 0;
}

function systemVoiceStop() {
TalkStop(0);
}


// 初期化処理
dofile("util.nut", true);
dofile("Object.nut", true);
dofile("BasicLayer.nut", true);
dofile("msgwindow.nut", true);
dofile("translayer.nut", true);
dofile("TEXTLAYER.NUT", true);

dofile("action.nut", true);
dofile("player.nut", true);

dofile("GraphicLayer.nut", true);
dofile("GamePlayer.nut", true);

dofile("buttonlayer.nut", true);
dofile("dialogwindow.nut", true);
dofile("selectwindow.nut", true);

dofile("EnvObject.nut", true);
dofile("EnvBase.nut", true);
dofile("EnvImage.nut", true);
dofile("EnvLevelLayer.nut", true);
dofile("EnvLayer.nut", true);
dofile("EnvBackLayer.nut", true);
dofile("EnvStageLayer.nut", true);
dofile("EnvEventLayer.nut", true);
dofile("EnvSimpleLayer.nut", true);
dofile("EnvCharacter.nut", true);
dofile("EnvBGM.nut", true);
dofile("EnvSE.nut", true);
dofile("Environment.nut", true);
dofile("ScenePlayer.nut", true);

dofile("main.nut", true);
dofile("game.nut", true);
dofile("override.nut", true); // ゲーム個別の初期化処理を登録する

function showGlobalInfos() {
dm("------- スクリプト側でキャッシュされてる LAYER の画像情報 ------");
foreach (name,value in images) {
dm("name:" + name + " value:" + value);
}
dm("------- アニメ情報一覧 -----------");
foreach (name,value in animeList) {
dm("name:" + name + " value:" + value);
}
showMemoryInfo();
}

有一串非常显眼的 dofile,并且参数包含了大量 .nut 结尾的文件,稍微 Google 一下可以发现这是 Squirrel 脚本。Squirrel 是一种面向对象的轻量级脚本语言,类似 Lua,常被用在游戏中。Squirrel 语言程序有两类,一类是 .nut 结尾的明文代码,另一类以 .cnut 结尾,类似 Python 的 pyc 文件,保存字节码,不过在 Squirrel 中这个操作不被称为“编译”,而被称为“序列化”,但是可惜的是 Squirrel 并没有提供反序列化这个操作,换句话说我们还是需要自己想办法将 .cnut 恢复成 .nut 文件。

基于此,我们将提取出的文件后缀改文 .nut。从 SYSTEM.BIN 中一共提取了 42 个文件,但是其中前 32 个都是明文代码,从第 33 个文件(32_5b000_8688.nut)到最后发现并不是明文,而是包含了大量二进制数据,其中夹杂着部分明文字符串。

32_5b000_8688.nut

实际上根据曾经逆向 .pyc 的经验,可以猜出这可能就是 .cnut 字节码文件,里面保存着一些符号。连续打开好几个这类文件,都可以看到很明显的特征:

  1. \xfa\xfaRIQS 开头
  2. 包含了大量的 TRAP 字符串
  3. 开头会出现一个路径 DataMake/xxx/xxx.nut

根据这些特征也可以推断出这就是 .cnut 字节码文件。实际上 .cnut 是用小端存储的,RIQS 也就是 SQIRSquirrel 的缩写,TRAP 也就是 PART,顾名思义是作为分隔符。

暂时先不管这些字节码,我们的目标是找到角色的语音文本,看了前 32 个脚本,在 14_24000_56d1.nut 中发现有一个 playVoice 函数

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
/**
* ボイス再生
* @param name キャラ名
* @param voicefile 再生ファイル
*/
function playVoice(name, voicefile) {
if (isSkip() || !voiceenable) {
return;
}
// ボイス情報取得
local configName = "etc";
if (name in voiceConfigs) {
configName = voiceConfigs[name];
}
if (!getVoiceOn(configName)) {
return;
}

// ボリューム調整
// volume = getVoiceVolume(configName);
if (typeof voicefile == "integer") {
// ボイス再生処理
dm(" ボイス再生:" + voicefile);
Talk(0, voicefile);
talkVoiceLength = TalkLength(0);
} else {
dm(" ボイスが再生できない(コンバート時に存在していない)" + voicefile);
}
// 時間を返す
return talkVoiceLength;
}

其中调用 Talk(0, voicefile) 猜测可能是让角色讲话,而 voicefile 是一个 int,应该就是从 VOICE_ID.bin 中提取出的声音文件的 id,0 代表什么暂时未知。可惜的是,在其他几个明文脚本中均未找到 Talk 函数的定义。根据 playVoice 函数注释(的翻译),name 参数表示角色名,voicefile 参数表示要播放的文件,但是最后影响 Tlak 函数的仅有 voicefile,而 name 仅用来判断该角色是否存在于 voiceConfigs

首先我们先看一下 playVoice 从哪里会被调用,发现主要是在 28_43000_d330.nut 文件的 playVoices 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 複数ボイスを鳴らす
* @return 最長のボイス再生時間
*/
function playVoices(voices) {
local ret = null;
if (!isJump() && voices != null) {
foreach (name, info in voices) {
local r;
// デフォルト名の場合の処理
if (isDefaultName() && info.voicen != null) {
r = playVoice(name, info.voicen);
} else {
r = playVoice(name, info.voice);
}
if (ret == null || r != null && r > ret) {
ret = r;
}
}
}
return ret;
}

playVoice 中的 voicefile 来自 playVoices 中的 voices字典,也就是其参数,保存了多个 (name, voicefile) 键值对。这个函数仅在该文件中的 playNextVoice 被调用

1
2
3
4
5
6
7
8
9
10
/**
* ボイス再生実行
*/
function playNextVoice() {
if (nextvoices != null) {
local ret = playVoices(nextvoices);
clearNextVoice();
return ret;
}
}

可以看到 playVoices 中的 voices 字典来自全局变量 nexvoices。接下来再找找 nextvoices 的写值操作

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
// 次回再生するボイス情報
// キー: キャラクタ名
// 値: ボイス
nextvoices = null;

/**
* 次回同時に鳴らすボイスの追加
* @param name キャラ名
* @param voice ボイス指定
* @param voicen 非デフォルト時ボイス指定
*/
function entryNextVoice(name, voice, voicen) {
if (name != null && voice != null) {
if (nextvoices == null) {
nextvoices = {};
}
nextvoices[name] <- {voice=voice, voicen=voicen};
}
}

/**
* 次回鳴らすボイスの情報をクリア
*/
function clearNextVoice() {
nextvoices = null;
}

根据注释,nextvoices 的作用是保存“下次播放的声音信息”,键为“角色名”,值为“声音”,并且仅在 entryNextVoice 中被写入,同时写入了 voice 也就是“语音指定”以及 voicen 也就是“非默认语音指定”两个信息,那么再找找哪里调用该函数写入接下来要播放的声音信息。

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
/**
* ボイス追加
*/
function tag_entryvoice(elm) {
entryNextVoice(elm.name, elm.voice, getval(elm, "voicen"));
return 0;
}

/**
* 名前表示
*/
function tag_dispname(elm) {
// ...

//dm(" 名前表示ハンドラ ");
if (elm == null || !("name" in elm) || elm.name == "") {
// ...
} else {

local name = getval(elm, "name");
local disp = getval(elm, "disp");

local ch = env.getCharacter(name, null);
env.currentNameTarget = ch;
if ("voice" in elm) {
entryNextVoice(name, elm.voice, getval(elm, "voicen"));
}
// 名前の決定
// ...

// 名前加工処理
// ...

// 表情判定
// ...
}

// 名前表示
// ...

// ボイス再生
if (nextvoices != null) {
local ret = playNextVoice();
if (ret > 0) {
addAutoWait(ret);
}
}
return 0;
}

同样在 28_43000_d330.nut 文件中,有两个 tag 开头的函数:tag_entryvoice 以及 tag_dispname。可以看到 namevoice 以及 voicen 主要来自 elm 参数。

根据注释以及代码逻辑,tag_entryvoice 的作用是在某个时刻追加要播放的声音,而主要播放逻辑在 tag_dispname 中,首先调用 entryNextVoiceelm 中获取所有接下来要播放的声音添加到全局变量 nextvoices 中,之后以此处理名称,名称前缀以及表情等,最后再调用 palyNextVoice 播放这些声音,并且阻塞等待播放完毕。

这些 tag 开头的函数作为回调函数保存在同文件的 getHandlers 函数中。

1
2
3
4
5
6
7
8
9
10
11
12
function getHandlers()
{
return { // 辞書配列オブジェクト
// ...
//----------------------------------------------- ボイス制御

entryvoice = tag_entryvoice,
// ...
dispname = tag_dispname,
// ...
};
}

接下来找找哪里获取并调用了这几个回调函数。同样仅在 28_43000_d330.nutScenePlayer 类的构造函数 constructor 调用 getHandlers 获取所有回调函数保存在 handlers 中。

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
/**
* シーン再生プレイヤー
* ・シーンの再生機能
* ・シーンのセーブとロード
*/
class ScenePlayer extends GamePlayer
{
// ...

/**
* コンストラクタ
*/
constructor() {
::GamePlayer.constructor();
pendings = [];
branches = [];
targetBranches = [];
env = Environment(this, SCWIDTH, SCHEIGHT);
if ("nameImageMap" in env.envinfo) {
setNameImageMap(env.envinfo.nameImageMap);
}
sceneInfos = dofile("scenes.nut");
handlers = getHandlers();
historyInit();
selectOption = {};
caches = {};
particles = {};
}

// ...
}

根据注释,这个类是一个“场景再生播放器”,负责“场景的再生功能”以及“场景的保存和载入”,在日语中“再生”就是重放的意思,那么肯定是在这个类中根据场景的变换,播放文本以及对应的音频。实际上 28_43000_d330.nut 文件中的函数以及全局变量(类的静态变量)都属于这个类,包括 playVoicesplayNextVoicenexvoicesentryNextVoicetag_entryvoicetag_dispnamegetHandlers 以及下面的 onTag。再找找哪里使用了 handlers,发现主要是在同文件同类下的 onTag 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* タグの処理
*/
function onTag(elm) {
local tagname = elm.tagname;
//dm(" シーンタグ:" + tagname);
if (tagname in handlers) {
local handler = handlers[tagname];
local ret = handler(elm);
lastTagName = tagname;
return ret;
}
// 無効なタグ
local ret = env.unknown(tagname, elm);
if (ret == null) {
local msg = " タグ / マクロ \"" + tagname + "\" は存在しません ";
errorCmd(msg);
return 0;
//throw new Exception(msg);
}
return ret;
}

通过 onTag 可以发现 elm 中会保存 tagname,也就是对应的回调函数名称,而这个函数则就是调用 elm 指定的回调函数,并且回调参数固定为 elm。接下来再找找哪里调用了 onTag,也许就可以找到哪里给 elm 指定了回调函数,发现同样是在同文件同类下的一个非常大的函数 run 中。

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
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
/**
* メインロジック
*/
function run() {
local obj;
for(;;) {

// ロード復帰処理
if (targetPage != null) {
// ...
}

if (pendings.len() > 0) {
// 後回しにされたタグがある場合
obj = pendings[0];
pendings.remove(0);
} else {
// 表示テキストがある場合の処理
if (currentText.len() > 0) {
// if (isSkip()) {
// obj = {tagname="ch2", text=currentText};
// currentText = "";
// } else {
if (iskanji(currentText)) {
local ch = currentText.slice(0,2);
currentText = currentText.slice(2);
obj = {tagname="ch2", text=ch};
} else {
local ch = currentText.slice(0,1);
currentText = currentText.slice(1);
obj = {tagname="ch2", text=ch};
}
// }
} else {
if (cur < lines.len()) {
obj = lines[cur++];
} else {
obj = null;
}
}
}
if (obj == null) {
// シナリオ終了
//dm(" シナリオ終了 cur:" + curStorage + " next:" + nextStorage) ;
local next = nextStorage;
if (next == null || next == 0) {
next = getNextScene(curStorage);
}
dm("next:" + next);
if (next != null && next != "" && next != 0) {
goToNext(next);
continue;
} else {
status = STOP;
return;
}
} else if (obj.tagname == "label") { // ラベル処理
if (targetPage != null) {
if (targetBranches.len() > 0) {
if (obj.count != targetBranches[0]) {
goToCount(targetBranches[0]);
continue;
} else {
targetBranches.remove(0);
}
} else {
// XXX ロード失敗
dm(" ロード失敗: ラベルの整合不良 ");
status = STOP;
return;
}
}
setReaded(curPage); // 次のラベルに進む前に最後の行の既読をたてる
curCount = obj.count;
branches.append(curCount);
curPage = 0;
savePrepare();
continue;
} else if (obj.tagname == "begintrans") {
//dm(" 全体トランジションを処理 ");
obj = null;
while (cur < lines.len() && (obj = lines[cur++]) != null && obj.tagname != "endtrans") {
if (obj.tagname == "begintrans") {
throw "begintrans は入れ子にできません ";
}
pendings.append(obj);
}
if (obj == null) {
throw "begintrans に対応する endtrans がありません ";
}
if (!isJump()) {
local trans = null;
foreach (cmd, param in obj) {
if (cmd == "tagname") {
// ignore
} else if (cmd == "trans") {
local tr = getTrans(param, obj);
if (tr != null) {
trans = tr;
}
} else if (cmd == "fade") {
local time = param.tointeger();
trans = {
method = "crossfade",
time = time > 1 ? time : env.getFadeValue(),
};
} else {
local tr = getTrans(cmd, obj);
if (tr != null) {
trans = tr;
}
}
}
if (trans != null && "msgoff" in trans) {
insertTag("msgoff", null);
}
if (trans != null && getval(trans, "method") == "layer") {
insertTag("_beginlt", trans);
local e = {};
if ("transwait" in trans) {
e.wait <- getint(trans, "transwait");
}
if ("endtime" in trans) {
e.time <- getint(trans, "endtime");
}
if ("endtype" in trans) {
e.type <- getval(trans, "endtype");
}
if ("endrule" in trans) {
e.rule <- getval(trans, "endrule");
}
addTag("_endlt", e);
} else if (trans != null) {
insertTag("_begintrans", null);
addTag("_endtrans", {trans=trans});
}
}
continue;
} else if (obj.tagname == "ch") {
doText(obj.text);
continue;
} else if (obj.tagname == "embex") { // 変数埋め込みの参照
if ("exp" in obj) {
local text = ::eval(obj.exp);
if (typeof text == "string") {
doText(text);
}
}
continue;
} else {
// その他のコマンド

// 実行時判定される cond
if ("condex" in obj) {
if (!::eval(obj.condex)) {
continue;
}
//delete obj.condex;
} else if ("if" in obj) {
if (!::eval(obj["if"])) {
continue;
}
//delete obj["if"];
}

// onTag を呼ぶ
local step = onTag(obj);
if (step == null) {
throw "onTag が null を返しました (" + obj.tagname + ")";
}
step = step.tointeger(); // step を数値に
//dm(obj.tagname + " タグ戻り値:" + step);

if (step == 0) {
continue;
}
if (step < 0) {
switch(step) {
case -5: // いったんイベントを処理(現在のタグは後回し)
pendings.insert(0, obj);
return;
case -4: // いったんイベントを処理
return;
case -3: // 後回ししてブレーク
pendings.insert(0, obj);
updateBeforeCh = 1;
return;
case -2: // ブレーク
updateBeforeCh = 1;
return;
case -1: // シナリオ終了
status = STOP;
return;
}
} else {
waitTime(step, false);
return;
}
}
}
}

这个函数是一个巨大的无限循环 for(;;),但是看起来循环存在多种退出逻辑,因此不一定是游戏主循环,根据类以及函数注释,该函数很有可能是一个场景下的的主要处理逻辑。

分析一下这个函数的逻辑,主要从 onTag 那一行开始看起,onTag 的参数 elm 也就是该函数中的 obj,在调用 onTag 前都是在生成 obj,之后调用 onTag 处理 obj,之后返回一个 step 来决定循环是否继续或者直接退出。

在大循环中,首先判断了一下 targetPage,根据游戏经验以及注释,这个 if 分支可能是快速推进剧本到 targetPage,也就是实现快进功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ロード復帰処理
if (targetPage != null) {
if (targetBranches.len() == 0 && curPage >= targetPage) {
dm(" ジャンプ終了 ");
savePrepare();
showVariables(variables, VAR_GAME);
targetPage = null;
// 画面復帰処理
insertTag("_endlt", {time=1000});
insertTag("_sync", null);
// すぐマスク
beginLayerTrans({time=0});
}
}

之后就是 obj 的生成

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
if (pendings.len() > 0) {
// 後回しにされたタグがある場合
obj = pendings[0];
pendings.remove(0);
} else {
// 表示テキストがある場合の処理
if (currentText.len() > 0) {
// if (isSkip()) {
// obj = {tagname="ch2", text=currentText};
// currentText = "";
// } else {
if (iskanji(currentText)) {
local ch = currentText.slice(0,2);
currentText = currentText.slice(2);
obj = {tagname="ch2", text=ch};
} else {
local ch = currentText.slice(0,1);
currentText = currentText.slice(1);
obj = {tagname="ch2", text=ch};
}
// }
} else {
if (cur < lines.len()) {
obj = lines[cur++];
} else {
obj = null;
}
}
}

如果 pendings 队列非空则直接获取队首,否则生成新的,这里我们先关注生成。首先判断了 currentText 是否有值,根据变量名猜测这个可能就是当前要播放的文字,注释说该 if 分支表示“有显示文本时的处理”,在游戏大部分时候肯定都是有文字显示,那么进入该分支的频率可能会非常高。

如果 currentText 有值,则先判断 iskanji,日语中 kanji 是汉字的意思,实际上这里就是判断是否需要采用双字节编码。不管是否属于汉字,都将 currentText 的第一个字符切下来保存在 obj.text 中,currentText 保存剩下的,而此时 tagnamech2也就是回调函数 tag_ch,很明显这不是我们想要的回调函数。如果 currentText 没有值,那么 obj 就可能会在 else 分支中来自 lines 数组,或者为 null

看一下 iskanji 函数发现其实这里是将前一个句子结尾的标点符号截取出来,因此这里 obj 仅是为了处理一个标点字符而生成。

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
// 行頭禁則
kinsokuText <- {
["。"] = true,
[","] = true,
["、"] = true,
["."] = true,
[":"] = true,
[";"] = true,
[" ゛ "] = true,
[" ゜ "] = true,
[" ヽ "] = true,
[" ヾ "] = true,
[" ゝ "] = true,
[" ゞ "] = true,
["々"] = true,
["’"] = true,
["”"] = true,
[")"] = true,
["〕"] = true,
["]"] = true,
["}"] = true,
["〉"] = true,
["》"] = true,
["」"] = true,
["』"] = true,
["】"] = true,
["°"] = true,
["′"] = true,
["″"] = true,
["℃"] = true,
["¢"] = true,
["%"] = true,
["‰"] = true,
};

function isKinsoku(ch)
{
return ch in kinsokuText;
}

接着往下看 objnull 会被如何处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if (obj == null) {
// シナリオ終了
//dm(" シナリオ終了 cur:" + curStorage + " next:" + nextStorage) ;
local next = nextStorage;
if (next == null || next == 0) {
next = getNextScene(curStorage);
}
dm("next:" + next);
if (next != null && next != "" && next != 0) {
goToNext(next);
continue;
} else {
status = STOP;
return;
}
}

根据注释,如果 objnull,那么代表剧本结束,进入下一个场景,因此当需要播放声音时,obj 不应该为 null。在往下看 obj 如何被处理

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
if (obj.tagname == "label") { // ラベル処理
// ...
} else if (obj.tagname == "begintrans") {
// ...
} else if (obj.tagname == "ch") {
doText(obj.text);
continue;
} else if (obj.tagname == "embex") { // 変数埋め込みの参照
if ("exp" in obj) {
local text = ::eval(obj.exp);
if (typeof text == "string") {
doText(text);
}
}
continue;
} else {
// その他のコマンド

// 実行時判定される cond
if ("condex" in obj) {
if (!::eval(obj.condex)) {
continue;
}
//delete obj.condex;
} else if ("if" in obj) {
if (!::eval(obj["if"])) {
continue;
}
//delete obj["if"];
}

// onTag を呼ぶ
local step = onTag(obj);
if (step == null) {
throw "onTag が null を返しました (" + obj.tagname + ")";
}
step = step.tointeger(); // step を数値に
// ...
}

之后在调用 onTag 前并没有生成新的 obj,那么到此为止生成的 obj 只是为了处理 currentText 开头的标点,则其他的 obj 只能来自类的静态变量 pendings 队列或者 lines 数组。首先看看 pendings 从哪里来

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
/**
* タグ割り込み処理
*/
function addTag(name, elm) {
local e;
if (elm != null) {
e = clone elm;
} else {
e = {};
}
if (name != null) {
e.tagname <- name;
}
pendings.append(e);
}

/**
* タグ割り込み処理
*/
function insertTag(name, elm) {
local e;
if (elm != null) {
e = clone elm;
} else {
e = {};
}
if (name != null) {
e.tagname <- name;
}
pendings.insert(0, e);
}

run 循环中的 pendings 主要添加的是已有的 obj,因此 pendings 的其他值只能通过函数 addTag 以及 insertTag 添加。这两个函数都会添加一个 elm,也就是 obj,并且将 name 作为 tagname,所以接下来找找哪些地方调用了这些函数。

insertTag 搜索结果

addTag 搜索结果

很可惜,在所有的搜索结果中,没有找到调用 entryvoice 以及 dispname 的代码,因此 pendings 并不是我们的目标,那么来看看 lines

lines 搜索结果

lines并没有写值操作,但是有赋值操作 lines = getScene(curStorage),都是在 28_43000_d330.nut 中.

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
/**
* 実行開始
* @param storage 開始シナリオ
* @param option パース用オプション
*/
function start(storage) {
dm(" シーン再生開始 ");
clearForStart();
curStorage = storage;
lines = getScene(curStorage);

prevSkipMode = null;
currentText = "";
status = RUN;
// 初期変数保存
savef = clone f;
savePrepare();
}

/**
* ロード処理
* ゲーム変数からシナリオ状態変数を読み込んでから実行開始
*/
function load() {
dm(" シーンロード開始 ");
clearForStart();

if (savef.storage != "") {
dm(" ロード対象:" + savef.storage);
dm(" ロードまでの分岐数:" + savef.branchCount);
dm(" ロード先:" + savef.targetPage);
curStorage = savef.storage;
lines = getScene(curStorage);
if (lines == null) {
dm(" ロード失敗 ");
return false;
}
// 分岐情報復帰
for (local i=0;i<savef.branchCount;i++) {
targetBranches.append(savef["branch"+i]);
//dm(" 分岐:" + savef["branch"+i]);
}
// ページ番号復帰
targetPage = savef.targetPage;
// 変数復帰
f = clone savef;
// 時間復帰
currentTick = savef.time;
prevSkipMode = null;
currentText = "";
status = RUN;
return true;
} else {
dm(" ロード失敗 ");
return false;
}
}

/**
* 次のシナリオに接続
*/
function goToNext(next) {
clearInfo();
curStorage = next;
lines = getScene(curStorage);
savef = clone f;
savePrepare();
status = RUN;
}

根据这三个函数名以及注释,连蒙带猜可能 lines 在每次加载新场景时,该场景内部所有的对话被 getScene 统一读入 lines 中。看看位于同一文件的 getScene 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* シーンファイルを変換してデータとして取得する
* @param name シーン名
*/
function getScene(name) {
// グローバルのシーン取得関数を使う
try {
return ::getScene(name)();
} catch (e) {
dm("error in loading:" + e);
return null;
}
}

参数 name 是场景名,很有可能是字符串。根据注释“转换场景文件作为数据获取”,证明我们的猜测可能是正确的。在 Squirrel 中函数或变量名前的 :: 代表调用的全局空间的函数而不是类函数,因此调用的是全局空间的同名函数。该全局函数返回值也是一个可调用的对象,可能也是回调函数。

Global variables are stored in a table called the root table. Usually in the global scope the environment object is the root table, but to explicitly access the closure root of the function from another scope, the slot name must be prefixed with ‘::’

然而在所有的明文脚本代码中并没有找到任何全局 getScene 的定义。很明显这个函数不是标准库函数,实际上脚本中出现了不少类似调用没有定义的函数,例如前文的 Talk,这类函数甚至在字节码中也找不到符号,推测这些函数可能定义在二进制程序中,测试一下

二进制程序中的 Talk

果然,在游戏主程序 SLPS_258.97 中,除了调用字符串,还出现了单独的符号,那么接下来就是针对 32 位 MIPS 程序 SLPS_258.97 的逆向,不过还是得先看看剩下的几个 .cnut 字节码文件里有没有值得关注的信息,首先需要想办法反编译 .cnt

字节码反编译

对于反编译来说,Squirrel 是一个比较坑的语言,兼容性极差,甚至每个小版本的字节码都不一样,并且 .cnut 文件中没有标注是哪个版本的 Squirrel,因此反编译及其困难。

目前已经有的针对 Squirrel 的反编译器主要有以下 3 个:

  1. NutCracker - DamianXVI
  2. NutCracker - darknesswind
  3. NutCracker - SydMontague

其中的 2 和 3 都是基于 1 的改进,而 1 最初来源 CE 论坛,仅支持 32 位的 Squirrel 2.2.4,不支持 unicode 编码。而 2 基于 1 实现了同版本 64 位的反编译,3 基于 1 实现了针对 Squirrel3 的反编译,同时支持 unicode 编码,此外还提供了一个 010 Editor 的模版供分析。很明显,在这个上古游戏的时代必不可能用 Squirrel3 开发,因此只能是 Squirrel2 的某个版本。查看一下 Squirrel2 的 历史版本,主要有以下 15 个稳定版本:2.02.0.[1-5]2.12.1.[1-2]2.22.2.[1-5]

Squirrel 历史稳定版本

以上 3 个工具反编译我们提取出的字节码全部失败,那么我们就需要自己魔改其中的一个,由于 3 提供了 010 的模版,因此先用 3 来验证一下我们提取出的字节码是哪个版本。由于明文脚本中使用了大量双字节日语,因此为了避免干扰我们的分析,我们找一个最小的,没有出现日文的 41_e2000_36f.nut 用 010 分析一下。

41_e2000_36f.nut 分析结果

不出意外,果然失败了,先分析一下为什么失败。模版是针对 Squirrel3 的字节码,因此看看模版里定义的结构和我们的文件有什么不同。

41_e2000_36f.nut

首先是 2 个字节的 fafa,然后是 4 个字节的 RIQS,然后是 4 个字节的 sizeChar,但是在模版中接下来就是用于分片的 TRAP,少了 sizeInt 以及 sizeFloat。考虑到这个游戏的发行年份,我们找一个 Squirrel 2.2.4 的源码分析一下 Squirrel 2 的 .cnut 字节码结构是什么样的。

首先找找字节码文件头 0xfafa 的定义,在 squirrel/sqapi.cpp 中。

1
#define SQ_BYTECODE_STREAM_TAG    0xFAFA

然后找找 SQ_BYTECODE_STREAM_TAG 的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
SQRESULT sq_readclosure(HSQUIRRELVM v,SQREADFUNC r,SQUserPointer up)
{
SQObjectPtr closure;

unsigned short tag;
if(r(up,&tag,2) != 2)
return sq_throwerror(v,_SC("io error"));
if(tag != SQ_BYTECODE_STREAM_TAG)
return sq_throwerror(v,_SC("invalid stream"));
if(!SQClosure::Load(v,up,r,closure))
return SQ_ERROR;
v->Push(closure);
return SQ_OK;
}

检查完文件头后进入 squirrel/sqobject.cppSQClosure::Load 加载字节码文件

1
2
3
4
5
6
7
8
9
10
bool SQClosure::Load(SQVM *v,SQUserPointer up,SQREADFUNC read,SQObjectPtr &ret)
{
_CHECK_IO(CheckTag(v,read,up,SQ_CLOSURESTREAM_HEAD));
_CHECK_IO(CheckTag(v,read,up,sizeof(SQChar)));
SQObjectPtr func;
_CHECK_IO(SQFunctionProto::Load(v,up,read,func));
_CHECK_IO(CheckTag(v,read,up,SQ_CLOSURESTREAM_TAIL));
ret = SQClosure::Create(_ss(v),_funcproto(func));
return true;
}

SQ_CLOSURESTREAM_HEAD 就是小端的 SQIR,检查完后读取 4 个字节的 sizeof(SQChar),因此在 Squirrel2 中没有 sizeInt 以及 sizeFloat,读取完后进入 SQFunctionProto::Load,看名字应该是读函数。那么我们先修改模版,注释掉 NutHeadersizeInt 以及 sizeFloat 看看解析结果。

010 Editor 解析失败

果然又失败了,看看报错原因发现是没有匹配到 TRAP,那么代表肯定在模版中多了几个字节。

41_e2000_36f.nut

通过核对数据,发现这里匹配失败,TRAP 被解析成了 nFunctions,那么再看看模版中 NutFunction 结构。

010 Editor cnut 模版

其中的 ConfirmOnPart 就是读取 4 个字节检查是否是 TRAP。很明显,nFunctions前面多了 4 个字节才导致识别错位到下一个 TRAP,继续看看 Squirrel 2.2.4 的源码中定义的结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
bool SQFunctionProto::Load(SQVM *v,SQUserPointer up,SQREADFUNC read,SQObjectPtr &ret)
{
SQInteger i, nliterals,nparameters;
SQInteger noutervalues ,nlocalvarinfos ;
SQInteger nlineinfos,ninstructions ,nfunctions,ndefaultparams ;
SQObjectPtr sourcename, name;
SQObjectPtr o;
_CHECK_IO(CheckTag(v,read,up,SQ_CLOSURESTREAM_PART));
_CHECK_IO(ReadObject(v, up, read, sourcename));
_CHECK_IO(ReadObject(v, up, read, name));

_CHECK_IO(CheckTag(v,read,up,SQ_CLOSURESTREAM_PART));
_CHECK_IO(SafeRead(v,read,up, &nliterals, sizeof(nliterals)));
_CHECK_IO(SafeRead(v,read,up, &nparameters, sizeof(nparameters)));
_CHECK_IO(SafeRead(v,read,up, &noutervalues, sizeof(noutervalues)));
_CHECK_IO(SafeRead(v,read,up, &nlocalvarinfos, sizeof(nlocalvarinfos)));
_CHECK_IO(SafeRead(v,read,up, &nlineinfos, sizeof(nlineinfos)));
_CHECK_IO(SafeRead(v,read,up, &ndefaultparams, sizeof(ndefaultparams)));
_CHECK_IO(SafeRead(v,read,up, &ninstructions, sizeof(ninstructions)));
_CHECK_IO(SafeRead(v,read,up, &nfunctions, sizeof(nfunctions)));
// ...
}

发现这里的结构和模版中一样,那么可以证明我们需要的 Squirrel2 版本一定小于 2.2.4,可以进一步缩小我们的目标范围。那么继续分析 2.2.4 之前的版本,最后在 2.1.2 中发现缺少了 ndefaultparams

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
bool SQFunctionProto::Load(SQVM *v,SQUserPointer up,SQREADFUNC read,SQObjectPtr &ret)
{
SQInteger i, nliterals,nparameters;
SQInteger noutervalues ,nlocalvarinfos ;
SQInteger nlineinfos,ninstructions ,nfunctions ;
SQObjectPtr sourcename, name;
SQObjectPtr o;
_CHECK_IO(CheckTag(v,read,up,SQ_CLOSURESTREAM_PART));
_CHECK_IO(ReadObject(v, up, read, sourcename));
_CHECK_IO(ReadObject(v, up, read, name));

_CHECK_IO(CheckTag(v,read,up,SQ_CLOSURESTREAM_PART));
_CHECK_IO(SafeRead(v,read,up, &nliterals, sizeof(nliterals)));
_CHECK_IO(SafeRead(v,read,up, &nparameters, sizeof(nparameters)));
_CHECK_IO(SafeRead(v,read,up, &noutervalues, sizeof(noutervalues)));
_CHECK_IO(SafeRead(v,read,up, &nlocalvarinfos, sizeof(nlocalvarinfos)));
_CHECK_IO(SafeRead(v,read,up, &nlineinfos, sizeof(nlineinfos)));
_CHECK_IO(SafeRead(v,read,up, &ninstructions, sizeof(ninstructions)));
_CHECK_IO(SafeRead(v,read,up, &nfunctions, sizeof(nfunctions)));
// ...
}

在 010 的模版中注释掉下面的代码

1
2
3
4
5
int nDefaultParams
// ...
if (!ConfirmOnPart()) return -1;
if (nDefaultParams)
DefaultParams m_DefaultParams(nDefaultParams);

继续运行模版,报了新的错

0101 Editor 解析失败

这次是模版本身报错,原因是文件解析完毕后没有找到最后的 LIAT

1
2
3
4
5
6
7
8
9
struct NutEnd
{
char magicTAIL[4];
if (magicTAIL != "LIAT")
{
Warning("Confirm End Failed");
return -1;
}
};

验证一下,果然把文件中最后的 LIAT 解析成了 m_VarParams

41_e2000_36f.nut

既然没有报 TRAP 的错,那么就表示在 nFunctions 后出现了问题,看一下模版,在 nFunctions 后主要有三个数据

010 Editor cnut 模版

还是再回到 Squirrel 2.1.2 的源码中,看看结构最后是不是有这三个数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
// ...
_CHECK_IO(CheckTag(v,read,up,SQ_CLOSURESTREAM_PART));
_CHECK_IO(SafeRead(v,read,up, f->_instructions, sizeof(SQInstruction)*ninstructions));

_CHECK_IO(CheckTag(v,read,up,SQ_CLOSURESTREAM_PART));
for(i = 0; i < nfunctions; i++){
_CHECK_IO(_funcproto(o)->Load(v, up, read, o));
f->_functions[i] = o;
}
_CHECK_IO(SafeRead(v,read,up, &f->_stacksize, sizeof(f->_stacksize)));
_CHECK_IO(SafeRead(v,read,up, &f->_bgenerator, sizeof(f->_bgenerator)));
_CHECK_IO(SafeRead(v,read,up, &f->_varparams, sizeof(f->_varparams)));
// ...

居然都有,那么再看看这三个数据的大小。在 squirrel/sqfuncproto.h 中找到这三个数据的定义

1
2
3
SQInteger _stacksize;
bool _bgenerator;
bool _varparams;

这时候就看出来问题了,在模版中最后的 m_VarParams 是 4 个字节的 int,但是在源码中却是 1 个字节的 bool,修改模版将 int m_VarParams 改成 char m_VarParams 后再次运行,这次就执行成功了,代表我们的修改全部都是正确的,并且 Squirrel 的版本一定小于等于 2.1.2。

010 Editor 解析结果

确定了大致的版本以后,由于版本 2.1.2 太过古老,我们先尝试修改第一个最初的 NutCracker - DamianXVI 来使其兼容 Squirrel 2.1.2,NutCracker 用 Visual Studio 开发,为了避免不必要的麻烦还是在 VS 下编辑运行最好,同时需要在项目 - 属性里设置 Windows SDK 版本以及平台工具集。

首先按照我们在 010 模版中修改的步骤,在 NutCracker 修改文件格式相关的代码。在 nutcracker/NutScript.cppNutFunction::Load 中注释以下代码

1
2
3
4
5
6
7
8
9
10
11
// ...
int nDefaultParams = reader.ReadInt32();
// ...
reader.ConfirmOnPart();

m_DefaultParams.resize(nDefaultParams);
if (nDefaultParams)
{
reader.Read(&(m_DefaultParams.at(0)), nDefaultParams * sizeof(int));
}
// ...

之后编译运行一下是否可以反编译。

Nutcracker 反编译结果

反编译成功,但是缺点是不支持类似 Shift-JIS 的双字节编码

Nutcracker 反编译结果

写个脚本转换一下

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
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
# =======================================================
# Author: Srpopty
# Email: srpopty@outlook.com
# FileName: transfor.py
# Description:
# Convert string in double qoute to ja_jp.shiftjis.
# ========================================================

import sys


def main(filename):
with open(filename, "rb") as f:
data = f.read()

invisible = [0, 0xFF, 0xFE]
filename = filename.split(".")
ext = filename[-1]
with open("".join(filename[:-1]) + "_shiftjis." + ext, "wb") as f:
i = 0
length = len(data)
while i < length:
c = data[i]
if c not in invisible:
if c == 0x22:
j = i + 1
string = ""
while data[j] != 0x22:
cc = data[j]
if cc not in invisible:
string += chr(cc)
j += 1
if "\\x00" in string:
string = eval(
'b"' + string.replace("\\x00", "\\x") + '"'
).decode("shift-jis")
string = string.replace("\r", "\\r").replace("\n", "\\n")
f.write(b'"%s"' % string.encode())
i = j
else:
f.write(chr(c).encode())
i += 1


if __name__ == "__main__":
main(sys.argv[1])

批量转换

1
2
ls 3[2-9]*.nut | xargs -I {} python3 ./transfor.py {}
python3 ./transfor.py 41.nut

最后终于正确反编译了全部 10 个 .cnut

Nutcracker 反编译结果