diff --git a/har/1ms_dianxuan/PROGRESS.md b/har/1ms_dianxuan/PROGRESS.md
index a3e9757..a36b5a4 100644
--- a/har/1ms_dianxuan/PROGRESS.md
+++ b/har/1ms_dianxuan/PROGRESS.md
@@ -66,6 +66,7 @@
| 33 | **VM 程序结构层已吃透:顶层就是 62 个模块工厂的注册表** | 顶层 `@0` 做 `registry[id] = function(module, exports, require){...}`(`DUP; PUSH_IMM [id]; REVERSE_TOP2; JUMP -> CREATE_CLOSURE; SET_PROP`)。`tools/tdc_program.py --modules` 直接导出。修了两个真 bug:① 反汇编器漏了 `RET_TRUE`/`THROW` 是终结符(控制流穿过函数结尾 → 子程序归属全错);② 提升器终结符判定写死 opcode 数值 | 确证 |
| 34 | **`cd` 槽位序的来源已定位并可静态导出** | 装配链:清单模块 `arr[arr.length] = require(N)`(**追加,所以 require 顺序就是数组顺序**,已核)→ 分发模块按 `.on`/`.reset`/`.get` 分三数组 → 装配模块 `r3='{"cd":['` + `for(...) r3 += arr[i].get()`。线上构建实测 **37 个 require + 1 个字面量槽 = 38**,与真机 `cd` 槽数完全吻合;两份构建的 require 串不同(与 #31 一致)。工具:`tools/tdc_layout.py` | 确证(顺序)/ 语义标签部分 |
| 35 | **运行时反证通过:静态导出的 require 序 == 运行时真实 require 序(逐项一致)** | 在真机 iframe(`tdc.js` 实际执行的上下文,`window.TDC.getData` 就在这里)里包一层 `Function.prototype.apply`,模块工厂调用形状 = **4 参 `[object,object,object,function]`**(require 是最后一个),按工厂实例分组记账。触发一次刷新后取运行时串,用 `tools/cdp_script_source.js`(Debugger.scriptParsed + getScriptSource)取到**运行时真正执行的那份 tdc.js**(exactly-once,不能用重新 fetch 代替——#29 每次返回不同构建),两边串:
静态 = 运行时 = `[8,9,10,13,14,15,16,17,18,19,20,21,23,24,25,30,31,32,34,36,37,1,38,39,40,41,42,43,44,47,48,49,50,51,56,57,58]`(37 项,逐位相同)
→ 由此可判定:之前"部分槽语义对不上"是**标注启发式**的问题,**顺序本身正确**。工具:`scripts/experiment_require_order.py` | 确证 |
+| 36 | **「清单项 → cd 槽」的精确规则(同一构建端到端校验确立)** | 装配循环(线上构建 `@4362`,370 条)分支树已读通:
`if(!item) continue;` `v,w,kind = item.get();`
· **`kind===2`(`@4820`)→ 不进 `cd`**:它把 `r3` 清空(`r3=""`)转存到 `r4`(`@4860`),`r4` 在收尾 `@4905` 被二次加工后另作他用(如随机串 `vXc29RxhvT34Q…`)
· `v===null/undefined`(`@4799`)→ `out += "null"`
· `typeof v==="number"`(`@4738`)→ `out += (v||0)`
· **`kind===1`(`@4724`)→ `out += v` 原样拼**:返回值是 JSON 碎片串,串里的逗号会**切出额外槽**(实测 `"641547489,1791732001"` → 2 槽)
· 其余(`@4703`)→ `out += '"' + v + '"'`
· 收尾 `@4753`:`typeof w === "number"` 才再 +1 槽(实测 kind=2 的第二元素是 null,不触发;此路径本次未命中)
→ **37 项 → 38 槽**,仅第 33 项(模块 1)占 2 槽。工具:`tools/tdc_slotmap.py`(`--items` 运行时取样 + `--plain` 明文对拍) | 确证 |
---
@@ -76,7 +77,6 @@
| 1 | 1ms aid=195672344 为何在答案正确时返回 `errorCode=12` | 需要"真实业务会话/真实验证码页面上下文"(登录态),而非环境指纹 | **中(未做单变量实验)** |
| 2 | 新版明文字段语义(哪一位是时间戳/指纹/轨迹) | 时间戳是 `1[6-9]\d{8}` 大整数;轨迹是 `[x,y,t]` 三元组 | 中高 |
| 3 | 明文里 `[360,360]`、`"2560-1440-1345-24-*-*-| -*"` 的作用 | 屏幕/窗口指纹串 | 中 |
-| 4 | **采集器 `get()` 的返回值是 tuple, 装配模块按 kind 分支 —— "静态第 i 项 ↔ cd 第 i 槽"的朴素对齐会被破坏** | 装配模块里是 `r5 = item.get(); r9 = r5[0]; r10 = r5[1]; r11 = r5[2];` 然后按 `r11`(kind=1/2/其它)与 `typeof r9` 走不同分支再拼进 JSON。所以每个采集器"贡献几个槽/怎么格式化"要看 kind;实测 cap2 那份里 `c.screen.width` 的模块闭包返回 `[screen.width]`,而 cd 里 `2560` 落在 9 号槽、`1023` 落在 11 号槽 → 说明**不能按项序号直接对齐**。**未解**:kind 各值对应的拼装规则(读透装配模块 `@4362` 的 `4445~4939` 分支即可判定) | **待验证** |
---
diff --git a/tools/tdc_slotmap.py b/tools/tdc_slotmap.py
new file mode 100644
index 0000000..20c4d57
--- /dev/null
+++ b/tools/tdc_slotmap.py
@@ -0,0 +1,174 @@
+# -*- coding: utf-8 -*-
+"""
+tdc_slotmap.py —— 把「清单项」精确映射到「cd 槽号」。
+
+为什么需要单独一层(承 har/1ms_dianxuan/PROGRESS.md #34/#35/#36):
+ #34 槽位序 = 清单模块的 require 顺序(追加式数组,已核)
+ #36 **槽数 ≠ 项数**: 装配循环里 `kind=1` 的分支是 `out += v`(不加引号直接拼),
+ 所以某些项的返回值**本身带逗号**时会一次性产出多个槽。
+ 实测同一构建: 37 项 -> 38 槽,多出的那一处是第 33 项(模块 1)
+ get() 返回 "641547489,1791732001"。
+
+因此映射不能是"第 i 项 = 第 i 槽",必须按每个采集器的**返回值形状**推算:
+
+ kind = ret[2](可能缺省)
+ kind == 1: ret[0] 原样拼 -> 占 1 + ret[0].count(",") 槽(数字则 1 槽)
+ 其它: ret[0] 占 1 槽
+ ret[1] 是 number: 再 +1 槽(实测 kind=2 的项第二元素是 null,不触发)
+
+本模块提供:
+ * `slot_map(prog, items)` —— 给静态导出的项 + 运行时取样到的返回值形状,算出映射
+ * `from_runtime_items(prog)` —— 需要浏览器环境(iframe 里读清单各项 get()),见 CLI
+ * CLI 校验模式: 与一份已解密的 collect 明文对拍,确认推算出的槽数 == 实际槽数
+
+用法:
+ # 用运行时取样 + 本次 getData 解密出的明文, 做一次端到端对拍
+ .venv/bin/python tools/tdc_slotmap.py \\
+ --items /tmp/items.json --plain /tmp/plain.txt
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from pathlib import Path
+from typing import Dict, List, Optional, Sequence, Tuple
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+if str(ROOT / "tools") not in sys.path:
+ sys.path.insert(0, str(ROOT / "tools"))
+
+import tdc_layout # noqa: E402
+import tdc_program as tp # noqa: E402
+
+
+def slots_of_item(ret) -> int:
+ """一个采集器的 get() 返回值会占几个 cd 槽。
+
+ ret 是运行时取到的值(或其形状描述)。规则见 PROGRESS.md #36。
+ """
+ if ret is None:
+ return 1
+ if not isinstance(ret, (list, tuple)) or not ret:
+ return 1
+ first = ret[0]
+ kind = ret[2] if len(ret) >= 3 else None
+ n = 1
+ if kind == 1 and isinstance(first, str):
+ # 不加引号直接拼 -> 串里的逗号会切出额外槽
+ n += first.count(",")
+ # 第二个元素是 number 时再占一槽(实测 kind=2 的第二元素是 null,不触发)
+ if len(ret) >= 2 and isinstance(ret[1], (int, float)) and not isinstance(ret[1], bool):
+ n += 1
+ return n
+
+
+def slot_map(seq: Sequence[int], rets: Sequence) -> List[Tuple[int, int, int]]:
+ """返回 [(清单项下标, 模块 id, 该首槽在 cd 中的槽号)]。
+
+ 某一项占多槽时,只给它的**首槽**;其余槽是你按 ret 内容自然拼出来的。
+ """
+ out: List[Tuple[int, int, int]] = []
+ slot = 0
+ for i, m in enumerate(seq):
+ out.append((i, m, slot))
+ slot += slots_of_item(rets[i] if i < len(rets) else None)
+ return out
+
+
+def total_slots(seq: Sequence[int], rets: Sequence) -> int:
+ return sum(slots_of_item(rets[i] if i < len(rets) else None) for i in range(len(seq)))
+
+
+def load_items(path: str) -> List:
+ """读 cdp_eval 的取样输出({items:[{head,types,len,...}]})。
+
+ 运行时只能带回 head(截断过),所以这里按 types/head 重建一个**够用的**形状。
+ """
+ d = json.load(open(path, encoding="utf-8"))
+ res = d.get("result", d)
+ items = res.get("items") or []
+ rets = []
+ for it in items:
+ types = it.get("types")
+ head = it.get("head")
+ if not isinstance(types, list):
+ rets.append(None)
+ continue
+ rebuilt = []
+ for t, h in zip(types, head or []):
+ if t == "string":
+ rebuilt.append(h if isinstance(h, str) else str(h))
+ elif t == "number":
+ rebuilt.append(h if isinstance(h, (int, float)) else 0)
+ elif t == "null":
+ rebuilt.append(None)
+ elif t == "array":
+ rebuilt.append([])
+ else:
+ rebuilt.append(None)
+ rets.append(rebuilt)
+ return rets
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description="清单项 -> cd 槽号的精确映射")
+ ap.add_argument("path")
+ ap.add_argument("--items", default=None, help="运行时取样的 items.json")
+ ap.add_argument("--plain", default=None, help="已解密的 collect 明文(用于对拍校验)")
+ ap.add_argument("--json", default=None, help="把映射写成 JSON")
+ args = ap.parse_args()
+
+ prog = tp.load(args.path)
+ reg = dict(prog.module_registry(0))
+ _mid, _entry, seq = tdc_layout.find_manifest_module(prog)
+ rets: List = load_items(args.items) if args.items else []
+
+ print(f"文件 : {args.path}")
+ print(f"清单模块 : registry[{_mid}]")
+ print(f"清单项数 : {len(seq)}")
+ if args.items:
+ print(f"运行时取样 : {len(rets)} 项")
+ print(f"推算 cd 槽数 : {total_slots(seq, rets)}")
+
+ if rets:
+ print("\n项 模块 工厂 取到的值(截断) 占槽 首槽")
+ for i, m in enumerate(seq):
+ e = reg.get(m)
+ r = rets[i] if i < len(rets) else None
+ s = str(r)[:36]
+ n = slots_of_item(r)
+ start = slot_map(seq, rets)[i][2]
+ print(f"{i:2d} {m:3d} @{e:<6} {s:<38} {n:^3} {start:2d}")
+
+ if args.plain:
+ cd = json.loads(open(args.plain, encoding="latin1").read()
+ .rstrip("\x00").strip()).get("cd") or []
+ print(f"\n明文实际 cd 槽数: {len(cd)}")
+ if rets:
+ ok = len(cd) == total_slots(seq, rets)
+ print(f">>> 推算 == 实际 ? {ok}")
+ if not ok:
+ print(" (差 %d 槽: 检查是否有项返回值的逗号计数没算准)" % (len(cd) - total_slots(seq, rets)))
+ else:
+ print("\n逐项 -> 槽对位:")
+ sm = slot_map(seq, rets)
+ for (i, m, st) in sm:
+ n = slots_of_item(rets[i])
+ vals = [repr(cd[st + k])[:24] for k in range(min(n, len(cd) - st))]
+ print(f" 项{i:2d}(模块{m:2d}) -> 槽 {st}..{st + n - 1} {' | '.join(vals)}")
+
+ if args.json and rets:
+ Path(args.json).write_text(json.dumps(
+ {"manifestModule": _mid, "seq": list(seq),
+ "map": [{"item": i, "module": m, "firstSlot": st, "slots": slots_of_item(rets[i])}
+ for (i, m, st) in slot_map(seq, rets)]},
+ ensure_ascii=False, indent=1), encoding="utf-8")
+ print(f"映射 -> {args.json}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())