diff --git a/docs/PRDs/refactoring/untangle-2026-11-06/PR-CHAIN.md b/docs/PRDs/refactoring/untangle-2026-11-06/PR-CHAIN.md index a651c2ffb4..196cee90d3 100644 --- a/docs/PRDs/refactoring/untangle-2026-11-06/PR-CHAIN.md +++ b/docs/PRDs/refactoring/untangle-2026-11-06/PR-CHAIN.md @@ -54,7 +54,7 @@ flowchart TD | Step | Proof that closes it | Risk | | --- | --- | --- | | 1 | `TN_GOLDEN_PATH_SCENARIOS=1 TN_GOLDEN_PATH_TEMPLATES=starter pnpm verify:golden-path` → EXIT=0 on a real GPU; `npx vitest run packages/core/__tests__/game.spec.ts packages/core/__tests__/game-stop-order.spec.ts` | ⚠️ the first run gave 0 frames; the cause can be a boot exception or a slow software adapter | -| 3 | `pnpm --filter @threenative/core exec vitest run __tests__/game.spec.ts`, `pnpm scaffold:pin --check`, `pnpm native:verify:desktop`, playtest `perf` on the starter with windows > 0 | ⚠️ the fold changes a public config key; the old key must throw `TN_GAME_RENDER_KEY_RENAMED` | +| 3 | `pnpm --filter @threenative/core exec vitest run __tests__/game.spec.ts`, `pnpm scaffold:pin --check`, `pnpm native:verify:desktop`, playtest `perf` on the starter with windows > 0 | ⚠️ the fold changes a public config key; the old key was to throw `TN_GAME_RENDER_KEY_RENAMED`. **Amended 2026-11-12, on the 0.3.13 release:** the throw refused to boot every game written against 0.3.12 (`create-threenative` 0.2.17 emits `render: config.renderer`), and the release upgrade proof stopped the publish with `TN_RELEASE_UPGRADE_RED`. `render` is now adopted onto `renderer` — its values win, as this key always did — and the same `TN_GAME_RENDER_KEY_RENAMED` guidance is reported instead of thrown; the hard throw is a 0.4.0 break. | | 4 | `pnpm parity` before and after; `pnpm bench:engines` medians and spread on web and native | ⚠️ the largest change: `renderer.cpp` split into files under 3 000 lines; each host keeps its current render choices, so no picture or cost should move | | 7 | the `ci.yml` run and `native-release.yml` `build-ios-simulator` on the PR head | ❌ the iOS checks never ran: Objective-C++ cannot compile on Linux, so a first red is likely a harness mistake | | 8 | `diff` of `gh run view --json jobs` against run `42452768709` | ✅ low | diff --git a/packages/core/__tests__/game.spec.ts b/packages/core/__tests__/game.spec.ts index 76566d10c3..5df7539612 100644 --- a/packages/core/__tests__/game.spec.ts +++ b/packages/core/__tests__/game.spec.ts @@ -2473,16 +2473,19 @@ describe("IGame", () => { ).toThrow("camera.far"); }); - it("fails closed at defineGame time on the retired `render` key, naming `renderer`", () => { + it("adopts the retired `render` key instead of refusing to boot, and reports the rename", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); expect(() => defineGame({ - renderer: renderer(testCanvas()), scenes: { test: EmptyScene }, start: "test", - // @ts-expect-error the retired key: the throw is the runtime half of this pin render: { preferWebGPU: true }, }), - ).toThrow(/TN_GAME_RENDER_KEY_RENAMED.*renderer/u); + ).not.toThrow(); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/TN_GAME_RENDER_KEY_RENAMED.*renderer/u), + ); + warn.mockRestore(); }); it("uses scene initial state when config state is absent and lets config win", async () => { diff --git a/packages/core/__tests__/renderer-config.spec.ts b/packages/core/__tests__/renderer-config.spec.ts index b7e59e7c40..a23f48406a 100644 --- a/packages/core/__tests__/renderer-config.spec.ts +++ b/packages/core/__tests__/renderer-config.spec.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { - assertNoLegacyRenderKey, + adoptLegacyRenderKey, resolvePlatformResolutionFloor, resolveRendererAlphaAntialiasing, resolveRendererAntialias, @@ -132,22 +132,52 @@ describe("resolvePlatformResolutionFloor", () => { }); }); -describe("assertNoLegacyRenderKey", () => { - it("names the key that replaced a retired `render`", () => { - expect(() => assertNoLegacyRenderKey({ render: { preferWebGPU: true } })).toThrow( - /TN_GAME_RENDER_KEY_RENAMED.*renderer/u, +describe("adoptLegacyRenderKey", () => { + it("forwards a retired `render` onto `renderer` and reports the rename", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const adopted = adoptLegacyRenderKey({ render: { preferWebGPU: true } }); + expect(adopted).toEqual({ renderer: { preferWebGPU: true } }); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching(/TN_GAME_RENDER_KEY_RENAMED.*renderer/u), ); + warn.mockRestore(); }); - it("states the field mapping, including the scale type and the engine-owned scaleSource", () => { - expect(() => assertNoLegacyRenderKey({ render: {} })).toThrow( - /resolutionScale \(a number in \(0, 1\] or "auto"\).*android block.*scaleSource.*not an input/su, + it("names every field, including the scale type and the engine-owned scaleSource", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + adoptLegacyRenderKey({ render: {} }); + expect(warn).toHaveBeenCalledWith( + expect.stringMatching( + /resolutionScale \(a number in \(0, 1\] or "auto"\).*android block.*scaleSource.*not an input/su, + ), ); + warn.mockRestore(); + }); + + it("fails closed on a `render` that is not an object", () => { + expect(() => adoptLegacyRenderKey({ render: "auto" })).toThrow(/TN_GAME_RENDER_KEY_INVALID/u); + }); + + it("lets `render` win over `renderer`, the merge the rename explained", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + expect( + adoptLegacyRenderKey({ + render: { antialias: false }, + renderer: { antialias: true, alpha: true }, + }), + ).toEqual({ renderer: { alpha: true, antialias: false } }); + warn.mockRestore(); }); - it("accepts a config with `renderer` and one with neither key", () => { - expect(() => assertNoLegacyRenderKey({ renderer: { preferWebGPU: true } })).not.toThrow(); - expect(() => assertNoLegacyRenderKey({})).not.toThrow(); - expect(() => assertNoLegacyRenderKey({ render: undefined })).not.toThrow(); + it("leaves a config with `renderer`, one with neither key, and one whose `render` is absent", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const withRenderer = { renderer: { preferWebGPU: true } }; + const empty = {}; + const absent = { render: undefined }; + expect(adoptLegacyRenderKey(withRenderer)).toBe(withRenderer); + expect(adoptLegacyRenderKey(empty)).toBe(empty); + expect(adoptLegacyRenderKey(absent)).toBe(absent); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); }); }); diff --git a/packages/core/src/game.ts b/packages/core/src/game.ts index b8bb837a3b..41f7ee8fec 100644 --- a/packages/core/src/game.ts +++ b/packages/core/src/game.ts @@ -20,7 +20,7 @@ import type { IRenderPerformanceSample } from "./loop.js"; import type { IPipelineCensus } from "./pipeline-census.js"; import { installQuietMarkers, uninstallQuietMarkers } from "./quiet-markers.js"; import type { IRandom } from "./random.js"; -import { type GameRendererConfig, assertNoLegacyRenderKey } from "./renderer-config.js"; +import { type GameRendererConfig, adoptLegacyRenderKey } from "./renderer-config.js"; import type { IRendererOptions } from "./renderer.js"; import { SCENE_ENTRY_INSPECTORS, @@ -282,9 +282,14 @@ export interface IGameConfig< * * `render` once carried the portable half, so a game wrote `render: config.renderer` beside a * second `renderer:` and the antialias and scale resolvers each fell back from one to the other. - * The retired key now throws `TN_GAME_RENDER_KEY_RENAMED` and names this one. + * The keys are one now; `render` is adopted onto this one and reported as renamed. */ readonly renderer?: GameRendererConfig; + /** + * @deprecated The retired name for {@link renderer}, adopted onto it and reported. Move the object + * to `renderer` with no other change. + */ + readonly render?: GameRendererConfig; readonly seed?: number; readonly scenes: Record>; /** @@ -421,22 +426,23 @@ class GameImpl, TPhysics> readonly #ui: IGameUiSeam; constructor(config: IGameConfig) { - assertNoLegacyRenderKey(config); - validateCameraConfig(config.camera); - const startScene = config.scenes[config.start]; - if (startScene === undefined) throw new Error(`Unknown start scene '${config.start}'.`); + // One key for the renderer settings: a game still on `render` is adopted, reported, and boots. + const resolved = adoptLegacyRenderKey(config); + validateCameraConfig(resolved.camera); + const startScene = resolved.scenes[resolved.start]; + if (startScene === undefined) throw new Error(`Unknown start scene '${resolved.start}'.`); const initialState = - config.initialState ?? + resolved.initialState ?? (startScene as SceneConstructor & { initialState?: TState }).initialState; if (initialState === undefined) { throw new Error( - `Scene '${config.start}' must declare static initialState or provide config.initialState.`, + `Scene '${resolved.start}' must declare static initialState or provide config.initialState.`, ); } this.#initialState = { ...initialState }; this.#boot = createBootGame({ - config, - state: createGameStore(initialState, config.stateFlushMs), + config: resolved, + state: createGameStore(initialState, resolved.stateFlushMs), connectUi: () => this.#ui.connect(), enterScene: (scene, ctx) => this.#enterScene(scene, ctx), goto: (name, ctx) => this.#goto(name, ctx), diff --git a/packages/core/src/renderer-config.ts b/packages/core/src/renderer-config.ts index a52a66e0e5..4792421ed4 100644 --- a/packages/core/src/renderer-config.ts +++ b/packages/core/src/renderer-config.ts @@ -18,17 +18,27 @@ type RendererConfig = NonNullable; export type GameRendererConfig = Omit & RendererConfig; +const LEGACY_RENDER_KEY_MESSAGE = + 'TN_GAME_RENDER_KEY_RENAMED: defineGame({ render }) is now defineGame({ renderer }). Move the object to `renderer` with no other change: preferWebGPU, antialias, alphaAntialiasing, resolutionScale (a number in (0, 1] or "auto"), the android block, matrixWorld, projection and minimumProjectedPixels keep their names and meaning, and `config.renderer` from threenative.config.ts passes through unchanged. `scaleSource` is reported by the engine and is not an input. Where a game set both keys, `render` won, so merge them with the `render` values on top.'; + /** - * Fail closed on the retired `render` key, naming the one that replaced it. + * Adopt the retired `render` key for one release, reporting the rename it names. * - * A game that still passes `render` would otherwise lose its settings without a word: the key is - * not read any more, so its scale, sampling and Android overrides would all fall back to defaults. + * Dropping the key silently would lose a game's scale, sampling and Android overrides; throwing on + * it refuses to boot every game written against 0.3.12, `config.renderer` and all. So the values are + * forwarded onto `renderer` — where a game set both, `render` won, so they go on top — and the + * rename is reported once with the guidance that names every field and its meaning. */ -export function assertNoLegacyRenderKey(config: object): void { - if ("render" in config && (config as { render?: unknown }).render !== undefined) +export function adoptLegacyRenderKey(config: T): T { + const { render, ...rest } = config as T & { render?: unknown }; + if (render === undefined) return config; + if (typeof render !== "object" || render === null) throw new Error( - 'TN_GAME_RENDER_KEY_RENAMED: defineGame({ render }) is now defineGame({ renderer }). Move the object to `renderer` with no other change: preferWebGPU, antialias, alphaAntialiasing, resolutionScale (a number in (0, 1] or "auto"), the android block, matrixWorld, projection and minimumProjectedPixels keep their names and meaning, and `config.renderer` from threenative.config.ts passes through unchanged. `scaleSource` is reported by the engine and is not an input. Where a game set both keys, `render` won, so merge them with the `render` values on top.', + `TN_GAME_RENDER_KEY_INVALID: defineGame({ render }) must be an object, received ${typeof render}.`, ); + console.warn(LEGACY_RENDER_KEY_MESSAGE); + const renderer = (config as { renderer?: object }).renderer; + return { ...rest, renderer: { ...renderer, ...render } } as T; } /** How the active drawing-buffer scale was arrived at, reported beside every fps number. */ diff --git a/packages/core/src/worker.ts b/packages/core/src/worker.ts index 184f91dbf6..4d3f1a5f15 100644 --- a/packages/core/src/worker.ts +++ b/packages/core/src/worker.ts @@ -835,14 +835,16 @@ export function startWorkerGame( listen(worker, "message", onWorkerMessage); const onWorkerError = (event: Event): void => { - const message = (event as ErrorEvent).message; + // A worker's ErrorEvent carries where it threw; without it the page sees only the message, and a + // stack-less "worker died" is unactionable (the 0.3.13 release proof lost a red this way). + const error = event as ErrorEvent; + const origin = [error.filename, error.lineno, error.colno].filter(Boolean).join(":"); + const message = `${error.message || `worker ${event.type}`}${origin ? ` (${origin})` : ""}`; // Once started, nothing awaits `ready`: without this line a frame that threw removed the canvas // and printed nothing (PRD-857). if (started && !stopped) - console.error( - `TN_WORKER_FAILED: the running worker game stopped: ${message || `worker ${event.type}`}`, - ); - stop(new Error(message || `TN_WORKER_FAILED: worker ${event.type}.`)); + console.error(`TN_WORKER_FAILED: the running worker game stopped: ${message}`); + stop(new Error(message)); }; listen(worker, "error", onWorkerError); listen(worker, "messageerror", onWorkerError);