Files
TankIC/client/tools/ExportItemIcons.mjs
T
TermiNexus 6c36c5e98d feat: M13a 导出官方「型号枚举→图标」表 + 素材中继 + 物品名称/类别中文可展示
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。
2026-09-18 15:56:00 +08:00

348 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 + ' 条');