旧 main.b7114b43.js 已被新构建替换(10,011,039 字节 / 1335 模块),本地镜像整体重切到新版并清掉旧产物;hornet 工具链按 tools/map_symbols.js 生成的新旧符号映射改名后重新打包(3dtank_bundle.js 与 unpacked/hornet_bundle.js 同步)。tools/symbol_map.json(8.9MB 映射产物)与切割解包目录不进库,可用 map_symbols.js / split.js 重新生成。
431 lines
14 KiB
JavaScript
431 lines
14 KiB
JavaScript
// map_symbols.js - 生成新旧客户端 bundle 之间的符号映射表
|
||
// 原理:两版源码除"混淆重命名"外高度一致。用 K 元 token 块做锚点对齐,
|
||
// 对齐后区域若 token 数相同则按位置一一对应,据此对标识符改名投票。
|
||
// 用法: node map_symbols.js <旧unpacked目录> <新bundle路径> <模块id逗号分隔> [输出json]
|
||
// 输出: JSON,结构 { 模块id: { 旧名: 新名 }, "_merged": { 旧名: 新名 } }
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const K = 10; // 锚点块长度(token 数)
|
||
const MIN_ID_LEN = 3; // 参与投票的标识符最小长度(过滤局部单字符名)
|
||
|
||
// ===== 词法切分:标识符 / 数字 / 字符串 / 正则 / 标点 =====
|
||
function Tokenize(src) {
|
||
const out = [];
|
||
let i = 0;
|
||
const n = src.length;
|
||
const IdStart = /[A-Za-z_$]/;
|
||
const IdPart = /[A-Za-z0-9_$]/;
|
||
while (i < n) {
|
||
const c = src[i];
|
||
// 空白(换行/空格/制表)不参与比对
|
||
if (c === ' ' || c === '\n' || c === '\r' || c === '\t' || c === '\f' || c === '\v') { i++; continue; }
|
||
// 注释
|
||
if (c === '/' && src[i + 1] === '/') {
|
||
while (i < n && src[i] !== '\n') i++;
|
||
continue;
|
||
}
|
||
if (c === '/' && src[i + 1] === '*') {
|
||
i += 2;
|
||
while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++;
|
||
i += 2;
|
||
continue;
|
||
}
|
||
// 字符串
|
||
if (c === '"' || c === "'" || c === '`') {
|
||
const quote = c;
|
||
let j = i + 1;
|
||
while (j < n) {
|
||
if (src[j] === '\\') { j += 2; continue; }
|
||
if (src[j] === quote) { j++; break; }
|
||
j++;
|
||
}
|
||
out.push({ k: 'str', s: src.slice(i, j) });
|
||
i = j;
|
||
continue;
|
||
}
|
||
// 正则字面量(启发式:前一个有效字符不是标识符/数字/右括号)
|
||
if (c === '/') {
|
||
let p = out.length - 1;
|
||
const prev = p >= 0 ? out[p] : null;
|
||
const okPrev = !prev || (prev.k === 'punct' && !')]'.includes(prev.s));
|
||
if (okPrev) {
|
||
let j = i + 1;
|
||
let closed = false;
|
||
while (j < n) {
|
||
if (src[j] === '\\') { j += 2; continue; }
|
||
if (src[j] === '\n') break;
|
||
if (src[j] === '[') { while (j < n && src[j] !== ']') { if (src[j] === '\\') j++; j++; } }
|
||
if (src[j] === '/') { j++; closed = true; break; }
|
||
j++;
|
||
}
|
||
if (closed) {
|
||
while (j < n && /[a-z]/.test(src[j])) j++;
|
||
out.push({ k: 're', s: src.slice(i, j) });
|
||
i = j;
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
if (IdStart.test(c)) {
|
||
let j = i + 1;
|
||
while (j < n && IdPart.test(src[j])) j++;
|
||
out.push({ k: 'id', s: src.slice(i, j) });
|
||
i = j;
|
||
continue;
|
||
}
|
||
if (/[0-9]/.test(c)) {
|
||
let j = i + 1;
|
||
while (j < n && /[0-9a-fA-FxX._]/.test(src[j])) j++;
|
||
out.push({ k: 'num', s: src.slice(i, j) });
|
||
i = j;
|
||
continue;
|
||
}
|
||
out.push({ k: 'punct', s: c });
|
||
i++;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ===== 从新 bundle 提取模块源码 =====
|
||
function ExtractNewModule(bundle, id) {
|
||
const re = /(?<=[{,])(\d{3,6}):(?:\(|function)/g;
|
||
let m;
|
||
const marks = [];
|
||
while ((m = re.exec(bundle)) !== null) {
|
||
marks.push({ id: m[1], start: m.index, keyEnd: m.index + m[1].length });
|
||
}
|
||
for (let i = 0; i < marks.length; i++) {
|
||
if (marks[i].id !== String(Number(id))) continue;
|
||
const end = i + 1 < marks.length ? marks[i + 1].start : bundle.length;
|
||
// 从 ':' 之后开始(跳过 "87072:")
|
||
return bundle.slice(marks[i].keyEnd + 1, end);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ===== 读旧模块源码(去掉文件头注释)=====
|
||
function ReadOldModule(dir, id) {
|
||
const f = path.join(dir, 'module_' + id + '.js');
|
||
if (!fs.existsSync(f)) return null;
|
||
let src = fs.readFileSync(f, 'utf8');
|
||
src = src.replace(/^(?:\s*\/\/[^\n]*\n)+/, '');
|
||
return src;
|
||
}
|
||
|
||
// ===== 过滤"无信息"标点 =====
|
||
// 两版由不同代 minifier 产出:括号/逗号/分号的增删不影响语义,但会打断对齐,故丢弃。
|
||
// 保留:标识符/数字/字符串/正则,以及 {} . [] = + - * / % < > ! & | ? : ~ ^
|
||
const SIG_PUNCT = '{}.[]=+-*/%<>!&|?:~^';
|
||
|
||
function FilterSignificant(tokens) {
|
||
const out = [];
|
||
for (const t of tokens) {
|
||
if (t.k === 'punct') {
|
||
if (SIG_PUNCT.indexOf(t.s) < 0) continue;
|
||
out.push(t);
|
||
} else {
|
||
out.push(t);
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ===== 流式对齐 =====
|
||
// 两个 token 流同源,差异只有两类:①标识符改名 ②minifier 风格差异/少量源码改动。
|
||
// 策略:同步推进;标识符对标识符即记录一条改名投票;否则在有限窗口内找一个"4 token 完全一致块"重同步。
|
||
const LOOK = 40; // 重同步搜索窗口(token 数)
|
||
const SYNC_LEN = 4; // 重同步锚块长度
|
||
|
||
function SameTok(a, b) {
|
||
return a.k === b.k && a.s === b.s;
|
||
}
|
||
|
||
function SameBlock(oldT, i, newT, j, len) {
|
||
if (i + len > oldT.length || j + len > newT.length) return false;
|
||
for (let x = 0; x < len; x++) if (!SameTok(oldT[i + x], newT[j + x])) return false;
|
||
return true;
|
||
}
|
||
|
||
function WalkAlign(oldT, newT, onIdPair, counter, onUnsync, look) {
|
||
const LOOK2 = look || LOOK;
|
||
let i = 0, j = 0;
|
||
const n = oldT.length, m = newT.length;
|
||
while (i < n && j < m) {
|
||
const a = oldT[i], b = newT[j];
|
||
if (SameTok(a, b)) { i++; j++; counter.same++; continue; }
|
||
if (a.k === 'id' && b.k === 'id') { onIdPair(a.s, b.s); i++; j++; counter.rename++; continue; }
|
||
// 重同步:优先以字符串/数字常量定位,其次 4-token 块
|
||
let bestK = -1, bestL = -1;
|
||
for (let k = 0; k <= LOOK2 && i + k < n; k++) {
|
||
const ta = oldT[i + k];
|
||
if (ta.k !== 'str' && ta.k !== 'num') continue;
|
||
for (let l = 0; l <= LOOK2 && j + l < m; l++) {
|
||
const tb = newT[j + l];
|
||
if (SameTok(ta, tb)) { bestK = k; bestL = l; break; }
|
||
}
|
||
if (bestK >= 0) break;
|
||
}
|
||
if (bestK < 0) {
|
||
for (let k = 0; k <= LOOK2 && i + k < n; k++) {
|
||
for (let l = 0; l <= LOOK2 && j + l < m; l++) {
|
||
if (SameBlock(oldT, i + k, newT, j + l, SYNC_LEN)) { bestK = k; bestL = l; break; }
|
||
}
|
||
if (bestK >= 0) break;
|
||
}
|
||
}
|
||
if (bestK < 0) {
|
||
if (onUnsync) onUnsync(i, j, oldT, newT);
|
||
i++; j++; counter.unsync++; continue;
|
||
}
|
||
counter.sync += (bestK + bestL);
|
||
i += bestK;
|
||
j += bestL;
|
||
}
|
||
}
|
||
|
||
// ===== 递归唯一 token 切分 =====
|
||
// 思路(patience diff):在两侧都"只出现一次"的 token 天然对应,可作锚点切分;
|
||
// 切出的段内再递归找唯一 token,直到段足够小,再做局部对齐。
|
||
const REGION_MAX = 4000; // 单段最大 token 数,超过则继续切分
|
||
const REGION_BAD = 0.25; // 段内未同步比例上限,超过则丢弃该段的投票
|
||
const MAX_DEPTH = 12;
|
||
|
||
function LowerBound(arr, v) {
|
||
let lo = 0, hi = arr.length;
|
||
while (lo < hi) {
|
||
const mid = (lo + hi) >> 1;
|
||
if (arr[mid] < v) lo = mid + 1; else hi = mid;
|
||
}
|
||
return lo;
|
||
}
|
||
|
||
function UniqueAnchors(oldT, newT) {
|
||
const cntO = new Map(), posO = new Map();
|
||
for (let i = 0; i < oldT.length; i++) {
|
||
const k = oldT[i].k + ':' + oldT[i].s;
|
||
cntO.set(k, (cntO.get(k) || 0) + 1);
|
||
posO.set(k, i);
|
||
}
|
||
const cntN = new Map(), posN = new Map();
|
||
for (let j = 0; j < newT.length; j++) {
|
||
const k = newT[j].k + ':' + newT[j].s;
|
||
cntN.set(k, (cntN.get(k) || 0) + 1);
|
||
posN.set(k, j);
|
||
}
|
||
const pairs = [];
|
||
for (const [k, c] of cntO) {
|
||
if (c !== 1 || cntN.get(k) !== 1) continue;
|
||
pairs.push([posO.get(k), posN.get(k)]);
|
||
}
|
||
pairs.sort((a, b) => a[0] - b[0]);
|
||
return LisLongest(pairs);
|
||
}
|
||
|
||
// 取按第二坐标严格递增的最长子序列,保证锚点单调
|
||
function LisLongest(pairs) {
|
||
const tails = [];
|
||
const prev = new Array(pairs.length).fill(-1);
|
||
for (let i = 0; i < pairs.length; i++) {
|
||
const v = pairs[i][1];
|
||
let lo = 0, hi = tails.length;
|
||
while (lo < hi) {
|
||
const mid = (lo + hi) >> 1;
|
||
if (pairs[tails[mid]][1] < v) lo = mid + 1; else hi = mid;
|
||
}
|
||
prev[i] = lo > 0 ? tails[lo - 1] : -1;
|
||
tails[lo] = i;
|
||
}
|
||
const out = [];
|
||
let k = tails.length ? tails[tails.length - 1] : -1;
|
||
while (k >= 0) { out.push(pairs[k]); k = prev[k]; }
|
||
return out.reverse();
|
||
}
|
||
|
||
// 段内局部对齐(带质量门限,低质段整段丢弃)
|
||
function LocalWalk(oldT, os, oe, newT, ns, ne, sink, counter) {
|
||
const oLen = oe - os, nLen = ne - ns;
|
||
if (oLen <= 0 || nLen <= 0) return;
|
||
if (oLen > REGION_MAX || nLen > REGION_MAX) { counter.skipBig++; return; }
|
||
const pairs = [];
|
||
const c = { same: 0, rename: 0, sync: 0, unsync: 0 };
|
||
WalkAlign(oldT.slice(os, oe), newT.slice(ns, ne), (a, b) => pairs.push([a, b]), c, null, 150);
|
||
counter.same += c.same;
|
||
counter.rename += c.rename;
|
||
counter.sync += c.sync;
|
||
counter.unsync += c.unsync;
|
||
const bad = (c.unsync * 2) / (oLen + nLen || 1);
|
||
if (bad > REGION_BAD) { counter.badRegion++; return; }
|
||
for (const [a, b] of pairs) sink(a, b);
|
||
}
|
||
|
||
function RecursiveAlign(oldT, newT, sink, counter, depth) {
|
||
const anchors = UniqueAnchors(oldT, newT);
|
||
if (anchors.length === 0) {
|
||
LocalWalk(oldT, 0, oldT.length, newT, 0, newT.length, sink, counter);
|
||
return;
|
||
}
|
||
const parts = [];
|
||
let oi = 0, nj = 0;
|
||
for (const [i, j] of anchors) {
|
||
parts.push([oi, i, nj, j]);
|
||
oi = i + 1;
|
||
nj = j + 1;
|
||
}
|
||
parts.push([oi, oldT.length, nj, newT.length]);
|
||
for (const [os, oe, ns, ne] of parts) {
|
||
const oLen = oe - os, nLen = ne - ns;
|
||
if (oLen <= 0 || nLen <= 0) continue;
|
||
if (oLen <= REGION_MAX && nLen <= REGION_MAX) {
|
||
LocalWalk(oldT, os, oe, newT, ns, ne, sink, counter);
|
||
} else if (depth < MAX_DEPTH) {
|
||
RecursiveAlign(oldT.slice(os, oe), newT.slice(ns, ne), sink, counter, depth + 1);
|
||
} else {
|
||
counter.skipBig++;
|
||
}
|
||
}
|
||
}
|
||
|
||
function AlignByAnchors(oldT, newT, sink, counter) {
|
||
RecursiveAlign(oldT, newT, sink, counter, 0);
|
||
}
|
||
|
||
// ===== 主流程 =====
|
||
function SumVotes(votes) {
|
||
const merged = {};
|
||
const detail = {};
|
||
for (const [oldName, m] of votes) {
|
||
let best = null, total = 0;
|
||
for (const [k, v] of m) {
|
||
total += v;
|
||
if (!best || v > best.v) best = { k, v };
|
||
}
|
||
merged[oldName] = best.k;
|
||
detail[oldName] = {
|
||
to: best.k,
|
||
keep: best.k === oldName,
|
||
votes: best.v,
|
||
total,
|
||
ratio: +(best.v / total).toFixed(3),
|
||
};
|
||
}
|
||
return { merged, detail };
|
||
}
|
||
|
||
function BuildMap(oldDir, newBundlePath, ids) {
|
||
const bundle = fs.readFileSync(newBundlePath, 'utf8');
|
||
const perModule = {};
|
||
const globalVotes = new Map();
|
||
const stats = [];
|
||
|
||
for (const id of ids) {
|
||
const oldSrc = ReadOldModule(oldDir, id);
|
||
const newSrc = ExtractNewModule(bundle, id);
|
||
if (!oldSrc || !newSrc) {
|
||
stats.push({ id, error: 'missing source' });
|
||
continue;
|
||
}
|
||
const oldT = FilterSignificant(Tokenize(oldSrc));
|
||
const newT = FilterSignificant(Tokenize(newSrc));
|
||
const counter = { same: 0, rename: 0, sync: 0, unsync: 0, skipBig: 0, badRegion: 0, voted: 0 };
|
||
const votes = new Map();
|
||
AlignByAnchors(oldT, newT, (a, b) => {
|
||
if (a.length < MIN_ID_LEN) return;
|
||
counter.voted++;
|
||
if (!votes.has(a)) votes.set(a, new Map());
|
||
const m = votes.get(a);
|
||
m.set(b, (m.get(b) || 0) + 1);
|
||
if (!globalVotes.has(a)) globalVotes.set(a, new Map());
|
||
const g = globalVotes.get(a);
|
||
g.set(b, (g.get(b) || 0) + 1);
|
||
}, counter);
|
||
|
||
const per = {};
|
||
for (const [oldName, m] of votes) {
|
||
let best = null, total = 0;
|
||
for (const [k, v] of m) {
|
||
total += v;
|
||
if (!best || v > best.v) best = { k, v };
|
||
}
|
||
if (best.v >= 2 && best.v / total >= 0.6) per[oldName] = best.k;
|
||
}
|
||
perModule[id] = per;
|
||
stats.push({
|
||
id,
|
||
oldTok: oldT.length,
|
||
newTok: newT.length,
|
||
same: counter.same,
|
||
rename: counter.rename,
|
||
sync: counter.sync,
|
||
unsync: counter.unsync,
|
||
skipBig: counter.skipBig,
|
||
badRegion: counter.badRegion,
|
||
voted: counter.voted,
|
||
mapped: Object.keys(per).length,
|
||
});
|
||
}
|
||
|
||
const { merged, detail } = SumVotes(globalVotes);
|
||
perModule['_merged'] = merged;
|
||
perModule['_detail'] = detail;
|
||
perModule['_stats'] = stats;
|
||
|
||
// 跨模块冲突检测:同一旧名在不同模块映射到不同新名
|
||
const conflicts = [];
|
||
const seen = new Map();
|
||
for (const id of ids) {
|
||
const per = perModule[id];
|
||
if (!per) continue;
|
||
for (const [oldName, newName] of Object.entries(per)) {
|
||
if (!seen.has(oldName)) { seen.set(oldName, new Map()); }
|
||
const m = seen.get(oldName);
|
||
if (!m.has(newName)) m.set(newName, []);
|
||
m.get(newName).push(id);
|
||
}
|
||
}
|
||
for (const [oldName, m] of seen) {
|
||
if (m.size > 1) {
|
||
const items = [];
|
||
for (const [newName, ids2] of m) items.push(newName + '(' + ids2.join('/') + ')');
|
||
conflicts.push(oldName + ' => ' + items.join(' | '));
|
||
}
|
||
}
|
||
perModule['_conflicts'] = conflicts;
|
||
return perModule;
|
||
}
|
||
|
||
function main() {
|
||
const oldDir = process.argv[2];
|
||
const newBundle = process.argv[3];
|
||
let ids = (process.argv[4] || '').split(',').filter(Boolean);
|
||
const outPath = process.argv[5] || path.join(__dirname, 'symbol_map.json');
|
||
if (!oldDir || !newBundle || ids.length === 0) {
|
||
console.error('用法: node map_symbols.js <旧unpacked目录> <新bundle> <模块id逗号分隔|all> [输出json]');
|
||
process.exit(1);
|
||
}
|
||
if (ids.length === 1 && ids[0] === 'all') {
|
||
ids = fs.readdirSync(oldDir)
|
||
.filter((f) => /^module_\d+\.js$/.test(f))
|
||
.map((f) => f.replace(/^module_|\.js$/g, ''));
|
||
console.log('模块数: ' + ids.length);
|
||
}
|
||
const result = BuildMap(oldDir, newBundle, ids);
|
||
fs.writeFileSync(outPath, JSON.stringify(result, null, 2), 'utf8');
|
||
console.log('=== 统计 ===');
|
||
for (const s of result._stats) {
|
||
console.log(s.error
|
||
? ('[' + s.id + '] ' + s.error)
|
||
: ('[' + s.id + '] 旧token=' + s.oldTok + ' 新token=' + s.newTok +
|
||
' 一致=' + s.same + ' 改名=' + s.rename + ' 重同步丢失=' + s.sync + ' 未同步=' + s.unsync +
|
||
' 跳过段=' + s.skipBig + ' 低质段=' + s.badRegion + ' 投票=' + s.voted + ' 映射=' + s.mapped));
|
||
}
|
||
const names = Object.keys(result._merged);
|
||
console.log('=== 映射条目: ' + names.length + ' ===');
|
||
console.log('输出: ' + outPath);
|
||
}
|
||
|
||
module.exports = { Tokenize, FilterSignificant, ExtractNewModule, ReadOldModule, WalkAlign, UniqueAnchors, RecursiveAlign, AlignByAnchors, BuildMap };
|
||
|
||
if (require.main === module) main();
|