↓ 跳过正文

游戏云存档被写坏:反编译游戏代码重建存档并回写云端

·6804 字·14 分钟
目录

把旧设备上的游戏进度同步到新设备,新设备一进游戏就崩在第一个活动事件的对话上,报错 Variable <unknown_object>.s_unit_0_1 not set before reading it。新设备未 ROOT,本地存档够不着;排查下来,被写坏的不是本地那份存档,而是云端那份。修法是:从游戏安装包里反编译出它自己的存档读写代码,用游戏自己的公式把本地存档解成真值,按游戏自己的字段表重建云端数据,再通过官方客户端接口写回并回读校验。本文记录完整过程,以及两个必须避开的坑。适用环境:Windows 11 分析机、两台 Android 真机(均未 ROOT)、LOST TRIGGER 1.66.668(com.studioshark.losttrigger,Google Play 已下架)、GameMaker 引擎、PlayFab 客户端接口、UndertaleModTool CLI 0.9.2,实测时间 2026-09-25。

一、症状与手上的材料
#

两台设备:A 是存档主体所在(能正常玩),B 是同步目标(未 ROOT)。在 A 上点云保存、在 B 上点云读取之后,B 启动游戏在第一个强制活动事件对话处崩:

ERROR in action number 1
of Alarm Event for alarm 10 for object o_event_sub2:
Variable <unknown_object>.s_unit_0_1(103272,-2147483648) not set before reading it.
at gml_Object_o_event_sub2_Alarm_10

手上的材料只有三样:游戏的安装包(.apks)、A 的存档目录(files/,35 个文件)、以及游戏界面里显示的那 16 位 ID。

先说清可改的范围。B 未 ROOT,游戏的私有目录进不去,清应用数据只会把 B 的本地也抹掉,所以 B 侧唯一能动的,是它从云端拉下来的那份数据。整个修复的目标因此很明确:修云端。

二、先确定改哪一份数据
#

第一反应是「把 A 的存档改好再同步一次」。这条路的收益是零,理由有两条:

  • 游戏读档是分层的:B 启动时读自己的本地文件,只有在玩家手动点云读取时才用云端数据覆盖内存。A 的本地文件不在 B 的读取路径上。
  • 关键字段在 A 的本地存档里本来就是对的。

所以顺序应该是先指定哪一份数据是坏的,再决定改哪一份。A 能正常玩、B 崩,坏数据必然只在影响 B 的那条路径上,也就是云端。

三、让游戏代码自己交代存档格式
#

3.1 从安装包里取出游戏数据
#

.apks 就是一个 zip。游戏本体不在 base.apk 里(那里只有 Android 壳与资源框架),GameMaker 的游戏数据在 split APK 的 assets/assets/ 下:

unzip -l game.apks
#   base.apk                            2.3 MB
#   split_DynamicAssetDeliveryFiles.apk  200 MB   ← 游戏数据在这里
unzip -o game.apks -d apk
unzip -o apk/split_DynamicAssetDeliveryFiles.apk -d gam
ls -la gam/assets/assets/
#   game.droid   121 MB   ← GameMaker 编译产物
#   options.ini   564 B

options.ini 是明文,能直接读到包名、版本号与打包开关;game.droid 的文件类型是 IFF data、开头为 FORM....GEN8,这是 GameMaker 的数据文件格式。

3.2 反编译 GML
#

GameMaker 的代码用 UndertaleModTool(一个通用的 GameMaker 数据文件工具)可以反编译回接近源码的 GML:

UndertaleModCli.exe info game.droid                 # 看条目规模
UndertaleModCli.exe dump game.droid -o dump_all -c UMT_DUMP_ALL

本作 dump 出 2396 个代码条目、65 MB。操作提示:dump 的日志只打印一行 Trying to load file: ... 之后就不再增长,看起来像卡死,其实它在安静地写文件,判进度看输出目录的文件数,不要根据日志判断。

3.3 从报错反推
#

GameMaker 的 Variable <unknown_object>.X not set before reading it 意思是「这个实例上变量 X 从来没被赋过值」。它不代表拼写错误,也不代表缺少资源。按这个方向查:

  1. 先排除 X 是资源。用 UTMT 的 .csx 脚本遍历 Data.Sprites 列出全部精灵名,确认里面没有 s_unit_0_1(只有 spine_0、spine_00 之类)。这一步花几分钟,省掉在资源表里白找的时间。
  2. 找赋值点:grep -rn "X *=[^=]" 的结果显示,赋值只写在两个条件分支里:
