Files
maipulse/maipulse/tools/apply_update.py
T
maipulse 967e93af98 release: v1.0.2
加固:release 构建 + R8 混淆,清理无用资源,修启动链路
2026-10-30 21:35:19 +08:00

283 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""把 SDGB 官方更新包(A0xx)里的曲目 / 称号合并进 maipulse 的 master 数据。
用法:
python tools/apply_update.py "C:\\Users\\20558\\Downloads\\SDGB_A041_20261020134418_0"
python tools/apply_update.py <包目录> --dry 只看差异,不写盘
包里有什么就用什么:
music/music*/Music.xml -> data/master/music.json(定数、bpm、版本、谱师全在这)
title/title*/Title.xml -> data/master/title.json
其它目录(event / map / information / MovieData / SoundData / AssetBundleImages)是机台资源,
maipulse 用不到,直接跳过。
★ 只增量合并:已存在的 id 用包里更完整的字段覆盖空值,不删任何旧条目。
★ 写盘前自动备份到 data/_backup/<时间戳>/。
"""
from __future__ import annotations
import io
import json
import os
import re
import shutil
import sys
import time
import xml.etree.ElementTree as ET
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join(BASE_DIR, "data")
MASTER_DIR = os.path.join(DATA_DIR, "master")
MASTER_INDEX = os.path.join(DATA_DIR, "master.json")
BACKUP_ROOT = os.path.join(DATA_DIR, "_backup")
def txt(node, path, default=""):
e = node.find(path)
if e is None:
return default
return (e.text or default) or default
def num(node, path, default=0):
e = node.find(path)
if e is None:
return default
try:
return int((e.text or "").strip())
except (ValueError, AttributeError):
return default
def flag(node, path, default=False):
return (txt(node, path, "true" if default else "false") or "").strip().lower() == "true"
# ---------------------------------------------------------------- 解析
def parse_music(path: str) -> dict:
root = ET.parse(path).getroot()
notes = []
for n in root.findall("./notesData/Notes"):
if not flag(n, "./isEnable", True):
notes.append({"level": 0, "levelDecimal": 0, "designer": ""})
continue
notes.append({
"level": num(n, "./level"),
"levelDecimal": num(n, "./levelDecimal"),
"designer": txt(n, "./notesDesigner/str"),
})
# 谱面固定补到 6 档(BAS/ADV/EXP/MAS/Re:MAS + 宴),和现有 master 对齐
while len(notes) < 6:
notes.append({"level": 0, "levelDecimal": 0, "designer": ""})
return {
"id": num(root, "./name/id"),
"name": txt(root, "./name/str"),
"sortName": txt(root, "./sortName"),
"artist": txt(root, "./artistName/str"),
"genre": txt(root, "./genreName/str"),
"bpm": num(root, "./bpm"),
"version": num(root, "./version"),
"addVersion": txt(root, "./AddVersion/str"),
"lockType": num(root, "./lockType"),
"disable": flag(root, "./disable"),
"notes": notes,
}
def parse_title(path: str) -> dict:
root = ET.parse(path).getroot()
return {
"id": num(root, "./name/id"),
"name": txt(root, "./name/str"),
# Title.xml 里叫 <genre>,Music.xml 里叫 <genreName>,两个都兼容
"genre": txt(root, "./genre/str") or txt(root, "./genreName/str"),
"release": txt(root, "./releaseTagName/str"),
"lock": txt(root, "./dispCond"),
"norm": txt(root, "./normText"),
"isDefault": flag(root, "./isDefault"),
"disable": flag(root, "./disable"),
}
def pack_of(pkg: str) -> str:
"""从包目录名取出包号:SDGB_A041_20261020134418_0 -> A041。
目录名里没有 A0xx 时回退取全部数字段里第一个 3 位纯数字,
再不行返回空串(这条就归到「无归属」)。
"""
for part in re.split(r"[\\/_\-\s]+", os.path.basename(os.path.normpath(pkg))):
if re.fullmatch(r"A\d{3}", part):
return part
return ""
def collect(pkg: str, sub: str, filename: str, parser, pack: str = "") -> list:
out = []
base = os.path.join(pkg, sub)
if not os.path.isdir(base):
return out
for name in sorted(os.listdir(base)):
p = os.path.join(base, name, filename)
if os.path.isfile(p):
try:
item = parser(p)
except ET.ParseError as exc:
print(" 跳过 %s:%s" % (p, exc))
continue
if item.get("id"):
if pack:
item["pack"] = pack
out.append(item)
return out
# ---------------------------------------------------------------- 合并
def merge(old: list, new: list):
"""按 id 增量合并。返回 (合并后列表, 新增, 更新)。"""
index = {x["id"]: i for i, x in enumerate(old)}
added, updated = [], []
for item in new:
i = index.get(item["id"])
if i is None:
old.append(item)
index[item["id"]] = len(old) - 1
added.append(item)
continue
cur = old[i]
dirty = False
for k, v in item.items():
if k == "pack":
# 包归属记首次出现的那个包:已有值不覆盖,
# 空值(早先水鱼补的曲)才由包补上
if v and not cur.get("pack"):
cur["pack"] = v
dirty = True
continue
if k == "notes":
# 谱面:包里启用的档位比旧的更权威
if any(n["level"] or n["levelDecimal"] for n in v):
if v != cur.get("notes"):
cur["notes"] = v
dirty = True
continue
if v in ("", 0, False, None) and cur.get(k) not in ("", 0, False, None):
continue # 包里是空值就不覆盖已有值
if cur.get(k) != v:
cur[k] = v
dirty = True
if dirty:
updated.append(item["id"])
old.sort(key=lambda x: x["id"])
return old, added, updated
def backup(paths: list) -> str:
stamp = time.strftime("%Y%m%d-%H%M%S")
dst = os.path.join(BACKUP_ROOT, stamp)
os.makedirs(dst, exist_ok=True)
for p in paths:
if os.path.isfile(p):
shutil.copy2(p, os.path.join(dst, os.path.basename(p)))
return dst
def write_json(path: str, obj):
with io.open(path, "w", encoding="utf-8", newline="\n") as fp:
json.dump(obj, fp, ensure_ascii=False, separators=(",", ":"))
def rebuild_index():
"""master.json 是总索引:{kind: {kind, count, items}}。"""
index = {}
if os.path.isfile(MASTER_INDEX):
try:
index = json.load(io.open(MASTER_INDEX, encoding="utf-8"))
except (OSError, ValueError):
index = {}
for name in os.listdir(MASTER_DIR):
if not name.endswith(".json"):
continue
kind = name[:-5]
p = os.path.join(MASTER_DIR, name)
try:
items = json.load(io.open(p, encoding="utf-8"))
except (OSError, ValueError):
continue
index[kind] = {"kind": kind, "count": len(items), "items": items}
write_json(MASTER_INDEX, index)
return {k: v["count"] for k, v in index.items()}
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
if not args:
print(__doc__)
return 1
pkg = os.path.abspath(args[0])
dry = "--dry" in sys.argv
if not os.path.isdir(pkg):
print("包目录不存在:%s" % pkg)
return 1
print("包:%s" % pkg)
ver = os.path.join(pkg, "DataConfig.xml")
if os.path.isfile(ver):
try:
r = ET.parse(ver).getroot()
print("版本:%s.%s.%s" % (num(r, "./version/major"), num(r, "./version/minor"),
num(r, "./version/release")))
except ET.ParseError:
pass
music_path = os.path.join(MASTER_DIR, "music.json")
title_path = os.path.join(MASTER_DIR, "title.json")
music_old = json.load(io.open(music_path, encoding="utf-8"))
title_old = json.load(io.open(title_path, encoding="utf-8"))
pkg_pack = pack_of(pkg)
pkg_music = collect(pkg, "music", "Music.xml", parse_music, pkg_pack)
pkg_title = collect(pkg, "title", "Title.xml", parse_title, pkg_pack)
print("包号:%s%s" % (pkg_pack or "(未识别)",
"" if pkg_pack else ",曲目会归到「无归属」"))
print("包里:曲目 %d 首 / 称号 %d 个" % (len(pkg_music), len(pkg_title)))
music_new, m_add, m_upd = merge(music_old, pkg_music)
title_new, t_add, t_upd = merge(title_old, pkg_title)
print("\n曲目新增 %d 首:" % len(m_add))
for it in m_add:
lv = "/".join("%d%s" % (n["level"], ("." + str(n["levelDecimal"])) if n["levelDecimal"] else "")
for n in it["notes"][:4])
print(" %-7d %-30s v%-6d %s" % (it["id"], it["name"][:28], it["version"], lv))
if m_upd:
print("曲目更新 %d 首:%s" % (len(m_upd), ", ".join(map(str, m_upd))))
print("\n称号新增 %d 个:" % len(t_add))
for it in t_add:
print(" %-7d %s" % (it["id"], it["name"]))
if t_upd:
print("称号更新 %d 个:%s" % (len(t_upd), ", ".join(map(str, t_upd))))
print("\n总计:曲目 %d -> %d,称号 %d -> %d"
% (len(music_old) - len(m_add), len(music_new),
len(title_old) - len(t_add), len(title_new)))
if dry:
print("\n--dry:没有写盘")
return 0
dst = backup([music_path, title_path, MASTER_INDEX])
print("\n已备份到 %s" % dst)
write_json(music_path, music_new)
write_json(title_path, title_new)
counts = rebuild_index()
print("已写入。master.json 计数:")
for k, v in sorted(counts.items()):
print(" %-8s %d" % (k, v))
return 0
if __name__ == "__main__":
sys.exit(main())