zizi 878ad3f7da feat(game-runtime): T1b-α集成段三门全PASS——SIZES九件全≤配额(全栈gz16.5KB),mini-desktop真浏览器证据(probe链/vfx像素哈希×3/CDP真输入),host-dev宿主页+参考件轮1计划
- 门①构建绿:all-plugins barrel+host-dev 双构建日志留档
- 门②SIZES增量实测:九件单件全≤配额,插件gz净增13263B(自律线60K),core基准gz 3222
- 门③浏览器证据:六锚点FNV-1a链verify.ok=true/burst-trail-drift三预设哈希×3重跑一致+几何命中+直方图非空/vignette-dither开关对照哈希互异/CDP dispatchTouchEvent真点击→probe第7条firstInput旁证
- 模板哲学红线被门③实证:particles不内置颜色致首版全黑,host作引擎供色后过——验证门逮住设计而非绕过
- α不接littlejsengine裁定留痕(host.js顶注/index.html/REPORT §4.1),受控面桩即引擎接缝点
- 遗留:probe前四锚tMono=0(冻结lane件,注入时钟拆分留β)
- games/wanglanmei-ref/LOOP-LOG.md:参考件内部预判轮1计划(五要素挂点/插件用法图/mmx资产单13次/红线五条)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:39:15 +00:00

448 lines
21 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.