function script_spine() {
    if (global.p1_select == 1) { s_unit_0_1 = spine_0;  ... }
    if (global.p1_select == 2) { s_unit_0_1 = spine_00; ... }
}
  1. 于是问题变成「global.p1_select 为什么不是合法值」。再查这个全局变量的全部来源:
    • 本地:global.p1_select = ini_read_real("info", "44", 0);
    • 云端:global.p1_select = data.userdata.a27;

问题到这里已经从「某个立绘变量没赋值」缩小成「两个来源里哪个给了非法值」。对照两台设备:A 本地 INI 的 "44" 是合法值,云端读出来的 a27 是 0。

顺带看一眼云读取代码的健壮性:整个恢复段(本作是 1707 行字段赋值)只有三处存在性检查,每个 blob 一处(if (data.userdata != undefined) 这种整体检查),字段级没有任何守卫,云端给 0 就直接写进全局变量。

四、存档结构
#

4.1 本地:INI、编号键、多存档位
#

A 的 files/ 里是几组数据文件,无后缀是第一存档位,2/3/4 后缀是另外三位:

save_info.ini  save_data.ini  save_menu.ini  save_stat.ini
save_unit.ini  save_item.ini  save_achievement.ini  save_deck.ini  save_land.ini
save_cloud.ini   游戏自己写的「云端摘要」,只有一个存档位

键名是数字编号,看不出语义:

[info]
01="7.000000"
02="9.000000"
44="1.000000"

(数值为示意,不是某份真实存档的原值。)

语义只能从游戏代码里读:写入处是 ini_write_real("info", "44", global.p1_select),读取处是 global.p1_select = ini_read_real("info", "44", 0)。

4.2 偏移:INI 里的数值不等于游戏内的数值
#

本作的读取公式带常数偏移,两种写法都出现在同一个文件里:

global.check_tutorial = ini_read_real("info", "01", 0) - 5;
global.p1_gold        = floor(ini_read_real("info", "11", 0)) - 201;
global.p1_gold2       = floor(ini_read_real("info", "12", 0) - 84);

游戏内数值 = INI 值减去 K,本作出现的 K 有 5、7、40、84、180、201、340。直接拿 INI 里的数字当游戏内数值会得到错的字段值,重建云端时尤其致命,所以解析器必须照抄游戏的公式。

4.3 「文件不存在」不等于损坏
#

拿到的存档里没有 save_land.ini,只有 save_land3.ini 与 save_land4.ini。这说明那一组数据从来没有被写过,游戏内对应的全局变量本来就是 0,而不是文件损坏或缺失。这一点在重建阶段有用:这类字段没有本地来源,沿用云端现值即可,结果与设备启动后的状态一致。

4.4 云端:少数几个键,整份存档塞在 JSON 字符串里
#

云存档走 PlayFab。游戏把整份存档 json_stringify 成一个字符串塞进少数几个键,本作是 userdata、unitdata、etcdata 三个,字段名是 a1..a33、h1..h48、ag1..ag80 这种编号。字段与全局变量的对应关系同样在代码里:写入段的字段表就是字段定义(a27: global.p1_select 这样的顺序),读取段是逐行赋值。

五、先把云端数据读出来
#

5.1 账号标识与登录
#

游戏界面显示的 16 位 ID 就是它登录用的 CustomId,代码是:

global.player_id_s = string_copy(sha1_string_utf8(global.player_id), 1, 16);

global.player_id 来自 Google Play 的玩家 id。CustomId 登录不需要密码,所以这 16 位字符串等同账号钥匙,只应留在本机文件里,不要贴到聊天、工单或任何公开场合。

只读访问用官方客户端接口:

POST https://<TitleId>.playfabapi.com/Client/LoginWithCustomID
{"TitleId":"<TitleId>","CustomId":"<16 位 ID>","CreateAccount":false}
  → data.SessionTicket

POST https://<TitleId>.playfabapi.com/Client/GetUserData      (头 X-Authorization: SessionTicket)
{}
  → data.Data = { 键: {Value, Permission, LastUpdated} }

CreateAccount 必须是 false。填 true 会在标识不存在时新建一个账号,等于在别人的数据面里造号;只读排查没有任何理由开它。

