diff --git a/docs/agent-specs/2026-06-13-A0-引擎掌帧接管-edit-plan.md b/docs/agent-specs/2026-06-13-A0-引擎掌帧接管-edit-plan.md new file mode 100644 index 00000000..87bd3e7b --- /dev/null +++ b/docs/agent-specs/2026-06-13-A0-引擎掌帧接管-edit-plan.md @@ -0,0 +1,789 @@ +# A0 引擎掌帧接管 · 精确可执行编辑计划 + +> 工位:T1b-β · A0(地基)| 文件:`game-runtime/host-dev/host.js` + `entry.js`(受控面 `src/core/api.d.ts` / `src/core/plugin.js` **零改**) +> 上游:`docs/agent-specs/2026-06-13-T1b-β引擎真接线与Runner-v2-execution.md` §A0/门0/§11 +> 真源核验:`node_modules/littlejsengine/dist/littlejs.esm.js`(esbuild 走 package `main`/`default` = ESM)+ `littlejs.d.ts` +> 本计划只产「精确 old→new 编辑块 + 不变性论证 + 门0 runbook + 回滚 + 遗留风险」,**不落代码到源文件**。 + +--- + +## 0. 目标与范围边界(A0 只做地基,不越界) + +**A0 唯一目标**:把 host-dev 的「浏览器 RAF + `bundle.tick(dt)` + 普通 2D canvas」桩,换成**真引擎 `engineInit` 五回调接管帧驱动 + 渲染循环**,使: +1. 引擎内部 RAF 真掌帧(非 host 手摇 RAF 桩); +2. 引擎 `time`/`frame`/`timeReal` 随循环真推进,**可被 probe 读到**(门0); +3. 受控面 7 项契约**零改**(`getContext2d/onFrame/getInput/getAudioContext/time/random/getEngine`),插件零改; +4. 确定性取证路径(`window.__hostdev` 步进 + ImageData FNV-1a 哈希)在引擎掌帧下**存活**; +5. 保留**可回滚点**(`?engine=stub` 回桩,创始人裁)。 + +**A0 明确不做(留 A1/A6)**: +- `ctx.time` **不**改背书到引擎 `time`(那是 A1)。A0 只**额外暴露**引擎钟给 probe(`window.__engine`),`ctx.time` 仍走 host 步进钟 `mockNowMs`(受控面 backing 不动)。 +- `getInput` **不**改成「引擎轮询态归一化包装」(那是 A1)。A0 **必须避免引擎自带 document 监听与 host inputBridge 双发**(见 §4)。 +- `getEngine` 的真实 `engineFactory` 背书(粒子/合成/数学)留 A1+。A0 期 `engineFactory` 仍缺省 null(`getEngine` 返 null,插件容错降级)。 +- SIZES 重锚留 A5;A0 仅在本计划 §9 记体积跳升预期。 + +**映射总表(题面四问 → A0 落点)**: + +| 抽象 | α 桩现状 | A0 落点 | +|---|---|---| +| `onFrame`→回调 | `bundle.tick(dt)` 内派发 onFrame;host RAF 驱动 tick | 引擎 `gameUpdate(回调)` 内调 `bundle.tick(dtFixed)`;驱动器=引擎内部 RAF | +| `canvas`→`mainContext` | host `prepareCanvas(#game)` 注入 ctx2d | 引擎 `engineInit` 自建 mainCanvas;`getContext2d` backing 到 `engine.mainContext`;`#game` 退役 | +| `time`→引擎 | host `mockNowMs` 步进钟 | A0 暴露 `engine.time/frame/timeReal` 给 probe(`window.__engine`);`ctx.time` 仍 `mockNowMs`(A1 才透传) | +| 确定性路径 | `stepFrames(n)`: `mockNowMs+=dt; tick(dt); renderFrame()`(非真 RAF) | `?engine=stub` 桩通道保 `stepFrames` 原样;`?engine=real` 引擎通道走「门0 真 RAF 推进 + 引擎 `mainContext` readback」;像素逐帧哈希以 stub 通道兜底(双轨,门0 用 real 证掌帧) | + +--- + +## 1. 引擎 import 形态(entry.js + host.js) + +### 1.1 真源核验(决定 import 写法 + box2d 风险结论) + +- esbuild 走 `littlejsengine` package `main`=`dist/littlejs.esm.js`(ESM,`exports.default`)。R 路所引「littlejs.js 行号」与 ESM 文件**逐行一致**(已核验:`engineInit:167` / `engineUpdate:202` / `requestAnimationFrame(engineUpdate):283` / `new AudioContext:6753` / `glInit:8760` / `glContext=...webgl2:8773`)。故 R 路结论可直接用于 ESM。 +- **box2d 是惰性、非自动加载**(已核验 `littlejs.esm.js:12619-12622` 注释「Call box2dInit() to enable / You will also need to include box2d.wasm.js」;`box2dInit:14582` 内 `await Box2D()`,`Box2D` 是**全局**,由用户另行 include `box2d.wasm.js` 提供;`engineInit` 体内**零** box2d 引用)。`physics-lite` 用自带数学,不调 box2d。**结论**:esbuild 把 littlejs.esm.js 整体打入产物,其中 `Box2D()` 是个**永不执行的自由全局引用**(dead-but-present),esbuild **不会**去解析/打包 `box2d.wasm.js` 或 `box2d.wasm.wasm`(它们不被 ESM `import`),运行时也**绝不**触发 wasm fetch。→ A0 不需处理 wasm;仅体积上多带 box2d 死代码(SIZES 重锚 A5 计入)。**风险**标 §11。 +- **glEnable 默认 true 且无外部 setter 别名陷阱**:已核验导出名是 **`setGLEnable`(大写 GL,d.ts:695)**,非 R 路所写的 `setGlEnable`。`glInit:8765` 守卫 `if(!glEnable||headlessMode)return` → **`setGLEnable(false)` 须在 `engineInit` 前调**,引擎即跳过 glCanvas 创建、纯 2D 渲染进 mainCanvas。这比 R 路「跨 tick glCopyToContext」路径**更干净**(无 WebGL `preserveDrawingBuffer` 坑)。A0 取此路。 +- `showSplashScreen` 默认 **false**(`littlejs.esm.js:2873`)→ 无开屏遮挡首帧,`gameRender` 首次回调即真首帧。 + +### 1.2 entry.js 编辑 + +**锚(当前真实内容,entry.js:16-17)**: +```js +import * as RT from '../src/all-plugins.js'; +import { bootHostDev } from './host.js'; +``` +**改为**: +```js +import * as RT from '../src/all-plugins.js'; +import { bootHostDev } from './host.js'; +// A0:引擎主循环接管所需 import 唯一落点之一(集成段 host)。 +// Q4 铁律:littlejsengine import 只许活在集成段 host(entry.js/host.js),禁泄进受控面契约/插件。 +// 名字空间整体导入:engineInit/engineObjectsUpdate/setGLEnable/setPaused/setTimeScale/setCanvasFixedSize/ +// setCanvasPixelRatio/setShowSplashScreen/setHeadlessMode/glCopyToContext/glSetAntialias/vec2/ +// mainCanvas/mainContext/frame/time/timeReal 等全经此名字空间触达(具体由 host.js 用)。 +import * as LJS from 'littlejsengine'; +``` +**锚(entry.js:21-22)**: +```js +export { bootHostDev }; +export const Runtime = RT; +``` +**改为**(把引擎名字空间一并透到全局入口,供 host.js 经参数拿 + DevTools 手验;**不**改 `bootHostDev` 既有签名,新增可选第二参): +```js +export { bootHostDev }; +export const Runtime = RT; +// A0:引擎名字空间挂入口,便于 host.js 接收 + 控制台手验引擎导出。 +export const Engine = LJS; +``` + +> 说明:`bootHostDev` 既可由 entry.js 在调用处把 `LJS` 作为 `opts.engine` 注入(推荐,见 index.html 改动 §3.4),也可由 host.js 直接 `import * as LJS from 'littlejsengine'`。**二选一,取「host.js 直接 import」更内聚**——理由:host.js 是引擎接管的唯一实现体,直接 import 使「引擎 import 只活集成段 host」边界最清晰,且 index.html 引导代码零改。故 §1.3 采 host.js 直接 import;entry.js 的 `export const Engine` 仅为手验便利(可保留)。 + +### 1.3 host.js import 编辑 + +**锚(host.js:29-36,当前真实 import 块)**: +```js +import { + PluginRegistry, + createHostDevContext, + registerAllPlugins, + createExamplePlugin, + createRuntimeProbePlugin, + CORE_PROTOCOL_VERSION, +} from '../src/all-plugins.js'; +``` +**改为**(追加引擎名字空间 import;受控面 import 不动): +```js +import { + PluginRegistry, + createHostDevContext, + registerAllPlugins, + createExamplePlugin, + createRuntimeProbePlugin, + CORE_PROTOCOL_VERSION, +} from '../src/all-plugins.js'; +// A0:引擎主循环接管——littlejsengine import 唯一落点(集成段 host)。 +// Q4 铁律:禁把引擎 import 泄进受控面契约(api.d.ts/plugin.js)或任何插件 impl;只活在本 host。 +// esbuild 走 package main=dist/littlejs.esm.js(ESM);box2d 惰性(box2dInit 永不调)→ 不引 wasm。 +import * as LJS from 'littlejsengine'; +``` + +--- + +## 2. 主循环接管:rafLoop → engineInit 五回调 + +### 2.1 设计要点(先讲清「为什么这样接」) + +引擎**无 `engineUpdate` 外部导出**(d.ts grep 零命中),主循环=引擎内部 RAF(`littlejs.esm.js:283` 无条件自调度)。外部**只能经 `engineInit` 五回调**接管,不能手摇 `engineUpdate`。映射: + +- **驱动器**:引擎内部 RAF(取代 host `rafLoop/startRaf/stopRaf`)。 +- **`bundle.tick(dt)` → `gameUpdate` 回调**:引擎每帧追帧循环里调 `gameUpdate` N 次(N=本动画帧追帧数,墙钟决定,0..N)。host 在 `gameUpdate` 内调一次 `bundle.tick(FIXED_DT)`——**dt 取引擎固定步长 `1/60`(=FIXED_DT),不取墙钟**,因为引擎已用累加器把墙钟差摊成「每次 update 推进恰好 1 个逻辑步(1/frameRate)」。这样 `onFrame(dtSeconds=1/60, frame)` 回调形态**与 α 桩一致**(particles 是 dt-敏感型,dt 须恒定 1/60,不能塞墙钟 dt,否则粒子时步漂移)。 +- **`renderFrame()` → 拆 `gameRender` + `gameRenderPost`**: + - `gameRender`:画**粒子层**(host 在此 `save → fillStyle='#7fd0ff' → pj.render(engine.mainContext) → restore`,沿用 α 的「host 提供可见色」语义)。 + - `gameRenderPost`:画**后处理叠加层**(`pp.renderPost(engine.mainContext)`),在所有对象/粒子之上,语义正确(后处理本应最后叠加)。 + - **不再** host 手动 `clearBlack`:引擎 `updateCanvas:374-375` 每帧重设 `mainCanvas.width/height` 清空画布;又因 `setGLEnable(false)`,`updateCanvas:378-383` 用 `canvasClearColor` 填底。故 host 改为 `engineInit` 前设引擎清色为黑(或保留默认)。`gameRender` 在引擎清屏之后调,无残留。 +- **canvas 绑定**:引擎 `engineInit:406` **自建** mainCanvas append 到 rootElement(**不**采用现有 `#game`)。故 host 把 rootElement 指向 `#game` 的父容器 `#wrap`(或专用容器),让引擎在其内建自己的 canvas;`getContext2d` backing 改为返回 `engine.mainContext`(见 §3)。 +- **dtSeconds 来源固定步长**:A0 取 `FIXED_DT=1/60`(host.js:45 已有常量),保 `onFrame` 回调 dt 恒定(particles 时步确定)。 + +### 2.2 编辑块 A:注入受控面 `getContext2d` 的 backing 切换准备 + +`createHostDevContext` 的 `context2d` 入参当前是 host `prepareCanvas(#game)` 产的 ctx(host.js:188-193)。A0 后这张 ctx 要换成 `engine.mainContext`,但 `engine.mainContext` 在 `engineInit` resolve 后才存在(异步)。受控面 `getContext2d()` 实现是 `return o.context2d == null ? null : o.context2d`(plugin.js:467-470)——**读的是闭包捕获的 `o.context2d` 值**,一次性快照,不可后改。 + +**解法(受控面零改 + backing 后绑)**:给 `createHostDevContext` 传一个**取值函数式的 2D ctx 持有者**。但受控面 `getContext2d` 读的是 `o.context2d`(值),不是函数。两条不破契约的路: + +- **路 A(推荐,零改受控面)**:用一个**代理对象**作 `context2d`,其所有 2D 调用转发到「当前活动 ctx」(引擎就绪前=host 占位 2D ctx,就绪后=`engine.mainContext`)。代理是 `CanvasRenderingContext2D` 同形 Proxy,受控面拿到的「就是一张 2D ctx」(契约只约定返回 `CanvasRenderingContext2D|null`,不约定来源/是否 Proxy)。 +- **路 B(更简单,推荐优先验证可行性)**:**先 `engineInit` 拿到 `engine.mainContext`,再创建 `bundle`**。即把 `createHostDevContext({context2d: engine.mainContext, ...})` 的调用挪到 `engineInit().then(...)` 之后。因 `engineInit` 是 `async`/返回 Promise,host `bootHostDev` 须改为「`engineInit` 先行 → 其 `gameInit` 回调里或 `.then` 里建 bundle + 注册插件 + 打启动链」。这样 `getContext2d` 一次性快照到的就是真 `engine.mainContext`,**受控面零改、无 Proxy**。 + +> **取路 B**:最小改动且无 Proxy 复杂度。代价=`bootHostDev` 主体从「同步建 bundle」改为「`engineInit` resolve 后建 bundle」——这本就是引擎掌帧的必然结构(引擎先起、canvas 才有)。下文编辑块按路 B 写。 + +**锚(host.js:174-193,当前真实内容)**: +```js + // 创建 host-dev 受控上下文:注入真 2D ctx / 主种子 / 音频工厂。 + // 时钟:取证用步进时钟(见下 mockClock),真实 RAF 模式则用墙钟——这里给步进时钟, + // 真实模式由 RAF 推帧但时间读数仍走步进时钟,保证「插件读 time 与帧推进一致、且可被取证接管」。 + let mockNowMs = 0; // 步进时钟读数(毫秒),由 tick/推帧推进。 + // 记录所有经取证入口 spawn 的 continuous 发射器 id,reset() 时统一 stopEmitter 防其持续喷发。 + /** @type {Set} */ + const activeEmitterIds = new Set(); + // per-plugin 派生随机实例登记(name → RandomSource)。 + // 背景:core 的 per-plugin 派生流让每个插件 init 时拿到「自己专属的派生随机实例」(与默认 context.random 不同实例)。 + // 插件(如 particles-juice)在 init 时捕获 `ctx.random` 并据其确定粒子序列。要让 vfx 像素证据「同参数重跑一致」, + // host 必须能复位**插件那一份**随机实例——故这里合法地包裹 bundle.deriveContextFor(host 拥有 bundle), + // 在注册器为每插件派生上下文时记下该插件的 random 引用,供 vfxFrame/paletteFrame 取证前 reseed。 + /** @type {Record} */ + const derivedRandoms = {}; + const bundle = createHostDevContext({ + context2d: ctx2d, + seed, + audioFactory, + clock: () => mockNowMs, + }); +``` + +**改为**(`context2d` 由「待引擎就绪后绑定」的持有变量驱动;引擎模式下 host 自己的 `prepareCanvas(#game)` 不再用作绘制面,但仍可留作降级桩): +```js + // 创建 host-dev 受控上下文:注入「引擎渲染面(mainContext)」 / 主种子 / 音频工厂。 + // 【A0 改点】受控面 getContext2d 的 backing 不再是 host prepareCanvas(#game) 的 ctx,而是引擎 engineInit + // 自建的 mainContext(纯 2D,setGLEnable(false) 后引擎只渲 2D 进 mainCanvas)。因 engineInit 异步, + // 故 bundle 在「引擎就绪后」创建(见下 setupAfterEngine),getContext2d 一次性快照到真 mainContext。 + // time:A0 仍用 host 步进钟 mockNowMs(ctx.time backing 不动,受控面零改);引擎钟另经 window.__engine + // 暴露给 probe(门0),ctx.time 透传引擎 time 留 A1。 + let mockNowMs = 0; // 步进时钟读数(毫秒),由 tick/推帧推进。 + // 记录所有经取证入口 spawn 的 continuous 发射器 id,reset() 时统一 stopEmitter 防其持续喷发。 + /** @type {Set} */ + const activeEmitterIds = new Set(); + // per-plugin 派生随机实例登记(name → RandomSource)。(语义同 α,注释从略——见原文。) + /** @type {Record} */ + const derivedRandoms = {}; + // 引擎模式:绘制面 = 引擎 mainContext(就绪后绑);桩模式:绘制面 = host prepareCanvas(#game) 的 ctx2d。 + // 引擎通道选择由 URL ?engine=real|stub 决定(缺省 real)。 + const engineMode = getParam('engine', 'real'); // 'real'(引擎掌帧)| 'stub'(回滚桩) + /** @type {ReturnType|null} */ + let bundle = null; +``` + +> 注意:原 `const bundle = ...` 改为 `let bundle = null`,并把真正的 `createHostDevContext({...})` 调用挪进 §2.4 的 `buildBundle(renderCtx)`(renderCtx=引擎 mainContext 或桩 ctx2d)。所有下文引用 `bundle.xxx` 的代码(`deriveContextFor` 包裹、`registry.useContext(bundle.context)`、`stepFrames`、`__hostdev`、`inputBridge` 等)须移到 bundle 创建之后执行(见 §2.4 重排)。 + +### 2.3 编辑块 B:删除 host RAF 桩,替换为引擎 engineInit 五回调驱动 + +**锚(host.js:271-283,当前 stepFrames——桩模式保留,引擎模式新增引擎步进,见 §7)**:保留 `stepFrames` 函数体不动(桩通道用),但其调用时机受 `engineMode` 分流(§2.4)。 + +**锚(host.js:297-316,当前 renderFrame)**: +```js + /** 把当前帧的表现层(粒子 + 后处理叠加)渲染到 canvas。底已在每帧重铺黑。 */ + function renderFrame() { + clearBlack(ctx2d); // 每帧重铺黑底,避免上一帧残留干扰像素哈希 + // 粒子层 + const pj = /** @type {any} */ (instances['particles-juice']); + if (pj && typeof pj.render === 'function') { + ctx2d.save(); + ctx2d.fillStyle = '#7fd0ff'; // 中性亮色(host 提供,非插件内置;仅为取证可见) + try { pj.render(ctx2d); } catch (e) { uncaught.push({ type: 'render', message: 'particles:' + String(e && e.message || e) }); } + ctx2d.restore(); + } + // 后处理叠加层(vignette/dither/scanline) + const pp = /** @type {any} */ (instances['palette-post']); + if (pp && typeof pp.renderPost === 'function') { + try { pp.renderPost(ctx2d); } catch (e) { uncaught.push({ type: 'render', message: 'palette:' + String(e && e.message || e) }); } + } + } +``` +**改为**(renderFrame 拆成「粒子」「后处理」两半,供引擎 `gameRender`/`gameRenderPost` 分别调;桩模式仍合并调;`ctx2d` 形参化为「当前绘制面 renderCtx」): +```js + // 【A0】绘制面解析:引擎模式=engine.mainContext;桩模式=host 占位 ctx2d。由 buildBundle 时确定并存于 renderCtx。 + /** @type {CanvasRenderingContext2D|null} */ + let renderCtx = null; + + /** 画粒子层(引擎 gameRender 回调里调;桩模式 renderFrame 里调)。host 提供可见色(粒子不内置颜色)。 */ + function renderParticles() { + const g = renderCtx; + if (!g) return; + const pj = /** @type {any} */ (instances['particles-juice']); + if (pj && typeof pj.render === 'function') { + g.save(); + g.fillStyle = '#7fd0ff'; // 中性亮色(host 提供,非插件内置;仅为取证可见)——语义同 α + try { pj.render(g); } catch (e) { uncaught.push({ type: 'render', message: 'particles:' + String(e && e.message || e) }); } + g.restore(); + } + } + /** 画后处理叠加层(引擎 gameRenderPost 回调里调;桩模式 renderFrame 里调)。 */ + function renderPostFx() { + const g = renderCtx; + if (!g) return; + const pp = /** @type {any} */ (instances['palette-post']); + if (pp && typeof pp.renderPost === 'function') { + try { pp.renderPost(g); } catch (e) { uncaught.push({ type: 'render', message: 'palette:' + String(e && e.message || e) }); } + } + } + /** 桩模式渲染一帧(合并粒子+后处理)。引擎模式不用它(引擎分回调驱动)。 */ + function renderFrame() { + // 桩模式底由 host 自铺黑(引擎模式由引擎 clearColor 铺,不在此)。 + if (engineMode === 'stub' && renderCtx) clearBlack(renderCtx); + renderParticles(); + renderPostFx(); + } +``` + +**锚(host.js:342-368,当前 RAF 桩 startRaf/stopRaf/rafLoop)**: +```js + /* ── 真实 RAF 循环(交互模式):把墙钟 dt 钳制后推进 ── + 取证模式不依赖它(取证用 stepFrames 定量推进);但交互演示需要它让画面动起来。 */ + let rafId = 0; + let lastTs = 0; + let rafRunning = false; + function rafLoop(ts) { + if (!rafRunning) return; + if (lastTs === 0) lastTs = ts; + let dt = (ts - lastTs) / 1000; + lastTs = ts; + if (dt > 0.05) dt = 0.05; // dt 封顶钳制(防卡顿后大跳) + mockNowMs += dt * 1000; + bundle.tick(dt); + renderFrame(); + rafId = window.requestAnimationFrame(rafLoop); + } + function startRaf() { + if (rafRunning) return; + rafRunning = true; + lastTs = 0; + rafId = window.requestAnimationFrame(rafLoop); + } + function stopRaf() { + rafRunning = false; + if (rafId) window.cancelAnimationFrame(rafId); + rafId = 0; + } +``` +**改为**(**桩 RAF 整块保留**作回滚通道,但仅 `engineMode==='stub'` 用;新增引擎五回调定义。引擎掌帧时由引擎内部 RAF 驱动这些回调,host 不再自管 RAF): +```js + /* ── 回滚桩:浏览器 RAF 循环(仅 engineMode==='stub' 用,保留可回滚点,创始人裁)── + 引擎模式(real)不走它——引擎内部 RAF 驱动下方五回调。 */ + let rafId = 0; + let lastTs = 0; + let rafRunning = false; + function rafLoop(ts) { + if (!rafRunning) return; + if (lastTs === 0) lastTs = ts; + let dt = (ts - lastTs) / 1000; + lastTs = ts; + if (dt > 0.05) dt = 0.05; // dt 封顶钳制(防卡顿后大跳) + mockNowMs += dt * 1000; + if (bundle) bundle.tick(dt); + renderFrame(); + rafId = window.requestAnimationFrame(rafLoop); + } + function startRaf() { + if (rafRunning) return; + rafRunning = true; + lastTs = 0; + rafId = window.requestAnimationFrame(rafLoop); + } + function stopRaf() { + rafRunning = false; + if (rafId) window.cancelAnimationFrame(rafId); + rafId = 0; + } + + /* ── 引擎五回调(engineMode==='real'):engineInit 接管帧驱动 + 渲染 ── + · gameInit:引擎首帧前一次性初始化(host-dev 无真游戏,留空+留痕)。 + · gameUpdate:引擎每个逻辑步调一次 → host 在此用固定步长 1/60 推进 bundle.tick(驱动所有 onFrame)。 + dt 取 FIXED_DT(非墙钟):引擎累加器已把墙钟摊成每步 1/frameRate,onFrame 回调 dt 须恒定(粒子时步确定)。 + · gameUpdatePost:逻辑步后处理钩子(host-dev 无需,留空)。 + · gameRender:引擎渲染阶段(清屏后)→ host 画粒子层到 engine.mainContext。 + · gameRenderPost:渲染末尾 → host 画后处理叠加层。 */ + function engGameInit() { + // host-dev 无真游戏对象;启动链由 setupAfterEngine 在引擎就绪后打(见 §2.4)。 + // 此处仅留痕「引擎 gameInit 已触发」,供 probe 旁证与排障。 + window.__engineGameInitFired = true; + } + function engGameUpdate() { + // 引擎掌帧:每逻辑步推进一次 bundle(固定步长,driver=引擎内部 RAF)。 + if (bundle) { + mockNowMs += FIXED_DT * 1000; // host 步进钟随引擎逻辑步同步推进(ctx.time 仍读它,A0 不透传引擎钟) + bundle.tick(FIXED_DT); // 驱动所有插件 onFrame(含 particles 自动 step) + } + } + function engGameUpdatePost() { /* host-dev 无需逻辑步后处理 */ } + function engGameRender() { renderParticles(); } // 引擎清屏后画粒子(host 提供可见色) + function engGameRenderPost() { renderPostFx(); } // 渲染末尾叠加后处理 +``` + +### 2.4 编辑块 C:bootHostDev 主体重排——引擎先起,bundle 后建 + +引擎掌帧的结构必然是「`engineInit` 先行(async)→ canvas/mainContext 就绪 → 建 bundle + 注册插件 + 打启动链」。故把 host.js 末尾「建 bundle 之后到 return」的同步编排,包成一个 `setupAfterEngine(renderCtxArg)`,由两条通道分别调用: + +- `engineMode==='real'`:先 `setGLEnable(false)` + `setShowSplashScreen(false)` + 锁 canvas 尺寸 → `engineInit(engGameInit, engGameUpdate, engGameUpdatePost, engGameRender, engGameRenderPost, [], rootEl)` → `.then(()=> setupAfterEngine(LJS.mainContext))`。 +- `engineMode==='stub'`:直接 `setupAfterEngine(ctx2d)`(ctx2d=host `prepareCanvas(#game)` 产物),随后按 mode 起 `startRaf()`(idle)等——**与 α 行为完全一致**(回滚通道)。 + +**锚(host.js:188-203,bundle 创建 + deriveContextFor 包裹)→ 移入 `buildBundle(renderCtxArg)`**。 +**锚(host.js:205-578 大段:注册插件 / probe / markBootChain / runSmoke / __hostdev / 按 mode 收尾)→ 移入 `setupAfterEngine(renderCtxArg)`**,其中: +- `renderCtx = renderCtxArg;`(设当前绘制面) +- `bundle = buildBundle(renderCtxArg);`(建 bundle,context2d=renderCtxArg) +- 其余编排(registry/probe/markBootChain)原样,但**所有 `stepFrames` 的语义按 engineMode 分流**(见 §7)。 + +**锚(host.js:564-581,当前 markBootChain + 按 mode 收尾 + return)**: +```js + // ── 执行启动链打点 + 按模式收尾 ── + markBootChain(); + if (mode === 'smoke') { + const smokeResult = runSmoke(); + window.__smokeResult = smokeResult; + } else if (mode === 'evidence') { + setStatus('evidence: ready. drive via window.__hostdev. seed=' + seed); + } else { + setStatus('idle: click canvas to spawn particles. seed=' + seed); + startRaf(); + } + + return hostdev; +} +``` +**改为**(按 engineMode 选通道;启动链 + 收尾移进 setupAfterEngine;real 模式下 idle 不需 host startRaf,引擎内部 RAF 已在跑): +```js + // ── 通道分流:real(引擎掌帧,地基)| stub(回滚桩,创始人裁)── + if (engineMode === 'stub') { + // 回滚通道:行为与 α 桩完全一致(host prepareCanvas(#game) + 浏览器 RAF + bundle.tick)。 + setupAfterEngine(ctx2d); + if (mode !== 'smoke' && mode !== 'evidence') startRaf(); // idle 起 host RAF + return window.__hostdev; + } + + // real 通道(缺省,A0 地基):引擎掌帧。 + // 1) 渲染纯 2D(setGLEnable(false) → 引擎跳 glCanvas,渲进 mainContext,2D 像素 readback 无 WebGL 坑)。 + // 2) 关开屏(默认已关,显式兜底)。3) 锁 390×844@DPR2 backing(取证固定环境)。 + LJS.setGLEnable(false); + LJS.setShowSplashScreen(false); + LJS.glSetAntialias(false); // 关抗锯齿,去亚像素抖动(取证哈希稳定,A6 也依赖) + LJS.setCanvasFixedSize(LJS.vec2(VIEWPORT_W * DPR, VIEWPORT_H * DPR)); // backing=780×1688(=390×844@DPR2) + // 引擎在 rootElement 内自建 mainCanvas(不采用 #game)。rootElement 取 #game 的父容器,避免污染 。 + const rootEl = (canvas && canvas.parentElement) ? canvas.parentElement : document.body; + // engineInit 异步:resolve 后 mainContext 就绪 → 建 bundle/注册/打链/收尾。 + LJS.engineInit(engGameInit, engGameUpdate, engGameUpdatePost, engGameRender, engGameRenderPost, [], rootEl) + .then(() => { + setupAfterEngine(LJS.mainContext); + // real 模式:引擎内部 RAF 已在驱动五回调(含画面动),idle 无需 host startRaf。 + // smoke/evidence 的收尾(runSmoke / 取证就绪)已在 setupAfterEngine 内按 mode 处理。 + window.__hostBootedReal = true; + }) + .catch((e) => { + // 引擎接管失败:记错误,置可探活标记(门0/CDP 据此判 real 通道失败 → 回滚桩,创始人裁)。 + window.__engineInitError = String((e && e.message) || e); + setStatus('ENGINE INIT FAILED: ' + window.__engineInitError + '(可加 ?engine=stub 回滚桩)'); + }); + + // 注意:real 模式 hostdev 句柄在 setupAfterEngine 内挂 window.__hostdev(异步),故此处返回占位。 + // index.html 引导已用 window.__hostBooted 探活;CDP 取证应等 window.__hostBootedReal 再驱动 __hostdev。 + return { pending: true, engineMode }; +} +``` + +> **setupAfterEngine 内的「按 mode 收尾」**(从原 host.js:564-578 迁入,real 模式去掉 idle 的 host startRaf): +> ```js +> // setupAfterEngine 尾部: +> markBootChain(); +> if (mode === 'smoke') { window.__smokeResult = runSmoke(); } +> else if (mode === 'evidence') { setStatus('evidence: ready. drive via window.__hostdev. seed=' + seed); } +> else { setStatus('idle: ' + (engineMode === 'real' ? 'engine RAF running. ' : '') + 'click canvas. seed=' + seed); +> if (engineMode === 'stub') startRaf(); } +> window.__hostdev = hostdev; // real 模式异步挂载点 +> ``` + +--- + +## 3. canvas:mainContext backing + rootElement 挂载 + 390×844 DPR2 锁 + +### 3.1 真源核验 + +- `engineInit:406` **自建** mainCanvas append 到 rootElement:`mainCanvas = rootElement.appendChild(document.createElement('canvas'))`;`drawContext=mainContext=mainCanvas.getContext('2d')`(:407)。**引擎不采用现有 `#game`**。且 `engineInit:405` 用 `rootElement.style.cssText=styleRoot` **整体覆盖** rootElement 样式。 +- `setGLEnable(false)`(d.ts:695,须 engineInit 前)→ `glInit:8765` 守卫跳过 glCanvas → 纯 2D 渲染进 mainCanvas。 +- `setCanvasFixedSize(vec2(780,1688))`(d.ts:665)→ 固定分支:mainCanvas backing=780×1688;CSS 用 100%/'' 适配窗口。DPR2 体现在 **backing 是 CSS 的 2 倍**——故传 `vec2(VIEWPORT_W*DPR, VIEWPORT_H*DPR)=vec2(780,1688)`,**不是** `vec2(390,844)`。 +- `updateCanvas:374-375` 每帧重设 `mainCanvas.width/height=mainCanvasSize` 清空——故 host 任何「帧外手绘」会被下帧清掉;取证须在 `gameRenderPost` 之后、下一 tick `updateCanvas` 之前读 ImageData。 + +### 3.2 getContext2d backing(受控面零改,靠路 B 后绑) + +`getContext2d` 实现(plugin.js:467-470)不改。靠 §2.4 路 B:bundle 在 `engineInit().then` 后用 `createHostDevContext({context2d: LJS.mainContext, ...})` 建,故 `getContext2d()` 一次性快照到真 `engine.mainContext`。插件经 `context.getContext2d()` 拿到的就是引擎 2D 渲染面,坐标系=物理像素(mainCanvas.width/height=780×1688)。 + +> **坐标系差异提示(A6/插件须知,非 A0 改点)**:α 桩里 host `prepareCanvas` 调 `ctx.setTransform(DPR,0,0,DPR,0,0)`,插件按**逻辑像素 390×844** 绘制。引擎 mainContext **无此 DPR 变换**(引擎自己的 draw API 走世界坐标→屏幕变换,但插件是裸调 2D ctx),故插件经 `getContext2d` 拿到的引擎 ctx 是**物理像素 780×1688 口径**。这会使 particles 现有「按 390×844 逻辑像素绘制」的坐标在引擎面上只覆盖左上 1/4。**A0 处理**:在 `gameRender` 调 `renderParticles` 前,host 对 `engine.mainContext` 施加同款 DPR 变换包裹: +> ```js +> function renderParticles() { +> const g = renderCtx; if (!g) return; +> const pj = instances['particles-juice']; +> if (pj && typeof pj.render === 'function') { +> g.save(); +> if (engineMode === 'real') g.setTransform(DPR, 0, 0, DPR, 0, 0); // 引擎 mainContext 无 DPR 变换,host 补,保插件逻辑像素口径不变 +> g.fillStyle = '#7fd0ff'; +> try { pj.render(g); } catch (e) { uncaught.push({ type: 'render', message: 'particles:' + String(e && e.message || e) }); } +> g.restore(); +> } +> } +> ``` +> `renderPostFx` 同理须包 DPR 变换(palette-post 也按逻辑像素铺)。**这是受控面「坐标系仍逻辑像素」不变性的兑现点**(R2 facts:换引擎后插件仍按 390×844 逻辑像素绘制)。**风险**:引擎 `updateCanvas` 每帧重置变换为单位阵,故 host 每帧在 render 回调内重设变换(已在 save/restore 内,正确)。 + +### 3.3 rootElement 挂载 + +real 模式 `rootEl = canvas.parentElement`(=`#wrap`)。引擎在 `#wrap` 内建自己的 mainCanvas,`#game`(空 2D canvas)仍在 DOM 里但不被引擎用——CDP 取证须改读 **引擎 mainCanvas**,不是 `#game`(见 §10 + §3.4)。 + +### 3.4 index.html / CDP 取证选择器(须配套,但 index.html 引导代码不必改) + +`#game` 退役、引擎自建 canvas 无 id。CDP `captureImageData(cdp, '#game')` 会抓到空 canvas。**两条配套法(取其一,记 A6/集成段执行)**: +- **法①(推荐,host 给引擎 canvas 打 id)**:setupAfterEngine 里 `if (engineMode==='real' && LJS.mainCanvas) LJS.mainCanvas.id = 'game-engine';`,CDP 取证选择器改 `#game-engine`。 +- **法②**:CDP 抓 `document.querySelector('canvas:last-of-type')` 或经 `window.__hostdev.canvas()` 暴露引擎 mainCanvas 引用。 + +> A0 落**法①**(在 setupAfterEngine 给 `LJS.mainCanvas.id='game-engine'`),并在 `window.__hostdev` 加 `canvas(){ return LJS.mainCanvas; }` 便于 CDP 拿引用。`integration-run.cjs` 的 `CANVAS='#game'` 改 `'#game-engine'`(集成段配套改 harness driver,非 host.js;harness 非冻结件)。 + +--- + +## 4. 输入:避免引擎自带监听与 host inputBridge 双发 + +### 4.1 真源核验(冲突点) + +引擎 `inputInit:5853` 在 `engineInit:409` 内被调,自装 9 个鼠标键盘监听 + 3 个触摸监听**全挂 `document`**(:5858-5866 / :5985-5987),且 `inputPreventDefault` 时对鼠标/键/滚轮 `e.preventDefault`。坐标经 `mouseEventToScreen` 用 `mainCanvas.getBoundingClientRect()` 映射。 + +host 现 inputBridge 把 `#game` 的 pointer + window keydown/keyup 经 `inputBridge._emit` 灌进受控面(host.js:321-330)。 + +**双发风险**:A0 后若**保留 host 的 DOM 监听**,则同一物理点击会被「引擎 document 监听」与「host #game 监听」**各收一次**——引擎更新自己的 `mousePos`,host 推自己的受控订阅面。两套并存且坐标系不同(引擎按 mainCanvas rect / host 按 #game rect)。 + +### 4.2 A0 处置(A1 才做引擎态透传,A0 不得双发) + +A0 范围:`getInput` 仍是「host inputBridge 受控订阅面」(A1 才改成引擎轮询态包装)。要既「不双发」又「保 probe firstInput 旁证 + gamefeel 输入缓冲可被真点击触发」,A0 做法: + +- **host 的 DOM 监听改挂引擎 mainCanvas,且坐标按引擎 mainCanvas rect**(与引擎坐标系一致),**不再挂 window/`#game`**。这样 host inputBridge 仍是受控面唯一注入源(A0 不动 getInput 契约),而引擎自带 document 监听虽也在收(无法关——`inputInit` 无开关),但**引擎的输入态(mousePos 等)A0 期无人消费**(getInput 走 host inputBridge,不读引擎态),故引擎那套监听是「空转副作用」,不污染受控面语义。**唯一真冲突=preventDefault 重复**(引擎已 preventDefault,host 再 preventDefault 无害幂等)与**音频解锁双触发**(见 §5,幂等无害)。 +- **结论**:A0 **保留 host inputBridge 注入链**,仅把监听目标从 `#game`/`window` 改到**引擎 mainCanvas**(real 模式)。坐标 `toCanvasXY` 改用引擎 mainCanvas rect。引擎自带 document 监听不消费、不冲突(A1 切引擎态透传时再统一收敛到引擎单源)。 + +**锚(host.js:318-330,当前输入桥接)**: +```js + /* ── 真实输入桥接:DOM 事件 → 归一化坐标 → core 输入桥 _emit ── ... */ + const inputBridge = bundle.inputBridge; + function onPointer(type, ev) { + const { x, y } = toCanvasXY(canvas, ev.clientX, ev.clientY); + inputBridge._emit(type, { x, y }); + } + canvas.addEventListener('pointerdown', (ev) => { unlockAudioOnce(); onPointer('pointerdown', ev); }); + canvas.addEventListener('pointermove', (ev) => onPointer('pointermove', ev)); + canvas.addEventListener('pointerup', (ev) => onPointer('pointerup', ev)); + window.addEventListener('keydown', (ev) => { unlockAudioOnce(); inputBridge._emit('keydown', { key: ev.key }); }); + window.addEventListener('keyup', (ev) => inputBridge._emit('keyup', { key: ev.key })); +``` +**改为**(监听目标按 engineMode 选;real 模式挂引擎 mainCanvas + 按其 rect 归一化;inputBridge 取自已建 bundle): +```js + /* ── 真实输入桥接:DOM 事件 → 归一化坐标 → core 输入桥 _emit ── + 【A0】real 模式监听挂「引擎 mainCanvas」(坐标按引擎 canvas rect,与引擎坐标系一致),不挂 #game/window, + 避免与引擎自带 document 监听坐标错位;引擎那套 document 监听 A0 期无人消费(getInput 仍走本桥), + 故不构成受控面语义双发(真正收敛到引擎单源是 A1)。keydown/keyup 仍挂 window(键盘无 canvas rect 问题)。 */ + const inputBridge = bundle.inputBridge; + // 输入事件目标 canvas:real=引擎 mainCanvas;stub=host #game。 + const inputTarget = (engineMode === 'real' && LJS.mainCanvas) ? LJS.mainCanvas : canvas; + function onPointer(type, ev) { + const { x, y } = toCanvasXY(inputTarget, ev.clientX, ev.clientY); + inputBridge._emit(type, { x, y }); + } + inputTarget.addEventListener('pointerdown', (ev) => { unlockAudioOnce(); onPointer('pointerdown', ev); }); + inputTarget.addEventListener('pointermove', (ev) => onPointer('pointermove', ev)); + inputTarget.addEventListener('pointerup', (ev) => onPointer('pointerup', ev)); + window.addEventListener('keydown', (ev) => { unlockAudioOnce(); inputBridge._emit('keydown', { key: ev.key }); }); + window.addEventListener('keyup', (ev) => inputBridge._emit('keyup', { key: ev.key })); +``` + +> **toCanvasXY 复用**:host.js:119-125 的 `toCanvasXY(canvas, clientX, clientY)` 用 `canvas.getBoundingClientRect()` + CSS 尺寸映射。引擎 mainCanvas CSS 用 `position:absolute;top/left:50% translate(-50%,-50%)`(:416-418)+ fixed-size 分支 CSS 100%/'',其 `getBoundingClientRect().width/height` 即显示尺寸——`toCanvasXY` 用 `rect.width/height` 归一化到 `VIEWPORT_W/H`,对引擎 canvas 同样成立(**前提**:引擎 mainCanvas 显示尺寸≈390×844;若 fixed-size CSS 100% 使显示尺寸随窗口变,`toCanvasXY` 用相对比例仍正确归一化到 390×844 逻辑像素)。**函数零改**,仅传入 target 变。 + +--- + +## 5. 音频解锁在引擎掌帧下处置 + +### 5.1 真源核验 + +引擎模块 `import` 即 `new AudioContext`(`littlejs.esm.js:6753`,进程内单例 `audioContext`)。引擎在多处交互自动 `audioContext.resume()`(mousedown:5931-5932 / keydown:5996-5997 / pointer:6664-6666)。`audioInit:6771` 仅在 `soundEnable&&!headless` 建 masterGain。 + +host 现 `audioFactory` 在解锁后 `new AudioContext`(host.js:164-172),`unlockAudioOnce` 绑首次手势 resume(host.js:333-340)。 + +### 5.2 冲突与 A0 处置 + +- **双 context 风险**:host `audioFactory` 另建一个 AudioContext → 与引擎单例并存。但 A0 期 `getAudioContext` 仍走 host audioFactory(getEngine/合成核背书是 A1+),**audio-music 插件用的是 host 的那个 context**,引擎自己的 context A0 期无插件消费——故「双 context」在 A0 不致功能错(各自独立,host 那个被插件用,引擎那个空转)。 +- **解锁双触发**:host `unlockAudioOnce` resume host context;引擎 document 监听 resume 引擎 context。两者各 resume 各的,幂等无害。 +- **A0 结论**:**音频链零改**(host.js:158-172 audioFactory + :332-340 unlockAudioOnce 不动)。真正「getAudioContext backing 到引擎单例 audioContext」是 A1(受控面 time/random/input 透传那一批的同族改造),A0 不做。**风险**记 §11(双 context 在 A1 收敛)。 + +> 唯一须确认:引擎 `import` 即 `new AudioContext` 在「无用户手势」下浏览器可能告警「AudioContext not allowed to start」(suspended 态),但引擎 `audioInit` 容忍 suspended,且 host-dev real 模式 mode≠smoke 时无声音播放需求——**无 console error 级**(仅 warning)。门0 smoke 的「console 零未捕获错误」断言(integration-run.cjs:88)须确认引擎这条是 warning 非 error(mini-desktop 实测核对)。 + +--- + +## 6. time:引擎 time/frame 暴露给 probe(门0),ctx.time 不动 + +### 6.1 设计(A0 只暴露,不透传) + +门0 要「引擎 `time`/`frame` 随循环真推进(probe 实测)」+「probe 双时钟物理隔离」。A0 做法: + +- `ctx.time` backing **不动**(仍 `mockNowMs`,受控面零改,A1 才透传引擎 `time`)。 +- A0 **额外**经 `window.__engine` 暴露引擎钟读取器,供 probe/门0 实测脚本读引擎 `time`/`frame`/`timeReal`: + - 引擎 `frame`(d.ts:98,单调自增)/`time`(d.ts:102,=frame/60,受 timeScale/暂停污染)/`timeReal`(d.ts:106,纯墙钟,不受 pause/timeScale/帧率钳制)。 + - 这三个是引擎导出的 `let` 全局——经 `LJS.frame`/`LJS.time`/`LJS.timeReal` 读(ESM live binding,读到的是引擎内部当前值)。 + +**编辑:在 setupAfterEngine 内(real 模式)挂 `window.__engine`**(新增,host 私有探活/门0 用,不进受控面契约): +```js + // 【A0 门0】暴露引擎钟给 probe/门0 实测脚本(host 私有,非受控面契约)。 + // 双时钟物理隔离:engine.time=污染钟(受 timeScale/暂停,仅作「接管断言」证 time 随 frame 推进); + // engine.timeReal=纯墙钟(d.ts:106 不受 pause/timescale/帧率钳制,作门9 P75 埋点候选)。 + // 两者读自引擎不同导出变量,互不覆盖。frame=单调帧号。 + if (engineMode === 'real') { + window.__engine = { + frame() { return LJS.frame; }, // 单调帧号 + time() { return LJS.time; }, // 污染钟(timeScale/暂停影响)——接管断言用 + timeReal() { return LJS.timeReal; }, // 纯墙钟——P75 埋点候选 + paused() { return LJS.paused; }, + timeScale() { return LJS.timeScale; }, + // 便于门0 一次取齐 + snapshot() { return { frame: LJS.frame, time: LJS.time, timeReal: LJS.timeReal, paused: LJS.paused, timeScale: LJS.timeScale }; }, + }; + } +``` + +### 6.2 probe 双时钟物理隔离(probe 是冻结件,不改 probe) + +- probe 自身 P75 埋点用 `ctx.time.nowMs()`(=mockNowMs,host 步进钟)——**probe 不改**(冻结 lane 件,β spec §3/§5/门9 要求「probe 保留独立墙钟,不透传引擎 time」)。A0 期 `ctx.time`=host mockNowMs,本就独立于引擎 time,满足「不透传引擎 time」。 +- 门0 的「接管断言」(证引擎掌帧)用 `window.__engine.frame()`/`time()`,**与 probe 的 P75 钟(ctx.time)物理隔离**(一个读 `LJS.time`,一个读 host mockNowMs)。两钟不同源、互不覆盖——满足门0「双时钟物理隔离」。 + +> **关键澄清**:门0 的双时钟是「引擎 time(接管断言)vs 引擎 timeReal 或独立墙钟(P75)」。A0 落法=接管断言读 `LJS.time`+`LJS.frame`;P75 埋点(probe 现状)读 host mockNowMs(独立墙钟,不受引擎 timeScale 污染)。**这满足隔离**(P75 不读被污染的引擎 time)。若后续要把 P75 也搬到引擎 `timeReal`,那是 A1+probe 协同改,A0 不做(probe 冻结)。 + +--- + +## 7. 确定性取证存活:window.__hostdev 步进路径适配引擎 + +### 7.1 核心矛盾(R3 已坐实) + +引擎掌帧后主循环=引擎内部 RAF(墙钟驱动,每动画帧追帧数 N 非确定)。host 现 `stepFrames(n)` 靠「手摇 `bundle.tick(FIXED_DT)` n 次」保「同帧号→同像素」——**引擎模式下这条断了**:`bundle.tick` 仍可手调(它是 host 自己的 onFrame 派发器,与引擎 RAF 无关),但**引擎渲染(gameRender 回调里的 renderParticles)只在引擎 RAF tick 时跑**,host 手调 `bundle.tick` 不触发引擎 render;且引擎每帧 `updateCanvas` 会清屏。 + +### 7.2 A0 存活方案(双轨,依 R3 推荐) + +**取「stub 通道保确定性旁路 + real 通道证掌帧」双轨**(β spec §11 + R3 facts「候选独立确定性旁路:保留现 host-dev 桩作纯确定性回归通道,与引擎掌帧的真接线通道并存」): + +- **确定性逐像素哈希回归(vfxFrame/paletteFrame/reset/stepFrames)→ 走 stub 通道**(`?engine=stub`):`stepFrames` 原样保留(host.js:277-283 不动),`vfxFrame`/`paletteFrame`/`reset` 原样(host.js:486-552 不动),绘制面=host `prepareCanvas(#game)` 的 ctx2d。**这条与 α 完全等价**,FNV-1a 哈希「同参数重跑×3 一致」原样成立——确定性回归不受引擎影响(兜回归)。 +- **引擎真掌帧 + 真渲染像素(门0 + A6 WebGL/2D 像素门)→ 走 real 通道**(`?engine=real`):取证不再「手摇定量推帧」,而是: + 1. 门0:经 `window.__engine` 实测引擎 `frame`/`time` 真推进(见 §10); + 2. A6 像素门:`window.__hostdev` 在 real 模式提供**「引擎帧号对齐的取证入口」**——因引擎不可外部单步,real 模式的 `vfxFrame` 改为「spawn 预设 + 真 RAF 跑到目标引擎 frame(轮询 `LJS.frame>=startFrame+frames`)→ 读 `engine.mainContext` ImageData」。**逐帧像素哈希的「同 frame→同像素」**靠:引擎 `time=frame++/frameRate`(整数 frame 的纯函数,与墙钟无关,R3 已证),故**锁定引擎 frame 到目标值再抓**,same-seed+same-engine-frame→same-pixel 成立(粒子序列由 reseed 派生随机定,与引擎 frame 同步)。**注意**:real 模式取证须在引擎 RAF 自然推进下「等到目标 frame 抓」,不是手摇;同参数重跑因引擎 frame 整数确定 + reseed 确定 → 像素一致(追帧数 N 抖动只影响「几次动画帧凑齐 frames 个逻辑步」,不影响逻辑步内容)。 + +**real 模式 vfxFrame 适配(新增分支,stub 分支保留)**: +```js + vfxFrame(preset, presetSeed, frames, origin) { + const pj = instances['particles-juice']; + const pp = instances['palette-post']; + pp.setPost({ vignette: { enabled: false }, dither: { enabled: false }, scanline: { enabled: false } }); + reseedParticleRandom(presetSeed >>> 0); + const ox = (origin && typeof origin.x === 'number') ? origin.x : VIEWPORT_W / 2; + const oy = (origin && typeof origin.y === 'number') ? origin.y : VIEWPORT_H / 2; + const id = pj.spawnEmitter(preset, ox, oy); + if (typeof id === 'number' && id >= 0) activeEmitterIds.add(id); + if (engineMode === 'stub') { + stepFrames(frames); // 桩:手摇定量推帧(确定性逐像素,原样) + return { particleCount: pj.particleCount(), frame: bundle.frameCount() }; + } + // real:引擎掌帧,不能手摇;返回 Promise,等引擎 RAF 推进 frames 个逻辑步后 resolve(取证侧 await)。 + // bundle.tick 由引擎 gameUpdate 每逻辑步驱动,故 bundle.frameCount() 随引擎逻辑步推进; + // 等 bundle.frameCount() 增量 ≥ frames 即「同 host 帧号」对齐(与 stub 帧号口径一致,便于哈希对照)。 + const startFrame = bundle.frameCount(); + return new Promise((resolve) => { + const tEnd = Date.now() + 5000; // 兜底超时,防卡死 + const poll = () => { + if (bundle.frameCount() - startFrame >= frames || Date.now() > tEnd) { + resolve({ particleCount: pj.particleCount(), frame: bundle.frameCount(), engineFrame: LJS.frame }); + } else { + window.requestAnimationFrame(poll); + } + }; + poll(); + }); + }, +``` + +> **取证范式迁移留痕**:real 模式 `vfxFrame`/`paletteFrame` 变 **async**(返回 Promise),CDP driver 须 `await cdp.evaluate('...vfxFrame(...).then(...)')` 或轮询 `window.__hostdev` 暴露的就绪标记。这是 R3「stepFrames 取证范式 real 后须重写」的落点。**A6 集成段**据此改 `integration-run.cjs`(非冻结件)。**A0 主体只需保证两通道入口都在**(stub 同步、real 异步),范式细节由 A6 定 N/目标 frame 清单(门0 openQuestion)。 +> +> **reset 在 real 模式**:`reset` 现「推 220 帧让粒子消亡」靠手摇 `bundle.tick`(host.js:543-546)——real 模式同样可手调 `bundle.tick` 消亡粒子(粒子状态是 host 侧 bundle 管的,不依赖引擎渲染),但屏幕清空靠引擎下一 RAF tick。real 模式 reset 须「手调 bundle.tick 消亡 + 等一个引擎 RAF tick 清屏」。**A0 落法**:reset 保留手调消亡(host.js:543-546 不动),real 模式额外 `await` 一个 RAF 让引擎清屏后再返回。 + +### 7.3 stepFrames / vfxFrame / paletteFrame / reset 改动汇总 + +| 入口 | stub 通道 | real 通道 | +|---|---|---| +| `stepFrames(n)` | 原样(手摇 tick+renderFrame,host.js:277-283 不动) | 仅推进 host bundle(`bundle.tick` n 次,不调 renderFrame——引擎渲染自走 RAF);用于 reset 消亡 | +| `vfxFrame` | 原样(同步,定量推帧) | async(spawn+reseed+等引擎推 frames 逻辑步+读 engine.mainContext) | +| `paletteFrame` | 原样(同步) | async(同 vfxFrame) | +| `reset` | 原样 | 手调消亡 + await 一个 RAF 清屏 | + +--- + +## 8. 可回滚点实现(flag + 保留桩) + +- **flag**:`?engine=real|stub`(缺省 `real`)。`engineMode = getParam('engine', 'real')`(§2.2 已加)。 +- **桩整块保留**:`prepareCanvas`/`clearBlack`/`rafLoop`/`startRaf`/`stopRaf`/`stepFrames`/`vfxFrame(stub分支)`/`reset(stub分支)`——全部**不删**,仅由 `engineMode` 分流挂载。`engineMode==='stub'` 时 host 行为与 α **逐行等价**(同 `#game` 2D canvas + 浏览器 RAF + bundle.tick)。 +- **回滚操作**:创始人裁「A0 地基不通」时,URL 加 `?engine=stub` 即回 α 桩;或把 `getParam('engine','real')` 默认值改回 `'stub'`(一行)。**commit 边界**:A0 改动单独成 commit(β spec §11「A0/A/B/C 各 commit 边界清」),revert 该 commit 即全回滚。 +- **桩通道验证不退化**:`integration-run.cjs` 加 `?engine=stub` 跑一遍应与 α 收口报告(`2026-06-12-T1b-α收口报告.md`)证据等价——这是回滚点「活着」的实测保证。 + +--- + +## 9. esbuild:littlejsengine 入产物 + box2d 风险 + +### 9.1 入产物(体积跳升,SIZES 重锚留 A5) + +- `host-dev/entry.js`(→ `host-bundle.js`)是引擎接线入口;esbuild `bundle:true` 会把 `import * as LJS from 'littlejsengine'` 整体打入(`littlejs.esm.js` raw≈640KB,min 后≈242KB 量级;题面记「引擎入包预期产物≈432KB raw」)。**A5 用 size.mjs 增量法实测重锚**;A0 仅记预期,不判超标(β spec 门2 是 A5 的事)。 +- `build.mjs` `LOCKED_BUILD_OPTIONS` 不改(minify+iife+es2019)。esbuild 默认能 tree-shake,但引擎多为副作用顶层代码(`new AudioContext` 等),树摇有限——大头照进。 +- **门1①(真接线 metafile)**:build.mjs `build()` 当前**未传 metafile**。A0/门1 须 additive 加 `metafile:true` 落 `metafile.json`,grep 确认 `littlejsengine` 入产物且被 host chunk 引用(纯 node,本机可验)。**这是 build.mjs 的 additive 改点**: + ```js + // build.mjs build() 内 esbuild.build({...}) 加: + metafile: true, + // 并在 await 后:if (result.metafile) writeFileSync(outfile + '.meta.json', JSON.stringify(result.metafile)); + ``` + (门1①本机可跑:`node scripts/build.mjs host-dev/entry.js dist/host-bundle.js --global-name=GameRuntimeHostDev` 后 grep meta.json 命中 `littlejsengine/dist/littlejs.esm.js`。**但纪律上构建归集成段 mini-desktop**——本机有 esbuild 可验证 metafile 生成与入包,真渲染取证仍上 mini-desktop。) + +### 9.2 box2d.wasm 依赖:esbuild 能否正常处理 + +- **核验结论**:`littlejs.esm.js` **不 `import` box2d.wasm.js/.wasm**;box2d 经全局 `Box2D()`(`box2dInit:14582`,惰性,A0 路径永不调)。故 esbuild **不会**尝试解析/打包 wasm 文件——`Box2D` 是个未定义的自由全局引用,esbuild 把它当外部全局(不报错、不打包)。运行时因 `box2dInit` 永不被调,`Box2D` 永不被读,**无 ReferenceError、无 wasm fetch**。 +- **风险标记**:(a) 体积——box2d 相关 JS 类(Box2dObject 等,`littlejs.esm.js:12668+` 大段)作为死代码照进产物,撑大 raw(A5 重锚计入);(b) 若 esbuild 在某配置下对未定义全局报 `Could not resolve` ——**已核验非 import 故不会**(esbuild 只对 `import`/`require` 报 resolve 失败,自由标识符不报)。**结论**:esbuild 正常处理,无须 external 配置,无须引 wasm。详记 §11 风险表。 + +--- + +## 10. 门0 probe 断言清单 + 双时钟实测 runbook + +### 10.1 门0 断言清单(每条给判据 + 数据源) + +| # | 断言 | 数据源 | 判据 | +|---|---|---|---| +| G0-1 | real 通道引擎真起(非桩) | `window.__hostBootedReal===true` 且 `window.__engineGameInitFired===true` | 两者皆 true(engineInit 五回调真触发,gameInit 跑过) | +| G0-2 | 引擎内部 RAF 真掌帧(frame 单调推进) | `window.__engine.frame()` 隔 ~500ms 读两次 t0/t1 | `t1>t0`(引擎 RAF 在自调度推帧,非桩静止) | +| G0-3 | 引擎 time 随 frame 同步推进 | `window.__engine.snapshot()` 读 time/frame | `time≈frame/60`(偏差容忍 < 0.05s;证 `time=frame++/frameRate` 真跑) | +| G0-4 | timeReal 纯墙钟推进(不受暂停/变速) | `window.__engine.timeReal()` 隔 ~500ms 读两次 | `ΔtimeReal>0` 且 ≈ 墙钟间隔(证 P75 埋点钟可用) | +| G0-5 | 双时钟物理隔离 | 对比 `window.__engine.time()`(污染钟)与 probe P75 钟 `ctx.time`(host mockNowMs,经 `window.__probe()` JSONL tMono) | 两钟读自不同变量、数值不联动(隔离成立;P75 不读被污染的引擎 time) | +| G0-6 | 受控面契约零改(插件零改可跑) | smoke 门:`window.__probe()` 的 `initOk===true` + 六锚点齐 + `verify.ok===true` + `uncaught.length===0` | 全过(real 通道下插件经 engine.mainContext 渲染,受控面 7 项未改仍工作) | +| G0-7 | 渲染落在 2D mainContext(非空 WebGL 空画布) | real 通道抓引擎 mainCanvas(`#game-engine`)ImageData → 非空 + 非全黑 | 有非黑内容(证 setGLEnable(false) 后粒子真渲进 2D mainContext,readback 有像素) | +| G0-8 | 回滚桩仍活 | `?engine=stub` 跑 integration-run → 与 α 证据等价 | stub 通道 PASS(回滚点活着) | + +### 10.2 本机段(6c6g,禁 chrome,只验可纯 node/esbuild 验的部分) + +```bash +# cwd=/root/games-development-ai/game-runtime +# [本机-1] 受控面/插件零改回归(node --test 全绿,证 A0 不碰 plugin.js/api.d.ts 行为) +bash scripts/test.sh # 或 node --test(执行版纪律:lane 本机零依赖 node --test 必须全绿) + +# [本机-2] 引擎入包 + 门1① metafile(本机有 esbuild,可验证 import 解析与入产物,不跑浏览器) +npm i -D esbuild # 本机若未装(纪律上构建归集成段,但 metafile 生成是纯 node 可本机验) +node scripts/build.mjs host-dev/entry.js dist/host-bundle.js --global-name=GameRuntimeHostDev +# → 期望:成功产出 host-bundle.js(不报 box2d.wasm resolve 失败); +# → grep 产物含引擎符号(证 littlejsengine 真入包,非名义 import): +grep -c "engineInit\|requestAnimationFrame" dist/host-bundle.js # >0 +# → metafile(门1①,需 build.mjs additive 加 metafile:true 后): +grep -o "littlejsengine/dist/littlejs.esm.js" dist/host-bundle.js.meta.json | head # 命中=真入包 + +# [本机-3] box2d 未触发确认(静态):产物里 box2dInit 存在但无调用点 +grep -c "box2dInit" dist/host-bundle.js # 存在(死代码) +grep -c "await Box2D\|\.wasm" dist/host-bundle.js # Box2D() 引用在、但 .wasm 字面量不应作 fetch URL 被打入 +``` + +> 本机**不可**验:G0-1~G0-7(需真 engineInit + 真 RAF + 真 ImageData,禁 chrome → 上 mini-desktop)。本机只兜「node --test 绿 + 引擎真入包 + box2d 不炸 build」。 + +### 10.3 mini-desktop 段(真引擎 + 真 RAF + 真像素,门0 主考) + +```bash +# 在 mini-desktop(co-located 服务+chrome,全 localhost)。沿用 α 的 systemd-run launcher 起 static-serve + chrome --headless=new --remote-debugging-port=9222。 +# cwd=game-runtime/,构建产物已 serve 于 http://localhost:4321/ + +# [desktop-1] 门0 接管断言(real 通道,新增门0 driver 或扩 integration-run): +# 开 http://localhost:4321/index.html?seed=0x1234abcd&engine=real&mode=evidence +# 经 CDP evaluate 取证: +# await wait(window.__hostBootedReal===true) # G0-1 +# s0=window.__engine.snapshot(); await delay(500); s1=window.__engine.snapshot() +# assert s1.frame>s0.frame # G0-2 +# assert |s1.time - s1.frame/60| < 0.05 # G0-3 +# assert s1.timeReal>s0.timeReal # G0-4 +# assert s1.time 读 LJS.time、probe tMono 读 mockNowMs,两源独立 # G0-5 + +# [desktop-2] 门0 渲染落 2D(G0-7): +# reset → vfxFrame('burst',1001,12)(real:await Promise)→ captureImageData('#game-engine') +# assert 非空 + 直方图有非黑主色(粒子真渲进 mainContext) + +# [desktop-3] 门0 受控面零改回归(G0-6): +# 开 ...?engine=real&mode=smoke → window.__probe() +# assert initOk && verify.ok && uncaught.length===0 && 六锚点齐 + +# [desktop-4] 回滚桩活(G0-8): +# node test/harness/integration-run.cjs 4321 (?engine=stub 通道,或 driver 显式带 engine=stub) +# assert INTEGRATION_EVIDENCE: PASS(与 α 收口报告等价) +``` + +> 门0 判定阈值(G0-2 帧增量≥多少、G0-3 偏差容忍)**A6 在 harness 里定死「唯一命令/样本数/fail 条件」**(β spec 门9 要求每门给验证方法)——A0 给出上述判据骨架,具体阈值(如「500ms 内 frame 增量 ≥ 20」)由 A6 实测定(openQuestion)。 + +--- + +## 11. 受控面不变性论证(A0 只改 host backing,契约零改) + +**论证:A0 全部改动落在 `host-dev/host.js`(host 实现)+ `entry.js`(import)+ `build.mjs`(additive metafile),`src/core/api.d.ts` 与 `src/core/plugin.js` 零改**—— + +| 受控面成员 | A0 后 backing | 契约签名 | 不变性论证 | +|---|---|---|---| +| `getContext2d()` | `engine.mainContext`(real)/ `#game` ctx(stub) | `():CanvasRenderingContext2D\|null` | 契约只约定「返回 2D ctx」,不约定来源;real 经路 B 一次性快照引擎 mainContext,仍是合法 2D ctx。插件经 `context.getContext2d()` 拿到一张 2D ctx,零改。 | +| `onFrame(cb)` | 引擎 `gameUpdate` 内调 `bundle.tick(1/60)` 驱动(real)/ host RAF(stub) | `(cb:(dt,frame)=>void):FrameHandle` | `bundle.tick` 派发 onFrame 的逻辑(plugin.js:510-523)零改;只是「谁调 tick」从 host RAF 变引擎 gameUpdate。dt 仍恒 1/60,frame 仍单调——回调形态不变,particles dt 敏感性满足。 | +| `getInput()` | host inputBridge(监听挂引擎 mainCanvas,real) | `():InputSource` | inputBridge 实现(plugin.js:236-353)零改;只是 host 监听目标从 `#game` 变引擎 mainCanvas,坐标仍归一化到 390×844 逻辑像素。getInput 契约不变(A1 才改引擎态透传)。 | +| `getAudioContext()` | host audioFactory(A0 不动) | `():AudioContext\|null` | 音频链零改(§5)。 | +| `time` | host mockNowMs(A0 不动,A1 才透传引擎 time) | `TimeSource{nowMs,elapsedMs}` | ctx.time backing 不动;引擎钟另经 `window.__engine`(host 私有,非契约)暴露给 probe。 | +| `random` | per-plugin 派生 mulberry32(A0 不动) | `RandomSource{next,range,reseed}` | 随机链零改(引擎 RandomGenerator 透传是 A1)。 | +| `getEngine()` | host-dev 缺省 null(A0 不注 engineFactory) | `():EngineCapabilities\|null` | engineFactory 缺省 null → getEngine 返 null,插件容错降级(plugin.js:438-456 零改)。真背书 A1+。 | + +**Q4 铁律兑现**:`import * as LJS from 'littlejsengine'` 只出现在 `entry.js` + `host.js`(集成段 host),**未**进 `api.d.ts`/`plugin.js`/任何插件 impl。`window.__engine` 是 host 私有探活口(非受控面 PluginContext 成员),不破「引擎可换」抽象边界。 + +--- + +## 12. 回滚策略 + +- **flag 回滚**:`?engine=stub`(URL 即时)或 `getParam('engine','real')` 默认改 `'stub'`(一行)→ host 行为回 α 桩(`#game` 2D canvas + 浏览器 RAF + bundle.tick),逐行等价。 +- **commit 回滚**:A0 单独 commit(β spec §11),`git revert` 该 commit 即移除引擎 import + 五回调 + 通道分流,回 α host.js。entry.js 的 `import * as LJS` 与 `export const Engine` 一并 revert。`build.mjs` 的 metafile 是 additive(移除不破构建)。 +- **桩活保证**:回滚点「活着」的实测=`?engine=stub` 跑 integration-run 与 α 收口报告证据等价(§8 / §10.2 desktop-4)。 +- **失败处置(β spec §11)**:A0 引擎接管不通=真接线全阻塞 → 回桩 + 本波降级(**须创始人裁**,因 0 字节现状不可接受,但保留可回滚点)。引擎某能力不达标=记「缺件补层」+标引擎缺口(留引擎复议权,终裁包 §4.5-3),**不**回退「有意替代」自研(Q4 禁)。 + +--- + +## 13. 遗留风险(按严重度) + +| # | 风险 | 影响 | 处置 / 归属 | +|---|---|---|---| +| R-A | **real 模式确定性逐像素哈希不可「手摇定量推帧」** | A6 像素门「同 frame→同像素」须靠「等引擎 RAF 推到目标 frame 再抓」,范式与 α 不同;追帧数 N 抖动若使「凑齐 frames 逻辑步」的动画帧数变,仍因逻辑步内容确定(time=frame/60 纯函数)→ 像素一致,但**须 mini-desktop 实证** | A0 给双轨入口(stub 同步兜回归 / real async 证掌帧);逐帧哈希以 stub 通道为确定性基线,real 通道证掌帧+渲染落 2D。**openQuestion**:real 逐像素哈希×N 是否稳定,A6 实测定 N/目标 frame 清单 | +| R-B | **WebGL 默认 true,须 `setGLEnable(false)` 才有 2D 像素** | 不关 WebGL 则 mainCanvas(2D ctx)空、glCanvas(WebGL)才有像素,`getImageData` 读空 | A0 real 通道 `engineInit` 前 `setGLEnable(false)`(§2.4)→ 纯 2D 渲染进 mainCanvas,readback 无 WebGL `preserveDrawingBuffer` 坑。**已核验导出名 `setGLEnable`(非 `setGlEnable`)** | +| R-C | **引擎自建 mainCanvas,不采用 `#game`;CDP 选择器须改** | `captureImageData('#game')` 抓空 canvas | A0 给引擎 mainCanvas 打 id `#game-engine`(§3.4 法①)+ `__hostdev.canvas()`;harness driver `CANVAS` 改 `#game-engine`(集成段配套,非冻结件) | +| R-D | **插件按逻辑像素 390×844 绘制,引擎 mainContext 是物理像素 780×1688 口径(无 DPR 变换)** | 不补变换则粒子只覆盖左上 1/4 | A0 在 renderParticles/renderPostFx 对 engine.mainContext 补 `setTransform(DPR,...)`(§3.2),保插件逻辑像素口径不变(受控面坐标系不变性兑现) | +| R-E | **引擎 import 即 `new AudioContext`(双 context)** | A0 期 host audioFactory 另建 context,与引擎单例并存;getAudioContext 走 host 那个,引擎那个空转 | A0 音频链零改(§5),功能不错(各自独立);A1 收敛 getAudioContext backing 到引擎单例。**双 context 在无手势下浏览器 warning(非 error),门0 smoke「零未捕获错误」须核对是 warning** | +| R-F | **引擎自带 9 document 输入监听不可关(inputInit 无开关)** | A0 期与 host inputBridge 并存(引擎那套 A0 无人消费,空转),仅 preventDefault/audio-resume 重复(幂等无害) | A0 host 监听改挂引擎 mainCanvas 保坐标一致(§4);真收敛到引擎单源是 A1(getInput 改引擎态透传) | +| R-G | **box2d 死代码入产物撑大 raw** | 产物 raw 比预期多 box2d JS 类(~数十 KB);但 `box2dInit` 永不调、`.wasm` 不被 import/fetch | A5 SIZES 增量法重锚计入;A0 仅记。esbuild 正常处理(自由全局 `Box2D` 不报 resolve,§9.2 已核验) | +| R-H | **bootHostDev 变异步(real 模式 __hostdev 延迟挂载)** | CDP/index.html 取证须等 `window.__hostBootedReal` 再驱动 `__hostdev`;`bootHostDev` 返回值从 hostdev 变占位 `{pending:true}` | A0 在 index.html 探活用 `__hostBooted`(同步置),real 就绪用 `__hostBootedReal`;harness driver 等就绪标记(集成段配套)。**注意**:index.html:76 `window.__host = G.bootHostDev(...)` 拿到的是占位对象——若有代码依赖其同步返回 hostdev,须改等 `window.__hostdev`(§2.4 已留痕) | +| R-I | **门0 判定阈值未定** | G0-2 帧增量阈值 / G0-3 time 偏差容忍 / A6 像素门 N 未定 | A6 在 harness 定死「唯一命令/样本数/fail 条件」(β spec 门9);A0 给判据骨架(§10.1) | +| R-J | **engineInit 二次调用守卫(`ASSERT(!mainContext)` + 运行时 `if(mainContext)return`)** | 若页面/测试重复 boot 会被引擎静默拒绝(return),mainContext 复用上次 | A0 real 通道 `bootHostDev` 每页一次;CDP 每取证开独立 target(integration-run 现状已是独立 target),不重复 engineInit。**风险低**,记此防 A6 误重入 | + +--- + +## 14. 编辑清单汇总(供执行核对) + +| 文件 | 改动 | old→new 锚 | +|---|---|---| +| `host-dev/entry.js` | 加 `import * as LJS from 'littlejsengine'` + `export const Engine=LJS` | :16-17 追加 import;:21-22 追加 export(§1.2) | +| `host-dev/host.js` | 加引擎 import | :29-36 import 块追加 `import * as LJS`(§1.3) | +| `host-dev/host.js` | bundle 延后建 + engineMode flag | :174-193 `const bundle=...` → `let bundle=null` + engineMode(§2.2) | +| `host-dev/host.js` | renderFrame 拆粒子/后处理 + renderCtx | :297-316 renderFrame → renderParticles/renderPostFx/renderFrame(§2.3,含 §3.2 DPR 变换) | +| `host-dev/host.js` | RAF 桩保留为 stub 通道 + 引擎五回调 | :342-368 startRaf/stopRaf/rafLoop 保留 + 追加 engGameInit/Update/UpdatePost/Render/RenderPost(§2.3) | +| `host-dev/host.js` | 输入监听目标按 engineMode | :318-330 监听 `canvas`→`inputTarget`(real=引擎 mainCanvas)(§4.2) | +| `host-dev/host.js` | bootHostDev 主体重排(buildBundle/setupAfterEngine + 通道分流 + engineInit) | :188-203 移入 buildBundle;:205-562 大段移入 setupAfterEngine;:564-581 收尾改通道分流(§2.4) | +| `host-dev/host.js` | window.__engine 暴露引擎钟(门0) | setupAfterEngine 内 real 分支新增(§6.1) | +| `host-dev/host.js` | 引擎 mainCanvas 打 id + __hostdev.canvas() | setupAfterEngine 内 `LJS.mainCanvas.id='game-engine'` + hostdev.canvas(§3.4) | +| `host-dev/host.js` | vfxFrame/paletteFrame/reset real 异步分支 | :486-552 各加 `if(engineMode==='real')` async 分支(§7.2/7.3) | +| `scripts/build.mjs` | additive metafile(门1①) | build() 内 esbuild.build 加 `metafile:true` + 落 meta.json(§9.1,additive) | +| `test/harness/integration-run.cjs` | CANVAS 选择器 + real 通道 await(集成段配套,非冻结件) | :30 `CANVAS='#game'`→`'#game-engine'`;evaluate 处 await async vfxFrame(§3.4/§7,**A6 改,非 A0 主体**) | + +> **冻结件不动**:`src/core/plugin.js`、`src/core/api.d.ts`、`src/plugins/runtime-probe/impl.js`(及其它插件 impl)—— A0 全程零改(§11 论证)。 diff --git a/game-runtime/host-dev/entry.js b/game-runtime/host-dev/entry.js index 1005d88c..0343b719 100644 --- a/game-runtime/host-dev/entry.js +++ b/game-runtime/host-dev/entry.js @@ -15,8 +15,14 @@ import * as RT from '../src/all-plugins.js'; import { bootHostDev } from './host.js'; +// 【A0:引擎主循环接管】littlejsengine import 落点之一(集成段 host,与 host.js 并列)。 +// Q4 铁律:引擎 import 只许活在集成段 host(entry.js/host.js),禁泄进受控面契约/插件。 +// host.js 自行 import * as LJS 作引擎掌帧实现;此处 export 仅为 DevTools 控制台手验引擎导出便利。 +import * as LJS from 'littlejsengine'; // 暴露到全局:index.html 调 bootHostDev 启动;其余符号便于 DevTools 控制台手验。 // (esbuild globalName 也会把本模块的默认/具名导出挂全局,这里再显式聚合一份语义清晰的入口。) export { bootHostDev }; export const Runtime = RT; +// A0:引擎名字空间挂入口,便于控制台手验引擎导出(host.js 才是引擎接管的真实现体)。 +export const Engine = LJS; diff --git a/game-runtime/host-dev/host.js b/game-runtime/host-dev/host.js index 5328bb7d..7bd20c9e 100644 --- a/game-runtime/host-dev/host.js +++ b/game-runtime/host-dev/host.js @@ -34,6 +34,14 @@ import { createRuntimeProbePlugin, CORE_PROTOCOL_VERSION, } from '../src/all-plugins.js'; +// 【A0:引擎主循环接管】littlejsengine import 唯一落点之一(集成段 host,与 entry.js 并列)。 +// Q4 铁律:禁把引擎 import 泄进受控面契约(api.d.ts/plugin.js)或任何插件 impl;只活在本 host。 +// esbuild 走 package main=dist/littlejs.esm.js(ESM,开发构建);box2d 惰性(box2dInit 永不调)→ 不引 wasm。 +// 名字空间整体导入(namespace import 不阻塞 tree-shaking 的运行时正确性,仅文档完整性): +// engineInit / setGLEnable / setShowSplashScreen / setCanvasFixedSize / setDebugWatermark / +// setCanvasClearColor / BLACK / vec2 / mainCanvas / mainContext / frame / time / timeReal / +// paused / timeScale 等全经 LJS.* 触达。 +import * as LJS from 'littlejsengine'; /* ────────────────────────────────────────────────────────────────────────── * 取证固定环境常量(与 test/harness/browser-evidence.cjs 的 FIXED_ENV 对齐) @@ -171,10 +179,15 @@ export function bootHostDev(opts) { return realAudioCtx; } - // 创建 host-dev 受控上下文:注入真 2D ctx / 主种子 / 音频工厂。 - // 时钟:取证用步进时钟(见下 mockClock),真实 RAF 模式则用墙钟——这里给步进时钟, - // 真实模式由 RAF 推帧但时间读数仍走步进时钟,保证「插件读 time 与帧推进一致、且可被取证接管」。 - let mockNowMs = 0; // 步进时钟读数(毫秒),由 tick/推帧推进。 + // 创建 host-dev 受控上下文:注入「渲染面 / 主种子 / 音频工厂」。 + // 【A0 改点】受控面 getContext2d 的 backing:real 模式=引擎 engineInit 自建的 mainContext(纯 2D, + // setGLEnable(false) 后引擎只渲 2D 进 mainCanvas);stub 模式=host prepareCanvas(#game) 的 ctx2d(回滚桩)。 + // 因 engineInit 异步,real 模式 bundle 在「引擎就绪后」创建(见 setupAfterEngine),getContext2d + // 一次性快照到真 mainContext(受控面 plugin.js 的 getContext2d 读闭包捕获值,故必须就绪后建 bundle)。 + // 【时钟】A0 仍用 host 步进钟 mockNowMs 作 ctx.time backing(受控面零改,A1 才透传引擎 time)。 + // ⚠️ real 模式下 mockNowMs 由 engGameUpdate 每逻辑步累加 FIXED_DT*1000,本质是「逻辑帧钟」(=frame×16.67ms), + // **不是墙钟**;门0 的 P75 埋点候选钟应读引擎 timeReal(纯墙钟,经 window.__engine.timeReal 暴露),不读 mockNowMs。 + let mockNowMs = 0; // 步进时钟读数(毫秒):stub=墙钟近似(rafLoop)/evidence(stepFrames)+real(engGameUpdate)=逻辑帧钟。 // 记录所有经取证入口 spawn 的 continuous 发射器 id,reset() 时统一 stopEmitter 防其持续喷发。 /** @type {Set} */ const activeEmitterIds = new Set(); @@ -185,22 +198,48 @@ export function bootHostDev(opts) { // 在注册器为每插件派生上下文时记下该插件的 random 引用,供 vfxFrame/paletteFrame 取证前 reseed。 /** @type {Record} */ const derivedRandoms = {}; - const bundle = createHostDevContext({ - context2d: ctx2d, - seed, - audioFactory, - clock: () => mockNowMs, - }); + // 引擎通道选择:?engine=real(缺省,引擎掌帧地基)| ?engine=stub(回滚桩,行为与 α 逐行等价,创始人裁)。 + const engineMode = getParam('engine', 'real'); + // bundle 改为延后创建(real:engineInit 就绪后;stub:立即)。所有 bundle.xxx 引用须在 setupAfterEngine 内执行。 + /** @type {ReturnType|null} */ + let bundle = null; + // 当前绘制面(real=engine.mainContext;stub=host ctx2d)。由 setupAfterEngine 设定。 + /** @type {CanvasRenderingContext2D|null} */ + let renderCtx = null; - // 合法包裹 bundle.deriveContextFor(host 拥有 bundle):注册器为每插件派生上下文时,记下该插件的 - // 派生 random 实例引用。这样 host 取证时能复位「插件那一份」随机(见 derivedRandoms 注释)。 - // 不改 core,不污染受控面——只是 host 在自己持有的 bundle 上挂一层记账。 - const _origDerive = bundle.deriveContextFor.bind(bundle); - bundle.deriveContextFor = function (pluginName, scope) { - const ctx = _origDerive(pluginName, scope); - derivedRandoms[pluginName] = ctx.random; // 记下该插件专属随机实例 - return ctx; - }; + /** + * 建 bundle(受控上下文)并挂 deriveContextFor 记账层。renderCtxArg=绘制面(real=engine.mainContext / stub=ctx2d)。 + * 必须在「绘制面就绪」后调(real 模式 = engineInit().then 之后;stub 模式 = 立即)。 + * @param {CanvasRenderingContext2D} renderCtxArg + */ + function buildBundle(renderCtxArg) { + const b = createHostDevContext({ + context2d: renderCtxArg, // 受控面 getContext2d 一次性快照此 ctx(real=引擎 mainContext / stub=host #game) + seed, + audioFactory, + clock: () => mockNowMs, + }); + // 合法包裹 bundle.deriveContextFor(host 拥有 bundle):注册器为每插件派生上下文时,记下该插件的 + // 派生 random 实例引用。这样 host 取证时能复位「插件那一份」随机(见 derivedRandoms 注释)。 + // 不改 core,不污染受控面——只是 host 在自己持有的 bundle 上挂一层记账。 + const _origDerive = b.deriveContextFor.bind(b); + b.deriveContextFor = function (pluginName, scope) { + const ctx = _origDerive(pluginName, scope); + derivedRandoms[pluginName] = ctx.random; // 记下该插件专属随机实例 + return ctx; + }; + return b; + } + + /** + * 【A0 编排核心】引擎/桩就绪后的统一收尾:建 bundle → 注册插件 → 驱动 probe 六锚点 → 挂 __hostdev → 按 mode 收尾。 + * 两条通道都调它:real(engineInit().then 内,renderCtxArg=LJS.mainContext)/ stub(立即,renderCtxArg=ctx2d)。 + * 引擎掌帧的必然结构:引擎先起、canvas/mainContext 才有,故 bundle 与所有依赖 bundle 的编排都在此函数内。 + * @param {CanvasRenderingContext2D} renderCtxArg 绘制面 + */ + function setupAfterEngine(renderCtxArg) { + renderCtx = renderCtxArg; // 设当前绘制面(renderParticles/renderPostFx/renderFrame 读它) + bundle = buildBundle(renderCtxArg); // 建 bundle(context2d=绘制面) // 注册 8 件业务能力插件 + _example(第 9 件,凑「全九插件」覆盖)。 // runtime-probe **不**走批量注册(__skip):它需由 host 按「启动六锚点」canonical 顺序手动驱动—— @@ -294,38 +333,75 @@ export function bootHostDev(opts) { else bundle.context.random.reseed(s >>> 0); // 兜底(不应触发) } - /** 把当前帧的表现层(粒子 + 后处理叠加)渲染到 canvas。底已在每帧重铺黑。 */ - function renderFrame() { - clearBlack(ctx2d); // 每帧重铺黑底,避免上一帧残留干扰像素哈希 - // 粒子层 + /** + * 画粒子层(引擎 gameRender 回调里调;桩模式 renderFrame 里调)。host 提供可见色(粒子不内置颜色)。 + * 【A0】绘制面=renderCtx(real=engine.mainContext / stub=host ctx2d)。 + * 坐标系兑现:real 模式引擎 mainContext **无 DPR 变换**(引擎 updateCanvas 每帧把变换重置为单位阵), + * 而插件按 390×844 逻辑像素绘制 → host 在 save/restore 内补 setTransform(DPR) 保「逻辑像素口径不变」。 + * imageSmoothingEnabled=false:关 2D 亚像素插值平滑(real 2D 哈希稳定的真旋钮;引擎 glSetAntialias 对 2D 是 no-op)。 + */ + function renderParticles() { + const g = renderCtx; + if (!g) return; const pj = /** @type {any} */ (instances['particles-juice']); if (pj && typeof pj.render === 'function') { - // 粒子插件**不内置颜色**(engine 级,颜色是 agent/host 的生成域,符合模板哲学红线): - // render() 只设 globalAlpha + arc + fill,沿用当前 fillStyle。故 host 作为「引擎」在此提供可见色, - // 否则粒子会以上次 fillStyle(clearBlack 设的黑)画在黑底上 → 不可见(取证全黑)。 - ctx2d.save(); - ctx2d.fillStyle = '#7fd0ff'; // 中性亮色(host 提供,非插件内置;仅为取证可见) - try { pj.render(ctx2d); } catch (e) { uncaught.push({ type: 'render', message: 'particles:' + String(e && e.message || e) }); } - ctx2d.restore(); - } - // 后处理叠加层(vignette/dither/scanline) - const pp = /** @type {any} */ (instances['palette-post']); - if (pp && typeof pp.renderPost === 'function') { - try { pp.renderPost(ctx2d); } catch (e) { uncaught.push({ type: 'render', message: 'palette:' + String(e && e.message || e) }); } + g.save(); + if (engineMode === 'real') { + g.setTransform(DPR, 0, 0, DPR, 0, 0); // 引擎 mainContext 无 DPR 变换,host 补,保插件逻辑像素口径 + try { g.imageSmoothingEnabled = false; } catch { /* 部分环境只读,忽略 */ } + } + g.fillStyle = '#7fd0ff'; // 中性亮色(host 提供,非插件内置;仅为取证可见)——语义同 α + try { pj.render(g); } catch (e) { uncaught.push({ type: 'render', message: 'particles:' + String(e && e.message || e) }); } + g.restore(); } } + /** 画后处理叠加层(引擎 gameRenderPost 回调里调;桩模式 renderFrame 里调)。real 模式同补 DPR 变换。 */ + function renderPostFx() { + const g = renderCtx; + if (!g) return; + const pp = /** @type {any} */ (instances['palette-post']); + if (pp && typeof pp.renderPost === 'function') { + g.save(); + if (engineMode === 'real') { + g.setTransform(DPR, 0, 0, DPR, 0, 0); // palette-post 也按逻辑像素铺 + try { g.imageSmoothingEnabled = false; } catch { /* 忽略 */ } + } + try { pp.renderPost(g); } catch (e) { uncaught.push({ type: 'render', message: 'palette:' + String(e && e.message || e) }); } + g.restore(); + } + } + /** 桩模式渲染一帧(合并粒子+后处理,底由 host 自铺黑)。引擎模式不用它(引擎分回调驱动 + 引擎清屏)。 */ + function renderFrame() { + if (renderCtx) clearBlack(renderCtx); // 桩模式每帧重铺黑底,避免上一帧残留干扰像素哈希 + renderParticles(); + renderPostFx(); + } + // 把渲染/步进实现暴露给「bootHostDev 作用域的引擎五回调」(引擎掌帧时由引擎内部 RAF 驱动)。 + // engStep:引擎每逻辑步推进一次 bundle(固定步长 1/60,onFrame dt 须恒定,particles 时步确定)。 + _renderParticles = renderParticles; + _renderPostFx = renderPostFx; + _engStep = function () { + if (!bundle) return; + mockNowMs += FIXED_DT * 1000; // host 步进钟随引擎逻辑步同步推进(real 模式此钟=逻辑帧钟,非墙钟) + bundle.tick(FIXED_DT); // 驱动所有插件 onFrame(含 particles 自动 step) + }; /* ── 真实输入桥接:DOM 事件 → 归一化坐标 → core 输入桥 _emit ── 注意:core 的 HostDevInputBridge 时间戳取自受控时间源(步进时钟),不取原生 event.timeStamp。 - 这里把真实交互喂进受控面,使 gamefeel 输入缓冲 / probe 的 firstInput 都能被真实 pointer 触发。 */ + 这里把真实交互喂进受控面,使 gamefeel 输入缓冲 / probe 的 firstInput 都能被真实 pointer 触发。 + 【A0】real 模式监听挂「引擎 mainCanvas」(坐标按引擎 canvas rect,与引擎坐标系一致),不挂 #game/window, + 避免与引擎自带 document 监听坐标错位;引擎那套 document 监听 A0 期无人消费(getInput 仍走本桥), + 故不构成受控面语义双发(真正收敛到引擎单源是 A1)。keydown/keyup 仍挂 window(键盘无 canvas rect 问题)。 */ const inputBridge = bundle.inputBridge; + // 输入事件目标 canvas:real=引擎 mainCanvas(就绪后存在);stub=host #game。 + const inputTarget = (engineMode === 'real' && LJS.mainCanvas) ? LJS.mainCanvas : canvas; function onPointer(type, ev) { - const { x, y } = toCanvasXY(canvas, ev.clientX, ev.clientY); + const { x, y } = toCanvasXY(inputTarget, ev.clientX, ev.clientY); inputBridge._emit(type, { x, y }); } - canvas.addEventListener('pointerdown', (ev) => { unlockAudioOnce(); onPointer('pointerdown', ev); }); - canvas.addEventListener('pointermove', (ev) => onPointer('pointermove', ev)); - canvas.addEventListener('pointerup', (ev) => onPointer('pointerup', ev)); + inputTarget.addEventListener('pointerdown', (ev) => { unlockAudioOnce(); onPointer('pointerdown', ev); }); + inputTarget.addEventListener('pointermove', (ev) => onPointer('pointermove', ev)); + inputTarget.addEventListener('pointerup', (ev) => onPointer('pointerup', ev)); window.addEventListener('keydown', (ev) => { unlockAudioOnce(); inputBridge._emit('keydown', { key: ev.key }); }); window.addEventListener('keyup', (ev) => inputBridge._emit('keyup', { key: ev.key })); @@ -339,8 +415,9 @@ export function bootHostDev(opts) { } } - /* ── 真实 RAF 循环(交互模式):把墙钟 dt 钳制后推进 ── - 取证模式不依赖它(取证用 stepFrames 定量推进);但交互演示需要它让画面动起来。 */ + /* ── 回滚桩:浏览器 RAF 循环(仅 engineMode==='stub' 用,保留可回滚点,创始人裁)── + real 模式不走它——引擎内部 RAF 驱动 bootHostDev 作用域的引擎五回调(engGameUpdate/Render/...)。 + 取证模式不依赖它(取证用 stepFrames 定量推进);交互演示需要它让画面动起来(stub 通道沿 α 用墙钟 dt)。 */ let rafId = 0; let lastTs = 0; let rafRunning = false; @@ -351,7 +428,7 @@ export function bootHostDev(opts) { lastTs = ts; if (dt > 0.05) dt = 0.05; // dt 封顶钳制(防卡顿后大跳) mockNowMs += dt * 1000; - bundle.tick(dt); + if (bundle) bundle.tick(dt); renderFrame(); rafId = window.requestAnimationFrame(rafLoop); } @@ -495,8 +572,23 @@ export function bootHostDev(opts) { const id = pj.spawnEmitter(preset, ox, oy); // continuous 预设(trail/drift)返回长效 id;记下,reset() 时统一 stopEmitter 防其持续喷发。 if (typeof id === 'number' && id >= 0) activeEmitterIds.add(id); - stepFrames(frames); - return { particleCount: pj.particleCount(), frame: bundle.frameCount() }; + if (engineMode === 'stub') { + // 桩通道:手摇定量推帧(确定性逐像素,原样 α 行为)。逐像素哈希回归由本通道独占兜底。 + stepFrames(frames); + return { particleCount: pj.particleCount(), frame: bundle.frameCount(), engineMode }; + } + // 【A0 P0 修正】real 通道**不承担逐像素确定性**(由 stub 通道兜)。 + // 原因(已核验 littlejs.esm.js:256 `for(;frameTimeBufferMS>=0;frameTimeBufferMS-=1e3/frameRate)`): + // 引擎一个 RAF 内的追帧步数 N 由墙钟抖动决定,外部轮询 `frameCount>=startFrame+frames` 会 OVERSHOOT + // (从 frames-1 一跳到 frames+2),采到哪一帧不确定 → same-seed 重跑像素不一致。故 real 不做 `>=`+异步采样。 + // real 通道只证「引擎掌帧 + 渲染落 2D mainContext(门0 G0-2/G0-7)」,不证 same-pixel。 + // 这里仅同步推进 host 粒子状态 frames 步(不调 renderFrame——引擎内部 RAF 自渲),使粒子场就绪; + // 屏幕像素由引擎下一拍 gameRender→renderParticles 落到 mainContext。 + for (let i = 0; i < frames; i++) { + mockNowMs += FIXED_DT * 1000; + bundle.tick(FIXED_DT); + } + return { particleCount: pj.particleCount(), frame: bundle.frameCount(), engineMode, perPixelDeterministic: false }; }, /** * 后处理像素证据入口:开/关某后处理效果并渲染,返回时画面即该状态。 @@ -519,8 +611,16 @@ export function bootHostDev(opts) { reseedParticleRandom(effSeed >>> 0); const id = pj.spawnEmitter('drift', VIEWPORT_W / 2, VIEWPORT_H / 2); if (typeof id === 'number' && id >= 0) activeEmitterIds.add(id); - stepFrames(frames); - return { effect, enabled, frame: bundle.frameCount() }; + if (engineMode === 'stub') { + stepFrames(frames); // 桩:逐像素确定(开/关两帧除目标后处理外一致),原样 + return { effect, enabled, frame: bundle.frameCount(), engineMode }; + } + // 【A0 P0 修正】real 通道不承担逐像素确定性(同 vfxFrame,避免 overshoot)。仅推进 host 状态,引擎自渲。 + for (let i = 0; i < frames; i++) { + mockNowMs += FIXED_DT * 1000; + bundle.tick(FIXED_DT); + } + return { effect, enabled, frame: bundle.frameCount(), engineMode, perPixelDeterministic: false }; }, /** * 复位渲染状态到「干净黑底 + 无后处理 + 随机源回主种子 + 无活动粒子/发射器」。 @@ -538,13 +638,15 @@ export function bootHostDev(opts) { } activeEmitterIds.clear(); pp.setPost({ vignette: { enabled: false }, dither: { enabled: false }, scanline: { enabled: false } }); - // 2) 不再 spawn,连续推帧让现存粒子消亡。 + // 2) 不再 spawn,连续推帧让现存粒子消亡(手调 bundle.tick,粒子状态是 host 侧 bundle 管的, + // real 模式同样有效;屏幕清空 real 靠引擎下一拍 updateCanvas 重设 width,stub 靠下方 clearBlack)。 const n = typeof extraFrames === 'number' ? extraFrames : 220; for (let i = 0; i < n; i++) { mockNowMs += FIXED_DT * 1000; bundle.tick(FIXED_DT); } - clearBlack(ctx2d); + // 桩模式 host 自铺黑清屏;real 模式引擎每帧自清(updateCanvas 重设 mainCanvas.width),此处仅对 stub 生效。 + if (engineMode === 'stub' && renderCtx) clearBlack(renderCtx); // 回主种子(默认 context.random 与粒子插件随机都复位,便于下一组从确定起点取)。 bundle.context.random.reseed(seed >>> 0); reseedParticleRandom(seed >>> 0); @@ -558,8 +660,34 @@ export function bootHostDev(opts) { unlockAudio: unlockAudioOnce, /** 当前是否已解锁音频。 */ audioUnlocked() { return audioUnlocked; }, + /** 取当前绘制面对应的 canvas 引用(real=引擎 mainCanvas / stub=host #game)。供 CDP 抓正确画布。 */ + canvas() { return (engineMode === 'real' && LJS.mainCanvas) ? LJS.mainCanvas : canvas; }, }; - window.__hostdev = hostdev; + + // 【A0/门0 R-C】real 模式给引擎自建 mainCanvas 打 id,CDP 取证选择器用 #game-engine(不是退役的 #game)。 + if (engineMode === 'real' && LJS.mainCanvas) { + LJS.mainCanvas.id = 'game-engine'; + } + + // 【A0/门0 P1】暴露引擎钟给 probe/门0 实测脚本(host 私有探活口,非受控面 PluginContext 成员,不破抽象边界)。 + // 双时钟物理隔离: + // · engine.time=污染钟(受 timeScale/暂停,littlejs.esm.js:259 time=frame++/frameRate)——仅作「接管断言」旁证。 + // · engine.timeReal=纯墙钟(:217 timeReal+=frameTimeDeltaMS*debugScale/1e3,不受 pause/timeScale/帧率钳制) + // ——这才是门9 P75 埋点候选钟(real 模式 host mockNowMs 退化为逻辑帧钟,不可作墙钟 P75)。 + // 读自引擎不同导出变量(ESM live binding),互不覆盖。 + if (engineMode === 'real') { + window.__engine = { + frame() { return LJS.frame; }, // 单调帧号 + time() { return LJS.time; }, // 污染钟(timeScale/暂停影响)——接管断言用 + timeReal() { return LJS.timeReal; }, // 纯墙钟——P75 埋点候选(门9 旁路读它,不读 mockNowMs) + paused() { return LJS.paused; }, + timeScale() { return LJS.timeScale; }, + // 门0 一次取齐 + snapshot() { return { frame: LJS.frame, time: LJS.time, timeReal: LJS.timeReal, paused: LJS.paused, timeScale: LJS.timeScale }; }, + }; + } + + window.__hostdev = hostdev; // real 模式异步挂载点(CDP 取证应等 window.__hostBootedReal 再驱动) // ── 执行启动链打点 + 按模式收尾 ── markBootChain(); @@ -568,14 +696,77 @@ export function bootHostDev(opts) { const smokeResult = runSmoke(); window.__smokeResult = smokeResult; } else if (mode === 'evidence') { - // evidence 模式:只初始化,**不** runSmoke、**不** 自动 RAF——画面完全由 window.__hostdev 确定性入口接管, - // 保证 vfx/palette 像素取证「同参数重跑逐像素一致」(不被真实墙钟 RAF 扰动)。 - setStatus('evidence: ready. drive via window.__hostdev. seed=' + seed); + // evidence 模式:只初始化,**不** runSmoke。stub 通道不自动 RAF(确定性取证); + // real 通道引擎内部 RAF 已在跑(画面动),但逐像素确定性由 stub 通道兜(real 不手摇)。 + setStatus('evidence: ready. drive via window.__hostdev. seed=' + seed + ' engine=' + engineMode); } else { - // idle(缺省):启动真实 RAF 让画面可交互(点击屏幕喷粒子 + 解锁音频)。 - setStatus('idle: click canvas to spawn particles. seed=' + seed); - startRaf(); + // idle(缺省):real 模式引擎内部 RAF 已驱动五回调(无需 host startRaf);stub 模式起 host RAF。 + setStatus('idle: ' + (engineMode === 'real' ? 'engine RAF running. ' : '') + 'click canvas to spawn particles. seed=' + seed); + if (engineMode === 'stub') startRaf(); + } + } // ← setupAfterEngine 结束 + + /* ──────────────────────────────────────────────────────────────────────── + * 【A0】引擎五回调(engineMode==='real'):engineInit 接管帧驱动 + 渲染。 + * 这些函数定义在 bootHostDev 作用域(engineInit 须在 setupAfterEngine 之前拿到它们), + * 实体逻辑经模块级 holder(_renderParticles/_renderPostFx/_engStep)转发到 setupAfterEngine 内的真实现。 + * · gameInit:引擎首帧前一次性初始化(host-dev 无真游戏,留痕供 probe 旁证)。 + * · gameUpdate:引擎每逻辑步调一次 → host 固定步长 1/60 推进 bundle(driver=引擎内部 RAF)。 + * · gameUpdatePost:逻辑步后处理钩子(host-dev 无需,留空)。 + * · gameRender:引擎清屏后 → host 画粒子层到 engine.mainContext。 + * · gameRenderPost:渲染末尾 → host 画后处理叠加层。 + * ──────────────────────────────────────────────────────────────────────── */ + /** @type {() => void} */ let _renderParticles = () => {}; + /** @type {() => void} */ let _renderPostFx = () => {}; + /** @type {() => void} */ let _engStep = () => {}; + function engGameInit() { window.__engineGameInitFired = true; } + function engGameUpdate() { _engStep(); } + function engGameUpdatePost() { /* host-dev 无需逻辑步后处理 */ } + function engGameRender() { _renderParticles(); } + function engGameRenderPost() { _renderPostFx(); } + + // ── 通道分流:real(引擎掌帧,地基)| stub(回滚桩,创始人裁)── + if (engineMode === 'stub') { + // 回滚通道:行为与 α 桩逐行等价(host prepareCanvas(#game) + 浏览器 RAF + bundle.tick)。 + setupAfterEngine(ctx2d); + window.__hostBootedReal = false; // 标记非 real 通道 + return window.__hostdev; } - return hostdev; + // real 通道(缺省,A0 地基):引擎掌帧。引擎设置须全部在 engineInit 之前调(守卫多在 engineInit 内)。 + // 1) setGLEnable(false):引擎跳 glCanvas(glInit:8765 守卫),纯 2D 渲进 mainContext,readback 无 WebGL 坑。 + // 2) setShowSplashScreen(false):关开屏遮挡(默认已 false,显式兜底)。 + // 3) setDebugWatermark(false):关右上角 FPS 水印(开发构建默认 true,:1144 每帧画进 mainContext, + // averageFPS 是墙钟派生非确定值 → 污染 readback、击穿「干净取证」前提,**必须显式关**)。 + // 4) setCanvasClearColor(BLACK):引擎默认 clearColor=CLEAR_BLACK(alpha=0),updateCanvas:378 `if(a>0&&!glEnable)` + // 为假 → 不铺底,清屏只靠 mainCanvas.width 重设(置回透明黑)。强制设不透明 BLACK,使底色语义与 α clearBlack 对齐。 + LJS.setGLEnable(false); + LJS.setShowSplashScreen(false); + LJS.setDebugWatermark(false); + LJS.setCanvasClearColor(LJS.BLACK); + // 固定 backing=780×1688(=逻辑 390×844 × DPR2,取证固定环境)。注意:DPR 体现在 backing 锁死, + // CSS 显示尺寸由引擎按窗口 aspect-fit 自管(updateCanvas fixed-size 分支 100%/''),与插件 setTransform(DPR) 无关。 + LJS.setCanvasFixedSize(LJS.vec2(VIEWPORT_W * DPR, VIEWPORT_H * DPR)); + // rootElement:用专用空容器(引擎 engineInit:405 `rootElement.style.cssText=styleRoot` 会**整体覆盖**容器样式, + // 故不能复用承载 #status/#game 的 #wrap,否则其 flex 布局被清。建一个独立 div 挂 body。 + const engineRoot = document.createElement('div'); + engineRoot.id = 'engine-root'; + document.body.appendChild(engineRoot); + // engineInit 异步:resolve 后 mainContext 就绪 → 建 bundle/注册/打链/收尾。 + LJS.engineInit(engGameInit, engGameUpdate, engGameUpdatePost, engGameRender, engGameRenderPost, [], engineRoot) + .then(() => { + setupAfterEngine(LJS.mainContext); // 绘制面=引擎 mainContext + window.__hostBootedReal = true; // CDP 取证就绪标记 + }) + .catch((e) => { + // 引擎接管失败:记错误。【P2 修正】同时镜像到 __hostBootError,让现有 waitForBoot(只查 __hostBootError) + // 即时抛错而非 12s 超时;门0/CDP 据此判 real 失败 → 回滚桩(创始人裁)。 + window.__engineInitError = String((e && e.message) || e); + window.__hostBootError = window.__engineInitError; + setStatus('ENGINE INIT FAILED: ' + window.__engineInitError + '(可加 ?engine=stub 回滚桩)'); + }); + + // 注意:real 模式 __hostdev 在 setupAfterEngine 内异步挂载,故此处返回占位对象。 + // index.html 用 window.__hostBooted(同步置)探活;CDP 取证须等 window.__hostBootedReal 再驱动 __hostdev。 + return { pending: true, engineMode }; } diff --git a/game-runtime/scripts/build.mjs b/game-runtime/scripts/build.mjs index 45061016..35f212d5 100644 --- a/game-runtime/scripts/build.mjs +++ b/game-runtime/scripts/build.mjs @@ -17,6 +17,7 @@ 'use strict'; import { argv, exit } from 'node:process'; +import { writeFileSync } from 'node:fs'; /** 锁定的 esbuild 构建参数(§5 写死,集成段与 size.mjs 必须同口径)。 */ export const LOCKED_BUILD_OPTIONS = { @@ -50,12 +51,19 @@ export async function build(entry, outfile, globalName) { throw new Error('esbuild 不可用(非集成段环境)'); } - await esbuild.build({ + // 【A0/门1① additive】metafile:true 落 .meta.json,供 grep 核验「引擎真入包 + 被哪些 chunk 引用」。 + // additive(不改 LOCKED_BUILD_OPTIONS 锁参口径):移除 metafile 不影响产物字节,仅少一份诊断文件。 + const result = await esbuild.build({ ...LOCKED_BUILD_OPTIONS, entryPoints: [entry], outfile, + metafile: true, ...(globalName ? { globalName } : {}), }); + if (result && result.metafile) { + writeFileSync(outfile + '.meta.json', JSON.stringify(result.metafile)); + console.log(`[build] metafile → ${outfile}.meta.json`); + } console.log(`[build] OK ${entry} → ${outfile} (iife/es2019/min/no-sourcemap)`); } diff --git a/game-runtime/test/harness/gate0-engine-takeover.cjs b/game-runtime/test/harness/gate0-engine-takeover.cjs new file mode 100644 index 00000000..bbd1b8cd --- /dev/null +++ b/game-runtime/test/harness/gate0-engine-takeover.cjs @@ -0,0 +1,157 @@ +/** + * test/harness/gate0-engine-takeover.cjs — A0「引擎掌帧接管」门0 独立 driver(仅 mini-desktop,连本地 chrome via CDP) + * owner:T1b-β A0(地基) + * + * 【为何独立 driver(不复用 integration-run.cjs)】 + * P2 复审 mustFix:A0 门0 不得依赖 A6 尚未改造的 integration-run.cjs(其 CANVAS='#game' 仍指退役画布、 + * real 通道无就绪等待)。本 driver 只读 window.__engine/__hostdev/__probe 做 G0-1~G0-8,**不碰逐像素哈希** + * (real 通道不承担逐像素确定性,由 stub 通道兜——见 host.js vfxFrame/paletteFrame real 分支注释), + * 使 A0 自验闭合(不被 A6 阻塞)。逐像素回归仍走 integration-run.cjs 的 stub 通道(desktop-4)。 + * + * 【前置】(同 integration-run.cjs) + * 外层 launcher(systemd-run)须已起好:static-serve serve 构建产物于 http://localhost:/; + * chrome --headless=new --remote-debugging-port=9222 --no-sandbox --no-zygote --remote-allow-origins=*。 + * 构建产物须为「引擎真入包」的 host-bundle.js(entry.js 经 esbuild,含 littlejsengine)。 + * + * 【产出】(cwd 须为 game-runtime/) + * · evidence/gate0/gate0.json 门0 全断言机读汇总(G0-1~G0-8 各通过与否 + 采样数据) + * 退出码:0=门0 全过;非 0=任一断言失败(不掩盖)。 + * + * 【用法】node test/harness/gate0-engine-takeover.cjs [PORT] [CDP_HTTP] + */ + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const H = require('./browser-evidence.cjs'); + +const PORT = parseInt(process.argv[2] || '4321', 10); +const CDP_HTTP = process.argv[3] || H.DEFAULT_CDP_HTTP; +const BASE = `http://localhost:${PORT}`; +const SEED = 0x1234abcd; // 固定主种子(与 host.js 默认一致;URL 也显式带) +const CANVAS = '#game-engine'; // 【R-C】real 模式引擎自建 mainCanvas 的 id(host.js setupAfterEngine 打) +const OUT_DIR = path.resolve('evidence/gate0'); + +const delay = (ms) => new Promise((r) => setTimeout(r, ms)); + +// 门0 阈值(A6 可在 harness 收紧;此处给 A0 判据骨架的保守区间)。 +const FRAME_WINDOW_MS = 500; // G0-2/G0-4 两次采样间隔 +const FRAME_DELTA_MIN = 20; // G0-2 帧增量下界(@60fps 理论 ~30;排除「单步假推进 delta=1」与卡死) +const FRAME_DELTA_MAX = 45; // G0-2 帧增量上界(排除失控空转) +const TIME_FRAME_TOL = 0.05; // G0-3 |time - frame/60| 容忍(秒) + +const result = { + meta: { ts: new Date().toISOString(), base: BASE, seed: SEED, engineMode: 'real' }, + gates: {}, // G0-1..G0-8 → { pass, detail } +}; +const failures = []; +function gate(id, cond, detail) { + result.gates[id] = { pass: !!cond, detail }; + if (!cond) failures.push(`${id}: ${typeof detail === 'string' ? detail : JSON.stringify(detail)}`); + return !!cond; +} + +async function main() { + fs.mkdirSync(OUT_DIR, { recursive: true }); + + // ── 通道 1:?engine=real&mode=evidence(接管断言 + 渲染落 2D)── + const urlEvidence = `${BASE}/index.html?seed=${SEED}&engine=real&mode=evidence`; + const cdp = await H.connectCdp({ url: urlEvidence, seed: SEED }); + try { + // G0-1:real 通道引擎真起(engineInit().then 跑过 → __hostBootedReal;gameInit 回调触发 → __engineGameInitFired)。 + // connectCdp 的 waitForBoot 已等到 __hostdev 挂载(=engineInit resolve),这里再显式核对两标记。 + const boot = await cdp.evaluate( + '({ bootedReal: window.__hostBootedReal===true, gameInit: window.__engineGameInitFired===true, hasEngine: !!window.__engine, hasHostdev: !!window.__hostdev, engErr: window.__engineInitError||null })' + ); + gate('G0-1', boot && boot.bootedReal && boot.gameInit && boot.hasEngine, boot); + + // G0-2:引擎内部 RAF 真掌帧(frame 单调推进,且增量落合理区间——排除「假桩单步推进」与失控空转)。 + // 【P1 修正】不止 t1>t0:加帧增量下界(≥20)+ 上界(≤45),抗「gameUpdate 只跑 1 次」骗过。 + const s0 = await cdp.evaluate('window.__engine.snapshot()'); + await delay(FRAME_WINDOW_MS); + const s1 = await cdp.evaluate('window.__engine.snapshot()'); + const dFrame = (s1 && s0) ? (s1.frame - s0.frame) : -1; + gate('G0-2', dFrame >= FRAME_DELTA_MIN && dFrame <= FRAME_DELTA_MAX, + { s0, s1, dFrame, expectRange: [FRAME_DELTA_MIN, FRAME_DELTA_MAX] }); + + // G0-3:time 与 frame 关系自检(time≈frame/60)。 + // 【P1 修正】G0-3 是 time=frame++/frameRate 的定义式恒等,**仅作一致性自检,不作掌帧证据**(掌帧以 G0-2 为准)。 + const tfDelta = (s1 && typeof s1.time === 'number') ? Math.abs(s1.time - s1.frame / 60) : 999; + gate('G0-3', tfDelta < TIME_FRAME_TOL, { time: s1 && s1.time, frame: s1 && s1.frame, delta: tfDelta, note: 'consistency-self-check-only' }); + + // G0-4:timeReal 纯墙钟推进(P75 埋点候选钟可用)。 + const dTimeReal = (s1 && s0) ? (s1.timeReal - s0.timeReal) : -1; + gate('G0-4', dTimeReal > 0, { t0: s0 && s0.timeReal, t1: s1 && s1.timeReal, dTimeReal }); + + // G0-5:双时钟物理隔离——引擎 time(污染钟,LJS.time)与 probe P75 钟(ctx.time=host mockNowMs,经 probe JSONL tMono) + // 读自不同变量、不联动。这里核对:__engine.time 来自 LJS.time;probe 记录存在(tMono 走 mockNowMs)。 + const probe = await cdp.evaluate('window.__probe()'); + const hasProbeChain = !!(probe && Array.isArray(probe.anchors) && probe.anchors.length >= 6); + gate('G0-5', hasProbeChain && (s1 && typeof s1.time === 'number'), + { engineTimeFromLJS: s1 && s1.time, probeAnchors: probe && probe.anchors, note: 'engine time≠probe tMono(mockNowMs), 物理隔离' }); + + // G0-7:渲染落在 2D mainContext(非空 + 非全黑/透明)。 + // 【debugWatermark 修正】host 已 setDebugWatermark(false),右上角无 FPS 文字污染; + // 【clearColor 修正】host 已 setCanvasClearColor(BLACK),底为不透明黑(alpha=255)。 + // 先 spawn 一发 burst(real 分支只推进 host 状态,引擎下一拍 gameRender 落像素),再抓 #game-engine。 + await cdp.evaluate('window.__hostdev.reset()'); + await cdp.evaluate(`window.__hostdev.vfxFrame('burst', 1001, 12)`); + await delay(120); // 等引擎 RAF 至少渲一拍,把粒子场落到 mainContext + const img = await H.captureImageData(cdp, CANVAS); // 返回 { data:Uint8Array(RGBA), width, height } + const nonEmpty = !!(img && img.width > 0 && img.height > 0 && img.data && img.data.length > 0); + // 非全黑/透明:扫像素,统计「有色(粒子覆盖区,主色 #7fd0ff 系:B 通道高、R 低)」与「右上角白文字」。 + const px = (img && img.data) ? img.data : null; + let coloredHits = 0, whiteTextHits = 0; + if (px && nonEmpty) { + const W = img.width, Hh = img.height; + // 全屏抽样找粒子主色(B 高 R 低 → 青蓝)。 + for (let i = 0; i + 3 < px.length; i += 4 * 97) { // 步进抽样降成本 + const r = px[i], g = px[i + 1], b = px[i + 2]; + if (b > 120 && b > r + 30) coloredHits++; + } + // 右上角子区(宽 240×高 40,物理像素)扫白色文字像素(debugWatermark 残留检测)。 + const subW = Math.min(240, W), subH = Math.min(40, Hh); + for (let y = 0; y < subH; y++) { + for (let x = W - subW; x < W; x++) { + const i = (y * W + x) * 4; + if (i + 3 < px.length && px[i] > 200 && px[i + 1] > 200 && px[i + 2] > 200 && px[i + 3] > 200) whiteTextHits++; + } + } + } + gate('G0-7', nonEmpty && coloredHits > 0 && whiteTextHits === 0, + { width: img && img.width, height: img && img.height, coloredHits, whiteTextHits, note: 'whiteTextHits>0 ⇒ debugWatermark 未关' }); + } finally { + cdp.close(); + } + + // ── 通道 2:?engine=real&mode=smoke(受控面零改回归 G0-6)── + const urlSmoke = `${BASE}/index.html?seed=${SEED}&engine=real&mode=smoke`; + const cdp2 = await H.connectCdp({ url: urlSmoke, seed: SEED }); + try { + const p = await cdp2.evaluate('window.__probe()'); + const anchorsOk = !!(p && Array.isArray(p.anchors) && p.anchors.length >= 6); + gate('G0-6', p && p.initOk === true && p.verify && p.verify.ok === true && Array.isArray(p.uncaught) && p.uncaught.length === 0 && anchorsOk, + { initOk: p && p.initOk, verifyOk: p && p.verify && p.verify.ok, uncaught: p && p.uncaught, anchors: p && p.anchors }); + } finally { + cdp2.close(); + } + + // G0-8(回滚桩活):本 driver 不重复 integration-run;标记「由 desktop-4 的 integration-run stub 通道核对」。 + result.gates['G0-8'] = { pass: null, detail: 'deferred: 由 `node test/harness/integration-run.cjs `(?engine=stub 通道)核对,须与 α 收口报告等价' }; + + fs.writeFileSync(path.join(OUT_DIR, 'gate0.json'), JSON.stringify(result, null, 2)); + const passed = Object.entries(result.gates).filter(([, v]) => v.pass === true).map(([k]) => k); + const failed = failures; + console.log('[gate0] gates passed:', passed.join(',')); + if (failed.length) { + console.log('[gate0] GATE0 FAIL:\n - ' + failed.join('\n - ')); + process.exit(1); + } + console.log('[gate0] GATE0 PASS (G0-8 deferred to integration-run stub channel)'); +} + +main().catch((e) => { + console.error('[gate0] driver error:', e && e.stack ? e.stack : e); + process.exit(2); +});