脚本代码分析 接下来就是要提取出每个声音所对应的文本,当然文本也不是凭空冒出来的,一定在某个文件中保存着文本,并且需要靠程序逻辑判断哪个文本播放哪个音频。
游戏中主要在以下几个地方存在执行逻辑:主程序在 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)到最后发现并不是明文,而是包含了大量二进制数据,其中夹杂着部分明文字符串。
实际上根据曾经逆向 .pyc
的经验,可以猜出这可能就是 .cnut
字节码文件,里面保存着一些符号。连续打开好几个这类文件,都可以看到很明显的特征:
\xfa\xfaRIQS
开头
包含了大量的 TRAP
字符串
开头会出现一个路径 DataMake/xxx/xxx.nut
根据这些特征也可以推断出这就是 .cnut
字节码文件。实际上 .cnut
是用小端存储的,RIQS
也就是 SQIR
是 Squirrel
的缩写,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
。可以看到 name
,voice
以及 voicen
主要来自 elm
参数。
根据注释以及代码逻辑,tag_entryvoice
的作用是在某个时刻追加要播放的声音,而主要播放逻辑在 tag_dispname
中,首先调用 entryNextVoice
从 elm
中获取所有接下来要播放的声音添加到全局变量 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.nut
中 ScenePlayer
类的构造函数 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
文件中的函数以及全局变量(类的静态变量)都属于这个类,包括 playVoices
,playNextVoice
,nexvoices
,entryNextVoice
,tag_entryvoice
,tag_dispname
,getHandlers
以及下面的 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
保存剩下的,而此时 tagname
为 ch2
也就是回调函数 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; }
接着往下看 obj
为 null
会被如何处理
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; } }
根据注释,如果 obj
为 null
,那么代表剧本结束,进入下一个场景,因此当需要播放声音时,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
,所以接下来找找哪些地方调用了这些函数。
很可惜,在所有的搜索结果中,没有找到调用 entryvoice
以及 dispname
的代码,因此 pendings
并不是我们的目标,那么来看看 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
,这类函数甚至在字节码中也找不到符号,推测这些函数可能定义在二进制程序中,测试一下
果然,在游戏主程序 SLPS_258.97
中,除了调用字符串,还出现了单独的符号,那么接下来就是针对 32 位 MIPS 程序 SLPS_258.97
的逆向,不过还是得先看看剩下的几个 .cnut
字节码文件里有没有值得关注的信息,首先需要想办法反编译 .cnt
。
字节码反编译 对于反编译来说,Squirrel 是一个比较坑的语言,兼容性极差,甚至每个小版本的字节码都不一样,并且 .cnut
文件中没有标注是哪个版本的 Squirrel,因此反编译及其困难。
目前已经有的针对 Squirrel 的反编译器主要有以下 3 个:
NutCracker - DamianXVI
NutCracker - darknesswind
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.0
,2.0.[1-5]
,2.1
,2.1.[1-2]
,2.2
,2.2.[1-5]
。
以上 3 个工具反编译我们提取出的字节码全部失败,那么我们就需要自己魔改其中的一个,由于 3 提供了 010 的模版,因此先用 3 来验证一下我们提取出的字节码是哪个版本。由于明文脚本中使用了大量双字节日语,因此为了避免干扰我们的分析,我们找一个最小的,没有出现日文的 41_e2000_36f.nut
用 010 分析一下。
不出意外,果然失败了,先分析一下为什么失败。模版是针对 Squirrel3 的字节码,因此看看模版里定义的结构和我们的文件有什么不同。
首先是 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.cpp
的 SQClosure::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
,看名字应该是读函数。那么我们先修改模版,注释掉 NutHeader
的 sizeInt
以及 sizeFloat
看看解析结果。
果然又失败了,看看报错原因发现是没有匹配到 TRAP
,那么代表肯定在模版中多了几个字节。
通过核对数据,发现这里匹配失败,TRAP
被解析成了 nFunctions
,那么再看看模版中 NutFunction
结构。
其中的 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 nDefaultParamsif (!ConfirmOnPart()) return -1 ;if (nDefaultParams) DefaultParams m_DefaultParams (nDefaultParams) ;
继续运行模版,报了新的错
这次是模版本身报错,原因是文件解析完毕后没有找到最后的 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
。
既然没有报 TRAP
的错,那么就表示在 nFunctions
后出现了问题,看一下模版,在 nFunctions
后主要有三个数据
还是再回到 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。
确定了大致的版本以后,由于版本 2.1.2 太过古老,我们先尝试修改第一个最初的 NutCracker - DamianXVI 来使其兼容 Squirrel 2.1.2,NutCracker 用 Visual Studio 开发,为了避免不必要的麻烦还是在 VS 下编辑运行最好,同时需要在项目 - 属性里设置 Windows SDK 版本以及平台工具集。
首先按照我们在 010 模版中修改的步骤,在 NutCracker 修改文件格式相关的代码。在 nutcracker/NutScript.cpp
的 NutFunction::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 )); }
之后编译运行一下是否可以反编译。
反编译成功,但是缺点是不支持类似 Shift-JIS 的双字节编码
写个脚本转换一下
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 import sysdef 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