games-development-ai/docs/agent-specs/2026-06-13-A0-引擎掌帧接管-edit-plan.md
zizi 11c8eafb42 feat(t1b-beta): A0 引擎掌帧接管落地——host-dev RAF桩→littlejsengine engineInit 五回调;本机门全绿,门0浏览器段待mini-desktop
host.js+entry.js:engineMode 分流。real=引擎掌帧(engineInit 五回调 engGameInit/
Update/UpdatePost/Render/RenderPost 接帧驱动+渲染)+getContext2d backing 切引擎
mainContext(setGLEnable(false) 纯2D渲进 mainContext);stub=α RAF桩原样保留(可回滚点)。

本机已验(主会话亲跑):
- 155单测绿(基线持平,受控面/插件零改故计数不变)
- 受控面零改实证:src/core/api.d.ts+plugin.js 未触(七项 backing 全在 host 层切换)
- Q4边界:引擎 import 仅 host.js+entry.js(metafile importers 反查证),未泄进受控面/插件
- 真接线门①:esbuild metafile 实证 littlejsengine 真入产物(163691 bytesInOutput),
  box2d 惰性(0 wasm input),产物 205KB min

8-agent workflow(源码取证→设计→对抗复审→实现):3/3 concerns 0 fatal。
17 mustFix(P0×1/P1×9/P2×7)全采纳:P0=real像素门 overshoot(引擎追帧 N tick/RAF 墙钟抖动
污染采样帧)→改 stub 通道独占逐像素回归,real 只证掌帧;P1 源码级纠偏=debug水印每帧画
averageFPS入mainContext(setDebugWatermark(false))/clearColor默认alpha=0不铺底
(setCanvasClearColor(BLACK))/P75须读timeReal非mockNowMs(real模式退化逻辑帧钟)。

门0 G0-1~G0-7(引擎真掌帧/真渲2D mainContext/双时钟/无uncaught/水印已除)须 mini-desktop
chrome 实测(6c6g 禁 chrome)——未实测不宣布 A0 完成。

新:docs/agent-specs/2026-06-13-A0-引擎掌帧接管-edit-plan.md(编辑计划+门0 runbook)
   +game-runtime/test/harness/gate0-engine-takeover.cjs(门0 CDP 驱动,独立闭合不复用未改造的 integration-run)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 06:33:27 +00:00

