Files
TankIC/3DTank/tools/serve.js
T
TermiNexus 9f6dcb0eba feat(3dtank): 协议逆向工程与自研服务器入库(工具链 + 协议表 + 空间回放 + MySQL)
工具链(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/(含数据库口令)不进库,可用上述工具重新生成。
2026-09-17 20:00:41 +08:00

246 lines
10 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.
// serve.js - 本地静态服务器:把镜像目录当作 3dtank.com 的站点根来跑
// 用法: node serve.js [--port 8080] [--web web] [--no-proxy] [--verbose]
// --verbose 打印每一个请求(含状态码/来源),排查页面加载问题时用
// 挂载:
// / → 3DTank/web/(index.html + play/static 资源)
// /play/static/js/ → TankIC/3dtank.com/play/static/js/(切割版 JS,单一来源,不重复拷贝)
// 代理(关键):游戏在非 3dtank.com 域名下会把配置指向本机,
// 见 src/module_13431.js:1704 分支 → resources=http://本机/resources、config-template=http://本机/config.xml
// 因此这里把这三个路径转发到真实站点,并强制带上 origin/referer(CDN 会校验),同时落盘缓存。
// 记录: 所有 404 会打印并追加到 3DTank/names/missing.txt,方便补齐缺失资源
const fs = require('fs');
const http = require('http');
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 PORT = Number(Arg('port', '8080'));
const WEB_DIR = path.resolve(Arg('web', path.join(PROJECT, 'web')));
const JS_DIR = path.join(ROOT, '3dtank.com', 'play', 'static', 'js');
const MISSING_LOG = path.join(PROJECT, 'names', 'missing.txt');
const CACHE_DIR = path.join(PROJECT, 'cache');
const NO_PROXY = Argv.indexOf('--no-proxy') >= 0;
const VERBOSE = Argv.indexOf('--verbose') >= 0;
// /config.xml 本地覆盖:local/config.xml 存在时用它(把游戏指向本地 wss_server 9091),
// 加 --real-config 可强制走真实服务器
const LOCAL_CONFIG = path.join(PROJECT, 'local', 'config.xml');
const REAL_CONFIG = Argv.indexOf('--real-config') >= 0;
// 本机路径 → 真实站点(StripPrefix: 去掉本机前缀再拼到 Target 上;KeepQuery: 是否保留查询串)
const PROXY = [
{ Prefix: '/resources', Target: 'https://res.3dtank.com', StripPrefix: true, KeepQuery: true },
{ Prefix: '/config.xml', Target: 'https://c1.3dtank.com', StripPrefix: false, KeepQuery: false },
{ Prefix: '/s/', Target: 'https://3dtank.com', StripPrefix: false, KeepQuery: false },
];
const UPSTREAM_HEADERS = {
'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',
'accept-language': 'zh-CN,zh;q=0.9,en-GB;q=0.8,en;q=0.7,en-US;q=0.6',
origin: 'https://3dtank.com',
referer: 'https://3dtank.com/',
};
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
// config.xml 必须给 XML 类型:客户端用 responseType="document" 读取,
// 非 XML 类型时 responseXML 恒为 null → 抛 NullPointerException(module_61860 onload)
'.xml': 'application/xml; charset=utf-8',
'.webmanifest': 'application/manifest+json; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.jpg': 'image/jpeg',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.mp3': 'audio/mpeg',
'.ogg': 'audio/ogg',
'.webm': 'video/webm',
'.mp4': 'video/mp4',
'.wasm': 'application/wasm',
'.txt': 'text/plain; charset=utf-8',
'.3ds': 'application/octet-stream',
'.bin': 'application/octet-stream',
};
function Resolve(UrlPath) {
const Clean = decodeURIComponent(UrlPath.split('?')[0]);
// 切割版 JS 走 TankIC 目录
if (Clean.startsWith('/play/static/js/')) {
return path.join(JS_DIR, Clean.slice('/play/static/js/'.length));
}
const Rel = Clean.replace(/^\/+/, '');
return Rel === '' ? path.join(WEB_DIR, 'index.html') : path.join(WEB_DIR, Rel);
}
if (!fs.existsSync(MISSING_LOG)) {
fs.mkdirSync(path.dirname(MISSING_LOG), { recursive: true });
fs.writeFileSync(MISSING_LOG, '', 'utf8');
} else {
// 每次启动清空,只保留本次会话的 404,避免旧记录干扰排查
fs.writeFileSync(MISSING_LOG, '', 'utf8');
}
function MatchProxy(UrlPath) {
if (NO_PROXY) return null;
const Q = UrlPath.indexOf('?');
const PathPart = Q >= 0 ? UrlPath.slice(0, Q) : UrlPath;
const Query = Q >= 0 ? UrlPath.slice(Q) : '';
for (const Rule of PROXY) {
const NoSlash = Rule.Prefix.replace(/\/$/, '');
if (PathPart !== NoSlash && !PathPart.startsWith(Rule.Prefix)) continue;
const Rest = Rule.StripPrefix ? PathPart.slice(NoSlash.length) : PathPart;
return { Url: Rule.Target + (Rest.startsWith('/') ? Rest : '/' + Rest) + (Rule.KeepQuery ? Query : '') };
}
return null;
}
const ProxyStats = { total: 0, cached: 0, failed: 0 };
function CachePath(Url) {
const U = new URL(Url);
const Rel = (U.hostname + U.pathname).replace(/[^\w.\-/]/g, '_');
return path.join(CACHE_DIR, Rel + (U.search ? '_' + Buffer.from(U.search).toString('hex').slice(0, 12) : ''));
}
async function HandleProxy(Rule, Req, Res) {
ProxyStats.total++;
if (ProxyStats.total % 50 === 0) {
console.log('[proxy] 累计 ' + ProxyStats.total + ' 次(命中缓存 ' + ProxyStats.cached + ',失败 ' + ProxyStats.failed + ')');
}
const CacheFile = Rule.SaveFile || CachePath(Rule.Url);
if (VERBOSE) console.log('[代理] ' + Rule.Url);
try {
if (fs.existsSync(CacheFile)) {
ProxyStats.cached++;
const Buf = fs.readFileSync(CacheFile);
Res.writeHead(200, {
'Content-Type': MIME[path.extname(new URL(Rule.Url).pathname).toLowerCase()] || 'application/octet-stream',
'Content-Length': Buf.length,
'Cache-Control': 'no-cache',
'X-Hornet-Proxy': 'cache',
});
Res.end(Buf);
return;
}
const Upstream = await fetch(Rule.Url, { headers: UPSTREAM_HEADERS, redirect: 'follow' });
const Buf = Buffer.from(await Upstream.arrayBuffer());
if (!Upstream.ok) {
ProxyStats.failed++;
console.log('[proxy] ' + Upstream.status + ' ' + Rule.Url);
Res.writeHead(Upstream.status, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Upstream ' + Upstream.status);
return;
}
fs.mkdirSync(path.dirname(CacheFile), { recursive: true });
fs.writeFileSync(CacheFile, Buf);
Res.writeHead(200, {
// 优先按扩展名给类型(config.xml 必须是 XML,否则 responseXML 为 null)
'Content-Type':
MIME[path.extname(new URL(Rule.Url).pathname).toLowerCase()] ||
Upstream.headers.get('content-type') ||
'application/octet-stream',
'Content-Length': Buf.length,
'Cache-Control': 'no-cache',
'X-Hornet-Proxy': 'upstream',
});
Res.end(Buf);
} catch (Err) {
ProxyStats.failed++;
console.log('[proxy] ERR ' + Rule.Url + ' : ' + Err.message);
Res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Proxy error: ' + Err.message);
}
}
const Server = http.createServer((Req, Res) => {
const UrlPath = Req.url.split('#')[0];
// 浏览器 → 本地落盘:控制台脚本把 dump 结果 POST 上来,写到 names/ 下
// 用法(控制台):fetch('/__dump__/regdump.json', {method:'POST', body: JSON.stringify(x)})
if (Req.method === 'POST' && UrlPath.startsWith('/__dump__/')) {
const Name = path.basename(decodeURIComponent(UrlPath.slice('/__dump__/'.length))).replace(/[^\w.\-]/g, '_') || 'dump.json';
const Out = path.join(PROJECT, 'names', Name);
const Chunks = [];
Req.on('data', (C) => Chunks.push(C));
Req.on('end', () => {
try {
const Buf = Buffer.concat(Chunks);
fs.mkdirSync(path.dirname(Out), { recursive: true });
fs.writeFileSync(Out, Buf);
console.log('[dump] 已写入 ' + Out + '(' + Buf.length + ' 字节)');
Res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }).end('saved ' + Name + ' ' + Buf.length);
} catch (Err) {
console.log('[dump] 写入失败: ' + Err.message);
Res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }).end('write failed: ' + Err.message);
}
});
return;
}
// 某些插件/缓存会来探 Vite HMR 客户端,给个空模块避免报错
if (UrlPath.startsWith('/@vite/')) {
Res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' }).end('/* noop */\n');
return;
}
// /config.xml 本地覆盖:存在 local/config.xml 时用它,把游戏指向本地 wss_server
if (!REAL_CONFIG && UrlPath.split('?')[0] === '/config.xml' && fs.existsSync(LOCAL_CONFIG)) {
const Conf = fs.readFileSync(LOCAL_CONFIG);
Res.writeHead(200, {
'Content-Type': 'application/xml; charset=utf-8',
'Content-Length': Conf.length,
'Cache-Control': 'no-cache',
'X-Hornet-Config': 'local',
}).end(Conf);
return;
}
const Rule = MatchProxy(UrlPath);
if (Rule) {
HandleProxy(Rule, Req, Res);
return;
}
const File = Resolve(UrlPath);
if (!File.startsWith(WEB_DIR) && !File.startsWith(JS_DIR)) {
Res.writeHead(403).end('Forbidden');
return;
}
fs.stat(File, (Err, Stat) => {
if (Err || !Stat.isFile()) {
// 自愈:本地没有的静态资源自动回源 3dtank.com 并落盘缓存(GET 才回源)
if (Req.method === 'GET' && !NO_PROXY) {
console.log('[回源] ' + UrlPath);
HandleProxy({ Url: 'https://3dtank.com' + UrlPath }, Req, Res);
return;
}
fs.appendFile(MISSING_LOG, UrlPath + '\n', () => {});
console.log('[404] ' + UrlPath);
Res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Not Found: ' + UrlPath);
return;
}
Res.writeHead(200, {
'Content-Type': MIME[path.extname(File).toLowerCase()] || 'application/octet-stream',
'Content-Length': Stat.size,
'Cache-Control': 'no-cache',
});
if (VERBOSE) console.log('[200] ' + Stat.size + '\t' + UrlPath);
fs.createReadStream(File).pipe(Res);
});
});
process.on('uncaughtException', (Err) => console.log('[server error] ' + Err.message));
Server.listen(PORT, () => {
console.log('本地客户端: http://localhost:' + PORT + '/');
console.log(' 站点根 : ' + WEB_DIR);
console.log(' 切割版 JS : ' + JS_DIR);
console.log(' 404 记录 : ' + MISSING_LOG);
console.log(' config.xml: ' + (!REAL_CONFIG && fs.existsSync(LOCAL_CONFIG) ? '本地覆盖 ' + LOCAL_CONFIG : '回源 c1.3dtank.com(--real-config)'));
});