From fcd071485ded6d9a843ee23e41903efc2efbba74 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Wed, 21 Oct 2026 01:32:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(protocol):=20=E9=87=87=E9=9B=86=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=20modelId=E2=86=92codec=20=E6=B3=A8=E5=86=8C?= =?UTF-8?q?=E5=B9=B6=E7=94=A8=E6=BA=90=E7=A0=81=E5=8F=8D=E6=9F=A5=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=BD=92=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 3DTank/tools/resolve_model_binding.py | 186 ++++++++++++++++++ .../play/static/js/unpacked/module_61860.js | 75 +++++++ TankiOffline/tools/GenerateProtocol.py | 25 +++ 3 files changed, 286 insertions(+) create mode 100644 3DTank/tools/resolve_model_binding.py diff --git a/3DTank/tools/resolve_model_binding.py b/3DTank/tools/resolve_model_binding.py new file mode 100644 index 0000000..0536dbb --- /dev/null +++ b/3DTank/tools/resolve_model_binding.py @@ -0,0 +1,186 @@ +# 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 (...) +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 + Resolved.append({ + 'ModelId': ModelId, + 'Module': Module, + 'Codec': Codec, + 'Key': Key, + 'InCodecFields': Key in Codecs, + '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']] + Out = { + 'Note': '运行时 modelId → codec 的权威绑定:u7h/t7h 源码文本反查模块 + 类名;' + 'InCodecFields=false 表示绑定已确定但 codec_fields 还没抽出该 codec 的字段表。', + 'Source': 'names/regdump_model.json(镜像站 module_61860.n7o 采集)', + 'Count': len(Entries), + 'Resolved': len(Resolved), + 'MissingCodec': len(MissingCodec), + 'AmbiguousCount': len(Ambiguous), + 'UnresolvedCount': len(Unresolved), + 'Entries': Resolved, + 'MissingCodecList': MissingCodec, + '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' % + (len(Resolved), len(Entries), len(MissingCodec), len(Ambiguous), len(Unresolved))) + if MissingCodec: + print(' 字段表缺失的 codec(前 15):') + for R in MissingCodec[:15]: + print(' %s %s ctor=%s' % (R['ModelId'], R['Key'], R['Ctor'])) + 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()) diff --git a/3dtank.com/play/static/js/unpacked/module_61860.js b/3dtank.com/play/static/js/unpacked/module_61860.js index 4eba0f3..3f9ad5d 100644 --- a/3dtank.com/play/static/js/unpacked/module_61860.js +++ b/3dtank.com/play/static/js/unpacked/module_61860.js @@ -1663,6 +1663,81 @@ }), (J(mr).n7o = function (t, n) { this.a7p_1.g3(t, n); + // [Hornet 调试] modelId → codec 注册采样:把 codec 的类名/槽位/读写函数源码攒起来, + // 节流后 POST 到本地镜像服务器的 /__dump__/regdump_model.json(serve.js 已实现落盘)。 + // 用途:离线用 u7h/t7h 源码文本反查 codec 所在模块与类,解决「短类名全局复用」的归属歧义。 + // 全程 try/catch,不影响注册本身的语义。 + try { + var H = typeof window !== "undefined" ? window : globalThis; + var D = H.__HornetModelRegDump__; + if (!D) { + D = H.__HornetModelRegDump__ = { + entries: {}, + order: [], + sent: 0, + timer: 0, + push: function (Id, Codec) { + if (null == Id || null == Codec) return; + var K = String(Id); + if (this.entries[K]) return; + var Src = function (F) { + try { + return F ? String(F) : null; + } catch (E) { + return null; + } + }; + var Own = []; + var Proto = []; + var Ctor = ""; + try { + Own = Object.keys(Codec).slice(0, 64); + } catch (E) {} + try { + Proto = Object.keys(Codec.prototype || {}).slice(0, 64); + } catch (E) {} + try { + Ctor = String(Codec.constructor || ""); + } catch (E) {} + this.entries[K] = { + modelId: K, + ctor: (Codec.constructor && Codec.constructor.name) || typeof Codec, + ctorSrc: Ctor, + keys: Own, + protoKeys: Proto, + u7h: Src(Codec.u7h), + t7h: Src(Codec.t7h), + s7h: Src(Codec.s7h), + }; + this.order.push(K); + if (!this.timer) { + var Me = this; + this.timer = setTimeout(function () { + Me.timer = 0; + Me.flush(); + }, 1500); + } + }, + flush: function () { + if (typeof fetch !== "function") return; + var Self = this; + var Body = JSON.stringify({ + at: new Date().toISOString(), + href: typeof location !== "undefined" ? location.href : "", + count: this.order.length, + entries: this.order.map(function (K) { + return Self.entries[K]; + }), + }); + this.sent++; + try { + fetch("/__dump__/regdump_model.json", { method: "POST", body: Body }); + } catch (E) {} + }, + }; + } + D.push(t, n); + } catch (E) {} }), (J(mr).o7o = function (t) { var n = this.a7p_1.z2(t); diff --git a/TankiOffline/tools/GenerateProtocol.py b/TankiOffline/tools/GenerateProtocol.py index 2a76e2b..ee39fdd 100644 --- a/TankiOffline/tools/GenerateProtocol.py +++ b/TankiOffline/tools/GenerateProtocol.py @@ -228,6 +228,31 @@ def Main(): Codecs = CodecFields.get("Codecs", {}) + # ---------- 0. 运行时权威绑定(若已采集)---------- + # codec_fields 里的 ModelId/ModelIds 是「短类名 + codec 槽位名」指纹 + 源码登记点推出来的; + # 短类名在 126 个模块里大量复用,误绑会让 pt 项静默串位(最后读出 modelId=8 这种不存在值)。 + # names/model_binding.json 来自客户端运行时 ModelReg.n7o 注册采样 + u7h 源码反查模块, + # 一旦存在就以它为准:只保留它给出的 modelId(其余 codec 在运行时没注册,ByModelId 里也不该有)。 + BindingPath = NAMES / "model_binding.json" + if BindingPath.exists(): + Binding = json.loads(BindingPath.read_text("utf-8")).get("Entries") or [] + BindingOverride = {} + for Row in Binding: + BindingOverride.setdefault(Row["Key"], []).append(str(Row["ModelId"])) + Dropped = 0 + for Key, C in Codecs.items(): + Ids = BindingOverride.get(Key) + if Ids: + C["ModelId"] = Ids[0] + C["ModelIds"] = Ids + else: + if C.get("ModelId") or C.get("ModelIds"): + Dropped += 1 + C["ModelId"] = None + C["ModelIds"] = [] + print(f"[绑定] 采用运行时权威绑定:{len(Binding)} 条 modelId / {len(BindingOverride)} 个 codec," + f"丢弃 {Dropped} 个无运行时注册的旧绑定", file=sys.stderr) + # ---------- 1. 给每个 codec 分配唯一类名 ---------- CodecKeyToName = {} NameOwner = {}