790 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 重锚留 A5A0 仅在本计划 §9 记体积跳升预期。
**映射总表(题面四问 → A0 落点)**
| 抽象 | α 桩现状 | A0 落点 |
|---|---|---|
| `onFrame`→回调 | `bundle.tick(dt)` 内派发 onFramehost 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-presentesbuild **不会**去解析/打包 `box2d.wasm.js``box2d.wasm.wasm`(它们不被 ESM `import`),运行时也**绝不**触发 wasm fetch。→ A0 不需处理 wasm仅体积上多带 box2d 死代码SIZES 重锚 A5 计入)。**风险**标 §11。
- **glEnable 默认 true 且无外部 setter 别名陷阱**:已核验导出名是 **`setGLEnable`(大写 GLd.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 只许活在集成段 hostentry.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 直接 importentry.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.jsESMbox2d 惰性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)` 产的 ctxhost.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`/返回 Promisehost `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 发射器 idreset() 时统一 stopEmitter 防其持续喷发。
/** @type {Set<number>} */
const activeEmitterIds = new Set();
// per-plugin 派生随机实例登记name → RandomSource
// 背景core 的 per-plugin 派生流让每个插件 init 时拿到「自己专属的派生随机实例」(与默认 context.random 不同实例)。
// 插件(如 particles-juice在 init 时捕获 `ctx.random` 并据其确定粒子序列。要让 vfx 像素证据「同参数重跑一致」,
// host 必须能复位**插件那一份**随机实例——故这里合法地包裹 bundle.deriveContextForhost 拥有 bundle
// 在注册器为每插件派生上下文时记下该插件的 random 引用,供 vfxFrame/paletteFrame 取证前 reseed。
/** @type {Record<string, import('../src/core/api.d.ts').RandomSource>} */
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纯 2DsetGLEnable(false) 后引擎只渲 2D 进 mainCanvas。因 engineInit 异步,
// 故 bundle 在「引擎就绪后」创建(见下 setupAfterEnginegetContext2d 一次性快照到真 mainContext。
// timeA0 仍用 host 步进钟 mockNowMsctx.time backing 不动,受控面零改);引擎钟另经 window.__engine
// 暴露给 probe门0ctx.time 透传引擎 time 留 A1。
let mockNowMs = 0; // 步进时钟读数(毫秒),由 tick/推帧推进。
// 记录所有经取证入口 spawn 的 continuous 发射器 idreset() 时统一 stopEmitter 防其持续喷发。
/** @type {Set<number>} */
const activeEmitterIds = new Set();
// per-plugin 派生随机实例登记name → RandomSource语义同 α,注释从略——见原文。)
/** @type {Record<string, import('../src/core/api.d.ts').RandomSource>} */
const derivedRandoms = {};
// 引擎模式:绘制面 = 引擎 mainContext就绪后绑桩模式绘制面 = host prepareCanvas(#game) 的 ctx2d。
// 引擎通道选择由 URL ?engine=real|stub 决定(缺省 real
const engineMode = getParam('engine', 'real'); // 'real'(引擎掌帧)| 'stub'(回滚桩)
/** @type {ReturnType<typeof createHostDevContext>|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/frameRateonFrame 回调 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 编辑块 CbootHostDev 主体重排——引擎先起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-203bundle 创建 + deriveContextFor 包裹)→ 移入 `buildBundle(renderCtxArg)`**
**锚host.js:205-578 大段:注册插件 / probe / markBootChain / runSmoke / __hostdev / 按 mode 收尾)→ 移入 `setupAfterEngine(renderCtxArg)`**,其中:
- `renderCtx = renderCtxArg;`(设当前绘制面)
- `bundle = buildBundle(renderCtxArg);`(建 bundlecontext2d=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 选通道;启动链 + 收尾移进 setupAfterEnginereal 模式下 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) 渲染纯 2DsetGLEnable(false) → 引擎跳 glCanvas渲进 mainContext2D 像素 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 的父容器,避免污染 <body>。
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. canvasmainContext 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×1688CSS 用 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 路 Bbundle 在 `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.jsharness 非冻结件)。
---
## 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 重复**(引擎已 preventDefaulthost 再 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;
// 输入事件目标 canvasreal=引擎 mainCanvasstub=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` 绑首次手势 resumehost.js:333-340
### 5.2 冲突与 A0 处置
- **双 context 风险**host `audioFactory` 另建一个 AudioContext → 与引擎单例并存。但 A0 期 `getAudioContext` 仍走 host audioFactorygetEngine/合成核背书是 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 非 errormini-desktop 实测核对)。
---
## 6. time引擎 time/frame 暴露给 probe门0ctx.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()`=mockNowMshost 步进钟)——**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**(返回 PromiseCDP 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+renderFramehost.js:277-283 不动) | 仅推进 host bundle`bundle.tick` n 次,不调 renderFrame——引擎渲染自走 RAF用于 reset 消亡 |
| `vfxFrame` | 原样(同步,定量推帧) | asyncspawn+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. esbuildlittlejsengine 入产物 + box2d 风险
### 9.1 入产物体积跳升SIZES 重锚留 A5
- `host-dev/entry.js`(→ `host-bundle.js`是引擎接线入口esbuild `bundle:true` 会把 `import * as LJS from 'littlejsengine'` 整体打入(`littlejs.esm.js` raw≈640KBmin 后≈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+` 大段)作为死代码照进产物,撑大 rawA5 重锚计入);(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` | 两者皆 trueengineInit 五回调真触发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 mainContextreadback 有像素) |
| 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-desktopco-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 渲染落 2DG0-7
# reset → vfxFrame('burst',1001,12)realawait 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` ctxstub | `():CanvasRenderingContext2D\|null` | 契约只约定「返回 2D ctx」不约定来源real 经路 B 一次性快照引擎 mainContext仍是合法 2D ctx。插件经 `context.getContext2d()` 拿到一张 2D ctx零改。 |
| `onFrame(cb)` | 引擎 `gameUpdate` 内调 `bundle.tick(1/60)` 驱动real/ host RAFstub | `(cb:(dt,frame)=>void):FrameHandle` | `bundle.tick` 派发 onFrame 的逻辑plugin.js:510-523零改只是「谁调 tick」从 host RAF 变引擎 gameUpdate。dt 仍恒 1/60frame 仍单调——回调形态不变particles dt 敏感性满足。 |
| `getInput()` | host inputBridge监听挂引擎 mainCanvasreal | `():InputSource` | inputBridge 实现plugin.js:236-353零改只是 host 监听目标从 `#game` 变引擎 mainCanvas坐标仍归一化到 390×844 逻辑像素。getInput 契约不变A1 才改引擎态透传)。 |
| `getAudioContext()` | host audioFactoryA0 不动) | `():AudioContext\|null` | 音频链零改§5。 |
| `time` | host mockNowMsA0 不动A1 才透传引擎 time | `TimeSource{nowMs,elapsedMs}` | ctx.time backing 不动;引擎钟另经 `window.__engine`host 私有,非契约)暴露给 probe。 |
| `random` | per-plugin 派生 mulberry32A0 不动) | `RandomSource{next,range,reseed}` | 随机链零改(引擎 RandomGenerator 透传是 A1。 |
| `getEngine()` | host-dev 缺省 nullA0 不注 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 则 mainCanvas2D ctx空、glCanvasWebGL才有像素`getImageData` 读空 | A0 real 通道 `engineInit``setGLEnable(false)`§2.4)→ 纯 2D 渲染进 mainCanvasreadback 无 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真收敛到引擎单源是 A1getInput 改引擎态透传) |
| 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 门9A0 给判据骨架§10.1 |
| R-J | **engineInit 二次调用守卫(`ASSERT(!mainContext)` + 运行时 `if(mainContext)return`** | 若页面/测试重复 boot 会被引擎静默拒绝returnmainContext 复用上次 | A0 real 通道 `bootHostDev` 每页一次CDP 每取证开独立 targetintegration-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.1additive |
| `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 论证)。