探究 kirikiri 引擎的存档文件 .ksd / .kdt 内容格式(解析)和存档机制

作者:一年又一年 分类: 📐 技术 发布时间:2020-11-25 18:01 10208 次浏览 2 条评论

引言

零知识背景:(ki)(ri)(ki)(ri) 或 kirikiri(简称 krkr)引擎,是一套由 W. Dee [日本程序员] 开发的开源免费视觉小说游戏引擎框架,其提供了一套标记和脚本语言(KAG、TJS)方便游戏制作者快速实现此类游戏常用的界面、流程、音画演出效果和数据管理,使制作者更专注于内容创作而不是底层程序开发;该引擎目前已被广泛应用,不少成功的商业作品正是基于该引擎制作。

笔者观察到一些基于 kirikiri 引擎的游戏的存档文件为 datasc.ksddatasu.ksd,检阅其内容,发现它们是二进制文件,文件头特征为 FE FE 02 FF FE

出于对 krkr 存档内容的好奇,笔者想了解存档文件 .ksd 的文件格式和语义,解析 krkr 存档内容。

krkr2 官方文档之文件说明章节提及,*.ksd*.kdt 文件是 "KAGのセーブデータ" (KAG save data) 即 KAG(krkr) 存档数据。

游戏程序对 .ksd / .kdt 存档文件的读写

查阅 kirikiri 引擎(具体版本是 krkr2)源代码仓库,发现其提供了一套实现游戏逻辑的演示脚本模板(参考实现);找到了其中读取存档函数的代码片段

https://github.com/krkrz/krkr2/blob/master/kirikiri2/trunk/kag3/template/system/MainWindow.tjs#L1011-L1045

// 为排版方便,对代码换行缩进做了适当调整。
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.ksddatasu.ksd,这一事实印证了上述程序实现。
  • (参考实现正如官方文档所约定,使用 .ksd 作为文件扩展名后缀;但由函数接口可知引擎并不强制约束后缀,调用者可以随意定后缀,只需把完整文件路径传给引擎即可。虽然功能可行,但大多数游戏都没有自定义存档扩展名。)
  • TJS 脚本层面通过调用引擎提供的 Scripts.evalStorage(存档文件路径) 函数实现加载存档功能调用。

Scripts.evalStorage()

查阅 krkr2 脚本文档,其中对 Scripts.evalStorage() 的描述非常简略:

ストレージ上の式の評価:指定されたストレージを読み込み、その内容を TJS2 式として評価します。

Evaluate 存储表达式:读取指定的存储并将其内容作为 TJS2 表达式 Evaluate。

并且提到可参考函数 Scripts.execStorage()。区别在于前者是 evaluate 表达式,后者是执行(execute)脚本。

查看 TJS 引擎中对 Scripts.evalStorage() 函数的具体底层实现,代码片段如下。

https://github.com/krkrz/krkr2/blob/master/kirikiri2/trunk/kirikiri2/src/core/base/ScriptMgnIntf.cpp#L1083-L1099

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() 函数的实现,代码片段如下。

https://github.com/krkrz/krkr2/blob/master/kirikiri2/trunk/kirikiri2/src/core/base/ScriptMgnIntf.cpp#L686-L734

//---------------------------------------------------------------------------
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 字节码反编译有关的开源工具:

Tjs2Disassemblerhttps://github.com/Project-AZUSA/KirikiriSharp/tree/master/Tjs2Disassembler

  • 可将 TJS2 字节码反汇编成汇编指令码

Furikirihttps://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.ksddatasu.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.ksddatasc.ksd)本质上是 TJS 脚本,但直接打开它们,看到的是乱码而非明文,因为存档内容(即脚本)被打乱或压缩混淆了,它们的文件头特征为 FE FE __ FF FE__ 可为 00~02 等类型标识 );使用本文上述提到的 KirikiriDescrambler 工具(使用方法见上),将 krkr 存档文件还原为明文后,就可以直接明文查看和修改 krkr 存档了。

后记

笔者解码一些 krkr 游戏的存档后发现,游戏并没有将剧情分支和进度等关键信息存储在 datasu.ksddatasc.ksd 文件中,而是存储到了存档的画面预览缩略图中(??),而且缩略图仍平平无奇地挂着 .bmp 文件后缀(???),不知道的人恐怕根本想不到存档缩略图里竟然还包含存档信息,而不只是纯图片。非常有意思,但愿玩家在备份这些 krkr 游戏存档的时候,不要忘记备份缩略图。关于这点日后如有机会笔者再详细展开介绍。

kirikiri krkr 存档 TJS

♥ 若您欲转载敝站的原创内容,还请您附注出处及相应链接

评论

  1. Avatar of Alex
    Alex 2022-10-11 11:12 回复

    感謝大大,這篇的講解及提供的github救了我的存檔QQ

  2. Avatar of uestclv
    uestclv 2021-05-02 15:05 回复

    感谢大佬, 话说后续这个bmp里面嵌入剧情分支和进度等关键信息 的实现能说明下吗

发表评论

* 标注的项目为必填项。

您的邮箱地址将不会在页面中公开
您的站点地址将会被检查,如被认为不适则可能被移除
Ɣ回顶部