工具链(3DTank/tools/):extract(模块提取)、mine_evidence/apply_names(命名证据与别名回填)、extract_codec_fields(字段表 + 全库槽位类型 + PrimClassWire/ResourceClasses)、mine_downlink(下行字段表)、extract_space_init/rebase_space_init/audit_space_init(空间模板与记录切分审计)、extract_space_session(把真实会话切成按上行触发的回放模板)、pair_flow(请求→应答配对)、probe_containers、ingest/decrypt_frames(抓包入库与解密)、serve/fetch_assets(本地站点镜像)、import_db(抓包对象仓库导入 MySQL)。 服务器(3DTank/tools/server/):link/entry/codec/container/dispatch/world/space/semantic(协议层与 lt/pt/zt 三个容器布局)、session(会话回放,顺序推进)、db + schema.sql(MySQL 连接层与对象仓库表结构)、store/responder(查库组帧与定向应答;接管范围默认只开 ba.lcr)。 同时修 pair_flow 的时间轴偏移:timeline_c2s 与 timeline_s2c 的 0 点相差 144~4453ms,改前配对整体偏约 2 秒(实测 space-100073070 偏 2084ms),改后 22.4% 的触发者被纠正。 src/、src_named/、packets/、3DTank/local/(含数据库口令)不进库,可用上述工具重新生成。
122 lines
4.8 KiB
JavaScript
122 lines
4.8 KiB
JavaScript
// fetch_assets.js - 从 3dtank.com 镜像本地可运行客户端所需文件
|
||
// 用法: node fetch_assets.js [--index i.html] [--out web] [--concurrency 8] [--base https://3dtank.com]
|
||
// 内容:
|
||
// 1) index.html 里引用的 /play/**(字体、图标、manifest;main.js 跳过,用本地切割版)
|
||
// 2) src/resources.json 里的 1218 个资源(static/**.svg|webp|json|mp3|webm|3ds...)
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const PROJECT = path.resolve(__dirname, '..');
|
||
const ROOT = path.resolve(PROJECT, '..');
|
||
const Argv = process.argv.slice(2);
|
||
const Arg = (Name, Def) => {
|
||
const I = Argv.indexOf('--' + Name);
|
||
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
|
||
};
|
||
|
||
const BASE = Arg('base', 'https://3dtank.com');
|
||
const INDEX_HTML = path.resolve(Arg('index', path.join(ROOT, 'i.html')));
|
||
const OUT_DIR = path.resolve(Arg('out', path.join(PROJECT, 'web')));
|
||
const CONCURRENCY = Number(Arg('concurrency', '8'));
|
||
const RESOURCES = path.join(PROJECT, 'src', 'resources.json');
|
||
|
||
// 与 curlcmd 一致的请求头
|
||
const HEADERS = {
|
||
accept: '*/*',
|
||
'accept-language': 'zh-CN,zh;q=0.9,en-GB;q=0.8,en;q=0.7,en-US;q=0.6',
|
||
'cache-control': 'no-cache',
|
||
pragma: 'no-cache',
|
||
origin: BASE,
|
||
referer: BASE + '/',
|
||
'user-agent':
|
||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36 Edg/152.0.0.0',
|
||
};
|
||
|
||
// 收集待下载路径(不含域名,形如 /play/xxx)
|
||
function CollectPaths() {
|
||
const Paths = new Set();
|
||
const Html = fs.readFileSync(INDEX_HTML, 'utf8');
|
||
for (const M of Html.matchAll(/["'(](\/play\/[^"')]+)["')]/g)) Paths.add(M[1]);
|
||
for (const M of Html.matchAll(/url\(['"]?(\/play\/[^'")]+)['"]?\)/g)) Paths.add(M[1]);
|
||
|
||
const Res = JSON.parse(fs.readFileSync(RESOURCES, 'utf8')).resourceMap;
|
||
for (const K of Object.keys(Res)) Paths.add('/play/' + Res[K]);
|
||
|
||
// main.js 用本地切割版,不覆盖
|
||
const List = [...Paths].filter((P) => !/\/play\/static\/js\/main\.[0-9a-f]+\.js$/.test(P));
|
||
// 本地已存在的 hornet 文件也不要覆盖
|
||
return List.filter((P) => !/\/hornet_[^/]*\.js$/.test(P));
|
||
}
|
||
|
||
async function DownloadOne(PathName) {
|
||
const Rel = PathName.replace(/^\/+/, '');
|
||
const File = path.join(OUT_DIR, Rel);
|
||
if (fs.existsSync(File) && fs.statSync(File).size > 0) return { PathName, skipped: true };
|
||
fs.mkdirSync(path.dirname(File), { recursive: true });
|
||
const Url = BASE + PathName;
|
||
for (let Attempt = 1; Attempt <= 3; Attempt++) {
|
||
try {
|
||
const Resp = await fetch(Url, { headers: HEADERS, redirect: 'follow' });
|
||
if (!Resp.ok) throw new Error('HTTP ' + Resp.status);
|
||
const Buf = Buffer.from(await Resp.arrayBuffer());
|
||
if (Buf.length === 0) throw new Error('empty body');
|
||
fs.writeFileSync(File, Buf);
|
||
return { PathName, bytes: Buf.length };
|
||
} catch (Err) {
|
||
if (Attempt === 3) return { PathName, error: String(Err.message || Err) };
|
||
await new Promise((R) => setTimeout(R, 300 * Attempt));
|
||
}
|
||
}
|
||
return { PathName, error: 'unreachable' };
|
||
}
|
||
|
||
async function Pool(Items, Worker, Concurrency) {
|
||
let Next = 0;
|
||
const Results = [];
|
||
const Runners = new Array(Math.min(Concurrency, Items.length)).fill(0).map(async () => {
|
||
while (true) {
|
||
const I = Next++;
|
||
if (I >= Items.length) return;
|
||
Results[I] = await Worker(Items[I]);
|
||
}
|
||
});
|
||
await Promise.all(Runners);
|
||
return Results;
|
||
}
|
||
|
||
(async () => {
|
||
const Paths = CollectPaths();
|
||
console.log('待下载: ' + Paths.length + ' 个(并发 ' + CONCURRENCY + ')');
|
||
console.log('来源: ' + BASE + ' 输出: ' + OUT_DIR);
|
||
|
||
// 首页就地复制一份(i.html 即 index.html)
|
||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||
fs.copyFileSync(INDEX_HTML, path.join(OUT_DIR, 'index.html'));
|
||
|
||
const Started = Date.now();
|
||
let Done = 0;
|
||
const Results = await Pool(
|
||
Paths,
|
||
async (P) => {
|
||
const R = await DownloadOne(P);
|
||
Done++;
|
||
if (Done % 100 === 0 || R.error) {
|
||
console.log(' 进度 ' + Done + '/' + Paths.length + (R.error ? (' [失败] ' + R.PathName + ' ' + R.error) : ''));
|
||
}
|
||
return R;
|
||
},
|
||
CONCURRENCY
|
||
);
|
||
|
||
const Failed = Results.filter((R) => R && R.error);
|
||
const Skipped = Results.filter((R) => R && R.skipped).length;
|
||
const Bytes = Results.reduce((S, R) => S + (R && R.bytes ? R.bytes : 0), 0);
|
||
console.log('=== 完成 ===');
|
||
console.log(' 成功下载: ' + (Results.length - Failed.length - Skipped) + ' 跳过(已存在): ' + Skipped + ' 失败: ' + Failed.length);
|
||
console.log(' 新增体积: ' + (Bytes / 1024 / 1024).toFixed(1) + ' MB 用时: ' + ((Date.now() - Started) / 1000).toFixed(0) + ' s');
|
||
if (Failed.length) {
|
||
fs.writeFileSync(path.join(OUT_DIR, '..', 'names', 'fetch_failed.txt'), Failed.map((F) => F.PathName + ' ' + F.error).join('\n'), 'utf8');
|
||
console.log(' 失败清单: 3DTank/names/fetch_failed.txt');
|
||
}
|
||
})();
|