5.2 读到了什么
#

  • userdata(1707 字段):本周目进度整段为 0,关键的 a27(主角选择)也是 0。
  • unitdata(352 字段)、etcdata(773 字段):与 A 的本地存档逐字段对得上(279/352、684/773 一致)。
  • LastUpdated:三个键的最后写入时间都很早,早于玩家印象里的那次云保存。这是判断「那次同步到底写没写上去」的硬证据。

「收藏类数据完好、本周目进度整段为 0」这个组合有很强的指向性,因为它对应一种特定的本地状态:上传那台设备的存档里,进度文件(save_info / save_data / save_land)缺失或为空,而收藏类文件(save_item / save_menu / save_stat / save_unit / save_achievement / save_deck)在。这种状态一旦上传过一次,之后每次「云读取(读到 0)然后云保存(把 0 传上去)」都会把它延续下去。

5.3 为什么上传失败长期不会被发现
#

反编译出来的上传调用点长这样:

Pf_Update_User_Data(st);      // 返回值直接丢弃

游戏自己的上传函数把 http_request 的句柄返回,但三处调用点都没有接返回值。再看异步回包的分发:登录、取数据、改显示名、取排行榜等都有对应分支处理,唯独没有上传对应的分支,上传的回包没有任何人看。也就是说,上传被拒、超时或者写错形状,玩家侧完全静默,界面上不会有任何迹象。判断云同步是否成功,只能去读云端。

六、重建:用游戏代码当规格,用本地存档当数据
#

思路是两张表对撞:

方向 来源
云端字段到全局变量(schema) 游戏写云存档的代码(字段表)
本地 INI 到全局变量(真值) 游戏读本地存档的公式(含偏移)

第一步,从 gameload_*.gml 里把所有形如 global.X = ... ini_read_real("<段>", "<键>", <默认值>) [+- K] 的行抽出来,得到「全局变量到(文件、段、键、偏移)」的映射。本作抽到 2845 个,未解析 0 行。注意偏移有两个位置,括号外与括号内都要覆盖,否则会静默漏字段(本例先漏了 1 个,补上内层模式后归零)。

第二步,读本地 INI,对每个全局变量按公式还原真值。

第三步,按 schema 遍历三个 blob 的字段:能解出本地值的用本地值,解不出的沿用云端现值。本作结果:

键 字段数 取本地 沿用云端 相对云端变化
userdata 1707 1594 113 348
unitdata 352 352 0 73
etcdata 773 772 1 88

userdata 里沿用云端的 113 个字段,主要就是本地没有对应文件的那些(见 4.3)。

七、写回:形状、备份、回读
#

7.1 先备份
#

写之前把三个键的原文(Value、LastUpdated、Permission)落盘成一份 JSON,同时准备一个回滚脚本。这一步后面救命了,见 7.2。

7.2 形状错了会把键删掉
#

Client/UpdateUserData 的请求体是「键到字符串」:

{"Data": {"userdata": "{\"a1\":1,...}"}}

第一次我写成了 GetUserData 的响应形状(键到 {"Value": ..., "Permission": ...} 的对象)。服务端返回 200 OK,DataVersion 也自增了,但三个键被整段删除,效果等同于把这些键放进 KeysToRemove。这是整件事里代价最大的一个失误,也说明写完之后必须回读:重新登录、重新 GetUserData、把每个键的 Value 与上传内容逐字节比对,再打印几个人工可读的关键字段确认。备份在手,所以这次没有造成损失。

7.3 验证结果
#

回读结果:三个键的 Value 与上传内容逐字节一致,DataVersion 从 70 变成 71,大小与重建产物相符(14087、3703、6981 字节),关键字段(主角选择、天数、难度、活动事件旗标)都变成与 A 本地一致的值。之后独立再登录读一次,确认这期间没有设备覆盖过它。

八、设备侧收尾:顺序错了会把坏数据传下去
#

  1. 在目标设备上进游戏,到读档界面执行云读取(クラウドからロード),确认不再崩、进度正确。本例到此解决。
  2. 在确认正常之前,绝不要在坏状态的设备上点云保存。云保存是整份内存状态上传,坏数据一旦上传就会覆盖刚修好的云端,之后每次「读坏数据,再保存」都会把 0 继续传下去。换设备、重装游戏的正确顺序是:先云读取、确认游戏正常,再考虑云保存。
  3. 兜底:如果坏状态的设备已经卡到界面点不动,在系统设置里清除该应用的存储数据,重开、登录后再云读取。
  4. 只存在于内存、不落地本地文件的字段(本例是据点开发一类),会在下一次云保存时由游戏自己补上云端,不补也不影响启动。

