zizi f59991b9f3 feat(game-runtime): T1b-α Gate0 七件落地(core-protocol-v0-rc)
- core 协议:Plugin 接口/注册器(重名拒绝/生命周期正序逆序/dispose幂等/错误隔离不连坐)+受控面 PluginContext(禁直透 littlejsengine)——前任agent核心设计17测绿收编(死于收尾的工具调用解析失败,续作字节级未碰)
- _example 空插件样例五件全(克隆母本)+PLUGIN-TEMPLATE 六节规格+manifest schema+零依赖校验器(负例自检逮6违规)
- scripts:esbuild锁参build/增量法size/test.sh遍历;browser-evidence harness纯计算口径冻结(FNV-1a/直方图/几何,CDP四函数=集成段占位)
- 22/22 测全绿(RESULT.txt 留档);ESM JS+JSDoc+手写d.ts 零工具链纪律全程
- rc 状态:待 Gate0.1 受控面补丁(input/audio+per-plugin派生RNG+useContext守卫)后冻结 core-protocol-v0

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

236 lines
12 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(Gate0 奠基件)|消费方:集成段(mini-desktop Chrome)+ lane-vfx/lane-sys 视觉/渲染证据
*
* ════════════════════════════════════════════════════════════════════════════
* 【本阶段交付边界 —— 重要】
* Gate0 **只交本骨架 + 契约 + 使用说明**,**不要求本机真跑**。理由(执行版 §1/§5/§2):
* · 真跑需 CDP 连真 Chrome(headless)+ 渲染真 canvas → **仅集成段(mini-desktop)**;
* · 本机(6c6g)禁启 headless Chrome(OOM/exit144,CLAUDE.md 红线);
* · vfx/audio 的渲染/发声证据按 spec **必须**在集成段浏览器出,**禁 node-canvas 兜底**。
* 因此本文件中「连 CDP / 抓 ImageData / 截图」均为**契约占位(throw NOT_IMPLEMENTED)**,
* 集成段 lane 接真 CDP 后实现这些占位即可;而**纯计算口径**(FNV-1a 哈希 / 非空 / 色彩分布 /
* 几何断言)是**完整可跑实现**(不依赖浏览器),集成段直接复用,保证证据口径在 Gate0 即冻结。
*
* 【固定取证环境(集成段实现时必须照此设,§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');
/** 占位错误:集成段需实现的浏览器侧能力,本阶段调用即抛(不静默假成功)。 */
function notImplemented(what) {
const e = new Error(
`[browser-evidence] ${what} 为集成段占位 —— 本骨架不在本机真跑(须 mini-desktop Chrome via CDP)。` +
` 集成段 lane 实现本函数后即可启用。`
);
e.code = 'NOT_IMPLEMENTED_IN_GATE0';
return e;
}
/* ──────────────────────────────────────────────────────────────────────────
* 一、固定取证环境常量(集成段实现 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`);
}
/* ──────────────────────────────────────────────────────────────────────────
* 三、浏览器侧能力(集成段占位 —— 本阶段抛 NOT_IMPLEMENTED,不在本机跑)
* ────────────────────────────────────────────────────────────────────────── */
/**
* 连接 CDP 并按固定环境建会话(集成段实现)。
* 集成段须:用 FIXED_ENV 设 viewport/DPR;注入 seed 到发行版受控随机源;
* 注入 mock 时钟接管 onFrame 驱动(不走真实 RAF/墙钟)。
* @param {{ url:string, seed:number, viewport?:object, dpr?:number, mockClock?:boolean }} _opts
* @returns {Promise<object>} CDP 会话句柄(集成段定义形状)
*/
async function connectCdp(_opts) {
throw notImplemented('connectCdp');
}
/**
* 用 mock 时钟推进 N 帧(集成段实现:驱动发行版 onFrame,确定步进)。
* @param {object} _cdp connectCdp 返回的会话
* @param {number} _frames 帧数
* @returns {Promise<void>}
*/
async function driveFrames(_cdp, _frames) {
throw notImplemented('driveFrames');
}
/**
* 抓取指定 canvas 的 ImageData(集成段实现:CDP Runtime.evaluate 读 getImageData)。
* 返回 { data, width, height },交 buildEvidence 汇证。
* @param {object} _cdp 会话
* @param {string} _selector canvas 选择器
* @returns {Promise<{ data:Uint8Array, width:number, height:number }>}
*/
async function captureImageData(_cdp, _selector) {
throw notImplemented('captureImageData');
}
/**
* 保存截图到约定路径(集成段实现:CDP Page.captureScreenshot 落盘)。
* @param {object} _cdp 会话
* @param {string} _outPath screenshotPath() 给出的路径
* @returns {Promise<void>}
*/
async function saveScreenshot(_cdp, _outPath) {
throw notImplemented('saveScreenshot');
}
module.exports = {
// 常量
FIXED_ENV,
// 纯计算口径(Gate0 完整实现,集成段复用)
fnv1a32,
assertNonEmpty,
colorHistogram,
assertGeometry,
buildEvidence,
screenshotPath,
// 浏览器侧占位(集成段实现)
connectCdp,
driveFrames,
captureImageData,
saveScreenshot,
};