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

65 KiB
Raw Blame History

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.jsesbuild 走 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 只额外暴露引擎钟给 probewindow.__enginectx.time 仍走 host 步进钟 mockNowMs(受控面 backing 不动)。
  • getInput 改成「引擎轮询态归一化包装」(那是 A1。A0 必须避免引擎自带 document 监听与 host inputBridge 双发(见 §4
  • getEngine 的真实 engineFactory 背书(粒子/合成/数学)留 A1+。A0 期 engineFactory 仍缺省 nullgetEngine 返 null插件容错降级
  • SIZES 重锚留 A5A0 仅在本计划 §9 记体积跳升预期。

映射总表(题面四问 → A0 落点)

抽象 α 桩现状 A0 落点
onFrame→回调 bundle.tick(dt) 内派发 onFramehost RAF 驱动 tick 引擎 gameUpdate(回调) 内调 bundle.tick(dtFixed);驱动器=引擎内部 RAF
canvasmainContext host prepareCanvas(#game) 注入 ctx2d 引擎 engineInit 自建 mainCanvasgetContext2d backing 到 engine.mainContext#game 退役
time→引擎 host mockNowMs 步进钟 A0 暴露 engine.time/frame/timeReal 给 probewindow.__enginectx.timemockNowMsA1 才透传)
确定性路径 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.jsESMexports.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:14582await Box2D()Box2D全局,由用户另行 include box2d.wasm.js 提供;engineInit 体内 box2d 引用)。physics-lite 用自带数学,不调 box2d。结论esbuild 把 littlejs.esm.js 整体打入产物,其中 Box2D() 是个永不执行的自由全局引用dead-but-presentesbuild 不会去解析/打包 box2d.wasm.jsbox2d.wasm.wasm(它们不被 ESM import),运行时也绝不触发 wasm fetch。→ A0 不需处理 wasm仅体积上多带 box2d 死代码SIZES 重锚 A5 计入)。风险标 §11。
  • glEnable 默认 true 且无外部 setter 别名陷阱:已核验导出名是 setGLEnable(大写 GLd.ts:695,非 R 路所写的 setGlEnableglInit:8765 守卫 if(!glEnable||headlessMode)returnsetGLEnable(false) 须在 engineInit 前调,引擎即跳过 glCanvas 创建、纯 2D 渲染进 mainCanvas。这比 R 路「跨 tick glCopyToContext」路径更干净(无 WebGL preserveDrawingBuffer。A0 取此路。
  • showSplashScreen 默认 falselittlejs.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 只许活在集成段 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

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 直接 importentry.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.jsESMbox2d 惰性box2dInit 永不调)→ 不引 wasm。
import * as LJS from 'littlejsengine';

2. 主循环接管rafLoop → engineInit 五回调

2.1 设计要点(先讲清「为什么这样接」)

引擎engineUpdate 外部导出d.ts grep 零命中),主循环=引擎内部 RAFlittlejs.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-383canvasClearColor 填底。故 host 改为 engineInit 前设引擎清色为黑(或保留默认)。gameRender 在引擎清屏之后调,无残留。
  • canvas 绑定:引擎 engineInit:406 自建 mainCanvas append 到 rootElement采用现有 #game)。故 host 把 rootElement 指向 #game 的父容器 #wrap(或专用容器),让引擎在其内建自己的 canvasgetContext2d backing 改为返回 engine.mainContext(见 §3
  • dtSeconds 来源固定步长A0 取 FIXED_DT=1/60host.js:45 已有常量),保 onFrame 回调 dt 恒定particles 时步确定)。

2.2 编辑块 A注入受控面 getContext2d 的 backing 切换准备

createHostDevContextcontext2d 入参当前是 host prepareCanvas(#game) 产的 ctxhost.js:188-193。A0 后这张 ctx 要换成 engine.mainContext,但 engine.mainContextengineInit resolve 后才存在(异步)。受控面 getContext2d() 实现是 return o.context2d == null ? null : o.context2dplugin.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(...) 之后。因 engineInitasync/返回 Promisehost bootHostDev 须改为「engineInit 先行 → 其 gameInit 回调里或 .then 里建 bundle + 注册插件 + 打启动链」。这样 getContext2d 一次性快照到的就是真 engine.mainContext受控面零改、无 Proxy

取路 B:最小改动且无 Proxy 复杂度。代价=bootHostDev 主体从「同步建 bundle」改为「engineInit resolve 后建 bundle」——这本就是引擎掌帧的必然结构引擎先起、canvas 才有)。下文编辑块按路 B 写。

host.js:174-193当前真实内容

  // 创建 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) 不再用作绘制面,但仍可留作降级桩):

  // 创建 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__hostdevinputBridge 等)须移到 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/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

  // ── 执行启动链打点 + 按模式收尾 ──
  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 已在跑):

  // ── 通道分流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

  // 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 到 rootElementmainCanvas = rootElement.appendChild(document.createElement('canvas'))drawContext=mainContext=mainCanvas.getContext('2d'):407引擎不采用现有 #game。且 engineInit:405rootElement.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 prepareCanvasctx.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 处理:在 gameRenderrenderParticleshost 对 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 打 idsetupAfterEngine 里 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.__hostdevcanvas(){ return LJS.mainCanvas; } 便于 CDP 拿引用。integration-run.cjsCANVAS='#game''#game-engine'(集成段配套改 harness driver非 host.jsharness 非冻结件)。


4. 输入:避免引擎自带监听与 host inputBridge 双发

4.1 真源核验(冲突点)