九、适用边界
#

可复用的前提:

  • 能拿到游戏安装包,且引擎的代码可以反编译(GameMaker 用 UndertaleModTool,其他引擎各有工具)。
  • 存档是自包含格式(INI、JSON 等),字段语义能从游戏代码里读出来。
  • 云存档走客户端可直接调用的接口(PlayFab 这类),而不是平台托管且没有客户端接口的存储(Google Play Games 的云存档、iCloud 之类)。
  • 坏的是云端那一条路径。如果本地文件才是坏源、设备又已 ROOT,从本地改更直接。

自我约束:只对自己的账号和存档使用;登录一律 CreateAccount:false;写前备份、写后回读;不发布、不传播游戏本体与资源。

十、清单
#

  • 先确定坏数据在哪一条路径上(本地还是云端),再决定改哪一份。
  • 从安装包取出游戏数据,反编译出存档读写代码。
  • 用报错里的变量名反查赋值点,再查这个 flag 的全部来源。
  • 从写入代码取 schema,从读取公式解本地真值(含常数偏移)。
  • 备份云端原文,准备回滚脚本。
  • 以「键到字符串」的形状调用 UpdateUserData。
  • 回读逐字节比对,并打印关键字段的人工可读值。
  • 目标设备先云读取验证,再考虑云保存。

附:脚本
#

四个脚本在同一目录下运行,目录约定是 <BASE>/dump_all/CodeEntries(反编译产物)、<BASE>/saveA/files(本地存档)、<BASE>/cloud_raw/values(云端现值,由 playfab_fetch.py 生成)。<BASE> 取脚本所在目录,也可以用第一个参数指定。

playfab_fetch.py(97 行):只读拉取云端存档并做字段审计
#!/usr/bin/env python
"""Read the LOST TRIGGER (PlayFab title 142A3) cloud save for a given in-game ID.

Usage: python playfab_fetch.py <16-char in-game ID>
Writes raw API responses to ./cloud_raw/*.json and prints only metadata
(key names, value lengths, missing-field audit). The PlayFab SessionTicket is
never printed.
"""
import json, os, sys, urllib.request, urllib.error, re

TITLE = "142A3"
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cloud_raw")
os.makedirs(OUT, exist_ok=True)


def post(path, body, ticket=None):
    url = f"https://{TITLE}.playfabapi.com/{path}"
    data = json.dumps(body).encode()
    req = urllib.request.Request(url, data=data, method="POST")
    req.add_header("Content-Type", "application/json")
    if ticket:
        req.add_header("X-Authorization", ticket)
    try:
        with urllib.request.urlopen(req, timeout=60) as r:
            return r.status, json.loads(r.read().decode())
    except urllib.error.HTTPError as e:
        raw = e.read().decode()
        try:
            return e.code, json.loads(raw)
        except Exception:
            return e.code, {"raw": raw}


