把旧设备上的游戏进度同步到新设备,新设备一进游戏就崩在第一个活动事件的对话上,报错
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 Boptions.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 从来没被赋过值」。它不代表拼写错误,也不代表缺少资源。按这个方向查:
- 先排除 X 是资源。用 UTMT 的
.csx脚本遍历Data.Sprites列出全部精灵名,确认里面没有s_unit_0_1(只有spine_0、spine_00之类)。这一步花几分钟,省掉在资源表里白找的时间。 - 找赋值点:
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; ... }
}- 于是问题变成「
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 本地一致的值。之后独立再登录读一次,确认这期间没有设备覆盖过它。
八、设备侧收尾:顺序错了会把坏数据传下去 #
- 在目标设备上进游戏,到读档界面执行云读取(
クラウドからロード),确认不再崩、进度正确。本例到此解决。 - 在确认正常之前,绝不要在坏状态的设备上点云保存。云保存是整份内存状态上传,坏数据一旦上传就会覆盖刚修好的云端,之后每次「读坏数据,再保存」都会把 0 继续传下去。换设备、重装游戏的正确顺序是:先云读取、确认游戏正常,再考虑云保存。
- 兜底:如果坏状态的设备已经卡到界面点不动,在系统设置里清除该应用的存储数据,重开、登录后再云读取。
- 只存在于内存、不落地本地文件的字段(本例是据点开发一类),会在下一次云保存时由游戏自己补上云端,不补也不影响启动。
九、适用边界 #
可复用的前提:
- 能拿到游戏安装包,且引擎的代码可以反编译(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())