跳转至

📂 Unity 逆向工程实战:Naninovel 剧本自动化提取与重构

项目代号:Project SweetHome (纸房子) 涉及技术C# Reflection (反射机制), Runtime Analysis (运行时分析), Regular Expressions (正则匹配), UnityExplorer

1. 项目背景与挑战

在对 Unity 引擎制作的游戏《纸房子》进行逆向分析时,我试图提取游戏剧本用于攻略制作。然而,该游戏使用了 Naninovel 引擎的 Managed Text(托管文本) 机制,导致数据层出现了严重的“逻辑与内容分离”现象:

  • 逻辑层:包含跳转、变量判断,但对话内容被替换为了 Hash ID(如 #~3fb402f3)。
  • 数据层:真实的文本被加密存储在内存深处的本地化字典中。

为了还原可读的剧本,我经历了一套完整的“侦查 -> 破解 -> 重构”的技术迭代过程。


2. 第一阶段:黑盒透视 (逻辑架构提取)

2.1 初始尝试

最初,我的目标是理解剧本的基本运行逻辑。通过 UnityExplorer,我编写了一个基于反射的“透视脚本”,试图强制读取内存中 Naninovel.Script 对象的指令流。

此脚本的主要功能是绕过 private 权限,提取所有隐藏的参数值。

2.2 阶段性代码 (X-Ray Scan)

更新日志:增加了对 LabelScriptLine (标签行) 的识别,以便追踪 Goto 指令的具体落点。

🔻 点击展开:逻辑架构透视脚本 (C#) - V2.0
// --- [1] 初始化输出构建器 ---
var sb = new System.Text.StringBuilder();
sb.AppendLine("《纸房子》全字段无差别透视版 (含标签) - " + System.DateTime.Now.ToString());
sb.AppendLine("--------------------------------------------------");

// --- [2] 设定目标范围 ---
var targetNames = new System.Collections.Generic.HashSet<string>() { "Script1", "Newscript", "Newscript1" };
var foundScripts = new System.Collections.Generic.List<Naninovel.Script>();

// --- [3] 锁定剧本 ---
var scripts = UnityEngine.Resources.FindObjectsOfTypeAll<Naninovel.Script>();
foreach (var s in scripts) if (targetNames.Contains(s.name)) foundScripts.Add(s);

if (foundScripts.Count == 0) {
    var loaded = UnityEngine.Resources.LoadAll<Naninovel.Script>("");
    foreach (var s in loaded) if (targetNames.Contains(s.name)) foundScripts.Add(s);
}

// --- [4] 定义核心工具 ---
System.Func<object, string> GetVal = (obj) => {
    if (obj == null) return null;
    try {
        var t = obj.GetType();
        if (!t.Namespace.StartsWith("Naninovel")) return null;
        var hv = t.GetProperty("HasValue");
        if (hv != null && !(bool)hv.GetValue(obj)) return null;
        return t.GetProperty("Value")?.GetValue(obj)?.ToString();
    } catch { return null; }
};

System.Func<object, string> InspectAllFields = (cmdObj) =>
{
    if (cmdObj == null) return "";
    var info = new System.Collections.Generic.List<string>();
    try {
        var fields = cmdObj.GetType().GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
        foreach (var f in fields)
        {
            if (f.Name == "LineNumber" || f.Name == "Indent" || f.Name == "PlaybackSpot") continue;
            var valObj = f.GetValue(cmdObj);
            var strVal = GetVal(valObj);
            if (!string.IsNullOrEmpty(strVal)) info.Add($"{f.Name}=[{strVal}]");
        }
    } catch {}
    if (info.Count > 0) return " (" + string.Join(", ", info.ToArray()) + ")";
    return "";
};

// --- [5] 开始遍历 ---
foreach (var script in foundScripts)
{
    sb.AppendLine();
    sb.AppendLine($"📄 剧本: {script.name}");
    sb.AppendLine("--------------------------------------------------");
    int lineIndex = 0;

    foreach (var line in script.Lines)
    {
        lineIndex++;
        if (line == null) continue;
        string lType = line.GetType().Name;

        // A. 剧情文本
        if (lType == "GenericTextScriptLine")
        {
            try {
                var listField = line.GetType().GetField("inlinedCommands", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
                var list = listField?.GetValue(line) as System.Collections.IList;
                if (list != null && list.Count > 0)
                {
                    var sbLine = new System.Text.StringBuilder();
                    foreach (var cmd in list)
                    {
                        var textProp = cmd.GetType().GetField("Text");
                        var authProp = cmd.GetType().GetField("AuthorId");
                        var text = GetVal(textProp?.GetValue(cmd));
                        var auth = GetVal(authProp?.GetValue(cmd));
                        if (!string.IsNullOrEmpty(auth)) sbLine.Append(auth + ":");
                        if (!string.IsNullOrEmpty(text)) sbLine.Append(text);
                    }
                    if (sbLine.Length > 0) sb.AppendLine($"   [{lineIndex}] [剧情] " + sbLine.ToString());
                }
            } catch {}
        }
        // B. 标签 (★ 修复新增 ★)
        else if (lType == "LabelScriptLine")
        {
            try {
                var labelProp = line.GetType().GetProperty("LabelText");
                var labelVal = labelProp?.GetValue(line)?.ToString();
                if (!string.IsNullOrEmpty(labelVal))
                {
                    sb.AppendLine($"   [{lineIndex}] 🔖 [标签] {labelVal}");
                }
            } catch {}
        }
        // C. 逻辑指令
        else if (lType == "CommandScriptLine")
        {
            var cmdProp = line.GetType().GetProperty("Command");
            if (cmdProp != null)
            {
                var cmdObj = cmdProp.GetValue(line);
                if (cmdObj != null)
                {
                    string cName = cmdObj.GetType().Name;
                    string allParams = InspectAllFields(cmdObj);

                    if (cName.Contains("Choice")) sb.AppendLine($"   [{lineIndex}] 🔘 [选项] {allParams}");
                    else if (cName == "Set" || cName == "SetCustomVariable") sb.AppendLine($"   [{lineIndex}] 🔧 [变量] {allParams}");
                    else if (cName == "Goto") sb.AppendLine($"   [{lineIndex}] 🔀 [跳转] {allParams}");
                    else if (cName == "Stop") sb.AppendLine($"   [{lineIndex}] 🛑 [停止] {allParams}");
                    else if (cName == "Else" || cName == "ElseIf") sb.AppendLine($"   [{lineIndex}] 🔹 [分支判定] {cName} {allParams}");
                    else if (cName == "If" || cName == "BeginIf" || cName == "EndIf") sb.AppendLine($"   [{lineIndex}] 🔹 [逻辑块] {cName} {allParams}");
                    else if (!string.IsNullOrEmpty(allParams) && allParams.Contains("ConditionalExpression")) sb.AppendLine($"   [{lineIndex}] ⚙️ [{cName}] {allParams}");
                }
            }
        }
    }
}

// --- [6] 导出 ---
var finalContent = sb.ToString();
finalContent = finalContent.Replace("WYH", "王艺菡").Replace("LT", "陆婷").Replace("XMM", "徐敏敏").Replace("HYH", "贺老师");
finalContent = finalContent.Replace(" A ", " [进度锁A] ").Replace(" B ", " [进度锁B] ");

var desktop = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Desktop);
var exportPath = System.IO.Path.Combine(desktop, "PaperHouse_XRAY_Scan_Labeled.txt");

System.IO.File.WriteAllText(exportPath, finalContent, System.Text.Encoding.UTF8);
Naninovel.Engine.Log("✅ 全字段透视提取完成!请查看: " + exportPath);

2.3 结果分析

这一步成功提取了游戏的骨架,但暴露了一个关键问题:

💡 发现:生成的报告中,大量出现了类似于 [剧情] wyh:Newscript #36.1 |#~3fb402f3| 的内容。这是因为游戏启用了 Managed Text 模式,文本被哈希 ID 替代。


3. 第二阶段:数据挖掘 (本地化字典提取)

3.1 深入内存分析

为了找到“消失的文本”,我通过对象分析器(Object Inspector)追踪了 Naninovel.Script 对象的内部结构。经过排查,数据的真实存储路径在私有字段 TextMap 的嵌套对象 idToText 中。

3.2 阶段性代码 (Dictionary Dump)

🔻 点击展开:字典提取脚本 (C#)
// --- [1] 初始化 ---
var sb = new System.Text.StringBuilder();
sb.AppendLine("《纸房子》字典文本深度提取版 - " + System.DateTime.Now.ToString());
sb.AppendLine("--------------------------------------------------");

// --- [2] 查找剧本 ---
var scripts = UnityEngine.Resources.FindObjectsOfTypeAll<Naninovel.Script>();
sb.AppendLine($"系统中共找到 {scripts.Length} 个剧本文件");

// --- [3] 钻取私有字段 ---
foreach (var script in scripts)
{
    if (script == null) continue;
    try 
    {
        // 反射获取 TextMap
        var sType = script.GetType();
        var mapField = sType.GetField("TextMap", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) 
                    ?? sType.GetField("textMap", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
        var mapProp = sType.GetProperty("TextMap") ?? sType.GetProperty("textMap");

        object textMapObj = null;
        if (mapProp != null) textMapObj = mapProp.GetValue(script);
        else if (mapField != null) textMapObj = mapField.GetValue(script);

        if (textMapObj != null)
        {
            // 精准打击 idToText
            var targetField = textMapObj.GetType().GetField("idToText", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
            object finalDataMap = (targetField != null) ? targetField.GetValue(textMapObj) : textMapObj;

            if (finalDataMap != null)
            {
                var dType = finalDataMap.GetType();
                var keysProp = dType.GetProperty("Keys") ?? dType.GetProperty("keys");
                var valsProp = dType.GetProperty("Values") ?? dType.GetProperty("values");

                if (keysProp != null && valsProp != null)
                {
                    var keys = keysProp.GetValue(finalDataMap) as System.Collections.Generic.ICollection<string>;
                    var vals = valsProp.GetValue(finalDataMap) as System.Collections.Generic.ICollection<string>;

                    if (keys != null && vals != null)
                    {
                        var kList = new System.Collections.Generic.List<string>(keys);
                        var vList = new System.Collections.Generic.List<string>(vals);

                        if (kList.Count > 0)
                        {
                            sb.AppendLine();
                            sb.AppendLine($"📂 剧本源 [{script.name}] - 包含 {kList.Count} 条目");
                            sb.AppendLine("--------------------------------------------------");
                            for (int i = 0; i < kList.Count; i++)
                            {
                                if (!string.IsNullOrEmpty(kList[i])) 
                                    sb.AppendLine($"{kList[i]} = {vList[i]}");
                            }
                        }
                    }
                }
            }
        }
    }
    catch {}
}

var desktop = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Desktop);
var exportPath = System.IO.Path.Combine(desktop, "PaperHouse_Dictionary_Dump.txt");
System.IO.File.WriteAllText(exportPath, sb.ToString(), System.Text.Encoding.UTF8);
Naninovel.Engine.Log("✅ 字典提取完成!文件已生成: " + exportPath);

4. 第三阶段:终极重构 (Runtime Linker)

4.1 数据清洗与合并

虽然我分别拿到了“逻辑”和“文本”,但手动比对效率极低。且剧本中的 ID 夹带元数据(如 Newscript #36.1 |#~Hash),导致无法直接匹配字典。

为此,我开发了最终版的运行时连接器,并合入了最新的标签 (Label) 识别功能

4.2 最终解决方案代码 (All-in-One)

该脚本专为 UnityExplorer 的 REPL 环境设计,集成了正则清洗自动翻译标签锚点识别变量汉化四大功能。

🔻 点击查看:全能重构工具代码 (Final Version)
// =============================================================
//  工具名称:Naninovel 剧本自动化合并与清洗脚本 (V3.0 Final)
//  环境兼容:UnityExplorer (REPL Safe Mode)
//  核心功能:
//    1. 内存字典构建 (Memory Mapping)
//    2. 正则表达式清洗 (Regex Cleaning)
//    3. 标签锚点识别 (Label Extraction) ★修复合并★
//    4. 变量可视化翻译 (Variable Localization)
// =============================================================

// --- [1] 初始化环境 ---
var sb = new System.Text.StringBuilder();
sb.AppendLine("《纸房子》剧本完美重构报告 - " + System.DateTime.Now.ToString());
sb.AppendLine("--------------------------------------------------");

// --- [2] 构建全局哈希索引 (Global Indexing) ---
var globalTextMap = new System.Collections.Generic.Dictionary<string, string>();
var allScripts = UnityEngine.Resources.FindObjectsOfTypeAll<Naninovel.Script>();

sb.AppendLine($"[System] 检测到 {allScripts.Length} 个剧本资源,开始构建索引...");

foreach (var script in allScripts)
{
    if (script == null) continue;
    try 
    {
        // 获取 TextMap 容器
        var sType = script.GetType();
        var mapField = sType.GetField("TextMap", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) ?? sType.GetField("textMap", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
        var mapProp = sType.GetProperty("TextMap") ?? sType.GetProperty("textMap");

        object textMapObj = null;
        if (mapField != null) textMapObj = mapField.GetValue(script);
        else if (mapProp != null) textMapObj = mapProp.GetValue(script);

        if (textMapObj != null)
        {
            // 钻取 idToText
            var targetField = textMapObj.GetType().GetField("idToText", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
            object finalDataMap = (targetField != null) ? targetField.GetValue(textMapObj) : textMapObj;

            if (finalDataMap != null)
            {
                var dType = finalDataMap.GetType();
                var kProp = dType.GetProperty("Keys") ?? dType.GetProperty("keys");
                var vProp = dType.GetProperty("Values") ?? dType.GetProperty("values");

                if (kProp != null && vProp != null)
                {
                    var keys = kProp.GetValue(finalDataMap) as System.Collections.Generic.ICollection<string>;
                    var vals = vProp.GetValue(finalDataMap) as System.Collections.Generic.ICollection<string>;

                    if (keys != null && vals != null)
                    {
                        var kList = new System.Collections.Generic.List<string>(keys);
                        var vList = new System.Collections.Generic.List<string>(vals);

                        for (int i = 0; i < kList.Count; i++)
                        {
                            string k = kList[i];
                            string v = vList[i];
                            if (!string.IsNullOrEmpty(k) && !globalTextMap.ContainsKey(k))
                                globalTextMap.Add(k, v);
                        }
                    }
                }
            }
        }
    }
    catch {}
}

sb.AppendLine($"[Success] 索引构建完毕,共载入 {globalTextMap.Count} 条本地化数据。");
sb.AppendLine("--------------------------------------------------");


// --- [3] 核心翻译算法 ---
System.Func<string, string> TryTranslate = (rawText) => {
    if (string.IsNullOrEmpty(rawText)) return "";
    // 1. 直接匹配
    if (globalTextMap.ContainsKey(rawText)) return globalTextMap[rawText];
    // 2. 正则清洗 (System.Text.RegularExpressions)
    var match = System.Text.RegularExpressions.Regex.Match(rawText, @"~[a-zA-Z0-9]+");
    if (match.Success)
    {
        string extractedHash = match.Value;
        if (globalTextMap.ContainsKey(extractedHash)) return globalTextMap[extractedHash];
    }
    return rawText;
};

// 辅助函数
System.Func<object, string> GetNaninovelVal = (obj) => {
    if (obj == null) return null;
    try {
        var t = obj.GetType();
        if (!t.Namespace.StartsWith("Naninovel")) return null;
        var hv = t.GetProperty("HasValue");
        if (hv != null && !(bool)hv.GetValue(obj)) return null;
        return t.GetProperty("Value")?.GetValue(obj)?.ToString();
    } catch { return null; }
};


// --- [4] 剧本遍历与重组 ---
foreach (var script in allScripts)
{
    // 可选:过滤
    // if (!script.name.Contains("Script")) continue;

    sb.AppendLine();
    sb.AppendLine($"📄 剧本: {script.name}");
    sb.AppendLine("--------------------------------------------------");
    int lineIndex = 0;

    foreach (var line in script.Lines)
    {
        lineIndex++;
        if (line == null) continue;
        string lType = line.GetType().Name;

        // Type A: 对话文本行
        if (lType == "GenericTextScriptLine")
        {
            try {
                var listField = line.GetType().GetField("inlinedCommands", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
                var list = listField?.GetValue(line) as System.Collections.IList;
                if (list != null && list.Count > 0)
                {
                    var sbLine = new System.Text.StringBuilder();
                    foreach (var cmd in list)
                    {
                        var textProp = cmd.GetType().GetField("Text");
                        var authProp = cmd.GetType().GetField("AuthorId");

                        var rawText = GetNaninovelVal(textProp?.GetValue(cmd));
                        var rawAuth = GetNaninovelVal(authProp?.GetValue(cmd));

                        // 翻译
                        var cleanText = TryTranslate(rawText);
                        var cleanAuth = TryTranslate(rawAuth);

                        if (!string.IsNullOrEmpty(cleanAuth)) sbLine.Append(cleanAuth + ":");
                        if (!string.IsNullOrEmpty(cleanText)) sbLine.Append(cleanText);
                    }
                    if (sbLine.Length > 0) sb.AppendLine($"   [{lineIndex:D4}] {sbLine}");
                }
            } catch {}
        }
        // Type B: 标签行 (★ 新增合并 ★)
        else if (lType == "LabelScriptLine")
        {
            try {
                var labelProp = line.GetType().GetProperty("LabelText");
                var labelVal = labelProp?.GetValue(line)?.ToString();
                if (!string.IsNullOrEmpty(labelVal))
                {
                    sb.AppendLine($"   [{lineIndex:D4}] 🔖 [标签] {labelVal}");
                }
            } catch {}
        }
        // Type C: 逻辑指令行
        else if (lType == "CommandScriptLine")
        {
            var cmdProp = line.GetType().GetProperty("Command");
            if (cmdProp != null)
            {
                var cmdObj = cmdProp.GetValue(line);
                if (cmdObj != null)
                {
                    string cName = cmdObj.GetType().Name;
                    var fields = cmdObj.GetType().GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
                    var paramInfo = new System.Collections.Generic.List<string>();

                    foreach (var f in fields)
                    {
                         if (f.Name == "LineNumber" || f.Name == "Indent" || f.Name == "PlaybackSpot") continue;

                         var valObj = f.GetValue(cmdObj);
                         var strVal = GetNaninovelVal(valObj);

                         if (!string.IsNullOrEmpty(strVal))
                         {
                             // 尝试翻译选项文本和带 Hash 的参数
                             if (cName.Contains("Choice") && f.Name.Contains("ChoiceText")) strVal = TryTranslate(strVal);
                             else if (strVal.Contains("~")) strVal = TryTranslate(strVal);

                             paramInfo.Add($"{f.Name}=[{strVal}]");
                         }
                    }
                    string allParams = string.Join(", ", paramInfo.ToArray());

                    if (cName.Contains("Choice")) sb.AppendLine($"   [{lineIndex:D4}] 🔘 [选项] {allParams}");
                    else if (cName == "Set" || cName == "SetCustomVariable") sb.AppendLine($"   [{lineIndex:D4}] 🔧 [变量] {allParams}");
                    else if (cName == "Goto") sb.AppendLine($"   [{lineIndex:D4}] 🔀 [跳转] {allParams}");
                    else if (cName == "Stop") sb.AppendLine($"   [{lineIndex:D4}] 🛑 [停止]");
                    else if (cName.StartsWith("Else")) sb.AppendLine($"   [{lineIndex:D4}] 🔹 [分支] {cName} {allParams}");
                    else if (cName.EndsWith("If")) sb.AppendLine($"   [{lineIndex:D4}] 🔹 [逻辑] {cName} {allParams}");
                }
            }
        }
    }
}

// --- [5] 导出与后处理 ---
var finalContent = sb.ToString();

finalContent = finalContent.Replace("WYH", "王艺菡")
                           .Replace("LT", "陆婷")
                           .Replace("XMM", "徐敏敏")
                           .Replace("HYH", "贺老师")
                           .Replace("zy", "主角")
                           .Replace("Expression=", "计算: ");

var desktop = System.Environment.GetFolderPath(System.Environment.SpecialFolder.Desktop);
var exportPath = System.IO.Path.Combine(desktop, "PaperHouse_Final_Merged.txt");

System.IO.File.WriteAllText(exportPath, finalContent, System.Text.Encoding.UTF8);
Naninovel.Engine.Log("✅ 剧本完美重构完成!输出文件: " + exportPath);

5. 最终成果

通过上述工具的处理,我成功将原始的“黑盒”剧本转化为完全可读的中文文档。现在,不仅能看到对话,还能通过标签 (Label) 精确追踪剧情跳转的逻辑。

🔴 处理前 (Raw Data):

[36] [剧情] wyh:Newscript #36.1 |#~3fb402f3| [37] 🔘 [选项] ChoiceText=~9be251d0 GotoPath=Scene2

🟢 处理后 (Processed Data):

[0036] 王艺菡:怎么今天早上迟到了这么久? [0037] 🔘 [选项] ChoiceText=[2017年 12月 5日] GotoPath=[Scene2] [0045] 🔖 [标签] Scene2_Start

该方案不仅解决了本地化文本提取的问题,还为后续的攻略逻辑分析提供了最直观的数据支持。

⛇点击下载纸房子完整剧本