202 lines
8.5 KiB
Python
202 lines
8.5 KiB
Python
# resolve_model_binding.py - 用「运行时 modelId → codec 实例」dump 反查 codec 的模块归属
|
||
#
|
||
# 为什么需要这个工具:
|
||
# regdump.json 的 ModelReg.w7n_1 只给出「类短名 + codec 槽位名」。类短名在 126 个模块里
|
||
# 大量复用(Fs / hs / It 这类两字母名到处都是),而 regdump 不含模块信息,
|
||
# 所以靠「短名 + 槽位」做指纹经常多解或误绑(例:658797348514407988 被绑到 ItemModelCC,
|
||
# 真实 codec 是另一个模块里的 Fs)→ pt 项静默串位,最后读出 modelId=8 这种不存在的值。
|
||
#
|
||
# 数据来源(由镜像站自动采集,不需要手工粘控制台):
|
||
# 3dtank.com/play/static/js/unpacked/module_61860.js 的 [Hornet 调试] 补丁
|
||
# 在 ModelReg.n7o(modelId, codec) 注册时记录 codec 的 u7h/t7h/s7h 源码文本,
|
||
# 节流后 POST 到 serve.js 的 /__dump__/regdump_model.json
|
||
# → 落盘到 3DTank/names/regdump_model.json
|
||
#
|
||
# 反查方法:
|
||
# u7h(codec 的「建槽」函数)文本在每个模块里都是唯一的:把它的空白全部去掉,
|
||
# 在同样去掉空白的模块源码里搜;命中后回看最近的一个 `X(类名).u7h = function`,
|
||
# 该赋值左侧的类名就是 codec 类,文件名给出模块 id。
|
||
# u7h 缺失(继承父类)时退回 t7h。
|
||
#
|
||
# 用法: python tools/resolve_model_binding.py [--names names] [--src src]
|
||
# 输出: names/model_binding.json
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
Root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
|
||
def Arg(Name, Def):
|
||
if '--' + Name in sys.argv:
|
||
I = sys.argv.index('--' + Name)
|
||
if I + 1 < len(sys.argv):
|
||
return sys.argv[I + 1]
|
||
return Def
|
||
|
||
|
||
NamesDir = os.path.abspath(Arg('names', os.path.join(Root, 'names')))
|
||
SrcDir = os.path.abspath(Arg('src', os.path.join(Root, 'src')))
|
||
DumpPath = os.path.join(NamesDir, 'regdump_model.json')
|
||
OutPath = os.path.join(NamesDir, 'model_binding.json')
|
||
|
||
# u7h/t7h 的赋值点:Se(ds).u7h = function (...) / x(lt).t7h = function (...)
|
||
# 注意类名里允许 `$`:Kotlin/JS 的局部类常被命名为 `$s`、`j_` 这类;
|
||
# 写成 \w+ 会漏掉 `$s`,从而把「最近的前一个 u7h」误当成本类(曾把 $s 误判成 bs)。
|
||
AssignRe = re.compile(r'\(([\w$]+)\)\.(u7h|t7h)\s*=\s*function')
|
||
ModuleRe = re.compile(r'module_(\d+)\.js$')
|
||
|
||
|
||
def Normalize(Text):
|
||
"""去掉全部空白,同时保留「归一化下标 → 原文下标」的映射。"""
|
||
Chars = []
|
||
Idx = []
|
||
for I, C in enumerate(Text):
|
||
if C.isspace():
|
||
continue
|
||
Chars.append(C)
|
||
Idx.append(I)
|
||
return ''.join(Chars), Idx
|
||
|
||
|
||
def LoadModules():
|
||
Out = []
|
||
for Name in sorted(os.listdir(SrcDir)):
|
||
if not ModuleRe.search(Name):
|
||
continue
|
||
Path = os.path.join(SrcDir, Name)
|
||
with open(Path, 'r', encoding='utf-8', errors='replace') as F:
|
||
Text = F.read()
|
||
Norm, Idx = Normalize(Text)
|
||
Assigned = [(M.start(), M.group(1), M.group(2)) for M in AssignRe.finditer(Text)]
|
||
Out.append({'Module': ModuleRe.search(Name).group(0)[:-3], 'Text': Text,
|
||
'Norm': Norm, 'Idx': Idx, 'Assigned': Assigned})
|
||
return Out
|
||
|
||
|
||
def OwnerAt(Mod, Offset, Want):
|
||
"""在原文 Offset 处,找出包含它的那一次 `<类>.u7h/t7h = function` 赋值。"""
|
||
Best = None
|
||
for Start, Cls, Kind in Mod['Assigned']:
|
||
if Start <= Offset and (Best is None or Start > Best[0]):
|
||
Best = (Start, Cls, Kind)
|
||
if Best is None:
|
||
return None
|
||
# 命中的函数体必须就是这次赋值(同一 class 可能先后有 u7h 和 t7h 两个赋值)
|
||
return Best[1] if Best[2] == Want else None
|
||
|
||
|
||
def Main():
|
||
if not os.path.exists(DumpPath):
|
||
print('缺少运行期 dump:' + DumpPath)
|
||
print('请先用镜像站进一次游戏(镜像 module_61860.js 已打采集补丁,会自动 POST)。')
|
||
return 1
|
||
with open(DumpPath, 'r', encoding='utf-8') as F:
|
||
Dump = json.load(F)
|
||
Entries = Dump.get('entries') or []
|
||
print('运行期 dump:%d 条 modelId 注册(采集于 %s)' % (len(Entries), Dump.get('at', '?')))
|
||
|
||
Modules = LoadModules()
|
||
print('已载入 %d 个模块源码' % len(Modules))
|
||
|
||
# codec_fields 里已有的 codec 键(module_XXXX::类名),用于判断「绑定成功但字段表缺失」
|
||
with open(os.path.join(NamesDir, 'codec_fields.json'), 'r', encoding='utf-8') as F:
|
||
Codecs = json.load(F).get('Codecs') or {}
|
||
|
||
Resolved = []
|
||
Unresolved = []
|
||
Ambiguous = []
|
||
for E in Entries:
|
||
ModelId = str(E.get('modelId'))
|
||
Hit = None
|
||
HitCount = 0
|
||
for Kind in ('u7h', 't7h'):
|
||
Sig = E.get(Kind)
|
||
if not Sig:
|
||
continue
|
||
NormSig = ''.join(Sig.split())
|
||
if len(NormSig) < 40:
|
||
continue
|
||
Hits = []
|
||
for Mod in Modules:
|
||
Pos = Mod['Norm'].find(NormSig)
|
||
if Pos < 0:
|
||
continue
|
||
Owner = OwnerAt(Mod, Mod['Idx'][Pos], Kind)
|
||
if Owner:
|
||
Hits.append((Mod['Module'], Owner))
|
||
if len(Hits) == 1:
|
||
Hit = Hits[0]
|
||
HitCount = 1
|
||
break
|
||
if len(Hits) > 1:
|
||
Ambiguous.append({'ModelId': ModelId, 'How': Kind, 'Candidates': Hits})
|
||
HitCount = len(Hits)
|
||
break
|
||
if Hit:
|
||
Module, Codec = Hit
|
||
Key = Module + '::' + Codec
|
||
# 自洽性校验:运行期 codec 实例的槽位名(keys)应与 codec_fields 里该 codec 的 Slots 一致。
|
||
# 不一致说明「源码反查落到了同名的另一个类」或字段表抽错,必须显式标出来,不能静默使用。
|
||
Slots = [S.get('Slot') for S in ((Codecs.get(Key) or {}).get('Slots') or [])]
|
||
RuntimeKeys = [str(K) for K in (E.get('keys') or [])]
|
||
SlotsOk = sorted(Slots) == sorted(RuntimeKeys)
|
||
Resolved.append({
|
||
'ModelId': ModelId,
|
||
'Module': Module,
|
||
'Codec': Codec,
|
||
'Key': Key,
|
||
'InCodecFields': Key in Codecs,
|
||
'SlotsOk': SlotsOk,
|
||
'Slots': Slots,
|
||
'RuntimeKeys': RuntimeKeys,
|
||
'Ctor': E.get('ctor'),
|
||
'Keys': E.get('keys'),
|
||
})
|
||
elif HitCount == 0:
|
||
Unresolved.append({'ModelId': ModelId, 'Ctor': E.get('ctor'), 'Keys': E.get('keys')})
|
||
|
||
MissingCodec = [R for R in Resolved if not R['InCodecFields']]
|
||
SlotMismatch = [R for R in Resolved if not R['SlotsOk']]
|
||
Out = {
|
||
'Note': '运行时 modelId → codec 的权威绑定:u7h/t7h 源码文本反查模块 + 类名;'
|
||
'InCodecFields=false 表示绑定已确定但 codec_fields 还没抽出该 codec 的字段表;'
|
||
'SlotsOk=false 表示反查到的类与运行期 codec 槽位不一致(疑似落在同名类上)。',
|
||
'Source': 'names/regdump_model.json(镜像站 module_61860.n7o 采集)',
|
||
'Count': len(Entries),
|
||
'Resolved': len(Resolved),
|
||
'MissingCodec': len(MissingCodec),
|
||
'SlotMismatch': len(SlotMismatch),
|
||
'AmbiguousCount': len(Ambiguous),
|
||
'UnresolvedCount': len(Unresolved),
|
||
'Entries': Resolved,
|
||
'MissingCodecList': MissingCodec,
|
||
'SlotMismatchList': SlotMismatch,
|
||
'Ambiguous': Ambiguous,
|
||
'Unresolved': Unresolved,
|
||
}
|
||
with open(OutPath, 'w', encoding='utf-8') as F:
|
||
json.dump(Out, F, ensure_ascii=False, indent=1)
|
||
print('已写入 ' + OutPath)
|
||
print(' 反查成功 %d / %d;其中字段表缺失 %d;槽位不一致 %d;多解 %d;未解 %d' %
|
||
(len(Resolved), len(Entries), len(MissingCodec), len(SlotMismatch), len(Ambiguous), len(Unresolved)))
|
||
if SlotMismatch:
|
||
print(' 槽位不一致(前 15):')
|
||
for R in SlotMismatch[:15]:
|
||
print(' %s %s 运行期=%s 字段表=%s' %
|
||
(R['ModelId'], R['Key'], R['RuntimeKeys'], R['Slots']))
|
||
if Ambiguous:
|
||
print(' 多解(前 5):')
|
||
for A in Ambiguous[:5]:
|
||
print(' %s ' % A['ModelId'] + ' / '.join(M + '::' + C for M, C in A['Candidates']))
|
||
if Unresolved:
|
||
print(' 未解(前 10):')
|
||
for U in Unresolved[:10]:
|
||
print(' %s ctor=%s keys=%s' % (U['ModelId'], U.get('Ctor'), U.get('Keys')))
|
||
return 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
sys.exit(Main())
|