A 图标表:从 module_36659.js:5135 的 eA.upf(底盘)与 module_57128.js:840 的 Dn.duk(炮塔)解出 case→形参→实参→i(模块id)→resources.json 的映射(严格走 case→形参名→形参声明行→实参行,不假设 case 号等于实参位置,因为该模块实参表约 1000 项而工厂形参只有 714 个、多出的被丢弃)。自验:用同文件 :907 的 Dn.euk 抗性表 21 case 全命中且本地镜像均存在(去重 20 个文件)。规模:底盘 416 case(未解 1)、炮塔 20、抗性 21、7 格道具 7。B 物品信息:名称来自服务端模型 3935107155251354407(DescriptionModelCC)的 name 字段,类别中文用 lobby.garage.<枚举名>(ARMOR=底盘/WEAPON=炮塔/PAINT=迷彩/DRONE=辅助无人机…),15 件仓库物品名称 9/9、类别中文 9/9 命中;但图标 0/9 —— 承载底盘/炮塔型号的 TankInfoCC(5543162332469864498)在全部 68 个 s2c 帧与模板里 0 次出现,属数据缺失而非算法问题,已如实标注。C 素材中继 client/tools/AssetRelay.mjs(8130):/ 服务客户端产物、/play/static/** 映射 3DTank/web/play/static 本地镜像、正确 MIME 与宽 CORS、防目录穿越(%2e%2e 返回 400)。回归:CompareContainerDecode 5 帧/26950/0、selftest 1108/9/90、semantic --demo 全部断言通过、Kotlin/JS 构建 SUCCESSFUL。
348 lines
16 KiB
JavaScript
348 lines
16 KiB
JavaScript
// ExportItemIcons.mjs —— 从官方解包源码导出「型号枚举 → 官方图标文件」表(客户端可用)
|
||
//
|
||
// 产物(幂等可重复运行):
|
||
// 1. client/src/jsMain/resources/ItemIcons.json 给人看 / 给其它语言用的 JSON 表
|
||
// 2. client/src/jsMain/kotlin/Protocol/ItemIconsData.kt 内嵌同一份 JSON 的 Kotlin 常量
|
||
// (Kotlin/JS 没有跨 browser/node 一致的资源读取 API,口径与 ExportProtocolTables.mjs 相同)
|
||
//
|
||
// 数据来源(只读,绝不改动):
|
||
// 3dtank.com/play/static/js/unpacked/resources.json webpack 模块 id → static/images/xxx.svg
|
||
// 3dtank.com/play/static/js/unpacked/module_36659.js 底盘:Mb(eA).upf = switch (t.k3_1) { case → n = <形参>; }
|
||
// 3dtank.com/play/static/js/unpacked/module_57128.js 炮塔:ct(Dn).duk = switch (t.k3_1) { … }
|
||
// 抗性:ct(Dn).euk = switch (t.k3_1) { case → return <形参>; }
|
||
// 3dtank.com/play/static/js/unpacked/module_82347.js 7 格道具图标表(ZMi 数组)
|
||
// 3DTank/web/play/static/translations/zh-CN.*.json lobby.garage.* 类别中文(显示层)
|
||
//
|
||
// 口径(为什么不能「case 号 = 实参位置」):
|
||
// 工厂形参只有 714 个,而 switch 里引用的实参是**形参名**;形参与实参一一对应:
|
||
// 形参#k 在参数列表第 k 行,实参#k 在调用实参列表第 k 行(两者行号差是文件的固定常量)。
|
||
// 所以必须走:case → 形参名 → 形参声明行 → 同序实参行 → i(<模块id>) → resources.json → 文件名。
|
||
// 自验:用 Dn.euk(抗性 20 项)全套跑一遍,全部命中且都落在本地镜像里(见 stdout 的 SelfCheck)。
|
||
//
|
||
// 用法:node client/tools/ExportItemIcons.mjs
|
||
'use strict';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import url from 'node:url';
|
||
|
||
const Here = path.dirname(url.fileURLToPath(import.meta.url));
|
||
const ClientDir = path.resolve(Here, '..');
|
||
const RepoDir = path.resolve(ClientDir, '..');
|
||
const UnpackedDir = path.join(RepoDir, '3dtank.com', 'play', 'static', 'js', 'unpacked');
|
||
// 图标路径形如 "static/images/xxx.svg",相对 web/play 根(不是 web/play/static)
|
||
const WebPlayDir = path.join(RepoDir, '3DTank', 'web', 'play');
|
||
const WebStaticDir = path.join(WebPlayDir, 'static');
|
||
const OutJson = path.join(ClientDir, 'src', 'jsMain', 'resources', 'ItemIcons.json');
|
||
const OutKotlin = path.join(ClientDir, 'src', 'jsMain', 'kotlin', 'Protocol', 'ItemIconsData.kt');
|
||
|
||
const Resources = JSON.parse(fs.readFileSync(path.join(UnpackedDir, 'resources.json'), 'utf8')).resourceMap;
|
||
|
||
// ---------------------------------------------------------------- 通用解析:形参表 ↔ 实参表
|
||
|
||
function ReadLines(File) {
|
||
return fs.readFileSync(File, 'utf8').split('\n');
|
||
}
|
||
|
||
// 形参声明行形如 " Ns,";实参行形如 " i(5012)," 或 " t.exports,"。
|
||
function TrimComma(Text) {
|
||
let S = Text.trim();
|
||
if (S.endsWith(',')) S = S.slice(0, -1);
|
||
return S;
|
||
}
|
||
|
||
function ModuleIdOfArg(Line) {
|
||
const M = /i\(([0-9]+)\)/.exec(Line);
|
||
return M ? M[1] : null;
|
||
}
|
||
|
||
// 为单个文件建「形参名 → 模块 id」:参数列表从 `function (` 行之后开始,实参列表从 `})(` 行之后开始。
|
||
// 两者同序、同长(取较短者为长度)。返回 null 表示该文件不是这种工厂结构。
|
||
function BuildParamMap(Lines) {
|
||
let FnAt = -1;
|
||
for (let I = 0; I < Lines.length; I++) if (Lines[I].trim() === '!(function (') { FnAt = I; break; }
|
||
if (FnAt < 0) return null;
|
||
let ParamEnd = -1;
|
||
for (let I = FnAt + 1; I < Lines.length; I++) if (Lines[I] === ' ) {') { ParamEnd = I; break; }
|
||
if (ParamEnd < 0) return null;
|
||
let InvAt = -1;
|
||
for (let I = ParamEnd + 1; I < Lines.length; I++) if (Lines[I] === ' })(') { InvAt = I; break; }
|
||
if (InvAt < 0) return null;
|
||
let ArgEnd = -1;
|
||
for (let I = InvAt + 1; I < Lines.length; I++) if (Lines[I] === ' );' || Lines[I] === ' )') { ArgEnd = I; break; }
|
||
if (ArgEnd < 0) return null;
|
||
const Count = Math.min(ParamEnd - FnAt - 1, ArgEnd - InvAt - 1);
|
||
const Map = {};
|
||
for (let K = 0; K < Count; K++) {
|
||
const Name = TrimComma(Lines[FnAt + 1 + K]);
|
||
const Id = ModuleIdOfArg(Lines[InvAt + 1 + K]);
|
||
if (Name && Id) Map[Name] = Id;
|
||
}
|
||
return Map;
|
||
}
|
||
|
||
// 解一个 switch:支持两种体——
|
||
// ① 赋值式(底盘/炮塔):case N: / n = <形参>; / break;
|
||
// ② 返回式(抗性):case N: return <形参>; 以及 fall-through(case 40: case 44: return M;)
|
||
// 返回 { 序号: 形参名 }。遇到 default / return n; / 收尾 } 即停。
|
||
function ParseSwitch(Lines, SwitchAt) {
|
||
const Out = {};
|
||
let Pending = [];
|
||
for (let I = SwitchAt + 1; I < Lines.length; I++) {
|
||
const L = Lines[I].trim();
|
||
if (L.startsWith('default:') || L === '}' || /^return [A-Za-z0-9_$]+;$/.test(L)) break;
|
||
const M = /^case ([0-9]+):(.*)$/.exec(L);
|
||
if (!M) continue;
|
||
const C = Number(M[1]);
|
||
const Rest = M[2].trim();
|
||
let Name = null;
|
||
if (Rest.startsWith('return ')) Name = Rest.slice(7).replace(/;.*/, '');
|
||
else if (Rest.startsWith('n = ')) Name = Rest.slice(4).replace(/;.*/, '');
|
||
else if (Rest === '') {
|
||
const Nx = Lines[I + 1] ? Lines[I + 1].trim() : '';
|
||
if (Nx.startsWith('return ')) { Name = Nx.slice(7).replace(/;.*/, ''); I++; }
|
||
else if (Nx.startsWith('n = ')) { Name = Nx.slice(4).replace(/;.*/, ''); I++; }
|
||
else { Pending.push(C); continue; } // fall-through(本 case 与下一个 case 共用体)
|
||
}
|
||
if (Name != null) {
|
||
for (const P of Pending) Out[P] = Name;
|
||
Pending = [];
|
||
Out[C] = Name;
|
||
}
|
||
}
|
||
return Out;
|
||
}
|
||
|
||
// 找函数体内第一个 switch (...) 行号;找不到返回 null
|
||
function FindSwitch(Lines, FromLine1Based) {
|
||
for (let I = FromLine1Based - 1; I < Lines.length; I++) if (/switch \([A-Za-z0-9_$]+\.k3_1\)/.test(Lines[I])) return I;
|
||
return null;
|
||
}
|
||
|
||
// case 表 → { 序号: 图标文件 }(形参 → 模块 id → resources.json)
|
||
function ResolveCaseTable(Cases, ParamMap) {
|
||
const Out = {};
|
||
const Misses = [];
|
||
for (const C of Object.keys(Cases).sort((A, B) => Number(A) - Number(B))) {
|
||
const Param = Cases[C];
|
||
const Id = ParamMap[Param];
|
||
const File = Id ? Resources[Id] : null;
|
||
const Rel = File && File.startsWith('/') ? File.slice(1) : File;
|
||
if (!Rel) Misses.push({ Case: Number(C), Param, ModuleId: Id || null });
|
||
else Out[C] = Rel; // 统一成 "static/images/xxx.svg"(不带前导 /)
|
||
}
|
||
return { Table: Out, Misses };
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 底盘 / 炮塔 / 抗性
|
||
|
||
const HullLines = ReadLines(path.join(UnpackedDir, 'module_36659.js'));
|
||
const TurretLines = ReadLines(path.join(UnpackedDir, 'module_57128.js'));
|
||
const HullParams = BuildParamMap(HullLines);
|
||
const TurretParams = BuildParamMap(TurretLines);
|
||
if (!HullParams || !TurretParams) {
|
||
console.error('✗ 解析形参/实参表失败(工厂结构变了?)');
|
||
process.exit(1);
|
||
}
|
||
|
||
const HullSwitchAt = FindSwitch(HullLines, 5133);
|
||
const HullRaw = ParseSwitch(HullLines, HullSwitchAt);
|
||
const Hull = ResolveCaseTable(HullRaw, HullParams);
|
||
const HullMissLines = Hull.Misses.map((M) => 'case ' + M.Case + ' -> 形参 ' + M.Param + '(模块 id ' + (M.ModuleId || '无') + ')');
|
||
|
||
const TurretSwitchAt = FindSwitch(TurretLines, 838);
|
||
const TurretRaw = ParseSwitch(TurretLines, TurretSwitchAt);
|
||
const Turret = ResolveCaseTable(TurretRaw, TurretParams);
|
||
|
||
// 抗性:ct(Dn).euk(约 906 行)—— 自验用
|
||
const ResistSwitchAt = FindSwitch(TurretLines, 906);
|
||
const ResistRaw = ParseSwitch(TurretLines, ResistSwitchAt);
|
||
const Resist = ResolveCaseTable(ResistRaw, TurretParams);
|
||
|
||
// 自验:抗性每一项都要(a)解析出图标文件、(b)文件在本地镜像里真的存在。
|
||
function LocalExists(Rel) {
|
||
return fs.existsSync(path.join(WebPlayDir, Rel));
|
||
}
|
||
const ResistLines = [];
|
||
let ResistLocal = 0;
|
||
for (const C of Object.keys(Resist.Table)) {
|
||
const Rel = Resist.Table[C];
|
||
const Ok = LocalExists(Rel);
|
||
if (Ok) ResistLocal++;
|
||
ResistLines.push('case ' + C + ' -> ' + Resist.Table[C] + (Ok ? ' ✓本地存在' : ' ✗本地缺失'));
|
||
}
|
||
const ResistDistinct = new Set(Object.values(Resist.Table)).size;
|
||
|
||
// ---------------------------------------------------------------- 7 格道具图标表(module_82347)
|
||
|
||
const SupplyLines = ReadLines(path.join(UnpackedDir, 'module_82347.js'));
|
||
const SupplyParams = BuildParamMap(SupplyLines);
|
||
const SupplyEntries = [];
|
||
if (SupplyParams) {
|
||
// 表体:7 个 new ZMi(<类型函数>, <图标资源形参>, <缩放函数>)
|
||
for (let I = 0; I < SupplyLines.length; I++) {
|
||
const M = /new ZMi\(([^,]+),\s*([A-Za-z0-9_$]+),\s*([^)]+)\)/.exec(SupplyLines[I]);
|
||
if (!M) continue;
|
||
const TypeFn = M[1].trim();
|
||
const IconParam = M[2].trim();
|
||
const Id = SupplyParams[IconParam];
|
||
const File = Id ? Resources[Id] : null;
|
||
const Rel = File || null;
|
||
SupplyEntries.push({
|
||
Key: TypeFn, // 产出「道具类型枚举」的函数名(源码里的局部名,语义未解,照抄不猜)
|
||
Icon: Rel,
|
||
ModuleId: Id || null,
|
||
Line: I + 1,
|
||
});
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 其它同类表(自动扫描,如实报告)
|
||
|
||
// 扫描所有解包模块:对每个 `switch (X.k3_1)`,若其 case 体解析出的形参**绝大多数**都落在
|
||
// resources.json 的图标资源上(≥ 4 个 case 且 ≥ 80% 命中),即认定是一张「case → 图标」表。
|
||
function ScanOtherTables() {
|
||
const Found = [];
|
||
// 已单独导出的三张 switch 表不再重复计入「其它表」
|
||
const Known = new Set([
|
||
'module_36659.js:' + (HullSwitchAt + 1),
|
||
'module_57128.js:' + (TurretSwitchAt + 1),
|
||
'module_57128.js:' + (ResistSwitchAt + 1),
|
||
]);
|
||
const Files = fs.readdirSync(UnpackedDir).filter((F) => /^module_\d+\.js$/.test(F));
|
||
for (const F of Files) {
|
||
let Lines;
|
||
try {
|
||
Lines = ReadLines(path.join(UnpackedDir, F));
|
||
} catch (E) {
|
||
continue;
|
||
}
|
||
const Params = BuildParamMap(Lines);
|
||
if (!Params) continue;
|
||
for (let I = 0; I < Lines.length; I++) {
|
||
if (!/switch \([A-Za-z0-9_$]+\.k3_1\)/.test(Lines[I])) continue;
|
||
if (Known.has(F + ':' + (I + 1))) continue;
|
||
const Cases = ParseSwitch(Lines, I);
|
||
const Nums = Object.keys(Cases);
|
||
if (Nums.length < 4) continue;
|
||
const R = ResolveCaseTable(Cases, Params);
|
||
const Hit = Object.keys(R.Table).length;
|
||
if (Hit / Nums.length >= 0.8 && Hit >= 4) {
|
||
Found.push({
|
||
Module: F,
|
||
Line: I + 1,
|
||
Cases: Nums.length,
|
||
Icons: Object.keys(R.Table).length,
|
||
Sample: Object.keys(R.Table).sort((A, B) => Number(A) - Number(B)).slice(0, 4).map((C) => C + '=' + R.Table[C]),
|
||
All: R.Table,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return Found;
|
||
}
|
||
const OtherTables = ScanOtherTables();
|
||
|
||
// ---------------------------------------------------------------- 类别中文(lobby.garage.*)
|
||
|
||
const ZhFiles = fs.readdirSync(path.join(WebStaticDir, 'translations')).filter((F) => /^zh-CN\..*\.json$/.test(F));
|
||
const Zh = JSON.parse(fs.readFileSync(path.join(WebStaticDir, 'translations', ZhFiles[0]), 'utf8'));
|
||
const GarageLabels = {};
|
||
for (const [K, V] of Object.entries((Zh.lobby && Zh.lobby.garage) || {})) {
|
||
if (typeof V === 'string') GarageLabels[K] = V;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 组装产物
|
||
|
||
const Selection = {
|
||
HullCases: Object.keys(Hull.Table).length,
|
||
HullMisses: Hull.Misses.length,
|
||
TurretCases: Object.keys(Turret.Table).length,
|
||
TurretMisses: Turret.Misses.length,
|
||
ResistanceCases: Object.keys(Resist.Table).length,
|
||
ResistanceMisses: Resist.Misses.length,
|
||
SupplyCount: SupplyEntries.length,
|
||
OtherTableCount: OtherTables.length,
|
||
GarageLabelCount: Object.keys(GarageLabels).length,
|
||
};
|
||
|
||
const Tables = {
|
||
Note:
|
||
'物品图标表,由 client/tools/ExportItemIcons.mjs 从官方解包源码生成,请勿手改。' +
|
||
'Hull = module_36659.js 的 Mb(eA).upf switch:case(底盘型号)→ i(<模块id>) → resources.json 文件名;' +
|
||
'Turret = module_57128.js 的 ct(Dn).duk switch(炮塔型号);Resistance = 同文件 ct(Dn).euk switch(抗性,' +
|
||
'用于自验解析算法)。解析口径:case → 形参名 → 形参声明行 → 同序实参行 → i(<模块id>),' +
|
||
'不能假设「case 号 = 实参位置」。Supply = module_82347.js 的 7 格道具图标表(new ZMi 数组,非 switch)。' +
|
||
'OtherTables = 自动扫描到的其它同类 case→图标 表(供手榴弹/无人机/防御模块查用;没有就是空数组)。' +
|
||
'GarageLabels = zh-CN 的 lobby.garage.*(只取字符串叶子),显示层类别中文用。' +
|
||
'图标路径统一为 "static/images/xxx.svg"(相对 web/play 根,客户端配 AssetRelay 的 /play 前缀取用)。',
|
||
GeneratedAt: new Date().toISOString(),
|
||
Source:
|
||
'3dtank.com/play/static/js/unpacked/{resources.json,module_36659.js,module_57128.js,module_82347.js} + ' +
|
||
'3DTank/web/play/static/translations/' + ZhFiles[0],
|
||
Selection,
|
||
SelfCheck: {
|
||
ResistanceCases: Object.keys(Resist.Table).length,
|
||
ResistanceResolved: Object.keys(Resist.Table).length,
|
||
ResistanceLocalFilesPresent: ResistLocal,
|
||
ResistanceDistinctFiles: ResistDistinct,
|
||
Lines: ResistLines,
|
||
},
|
||
Hull: Hull.Table,
|
||
Turret: Turret.Table,
|
||
Resistance: Resist.Table,
|
||
Supply: SupplyEntries,
|
||
OtherTables,
|
||
GarageLabels,
|
||
};
|
||
|
||
const Json = JSON.stringify(Tables);
|
||
fs.mkdirSync(path.dirname(OutJson), { recursive: true });
|
||
fs.writeFileSync(OutJson, Json + '\n', 'utf8');
|
||
|
||
function EscapeKotlin(S) {
|
||
let Out = '';
|
||
for (const Ch of S) {
|
||
const Code = Ch.codePointAt(0);
|
||
if (Ch === '\\') Out += '\\\\';
|
||
else if (Ch === '"') Out += '\\"';
|
||
else if (Ch === '$') Out += '\\$';
|
||
else if (Code < 0x20) Out += '\\u' + Code.toString(16).padStart(4, '0');
|
||
else Out += Ch;
|
||
}
|
||
return Out;
|
||
}
|
||
|
||
const Kotlin =
|
||
'// 本文件由 client/tools/ExportItemIcons.mjs 生成,请勿手改。\n' +
|
||
'// 内容 = client/src/jsMain/resources/ItemIcons.json(同一份 JSON)。\n' +
|
||
'package Protocol\n' +
|
||
'\n' +
|
||
'internal const val ItemIconsJsonText: String =\n "' + EscapeKotlin(Json) + '"\n';
|
||
fs.mkdirSync(path.dirname(OutKotlin), { recursive: true });
|
||
fs.writeFileSync(OutKotlin, Kotlin, 'utf8');
|
||
|
||
// ---------------------------------------------------------------- 自验输出
|
||
|
||
console.log('已生成物品图标表:');
|
||
console.log(' ' + OutJson + ' ' + Json.length + ' 字符');
|
||
console.log(' ' + OutKotlin + ' ' + Kotlin.length + ' 字符');
|
||
console.log(' module_36659.js 形参数 ' + Object.keys(HullParams).length + ' / module_57128.js 形参数 ' + Object.keys(TurretParams).length);
|
||
console.log('');
|
||
console.log('【自验】抗性表 Dn.euk(module_57128.js:' + (ResistSwitchAt + 1) + '):' + Object.keys(Resist.Table).length + ' 个 case → 解析出 '
|
||
+ Object.keys(Resist.Table).length + ' 个图标,本地镜像里存在 ' + ResistLocal + ' 个,去重后 ' + ResistDistinct + ' 个文件');
|
||
for (const L of ResistLines) console.log(' ' + L);
|
||
console.log(' 自验结论:' + (ResistLocal === Object.keys(Resist.Table).length ? '✓ ' + ResistLocal + '/' + Object.keys(Resist.Table).length + ' 全命中且本地存在(去重 ' + ResistDistinct + ' 个文件),case→形参→实参 的解析算法成立' : '✗ 有未命中'));
|
||
console.log('');
|
||
console.log('【导出】底盘 upf(module_36659.js:' + (HullSwitchAt + 1) + '):' + Selection.HullCases + ' case → ' + Selection.HullCases + ' 图标(未解 ' + Selection.HullMisses + ')');
|
||
for (const L of HullMissLines) console.log(' 未解:' + L);
|
||
console.log('【导出】炮塔 duk(module_57128.js:' + (TurretSwitchAt + 1) + '):' + Selection.TurretCases + ' case → ' + Selection.TurretCases + ' 图标(未解 ' + Selection.TurretMisses + ')');
|
||
console.log('【导出】7 格道具图标表(module_82347.js):' + SupplyEntries.length + ' 项 → ' + SupplyEntries.map((S) => S.Icon).join(', '));
|
||
console.log('');
|
||
console.log('【自动扫描】其它 case→图标 表:' + OtherTables.length + ' 张');
|
||
for (const T of OtherTables) {
|
||
console.log(' ' + T.Module + ':' + T.Line + ' case ' + T.Cases + ' → 图标 ' + T.Icons + ' 样例 ' + T.Sample.join(', '));
|
||
}
|
||
if (!OtherTables.length) console.log(' (无:手榴弹 / 无人机 / 防御模块未找到同类 case→图标 表,如实标注「无此表」)');
|
||
console.log('');
|
||
console.log('【显示层】lobby.garage.* 中文标签 ' + Object.keys(GarageLabels).length + ' 条');
|