def main():
    pid = sys.argv[1].strip() if len(sys.argv) > 1 else open("player_id.txt").read().strip()
    print(f"[i] CustomId length = {len(pid)}")

    st, resp = post("Client/LoginWithCustomID",
                    {"TitleId": TITLE, "CustomId": pid, "CreateAccount": False, "InfoRequestParameters": {"GetUserData": False}})
    json.dump(resp, open(os.path.join(OUT, "login.json"), "w"), indent=1)
    if resp.get("code") != 200:
        print("[x] login failed:", resp.get("errorCode"), "-", resp.get("errorMessage"))
        return
    d = resp["data"]
    ticket = d["SessionTicket"]
    print("[+] login ok; PlayFabId:", d.get("PlayFabId"), "| NewlyCreated:", d.get("NewlyCreated"))
    print("[i] SessionTicket length:", len(ticket), "prefix:", ticket[:6] + "…")

    st, resp = post("Client/GetUserData", {}, ticket)
    json.dump(resp, open(os.path.join(OUT, "userdata.json"), "w"), indent=1)
    if resp.get("code") != 200:
        print("[x] GetUserData failed:", resp.get("errorCode"), "-", resp.get("errorMessage"))
        return
    data = resp["data"].get("Data", {})
    print(f"[+] cloud keys: {sorted(data.keys())}")
    outdir = os.path.join(OUT, "values")
    os.makedirs(outdir, exist_ok=True)
    for k, v in sorted(data.items()):
        val = v.get("Value")
        print(f"    {k}: len={len(val) if val is not None else 0} LastUpdated={v.get('LastUpdated')}")
        if val is not None:
            open(os.path.join(outdir, k + ".json"), "w", encoding="utf-8").write(val)

    # ---- audit against the fields the installed version expects ----
    expected = {}
    src = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                       "dump_all/CodeEntries/gml_Object_o_popup_option_Step_0.gml")
    if os.path.exists(src):
        lines = open(src, encoding="utf-8", errors="replace").read().split("\n")
        bounds = {"userdata": (23, 1736), "unitdata": (1749, 2105), "etcdata": (2118, 2895)}
        for name, (a, b) in bounds.items():
            keys = []
            for ln in lines[a - 1:b]:
                m = re.match(r"\s*([A-Za-z_][A-Za-z0-9_]*):\s", ln)
                if m:
                    keys.append(m.group(1))
            expected[name] = keys
    for name, keys in expected.items():
        if name not in data:
            print(f"[!] cloud has NO '{name}' key (expected {len(keys)} fields)")
            continue
        try:
            got = json.loads(data[name]["Value"])
        except Exception as e:
            print(f"[!] '{name}' is not valid JSON: {e}")
            continue
        miss = [k for k in keys if k not in got]
        extra = [k for k in got if k not in keys]
        print(f"[audit] {name}: cloud fields={len(got)} expected={len(keys)} missing={len(miss)} extra={len(extra)}")
        if miss:
            print("        MISSING:", miss[:60])
        if extra:
            print("        EXTRA  :", extra[:60])


if __name__ == "__main__":
    main()
build_fixed_cloud.py(168 行):用游戏自己的公式从本地存档重建三个 blob
"""Build corrected cloud blobs from device A's local save files.

- schema (which key holds which global): from the game's own save code
  (gml_Object_o_popup_option_Step_0.gml)
- values: from device A's INI files, decoded with the game's own load formulas
  (gameload_*.gml, including the +K obfuscation offsets)
- fields with no local source (e.g. save_land.ini absent, or cloud-only fields)
  keep the value currently stored in the cloud.
"""
import json, os, re, sys, shutil, time

# 目录约定(都在 BASE 下):dump_all/CodeEntries(反编译产物)、saveA/files(本地存档)、
# cloud_raw/values(云端现值,playfab_fetch.py 的输出)。BASE 取脚本所在目录,或用 argv[1] 指定。
BASE = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.path.dirname(os.path.abspath(__file__))
CE = os.path.join(BASE, "dump_all/CodeEntries")
SAVE = os.path.join(BASE, "saveA/files")
CLOUD = os.path.join(BASE, "cloud_raw/values")

# ---------- 1. global -> (ini, section, key, op, offset) ----------
LOADERS = {
    "gml_GlobalScript_gameload_info.gml": "save_info.ini",
    "gml_GlobalScript_gameload_data.gml": "save_data.ini",
    "gml_GlobalScript_gameload_stat.gml": "save_stat.ini",
    "gml_GlobalScript_gameload_unit.gml": "save_unit.ini",
    "gml_GlobalScript_gameload_item.gml": "save_item.ini",
    "gml_GlobalScript_gameload_menu.gml": "save_menu.ini",
    "gml_GlobalScript_gameload_achievement.gml": "save_achievement.ini",
    "gml_GlobalScript_gameload_land.gml": "save_land.ini",
    "gml_GlobalScript_gameload_deck.gml": "save_deck.ini",
}
# global = [floor(] ini_read_real("sec","key",def) [)] [ -/+ K] ;
READ = re.compile(
    r'global\.([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:(floor|round|int64)\()?\s*'
    r'ini_read_(real|string)\("([^"]+)",\s*"([^"]+)",\s*([^)]*)\)\s*\)?\s*([-+]\s*[0-9.]+)?\s*;')
# variant where the offset sits inside the floor(): floor(ini_read_real(s,k,d) - 84)
READ_INNER = re.compile(
    r'global\.([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:(floor|round|int64)\()?\s*'
    r'ini_read_(real|string)\("([^"]+)",\s*"([^"]+)",\s*([^(]*?)\)\s*([-+]\s*[0-9.]+)\)\s*;')
gl_map, unsupported = {}, []
for fn, inif in LOADERS.items():
    p = os.path.join(CE, fn)
    if not os.path.exists(p):
        unsupported.append((fn, "missing code entry"))
        continue
    for line in open(p, encoding='utf-8', errors='replace'):
        line = line.strip()
        if 'ini_read_' not in line:
            continue
        m = READ_INNER.match(line) or READ.match(line)
        if not m:
            unsupported.append((fn, line[:110])); continue
        g, fnc, kind, sec, key, dflt, off = m.groups()
        off = float(off.replace(' ', '')) if off else 0.0
        gl_map[g] = (inif, sec, key, kind, fnc, off)

print(f"[i] globals mapped from local save: {len(gl_map)}; unparsed loader lines: {len(unsupported)}")
for u in unsupported[:8]:
    print("    unparsed:", u)

def read_ini(path):
    d, sec = {}, None
    if not os.path.exists(path):
        return d
    for line in open(path, encoding='utf-8', errors='replace'):
        line = line.strip()
        if not line:
            continue
        if line.startswith('['):
            sec = line.strip('[]'); continue
        if '=' in line:
            k, v = line.split('=', 1)
            v = v.strip().strip('"')
            try:
                d[(sec, k.strip())] = float(v)
            except ValueError:
                d[(sec, k.strip())] = v
    return d

INIS = {f: read_ini(os.path.join(SAVE, f)) for f in set(v[0] for v in gl_map.values())}
print("[i] local ini loaded:", {k: len(v) for k, v in INIS.items()})

def local_value(g):
    """in-game value of a global, as the game would have it after boot."""
    m = gl_map.get(g)
    if not m:
        return None
    inif, sec, key, kind, fnc, off = m
    raw = INIS.get(inif, {}).get((sec, key))
    if raw is None:
        return None
    if kind == 'string':
        return raw
    try:
        v = float(raw) + off
    except (TypeError, ValueError):
        return None
    if fnc:
        v = int(v // 1) if v >= 0 else -int((-v) // 1)
    if v == int(v):
        return int(v)
    return round(v, 6)

# ---------- 2. schema from the game's save code ----------
lines = open(os.path.join(CE, "gml_Object_o_popup_option_Step_0.gml"), encoding='utf-8', errors='replace').read().split('\n')
BOUNDS = {"userdata": (25, 1735), "unitdata": (1750, 2104), "etcdata": (2119, 2894)}
FIELD = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_]*):\s*(.+?),?\s*$')

def schema(a, b):
    out = []
    for ln in lines[a - 1:b]:
        m = FIELD.match(ln.rstrip())
        if m:
            out.append((m.group(1), m.group(2).strip()))
    return out

STATS = {}
built = {}
for blob, (a, b) in BOUNDS.items():
    cur = json.loads(open(os.path.join(CLOUD, blob + ".json"), encoding='utf-8').read())
    fields = schema(a, b)
    out, cnt = {}, {"from_local": 0, "kept_cloud": 0, "only_cloud": 0, "changed": 0, "same": 0}
    for key, expr in fields:
        if key == blob:                      # wrapper key of the outer object
            continue
        m = re.fullmatch(r'global\.([A-Za-z_][A-Za-z0-9_]*)', expr)
        val = local_value(m.group(1)) if m else None
        if val is not None:
            out[key] = val; cnt["from_local"] += 1
        elif key in cur:
            out[key] = cur[key]; cnt["kept_cloud"] += 1
        else:
            out[key] = 0; cnt["only_cloud"] += 1
        if key in cur:
            if out[key] == cur[key]:
                cnt["same"] += 1
            else:
                cnt["changed"] += 1
    extra = [k for k in cur if k not in out]
    built[blob] = out
    STATS[blob] = (cnt, len(fields), extra)
    print(f"\n== {blob}: fields={len(fields)} extra-in-cloud={len(extra)} {extra[:5]}")
    print("   ", cnt)

