Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/PRDs/refactoring/untangle-2026-11-06/PR-CHAIN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> --json jobs` against run `42452768709` | ✅ low |
Expand Down
11 changes: 7 additions & 4 deletions packages/core/__tests__/game.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
56 changes: 43 additions & 13 deletions packages/core/__tests__/renderer-config.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
assertNoLegacyRenderKey,
adoptLegacyRenderKey,
resolvePlatformResolutionFloor,
resolveRendererAlphaAntialiasing,
resolveRendererAntialias,
Expand Down Expand Up @@ -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();
});
});
26 changes: 16 additions & 10 deletions packages/core/src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, SceneConstructor<TState, TPhysics>>;
/**
Expand Down Expand Up @@ -421,22 +426,23 @@ class GameImpl<TState extends Record<string, unknown>, TPhysics>
readonly #ui: IGameUiSeam;

constructor(config: IGameConfig<TState, TPhysics>) {
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<TState, TPhysics> & { 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),
Expand Down
22 changes: 16 additions & 6 deletions packages/core/src/renderer-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,27 @@ type RendererConfig = NonNullable<IThreeNativeConfig["renderer"]>;
export type GameRendererConfig = Omit<IRendererOptions, "resolutionScale" | "scaleSource"> &
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<T extends object>(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. */
Expand Down
12 changes: 7 additions & 5 deletions packages/core/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading