# P1 · 王蓝莓上引擎(spike 游戏宿主装载契约)· 执行版 > **状态**:execution 版 · 2026-06-13 · 给执行 agent 实现。配套 review 版 [`2026-06-13-P1-王蓝莓上引擎-review.md`](./2026-06-13-P1-王蓝莓上引擎-review.md);母梁 [`2026-06-13-runner-v2-arc-review.md`](./2026-06-13-runner-v2-arc-review.md) §5.1。 > **修订**:2026-06-13 对抗复审(deep·全程读真源码)→ 纳入 4 major(real 就绪标记 / driver 范式切换 / 同屏暖色阈值坐实 / bundle 延后建)+ 5 项不明假设处置(IDL 落点改 game-runtime/src·缺省分叉显式声明·game 工厂硬独立·部署改 git-clone-scratch·步骤0 降常规核对)。逐条裁决见 §12。 > **模型分流**:一律 opus(创始人拍)。 > **前提(创始人已拍)**:装载契约 = 新立 additive 第 9 类契约(不污染 `PluginContext`);生命周期 = `init/update/render/onInput` + `destroy`;boot 注入引擎 `mainContext` 给游戏层。 > **铁律**:受控面 6 项契约(`getContext2d/onFrame/getInput/getAudioContext/time/random`,`api.d.ts:269/281/286/303`)**零改**;引擎 import 只活 entry/host(Q4);区分 verified/inferred/assumption,凡引代码事实带 file:line。 > **装载契约落点(复审改)**:IDL 落 `game-runtime/src/core/game-host.d.ts`(与 `api.d.ts` 同目录),**不**落 `contracts/`——`contracts/` 是跨三仓五工位的**业务契约** SSOT(API/DB/SDK/event…,README §1),装载契约是 game-runtime 内部「host↔游戏工厂」接线面,消费方只在 game-runtime(host boot + 生成 agent),且让跨仓 SSOT 反向相对 import `../game-runtime/src/*` 违「契约即 SSOT」且 contracts 拆独立仓时即断(README §1 明列拆仓计划)。落同目录后 import 方向自然(`import type { PluginContext } from './api.d.ts'`)。详见 §12-A1。 --- ## 1. 目标 / 范围边界 ### 1.1 目标 给 `wanglanmei-ref` 加 `?engine=real` 通道:① 引擎真掌帧(`engineInit` 五回调驱动游戏 `update/render`,废浏览器 RAF);② 游戏美术与引擎粒子**同屏可玩**(游戏绘制面从 `#game` 2D 改引擎 `mainContext`,合并成一张画布);③ 人在 mini-desktop 浏览器真玩;④ 保 `?engine=stub` 回滚(逐像素确定性)。同时把 `makeEngineCaps`(`host.js:197-318`)提升为可 import 模块 `host-dev/engine-caps.js`,host-dev / ref 共用。 ### 1.2 范围内 - 新立装载契约工件 **`game-runtime/src/core/game-host.d.ts`**(additive 第 9 类契约;落 game-runtime/src,**非** contracts/,详见 §12-A1)。 - `host-dev/engine-caps.js` 提升(搬 `host.js:197-318` 闭包为 `createEngineCaps({LJS, engineMath, engineMode, recHook})`);`host.js` 改 import。 - `wanglanmei-ref` real 通道改造:`entry.js` import 引擎、`main.js` 主循环挂引擎五回调、绘制面改 `mainContext`、`createHostDevContext` 注 `engineFactory`、`mockNowMs` 改逻辑帧钟、RAF 门控。 - ref 内拆出符合装载契约的 game 工厂(**硬约束:独立文件 `game-shop.js`,默认导出 `GameHostFactory`,零引擎 import;禁 main.js 内联糊过**,详见步骤 3 + §12-A3)。 - `e2e-input.cdp.cjs` 加 `engine=real` 通道断言 + 同屏像素判据 + **real 通道 driver 范式切换(真触摸+轮询,禁 stepFrames 手摇,详见步骤 8 + §12-M2)**。 - `index.html` 加 `__refBootedReal` 就绪标记接线(real 通道;详见步骤 4.3 + §12-M1)。 - `SIZES.md` 增"游戏产物"口径。 ### 1.3 范围外(非目标) - 受控面 6 项契约(零改)。 - P2(泛化宿主/装载任意 GamePackage)→ 母 spec Phase D(O2 已拍)。 - P3(生成游戏按契约产出)→ W-G1 自身 spec。 - 后端发布主链 / game-studio / feed / 生成管线。 - 不接 OSS;不改 feed 引擎选型。 - 不动 stub 通道既有行为(逐行保旧)。 --- ## 2. 前置 | # | 前置 | 状态 / 证据 | |---|---|---| | P-1 | A0 引擎掌帧地基已收口(real 通道范式可用) | verified:`host.js:917` engineInit 五回调 + `host.js:919` setupAfterEngine(mainContext);门0 8/8(`3e5cab4`) | | P-2 | A1-A4 引擎能力包装已落地(makeEngineCaps 已接线) | verified:`host.js:359` `engineFactory: makeEngineCaps`;`engine-math.js` 已独立模块 | | P-3 | mini-desktop 具备 esbuild + chrome(6c6g 禁 chrome) | verified:`a6-runbook.sh` / `ref-e2e-runbook.sh` 均跑 mini-desktop | | P-4 | ref 现状:49 单测绿 + e2e 四件套(stub 基线) | verified:`BUILD.md` RESULT.txt 49/49 | | P-5 | LittleJS 已是 ref devDep(构建可引擎入包) | inferred:host-dev 同仓共用 `littlejsengine`;执行首步须核 `games/wanglanmei-ref` 经 monorepo node_modules 可解析(与 host-dev entry 同口径) | --- ## 3. 涉及文件路径 | 文件 | 动作 | 关键行锚 | |---|---|---| | `game-runtime/src/core/game-host.d.ts` | **新增** | 装载契约 IDL(§5);落 src/core 与 `api.d.ts` 同目录(**非** contracts/,§12-A1) | | `contracts/README.md` | 改(索引指针) | 不折进跨仓 8 类;加一行指针:「游戏宿主装载契约(game-runtime 内部,落 `game-runtime/src/core/game-host.d.ts`)」(§12-A1) | | `host-dev/engine-caps.js` | **新增** | `createEngineCaps`(搬 `host.js:197-318`) | | `host-dev/host.js` | 改 | `makeEngineCaps` 闭包 → import `createEngineCaps`(`host.js:197-318` 删,`host.js:204/359` 改) | | `games/wanglanmei-ref/entry.js` | 改 | `entry.js:15` 增 `import * as LJS from 'littlejsengine'` | | `games/wanglanmei-ref/src/main.js` | 改(real 通道) | `main.js:112`(engineFactory,real 须延后到 engineInit().then 内建·§12-M4)/`617-661`(frame→game.update/render)/`648`(drawFrame g→mainContext)/`668-691`(RAF 门控)/`618`(mockNowMs)/`583-602`(real 输入改挂 mainCanvas·§12-M4) | | `games/wanglanmei-ref/src/game-shop.js` | **新增(硬约束·非建议)** | 把 main.js 游戏逻辑/绘制段封成装载契约 game 工厂;默认导出 `GameHostFactory`、零引擎 import(步骤 3 + §12-A3) | | `games/wanglanmei-ref/index.html` | 改 | real 通道 `__refBootedReal` 就绪标记接线(§12-M1);stub 仍用 `__refBooted` | | `games/wanglanmei-ref/test/e2e-input.cdp.cjs` | 改 | 加 `engine=real` 通道(rect 选择器切 `#game-engine`、waitRefBoot 等 `__refBootedReal`、real 范式=真触摸+轮询禁 stepFrames、同屏像素判据)(步骤 8 + §12-M1/M2/M3) | | `games/wanglanmei-ref/test/ref-e2e-runbook.sh` | 改(或旁建 real 版) | **沿 git-clone-scratch 口径**(`ref-e2e-runbook.sh:20-21`,**非** tar-pipe);URL 加 `&engine=real`;构建后 `grep -q ParticleEmitter` 核引擎入包(§12-A4) | | `SIZES.md` | 改 | 增"游戏产物=引擎+包装层"口径 | --- ## 4. 数据流 ```mermaid sequenceDiagram participant HTML as index.html participant Boot as bootRef(main.js) participant Caps as engine-caps.js participant LJS as littlejsengine participant Game as game(装载契约工厂) HTML->>Boot: bootRef({canvas,statusEl}) alt engine=real Boot->>Boot: setGLEnable(false)+setDebugWatermark(false)+setCanvasClearColor(BLACK) Boot->>Caps: createEngineCaps({LJS,engineMath,engineMode:'real',recHook}) Caps-->>Boot: engineFactory(particles/audio/math 三面) Boot->>LJS: engineInit(gameInit,gameUpdate,...,gameRender,...) LJS-->>Boot: .then() → mainContext 就绪 Boot->>Boot: createHostDevContext({context2d:mainContext, engineFactory, clock:逻辑帧钟}) Boot->>Game: game.init(ctx, mainContext) loop 引擎内部 RAF LJS->>Game: gameUpdate → game.update(FIXED_DT) LJS->>Game: gameRender → game.render(mainContext) Game->>LJS: ctx.getEngine().particles.spawnEmitter(粒子自渲落 mainContext) end else engine=stub(保旧) Boot->>Boot: prepareCanvas(#game) 2D + createHostDevContext(无 engineFactory) Boot->>Boot: 浏览器 RAF + mockNowMs 墙钟 → frame(dtReal) → drawFrame(#game) end ``` **关键数据流不变量**: - real:掌帧唯一源=引擎(游戏不自起 RAF);绘制面唯一=引擎 `mainContext`(游戏美术+引擎粒子同屏);插件能力经 `ctx.getEngine()`(下层已冻)。 - **real 时序硬约束(复审 M1/M4)**:`createHostDevContext`(bundle)、`game.init`、输入桥挂 `mainCanvas`、就绪标记 `__refBootedReal=true` **全部在 `engineInit().then(setupReal)` 内**(上图 `.then() → mainContext 就绪` 之后)——引擎掌帧的必然结构:引擎先起、`mainContext`/`mainCanvas` 才有;顶层提前建 bundle 会快照错 ctx(`host.js:323-324`)。 - stub:与今天 `main.js` 逐行等价(回滚锚);就绪标记仍用 `index.html` 的 `__refBooted`(`index.html:81`)。 --- ## 5. 装载契约 IDL(新立 additive 第 9 类契约 · `game-runtime/src/core/game-host.d.ts`) > **设计原则**:游戏 = 纯模块工厂(零引擎 import,Q4)。host 经 `engineInit` 五回调驱动游戏生命周期;游戏绘制面 `render(g)` 的 `g` = 引擎 `mainContext`(boot 注入);插件能力经下层已冻结 `ctx.getEngine()`(不在本契约重复定义)。**与 `api.d.ts` 是 additive 关系:本契约只新增 `GameHostFactory`/`GameInstance`/`GameHostBootContext` 三个类型,受控面 6 项零改。** > **落点(复审改)**:落 `game-runtime/src/core/game-host.d.ts`(与 `api.d.ts` 同目录),故 import 方向自然=同目录 sibling `./api.d.ts`;**不**落 contracts/(理由见 §12-A1)。 ```typescript // game-runtime/src/core/game-host.d.ts —— 游戏宿主装载契约(第 9 类契约 · additive · game-runtime 内部) // owner:Runner v2 P1 | 消费方:集成段 host(boot 游戏)+ 生成 agent(产出靶) // // 【为什么新立、不折进 api.d.ts】 // api.d.ts 的 PluginContext 是「插件↔引擎」面(getEngine() 等,api.d.ts:303,已冻结); // 本契约是「游戏整体↔引擎宿主」面——游戏的 update/render 主体不是插件、不走 PluginContext。 // 两者正交(边界模型「同一职不留两条并行路」):插件经 getEngine() 拿能力,游戏经本契约挂宿主。 // 引擎 import 只活集成段 host(Q4 铁律),游戏工厂零引擎 import。 // 【为什么落 game-runtime/src 而非 contracts/】 // contracts/ 是跨三仓五工位的业务契约 SSOT(API/DB/SDK/event…,contracts/README.md §1); // 本契约是 game-runtime 内部「host↔游戏工厂」接线面,消费方只在 game-runtime, // 故与 api.d.ts 同目录、import 方向顺;落 contracts 会让跨仓 SSOT 反向相对 import ../game-runtime/src(拆仓即断)。 import type { PluginContext } from './api.d.ts'; /** * 游戏挂宿主时拿到的 boot 上下文。host 在游戏 init 时一次性注入。 * - ctx:下层已冻结受控面(PluginContext)。游戏经 ctx.getEngine() 拿插件能力(粒子/音频/数学), * 经 ctx.getInput() 拿受控输入,经 ctx.random/time 拿受控随机/时钟。**不得绕过 ctx 触碰裸引擎/DOM。** * - mainContext:引擎 mainContext(setGLEnable(false) 后的纯 2D 绘制面)。游戏 render(g) 的 g 即它; * 游戏美术画在此面 → 与引擎粒子(引擎自渲落同一 mainContext)同屏。**此字段是装载契约的核心** * (游戏取引擎绘制面的唯一约定,不借 PluginContext.getContext2d——那是插件面)。 * - seed:主随机种子(确定性);canvas:可见画布引用(输入 rect 映射用,real=引擎 mainCanvas)。 */ export interface GameHostBootContext { ctx: PluginContext; // 下层已冻结受控面(复用) mainContext: CanvasRenderingContext2D; // 引擎 mainContext(boot 注入,render 绘制面) canvas: HTMLCanvasElement; // 可见画布(real=引擎 mainCanvas,输入 rect 映射用) seed: number; // 主随机种子 } /** * 游戏实例:host 经引擎五回调驱动的稳定生命周期。 * 生命周期对齐 engineInit 五回调(host.js:883-887 engGameInit/Update/Render): * · init ← gameInit(引擎首帧前一次性):建游戏对象、订阅 ctx.getInput()、装资产。 * · update(dt) ← gameUpdate(引擎每逻辑步,dt=FIXED_DT 恒定):core.update/事件/特效计时。 * · render(g) ← gameRender(引擎清屏后,g=mainContext):画游戏美术;引擎粒子已自渲落同一 g。 * · onInput(ev)(可选):归一化输入事件回调。**注**:ref 经 ctx.getInput().on 订阅即可, * onInput 是「不便用 ctx.getInput 的极简游戏」的备选入口;二者择一,ref 用前者。 * · destroy():卸载卫生(P2 装载多游戏复位用;P1 ref 单游戏可空实现但须声明)。 */ export interface GameInstance { init(boot: GameHostBootContext): void | Promise; // 异步:装资产(ref loadAssets 异步) update(dt: number): void; // 逻辑步(dt 秒,引擎恒定步长) render(g: CanvasRenderingContext2D): void; // g=mainContext(boot 注入的同一面) onInput?(ev: { type: string; x?: number; y?: number; key?: string }): void; // 可选输入入口 destroy(): void; // 卸载(幂等) } /** * 游戏工厂:纯模块导出(零引擎 import)。host 调它建 GameInstance。 * 生成 agent 的唯一产出靶 = 实现并默认导出一个 GameHostFactory。 * 【硬约束·§12-A3】必须是「独立、可被 P3 生成 agent 复制的工厂模块」: * · 独立文件默认导出(ref=games/wanglanmei-ref/src/game-shop.js 的 `export default createShopGame`); * · 零引擎 import(引擎能力一律经 boot.ctx.getEngine()); * · 禁在 main.js 内用内联闭包「形似实现」糊过——否则 P3 缺真实参照实现,违 P1「spike 装载契约到可被 P3 复制」目标。 * @param opts 工厂选项(无玩法语义;预留 difficulty/variant 等 additive 扩展位)。 */ export type GameHostFactory = (opts?: Record) => GameInstance; ``` **IDL 三条不可破约定(对齐边界模型)**: 1. **掌帧唯一源=引擎**:`GameInstance` 不含任何 RAF/计时器;`update/render` 只被引擎五回调驱动。**推论(§12-M2)**:取证 driver 在 real 通道亦不得手摇 `stepFrames`(否则与引擎 RAF 双掌帧,破此条)——真触摸后等引擎自然推帧 + 轮询状态。 2. **绘制面唯一=引擎 `mainContext`**:`render(g)` 的 g 必为 boot 注入的 `mainContext`;游戏美术与引擎粒子同屏。**推论(§12-M4)**:real 通道 `bundle` 必须在 `engineInit().then` 内、以 `context2d=mainContext` 建(受控面 getContext2d 一次性快照闭包捕获值,`host.js:323-324`);不得在顶层先建再改 ctx。 3. **能力唯一面=下层 `ctx.getEngine()`**:粒子/音频/easing 经它(不在本契约重复定义引擎能力面)。 **additive 关系明确**:`game-host.d.ts` 仅 `import type { PluginContext } from './api.d.ts'`,不修改 `api.d.ts` 任何导出;受控面 6 项零改。 --- ## 6. 关键实现步骤(有序) ### 步骤 0:核前置(执行首步,read-only · 常规核对) - mini-desktop scratch clone 后核 `games/wanglanmei-ref/entry.js` 加 `import * as LJS from 'littlejsengine'` 能被 esbuild 解析(与 host-dev entry 同 monorepo node_modules)。**口径(复审降级·§12-A5)**:此项已被 host-dev 既有事实强力支撑(`host-dev/entry.js` 末行已 `import * as LJS from 'littlejsengine'` 且 host-dev real 门 6/6 已过,同 monorepo node_modules)→ 不是可能阻断的高不确定项,作**常规构建核对**:构建 ref-bundle 后 `grep -q ParticleEmitter dist/ref-bundle.js` 即可(无需专门 /tmp dry-build 阻断门)。 ### 步骤 1:新立装载契约工件 `game-runtime/src/core/game-host.d.ts`(§12-A1) - 按 §5 IDL 落盘到 **`game-runtime/src/core/game-host.d.ts`**(与 `api.d.ts` 同目录);import 方向=同目录 sibling `./api.d.ts`。 - `contracts/README.md` **不**把它折进跨仓 8 类清单,仅加一行指针登记:「游戏宿主装载契约(game-runtime 内部,落 `game-runtime/src/core/game-host.d.ts`),消费方=host boot + 生成 agent」(理由 §12-A1)。 - **验证**:`tsc --noEmit`(若仓有 ts 校验)或 node 类型注释自洽;确认只 `import type { PluginContext } from './api.d.ts'`、不改 `api.d.ts` 任何导出(diff 证 api.d.ts 零改)。 ### 步骤 2:提升 engine-caps.js(搬运已有件,语义零变) 2.1 新建 `host-dev/engine-caps.js`,导出 `createEngineCaps({ LJS, engineMath, engineMode, recHook })`: - 把 `host.js:197-318` `makeEngineCaps` 闭包体整体搬入,**逐行保语义**: - 首行 `if (engineMode !== 'real') return null;`(保 null-engine 回归短路,`host.js:198`)。 - `rec` 改为参数注入:`const rec = recHook || (() => {});`(**默认 no-op**;原闭包内 `window.__engineCalls.push` 语义由调用方经 recHook 提供)。 - `mathRaw = engineMath`(原 `buildEngineMath(LJS)` 的结果由调用方传入,或 `createEngineCaps` 内部 `buildEngineMath(LJS)`——二选一,保 `host.js:204` 同口径)。 - particles/audio.synth 三面包装体(`host.js:214-316`)逐字搬入,引擎引用经入参 `LJS.*`。 2.2 `host.js` 改:删内联 `makeEngineCaps`(`197-318`),改 `import { createEngineCaps } from './engine-caps.js'`;在 `buildBundle`(`host.js:353`)处 `engineFactory: () => createEngineCaps({ LJS, engineMath: buildEngineMath(LJS), engineMode, recHook: id => { try { (window.__engineCalls||(window.__engineCalls=[])).push(id); } catch(_){} } })`。 - **关键**:host-dev 仍注入 rec(门② 探针在 host-dev 取证产物里有,符合现状——host-dev 不是渠道产物)。 2.3 **验证(回归基线)**:mini-desktop 跑 `a6-runbook.sh`(门0 间接 + 门②)+ gate0 driver → **门0 8/8 + real 门 6/6 逐项不变**(提升不得改任何判据数值)。 ### 步骤 3:ref 拆出装载契约 game 工厂(`game-shop.js` · 硬约束独立文件 · §12-A3) - **硬约束**:必须是独立文件 `games/wanglanmei-ref/src/game-shop.js`,`export default createShopGame`(`GameHostFactory` 形态),**零引擎 import**(引擎能力一律经 `boot.ctx.getEngine()`)。**禁**在 main.js 内联闭包形似实现糊过——P3 生成 agent 需要一个真实、可复制的参照工厂(§12-A3)。stub 通道亦经此工厂的 stub 适配驱动(绘制面 g=`#game` ctx),保 stub 行为不变由逐像素门⑤兜。 - 把 `main.js` 的游戏逻辑段与绘制段封成 `createShopGame(): GameInstance`(默认导出 `GameHostFactory`): - `init(boot)`:搬 `main.js:158-178` 游戏对象创建(`createShopCore`/`createLinePicker`/`createHostFx` 等)+ `main.js:165-167` 异步 `loadAssets` + `main.js:582-611` 输入订阅(经 `boot.ctx.getInput()`)。保存 `boot.mainContext` 为绘制面。 - `update(dt)`:搬 `main.js:617-647` 逻辑段(`mockNowMs += dt*1000`;`core.update(dtWorld)`;`bundle.tick(dt)`;`applyEvents`;`maybeReplayBufferedServe`;`fx.step`;rush 联动;闲时台词;ghosts 清理;`rebuildZones`)。**注**:`bundle.tick` 仍由游戏 update 调(驱动插件 onFrame 计时;引擎粒子自渲与此解耦,同 host.js A2 注释 `host.js:726-728`)。 - `render(g)`:搬 `main.js:648-658` 绘制段(`drawFrame(g, {...})` + `palettePost.renderPost(g)`)。**g=boot.mainContext**。 - `destroy()`:cancel 输入订阅 + dispose registry(P1 可最小实现,须声明)。 - **难点(real 坐标系)**:引擎 `mainContext` 无 DPR 变换(引擎每帧 `updateCanvas` 重置为单位阵)。游戏按逻辑 390×844 绘制 → `render(g)` 须在 `g.save()` 内 `g.setTransform(DPR,0,0,DPR,0,0)` + `g.imageSmoothingEnabled=false`,对齐 `host.js:489-490` renderParticles 的补偿做法。stub 通道 g=`prepareCanvas(#game)` 已自带 DPR 变换(`main.js:69`)→ 不补。 ### 步骤 4:main.js bootRef real 通道接线 4.1 读 `engineMode = getParam('engine', 'stub')`(**缺省 stub** 保旧;real 显式开。注:host-dev 缺省 real,但 ref 缺省 stub 更稳——真玩取证显式带 `&engine=real`)。 4.2 **stub 分支**:逐行保旧(`prepareCanvas(#game)` + `createHostDevContext` 无 engineFactory + 浏览器 RAF + `frame(dtReal)` + `drawFrame(#game)`)。把现有 `main.js` 主体收进 stub 分支(或经 game 工厂的 stub 适配,但 stub 务必行为不变——逐像素门⑤兜)。 4.3 **real 分支**(仿 `host.js:897-928`;控制流=实质重构,不是局部插桩——§12-M4): - `LJS.setGLEnable(false)` / `setShowSplashScreen(false)` / `setDebugWatermark(false)` / `setCanvasClearColor(LJS.BLACK)` / `setCanvasFixedSize(vec2(390*DPR, 844*DPR))`(全在 engineInit 前)。 - 建独立 `engineRoot` div 挂 body(不复用 `#wrap`,引擎会覆盖容器样式,`host.js:911-915`)。 - `engineMath = buildEngineMath(LJS)`;`engineFactory = () => createEngineCaps({ LJS, engineMath, engineMode:'real', recHook })`(取证时注 recHook,渠道不注 —— 见步骤 7)。 - `engineInit(engGameInit, engGameUpdate, engGameUpdatePost, engGameRender, engGameRenderPost, [], engineRoot).then(() => setupReal(LJS.mainContext)).catch(e => { window.__refBootError=String(e&&e.message||e); window.__refBootedReal=false; ... })`。 - **`setupReal(mainContext)` —— 一切 bundle/game/输入/就绪标记都在此(engineInit().then 内)**: - **【M4 硬约束·bundle 必须延后到此建】** `bundle = createHostDevContext({ context2d: mainContext, seed, audioFactory, clock: () => mockNowMs, engineFactory })`。**context2d 必为 mainContext**——受控面 getContext2d 一次性快照闭包捕获的 ctx(`host.js:323-324` 注释明确「读闭包捕获值,故必须就绪后建 bundle」);**绝不可在顶层先用 `#game` ctx 建 bundle 再改**,否则 getContext2d 快照到 stub `#game` ctx,受控面 context2d 与游戏 render 面分裂(= spec 要消除的渲染面分裂,会换形式复现)。可直接引 `host.js:353-371`(buildBundle)+ `host.js:379-381`(setupAfterEngine 在 then 内建 bundle)作样板。 - 建 game 工厂 → `game.init({ ctx: bundle.context, mainContext, canvas: LJS.mainCanvas, seed })`(异步,`init` 内 await loadAssets)。 - 挂 `window.__refHost`、`window.__refForensics`、`window.__refChecklist`(**全部移进 then 内挂,real 下顶层不可早挂**——顶层时 bundle/game 尚不存在)。 - `LJS.mainCanvas.id='game-engine'`(CDP 取证选择器,仿 `host.js:830`);暴露 `window.__engine` 双时钟(仿 `host.js:840`)。 - **【M1 硬约束·real 专属就绪标记】** game.init 完成后置 `window.__refBootedReal = true`(仿 host-dev `__hostBootedReal`,`host.js:920`)。**这是 real 通道唯一就绪信号**——必须在 engineInit().then 内、game.init/资产/受控面全就绪后置。**stub 通道照旧用 `index.html` 的 `__refBooted`(bootRef().then 置,`index.html:81`),real 通道额外置 `__refBootedReal`**。driver 在 real 下改等 `__refBootedReal`(步骤 8 + §12-M1)。 - 五回调:`engGameUpdate(){ game.update(FIXED_DT); }`;`engGameRender(){ game.render(mainContext); }`;其余空/留痕(`engGameInit(){ window.__refEngineInitFired=true; }`)。五回调函数定义在 bootRef 作用域(engineInit 须先拿到),实体经 holder 转发到 setupReal 内真实现(仿 `host.js:880-887` 的 `_engStep`/`_renderParticles` holder 范式)。 - **mockNowMs**:real 下由 `game.update` 内 `mockNowMs += dt*1000` 累加(逻辑帧钟,非墙钟,同 `host.js:526`)。 - **RAF 门控**:`startRaf/rafLoop`(`main.js:668-691`)仅 stub 调;real 分支不调(引擎内部 RAF 驱动五回调)。 4.4 输入(real·控制流重构 §12-M4):real 下输入监听**改挂 `LJS.mainCanvas`**(不再硬绑传入的 `opts.canvas`=`#game`;坐标按引擎 canvas rect,仿 `host.js:536-538` `inputTarget = real ? LJS.mainCanvas : canvas`);`toCanvasXY` 的 `getBoundingClientRect` 改读 `LJS.mainCanvas`。监听挂载 + `inputBridge` 取用同样移进 `setupReal`(bundle 就绪后才有 `bundle.inputBridge`)。经 `bundle.inputBridge._emit` 灌受控面;游戏经 `boot.ctx.getInput().on` 订阅(同 `main.js:604-611`,订阅在 game 工厂 `init` 内)。**stub 通道输入逐行不变**(挂 `#game`,`main.js:583-602`)。 ### 步骤 5:entry.js 引擎入包 - `entry.js:15` 后增 `import * as LJS from 'littlejsengine';` + `export const Engine = LJS;`(仿 `host-dev/entry.js:21/28`)。**Q4**:引擎 import 只活 entry/host,game 工厂零引擎 import(构建后核 `game-shop.js` 编译产物经 LJS namespace 间接,不直 import)。 ### 步骤 6:SIZES.md 口径 - 增"游戏产物(引擎+包装层)"表:ref stub=197KB(不打引擎)/ ref real≈540KB raw min(引擎真入);标 gz 实测(`size.mjs`);注"插件增量法不覆盖游戏产物口径"。 ### 步骤 7:探针绝不入渠道产物(物理保证) - `createEngineCaps` 的 `recHook` **默认 no-op**;`engineFactory` 默认不传 recHook。 - **王蓝莓真玩取证**:取证脚本经 URL/全局开关让 main.js real 分支注入 `recHook = id => window.__engineCalls.push(id)`(仅取证路径)。 - **渠道产物(未来 P2/P3 出仓 bundle)**:esbuild 入口不引取证注入点 → recHook 恒 no-op → 探针代码无副作用、不挂 `window.__engineCalls`。**P1 阶段 ref-bundle 即取证用**,注 recHook 可接受(ref-bundle 非渠道产物,同 host-bundle 现状 `host.js:202`)。 ### 步骤 8:e2e driver real 通道改造清单(范式切换·非增量 · §12-M1/M2/M3) > **背景(复审 major 2/1)**:现行 `e2e-input.cdp.cjs` driver 主体建在 **`window.__refHost.stepFrames(n)` 手摇逻辑帧** 上(`e2e-input.cdp.cjs:109`,贯穿 `:144` tapAt 内 `stepFrames(3)`、`:236/:277` 营业循环 `stepFrames(20)`),坐标 rect 写死 `document.getElementById('game')`(`:127`),就绪只等 `__refBooted/__refHost`(`:76`)。这三处在 real 通道**整体失效**——必须按下列清单改造,**不是「加个 engine=real 通道断言」的增量**。 real 通道 driver 改造(保 stub 通道路径逐行不变,real 走新分支): (a) **就绪标记(M1)**:`waitRefBoot` 在 `engine=real` 时改等 **`window.__refBootedReal`**(而非 `__refBooted`)。real 下 `__refBooted`(`index.html:81` bootRef().then 置)会在引擎/game 就绪前被置 true(bootRef 顶层同步段先 resolve),driver 据它驱动会打空/读 undefined → 假失败或间歇绿。host-dev 正是为此引入 `__hostBootedReal`(`host.js:920`,注释「CDP 取证须等 __hostBootedReal 再驱动」`host.js:851/931`)。新分支:`evaluate('({ b: !!window.__refBootedReal, e: window.__refBootError||null, h: !!window.__refHost })')`。 (b) **坐标 rect 选择器(M3)**:`initMapping` 的 `document.getElementById('game')`(`:127`)在 `engine=real` 时切到 **`document.getElementById('game-engine')`**(引擎自建可见画布,`host.js:830`;real 下 `#game` 不是可见画布)。否则取错画布/null → 坐标全错。stub 通道仍用 `#game`。 (c) **确定性序列范式切换(M2·硬·边界铁律)**:real 通道**禁用 `stepFrames` 手摇**——引擎内部 RAF 已掌帧(装载契约第 1 条「掌帧唯一源=引擎」),game 不暴露/不手摇 `stepFrames`,否则与引擎 RAF 双掌帧违铁律。原「tapAt 后 stepFrames(3)」「循环 stepFrames(20)」改为:**真触摸 → `delay(ms)` 等引擎自然推帧 → 轮询 `__refForensics.state()`/`measures()` 判相位推进**(轮询到目标态或超时)。这与 stub 的「手摇定量推帧」是**范式切换**(harness §5-坑6 已订:逐像素门交 stub 通道独占手摇,引擎通道只证掌帧+渲染落 2D+真玩闭环,不比逐像素哈希)。real 真玩去掉 `mode=evidence`(real 取证不靠 stepFrames,靠真触摸+轮询)。 (d) **同屏像素扫描**:合 gate0 G0-7 + a6 A6-5 像素扫描,CANVAS=`#game-engine`,判据见 §8 门②(暖色阈值已开工前坐实,见门②)。 --- ## 7. 边界失败路径 | 失败 | 触发 | 处置 | |---|---|---| | 引擎 engineInit 失败 | WebGL/资源异常 | 镜像 `window.__refBootError`(仿 `host.js:925`),status 提示"可加 `?engine=stub` 回滚";门④/CDP 据此判 real 失败→回滚 stub | | **driver 在引擎就绪前驱动(打空/读 undefined)** | real 下 `__refBooted` 早于引擎/game 就绪被置(§12-M1) | `__refBootedReal` 只在 engineInit().then 内 game.init 完成后置;driver real 分支等它(步骤 8a) | | 资产装载缺失 | 图片/mp3 缺 | 游戏 init 走程序化回退(保 `main.js:165` 容错),照常启动;取证 `waitAssets` 超时如实记录续跑(`e2e-input.cdp.cjs:87`) | | 粒子渲到分离画布(假阳性) | 绘制面未真改 mainContext | 门② 同屏像素判据逮(游戏画布粒子命中=0=fail),见 §8 门② | | AudioContext headless suspended | 无手势 | `--autoplay-policy=no-user-gesture-required`(harness §5);门② 音频降软门(同 `a6-engine-wiring.cjs:89`) | | engine-caps 提升回归 | 探针/短路语义漂移 | 步骤 2.3 门0 8/8 + real 门 6/6 回归卡住;不过即 revert engine-caps.js | | 受控面被改 | 误扩 6 项 | code review + 契约 diff 卡死(硬约束) | --- ## 8. 验证方法(harness 门) > 全在 mini-desktop 跑(6c6g 禁 chrome)。**部署口径=沿现行 `ref-e2e-runbook.sh` 的 git-clone-scratch(复审改·§12-A4)**:`git clone --depth 50 --branch dev/2.0.0 ssh://git@101.200.34.71:2222/...` → `git checkout <精确SHA>` 到 scratch(`ref-e2e-runbook.sh:20-21`)→ esbuild 锁参构建 → IP gate → systemd-run serve(`static-serve.cjs` 根=`games/wanglanmei-ref` 端口 4320,`:44`)+chrome 两单元(`:47`)→ 就绪 10×0.5s + serve 5×6s 在线 + CDP 10×2s 两段探活(`:55/:59/:64`)→ driver(`:69`)→ journal 归档 → 停单元。chrome 旗标含 `--autoplay-policy=no-user-gesture-required`(`ref-e2e-runbook.sh:48`,harness §5)。**real 版只需在 runbook 的 driver URL 加 `&engine=real` + 构建后 `grep -q ParticleEmitter dist/ref-bundle.js` 核引擎入包**。**严禁裸 `./` rsync / tar-pipe 整树**(6c6g cwd 易重置到仓根吞整树,harness §6-律4);git-clone-scratch 是 ref runbook 既有口径,勿改成 host-dev/a6 的 tar-pipe staging 配方。 **构建命令**(mini-desktop,cwd=`game-runtime`): ``` node scripts/build.mjs games/wanglanmei-ref/entry.js games/wanglanmei-ref/dist/ref-bundle.js --global-name=WanglanmeiRef # 此时引擎真入包 ~540KB;核对:grep -q ParticleEmitter dist/ref-bundle.js ``` **真玩 URL**:`http://localhost:4320/?engine=real&seed=305441741`(取证模式 `&mode=evidence` 用 `__refHost` 确定性驱动;真人交互去掉 mode)。 | 门 | 判据(可执行阈值) | driver / 口径 | |---|---|---| | **① 掌帧** | `window.__engine.snapshot()` 两采样间隔 500ms,`frame` 增量 ∈ `[20,45]`(@60fps ~30,排单步假推进+失控空转) | 复用 gate0 G0-2(`gate0-engine-takeover.cjs:75`);ref driver 加同款断言(ref 暴露 `window.__engine`) | | **② 同屏像素(堵假阳性)** | 截 `#game-engine`(引擎接管后 DOM 可见、=mainContext 的 canvas,`host.js:830`)。**两类命中均 >0**:(a) **游戏美术·暖色(开工前已坐实,非占位)**:店面暖色主色像素命中数 `warmHits > N`(N=200,按 780×1688 抽样网格调,详见下「门②(a) 暖色判据」);(b) **引擎粒子·亮色**:burst 白 `brightHits>0` ∧ `maxCh>80`(a6 A6-5 口径,`a6-engine-wiring.cjs:111`)。**为何这证合并非两画布**:若粒子渲到另一张分离 canvas,则截 `#game-engine` 时粒子命中=0(粒子不在这张)→ (b) fail;若游戏渲到 #game 旧画布、引擎渲 mainContext,则截 `#game-engine` 时游戏命中=0→ (a) fail。**两类同时命中 ⟺ 两者真在同一张 `#game-engine` 上** | ref driver 新增(合 gate0 G0-7 + a6 A6-5 像素扫描,CANVAS=`#game-engine`) | | **③ 门②真接线** | 取证注 recHook 后,`window.__engineCalls` 含 `particles.spawnEmitter` call-ID(经 particles-juice 真实 spawnEmitter,非直调引擎) | 复用 a6 A6-2(`a6-engine-wiring.cjs:79`);ref driver 触发游戏内真粒子(serve_ok 飞行/burst)后读 `__engineCalls` | | **④ CDP 真玩 + 四件套** | CDP 真触摸逐 zone 真玩闭环(开店→进货→营业→结算→升级),首日接待数>0、serve_ok>0、到达结算、升级 `upgrades.speed1=true`;四件套 evidence(输入轨迹 JSONL/截图+canvas 哈希/probe JSONL/checklist JSON)落 `evidence/round-N/`。**zone 名以 `main.js` computeZones 为唯一真相**(serve-/inc-/up-,`render.js:967/975/981`),驱动器猜语义名必踩坑(harness §2-律4) | 扩 `e2e-input.cdp.cjs` 加 real 通道(步骤 8:`&engine=real`、选择器 `#game-engine`、等 `__refBootedReal`、真触摸+轮询,harness §2-律2) | | **⑤ stub 回归** | `?engine=stub&mode=evidence` 菜单帧双开哈希一致(清档 reload 后比,harness §5-坑6);ref 49 单测仍绿 | 现有 `e2e-input.cdp.cjs` 门 B(stub 通道不变) | | **⑥ 提升回归** | engine-caps 提升后:host-dev 门0 8/8 + real 门(a6)6/6 逐项不变 | 步骤 2.3 | | **⑦ console 干净** | `uncaught=[]`(real 通道,含未捕获 error/unhandledrejection) | ref driver(`main.js:90-96` uncaught 归集) | > **门④ driver 范式(复审 M2)**:real 通道 driver=真触摸+轮询(**禁 stepFrames 手摇**,否则双掌帧违铁律),坐标走 `#game-engine` rect,就绪等 `__refBootedReal`——见步骤 8。zone 名以 `main.js computeZones` 为唯一真相(`serve-`/`inc-`/`up-`,`render.js:967/975/981`),驱动器猜语义名必踩坑(harness §2-律4)。 ### 门②(a) 暖色判据(开工前坐实 · 复审 major 3) > **背景**:gate0 G0-7 的 `coloredHits` 判据是 **青蓝**(`b>120 && b>r+30`,`gate0-engine-takeover.cjs:111`),a6 A6-5 是 **亮度**(`max(r,g,b)>80`,`:111`,注释明告青蓝口径对引擎白粒子失效)——二者均**不适配**王蓝莓暖色店面(R 高 B 低,与青蓝正相反)。harness §6-2 已订「换引擎能力后像素门颜色假设必同步重核」。故门②(a) 判据**开工前从 `palette.js` 坐实**,不留执行时占位。 **已坐实色域**(源:`games/wanglanmei-ref/src/palette.js:23-41` BASE 暖调家族,已读真源核对): | 用途 | 色名 | RGB | R−B | 命中 | |---|---|---|---|---| | 柜台/货架木带 | `wood` | (169,118,79) | 90 | ✓ | | 深木描边 | `woodDark` | (124,84,55) | 69 | (R<140 不计,wood 覆盖) | | 砖橙按钮/警示 | `brick` | (201,111,74) | 127 | ✓ | | 金/钱强调 | `gold` | (232,169,66) | 166 | ✓ | | 琥珀进度条 | `amber` | (240,196,117) | 123 | ✓ | | 差评红 | `danger` | (178,72,54) | 124 | ✓ | | **纸底(须排除)** | `paper` `#f6e7c9` | (246,231,201) | 45 | ✗ 反例 | | 高亮纸(须排除) | `paperHi` | (255,246,227) | 28 | ✗ 反例 | | 暖天光(须排除) | `sky` | (243,217,168) | 75 | ✗ 反例 | | 奶油粒子亮色(须排除) | `cream` | (255,243,220) | 35 | ✗ 反例(近白,归引擎粒子域) | **判据(暖木/砖/金色域,排纸底与暖天光)**: ``` warmHit ⟺ (r > 140) && (r - b > 85) && (g < r) ``` - 命中:wood(R169,R−B90,118<169)✓ / brick(201,127,✓) / gold(232,166,✓) / amber(240,123,✓) / danger(178,124,✓)——店面木带/货架/按钮/HUD 金额(`render.js` 大量 `pal.c('wood'|'brick'|'gold'|'amber')`,已读真源:`render.js:120/165/179/200/253/308` 等); - 排除:paper(R−B45<85)✗ / paperHi(28)✗ / sky(75<85)✗ / cream(35)✗——纸底/天光/近白粒子均落空,堵「把暖纸背景误判成店面美术」的假阳性; - 鲁棒性:rush 压力态色板经 `shiftHsl(hue −10, sat 1.18, light 0.9)`(`palette.js:57`)把暖色**更暖**(R−B 进一步拉大、饱和升),仍稳在色域内,不会因 rush 漂出; - 阈值 `warmHits > 200`:扫 780×1688 backing(或抽样网格,与 gate0/a6 同扫法),店面木带+货架+按钮+招牌占屏面积远超 200 抽样点;过窄(真渲也判 0=假阴性)与过宽(纸底误命中=假阳性)两侧均已由上面色域不等式+反例锚定。**N 在 driver 落地时按实际抽样网格密度微调,但色域不等式不动**(不等式是堵假阳性的客观锚,开工前定死)。 --- ## 9. 完成条件 - 装载契约 **`game-runtime/src/core/game-host.d.ts`** 落盘(§12-A1)+ `contracts/README.md` 指针登记(不折进跨仓 8 类);只 `import type { PluginContext } from './api.d.ts'`、`api.d.ts` 零改(diff 证)。 - game 工厂 `game-shop.js` 为**独立文件、默认导出 `GameHostFactory`、零引擎 import**(非 main.js 内联,§12-A3 硬约束)。 - `host-dev/engine-caps.js` 提升完成;host.js import 它;**门0 8/8 + real 门 6/6 回归逐项不变**(evidence 证)。 - ref `?engine=real` 通道:门① 掌帧 ∈[20,45] ∧ 门② 同屏(`#game-engine` 上游戏美术 `warmHits>200`(§门②(a) 坐实暖色判据)+ 引擎粒子 `brightHits>0∧maxCh>80` 双命中)∧ 门③ `particles.spawnEmitter` call-ID ∧ 门④ CDP 真玩闭环(**真触摸+轮询范式,非 stepFrames 手摇**)+四件套 ∧ 门⑦ console 零未捕获——**全绿**。 - real 通道就绪标记 `__refBootedReal` 接线正确(driver real 分支等它,§12-M1);bundle 在 engineInit().then 内以 `context2d=mainContext` 建(§12-M4)。 - ref `?engine=stub` 门⑤ 逐像素双开哈希一致 + 49 单测绿(stub 通道行为逐行不变)。 - SIZES.md 增游戏产物口径(gz 实测)。 - 受控面 6 项契约零改(硬约束守住)。 - 人在 mini-desktop 浏览器 `?engine=real` 真玩一局(开店→营业→结算→升级),截图归档。 --- ## 10. 回滚 - **代码**:`?engine=stub` 与今天 `main.js` 逐行等价;real 过不了→用户/取证走 stub,既有交付零连累。 - **契约**:`game-runtime/src/core/game-host.d.ts` additive(落 src/core,§12-A1),移除不破存量;受控面零改。 - **模块**:engine-caps.js 提升若回归→git revert 该文件 + host.js import 行(host.js 退内联闭包),ref real 自带 engineFactory 经 createEngineCaps 不连累。 - **构建**:ref-bundle stub 版(197KB,不打引擎)随时可重出,回退引擎入包。 --- ## 11. 风险与假设(诚实标注) - **[verified]** 装载契约全仓不存在(grep 零命中);ref 游戏层 file:line(`main.js:112/583-602/618/621/648/668-691`,`stepFrames` 手摇 `:664/:109`);host.js real 范式(`host.js:323-324/353-371/379-381/488-490/536-538/830/840/904/917-920/930-932`,缺省 real `:340`);index.html `__refBooted` 置位时机(`index.html:80-81`);driver 现状(`e2e-input.cdp.cjs:76/109/127/144/236/277`);makeEngineCaps 闭包(`host.js:197-318`)+ engineFactory 注入(`host.js:359`);burst colorStart 白(`particles-juice/impl.js:217`,gate0 青蓝 `:111` / a6 亮度 `:111`);palette 暖色 BASE(`palette.js:23-41`,rush shiftHsl `:57`);zone kind 枚举(`render.js:967/975/981`);ref runbook git-clone-scratch(`ref-e2e-runbook.sh:20-21/44/47/48`);contracts 跨仓 SSOT 定位(`contracts/README.md:1-3` §1,含拆仓计划);sdk-interface.d.ts 自洽无 game-runtime import(先例)。 - **[verified·复审降级]** ref entry 加引擎 import 能被 esbuild 解析入包——host-dev/entry.js 末行已 `import * as LJS from 'littlejsengine'` 且 host-dev real 门 6/6 已过(同 monorepo node_modules),已被既有事实强力支撑;作常规构建核对(`grep -q ParticleEmitter`,步骤 0),非阻断门(§12-A5)。 - **[inferred]** ref real ~540KB 不触 B1 入场券——基于 host-dev entry 544KB 实测 + gz 量级;P1 用 `size.mjs` 实测游戏产物 gz 坐实(步骤 6)。 - **[已裁·非待确认]** game 工厂**必须**独立文件 `game-shop.js`、默认导出、零引擎 import(§12-A3 硬约束,不再留「可 main.js 内联」口子)。 - **[已裁·非占位]** 门②(a) 暖色判据已开工前从 `palette.js` 坐实具体色域(`r>140 && r−b>85 && g 复审口径:execution 是落地契约 + 高风险(立新契约 + 改真游戏主循环),deep 校准全程读真源码、凡引代码事实带 file:line。无 fatal;4 major 全纳入、5 项不明假设逐条处置。下表为各条裁决与依据,正文已落地。 | 编号 | 复审条目 | 裁决 | 落地位置 / 依据 | |---|---|---|---| | **M1** | real 就绪标记缺失(`__refBooted` 早于引擎/game 就绪被置,driver 据它打空/flaky) | **纳入·硬约束**:新增 real 专属 `window.__refBootedReal`(只在 `engineInit().then` 内、game.init 完成后置);driver real 分支改等它 | 步骤 4.3(setupReal 末置 `__refBootedReal`)+ 步骤 8a(driver 等它)+ §7 失败路径;依据 `index.html:81`/`host.js:920/851/931`/`e2e-input.cdp.cjs:76` | | **M2** | stepFrames 手摇范式在引擎掌帧下失效(且沿用=双掌帧违铁律);改造被低估为「加通道断言」 | **纳入·范式切换**:real driver 禁 stepFrames,改真触摸+`delay`等引擎自然推帧+轮询状态;明列这是相对 stub 的范式切换非增量 | 步骤 8c(确定性序列范式切换)+ §8 门④范式注 + IDL 不可破约定 1 推论;依据 `e2e-input.cdp.cjs:109/144/236/277`、harness §5-坑6 | | **M3** | 门②(a) 暖色阈值与 gate0/a6 青蓝/亮度口径相反,却留执行时占位 | **纳入·开工前坐实**:从 `palette.js` BASE 取定暖色色域不等式 `r>140 && r−b>85 && g