引擎 inputInit:5853engineInit:409 内被调,自装 9 个鼠标键盘监听 + 3 个触摸监听全挂 document:5858-5866 / :5985-5987inputPreventDefault 时对鼠标/键/滚轮 e.preventDefault。坐标经 mouseEventToScreenmainCanvas.getBoundingClientRect() 映射。

host 现 inputBridge 把 #game 的 pointer + window keydown/keyup 经 inputBridge._emit 灌进受控面host.js:321-330

双发风险A0 后若保留 host 的 DOM 监听,则同一物理点击会被「引擎 document 监听」与「host #game 监听」各收一次——引擎更新自己的 mousePoshost 推自己的受控订阅面。两套并存且坐标系不同(引擎按 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 改到引擎 mainCanvasreal 模式)。坐标 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;
  // 输入事件目标 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 即显示尺寸——toCanvasXYrect.width/height 归一化到 VIEWPORT_W/H,对引擎 canvas 同样成立(前提:引擎 mainCanvas 显示尺寸≈390×844若 fixed-size CSS 100% 使显示尺寸随窗口变,toCanvasXY 用相对比例仍正确归一化到 390×844 逻辑像素)。函数零改,仅传入 target 变。


5. 音频解锁在引擎掌帧下处置

5.1 真源核验

引擎模块 importnew AudioContextlittlejs.esm.js:6753,进程内单例 audioContext)。引擎在多处交互自动 audioContext.resume()mousedown:5931-5932 / keydown:5996-5997 / pointer:6664-6666audioInit:6771 仅在 soundEnable&&!headless 建 masterGain。

host 现 audioFactory 在解锁后 new AudioContexthost.js:164-172unlockAudioOnce 绑首次手势 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 收敛)。

唯一须确认:引擎 importnew 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
    • 引擎 framed.ts:98单调自增/timed.ts:102=frame/60受 timeScale/暂停污染)/timeReald.ts:106纯墙钟不受 pause/timeScale/帧率钳制)。
    • 这三个是引擎导出的 let 全局——经 LJS.frame/LJS.time/LJS.timeRealESM 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()=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.frameP75 埋点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=stubstepFrames 原样保留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. 门0window.__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 分支保留)

    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/paletteFrameasync(返回 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.tickhost.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 bundlebundle.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①真接线 metafilebuild.mjs build() 当前未传 metafile。A0/门1 须 additive 加 metafile:truemetafile.jsongrep 确认 littlejsengine 入产物且被 host chunk 引用(纯 node本机可验这是 build.mjs 的 additive 改点
    // 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/.wasmbox2d 经全局 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===truewindow.__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.timehost mockNowMswindow.__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-engineImageData → 非空 + 非全黑 有非黑内容(证 setGLEnable(false) 后粒子真渲进 2D mainContextreadback 有像素)
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-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.jshost 实现)+ entry.jsimport+ build.mjsadditive metafilesrc/core/api.d.tssrc/core/plugin.js 零改——

受控面成员 A0 后 backing 契约签名 不变性论证
getContext2d() engine.mainContextreal/ #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.__enginehost 私有,非契约)暴露给 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(集成段 hostapi.d.ts/plugin.js/任何插件 impl。window.__engine 是 host 私有探活口(非受控面 PluginContext 成员),不破「引擎可换」抽象边界。


12. 回滚策略

  • flag 回滚?engine=stubURL 即时)或 getParam('engine','real') 默认改 'stub'(一行)→ host 行为回 α 桩(#game 2D canvas + 浏览器 RAF + bundle.tick逐行等价。
  • commit 回滚A0 单独 commitβ spec §11git revert 该 commit 即移除引擎 import + 五回调 + 通道分流,回 α host.js。entry.js 的 import * as LJSexport const Engine 一并 revert。build.mjs 的 metafile 是 additive移除不破构建
  • 桩活保证:回滚点「活着」的实测=?engine=stub 跑 integration-run 与 α 收口报告证据等价§8 / §10.2 desktop-4
  • 失败处置(β spec §11A0 引擎接管不通=真接线全阻塞 → 回桩 + 本波降级(须创始人裁,因 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。openQuestionreal 逐像素哈希×N 是否稳定A6 实测定 N/目标 frame 清单
R-B WebGL 默认 truesetGLEnable(false) 才有 2D 像素 不关 WebGL 则 mainCanvas2D ctx空、glCanvasWebGL才有像素getImageData 读空 A0 real 通道 engineInitsetGLEnable(false)§2.4)→ 纯 2D 渲染进 mainCanvasreadback 无 WebGL preserveDrawingBuffer 坑。已核验导出名 setGLEnable(非 setGlEnable
R-C 引擎自建 mainCanvas不采用 #gameCDP 选择器须改 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 类(~数十 KBbox2dInit 永不调、.wasm 不被 import/fetch A5 SIZES 增量法重锚计入A0 仅记。esbuild 正常处理(自由全局 Box2D 不报 resolve§9.2 已核验)
R-H bootHostDev 变异步real 模式 __hostdev 延迟挂载) CDP/index.html 取证须等 window.__hostBootedReal 再驱动 __hostdevbootHostDev 返回值从 hostdev 变占位 {pending:true} A0 在 index.html 探活用 __hostBooted同步置real 就绪用 __hostBootedRealharness 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 监听 canvasinputTargetreal=引擎 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/§7A6 改,非 A0 主体

冻结件不动src/core/plugin.jssrc/core/api.d.tssrc/plugins/runtime-probe/impl.js(及其它插件 impl—— A0 全程零改§11 论证)。