/**
* test/harness/browser-evidence.cjs — 浏览器视觉证据 harness(core-protocol-v0)
* owner:T1b-α lane-core 奠基(纯计算口径)+ 集成段 T1b-α 接 CDP(浏览器侧四函数)
* 消费方:集成段(mini-desktop Chrome)+ lane-vfx/lane-sys 视觉/渲染证据
*
* ════════════════════════════════════════════════════════════════════════════
* 【交付边界 —— 两段口径】
* · **纯计算口径**(FNV-1a 哈希 / 非空 / 色彩分布 / 几何断言):Gate0 奠基即**完整可跑、口径已冻结**,
* 不依赖浏览器,本机(6c6g)可独立自检。
* · **浏览器侧四函数**(connectCdp / driveFrames / captureImageData / saveScreenshot):集成段(本次)
* 已**接真 CDP 实现**(连 mini-desktop 本地 chrome,见下「实现策略」),**仅集成段跑**——
* 本机(6c6g)禁启 headless Chrome(OOM/exit144,CLAUDE.md 红线)。
* vfx/audio 的渲染/发声证据按 spec **必须**在集成段浏览器出,**禁 node-canvas 兜底**。
*
* 【固定取证环境(集成段实现时必须照此设,§2 第6条)】
* viewport = 390×844(移动竖屏基准);DPR = 2;
* seed = 注入受控随机源种子(复现);时间源 mock = 注入虚拟时钟驱动帧(复现,不依赖真实墙钟)。
* → 同一 build + 同 seed + 同 mock 时钟 ⇒ 渲染逐像素确定 ⇒ ImageData 哈希可作回归断言。
*
* 【证据四件(与参考件/集成段口径对齐,§4/§6)】
* ① ImageData FNV-1a 哈希(逐像素,回归对照);② 非空断言(画面有不透明像素,非全透);
* ③ 色彩分布(直方图,防「画了但全黑」);④ 几何断言(指定区域命中预期像素,验「画在了该画的位置」);
* 截图落 evidence/<round|integration>/<name>.png(路径约定见 §6)。
*
* 【哈希口径】FNV-1a 32-bit —— 与 P10 RuntimeProbe 的哈希链同口径(§3),全发行版证据用同一哈希族。
*
* 用法(集成段,伪流程):
* const H = require('./browser-evidence.cjs');
* const cdp = await H.connectCdp({ url, viewport:{w:390,h:844}, dpr:2, seed, mockClock:true });
* await H.driveFrames(cdp, 60); // 用 mock 时钟推进 60 帧
* const img = await H.captureImageData(cdp, 'canvas'); // 抓 canvas 像素
* const ev = H.buildEvidence(img, { geometry:[{x,y,w,h,expect:'non-empty'}] });
* await H.saveScreenshot(cdp, H.screenshotPath('integration','first-paint'));
* // ev = { hash, nonEmpty, histogramTop, geometry:[...] } → 落 evidence + sha256-manifest
* ════════════════════════════════════════════════════════════════════════════
*/
'use strict';
const path = require('node:path');
/* ──────────────────────────────────────────────────────────────────────────
* 一、固定取证环境常量(集成段实现 connectCdp 时必须照此设)
* ────────────────────────────────────────────────────────────────────────── */
/** 固定取证环境(§2 第6条)。集成段建会话时照此设置,保证证据可复现。 */
const FIXED_ENV = Object.freeze({
viewport: Object.freeze({ width: 390, height: 844 }), // 移动竖屏基准
deviceScaleFactor: 2, // DPR 2
// seed / mockClock 由调用方按用例传入;此处声明「必须可注入」这一契约。
requiresSeed: true,
requiresMockClock: true,
});
/* ──────────────────────────────────────────────────────────────────────────
* 二、纯计算口径(不依赖浏览器,Gate0 即完整实现并冻结口径,集成段直接复用)
* ────────────────────────────────────────────────────────────────────────── */
/**
* FNV-1a 32-bit 哈希(与 P10 probe 哈希链同口径)。对 ImageData 的 RGBA 字节序列逐字节哈希。
* @param {Uint8Array|Buffer|number[]} bytes 字节序列(如 ImageData.data)
* @returns {string} 8 位十六进制哈希串(如 'a3f1c0de')
*/
function fnv1a32(bytes) {
let h = 0x811c9dc5; // FNV offset basis
for (let i = 0; i < bytes.length; i++) {
h ^= bytes[i] & 0xff;
// FNV prime 16777619,用 Math.imul 保 32 位乘法
h = Math.imul(h, 0x01000193) >>> 0;
}
return (h >>> 0).toString(16).padStart(8, '0');
}
/**
* 非空断言:画面是否「有内容」(防「成功抓图但全透明,啥也没画」)。
* 判据:存在任一不透明像素(alpha>0)即视为非空。
* 注:本断言只判「画面非全透」;「画面非纯一色」(如背景同色没画东西)由 colorHistogram
* 的主色集中度 + assertGeometry 的区域命中覆盖,不混入本断言,避免「画了单色物体反被判空」。
* @param {{ data:(Uint8Array|number[]), width:number, height:number }} img ImageData 形
* @returns {boolean}
*/
function assertNonEmpty(img) {
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
if (d[i + 3] > 0) return true; // 存在不透明像素 → 画面有内容
}
return false; // 全透明 → 视为空
}
/**
* 色彩分布直方图:把 RGB 量化到粗粒度桶,统计 topN 主色(防「画了但全黑/全白」)。
* @param {{ data:(Uint8Array|number[]) }} img
* @param {number} [topN=5] 返回前 N 主色
* @returns {Array<{ color:string, count:number }>} 形如 [{color:'r0g128b255', count:1234}, ...]
*/
function colorHistogram(img, topN = 5) {
const d = img.data;
/** @type {Map<string, number>} */
const buckets = new Map();
// 量化到 32 级/通道(>>3),降桶数;只统计不透明像素。
for (let i = 0; i < d.length; i += 4) {
if (d[i + 3] === 0) continue;
const key = `r${d[i] >> 3}g${d[i + 1] >> 3}b${d[i + 2] >> 3}`;
buckets.set(key, (buckets.get(key) || 0) + 1);
}
return Array.from(buckets.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, topN)
.map(([color, count]) => ({ color, count }));
}
/**
* 几何断言:检查指定矩形区域是否命中预期('non-empty' = 区域内有不透明像素)。
* 用于验「画在了该画的位置」(如:飞机应出现在屏幕下方中央矩形内)。
* @param {{ data:(Uint8Array|number[]), width:number, height:number }} img
* @param {Array<{ x:number, y:number, w:number, h:number, expect:'non-empty'|'empty' }>} regions
* @returns {Array<{ region:object, pass:boolean, opaquePixels:number }>}
*/
function assertGeometry(img, regions) {
const d = img.data;
const W = img.width;
return (regions || []).map((reg) => {
let opaque = 0;
for (let yy = reg.y; yy < reg.y + reg.h; yy++) {
for (let xx = reg.x; xx < reg.x + reg.w; xx++) {
const idx = (yy * W + xx) * 4;
if (d[idx + 3] > 0) opaque++;
}
}
const hasContent = opaque > 0;
const pass = reg.expect === 'empty' ? !hasContent : hasContent;
return { region: reg, pass, opaquePixels: opaque };
});
}
/**
* 把一张 ImageData 汇成「证据对象」(四件套:哈希/非空/色彩分布/几何)。
* 这是集成段把渲染结果落证的统一入口;纯计算,Gate0 即可用。
* @param {{ data:(Uint8Array|number[]), width:number, height:number }} img
* @param {{ geometry?: Array<object> }} [opts]
* @returns {{ hash:string, nonEmpty:boolean, histogramTop:Array, geometry:Array, width:number, height:number }}
*/
function buildEvidence(img, opts = {}) {
return {
hash: fnv1a32(img.data),
nonEmpty: assertNonEmpty(img),
histogramTop: colorHistogram(img, 5),
geometry: assertGeometry(img, opts.geometry || []),
width: img.width,
height: img.height,
};
}
/**
* 截图路径约定(§6):evidence/<bucket>/<name>.png。
* @param {'integration'|`round-${number}`|string} bucket 证据桶(集成段=integration;参考件=round-N)
* @param {string} name 截图名(不含扩展名)
* @returns {string} 相对仓根的截图路径
*/
function screenshotPath(bucket, name) {
return path.posix.join('evidence', bucket, `${name}.png`);
}
/* ──────────────────────────────────────────────────────────────────────────
* 三、浏览器侧能力(集成段实现 —— 经 CDP 连真 Chrome;仅 mini-desktop 跑,6c6g 禁)
*
* 【实现策略(与 .agents/skills/ui-walkthrough-cdp.md 配方对齐,Chrome 146 实证)】
* · 复用 admin_walk.py 同款 CDP 接法:Chrome 以 --remote-debugging-port=9222 + --no-sandbox
* + --no-zygote + --remote-allow-origins=* 启动(由集成段 driver 脚本负责拉起,本文件只连);
* · Chrome 111+ 建 target 用 PUT /json/new?<url>;
* · WebSocket 经 `ws` 包连 webSocketDebuggerUrl(origin 已由 --remote-allow-origins=* 放行);
* · Runtime.evaluate(returnByValue+awaitPromise)驱动 host-dev 的确定性入口 window.__hostdev;
* · ImageData 经页面内 canvas.getContext('2d').getImageData 读「物理像素」回传(取证哈希逐字节);
* · 截图 Page.captureScreenshot 落 evidence/<bucket>/<name>.png。
*
* 【依赖纪律】
* `ws` 仅在下列 CDP 函数内 **lazy require**——本机(6c6g)只用上节「纯计算口径」时无需装 ws、
* require 本文件不报错;集成段 driver 先 `npm i ws` 再调 CDP 函数。
* ────────────────────────────────────────────────────────────────────────── */
/** CDP HTTP 端点(集成段 driver 在 mini-desktop 本地起 chrome)。可被 connectCdp 的 opts.cdpHttp 覆盖。 */
const DEFAULT_CDP_HTTP = 'http://localhost:9222';
/** lazy 取 ws 构造器;未装给出明确指引(守「纯计算路径零依赖」纪律)。 */
function getWebSocketCtor() {
try {
return require('ws');
} catch {
const e = new Error(
'[browser-evidence] 需要 `ws` 包驱动 CDP —— 仅集成段(mini-desktop)跑浏览器证据时需要。' +
' 请在集成段执行:npm i ws,再重跑。本机纯计算口径(fnv1a32/buildEvidence 等)无需 ws。'
);
e.code = 'WS_NOT_INSTALLED';
throw e;
}
}
/** 极简 HTTP GET/PUT(取 CDP /json 列表与建 target,零依赖 node:http)。 */
function httpJson(method, urlStr) {
return new Promise((resolve, reject) => {
const http = require('node:http');
const u = new URL(urlStr);
const req = http.request(
{ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, timeout: 15000 },
(res) => {
let buf = '';
res.on('data', (c) => (buf += c));
res.on('end', () => {
try {
resolve(buf ? JSON.parse(buf) : {});
} catch (e) {
reject(new Error('CDP HTTP 响应非 JSON:' + buf.slice(0, 200)));
}
});
}
);
req.on('error', reject);
req.on('timeout', () => req.destroy(new Error('CDP HTTP 超时:' + urlStr)));
req.end();
});
}
/**
* 一个最小 CDP 会话(mirror admin_walk.py CDP 类):建 target → 连 WS → 收发带 id 的方法调用。
* 集成段内部用,不对外导出形状(connectCdp 返回它)。
*/
class CdpSession {
/**
* @param {object} ws 已连接的 ws 实例
* @param {string} targetId 目标 id
*/
constructor(ws, targetId) {
this.ws = ws;
this.targetId = targetId;
this._id = 0;
/** @type {Map<number,{resolve:Function,reject:Function}>} */
this._pending = new Map();
this.ws.on('message', (raw) => {
let msg;
try { msg = JSON.parse(raw.toString()); } catch { return; }
if (msg.id != null && this._pending.has(msg.id)) {
const { resolve, reject } = this._pending.get(msg.id);
this._pending.delete(msg.id);
if (msg.error) reject(new Error(JSON.stringify(msg.error)));
else resolve(msg.result || {});
}
// 事件(无 id)本会话不订阅,忽略。
});
}
/**
* 发一个 CDP 方法,等其 id 应答。
* @param {string} method 如 'Page.navigate'
* @param {object} [params]
* @param {number} [timeoutMs]
* @returns {Promise<object>}
*/
send(method, params, timeoutMs) {
const id = ++this._id;
const payload = JSON.stringify({ id, method, params: params || {} });
return new Promise((resolve, reject) => {
const to = setTimeout(() => {
this._pending.delete(id);
reject(new Error('CDP 方法超时:' + method));
}, timeoutMs || 40000);
this._pending.set(id, {
resolve: (r) => { clearTimeout(to); resolve(r); },
reject: (e) => { clearTimeout(to); reject(e); },
});
this.ws.send(payload);
});
}
/**
* 在页面上下文求值表达式(returnByValue + awaitPromise)。
* @param {string} expression
* @param {number} [timeoutMs]
* @returns {Promise<*>} 求值结果 value(异常时 reject)。
*/
async evaluate(expression, timeoutMs) {
const r = await this.send(
'Runtime.evaluate',
{ expression, returnByValue: true, awaitPromise: true },
timeoutMs
);
if (r && r.exceptionDetails) {
throw new Error('页面求值异常:' + JSON.stringify(r.exceptionDetails).slice(0, 400));
}
return r && r.result ? r.result.value : undefined;
}
/** 关闭会话(关 WS)。 */
close() {
try { this.ws.close(); } catch { /* ignore */ }
}
}
/**
* 连接 CDP 并按固定取证环境建会话。
* 前置:集成段 driver 已在 mini-desktop 本地起好 chrome(:9222,含 --no-sandbox/--no-zygote/--remote-allow-origins=*)。
* 本函数:建 target → 连 WS → 启用 Page/Runtime → 设 deviceMetrics(viewport/DPR) → 导航 url → 等 host 引导完成。
*
* @param {{ url:string, seed?:number, viewport?:{width:number,height:number}, dpr?:number, cdpHttp?:string, settleMs?:number }} opts
* url 应已带 ?seed=&mode=(seed 经 URL 注入发行版受控随机源,与 mockClock 由 host.js 落实)。
* @returns {Promise<CdpSession>} CDP 会话句柄
*/
async function connectCdp(opts) {
const o = opts || {};
if (!o.url) throw new Error('[browser-evidence] connectCdp 需要 opts.url');
const cdpHttp = o.cdpHttp || DEFAULT_CDP_HTTP;
const vp = o.viewport || FIXED_ENV.viewport;
const dpr = o.dpr || FIXED_ENV.deviceScaleFactor;
// 1) 建 target(Chrome 111+ 必须 PUT /json/new?<url>,GET 不行——见 CDP 七坑#4)。
const created = await httpJson('PUT', `${cdpHttp}/json/new?${encodeURI('about:blank')}`);
const wsUrl = created.webSocketDebuggerUrl;
const targetId = created.id;
if (!wsUrl) throw new Error('[browser-evidence] 建 target 失败(无 webSocketDebuggerUrl):' + JSON.stringify(created).slice(0, 200));
// 2) 连 WS(origin 由 chrome 启动参数 --remote-allow-origins=* 放行)。
const WebSocket = getWebSocketCtor();
const ws = await new Promise((resolve, reject) => {
const sock = new WebSocket(wsUrl, { maxPayload: 256 * 1024 * 1024 });
sock.on('open', () => resolve(sock));
sock.on('error', reject);
});
const cdp = new CdpSession(ws, targetId);
// 3) 启用域。
await cdp.send('Page.enable');
await cdp.send('Runtime.enable');
// 4) 设固定取证环境:viewport 逻辑尺寸 + DPR(host.js 内部也会按 DPR 设 canvas 物理像素,双保险一致)。
await cdp.send('Emulation.setDeviceMetricsOverride', {
width: vp.width,
height: vp.height,
deviceScaleFactor: dpr,
mobile: true,
});
// 开触摸模拟:使 Input.dispatchTouchEvent 的 touchStart 能可靠合成兼容 pointerdown 事件
//(host 经 pointerdown 桥接输入 → runtime-probe 记 firstInput 旁证)。
try {
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
} catch { /* 个别 Chrome 版本无此方法时忽略,touch 仍可由 deviceMetrics mobile:true 启用 */ }
// 5) 导航 + 等加载完成 + 等 host 引导(window.__hostBooted)。
await cdp.send('Page.navigate', { url: o.url });
const settle = typeof o.settleMs === 'number' ? o.settleMs : 800;
await waitForBoot(cdp, settle);
return cdp;
}
/** 轮询页面直到 window.__hostBooted===true(或拿到 __hostBootError 即抛),最多约 12s。 */
async function waitForBoot(cdp, settleMs) {
const deadline = Date.now() + 12000;
// 先给一个固定 settle,让脚本起步。
await delay(settleMs);
while (Date.now() < deadline) {
const st = await cdp.evaluate(
'({ booted: !!window.__hostBooted, err: window.__hostBootError || null, hasHostdev: !!window.__hostdev })'
);
if (st && st.err) throw new Error('[browser-evidence] host 引导失败:' + st.err);
if (st && st.booted && st.hasHostdev) return;
await delay(300);
}
throw new Error('[browser-evidence] 等 host 引导超时(window.__hostBooted 未就绪)');
}
/** 纯 Promise 延时(不走被拦的 sleep 命令;harness 内部异步等待用)。 */
function delay(ms) {
return new Promise((r) => setTimeout(r, ms));
}
/**
* 用 host 的步进时钟确定性推进 N 帧(不走真实 RAF)。
* 经 window.__hostdev.stepFrames(n) 落实(host.js 内每帧固定 dt + 固定时钟步进 + render)。
* @param {CdpSession} cdp 会话
* @param {number} frames 帧数
* @returns {Promise<{frame:number}>} 推进后的帧号
*/
async function driveFrames(cdp, frames) {
const n = frames | 0;
const r = await cdp.evaluate(`(function(){ window.__hostdev.stepFrames(${n}); return { frame: window.__hostdev.frameCount() }; })()`);
return r;
}
/**
* 抓取指定 canvas 的 ImageData(物理像素,逐字节供 FNV-1a 哈希)。
* 经页面内 getImageData 读出 RGBA 字节,base64 回传后解码为 Uint8Array(避免巨数组 JSON 化)。
* @param {CdpSession} cdp 会话
* @param {string} selector canvas 选择器(如 '#game')
* @returns {Promise<{ data:Uint8Array, width:number, height:number }>}
*/
async function captureImageData(cdp, selector) {
// 页面内:取 canvas 物理像素 ImageData → 转 base64(btoa 逐字节)回传。
const expr = `(function(){
var c = document.querySelector(${JSON.stringify(selector)});
if (!c) return { err: 'canvas not found: ' + ${JSON.stringify(selector)} };
var g = c.getContext('2d', { willReadFrequently: true });
var img = g.getImageData(0, 0, c.width, c.height);
var d = img.data, n = d.length, s = '';
// 分块 btoa(避免 String.fromCharCode 超长参数栈溢出)。
var CH = 0x8000;
for (var i = 0; i < n; i += CH) {
s += String.fromCharCode.apply(null, d.subarray(i, Math.min(i + CH, n)));
}
return { b64: btoa(s), width: img.width, height: img.height };
})()`;
const r = await cdp.evaluate(expr, 60000);
if (!r || r.err) throw new Error('[browser-evidence] captureImageData 失败:' + (r && r.err ? r.err : 'empty'));
const data = new Uint8Array(Buffer.from(r.b64, 'base64'));
return { data, width: r.width, height: r.height };
}
/**
* 保存截图到约定路径(Page.captureScreenshot → 落盘)。
* @param {CdpSession} cdp 会话
* @param {string} outPath screenshotPath() 给出的路径(相对仓根;driver 应保证 cwd=game-runtime)
* @returns {Promise<string>} 实际落盘绝对路径
*/
async function saveScreenshot(cdp, outPath) {
const fs = require('node:fs');
const r = await cdp.send('Page.captureScreenshot', { format: 'png' }, 60000);
if (!r || !r.data) throw new Error('[browser-evidence] 截图失败(无 data)');
const abs = path.resolve(outPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, Buffer.from(r.data, 'base64'));
return abs;
}
module.exports = {
// 常量
FIXED_ENV,
DEFAULT_CDP_HTTP,
// 纯计算口径(Gate0 完整实现,集成段复用)
fnv1a32,
assertNonEmpty,
colorHistogram,
assertGeometry,
buildEvidence,
screenshotPath,
// 浏览器侧(集成段实现,经 CDP 连真 Chrome)
connectCdp,
driveFrames,
captureImageData,
saveScreenshot,
CdpSession,
};