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>
65 KiB
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 走 packagemain/default= ESM)+littlejs.d.ts本计划只产「精确 old→new 编辑块 + 不变性论证 + 门0 runbook + 回滚 + 遗留风险」,不落代码到源文件。
0. 目标与范围边界(A0 只做地基,不越界)
A0 唯一目标:把 host-dev 的「浏览器 RAF + bundle.tick(dt) + 普通 2D canvas」桩,换成真引擎 engineInit 五回调接管帧驱动 + 渲染循环,使:
- 引擎内部 RAF 真掌帧(非 host 手摇 RAF 桩);
- 引擎
time/frame/timeReal随循环真推进,可被 probe 读到(门0); - 受控面 7 项契约零改(
getContext2d/onFrame/getInput/getAudioContext/time/random/getEngine),插件零改; - 确定性取证路径(
window.__hostdev步进 + ImageData FNV-1a 哈希)在引擎掌帧下存活; - 保留可回滚点(
?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 走
littlejsenginepackagemain=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是全局,由用户另行 includebox2d.wasm.js提供;engineInit体内零 box2d 引用)。physics-lite用自带数学,不调 box2d。结论:esbuild 把 littlejs.esm.js 整体打入产物,其中Box2D()是个永不执行的自由全局引用(dead-but-present),esbuild 不会去解析/打包box2d.wasm.js或box2d.wasm.wasm(它们不被 ESMimport),运行时也绝不触发 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」路径更干净(无 WebGLpreserveDrawingBuffer坑)。A0 取此路。 showSplashScreen默认 false(littlejs.esm.js:2873)→ 无开屏遮挡首帧,gameRender首次回调即真首帧。
1.2 entry.js 编辑
锚(当前真实内容,entry.js:16-17):
import * as RT from '../src/all-plugins.js';
import { bootHostDev } from './host.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):
export { bootHostDev };
export const Runtime = RT;
改为(把引擎名字空间一并透到全局入口,供 host.js 经参数拿 + DevTools 手验;不改 bootHostDev 既有签名,新增可选第二参):
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 块):
import {
PluginRegistry,
createHostDevContext,
registerAllPlugins,
createExamplePlugin,
createRuntimeProbePlugin,
CORE_PROTOCOL_VERSION,
} from '../src/all-plugins.js';
改为(追加引擎名字空间 import;受控面 import 不动):
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回调:引擎每帧追帧循环里调gameUpdateN 次(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;getContext2dbacking 改为返回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,hostbootHostDev须改为「engineInit先行 → 其gameInit回调里或.then里建 bundle + 注册插件 + 打启动链」。这样getContext2d一次性快照到的就是真engine.mainContext,受控面零改、无 Proxy。
取路 B:最小改动且无 Proxy 复杂度。代价=
bootHostDev主体从「同步建 bundle」改为「engineInitresolve 后建 bundle」——这本就是引擎掌帧的必然结构(引擎先起、canvas 才有)。下文编辑块按路 B 写。
锚(host.js:174-193,当前真实内容):
// 创建 host-dev 受控上下文:注入真 2D ctx / 主种子 / 音频工厂。
// 时钟:取证用步进时钟(见下 mockClock),真实 RAF 模式则用墙钟——这里给步进时钟,
// 真实模式由 RAF 推帧但时间读数仍走步进时钟,保证「插件读 time 与帧推进一致、且可被取证接管」。
let mockNowMs = 0; // 步进时钟读数(毫秒),由 tick/推帧推进。
// 记录所有经取证入口 spawn 的 continuous 发射器 id,reset() 时统一 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.deriveContextFor(host 拥有 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) 不再用作绘制面,但仍可留作降级桩):
// 创建 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<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):
/** 把当前帧的表现层(粒子 + 后处理叠加)渲染到 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」):
// 【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):
/* ── 真实 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):
/* ── 回滚桩:浏览器 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=hostprepareCanvas(#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):
// ── 执行启动链打点 + 按模式收尾 ──
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 已在跑):
// ── 通道分流: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 的父容器,避免污染 <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):
// 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之后、下一 tickupdateCanvas之前读 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 变换包裹: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,当前输入桥接):
/* ── 真实输入桥接: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):
/* ── 真实输入桥接: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
unlockAudioOnceresume 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.timebacking 不动(仍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 用,不进受控面契约):
// 【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 不动),绘制面=hostprepareCanvas(#game)的 ctx2d。这条与 α 完全等价,FNV-1a 哈希「同参数重跑×3 一致」原样成立——确定性回归不受引擎影响(兜回归)。 - 引擎真掌帧 + 真渲染像素(门0 + A6 WebGL/2D 像素门)→ 走 real 通道(
?engine=real):取证不再「手摇定量推帧」,而是:- 门0:经
window.__engine实测引擎frame/time真推进(见 §10); - A6 像素门:
window.__hostdev在 real 模式提供**「引擎帧号对齐的取证入口」**——因引擎不可外部单步,real 模式的vfxFrame改为「spawn 预设 + 真 RAF 跑到目标引擎 frame(轮询LJS.frame>=startFrame+frames)→ 读engine.mainContextImageData」。逐帧像素哈希的「同 frame→同像素」靠:引擎time=frame++/frameRate(整数 frame 的纯函数,与墙钟无关,R3 已证),故锁定引擎 frame 到目标值再抓,same-seed+same-engine-frame→same-pixel 成立(粒子序列由 reseed 派生随机定,与引擎 frame 同步)。注意:real 模式取证须在引擎 RAF 自然推进下「等到目标 frame 抓」,不是手摇;同参数重跑因引擎 frame 整数确定 + reseed 确定 → 像素一致(追帧数 N 抖动只影响「几次动画帧凑齐 frames 个逻辑步」,不影响逻辑步内容)。
- 门0:经
real 模式 vfxFrame 适配(新增分支,stub 分支保留):
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 行为与 α 逐行等价(同#game2D 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)是引擎接线入口;esbuildbundle:true会把import * as LJS from 'littlejsengine'整体打入(littlejs.esm.jsraw≈640KB,min 后≈242KB 量级;题面记「引擎入包预期产物≈432KB raw」)。A5 用 size.mjs 增量法实测重锚;A0 仅记预期,不判超标(β spec 门2 是 A5 的事)。build.mjsLOCKED_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 改点:
(门1①本机可跑:// build.mjs build() 内 esbuild.build({...}) 加: metafile: true, // 并在 await 后:if (result.metafile) writeFileSync(outfile + '.meta.json', JSON.stringify(result.metafile));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不importbox2d.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 验的部分)
# 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 主考)
# 在 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 行为回 α 桩(#game2D 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 论证)。