探究 kirikiri 引擎的存档文件 .ksd / .kdt 内容格式(解析)和存档机制
引言
零知识背景:
笔者观察到一些基于 kirikiri 引擎的游戏的存档文件为 datasc.ksd
和 datasu.ksd
,检阅其内容,发现它们是二进制文件,文件头特征为 FE FE 02 FF FE
。
出于对 krkr 存档内容的好奇,笔者想了解存档文件 .ksd
的文件格式和语义,解析 krkr 存档内容。
krkr2 官方文档之文件说明章节提及,*.ksd
、*.kdt
文件是 "KAGのセーブデータ" (KAG save data) 即 KAG(krkr) 存档数据。
游戏程序对 .ksd / .kdt 存档文件的读写
查阅 kirikiri 引擎(具体版本是 krkr2)源代码仓库,发现其提供了一套实现游戏逻辑的演示脚本模板(参考实现);找到了其中读取存档函数的代码片段
// 为排版方便,对代码换行缩进做了适当调整。
function loadSystemVariables() {
// システム変数の読み込み
try {
var fn = saveDataLocation + "/" + dataName +
"sc.ksd";
if (Storages.isExistentStorage(fn)) {
scflags = Scripts.evalStorage(fn);
scflags = %[] if scflags === void;
}
else { scflags = %[]; }
var fn = saveDataLocation + "/" + dataName +
"su.ksd";
if (Storages.isExistentStorage(fn)) {
sflags = Scripts.evalStorage(fn);
sflags = %[] if sflags === void;
}
else { sflags = %[]; }
}
catch(e) {
throw new Exception("システム変数データを読み込めないか、"
"あるいはシステム変数データが壊れています(" + e.message + ")");
}
}
由上述参考实现得知几个线索:
-
参考实现将存档文件的扩展名后缀定为
.ksd
,并且还将存档分为两个文件,分别是{dataName}sc.ksd
和{dataName}su.ksd
(其中{dataName}
是变量,大概默认设定值就是data
)。很多使用 krkr 引擎的游戏,确实遵循了这一不成文的标准,将存档分为datasc.ksd
和datasu.ksd
,这一事实印证了上述程序实现。 -
(参考实现正如官方文档所约定,使用
.ksd
作为文件扩展名后缀;但由函数接口可知引擎并不强制约束后缀,调用者可以随意定后缀,只需把完整文件路径传给引擎即可。虽然功能可行,但大多数游戏都没有自定义存档扩展名。) - TJS 脚本层面通过调用引擎提供的 Scripts.evalStorage(存档文件路径) 函数实现加载存档功能调用。
Scripts.evalStorage()
查阅 krkr2 脚本文档,其中对 Scripts.evalStorage()
的描述非常简略:
ストレージ上の式の評価:指定されたストレージを読み込み、その内容を TJS2 式として評価します。
Evaluate 存储表达式:读取指定的存储并将其内容作为 TJS2 表达式 Evaluate。
并且提到可参考函数 Scripts.execStorage()
。区别在于前者是 evaluate 表达式,后者是执行(execute)脚本。
查看 TJS 引擎中对 Scripts.evalStorage()
函数的具体底层实现,代码片段如下。
TJS_BEGIN_NATIVE_METHOD_DECL(/*func. name*/evalStorage)
{
// execute expression which stored in storage
if(numparams < 1) return TJS_E_BADPARAMCOUNT;
ttstr name = *param[0];
ttstr modestr;
if(numparams >=2 && param[1]->Type() != tvtVoid)
modestr = *param[1];
iTJSDispatch2 *context = numparams >= 3 && param[2]->Type() != tvtVoid ? param[2]->AsObjectNoAddRef() : NULL;
TVPExecuteStorage(name, context, result, true, modestr.c_str());
return TJS_S_OK;
}
Scripts.evalStorage()
底层实现转而将存档文件路径传给引擎原生函数 TVPExecuteStorage()
,调用它加载存档。
TVPExecuteStorage()
查阅 TVPExecuteStorage()
函数的实现,代码片段如下。
//---------------------------------------------------------------------------
void TVPExecuteStorage(const ttstr &name, tTJSVariant *result, bool isexpression,
const tjs_char * modestr)
{
TVPExecuteStorage(name, NULL, result, isexpression, modestr);
}
//---------------------------------------------------------------------------
void TVPExecuteStorage(const ttstr &name, iTJSDispatch2 *context, tTJSVariant *result, bool isexpression,
const tjs_char * modestr)
{
// execute storage which contains script
if(!TVPScriptEngine) TVPThrowInternalError;
{ // for bytecode
ttstr place(TVPSearchPlacedPath(name));
ttstr shortname(TVPExtractStorageName(place));
tTJSBinaryStream* stream = TVPCreateBinaryStreamForRead(place, modestr);
if( stream ) {
bool isbytecode = false;
try {
isbytecode = TVPScriptEngine->LoadByteCode( stream, result, context, shortname.c_str() );
} catch(...) {
delete stream;
throw;
}
delete stream;
if( isbytecode ) return;
}
}
ttstr place(TVPSearchPlacedPath(name));
ttstr shortname(TVPExtractStorageName(place));
iTJSTextReadStream * stream = TVPCreateTextStreamForRead(place, modestr);
ttstr buffer;
try
{
stream->Read(buffer, 0);
}
catch(...)
{
stream->Destruct();
throw;
}
stream->Destruct();
if(TVPScriptEngine)
{
if(!isexpression)
TVPScriptEngine->ExecScript(buffer, result, context,
&shortname);
else
TVPScriptEngine->EvalExpression(buffer, result, context,
&shortname);
}
}
由上述代码,可知
- 存档读取的实质就是把存档文件当作 TJS 脚本或表达式让 TJS 引擎执行,意味着存档内容实质上就是一种特殊形式的 TJS 脚本(或表达式),执行(或者说 evaluate)它即可以更新游戏状态变量,达到存档读取的效果。
-
执行函数既首先把待执行的脚本文件当作是被编译成字节码(bytecode)的二进制脚本尝试执行
TVPScriptEngine->LoadByteCode()
。如果执行成功则返回,如果执行不成功则当作明文脚本再尝试执行(TVPScriptEngine->ExecScript()/EvalExpression()
)。
(实际上,无论是 Scripts.evalStorage()
还是 Scripts.execStorage()
,都是通过调用 TVPExecuteStorage()
来"执行脚本",由参数 isexpression
区分是否限定为表达式。)
存档内容是脚本字节码?
到目前为止,krkr 存档读取的机制和存档文件的内容实质基本已经探明了。接下来想办法解析存档文件。
存档文件本质上就是 TJS 脚本或表达式,但存档文件却不是明文的,因此笔者猜测其可能是 TJS 字节码(剧透:猜测错误),于是笔者尝试使用 krkr 字节码反汇编/反编译工具,看看能否将存档文件反编译为汇编指令码或脚本程序。
笔者找到了两款与 krkr 字节码反编译有关的开源工具:
Tjs2Disassembler:https://github.com/Project-AZUSA/KirikiriSharp/tree/master/Tjs2Disassembler
- 可将 TJS2 字节码反汇编成汇编指令码
Furikiri:https://github.com/UlyssesWu/Furikiri
- 可将字节码直接反编译为 TJS2 脚本代码,而不是难阅读的汇编指令码。
(顺便一提,上述两款由大佬编写的工具均由 C# 编写,虽然开源但没有发布编译好的二进制 Release,无法开箱即用,需要自行配置环境编译才能使用。这对于或许也对此感兴趣的一般游戏玩家而言,略微麻烦,不太利于工具的交流推广。)
过程略,直接说结果。结果是:用它们反编译存档文件 .ksd
均报失败。
之后会发现,这是因为存档文件根本不是 TJS/KS 脚本字节码,之前对“存档文件是脚本字节码”的猜测完全错了。
真相:存档实际上是明文脚本混淆/压缩
反编译失败,一筹莫展之际,笔者以存档文件的文件头特征 "FE FE 02 FF FE" 和 "krkr" 为关键字在搜索引擎中进行检索。虽然之前也尝试过,这回注意到了 KirikiriTools 项目(https://github.com/arcusmaximus/KirikiriTools),因为该项目在说明中指出:
Some Kirikiri games have their plaintext scripts (.ks/.tjs) scrambled or compressed. Such files can be recognized by the signature "FE FE XX FF FE" at the start, XX being 00, 01 or 02. KirikiriDescrambler turns them into regular text files which can be placed right back in the game - no rescrambling needed.
原来这类文件的文件头特征实际上是 FE FE __ FF FE
,其中中间的第三个字节目前已知可取 00
, 01
, 02
三种模式。笔者手上某款游戏的存档文件恰好是 02
模式罢了。这类文件实际上是经打乱或压缩(混淆)的 KS 或 TJS 脚本。
KirikiriTools 是一套针对 kirikiri 引擎的实用工具集,其中包含了一款名为 KirikiriDescrambler 的实用工具,正是用于解混淆(descramble)此类混淆的脚本文件。KirikiriTools 也是用 C# 编写的,且作者有发布开箱即用的二进制 Release,爱了爱了。事不宜迟,赶紧尝试用 KirikiriDescrambler 揭开 krkr 存档的明文内容:
PS > .\KirikiriDescrambler.exe datasc.ksd
PS > .\KirikiriDescrambler.exe datasu.ksd
(↑ ⚠ 注意:目前 KirikiriDescrambler 会将解码后的明文内容覆盖写回原文件,正常情况下 kirikiri 引擎能自动识别兼容,正常解析,与解混淆前无异;如有担心可备份文件。)
KirikiriTools 工具最终成功解码了笔者的存档文件 datasc.ksd
和 datasu.ksd
,存档的明文内容是 TJS 表达式,记录着游戏状态变量和对应值。
查阅 KirikiriTools 的源代码,才得知原来 02
模式实际上仅仅是把明文的 TJS 表达式进行了 DEFLATE 压缩而已,而非编译后的字节码,因此解压(Decompress)载荷内容即可获得明文,算是一种混淆的 plaintext script
。这也解释了之前为什么字节码反编译失败,因为存档根本不是字节码。
https://github.com/arcusmaximus/KirikiriTools/blob/1.0/KirikiriDescrambler/Descrambler.cs#L41-L43
public static string Descramble(Stream stream)
{
BinaryReader reader = new BinaryReader(stream);
byte[] magic = reader.ReadBytes(2);
if (magic[0] != 0xFE || magic[1] != 0xFE)
throw new InvalidDataException("Not a scrambled Kirikiri file.");
byte mode = reader.ReadByte();
byte[] bom = reader.ReadBytes(2);
if (bom[0] != 0xFF || bom[1] != 0xFE)
throw new InvalidDataException("Not a scrambled Kirikiri file.");
byte[] utf16;
switch (mode)
{
case 0:
utf16 = DescrambleMode0(reader);
break;
case 1:
utf16 = DescrambleMode1(reader);
break;
case 2:
utf16 = Decompress(reader);
break;
default:
throw new NotSupportedException($"File uses unsupported scrambling mode {mode}");
}
return Encoding.Unicode.GetString(utf16);
}
总结
kirikiri (krkr) 引擎的存档文件(文件后缀 .ksd
/ .kdt
,常见的为 datasu.ksd
和 datasc.ksd
)本质上是 TJS 脚本,但直接打开它们,看到的是乱码而非明文,因为存档内容(即脚本)被打乱或压缩混淆了,它们的文件头特征为 FE FE __ FF FE
(__
可为 00
~02
等类型标识 );使用本文上述提到的 KirikiriDescrambler 工具(使用方法见上),将 krkr 存档文件还原为明文后,就可以直接明文查看和修改 krkr 存档了。
后记
笔者解码一些 krkr 游戏的存档后发现,游戏并没有将剧情分支和进度等关键信息存储在 datasu.ksd
和 datasc.ksd
文件中,而是存储到了存档的画面预览缩略图中(??),而且缩略图仍平平无奇地挂着 .bmp
文件后缀(???),不知道的人恐怕根本想不到存档缩略图里竟然还包含存档信息,而不只是纯图片。非常有意思,但愿玩家在备份这些 krkr 游戏存档的时候,不要忘记备份缩略图。关于这点日后如有机会笔者再详细展开介绍。
评论
感謝大大,這篇的講解及提供的github救了我的存檔QQ
感谢大佬, 话说后续这个bmp里面嵌入剧情分支和进度等关键信息 的实现能说明下吗
发表评论