# 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 论证)。