diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fc97b3b4..c8ba8bbd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -312,6 +312,19 @@ jobs: CARGO_BUILD_JOBS: '2' run: python tools/ci/setup_windows_perry.py --out "$env:RUNNER_TEMP/bloom-perry" --github-env + - name: Native and WASM fixed lifecycle contract + shell: pwsh + run: python tools/ci/fixed_step_smoke.py + + - name: Retain fixed lifecycle evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: fixed-game-lifecycle + path: target/ci/fixed-step + if-no-files-found: error + retention-days: 1 + - name: Compile real browser startup fixture shell: pwsh run: python tools/ci/compile_web_game.py diff --git a/docs/evidence/fixed-game-lifecycle-v1.md b/docs/evidence/fixed-game-lifecycle-v1.md new file mode 100644 index 00000000..d286ce10 --- /dev/null +++ b/docs/evidence/fixed-game-lifecycle-v1.md @@ -0,0 +1,59 @@ +# Fixed game lifecycle + +The callback-only loop supplies variable delta and leaves timing and hook order +to each game. The new optional `runGameLifecycle` entry defines init, fixed +update, variable update, interpolated draw and one-time cleanup for native and +web. `FixedStepClock` provides bounded catch-up and explicit dropped-time +diagnostics. The original `runGame` API stays supported. + +The pure TypeScript contract is compiled and executed with the qualified Perry +0.5.1220 profile both as a Windows executable and as actual WASM in its generated +runtime. Uniform and varied partitions each produce 50 ticks; a 6,000-frame +144 Hz sequence produces 2,500 ticks at 60 Hz. Checks cover catch-up/drop and +resume, interpolation, invalid settings/deltas, overflowing accumulation, very +small steps, optional hooks, initialization/disposal order, duplicate cleanup, +stop during fixed update and stop during variable update. Host-side controls +reject missing, duplicate, malformed and incorrect observations. + +Initial failed runs are retained. Object-literal JSON reporting returned +undefined in WASM, so the fixture now reports the actual scalar observations +directly. `Number.isFinite` has a distinct HIR expression without a corresponding +WASM lowering in pinned Perry; an attempted runtime dispatch shim did not fix +that and was removed. A portable noncoercing arithmetic finite check now serves +the clock and existing quality/scene validators. Method syntax for stored +callbacks also went through named dispatch instead of the closure bridge. +Reading each callback into a local function reference fixes the native/WASM +observations without changing Perry. General JSON/exception compatibility is +not claimed. + +A fresh installed candidate package compiles the public lifecycle fixture and +renders its exact 128x128 image on Radeon DX12 and Vulkan. Both captures match +all 16,384 pixels and PNG SHA-256 +`8a509d87d3aa3fab96e0a9e2c67228e187f1bc0853cb4726aa5844799440f30e`. +Each records one init, nine updates/draws, two fixed ticks and one cleanup, with +valid interpolation. The fixture captures frame eight and exits after readback. +This lifecycle mode does not simulate Jolt; the existing scene/direct-2D modes +retain their independent physics acceptance. An older generic success message +in the first local log mentions Jolt; the mode-specific observations establish +the actual scope, and the harness message is corrected. + +The same fixture compiles for web and a recording-FFI probe verifies all hooks, +13 fixed ticks across eight supplied 1/60-second frames, interpolation and the +explicit startup-fault control. That probe uses real Perry WASM/runtime but no +renderer. Hosted browser rendering of this revised fixture remains required. +The Windows job now runs the pure native/WASM contract and retains its evidence; +the installed native gate adds this lifecycle mode to its existing two modes. +The browser gate requires lifecycle counters in addition to the exact image. + +The starter uses fixed simulation and previous/current interpolation. A fresh +installed CLI creates it with the default command and builds the unmodified +native template in 266.563 seconds. `npm start` builds/runs a bounded copy in +7.047 seconds: the 800x450 image shows the greeting and interpolated square, +the text asset loads, and cleanup runs once. Restoring the unmodified source +completes the full web build with its asset manifest. Seven installed runtime +and template sources match the candidate. This is local headless native and +web-build acceptance; full starter browser rendering is still unperformed. +Repository contracts and eight acceptance-control tests pass. Broader examples, +components, input/pause policy, +device-loss recovery, full starter browser assets/text and named-hardware +qualification remain separate. No issue is closed by these focused checks. diff --git a/docs/game-loop.md b/docs/game-loop.md index 9946ad4d..eba9da2f 100644 --- a/docs/game-loop.md +++ b/docs/game-loop.md @@ -40,5 +40,65 @@ Canonical examples use the palette's public uppercase names, such as The installed native gate verifies cleanup runs exactly once after its physics and capture fixture. Scheduler tests cover the asynchronous ordering, stop, failure, re-entry and stale-callback behavior without claiming browser rendering. -Actual compiled-game browser startup, a complete init/update/fixed-update/draw -lifecycle and the one-command starter remain open under #142/#74. +The compiled-game gate at #170 passes real browser rendering and its explicit +startup-fault control. Full starter assets/text and all canonical example runtimes +remain open under #142/#74. + +## Fixed game lifecycle + +`runGameLifecycle(game, options?)` provides optional `init`, `fixedUpdate`, +`update` and `cleanup` hooks plus required `draw`. The starter uses this entry. +Hooks are closures or free functions without a bound `this`; keep shared state +in their lexical scope. The engine still owns begin/end drawing around each frame. + +```ts +let previous = 0; +let position = 0; +runGameLifecycle({ + init: () => initWindow(800, 450, 'Fixed simulation'), + fixedUpdate: (dt, tick) => { + previous = position; + position += 60 * dt; + }, + draw: (alpha) => { + clearBackground(Colors.BLACK); + drawRect(previous + (position - previous) * alpha, 40, 20, 20, Colors.WHITE); + }, + cleanup: () => closeWindow(), +}); +``` + +Each frame runs zero or more fixed ticks, then one variable update, then one +draw. Tick numbers start at 1. Defaults are `fixedStepSeconds: 1 / 60`, +`maxFixedSteps: 8`, and `maxFrameSeconds: 0.25`. Variable update receives the +clamped frame delta. Fixed update receives the constant step; use it for physics +instead of feeding variable delta into a deterministic simulation. Draw receives +the remaining fraction of a tick, `alpha` in `[0, 1)`, for interpolation between +previous and current simulation state. Interpolation intentionally trails the +latest simulation by up to one tick. + +The clock clamps long frame deltas and drops whole ticks left after the catch-up +limit, retaining only the fractional remainder. A background tab therefore cannot +trigger an unbounded catch-up loop. This policy slows simulation relative to +wall time during overload; it is not a networking or lockstep guarantee. Pause +and focus policy stay with the game. Resize behavior is unchanged, and resource +restoration after device loss is still separate. + +`FixedStepClock` exposes `steps`, cumulative scheduled `ticks`, `alpha`, accepted +`deltaSeconds` and cumulative `droppedSeconds` for custom loops or diagnostics. +Its `advance(dt)` returns false for negative/nonfinite deltas or overflowing +accumulation, preserving accumulated time and interpolation. Per-frame steps +and accepted delta become zero on rejection. Settings require positive finite +times and an integer step limit from 1 through 1000. A `1e-9` relative tick +tolerance handles ordinary floating-point partition rounding; determinism here +means a fixed simulation step, not arbitrary cross-machine bitwise arithmetic. + +Invalid settings make `runGameLifecycle` report an error and return false before +init. A second active lifecycle is also rejected. A valid start returns true; +native returns after shutdown, while web returns after scheduling. Calling +`closeWindow()` from init prevents frame scheduling. Calling it during fixed +update or variable update suppresses later hooks in that frame. Cleanup runs +once after normal shutdown; no update runs after disposal. As with `runGame`, +fatal process termination cannot promise cleanup. General Perry 0.5.1220 throw +propagation remains limited; the browser gate's explicit FFI error control does +not establish a general language exception guarantee. diff --git a/docs/starter.md b/docs/starter.md index f3ffed95..fd5a9b37 100644 --- a/docs/starter.md +++ b/docs/starter.md @@ -59,20 +59,25 @@ executable. General long Windows project paths remain unqualified. ## Development and validation -The template separates init, variable update, draw and cleanup while using -`runGame` on both targets. See the [game-loop contract](game-loop.md). Edit, -stop and rerun to rebuild; automatic hot reload, fixed update and device-loss -recovery are not implemented by this command. +The template uses `runGameLifecycle` on both targets, with init, 60 Hz fixed +update, variable update, interpolated draw and cleanup. See the +[game-loop contract](game-loop.md). Edit, stop and rerun to rebuild; automatic +hot reload and device-loss recovery are not implemented by this command. -Local installed-package acceptance creates the project through the installed +The original #171 installed-package acceptance creates the project through the installed npm command, builds the unmodified template natively, and completes the full web build from that same source. A bounded copy adds capture/state/cleanup observations for native execution. It renders the 800x450 greeting and square, loads `assets/welcome.txt` and cleans up once on Radeon DX12. This does not prove -visible presentation, compiled-starter browser rendering or a clean machine's +visible presentation, the new fixed-lifecycle template, compiled-starter browser rendering or a clean machine's shader DLL packaging. The installed-package CI check verifies default creation from the exact packed engine and rejects missing compilers and unsupported targets. Unit tests cover manifest drift, existing-project preservation, asset inventory and the local server. See [the retained acceptance report](evidence/windows-starter-cli-v1.md). + +The fixed-lifecycle follow-up repeats fresh installed default creation, native +build and the bounded greeting/asset/cleanup run using the revised template, +then completes its full web build. See [the lifecycle evidence](evidence/fixed-game-lifecycle-v1.md). +The browser runtime still needs to qualify this complete starter. diff --git a/docs/windows-engine-plan.md b/docs/windows-engine-plan.md index 6819d677..d5509106 100644 --- a/docs/windows-engine-plan.md +++ b/docs/windows-engine-plan.md @@ -17,9 +17,9 @@ first nine-scene Radeon evidence are in draft PR #154. Follow-up work starts at | #127 Vulkan PT correctness | Three deterministic progressive and motion runs, both negative controls, finite intermediates, reset/lighting/rigid-motion checks, retained report | Canonical hardware gate, all four focused temporal tests, and CPU reference sanity check pass on Radeon/Vulkan; [report](evidence/issue-127-windows-vulkan-v1.md) and [raw evidence](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-155-windows-vulkan-20260910) published | | #128 Windows image discrepancies | Identify the first incorrect stage or document a reviewed backend-specific baseline decision; rerun the full strict corpus and reproducibility checks | Cutout and surface corrections pass all nine Radeon images. At #159 source `d610d6a`, full runs 2 and 3 pass every configured check and reproduce 257 artifacts byte-identically with matching metadata and timing differences inside existing noise bounds. Earlier invalid runs retain their failures; named hardware acceptance remains separate | | #135 / #149 temporal reconstruction | Enforced motion/producer/quality-preset corpus, representative scenes, fractional/native and frozen A/B timing, memory/resize checks, platform evidence | Device/resource, stationary SSGI, and profiler fixes are retained. The surface correction passes original HD startup limits and 154,720 analytic receiver checks on Vulkan, DX12, and hosted Metal; 93 local goldens pass, including lighting recovery. The full Radeon corpus passes twice. Wider representative scenes, frozen A/B performance, memory/resize, and platform acceptance remain open | -| #140 integration gates | Same required local/hosted lanes pass on exact source; release package startup and all-example evidence | #160 fixes silent Windows CI non-execution and MSVC PATH ordering. #161 passes the actual native engine build and all 20 native links locally and in hosted CI. #162 fixes the focused DX12 failures; #163 fixes camera-history reset. #164 passes all 22 hosted Tests jobs using an explicit FXC Windows lane. The underlying WARP/DXIL crash remains open. A separate layered-material correction passes the full local FXC shared suite and all 93 DXC/Vulkan goldens. Fresh installed headless scene/direct-2D rendering and cleanup pass through #169. Visible presentation and release packaging remain open. #170's initial Windows shared job again crashes despite FXC; serial mitigation awaits hosted qualification | +| #140 integration gates | Same required local/hosted lanes pass on exact source; release package startup and all-example evidence | #160 fixes silent Windows CI non-execution and MSVC PATH ordering. #161 passes the actual native engine build and all 20 native links locally and in hosted CI. #162 fixes the focused DX12 failures; #163 fixes camera-history reset. #164 passes all 22 hosted Tests jobs using an explicit FXC Windows lane. The underlying WARP/DXIL crash remains open. A separate layered-material correction passes the full local FXC shared suite and all 93 DXC/Vulkan goldens. Fresh installed headless scene/direct-2D rendering and cleanup pass through #169. Visible presentation and release packaging remain open. #170's initial Windows shared job again crashes despite FXC; the serial follow-up and #171 each pass all 22 hosted Tests jobs. The driver root cause remains open | | #138 capability fallback | Actual constrained-adapter startup and relevant forced-tier corpus, truthful capability outputs | Existing implementation/evidence preserved; physical constrained-limit acceptance still needs proof | -| PR integration | Reviewable changes, passing required checks, full issue evidence, merge-ready rendering branch | #147 and the stacked fixes #154–#170 remain drafts; no merge performed | +| PR integration | Reviewable changes, passing required checks, full issue evidence, merge-ready rendering branch | #147 and the stacked fixes #154–#171 remain drafts; no merge performed | ## Engine work retained in scope @@ -54,33 +54,27 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json ## Current next steps -1. **Finish real compiled-game browser acceptance (#74/#142).** - The [compiled-game gate](evidence/compiled-web-startup-v1.md) catches a gap - in the earlier JavaScript-driven renderer check: Perry returned success for - unresolved imports and emitted a game without the engine calls. The corrected - preparer installs the exact checkout as a dependency, rejects unresolved - imports and inspects the actual WASM import table. A local recording-FFI probe - passes callback/cleanup and explicit startup-fault controls; hosted rendering - must still produce the exact frame. Perry's plain throw propagation remains - a separate limitation found during this work. -2. **Qualify the Windows CI mitigation (#140).** - All 22 Tests jobs passed through #169. #170's initial shared-library job then - hit an access violation despite FXC. The next attempt serializes the Windows - harness while retaining every assertion. The local serial library passes - 489 tests with one existing ignored test; that does not establish the crash's - cause or qualify every helper on WARP. Physical Radeon DX12/DXC and Vulkan - image evidence remains distinct from hosted software rendering. -3. **Complete the starter and example experience (#142/#145).** - The installed web command works on Windows. Fresh native packages render - exact scene/direct-2D frames and simulate Jolt locally and in hosted CI. - [Shared cleanup, corrected example palettes and Pong pause replay](evidence/windows-game-cleanup-v1.md) - are [published at #169](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-starter-lifecycle-20260911). - All 20 canonical native examples link. The [starter command](starter.md) - creates a project from an installed package; its unmodified native/web builds - and bounded native greeting/asset/cleanup run pass locally. Default creation - now carries the exact engine archive, with hosted packaging checks pending. - All-example web/runtime acceptance, fixed updates, visible native presentation, - packaged DXC/DXIL and general Windows long-path support remain incomplete. +1. **Complete the starter and example runtime experience (#142/#74).** + [#170's real compiled game](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-compiled-web-20260911) + passes exact browser pixels, eight frames, one cleanup and its explicit startup + fault control. [#171's installed commands](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-starter-cli-20260911) + pass default project creation and setup-error controls in hosted CI. The full + starter's assets/text browser rendering and canonical example runtime matrix + remain open. All 20 native examples compile and link. +2. **Finish and qualify fixed lifecycle integration.** + The [fixed lifecycle candidate](evidence/fixed-game-lifecycle-v1.md) passes pure + native/WASM timing and hook-order contracts, plus exact installed rendering + and cleanup on Radeon DX12/Vulkan. Its revised installed starter passes native + build/render/asset/cleanup and the full web build locally. + New hosted checks require the lifecycle counters in native and browser games. + Pause/focus policy and device-loss recovery remain separate. +3. **Continue Windows integration and packaging (#140/#145).** + Both #170 and #171 pass all 22 hosted Tests jobs with a serial Windows harness. + The original access violations remain retained and their root cause unresolved. + Installed headless scene/direct-2D modes render exact frames, simulate Jolt and + clean up once. Visible presentation, packaged DXC/DXIL, clean-machine starter + setup and general Windows long paths remain incomplete. Physical Radeon + measurements and hosted software rendering remain distinct evidence. 4. **Complete wider graphics and performance acceptance.** [Two strict full Radeon runs at #159](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-ssgi-surface-20260911) pass all nine images and reproduce 257 artifacts byte-identically. Rerun diff --git a/scripts/ci-check.sh b/scripts/ci-check.sh index 325f156d..3c7e458d 100755 --- a/scripts/ci-check.sh +++ b/scripts/ci-check.sh @@ -287,6 +287,8 @@ run_component() { tools/ci/compile_web_game.py \ tools/ci/compiled_web_smoke.py \ tools/ci/test_compiled_web_smoke.py \ + tools/ci/fixed_step_smoke.py \ + tools/ci/test_fixed_step_smoke.py \ tools/ci/test_compile_examples.py "$python_cmd" -m unittest \ tools/quality/test_run.py \ @@ -300,6 +302,7 @@ run_component() { tools/ci/test_web_smoke.py \ tools/ci/test_compile_examples.py \ tools/ci/test_compiled_web_smoke.py \ + tools/ci/test_fixed_step_smoke.py \ -v hr "visual metric and fault-engine tests" cargo test --release --manifest-path tools/bloom-diff/Cargo.toml diff --git a/src/core/fixed_step.ts b/src/core/fixed_step.ts new file mode 100644 index 00000000..7421625a --- /dev/null +++ b/src/core/fixed_step.ts @@ -0,0 +1,56 @@ +import { isFiniteNumber } from './numbers'; + +/** Bounded fixed-step timing. All time values are in seconds. */ +export class FixedStepClock { + readonly stepSeconds: number; + readonly maxSteps: number; + readonly maxFrameSeconds: number; + readonly valid: boolean; + steps = 0; + ticks = 0; + alpha = 0; + deltaSeconds = 0; + droppedSeconds = 0; + private remainder = 0; + + constructor(stepSeconds: number = 1 / 60, maxSteps: number = 8, maxFrameSeconds: number = 0.25) { + this.stepSeconds = stepSeconds; + this.maxSteps = maxSteps; + this.maxFrameSeconds = maxFrameSeconds; + this.valid = isFiniteNumber(stepSeconds) && stepSeconds > 0 && + isFiniteNumber(maxSteps) && maxSteps >= 1 && maxSteps <= 1000 && Math.floor(maxSteps) === maxSteps && + isFiniteNumber(maxFrameSeconds) && maxFrameSeconds > 0; + } + + /** Returns false for invalid configuration or delta; no simulation time advances. */ + advance(deltaSeconds: number): boolean { + this.steps = 0; + this.deltaSeconds = 0; + if (!this.valid || !isFiniteNumber(deltaSeconds) || deltaSeconds < 0) return false; + const accepted = Math.min(deltaSeconds, this.maxFrameSeconds); + const combined = this.remainder + accepted; + if (!isFiniteNumber(combined)) return false; + this.deltaSeconds = accepted; + this.droppedSeconds = Math.min(1.7976931348623157e308, this.droppedSeconds + (deltaSeconds - accepted)); + this.remainder = combined; + // A tiny tolerance prevents an exact tick partition (e.g. 3 * 0.01) from + // missing its boundary solely because binary floating point rounded down. + const tolerance = this.stepSeconds * 1e-9; + while (this.steps < this.maxSteps && this.stepSeconds - this.remainder <= tolerance) { + this.remainder = Math.max(0, this.remainder - this.stepSeconds); + this.steps = this.steps + 1; + } + if (this.stepSeconds - this.remainder <= tolerance) { + const remainder = this.remainder % this.stepSeconds; + this.droppedSeconds = Math.min(1.7976931348623157e308, this.droppedSeconds + (this.remainder - remainder)); + this.remainder = remainder; + if (this.stepSeconds - this.remainder <= tolerance) { + this.droppedSeconds = Math.min(1.7976931348623157e308, this.droppedSeconds + this.remainder); + this.remainder = 0; + } + } + this.alpha = this.remainder / this.stepSeconds; + this.ticks = this.ticks + this.steps; + return true; + } +} diff --git a/src/core/game_lifecycle.ts b/src/core/game_lifecycle.ts new file mode 100644 index 00000000..684a7447 --- /dev/null +++ b/src/core/game_lifecycle.ts @@ -0,0 +1,73 @@ +import { FixedStepClock } from './fixed_step'; + +export interface GameLifecycle { + init?: () => void; + /** One simulation tick, numbered from 1. Use the supplied constant delta. */ + fixedUpdate?: (dt: number, tick: number) => void; + /** Once per rendered frame, after fixed updates; delta is clamped. */ + update?: (dt: number) => void; + /** Blend previous/current simulation state with alpha in [0, 1). */ + draw: (alpha: number) => void; + cleanup?: () => void; +} + +export interface GameLoopOptions { + fixedStepSeconds?: number; + maxFixedSteps?: number; + maxFrameSeconds?: number; +} + +/** Internal lifecycle state; the platform adapter owns begin/end drawing. */ +export class GameLifecycleDriver { + readonly clock: FixedStepClock; + private game: GameLifecycle; + private initialized = false; + private disposed = false; + + constructor(game: GameLifecycle, options?: GameLoopOptions) { + this.game = game; + let step = 1 / 60; + let maxSteps = 8; + let maxFrame = 0.25; + if (options !== undefined) { + if (options.fixedStepSeconds !== undefined) step = options.fixedStepSeconds; + if (options.maxFixedSteps !== undefined) maxSteps = options.maxFixedSteps; + if (options.maxFrameSeconds !== undefined) maxFrame = options.maxFrameSeconds; + } + this.clock = new FixedStepClock(step, maxSteps, maxFrame); + } + + initialize(): boolean { + if (!this.clock.valid || this.disposed || this.initialized) return false; + this.initialized = true; + // Perry WASM routes obj.callback() through named method dispatch. Reading + // the function first uses closure dispatch on both supported targets. + const init = this.game.init; + if (init !== undefined) init(); + return true; + } + + frame(dt: number, shouldStop: () => boolean): boolean { + if (!this.initialized || this.disposed || shouldStop()) return false; + if (!this.clock.advance(dt)) return false; + const firstTick = this.clock.ticks - this.clock.steps + 1; + const fixedUpdate = this.game.fixedUpdate; + for (let i = 0; i < this.clock.steps; i++) { + if (fixedUpdate !== undefined) fixedUpdate(this.clock.stepSeconds, firstTick + i); + if (shouldStop()) return false; + } + const update = this.game.update; + if (update !== undefined) update(this.clock.deltaSeconds); + if (shouldStop()) return false; + const draw = this.game.draw; + draw(this.clock.alpha); + return !shouldStop(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + const cleanup = this.game.cleanup; + if (this.initialized && cleanup !== undefined) cleanup(); + } +} diff --git a/src/core/index.ts b/src/core/index.ts index 1982f7a2..3b71853d 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,4 +1,10 @@ import { Color, Camera2D, Camera3D } from './types'; +import { GameLifecycleDriver, GameLifecycle, GameLoopOptions } from './game_lifecycle'; + +export { FixedStepClock } from './fixed_step'; +export type { GameLifecycle, GameLoopOptions } from './game_lifecycle'; +let lifecycleActive = false; +let lifecycleStopRequested = false; export type { Color, Vec2, Vec3, Vec4, Rect, Camera2D, Camera3D, Texture, Font, Sound, Music, Quat, Ray, BoundingBox, Model, Mat4, RayHit, FrustumPlanes } from './types'; // GH #53 — `Color` is deliberately NOT re-exported from './colors' any more. @@ -237,6 +243,7 @@ export function attachToSurface(handle: number, width: number, height: number): } export function closeWindow(): void { + lifecycleStopRequested = true; bloom_close_window(); } @@ -1465,6 +1472,41 @@ export function runGame(update: (dt: number) => void, cleanup?: () => void): voi } } +/** Shared init/fixed-update/update/draw/cleanup entry. Invalid setup returns false. */ +export function runGameLifecycle(game: GameLifecycle, options?: GameLoopOptions): boolean { + if (lifecycleActive) { + console.error('Bloom lifecycle is already active; stop it before starting another.'); + return false; + } + const driver = new GameLifecycleDriver(game, options); + if (!driver.clock.valid) { + console.error('Bloom lifecycle requires positive finite timing values and an integer maxFixedSteps from 1 to 1000.'); + return false; + } + lifecycleActive = true; + lifecycleStopRequested = false; + driver.initialize(); + const shouldStop = (): boolean => { + if (lifecycleStopRequested) return true; + // The web windowShouldClose bridge guards unsupported blocking source + // loops. The shared lifecycle observes its explicit stop request instead. + if (bloom_get_platform() === 7) return false; + return windowShouldClose(); + }; + if (shouldStop()) { + lifecycleActive = false; + driver.dispose(); + return true; + } + runGame((dt) => { + if (!driver.frame(dt, shouldStop)) closeWindow(); + }, () => { + lifecycleActive = false; + driver.dispose(); + }); + return true; +} + // Pure TS camera helpers export function getScreenToWorld2D(position: { x: number; y: number }, camera: Camera2D): { x: number; y: number } { diff --git a/src/core/numbers.ts b/src/core/numbers.ts new file mode 100644 index 00000000..471118d2 --- /dev/null +++ b/src/core/numbers.ts @@ -0,0 +1,6 @@ +/** Numeric finiteness without Number static intrinsics missing in Perry WASM. */ +export function isFiniteNumber(value: number): boolean { + // Finite values subtract to zero; NaN and either infinity subtract to NaN. + // Keep the type guard so JavaScript callers do not coerce strings or null. + return typeof value === 'number' && value - value === 0; +} diff --git a/src/index.ts b/src/index.ts index 737b2be6..6f5b78d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,7 +30,7 @@ export { ColorConstants, Colors, Key, MouseButton, injectKeyDown, injectKeyUp, isAnyInputPressed, getPlatform, isMobile, isTV, Platform, injectGamepadAxis, injectGamepadButtonDown, injectGamepadButtonUp, - runGame, + runGame, runGameLifecycle, FixedStepClock, setProfilerEnabled, getProfilerFrameCpuUs, getProfilerFrameGpuUs, printProfilerSummary, getProfilerOverlay, getProfilerFrameHistory, getMaterialBindingCapabilities, getRendererCapabilities, getImportedRefractionMode, @@ -41,6 +41,7 @@ export { } from './core/index'; export type { + GameLifecycle, GameLoopOptions, Rect, Camera2D, Camera3D, Texture, Font, Sound, Music, Quat, Ray, BoundingBox, Model, Mat4, RayHit, FrustumPlanes, diff --git a/src/quality/index.ts b/src/quality/index.ts index 1160238a..c59bf449 100644 --- a/src/quality/index.ts +++ b/src/quality/index.ts @@ -1,3 +1,4 @@ +import { isFiniteNumber } from '../core/numbers'; /** * Deterministic qualification-window helper used by Bloom's versioned scene * corpus. It is inert unless an example receives `--quality-run`. @@ -32,7 +33,7 @@ export interface QualityRunConfig { } function finitePositive(value: number, fallback: number): number { - return Number.isFinite(value) && value > 0 ? value : fallback; + return isFiniteNumber(value) && value > 0 ? value : fallback; } /** diff --git a/src/scene/index.ts b/src/scene/index.ts index 0dbcc393..43fada9e 100644 --- a/src/scene/index.ts +++ b/src/scene/index.ts @@ -1,3 +1,4 @@ +import { isFiniteNumber } from '../core/numbers'; /** * Bloom Scene Graph — Retained-mode 3D scene management. * @@ -286,7 +287,7 @@ export const MATERIAL_TEXTURE_SLOT_EMISSIVE = 3; export const MATERIAL_TEXTURE_SLOT_OCCLUSION = 4; function finiteOr(value: number, fallback: number): number { - return Number.isFinite(value) ? value : fallback; + return isFiniteNumber(value) ? value : fallback; } function unitMaterialValue(value: number, fallback: number): number { diff --git a/tools/ci/compiled_web_smoke.py b/tools/ci/compiled_web_smoke.py index c7e093fb..d8d5ee4a 100644 --- a/tools/ci/compiled_web_smoke.py +++ b/tools/ci/compiled_web_smoke.py @@ -81,6 +81,8 @@ def validate_state(name, state): if name == "game": if state["errors"] or state["frames"] != "8" or state["cleanups"] != "1": raise RuntimeError(f"compiled game failed startup/frame/cleanup acceptance: {state}") + from tools.ci.fixed_step_smoke import validate_game_lifecycle + validate_game_lifecycle(state.get("lifecycle"), expected_frames=8) elif state["expectedFault"] != "BLOOM_EXPECTED_STARTUP_FAILURE" or not any("RuntimeError: BLOOM_EXPECTED_STARTUP_FAILURE" in error for error in state["errors"]) or state["frames"] is not None or state["cleanups"] is not None: raise RuntimeError(f"intentional compiled startup failure was not rejected: {state}") @@ -163,6 +165,7 @@ def save(): " || !Array.isArray(globalThis.__compiledGameErrors)) return null; return ({" + """ frames: localStorage.getItem('bloom_fs:compiled-web-frames'), cleanups: localStorage.getItem('bloom_fs:compiled-web-cleanups'), + lifecycle: localStorage.getItem('bloom_fs:compiled-web-lifecycle'), expectedFault: localStorage.getItem('bloom_fs:compiled-web-expected-fault'), errors: globalThis.__compiledGameErrors || [], logs: globalThis.__compiledGameLog || [], diff --git a/tools/ci/fixed_step_smoke.py b/tools/ci/fixed_step_smoke.py new file mode 100644 index 00000000..4e029191 --- /dev/null +++ b/tools/ci/fixed_step_smoke.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Check actual native and WASM Perry timing/lifecycle observations.""" + +import argparse +import hashlib +import json +import math +import os +from pathlib import Path +import shutil +import subprocess +import time + +ROOT = Path(__file__).resolve().parents[2] +EXPECTED = { + "uniformTicks": 50, "variedTicks": 50, "uniformAlpha": 0.0, "variedAlpha": 0.0, + "longTicks": 2500, "longAlpha": 0.0, "cappedSteps": 3, "cappedAlpha": 0.5, + "cappedDropped": 0.205, "resumedTicks": 4, + "invalidConfig": True, "invalidCap": True, "rejectedNegative": True, + "rejectedNaN": True, "rejectedInfinity": True, "preservedAlpha": 0.5, + "events": "IUDFFUDC", "total": 0.04, "drawn": 0.03, "lastTick": 2, + "beforeInitRejected": True, "duplicateInitRejected": True, "afterDisposeRejected": True, + "stopReturned": True, "stopEvents": "FC", + "overflowRejected": True, "hugeAlpha": 0.9, "tinyBounded": True, + "finiteContract": True, "sparseStarted": True, "sparseDraws": 1, + "updateStopEvents": "UC", "invalidDriverRejected": True, "invalidCalls": 0, +} + + +def validate_observations(output): + lines = [line.removeprefix("BLOOM_FIXED_STEP_RESULT:") for line in output.splitlines() + if line.startswith("BLOOM_FIXED_STEP_RESULT:")] + if len(lines) != 1: + raise RuntimeError("fixture must emit exactly one result; compiler/process success is insufficient") + actual = json.loads(lines[0]) + if set(actual) != set(EXPECTED): + raise RuntimeError("fixture result fields differ from the required contract") + for key, expected in EXPECTED.items(): + value = actual[key] + if isinstance(expected, float): + good = type(value) in (int, float) and math.isfinite(value) and abs(value - expected) <= 1e-10 + else: + good = type(value) is type(expected) and value == expected + if not good: + raise RuntimeError(f"{key}: expected {expected!r}, found {value!r}") + return actual + + +def validate_game_lifecycle(value, expected_frames=None): + """Validate observations written by the real engine fixture's hooks.""" + if not isinstance(value, str) or len(value.split(',')) != 6: + raise RuntimeError('missing or malformed game lifecycle observations') + try: + init, updates, draws, fixed, tick = [int(v) for v in value.split(',')[:5]] + alpha = float(value.split(',')[5]) + except ValueError as error: + raise RuntimeError('malformed game lifecycle observations') from error + if (init != 1 or updates != draws or not 8 <= draws <= 120 or fixed < 1 or tick != fixed + or not math.isfinite(alpha) or not 0 <= alpha < 1 + or (expected_frames is not None and draws != expected_frames)): + raise RuntimeError(f'game lifecycle hooks failed: {value}') + return dict(init=init, updates=updates, draws=draws, fixed_updates=fixed, last_tick=tick, alpha=alpha) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--perry", default=os.environ.get("BLOOM_PERRY") or shutil.which("perry")) + parser.add_argument("--out", type=Path, default=ROOT / "target/ci/fixed-step") + args = parser.parse_args() + if not args.perry: + parser.error("Perry 0.5.1220 is required") + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=True) + report = {"schema": "bloom-fixed-game-lifecycle-v1", "status": "running", "commands": [], "cases": []} + def save(): + (out / "result.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + def run(name, command): + start = time.monotonic() + with (out / f"{name}.log").open("wb") as log: + result = subprocess.run(command, cwd=ROOT, stdout=log, stderr=subprocess.STDOUT, timeout=180) + report["commands"].append({"name": name, "command": command, "exit_code": result.returncode, + "duration_seconds": round(time.monotonic() - start, 3)}) + save() + output = (out / f"{name}.log").read_text(encoding="utf-8", errors="replace") + if result.returncode or "Could not resolve import" in output: + raise RuntimeError(f"{name}: process or import resolution failed; see retained log") + return output + try: + if run("compiler-version", [args.perry, "--version"]).strip() != "perry 0.5.1220": + raise RuntimeError("timing fixture requires Perry 0.5.1220") + report["source_commit"] = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip() + report["source_dirty"] = bool(subprocess.check_output(["git", "status", "--porcelain"], cwd=ROOT)) + report["compiler_sha256"] = hashlib.sha256(Path(args.perry).read_bytes()).hexdigest() + fixture = ROOT / "tools/ci/fixtures/fixed-step.ts" + report["source_sha256"] = { + name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() + for name in ("src/core/fixed_step.ts", "src/core/game_lifecycle.ts", "src/core/numbers.ts", + "tools/ci/fixtures/fixed-step.ts", "tools/ci/perry_wasm_console.cjs") + } + for mode in ("native", "wasm"): + artifact = out / ("fixed-step.html" if mode == "wasm" else "fixed-step.exe" if os.name == "nt" else "fixed-step") + artifact.unlink(missing_ok=True) + command = [args.perry, "compile", str(fixture)] + if mode == "wasm": + command += ["--target", "wasm"] + run(mode + "-compile", command + ["-o", str(artifact)]) + if not artifact.is_file() or artifact.stat().st_size == 0: + raise RuntimeError(f"{mode}: no fresh compiled artifact") + execute = ["node", str(ROOT / "tools/ci/perry_wasm_console.cjs"), str(artifact)] if mode == "wasm" else [str(artifact)] + observations = validate_observations(run(mode + "-run", execute)) + report["cases"].append({"mode": mode, "status": "pass", "observations": observations, + "artifact_sha256": hashlib.sha256(artifact.read_bytes()).hexdigest()}) + save() + report["status"] = "pass" + print("PASS: actual native and WASM Perry fixed-step/lifecycle contracts") + return 0 + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: + report.update(status="fail", error=str(error)) + print(f"FAIL: {error}") + return 1 + finally: + save() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ci/fixtures/compiled-web.ts b/tools/ci/fixtures/compiled-web.ts index 24e18195..272d01f2 100644 --- a/tools/ci/fixtures/compiled-web.ts +++ b/tools/ci/fixtures/compiled-web.ts @@ -1,6 +1,6 @@ import { - initWindow, runGame, clearBackground, closeWindow, setDirect2DMode, - setTargetFPS, writeFile, + initWindow, runGameLifecycle, clearBackground, closeWindow, setDirect2DMode, + setTargetFPS, writeFile, getPlatform, captureFrameToPng, isFrameCaptureReady, } from "@bloomengine/engine/core"; import { drawRect } from "@bloomengine/engine/shapes"; @@ -12,20 +12,40 @@ if (BLOOM_SMOKE_FAIL_STARTUP) { writeFile("compiled-web-expected-fault", "BLOOM_EXPECTED_STARTUP_FAILURE"); } -initWindow(128, 128, "Bloom compiled web startup"); -setTargetFPS(60); -setDirect2DMode(true); +const browser = getPlatform() === 7; +let inits = 0; +let updates = 0; +let fixedUpdates = 0; +let lastTick = 0; +let lastAlpha = 0; let frames = 0; let cleanups = 0; -runGame((_dt) => { - clearBackground({ r: 0, g: 0, b: 0, a: 255 }); - drawRect(32, 32, 64, 64, { r: 255, g: 255, b: 255, a: 255 }); - frames = frames + 1; - if (frames === 8) { - writeFile("compiled-web-frames", frames.toString()); - closeWindow(); - } -}, () => { - cleanups = cleanups + 1; - writeFile("compiled-web-cleanups", cleanups.toString()); -}); +runGameLifecycle({ + init: () => { + inits = inits + 1; + initWindow(128, 128, "Bloom compiled lifecycle startup"); + setTargetFPS(60); + setDirect2DMode(true); + }, + fixedUpdate: (_dt, tick) => { fixedUpdates = fixedUpdates + 1; lastTick = tick; }, + update: (_dt) => { updates = updates + 1; }, + draw: (alpha) => { + lastAlpha = alpha; + clearBackground({ r: 0, g: 0, b: 0, a: 255 }); + drawRect(32, 32, 64, 64, { r: 255, g: 255, b: 255, a: 255 }); + frames = frames + 1; + if (frames === 8) { + writeFile("compiled-web-frames", frames.toString()); + if (browser) closeWindow(); + else captureFrameToPng("native-startup.png"); + } + if (!browser && frames > 8 && isFrameCaptureReady()) closeWindow(); + if (frames >= 120) closeWindow(); + }, + cleanup: () => { + cleanups = cleanups + 1; + writeFile("compiled-web-cleanups", cleanups.toString()); + writeFile("compiled-web-lifecycle", inits + "," + updates + "," + frames + "," + fixedUpdates + "," + lastTick + "," + lastAlpha); + if (!browser) writeFile("native-cleanup.txt", cleanups.toString()); + }, +}, { fixedStepSeconds: 0.01 }); diff --git a/tools/ci/fixtures/fixed-step.ts b/tools/ci/fixtures/fixed-step.ts new file mode 100644 index 00000000..1c2df185 --- /dev/null +++ b/tools/ci/fixtures/fixed-step.ts @@ -0,0 +1,145 @@ +import { FixedStepClock } from '../../../src/core/fixed_step'; +import { GameLifecycleDriver } from '../../../src/core/game_lifecycle'; +import { isFiniteNumber } from '../../../src/core/numbers'; + +// Same pure TypeScript timing and lifecycle code executes in native Perry and +// generated game WASM. The host verifies observations against independent +// expected values; a compiler's zero exit does not constitute a passing test. +const uniform = new FixedStepClock(0.02, 8, 0.25); +const varied = new FixedStepClock(0.02, 8, 0.25); +for (let i = 0; i < 100; i++) uniform.advance(0.01); +for (let i = 0; i < 10; i++) { + varied.advance(0.007); + varied.advance(0.023); + varied.advance(0.04); + varied.advance(0.03); +} +const longRun = new FixedStepClock(1 / 60, 8, 0.25); +for (let i = 0; i < 6000; i++) longRun.advance(1 / 144); + +const capped = new FixedStepClock(0.02, 3, 0.25); +capped.advance(0.275); +const cappedSteps = capped.steps; +const cappedAlpha = capped.alpha; +const cappedDropped = capped.droppedSeconds; +capped.advance(0.01); + +const invalidConfig = new FixedStepClock(0, 8, 0.25); +const invalidCap = new FixedStepClock(0.02, 1.5, 0.25); +const invalidDelta = new FixedStepClock(0.02, 8, 0.25); +invalidDelta.advance(0.01); +const rejectedNegative = !invalidDelta.advance(-1); +const rejectedNaN = !invalidDelta.advance(0 / 0); +const rejectedInfinity = !invalidDelta.advance(1 / 0); + +let events = ''; +let total = 0; +let previous = 0; +let drawn = 0; +let lastTick = 0; +const driver = new GameLifecycleDriver({ + init: () => { events = events + 'I'; }, + fixedUpdate: (dt, tick) => { events = events + 'F'; previous = total; total = total + dt; lastTick = tick; }, + update: (_dt) => { events = events + 'U'; }, + draw: (alpha) => { events = events + 'D'; drawn = previous + (total - previous) * alpha; }, + cleanup: () => { events = events + 'C'; }, +}, { fixedStepSeconds: 0.02, maxFixedSteps: 8, maxFrameSeconds: 0.25 }); +const beforeInitRejected = !driver.frame(0.01, () => false); +driver.initialize(); +const duplicateInitRejected = !driver.initialize(); +driver.frame(0.01, () => false); +driver.frame(0.04, () => false); +driver.dispose(); +driver.dispose(); +const afterDisposeRejected = !driver.frame(0.01, () => false); + +let stopped = false; +let stopEvents = ''; +const stopping = new GameLifecycleDriver({ + fixedUpdate: (_dt, _tick) => { stopEvents = stopEvents + 'F'; stopped = true; }, + update: (_dt) => { stopEvents = stopEvents + 'U'; }, + draw: (_alpha) => { stopEvents = stopEvents + 'D'; }, + cleanup: () => { stopEvents = stopEvents + 'C'; }, +}, { fixedStepSeconds: 0.02 }); +stopping.initialize(); +const stopReturned = !stopping.frame(0.08, () => stopped); +stopping.dispose(); + +// Reject an overflowing accumulated delta without corrupting interpolation. +const huge = new FixedStepClock(1e308, 2, 1.7e308); +huge.advance(9e307); +const overflowRejected = !huge.advance(9e307); +const hugeAlpha = huge.alpha; +const tiny = new FixedStepClock(1e-300, 4, 0.25); +tiny.advance(0.1); +const tinyBounded = tiny.steps === 4 && tiny.alpha >= 0 && tiny.alpha < 1 && isFiniteNumber(tiny.droppedSeconds); +const finiteContract = isFiniteNumber(0) && isFiniteNumber(-0) && isFiniteNumber(1.7976931348623157e308) && + !isFiniteNumber(0 / 0) && !isFiniteNumber(1 / 0) && !isFiniteNumber(-1 / 0) && + !isFiniteNumber('1' as any); + +let sparseDraws = 0; +const sparse = new GameLifecycleDriver({ draw: (_alpha) => { sparseDraws = sparseDraws + 1; } }); +const sparseStarted = sparse.initialize(); +sparse.frame(0.01, () => false); +sparse.dispose(); + +let updateStopped = false; +let updateStopEvents = ''; +const stopInUpdate = new GameLifecycleDriver({ + update: (_dt) => { updateStopEvents = updateStopEvents + 'U'; updateStopped = true; }, + draw: (_alpha) => { updateStopEvents = updateStopEvents + 'D'; }, + cleanup: () => { updateStopEvents = updateStopEvents + 'C'; }, +}); +stopInUpdate.initialize(); +stopInUpdate.frame(0.01, () => updateStopped); +stopInUpdate.dispose(); + +let invalidCalls = 0; +const invalidDriver = new GameLifecycleDriver({ + init: () => { invalidCalls = invalidCalls + 1; }, + draw: (_alpha) => { invalidCalls = invalidCalls + 1; }, + cleanup: () => { invalidCalls = invalidCalls + 1; }, +}, { maxFixedSteps: 1001 }); +const invalidDriverRejected = !invalidDriver.initialize(); +invalidDriver.frame(0.01, () => false); +invalidDriver.dispose(); + +// Read each observation directly. The original object-literal JSON.stringify +// report returned undefined in Perry WASM; preserve that separately from the +// timing/lifecycle contract. Events contain only this fixture's fixed ASCII tags. +let result = "{"; +result = result + "\"uniformTicks\":" + (uniform.ticks); +result = result + ",\"variedTicks\":" + (varied.ticks); +result = result + ",\"uniformAlpha\":" + (uniform.alpha); +result = result + ",\"variedAlpha\":" + (varied.alpha); +result = result + ",\"longTicks\":" + (longRun.ticks); +result = result + ",\"longAlpha\":" + (longRun.alpha); +result = result + ",\"cappedSteps\":" + (cappedSteps); +result = result + ",\"cappedAlpha\":" + (cappedAlpha); +result = result + ",\"cappedDropped\":" + (cappedDropped); +result = result + ",\"resumedTicks\":" + (capped.ticks); +result = result + ",\"invalidConfig\":" + (!invalidConfig.valid); +result = result + ",\"invalidCap\":" + (!invalidCap.valid); +result = result + ",\"rejectedNegative\":" + (rejectedNegative); +result = result + ",\"rejectedNaN\":" + (rejectedNaN); +result = result + ",\"rejectedInfinity\":" + (rejectedInfinity); +result = result + ",\"preservedAlpha\":" + (invalidDelta.alpha); +result = result + ",\"events\":\"" + (events) + "\""; +result = result + ",\"total\":" + (total); +result = result + ",\"drawn\":" + (drawn); +result = result + ",\"lastTick\":" + (lastTick); +result = result + ",\"beforeInitRejected\":" + (beforeInitRejected); +result = result + ",\"duplicateInitRejected\":" + (duplicateInitRejected); +result = result + ",\"afterDisposeRejected\":" + (afterDisposeRejected); +result = result + ",\"stopReturned\":" + (stopReturned); +result = result + ",\"stopEvents\":\"" + (stopEvents) + "\""; +result = result + ",\"overflowRejected\":" + overflowRejected; +result = result + ",\"hugeAlpha\":" + hugeAlpha; +result = result + ",\"tinyBounded\":" + tinyBounded; +result = result + ",\"finiteContract\":" + finiteContract; +result = result + ",\"sparseStarted\":" + sparseStarted; +result = result + ",\"sparseDraws\":" + sparseDraws; +result = result + ",\"updateStopEvents\":\"" + updateStopEvents + "\""; +result = result + ",\"invalidDriverRejected\":" + invalidDriverRejected; +result = result + ",\"invalidCalls\":" + invalidCalls; +console.log("BLOOM_FIXED_STEP_RESULT:" + result + "}"); diff --git a/tools/ci/native_package_smoke.py b/tools/ci/native_package_smoke.py index 83162139..614ab921 100644 --- a/tools/ci/native_package_smoke.py +++ b/tools/ci/native_package_smoke.py @@ -18,6 +18,7 @@ ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from tools.quality.khronos_materials import png_rgb # noqa: E402 +from tools.ci.fixed_step_smoke import validate_game_lifecycle # noqa: E402 def npm_command() -> list[str]: @@ -54,14 +55,14 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--out", type=Path, default=ROOT / "target/ci/native-package") parser.add_argument("--backend", choices=["dx12", "vulkan"], action="append") - parser.add_argument("--mode", choices=["scene", "direct-2d"], action="append") + parser.add_argument("--mode", choices=["scene", "direct-2d", "lifecycle"], action="append") args = parser.parse_args() if os.name != "nt": parser.error("this installed-package smoke currently supports Windows") out = args.out.resolve() out.mkdir(parents=True, exist_ok=True) - report = {"schema": "bloom-native-package-smoke-v3", "status": "running", "commands": [], "frames": [], "binaries": [], - "scope": "Installed source package, native headless renderer and Jolt; window presentation and packaged DXC remain separate."} + report = {"schema": "bloom-native-package-smoke-v4", "status": "running", "commands": [], "frames": [], "binaries": [], + "scope": "Installed headless rendering; scene/direct-2D modes exercise Jolt and lifecycle mode checks hooks. Window presentation and packaged DXC remain separate."} def save() -> None: (out / "result.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") @@ -104,6 +105,8 @@ def run(name: str, command: list[str], cwd: Path, env: dict, timeout: int) -> st report["compiler_version"] = run("perry-version", [compiler, "--version"], ROOT, env, 30).strip() report["compiler_sha256"] = hashlib.sha256(Path(compiler).read_bytes()).hexdigest() report["source_commit"] = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip() + report["source_dirty"] = bool(subprocess.check_output(["git", "status", "--porcelain"], cwd=ROOT)) + report["selected_modes"] = args.mode or ["scene", "direct-2d", "lifecycle"] npm = npm_command() packed = json.loads(run("pack", npm + ["pack", "--json", "--ignore-scripts", "--pack-destination", str(temporary)], ROOT, env, 120))[0] archive = temporary / packed["filename"] @@ -121,7 +124,8 @@ def run(name: str, command: list[str], cwd: Path, env: dict, timeout: int) -> st report["installed_source_sha256"] = { name: hashlib.sha256((installed / name).read_bytes()).hexdigest() for name in ("native/shared/src/renderer/mod.rs", "native/shared/src/renderer/direct_frame.rs", - "native/shared/src/renderer/quality_capture.rs") + "native/shared/src/renderer/quality_capture.rs", "src/core/index.ts", + "src/core/fixed_step.ts", "src/core/game_lifecycle.ts", "src/core/numbers.ts") } jolt = project / "node_modules/@bloomengine/jolt-prebuilt" report["jolt_version"] = json.loads((jolt / "package.json").read_text())["version"] @@ -132,8 +136,10 @@ def run(name: str, command: list[str], cwd: Path, env: dict, timeout: int) -> st if fixture_text.count(mode_marker) != 1: raise RuntimeError("native fixture must have exactly one render-mode marker") ctypes.windll.kernel32.SetErrorMode(0x0002 | 0x8000) - for mode in args.mode or ["scene", "direct-2d"]: + for mode in args.mode or ["scene", "direct-2d", "lifecycle"]: entry = fixture_text.replace(mode_marker, "const BLOOM_SMOKE_DIRECT_2D = " + ("true;" if mode == "direct-2d" else "false;")) + if mode == "lifecycle": + entry = (ROOT / "tools/ci/fixtures/compiled-web.ts").read_text(encoding="utf-8") (project / "main.ts").write_text(entry, encoding="utf-8") binary = temporary / f"native-smoke-{mode}.exe" run("compile-" + mode, [compiler, "compile", "main.ts", "-o", str(binary)], project, env, 1800) @@ -161,10 +167,15 @@ def run(name: str, command: list[str], cwd: Path, env: dict, timeout: int) -> st raise RuntimeError("native startup exited without its required frame capture") capture = out / f"{name}.png" shutil.copyfile(png, capture) - report["frames"].append({"mode": mode, "backend": backend, "cleanup_count": 1, **check_frame(capture)}) + frame = {"mode": mode, "backend": backend, "cleanup_count": 1, **check_frame(capture)} + if mode == "lifecycle": + observation = run_dir / "compiled-web-lifecycle" + frame["lifecycle"] = validate_game_lifecycle(observation.read_text(encoding="utf-8")) + shutil.copyfile(observation, out / f"{name}.lifecycle.txt") + report["frames"].append(frame) save() report["status"] = "pass" - print("PASS: installed native package links, simulates Jolt, and renders its exact frame.") + print("PASS: selected installed native modes link, render the exact frame and complete their required callbacks.") return 0 except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as exc: report.update(status="fail", error=str(exc)) diff --git a/tools/ci/perry_wasm_console.cjs b/tools/ci/perry_wasm_console.cjs new file mode 100644 index 00000000..d12b639a --- /dev/null +++ b/tools/ci/perry_wasm_console.cjs @@ -0,0 +1,58 @@ +"use strict"; + +// Execute a pure TypeScript contract fixture's actual Perry WASM/runtime in +// Node. Minimal DOM objects satisfy runtime style setup; no browser, engine +// renderer, GPU, network or FFI stubs participate in the contract result. +const fs = require("node:fs"); +const vm = require("node:vm"); + +async function main() { + if (process.argv.length !== 3) throw new Error("Usage: perry_wasm_console.cjs "); + const html = fs.readFileSync(process.argv[2], "utf8"); + const encoded = html.match(/window\.__perryWasmB64\s*=\s*"([A-Za-z0-9+/=]+)"/); + if (!encoded) throw new Error("Perry output has no embedded WASM"); + const imports = WebAssembly.Module.imports(new WebAssembly.Module(Buffer.from(encoded[1], "base64"))); + if (imports.some(item => item.module === "ffi")) throw new Error("Pure timing fixture unexpectedly requires engine FFI"); + const scripts = [...html.matchAll(/' + '', encoding='utf-8') + result = subprocess.run(['node', str(helper), str(page)], capture_output=True, text=True, timeout=15) + with self.subTest(body=body): + self.assertEqual(result.returncode == 0, success, result.stderr) + + def test_actual_game_requires_every_hook_and_valid_interpolation(self): + validate_game_lifecycle('1,8,8,13,13,0.3', expected_frames=8) + validate_game_lifecycle('1,9,9,15,15,0') + for value in (None, '', '1,8,8,13,13', '0,8,8,13,13,0', '1,7,8,13,13,0', + '1,8,8,0,0,0', '1,8,8,13,12,0', '1,8,8,13,13,NaN', '1,8,8,13,13,1', + '1,9,9,15,15,0'): + with self.subTest(value=value), self.assertRaises(RuntimeError): + validate_game_lifecycle(value, expected_frames=8) + + def test_rejects_missing_duplicate_wrong_ticks_and_nonfinite_results(self): + line = "BLOOM_FIXED_STEP_RESULT:" + json.dumps(EXPECTED) + validate_observations(line) + for output in ("", line + "\n" + line): + with self.assertRaises(RuntimeError): + validate_observations(output) + for change in ({"variedTicks": 49}, {"cappedSteps": 12}, {"stopEvents": "FUDC"}, + {"events": "IUDFFUDCC"}, {"longAlpha": float("nan")}, {"invalidConfig": 1}): + with self.subTest(change=change), self.assertRaises(RuntimeError): + validate_observations("BLOOM_FIXED_STEP_RESULT:" + json.dumps({**EXPECTED, **change})) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/ci/test_starter_cli.cjs b/tools/ci/test_starter_cli.cjs index 1d4ba91a..0c7f854e 100644 --- a/tools/ci/test_starter_cli.cjs +++ b/tools/ci/test_starter_cli.cjs @@ -36,7 +36,7 @@ test("scaffold keeps pinned manifests, separate lifecycle and assets; refuses ex const config = readProject(directory); assert.equal(config.entry, fs.realpathSync(path.join(directory, "main.ts"))); const source = fs.readFileSync(config.entry, "utf8"); - assert.match(source, /runGame\(\(dt\) => \{ update\(dt\); draw\(\); \}, cleanup\)/); + assert.equal(source, fs.readFileSync(path.resolve(__dirname, "../cli/templates/main.ts"), "utf8")); assert.match(source, /readFile\("assets\/welcome.txt"\)/); assert.deepEqual(await assetManifest(path.join(directory, "assets")), ["assets/welcome.txt"]); await assert.rejects(() => createProject({ directory, install: false }), /already exists/); diff --git a/tools/cli/templates/README.md b/tools/cli/templates/README.md index 39056b24..2a67f3b5 100644 --- a/tools/cli/templates/README.md +++ b/tools/cli/templates/README.md @@ -29,15 +29,18 @@ platform SDK and packaging flow. Release builds omit Perry's debug-symbol flag; this command does not sign, install or publish a distributable release. Edit `main.ts`, stop and rerun the command to rebuild. There is no automatic hot -reload yet. The template has init, variable update, draw and cleanup functions. +reload yet. The template has init, fixed update, variable update, draw and cleanup +functions. Fixed updates run at 60 Hz with at most eight ticks per frame; excess +backlog is dropped. Draw interpolates between the previous and current tick. Cleanup runs after the final frame during normal shutdown. Focus loss does not -automatically pause the game; the template caps its variable delta at 0.1 seconds. -Fixed update and recovery after device loss are separate engine work. +automatically pause the game; the lifecycle caps variable delta at 0.25 seconds. +Recovery after device loss remains separate engine work. `assets/welcome.txt` demonstrates loading the same asset on native and web. Asset paths are relative to the project and are copied beside the generated binary or web page. Keep runtime resource disposal in `cleanup`. Do not put another -beginDrawing/endDrawing pair inside the runGame callback. +beginDrawing/endDrawing pair inside lifecycle hooks. Hooks use closures or free +functions; they do not receive a bound `this` object. Missing prerequisites, incompatible versions and manifest errors stop the build. Native failures retain the compiler's output; browser startup failures appear in diff --git a/tools/cli/templates/main.ts b/tools/cli/templates/main.ts index 12a23efb..e4dea9c7 100644 --- a/tools/cli/templates/main.ts +++ b/tools/cli/templates/main.ts @@ -1,11 +1,12 @@ import { - initWindow, runGame, closeWindow, clearBackground, setTargetFPS, + initWindow, runGameLifecycle, closeWindow, clearBackground, setTargetFPS, setDirect2DMode, getScreenWidth, getScreenHeight, readFile, Colors, } from "@bloomengine/engine/core"; import { drawRect } from "@bloomengine/engine/shapes"; import { drawText } from "@bloomengine/engine/text"; let elapsed = 0; +let previousElapsed = 0; let greeting = ""; function init(): void { @@ -16,14 +17,19 @@ function init(): void { if (greeting.length === 0) throw new Error("Missing starter asset: assets/welcome.txt"); } -function update(dt: number): void { - // Variable update, with a cap on time accumulated while a tab is hidden. - elapsed = elapsed + Math.min(dt, 0.1); +function fixedUpdate(dt: number): void { + previousElapsed = elapsed; + elapsed = elapsed + dt; } -function draw(): void { +function update(_dt: number): void { + // Read per-frame input and update non-simulation state here. +} + +function draw(alpha: number): void { clearBackground(Colors.BLACK); - const x = getScreenWidth() / 2 - 32 + Math.sin(elapsed) * 80; + const displayTime = previousElapsed + (elapsed - previousElapsed) * alpha; + const x = getScreenWidth() / 2 - 32 + Math.sin(displayTime) * 80; drawRect(x, getScreenHeight() / 2 - 32, 64, 64, Colors.WHITE); drawText(greeting, 24, 24, 24, Colors.WHITE); } @@ -33,5 +39,4 @@ function cleanup(): void { closeWindow(); } -init(); -runGame((dt) => { update(dt); draw(); }, cleanup); +runGameLifecycle({ init, fixedUpdate, update, draw, cleanup });