# ---------- 3. sanity: what the game will read back for the crash-critical fields ----------
u = built["userdata"]
print("\n[crash-critical fields in the rebuilt userdata]")
for k, what in [("a27", "p1_select (1=CROW 2=RAVEN)"), ("a7", "p1_gameturn (DAY)"), ("a28", "difficulty"),
                ("a29", "difficulty_style"), ("a3", "p1_gold"), ("a4", "p1_supplies"), ("a5", "p1_medicine"),
                ("a6", "p1_cash"), ("a1", "check_tutorial"), ("a2", "p1_gameclear"), ("a9", "newgame"),
                ("a24", "bgm_ok"), ("a25", "se_ok"), ("a26", "set_language"), ("a11", "p1_pioneer"),
                ("a13", "p1_gameturn2"), ("a15", "proposal_count"), ("a12", "event_boss_num"),
                ("a30", "ope_num"), ("a31", "option_battle_speed"), ("a32", "map_tap_now")]:
    print(f"   {k:>5} = {u.get(k)!r:>12}   {what}")
print("   h1..h10 =", [u.get('h%d' % i) for i in range(1, 11)])
print("   h41..h48 =", [u.get('h%d' % i) for i in range(41, 49)])

# crash simulation: every key the load code reads for check_event_sub2_* / mission_clear
ev = {k: u.get(k) for k in ('h1', 'h2', 'h3', 'h4', 'h5')}
print("   event flags ->", ev)
print("   mission_clear-ish fields present:", sum(1 for k in u if k.startswith('g')))

out_dir = os.path.join(BASE, "cloud_build")
os.makedirs(out_dir, exist_ok=True)
for blob, obj in built.items():
    raw = json.dumps(obj, separators=(',', ':'))
    open(os.path.join(out_dir, blob + ".json"), "w", encoding="utf-8").write(raw)
    print(f"\n[i] wrote cloud_build/{blob}.json  ({len(raw)} bytes, {len(obj)} fields)")
print("\n[i] current cloud sizes:", {b: os.path.getsize(os.path.join(CLOUD, b + ".json")) for b in built})
playfab_push.py(99 行):备份、写回、回读校验
#!/usr/bin/env python
"""Push the rebuilt cloud blobs (cloud_build/*.json) to the user's PlayFab save.

Backs up the current values first, then Client/UpdateUserData, then re-reads the
cloud and verifies. Never prints the session ticket.
"""
import json, os, shutil, sys, time, urllib.request, urllib.error

TITLE = "142A3"
BASE = os.path.dirname(os.path.abspath(__file__))
CLOUD = os.path.join(BASE, "cloud_raw")
BUILD = os.path.join(BASE, "cloud_build")
BACKUP = os.path.join(BASE, "cloud_backup")


def post(path, body, ticket=None):
    req = urllib.request.Request(f"https://{TITLE}.playfabapi.com/{path}",
                                 data=json.dumps(body).encode(), method="POST")
    req.add_header("Content-Type", "application/json")
    if ticket:
        req.add_header("X-Authorization", ticket)
    try:
        with urllib.request.urlopen(req, timeout=90) as r:
            return r.status, json.loads(r.read().decode())
    except urllib.error.HTTPError as e:
        raw = e.read().decode()
        try:
            return e.code, json.loads(raw)
        except Exception:
            return e.code, {"raw": raw[:2000]}


def main():
    args = [a for a in sys.argv[1:] if not a.startswith("-")]
    dry = "--dry" in sys.argv
    pid = args[0].strip() if args else open(os.path.join(BASE, "player_id.txt")).read().strip()

    st, resp = post("Client/LoginWithCustomID",
                    {"TitleId": TITLE, "CustomId": pid, "CreateAccount": False})
    if resp.get("code") != 200:
        print("[x] login failed:", resp.get("errorCode"), resp.get("errorMessage")); return 1
    ticket = resp["data"]["SessionTicket"]
    print("[+] logged in, PlayFabId:", resp["data"].get("PlayFabId"))

    # ---- backup current cloud values ----
    st, resp = post("Client/GetUserData", {}, ticket)
    if resp.get("code") != 200:
        print("[x] GetUserData failed:", resp.get("errorCode")); return 1
    cur = resp["data"]["Data"]
    ts = time.strftime("%Y%m%d-%H%M%S")
    os.makedirs(BACKUP, exist_ok=True)
    bpath = os.path.join(BACKUP, f"cloud_backup_{ts}.json")
    json.dump({k: {"Value": v.get("Value"), "LastUpdated": v.get("LastUpdated"),
                   "Permission": v.get("Permission")} for k, v in cur.items()},
              open(bpath, "w", encoding="utf-8"), indent=1)
    print(f"[+] backed up current cloud data -> {bpath}")
    for k, v in sorted(cur.items()):
        print(f"    {k}: {len(v.get('Value') or '')} bytes, LastUpdated {v.get('LastUpdated')}, {v.get('Permission')}")

    payload = {}
    for blob in ("userdata", "unitdata", "etcdata"):
        payload[blob] = open(os.path.join(BUILD, blob + ".json"), encoding="utf-8").read()
    print("[i] upload sizes:", {k: len(v) for k, v in payload.items()})
    if dry:
        print("[dry-run] not uploading"); return 0

    st, resp = post("Client/UpdateUserData", {"Data": payload}, ticket)
    print("[upload] http", st, "->", resp.get("code"), resp.get("status"),
          resp.get("errorCode") or "", resp.get("errorMessage") or "")
    if resp.get("code") != 200:
        print("[x] upload FAILED; backup:", bpath)
        return 1
    print("[+] UpdateUserData accepted:", resp.get("data"))

    # ---- verify by reading back ----
    st, resp = post("Client/GetUserData", {}, ticket)
    data = resp.get("data", {}).get("Data", {}) or {}
    print("[i] read-back raw:", json.dumps(resp)[:400])
    ok = True
    for k in sorted(payload):
        if k not in data:
            print(f"    {k}: MISSING after upload"); ok = False; continue
        val = data[k].get("Value") or ""
        same = val == payload[k]
        ok &= same
        print(f"    {k}: {len(val)} bytes, LastUpdated {data[k].get('LastUpdated')}, "
              f"{data[k].get('Permission')}, matches_upload={same}")
    if ok:
        u = json.loads(open(os.path.join(BUILD, "userdata.json"), encoding="utf-8").read())
        print("[verify] p1_select(a27) =", u.get("a27"), "| DAY(a7) =", u.get("a7"),
              "| difficulty(a28) =", u.get("a28"), "| check_event_sub2_1(h1) =", u.get("h1"))
        print("[+] cloud save replaced and verified.")
    else:
        print("[!] verify mismatch")
    return 0


if __name__ == "__main__":
    sys.exit(main())
playfab_restore.py(51 行):用备份回滚云端存档
#!/usr/bin/env python
"""Restore the PlayFab cloud save from a backup file made by playfab_push.py.

Usage: python playfab_restore.py cloud_backup/cloud_backup_YYYYmmdd-HHMMSS.json
"""
import json, os, sys, urllib.request, urllib.error

TITLE = "142A3"
BASE = os.path.dirname(os.path.abspath(__file__))


def post(path, body, ticket=None):
    req = urllib.request.Request(f"https://{TITLE}.playfabapi.com/{path}",
                                 data=json.dumps(body).encode(), method="POST")
    req.add_header("Content-Type", "application/json")
    if ticket:
        req.add_header("X-Authorization", ticket)
    try:
        with urllib.request.urlopen(req, timeout=90) as r:
            return r.status, json.loads(r.read().decode())
    except urllib.error.HTTPError as e:
        raw = e.read().decode()
        try:
            return e.code, json.loads(raw)
        except Exception:
            return e.code, {"raw": raw[:2000]}


def main():
    path = sys.argv[1]
    pid = open(os.path.join(BASE, "player_id.txt")).read().strip()
    st, resp = post("Client/LoginWithCustomID", {"TitleId": TITLE, "CustomId": pid, "CreateAccount": False})
    if resp.get("code") != 200:
        print("[x] login failed:", resp.get("errorCode"), resp.get("errorMessage")); return 1
    ticket = resp["data"]["SessionTicket"]
    backup = json.load(open(path, encoding="utf-8"))
    payload = {k: (v.get("Value") if isinstance(v, dict) else v) for k, v in backup.items()}
    print("[i] restoring", {k: len(v or "") for k, v in payload.items()})
    st, resp = post("Client/UpdateUserData", {"Data": payload}, ticket)
    print("[restore] ->", resp.get("code"), resp.get("status"), resp.get("errorCode") or "",
          resp.get("errorMessage") or "", resp.get("data"))
    st, resp = post("Client/GetUserData", {}, ticket)
    data = resp.get("data", {}).get("Data", {}) or {}
    for k in sorted(payload):
        got = (data.get(k) or {}).get("Value") or ""
        print(f"    {k}: {len(got)} bytes, matches_backup={got == payload[k]}")
    return 0


if __name__ == "__main__":
    sys.exit(main())