diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c8c4ddf..f0819dc6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -317,6 +317,19 @@ jobs: shell: pwsh run: python tools/ci/fixed_step_smoke.py + - name: Native and WASM scalar math contract + shell: pwsh + run: python tools/ci/scalar_math_smoke.py + + - name: Retain scalar math evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: scalar-math + path: target/ci/scalar-math + if-no-files-found: error + retention-days: 1 + - name: Retain fixed lifecycle evidence if: always() uses: actions/upload-artifact@v4 @@ -346,6 +359,32 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } rustup target add wasm32-unknown-unknown + - name: Compile unchanged browser examples + shell: pwsh + run: python tools/ci/compile_web_examples.py + + - name: Retain compiled browser examples + if: always() + uses: actions/upload-artifact@v4 + with: + name: bloom-web-examples-${{ github.run_id }} + path: target/ci/web-examples + if-no-files-found: error + retention-days: 1 + + - name: Native and WASM public constant contract + shell: pwsh + run: python tools/ci/palette_smoke.py + + - name: Retain public constant evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: public-constants + path: target/ci/palette + if-no-files-found: error + retention-days: 1 + - name: Windows / installed starter web run shell: pwsh env: @@ -384,6 +423,21 @@ jobs: CARGO_BUILD_JOBS: '2' run: ./scripts/ci-check.sh --full --component example-compile + - name: Windows / portable example startup + shell: pwsh + env: + CARGO_BUILD_JOBS: '2' + run: python tools/ci/native_example_smoke.py + + - name: Retain portable example startup evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: windows-native-examples + path: target/ci/native-examples + if-no-files-found: error + retention-days: 1 + - name: Retain executed example-check summary if: always() uses: actions/upload-artifact@v4 @@ -527,6 +581,9 @@ jobs: # a working hosted WebGPU presentation path. browser-smoke: needs: [build-web, build-windows] + # A later Windows native failure must not hide browser evidence for pages + # already compiled/uploaded. Missing or failed artifacts still fail here. + if: ${{ !cancelled() && needs.build-web.result == 'success' }} runs-on: macos-14 steps: - uses: actions/checkout@v4 @@ -556,6 +613,17 @@ jobs: - name: web / browser-smoke run: ./scripts/ci-check.sh --web --component browser-smoke + - name: Download compiled browser examples + if: ${{ !cancelled() }} + uses: actions/download-artifact@v4 + with: + name: bloom-web-examples-${{ github.run_id }} + path: target/ci/web-examples + + - name: web / portable example runtime + if: ${{ !cancelled() }} + run: python tools/ci/web_example_smoke.py + - name: Upload browser quality evidence if: always() uses: actions/upload-artifact@v4 diff --git a/docs/evidence/example-browser-v1.md b/docs/evidence/example-browser-v1.md new file mode 100644 index 00000000..ac30af09 --- /dev/null +++ b/docs/evidence/example-browser-v1.md @@ -0,0 +1,90 @@ +# Public constants and portable example browser acceptance + +The six shared-loop examples compile for WASM, but executing their original +game code found undefined RGBA arguments in every example. Reading the palette +implementation directly returns the correct values; importing it through the +public `bloom/core` barrel returns undefined bindings in Perry 0.5.1220 WASM. +The same probe finds undefined public `Key` and `MouseButton` values. Package-root +imports also lose palette, input, platform, cursor, texture-kind/filter and +material-slot constants. Native and WASM compilation alone do not prove those +public bindings are usable. + +Explicit initialized exports in the two barrels preserve the original names, +values, TypeScript types and shared object identities. The palette definitions +and numeric key values do not change. Actual native/WASM execution now verifies +all 24 colors through both naming styles and both public entry points, shared +color mutation/restoration, 102 input/platform/cursor values through each entry +point, and the ten package-root texture/material constants. Its console runner +can admit unused FFI imports only with guards that reject every engine call. +A separately compiled control calls `getPlatform()` and must fail at that guard. +Default pure timing/scalar fixtures still reject FFI imports entirely. + +The expanded browser preparer copies each original example byte-for-byte into +an explicit local dependency project, compiles it with the pinned Perry binary, +verifies the real WASM imports and splices the production bootstrap. Source and +page hashes survive the artifact transfer. Hosted Chrome uses the qualified +engine WASM package, normal physics bootstrap and the original game pages. + +The monitor observes actual FFI and Perry callback dispatch, then requests normal +engine stop after eight updates. It requires one completed cleanup, the intended +viewport and drawing in every frame. Undefined/non-finite drawing arguments and +missing key/mouse codes are failures. Space-blaster must initialize and close +audio; voxel-sandbox must restore cursor intent. Captures must satisfy the same +game-content checks as the native examples. The monitor does not replace game +draws, supply input values or inject a fake physics factory. Browser evidence runs +even if a later Windows native check fails; absent or invalid compiled artifacts +still fail, and the native job remains required. + +Local recording-FFI execution of the actual compiled games rejects all six +earlier pages and accepts all six after the shared binding correction. This +diagnostic uses the production scheduler, but does not load a browser, renderer, +GPU, audio device or network. The first local probe selected the wrong boot +script and failed before game execution; that setup failure is retained. The +audio observer was also corrected to use the actual `bloom_init_audio` and +`bloom_close_audio` names before hosted qualification. + +With the shared bindings corrected, all six native content/capture/cleanup +checks pass on Radeon 760M / DX12, and all 20 canonical examples compile/link. +The unchanged six WASM pages pass the final recording-FFI state verifier, +including audio and cursor cleanup. Actual fixed lifecycle and scalar math +contracts still pass in native/WASM execution. Fourteen Python acceptance +controls, four monitor tests and the repository contracts pass. The first +repository contract attempt failed because the new worktree had no initialized +Jolt submodule; it passes after checking out the existing pinned submodule. + +The initial #174 hosted run, 34576806162, passes the scalar contract and all +20 example links. All six native executions then fail with `TypeError: Expected +number for native f64 parameter`, after WARP adapter initialization and before +capture/cleanup. Its logs and reports remain retained. Local native success does +not replace this failed hosted requirement; the expanded public binding +correction and runtime gates require a fresh hosted run. + +Hosted run 34580890147 at `da31009` passes all six browser compilations and +actual browser content/cleanup for five games. Voxel-sandbox stops after two +frames because an initial pointer-lock request rejects with `WrongDocumentError`. +The bridge now handles both synchronous denial and promise rejection, preserves +intent for a later canvas click, and releases a late grant after cursor cleanup. +Three production-bridge unit controls cover rejection/retry, legacy behavior +and a grant arriving after cleanup. + +The same run's native public-constant fixture fails before output. Its retained +linker log shows multiple `perry_global_index_ts__*` and `index_ts__init` symbols +merged by MSVC despite a zero compiler exit. The fixture is on `C:` while its +linked engine is on `D:`. Perry's common-project-root calculation cannot find +a shared ancestor across drives and its module-name fallback collapses distinct +`index.ts` paths. Replaying the hosted executable locally reproduces the failure; +locally rebuilding with MSVC succeeds, so changing the linker alone is not a +demonstrated remedy. Test projects now live under the checkout's `target/ci`, +and native compilation rejects duplicate Perry module globals. The new check +rejects the actual hosted log while accepting both earlier local linker logs. +Both linker formats also have zero-exit failure controls. +The same duplicate-global pattern is present in all six compile logs from the +initial native failure. The revised local native/WASM constant contract and +all six native content/cleanup checks pass with the new project location. + +The same-drive and pointer-lock corrections require a fresh hosted run. +Eight startup frames do not qualify full gameplay or input interaction, pause/ +focus policy, resize/device loss, quality goldens or performance. The missing +test3d grid lines, broad generated API/export audit and general compiler +qualification remain open. No discrete-GPU acceptance is inferred from hosted +Metal or Windows software rendering. diff --git a/docs/evidence/example-loops-v1.md b/docs/evidence/example-loops-v1.md new file mode 100644 index 00000000..295a12c1 --- /dev/null +++ b/docs/evidence/example-loops-v1.md @@ -0,0 +1,73 @@ +# Shared example loops and scalar interpolation + +Six portable examples used blocking source loops, preventing the browser from +scheduling their frames. They now use the shared `runGame` callback and cleanup: +test3d, dungeon-crawl, isometric-rpg, kart-racer, space-blaster and voxel-sandbox. +The 3D test now closes its window, the space game closes audio during cleanup, +and the voxel game restores the cursor before closing. + +## Runtime findings and corrections + +Actual native execution exposed failures that compilation did not detect. + +- Perry 0.5.1220's integer specialization truncates fractional inputs in + arithmetic-only number functions. For example, native `lerp(10, 20, 0.25)` + returned 10, while WASM returned 12.5. Five quadratic/cubic easing functions + also returned incorrect values. An identity division keeps these public + functions on the compiler's floating-point path. The generated native trace + and earlier failed observations are retained. The division preserves the + mathematical operation and can be removed by LLVM after correct lowering. +- The same specialization loses globals inside arithmetic expressions. The + native voxel index returned 32 for both `(32, 0, 32)` and `(32, 5, 32)`, + overwriting unrelated terrain cells. LLVM showed multiplication by zero + instead of the captured dimension. Passing width/depth explicitly preserves + the intended packed layout and restores visible terrain. +- Combined interpolation calls and nested camera writes produced undefined + fields in the native isometric game. Local result variables preserve the + camera values in both camera examples. Isometric entities now retain map + coordinates only; screen coordinates are derived when drawing. This avoids + a separate observed failure where stored derived coordinates became undefined + during intervening draw loops, hiding the player and NPCs. + +The compiler binary and source remain unchanged. These corrections qualify +the stated engine/example paths; general numeric specialization and object +assignment behavior in arbitrary user programs remain compiler limitations. + +## Required checks and local observations + +`tools/ci/scalar_math_smoke.py` compiles and executes the public math code with +Perry as a native executable and as actual WASM. Eight sample points cover +fractional interpolation, both easing branches, endpoints and extrapolation, +with independent host expectations. NaN, infinity and signed-zero behavior also +pass. The WASM runner uses the production void-return compatibility and no +engine FFI, renderer, browser or network. CI retains this contract separately +from the existing fixed-step lifecycle check. + +`tools/ci/native_example_smoke.py` compiles bounded diagnostic copies of all six +examples against the actual checkout. It preserves their update/draw and cleanup +bodies, captures frame eight and stops after readback. All six pass locally on +Radeon 760M DX12 with nine frames and one completed cleanup each. The six +unchanged source entries also compile and splice to WASM with resolved engine +imports; that is compilation evidence, not browser execution. + +The native gate requires recognizable game content at the requested viewport, +including the isometric player and terrain, the space game's player, the dungeon +player/floor, a kart and track, the 3D cube and voxel terrain. Applying these +checks to the actual earlier voxel HUD-only and isometric missing-player images +rejects them; all six corrected native captures pass. Synthetic controls also +reject a nonblank HUD, missing player, incomplete readback and duplicate cleanup. +These checks do not replace approved renderer image goldens or prove gameplay. +The all-20 canonical native compile/link gate remains required. + +The first diagnostic native runs failed before rendering because DXC/DXIL was +not available to the process. Running the same binaries with the existing SDK +shader-runtime directory succeeded. Logs retain the failure and DLL hashes. +Automatic distribution of shader runtimes remains packaging work; diagnostic +crash dumps are private and excluded from publication. + +Full browser execution of these six examples, other canonical runtime entries, +input/gameplay coverage and visible native presentation remain open. Visual +inspection also found missing grid lines in test3d; the cube/content smoke does +not qualify that separate renderer defect. Current-source full strict graphics +qualification remains required before integration. Hosted results for this +candidate are pending; no issue is closed by the local checks. diff --git a/docs/windows-engine-plan.md b/docs/windows-engine-plan.md index 13828f71..e381a3e3 100644 --- a/docs/windows-engine-plan.md +++ b/docs/windows-engine-plan.md @@ -54,27 +54,35 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json ## Current next steps -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. - The [complete starter browser candidate](evidence/installed-starter-browser-v1.md) - now runs the installed web command, verifies its served files and retains the - unchanged site for hosted asset/text/render acceptance. Executing its real WASM - found a named-void-callback return conversion failure; the production bootstrap - bridge and expanded named-hook contracts pass locally. Hosted rendering is pending. +1. **Complete the example runtime experience (#142/#74).** + The installed one-command starter is qualified through #173 at `955ac5b`: + all 22 Tests jobs pass, including real asset/text/square browser rendering, + eight frames and one cleanup. #174's six native game-content/cleanup checks + and all 20 native links pass locally. Hosted run 34580890147 qualifies five + browser games; Voxel Sandbox stops on a rejected pointer-lock request. The + native public-constant fixture exposes colliding module names across `C:` + and `D:`. The follow-up handles pointer-lock denial, creates linked compiler + projects beside the checkout and rejects duplicate generated globals. Its + local native/WASM constants, six native games, six browser builds and all + 20 native links pass; the new hosted run is still required. Full gameplay, + input interaction and the broader canonical runtime matrix remain open. + A separate local grid-line candidate restores visibility on DX12/Vulkan, + but ten existing reconstruction/quality tests fail. It remains incomplete. 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. + [#172's published evidence](https://github.com/Bloom-Engine/engine/releases/tag/quality-evidence-fixed-lifecycle-20260911) + now includes all 22 passing hosted Tests jobs, the pure native/WASM contract, + three exact installed native images and the eight-frame compiled browser + lifecycle with one cleanup. The native package report preserves its dirty + checkout flag and seven verified installed runtime hashes. Later full starter + execution found a named-void callback failure addressed by #173; #172's web + build alone does not establish successful full starter browser execution. 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. + #170, #171 and #172 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 @@ -87,9 +95,39 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json temporal/geometry scenes, fractional/native and frozen A/B timing, memory, resize and constrained-adapter checks. Hosted Metal without timestamp queries cannot qualify GPU timing. Named discrete hardware acceptance stays open. -5. **Finish API generation, streaming, components, runtime UI and packaging**, then - prepare the draft stack for review and integration. The full issue requirements - in the table above govern completion; each subsystem already has some code. +5. **Finish the remaining engine systems in dependency order.** Establish the + safe generated API/UTF-8/ownership contracts (#141), then bounded asynchronous + asset streaming and cancellation (#137). Use those contracts to finish + component/prefab lifecycle and destruction safety (#143). Complete runtime UI + input/layout/accessibility (#144) and platform packaging (#145), then prepare + the draft stack for review and integration. The full issue requirements in + the table above govern completion; each subsystem already has some code. + +The [current example-loop candidate](evidence/example-loops-v1.md) migrates +test3d, dungeon-crawl, isometric-rpg, kart-racer, space-blaster and voxel-sandbox +onto the shared loop and cleanup. Native execution exposed fractional math, +voxel indexing and camera/derived-coordinate failures. Their corrections pass +the public scalar contract in native/WASM and six actual native render/content/ +cleanup checks. Earlier missing-content images are rejected by the new gate. +The all-20 native compile/link gate remains required. Hosted candidate acceptance, +actual example browser runtime, gameplay/input and the missing test3d grid lines +remain open; a nonblank startup image does not complete those requirements. + +The [public constants and browser runtime follow-up](evidence/example-browser-v1.md) +finds that WASM barrel re-exports lose palette and input bindings. Explicit +initialized exports retain shared values through `bloom/core` and the package +root, with actual native/WASM constant and alias contracts. All six compiled +games then pass recording-FFI argument checks. Real browser content/cleanup +acceptance is now required in CI. #174's initial hosted native checks all fail +before capture with a numeric-argument TypeError; that failure is preserved and +the shared-binding correction still requires hosted qualification. + +At `da31009`, hosted run 34580890147 qualifies five actual browser games; +voxel-sandbox stops on a rejected pointer-lock request. Its native constant +fixture exposes merged module globals when a temporary project on `C:` links +the checkout on `D:`. The follow-up keeps linked test projects beside the +checkout, rejects duplicate generated globals, and handles pointer-lock denial +without stopping rendering. Those corrections still need hosted acceptance. Local work continues on the Radeon 760M. An RTX 4080 is not a prerequisite for this implementation work. RTX-specific and physical constrained-adapter evidence diff --git a/examples/dungeon-crawl/main.ts b/examples/dungeon-crawl/main.ts index 041d7ccd..c0ca69b4 100644 --- a/examples/dungeon-crawl/main.ts +++ b/examples/dungeon-crawl/main.ts @@ -1,6 +1,5 @@ import { - initWindow, windowShouldClose, beginDrawing, endDrawing, - clearBackground, setTargetFPS, getDeltaTime, isKeyPressed, isKeyDown, + initWindow, runGame, clearBackground, setTargetFPS, isKeyPressed, isKeyDown, getScreenWidth, getScreenHeight, closeWindow, beginMode2D, endMode2D, } from "bloom/core"; import { Color, Colors, Key, Camera2D } from "bloom/core"; @@ -312,8 +311,7 @@ const camera: Camera2D = { }; // Main game loop -while (!windowShouldClose()) { - const dt = getDeltaTime(); +runGame((dt) => { if (player.hp > 0) { // Turn-based input @@ -348,7 +346,6 @@ while (!windowShouldClose()) { if (messageTimer > 0) messageTimer = messageTimer - dt; // Drawing - beginDrawing(); clearBackground({ r: 10, g: 10, b: 15, a: 255 }); beginMode2D(camera); @@ -416,8 +413,6 @@ while (!windowShouldClose()) { const restartMsg = "Press ENTER to try again"; drawText(restartMsg, SCREEN_WIDTH / 2 - measureText(restartMsg, 18) / 2, SCREEN_HEIGHT / 2 + 15, 18, Colors.LIGHTGRAY); } - - endDrawing(); -} - -closeWindow(); +}, () => { + closeWindow(); +}); diff --git a/examples/isometric-rpg/main.ts b/examples/isometric-rpg/main.ts index 3f5340ba..396a6b9f 100644 --- a/examples/isometric-rpg/main.ts +++ b/examples/isometric-rpg/main.ts @@ -1,6 +1,5 @@ import { - initWindow, windowShouldClose, beginDrawing, endDrawing, - clearBackground, setTargetFPS, getDeltaTime, isKeyPressed, isKeyDown, + initWindow, runGame, clearBackground, setTargetFPS, isKeyPressed, isKeyDown, getScreenWidth, getScreenHeight, closeWindow, getMouseX, getMouseY, isMouseButtonPressed, writeFile, fileExists, @@ -41,8 +40,6 @@ const ITEM_COIN = 4; interface Entity { mapX: number; mapY: number; - screenX: number; - screenY: number; name: string; hp: number; maxHp: number; @@ -81,7 +78,7 @@ const map: number[] = []; for (let i = 0; i < MAP_W * MAP_H; i++) map.push(T_GRASS); const player: Entity = { - mapX: 5, mapY: 5, screenX: 0, screenY: 0, + mapX: 5, mapY: 5, name: "Hero", hp: 30, maxHp: 30, attack: 8, defense: 3, friendly: true, dialogue: [], dialogueIndex: 0, }; @@ -188,7 +185,7 @@ function generateWorld(): void { // NPCs npcs.push({ - mapX: 4, mapY: 4, screenX: 0, screenY: 0, + mapX: 4, mapY: 4, name: "Elder", hp: 20, maxHp: 20, attack: 0, defense: 0, friendly: true, dialogue: [ @@ -199,24 +196,24 @@ function generateWorld(): void { dialogueIndex: 0, }); npcs.push({ - mapX: 12, mapY: 8, screenX: 0, screenY: 0, + mapX: 12, mapY: 8, name: "Merchant", hp: 15, maxHp: 15, attack: 0, defense: 0, friendly: true, dialogue: ["I sell potions and shields!", "Come back when you have gold."], dialogueIndex: 0, }); npcs.push({ - mapX: 16, mapY: 5, screenX: 0, screenY: 0, + mapX: 16, mapY: 5, name: "Goblin", hp: 12, maxHp: 12, attack: 5, defense: 1, friendly: false, dialogue: ["Grrrr!"], dialogueIndex: 0, }); npcs.push({ - mapX: 18, mapY: 7, screenX: 0, screenY: 0, + mapX: 18, mapY: 7, name: "Goblin", hp: 12, maxHp: 12, attack: 5, defense: 1, friendly: false, dialogue: ["Grrrr!"], dialogueIndex: 0, }); npcs.push({ - mapX: 17, mapY: 3, screenX: 0, screenY: 0, + mapX: 17, mapY: 3, name: "Goblin Chief", hp: 25, maxHp: 25, attack: 8, defense: 3, friendly: false, dialogue: ["You dare challenge me?!"], dialogueIndex: 0, }); @@ -311,8 +308,7 @@ const camera: Camera2D = { zoom: 1.0, }; -while (!windowShouldClose()) { - const dt = getDeltaTime(); +runGame((dt) => { if (showDialogue) { if (isKeyPressed(Key.SPACE) || isKeyPressed(Key.ENTER)) { @@ -346,23 +342,16 @@ while (!windowShouldClose()) { // Smooth camera const playerScreen = isoToScreen(player.mapX, player.mapY); - camera.target.x = lerp(camera.target.x, playerScreen.x, 6 * dt); - camera.target.y = lerp(camera.target.y, playerScreen.y, 6 * dt); + // Materialize call results before writing nested camera fields. The pinned + // native compiler corrupts this read/call/write expression when combined. + const targetX = lerp(camera.target.x, playerScreen.x, 6 * dt); + const targetY = lerp(camera.target.y, playerScreen.y, 6 * dt); + camera.target.x = targetX; + camera.target.y = targetY; if (messageTimer > 0) messageTimer = messageTimer - dt; - // Update NPC screen positions - for (let i = 0; i < npcs.length; i++) { - const s = isoToScreen(npcs[i].mapX, npcs[i].mapY); - npcs[i].screenX = s.x; - npcs[i].screenY = s.y; - } - const ps = isoToScreen(player.mapX, player.mapY); - player.screenX = ps.x; - player.screenY = ps.y; - // Drawing - beginDrawing(); clearBackground({ r: 20, g: 25, b: 30, a: 255 }); // Use camera for world rendering @@ -401,8 +390,10 @@ while (!windowShouldClose()) { // Draw NPCs for (let i = 0; i < npcs.length; i++) { if (npcs[i].hp <= 0) continue; - const sx = npcs[i].screenX * camera.zoom + ox; - const sy = npcs[i].screenY * camera.zoom + oy; + // Screen coordinates are derived for drawing, not persisted on entities. + const screen = isoToScreen(npcs[i].mapX, npcs[i].mapY); + const sx = screen.x * camera.zoom + ox; + const sy = screen.y * camera.zoom + oy; const size = 12 * camera.zoom; const bodyColor = npcs[i].friendly ? { r: 50, g: 150, b: 50, a: 255 } : { r: 200, g: 50, b: 50, a: 255 }; drawRect(sx - size / 2, sy - size + TILE_H * camera.zoom * 0.3, size, size * 1.5, bodyColor); @@ -414,8 +405,9 @@ while (!windowShouldClose()) { // Draw player { - const sx = player.screenX * camera.zoom + ox; - const sy = player.screenY * camera.zoom + oy; + const screen = isoToScreen(player.mapX, player.mapY); + const sx = screen.x * camera.zoom + ox; + const sy = screen.y * camera.zoom + oy; const size = 14 * camera.zoom; drawRect(sx - size / 2, sy - size + TILE_H * camera.zoom * 0.3, size, size * 1.5, { r: 50, g: 100, b: 255, a: 255 }); // Head @@ -470,8 +462,6 @@ while (!windowShouldClose()) { // Controls hint drawText("WASD/Arrows: Move | +/-: Zoom", SCREEN_WIDTH - 310, SCREEN_HEIGHT - 20, 12, { r: 150, g: 150, b: 150, a: 150 }); - - endDrawing(); -} - -closeWindow(); +}, () => { + closeWindow(); +}); diff --git a/examples/kart-racer/main.ts b/examples/kart-racer/main.ts index f9229f6b..bcb8eaa6 100644 --- a/examples/kart-racer/main.ts +++ b/examples/kart-racer/main.ts @@ -1,6 +1,5 @@ import { - initWindow, windowShouldClose, beginDrawing, endDrawing, - clearBackground, setTargetFPS, getDeltaTime, isKeyDown, isKeyPressed, + initWindow, runGame, clearBackground, setTargetFPS, isKeyDown, isKeyPressed, closeWindow, beginMode3D, endMode3D, } from "bloom/core"; import { Color, Colors, Key, Camera3D } from "bloom/core"; @@ -208,8 +207,7 @@ const camera: Camera3D = { projection: "perspective", }; -while (!windowShouldClose()) { - const dt = getDeltaTime(); +runGame((dt) => { const player = karts[0]; // Countdown @@ -266,16 +264,22 @@ while (!windowShouldClose()) { const camHeight = 10; const behindX = player.x - Math.cos(player.angle) * camDist; const behindZ = player.z - Math.sin(player.angle) * camDist; - camera.position.x = lerp(camera.position.x, behindX, 4 * dt); - camera.position.y = lerp(camera.position.y, camHeight, 4 * dt); - camera.position.z = lerp(camera.position.z, behindZ, 4 * dt); + // Keep interpolation calls separate from nested property writes for Perry's + // native read/call/write lowering, as in the isometric camera. + const cameraX = lerp(camera.position.x, behindX, 4 * dt); + const cameraY = lerp(camera.position.y, camHeight, 4 * dt); + const cameraZ = lerp(camera.position.z, behindZ, 4 * dt); const lookAhead = 8; - camera.target.x = lerp(camera.target.x, player.x + Math.cos(player.angle) * lookAhead, 6 * dt); + const targetX = lerp(camera.target.x, player.x + Math.cos(player.angle) * lookAhead, 6 * dt); + const targetZ = lerp(camera.target.z, player.z + Math.sin(player.angle) * lookAhead, 6 * dt); + camera.position.x = cameraX; + camera.position.y = cameraY; + camera.position.z = cameraZ; + camera.target.x = targetX; camera.target.y = 1; - camera.target.z = lerp(camera.target.z, player.z + Math.sin(player.angle) * lookAhead, 6 * dt); + camera.target.z = targetZ; // Drawing - beginDrawing(); clearBackground({ r: 100, g: 180, b: 255, a: 255 }); beginMode3D(camera); @@ -396,8 +400,6 @@ while (!windowShouldClose()) { if (raceTime < 5 && raceStarted) { drawText("WASD/Arrows to drive", 10, SCREEN_HEIGHT - 25, 16, { r: 200, g: 200, b: 200, a: 180 }); } - - endDrawing(); -} - -closeWindow(); +}, () => { + closeWindow(); +}); diff --git a/examples/space-blaster/main.ts b/examples/space-blaster/main.ts index 7321d72f..5204ec18 100644 --- a/examples/space-blaster/main.ts +++ b/examples/space-blaster/main.ts @@ -1,6 +1,5 @@ import { - initWindow, windowShouldClose, beginDrawing, endDrawing, - clearBackground, setTargetFPS, getDeltaTime, isKeyDown, isKeyPressed, + initWindow, runGame, clearBackground, setTargetFPS, isKeyDown, isKeyPressed, getScreenWidth, getScreenHeight, closeWindow, } from "bloom/core"; import { Color, Colors, Key } from "bloom/core"; @@ -181,8 +180,7 @@ setTargetFPS(60); initAudioDevice(); // Main game loop -while (!windowShouldClose()) { - const dt = getDeltaTime(); +runGame((dt) => { if (gameOver) { if (isKeyPressed(Key.ENTER)) { @@ -321,7 +319,6 @@ while (!windowShouldClose()) { } // Drawing - beginDrawing(); clearBackground({ r: 5, g: 5, b: 15, a: 255 }); // Stars @@ -408,9 +405,7 @@ while (!windowShouldClose()) { const restartText = "Press ENTER to restart"; drawText(restartText, SCREEN_WIDTH / 2 - measureText(restartText, 20) / 2, SCREEN_HEIGHT / 2 + 60, 20, Colors.LIGHTGRAY); } - - endDrawing(); -} - -closeAudioDevice(); -closeWindow(); +}, () => { + closeAudioDevice(); + closeWindow(); +}); diff --git a/examples/test3d/main.ts b/examples/test3d/main.ts index 1756565c..b129aca0 100644 --- a/examples/test3d/main.ts +++ b/examples/test3d/main.ts @@ -1,15 +1,15 @@ -import { initWindow, windowShouldClose, beginDrawing, endDrawing, clearBackground, setTargetFPS, drawText, drawCube, drawGrid, beginMode3D, endMode3D, Colors } from 'bloom'; +import { initWindow, closeWindow, runGame, clearBackground, setTargetFPS, drawText, drawCube, drawGrid, beginMode3D, endMode3D, Colors } from 'bloom'; initWindow(800, 600, "Bloom 3D Test"); setTargetFPS(60); -while (!windowShouldClose()) { - beginDrawing(); +runGame((dt) => { clearBackground(Colors.SNOW); beginMode3D({ position: { x: 10, y: 10, z: 10 }, target: { x: 0, y: 0, z: 0 }, up: { x: 0, y: 1, z: 0 }, fovy: 45, projection: "perspective" }); drawCube({ x: 0, y: 1, z: 0 }, 2, 2, 2, { r: 200, g: 50, b: 50, a: 255 }); drawGrid(10, 1.0); endMode3D(); drawText("Bloom 3D Test", 10, 10, 20, Colors.BLACK); - endDrawing(); -} +}, () => { + closeWindow(); +}); diff --git a/examples/voxel-sandbox/main.ts b/examples/voxel-sandbox/main.ts index 8a1516df..e73e58c7 100644 --- a/examples/voxel-sandbox/main.ts +++ b/examples/voxel-sandbox/main.ts @@ -1,8 +1,7 @@ import { - initWindow, windowShouldClose, beginDrawing, endDrawing, - clearBackground, setTargetFPS, getDeltaTime, isKeyDown, isKeyPressed, + initWindow, runGame, clearBackground, setTargetFPS, isKeyDown, isKeyPressed, isMouseButtonPressed, closeWindow, beginMode3D, endMode3D, - disableCursor, getMouseDeltaX, getMouseDeltaY, + disableCursor, enableCursor, getMouseDeltaX, getMouseDeltaY, } from "bloom/core"; import { Color, Colors, Key, Camera3D, MouseButton } from "bloom/core"; import { drawCube, drawCubeWires } from "bloom/models"; @@ -48,18 +47,20 @@ for (let i = 0; i < worldSizeX * WORLD_HEIGHT * worldSizeZ; i++) { blocks.push(BLOCK_AIR); } -function blockIndex(x: number, y: number, z: number): number { - return (y * worldSizeX * worldSizeZ) + (z * worldSizeX) + x; +// Explicit dimensions keep Perry's native integer specialization from replacing +// captured globals with zero. Coordinates still use the same packed layout. +function blockIndex(x: number, y: number, z: number, width: number, depth: number): number { + return (y * width * depth) + (z * width) + x; } function getBlock(x: number, y: number, z: number): number { if (x < 0 || x >= worldSizeX || y < 0 || y >= WORLD_HEIGHT || z < 0 || z >= worldSizeZ) return BLOCK_AIR; - return blocks[blockIndex(x, y, z)]; + return blocks[blockIndex(x, y, z, worldSizeX, worldSizeZ)]; } function setBlock(x: number, y: number, z: number, type: number): void { if (x < 0 || x >= worldSizeX || y < 0 || y >= WORLD_HEIGHT || z < 0 || z >= worldSizeZ) return; - blocks[blockIndex(x, y, z)] = type; + blocks[blockIndex(x, y, z, worldSizeX, worldSizeZ)] = type; } // Simple terrain generation using sine waves @@ -296,10 +297,9 @@ function drawHUD(): void { ); } -while (!windowShouldClose()) { - handleInput(getDeltaTime()); +runGame((dt) => { + handleInput(dt); - beginDrawing(); clearBackground({ r: 130, g: 200, b: 255, a: 255 }); beginMode3D(camera); @@ -314,7 +314,7 @@ while (!windowShouldClose()) { endMode3D(); drawHUD(); - endDrawing(); -} - -closeWindow(); +}, () => { + enableCursor(); + closeWindow(); +}); diff --git a/native/web/bloom_glue.js b/native/web/bloom_glue.js index 20515840..6f2a03ea 100644 --- a/native/web/bloom_glue.js +++ b/native/web/bloom_glue.js @@ -75,6 +75,24 @@ let manifestPaths = null; // Set when a manifest was loaded // ESC without delivering the keydown. Both mismatches are reconciled here. let wantPointerLock = false; +function requestDesiredPointerLock() { + const canvas = document.getElementById('bloom-canvas'); + if (!wantPointerLock || !canvas || document.pointerLockElement === canvas) return; + try { + // Permission, focus and document state can reject a request, including a + // game's initial request outside a user gesture. Keep the intent so the + // next canvas click retries; optional pointer lock must not stop rendering. + Promise.resolve(canvas.requestPointerLock?.()).then(() => { + // Cleanup may have enabled the cursor while the browser was deciding. + if (!wantPointerLock && document.pointerLockElement === canvas) { + document.exitPointerLock?.(); + } + }).catch(() => {}); + } catch { + // Older implementations can throw synchronously instead of rejecting. + } +} + /** * Idempotent engine bootstrap. Safe to call multiple times; only the first * call performs work. Returns the resolved Bloom wasm-bindgen module. @@ -319,7 +337,7 @@ function buildFfiImports() { }; imports.bloom_disable_cursor = () => { wantPointerLock = true; - document.getElementById('bloom-canvas')?.requestPointerLock?.(); + requestDesiredPointerLock(); bloom.bloom_disable_cursor(); }; imports.bloom_enable_cursor = () => { @@ -586,7 +604,7 @@ function setupDomBridge() { // released it on ESC, or never granted it because the request happened // outside a user gesture). if (wantPointerLock && document.pointerLockElement !== canvas) { - canvas.requestPointerLock?.(); + requestDesiredPointerLock(); } const b = domButtonToBloom[e.button]; if (b !== undefined) inputQueue.push({ kind: 'mouse', k: 'm' + b, code: b, up: false }); diff --git a/scripts/ci-check.sh b/scripts/ci-check.sh index 1e3e198e..ab3562ac 100755 --- a/scripts/ci-check.sh +++ b/scripts/ci-check.sh @@ -233,6 +233,8 @@ run_component() { node tools/validate-docs.js node --test tools/ci/test_web_build.cjs node --test tools/ci/test_game_loop.cjs + node --test tools/ci/test_example_monitor.cjs + node --test tools/ci/test_pointer_lock.cjs hr "file-size ratchet" node tools/check-file-lines.js ;; @@ -289,6 +291,16 @@ run_component() { tools/ci/test_compiled_web_smoke.py \ tools/ci/fixed_step_smoke.py \ tools/ci/test_fixed_step_smoke.py \ + tools/ci/scalar_math_smoke.py \ + tools/ci/test_scalar_math_smoke.py \ + tools/ci/palette_smoke.py \ + tools/ci/test_palette_smoke.py \ + tools/ci/example_runtime.py \ + tools/ci/native_example_smoke.py \ + tools/ci/test_example_runtime.py \ + tools/ci/compile_web_examples.py \ + tools/ci/web_example_smoke.py \ + tools/ci/test_web_example_smoke.py \ tools/ci/starter_package_smoke.py \ tools/ci/starter_web_run.py \ tools/ci/starter_browser_smoke.py \ @@ -308,6 +320,10 @@ run_component() { tools/ci/test_compile_examples.py \ tools/ci/test_compiled_web_smoke.py \ tools/ci/test_fixed_step_smoke.py \ + tools/ci/test_scalar_math_smoke.py \ + tools/ci/test_palette_smoke.py \ + tools/ci/test_example_runtime.py \ + tools/ci/test_web_example_smoke.py \ tools/ci/test_starter_browser_smoke.py \ -v hr "visual metric and fault-engine tests" diff --git a/src/core/index.ts b/src/core/index.ts index 3b71853d..b87670b5 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,4 +1,6 @@ import { Color, Camera2D, Camera3D } from './types'; +import { Colors as sharedColors, ColorConstants as sharedColorConstants } from './colors'; +import { Key as sharedKey, MouseButton as sharedMouseButton } from './keys'; import { GameLifecycleDriver, GameLifecycle, GameLoopOptions } from './game_lifecycle'; export { FixedStepClock } from './fixed_step'; @@ -19,8 +21,12 @@ export type { Color, Vec2, Vec3, Vec4, Rect, Camera2D, Camera3D, Texture, Font, // reads the palette through the name `Color` (i.e. `Color.Red`). `jump` imports // `Color` from here, but only ever in TYPE position (`const WHITE: Color = {...}`), // which the type re-export above still serves. -export { ColorConstants, Colors } from './colors'; -export { Key, MouseButton } from './keys'; +// Perry 0.5.1220's WASM barrel re-export leaves these object bindings undefined. +// Explicit bindings preserve the public names and the shared palette objects. +export const ColorConstants = sharedColorConstants; +export const Colors = sharedColors; +export const Key = sharedKey; +export const MouseButton = sharedMouseButton; // FFI declarations declare function bloom_init_window(width: number, height: number, title: number, fullscreen: number): void; diff --git a/src/index.ts b/src/index.ts index 6f5b78d5..47b730e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,37 @@ +import { + ColorConstants as coreColorConstants, Colors as coreColors, Key as coreKey, + MouseButton as coreMouseButton, CursorShape as coreCursorShape, Platform as corePlatform, +} from './core/index'; +import { + FILTER_LINEAR as filterLinear, FILTER_NEAREST as filterNearest, + TEXTURE_KIND_COLOR as textureKindColor, TEXTURE_KIND_NORMAL as textureKindNormal, + TEXTURE_KIND_DATA as textureKindData, +} from './textures/index'; +import { + MATERIAL_TEXTURE_SLOT_BASE_COLOR as materialSlotBaseColor, MATERIAL_TEXTURE_SLOT_NORMAL as materialSlotNormal, + MATERIAL_TEXTURE_SLOT_METALLIC_ROUGHNESS as materialSlotMetallicRoughness, + MATERIAL_TEXTURE_SLOT_EMISSIVE as materialSlotEmissive, MATERIAL_TEXTURE_SLOT_OCCLUSION as materialSlotOcclusion, +} from './scene/index'; + +// Explicit initialized bindings preserve runtime constants through Perry's WASM +// package-root imports. Object aliases retain their original shared identity. +export const ColorConstants = coreColorConstants; +export const Colors = coreColors; +export const Key = coreKey; +export const MouseButton = coreMouseButton; +export const CursorShape = coreCursorShape; +export const Platform = corePlatform; +export const FILTER_LINEAR = filterLinear; +export const FILTER_NEAREST = filterNearest; +export const TEXTURE_KIND_COLOR = textureKindColor; +export const TEXTURE_KIND_NORMAL = textureKindNormal; +export const TEXTURE_KIND_DATA = textureKindData; +export const MATERIAL_TEXTURE_SLOT_BASE_COLOR = materialSlotBaseColor; +export const MATERIAL_TEXTURE_SLOT_NORMAL = materialSlotNormal; +export const MATERIAL_TEXTURE_SLOT_METALLIC_ROUGHNESS = materialSlotMetallicRoughness; +export const MATERIAL_TEXTURE_SLOT_EMISSIVE = materialSlotEmissive; +export const MATERIAL_TEXTURE_SLOT_OCCLUSION = materialSlotOcclusion; + export { initWindow, closeWindow, windowShouldClose, resize, // Embedding (Perry UI `BloomView`, or any host that owns a native view). @@ -20,15 +54,14 @@ export { isTouchActive, getMaxTouchPoints, toggleFullscreen, setWindowTitle, setWindowIcon, disableCursor, enableCursor, getMouseDeltaX, getMouseDeltaY, getMouseWheel, getCharPressed, - setCursorShape, CursorShape, + setCursorShape, setClipboardText, getClipboardText, openFileDialog, saveFileDialog, writeFile, fileExists, readFile, getScreenToWorld2D, getWorldToScreen2D, // `Color` is not here: it is the RGBA type, re-exported as a type below. // The palette map is `Colors` / `ColorConstants` (GH #53). - ColorConstants, Colors, Key, MouseButton, - injectKeyDown, injectKeyUp, isAnyInputPressed, getPlatform, isMobile, isTV, Platform, + injectKeyDown, injectKeyUp, isAnyInputPressed, getPlatform, isMobile, isTV, injectGamepadAxis, injectGamepadButtonDown, injectGamepadButtonUp, runGame, runGameLifecycle, FixedStepClock, setProfilerEnabled, getProfilerFrameCpuUs, getProfilerFrameGpuUs, @@ -79,8 +112,7 @@ export { loadTexture, createTextureRgba8, unloadTexture, drawTexture, drawTexturePro, drawTextureRec, getTextureWidth, getTextureHeight, loadImage, imageResize, imageCrop, imageFlipH, imageFlipV, loadTextureFromImage, - genTextureMipmaps, setTextureFilter, FILTER_LINEAR, FILTER_NEAREST, - TEXTURE_KIND_COLOR, TEXTURE_KIND_NORMAL, TEXTURE_KIND_DATA, + genTextureMipmaps, setTextureFilter, loadTextureAsync, stageTextures, commitTexture, loadRenderTexture, loadRenderTextureKind, unloadRenderTexture, renderTextureGlsl, renderTextureSobel, @@ -146,9 +178,6 @@ export { setSceneNodeColor, setSceneNodePbr, setSceneNodeMaterial, setSceneNodeTexture, setSceneNodeMaterialTextureHandles, setSceneNodeMaterialTextureTransform, setSceneNodeMaterialTextureStrengths, - MATERIAL_TEXTURE_SLOT_BASE_COLOR, MATERIAL_TEXTURE_SLOT_NORMAL, - MATERIAL_TEXTURE_SLOT_METALLIC_ROUGHNESS, MATERIAL_TEXTURE_SLOT_EMISSIVE, - MATERIAL_TEXTURE_SLOT_OCCLUSION, setSceneNodeWaterMaterial, pickSceneAll, getSceneNodeTransform, getSceneNodeBounds, setSceneNodeUserData, getSceneNodeUserData, diff --git a/src/math/index.ts b/src/math/index.ts index b72e25ec..1465b88d 100644 --- a/src/math/index.ts +++ b/src/math/index.ts @@ -128,8 +128,12 @@ export function vec4Normalize(v: Vec4): Vec4 { // Scalar utilities +// Perry 0.5.1220 incorrectly specializes arithmetic-only number functions as +// i64, truncating fractional arguments. An identity division keeps these +// functions on its f64 path; LLVM can remove the division after lowering. +// Keep this until the actual native/WASM scalar contract qualifies its removal. export function lerp(a: number, b: number, t: number): number { - return a + (b - a) * t; + return (a + (b - a) * t) / 1; } export function clamp(value: number, min: number, max: number): number { @@ -152,24 +156,16 @@ export function randomInt(min: number, max: number): number { // Easing functions -export function easeInQuad(t: number): number { return t * t; } -export function easeOutQuad(t: number): number { return t * (2 - t); } -// BROKEN under Perry — EN-051. The parameter never arrives: `t < 0.5` is false -// for every input, so this returns a constant. Adding a `console.log(t)` to the -// body makes it correct, which is the signature of a codegen bug, not a logic -// one. Rewriting with `if`, reordering the expression, and binding `t` to a -// local were all tried and none of them fix it. `easeInOutCubic` below is the -// same shape and is fine, so the shape is not the trigger. -// -// Left in its honest form rather than contorted around a bug I cannot explain. -// Nothing in the shooter or the editor calls it. See shooter -// docs/perry-quirks.md #8, Case B. +export function easeInQuad(t: number): number { return (t * t) / 1; } +export function easeOutQuad(t: number): number { return (t * (2 - t)) / 1; } +// EN-051 had the same integer-specialization cause as lerp. Both branches +// need the f64 lowering constraint; changing control-flow shape is insufficient. export function easeInOutQuad(t: number): number { - if (t < 0.5) return 2 * t * t; - return (4 - 2 * t) * t - 1; + if (t < 0.5) return (2 * t * t) / 1; + return ((4 - 2 * t) * t - 1) / 1; } -export function easeInCubic(t: number): number { return t * t * t; } -export function easeOutCubic(t: number): number { const t1 = t - 1; return t1 * t1 * t1 + 1; } +export function easeInCubic(t: number): number { return (t * t * t) / 1; } +export function easeOutCubic(t: number): number { const t1 = t - 1; return (t1 * t1 * t1 + 1) / 1; } export function easeInOutCubic(t: number): number { if (t < 0.5) return 4 * t * t * t; return 1 - Math.pow(-2 * t + 2, 3) / 2; diff --git a/tools/ci/compile_examples.py b/tools/ci/compile_examples.py index a4dbac05..83c1c455 100644 --- a/tools/ci/compile_examples.py +++ b/tools/ci/compile_examples.py @@ -22,6 +22,13 @@ REPORT_SCHEMA = "bloom-example-compile-v2" +def reject_duplicate_module_globals(output: str) -> None: + # Perry can return success after the Windows linker merges unrelated TS + # module globals. Ordinary duplicate Rust archive symbols are distinct. + if re.search(r"(?:duplicate symbol:|warning LNK4006:)\s+perry_global_", output): + raise RuntimeError("linker merged duplicate Perry module globals; check cross-drive source paths") + + def load_inventory() -> tuple[list[str], list[str]]: failures: list[str] = [] try: @@ -201,6 +208,12 @@ def main() -> int: elif not is_native_binary(fresh_output): error = "compiler did not produce a new native executable" else: + stdout.flush() + stderr.flush() + reject_duplicate_module_globals( + (log_dir / f"{name}.stdout.log").read_text(encoding="utf-8", errors="replace") + + (log_dir / f"{name}.stderr.log").read_text(encoding="utf-8", errors="replace") + ) fresh_output.replace(output) except (OSError, RuntimeError, subprocess.SubprocessError) as exc: error = str(exc) diff --git a/tools/ci/compile_web_examples.py b/tools/ci/compile_web_examples.py new file mode 100644 index 00000000..4f43c21e --- /dev/null +++ b/tools/ci/compile_web_examples.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Compile unchanged portable examples for the hosted browser runtime gate.""" + +import argparse +import base64 +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from tools.ci.compile_examples import load_inventory +from tools.ci.example_runtime import EXAMPLES +from tools.ci.native_package_smoke import npm_command + + +def validate_imports(log, imports): + required = {'bloom_init_window', 'bloom_run_game_with_cleanup', 'bloom_close_window', 'bloom_clear_background'} + actual = {item['name'] for item in imports if item['module'] == 'ffi'} + if 'Could not resolve import' in log or required - actual: + raise RuntimeError(f'example imports are unresolved or missing: {sorted(required - actual)}') + if not any(name.startswith('bloom_draw_') for name in actual): + raise RuntimeError('compiled example has no engine drawing imports') + return sorted(actual) + + +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/web-examples') + 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) + if (out / 'result.json').exists(): parser.error('choose a fresh output directory for this compilation') + report = dict(schema='bloom-compiled-web-examples-v1', status='running', commands=[], examples=[]) + # Use the same drive as the linked engine for stable module source paths. + parent = (ROOT / 'target/ci').resolve() + parent.mkdir(parents=True, exist_ok=True) + temporary = Path(tempfile.mkdtemp(prefix='bwe-', dir=parent)).resolve() + + def save(): (out / 'result.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + + def run(name, command): + record = dict(name=name, command=command, cwd=str(temporary)) + report['commands'].append(record) + start = time.monotonic() + save() + try: + with (out / (name + '.log')).open('wb') as log: + process = subprocess.run(command, cwd=temporary, stdout=log, stderr=subprocess.STDOUT, timeout=180) + record['exit_code'] = process.returncode + if process.returncode: raise RuntimeError(f'{name} exited {process.returncode}; see retained log') + return (out / (name + '.log')).read_text(encoding='utf-8', errors='replace') + finally: + record['duration_seconds'] = round(time.monotonic() - start, 3) + save() + + try: + inventory, failures = load_inventory() + if failures or any('examples/' + name not in inventory for name in EXAMPLES): + raise RuntimeError(f'canonical example inventory is invalid: {failures}') + 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_version'] = run('compiler-version', [args.perry, '--version']).strip() + if report['compiler_version'] != 'perry 0.5.1220': raise RuntimeError('examples require Perry 0.5.1220') + report['compiler_sha256'] = hashlib.sha256(Path(args.perry).read_bytes()).hexdigest() + manifest = dict(name='bloom-web-example-smoke', private=True, dependencies={'bloom': 'file:' + ROOT.as_posix()}, + perry={'allow': {'nativeLibrary': ['bloom', 'bloom/*']}}) + (temporary / 'package.json').write_text(json.dumps(manifest) + '\n', encoding='utf-8') + run('install', npm_command() + ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', '--install-links=false']) + if (temporary / 'node_modules/bloom').resolve() != ROOT.resolve(): + raise RuntimeError('example dependency must resolve to this exact checkout') + for name in EXAMPLES: + case = dict(name=name, status='running') + report['examples'].append(case) + try: + original = ROOT / 'examples' / name / 'main.ts' + source = original.read_bytes() + entry = temporary / (name + '.ts') + entry.write_bytes(source) + (out / (name + '.ts')).write_bytes(source) + case['source_sha256'] = hashlib.sha256(source).hexdigest() + raw, page = out / (name + '.perry.html'), out / (name + '.html') + log = run(name + '-compile', [args.perry, 'compile', str(entry), '--target', 'wasm', '-o', str(raw)]) + encoded = re.search(r'window\.__perryWasmB64\s*=\s*"([A-Za-z0-9+/=]+)"', raw.read_text(encoding='utf-8')) + if encoded is None: raise RuntimeError('compiler output has no game WASM') + wasm = base64.b64decode(encoded[1], validate=True) + binary = out / (name + '.wasm') + binary.write_bytes(wasm) + inspect = "const fs=require('node:fs');process.stdout.write(JSON.stringify(WebAssembly.Module.imports(new WebAssembly.Module(fs.readFileSync(process.argv[1])))));" + imports = json.loads(run(name + '-imports', ['node', '-e', inspect, str(binary)])) + case['engine_ffi_imports'] = validate_imports(log, imports) + run(name + '-splice', ['node', str(ROOT / 'native/web/splice_game.cjs'), str(raw), str(page)]) + case.update(game_wasm_sha256=hashlib.sha256(wasm).hexdigest(), html_sha256=hashlib.sha256(page.read_bytes()).hexdigest(), + source_unchanged=original.read_bytes() == source) + if not case['source_unchanged']: raise RuntimeError('original example changed during compilation') + case['status'] = 'pass' + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: + case.update(status='fail', error=str(error)) + save() + report['status'] = 'pass' if all(case['status'] == 'pass' for case in report['examples']) else 'fail' + print(report['status'].upper() + ': six unchanged examples compiled and spliced for browser acceptance') + return 0 if report['status'] == 'pass' else 1 + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: + report.update(status='fail', error=str(error)) + print(f'FAIL: {error}') + return 1 + finally: + save() + if temporary.parent != parent or temporary.is_symlink() or temporary.is_junction(): + raise RuntimeError('refusing cleanup outside the owned web example project') + shutil.rmtree(temporary) + + +if __name__ == '__main__': raise SystemExit(main()) diff --git a/tools/ci/example_monitor.js b/tools/ci/example_monitor.js new file mode 100644 index 00000000..b8d208f5 --- /dev/null +++ b/tools/ci/example_monitor.js @@ -0,0 +1,79 @@ +// Observe the original game's real FFI/closure boundaries. Stop normally after +// eight callbacks; production rendering, input, audio and physics stay active. +(() => { + const state = globalThis.__exampleProbe = { + frames: 0, cleanups: 0, registrations: 0, stopped: false, + errors: [], logs: [], badArguments: [], drawCalls: 0, frameDraws: [], calls: {}, windows: [], + }; + const describe = value => String(value?.stack || value).slice(0, 16384); + const record = value => { if (state.errors.length < 32) state.errors.push(describe(value)); }; + for (const level of ['error', 'warn', 'log']) { + const original = console[level]; + console[level] = (...args) => { + if (level === 'error') record(args.map(describe).join(' ')); + else if (state.logs.length < 100) state.logs.push(level + ': ' + args.map(describe).join(' ')); + original.apply(console, args); + }; + } + addEventListener('error', event => record(event.error || event.message)); + addEventListener('unhandledrejection', event => record(event.reason)); + if (globalThis.GPU) { + const request = GPU.prototype.requestAdapter; + GPU.prototype.requestAdapter = async function(...args) { + const adapter = await request.apply(this, args); + globalThis.__exampleAdapter = adapter; + return adapter; + }; + } + let ffi; + Object.defineProperty(globalThis, '__ffiImports', { + configurable: true, + get: () => ffi, + set: value => { + for (const name of Object.keys(value)) { + const draw = /^bloom_(draw_|clear_background|begin_mode_[23]d)/.test(name); + const input = /^bloom_is_(key|mouse_button)_/.test(name); + const resource = ['bloom_init_window', 'bloom_close_window', 'bloom_init_audio', + 'bloom_close_audio', 'bloom_disable_cursor', 'bloom_enable_cursor'].includes(name); + if (!draw && !resource && !input) continue; + const original = value[name]; + value[name] = (...args) => { + state.calls[name] = (state.calls[name] || 0) + 1; + if (name === 'bloom_init_window') state.windows.push(args.slice(0, 2)); + if (draw || input) { + if (name.startsWith('bloom_draw_')) state.drawCalls++; + if (args.some(v => v === undefined || v === null || (typeof v === 'number' && !Number.isFinite(v)))) { + if (state.badArguments.length < 8) state.badArguments.push({ name, args: args.map(describe) }); + } + } + return original(...args); + }; + } + let updateHandle, cleanupHandle; + const register = value.bloom_run_game_with_cleanup; + value.bloom_run_game_with_cleanup = (update, cleanup) => { + state.registrations++; + updateHandle = update; + cleanupHandle = cleanup; + return register(update, cleanup); + }; + const call = globalThis.callWasmClosure; + if (typeof call !== 'function') throw new Error('Example monitor requires the real Perry dispatcher'); + globalThis.callWasmClosure = (callback, ...args) => { + const before = state.drawCalls; + const result = call(callback, ...args); + if (callback === cleanupHandle) state.cleanups++; + if (callback === updateHandle) { + state.frameDraws.push(state.drawCalls - before); + state.frames++; + if (state.frames === 8) { + state.stopped = true; + value.bloom_close_window(); + } + } + return result; + }; + ffi = value; + }, + }); +})(); diff --git a/tools/ci/example_runtime.py b/tools/ci/example_runtime.py new file mode 100644 index 00000000..19a5f8eb --- /dev/null +++ b/tools/ci/example_runtime.py @@ -0,0 +1,89 @@ +"""Shared startup content checks for the six portable game examples. + +These detect missing game content, including the retained HUD-only failures. +They do not replace renderer quality goldens or prove complete gameplay. +""" + +import hashlib +import re + +from tools.quality.khronos_materials import png_rgb + +EXAMPLES = { + 'test3d': (800, 600), + 'dungeon-crawl': (800, 600), + 'isometric-rpg': (960, 640), + 'kart-racer': (960, 540), + 'space-blaster': (800, 600), + 'voxel-sandbox': (960, 540), +} + +NATIVE_WRAPPER = '''import { runGame as __bloomSmokeRun, closeWindow as __bloomSmokeClose, + captureFrameToPng as __bloomSmokeCapture, isFrameCaptureReady as __bloomSmokeReady, + writeFile as __bloomSmokeWrite } from "bloom/core"; +let __bloomSmokeFrames = 0; +let __bloomSmokeCleanups = 0; +let __bloomSmokeRequested = false; +function __bloomExampleSmoke(update: (dt: number) => void, cleanup: () => void): void { + __bloomSmokeRun((dt) => { + update(dt); + __bloomSmokeFrames = __bloomSmokeFrames + 1; + if (__bloomSmokeFrames === 8) __bloomSmokeRequested = __bloomSmokeCapture("frame.png"); + if (__bloomSmokeFrames > 8 && __bloomSmokeReady()) __bloomSmokeClose(); + if (__bloomSmokeFrames >= 120) __bloomSmokeClose(); + }, () => { + cleanup(); + __bloomSmokeCleanups = __bloomSmokeCleanups + 1; + __bloomSmokeWrite("state.txt", __bloomSmokeFrames + "," + __bloomSmokeCleanups + "," + __bloomSmokeRequested); + }); +} +''' + + +def bounded_native_source(source): + # Limit adaptation to one known public loop call; preserve both bodies. + if '__bloomSmoke' in source or len(re.findall(r'\brunGame\(', source)) != 1: + raise RuntimeError('example must contain exactly one uninstrumented runGame call') + return NATIVE_WRAPPER + re.sub(r'\brunGame\(', '__bloomExampleSmoke(', source) + + +def validate_native_state(value): + fields = value.split(',') + if len(fields) != 3 or not fields[0].isdigit() or not 9 <= int(fields[0]) < 120 or fields[1:] != ['1', 'true']: + raise RuntimeError(f'example did not capture and complete one cleanup before its frame limit: {value}') + return dict(frames=int(fields[0]), cleanups=1, capture_requested=True) + + +def check_pixels(name, width, height, pixels): + if name not in EXAMPLES or (width, height) != EXAMPLES[name] or len(pixels) != width * height: + raise RuntimeError(f'{name}: capture does not have the complete expected viewport') + counts = dict(red=0, green=0, blue=0, dungeon_floor=0, player=0) + for index, (r, g, b) in enumerate(pixels): + x, y = index % width, index // width + if 60 <= y < height - 40: + counts['red'] += r > 100 and r > g * 1.25 and r > b * 1.25 + counts['green'] += g > 60 and g > r * 1.2 and g > b * 1.2 + counts['blue'] += r < 140 and b > 100 and b > r * 1.3 and b > g * 1.1 + counts['dungeon_floor'] += (r, g, b) == (40, 40, 50) + if name == 'dungeon-crawl' and 250 <= x < 550 and 150 <= y < 450: + counts['player'] += (r, g, b) == (50, 150, 255) + elif name == 'isometric-rpg' and 60 <= y < height - 40: + counts['player'] += (r, g, b) == (50, 100, 255) + elif name == 'space-blaster' and 250 <= x < 550 and 450 <= y < 570: + counts['player'] += (r, g, b) == (50, 200, 255) + requirements = { + 'test3d': {'red': 1000}, + 'dungeon-crawl': {'player': 500, 'dungeon_floor': 5000}, + 'isometric-rpg': {'player': 100, 'green': 10000}, + 'kart-racer': {'blue': 10, 'green': 10000}, + 'space-blaster': {'player': 300}, + 'voxel-sandbox': {'green': 10000}, + }[name] + for feature, minimum in requirements.items(): + if counts[feature] < minimum: + raise RuntimeError(f'{name}: missing required game content ({feature}: {counts[feature]}, need {minimum})') + return dict(width=width, height=height, content_pixels=counts) + + +def check_frame(name, path): + return {**check_pixels(name, *png_rgb(path)), 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()} diff --git a/tools/ci/fixtures/palette.ts b/tools/ci/fixtures/palette.ts new file mode 100644 index 00000000..9631f2b1 --- /dev/null +++ b/tools/ci/fixtures/palette.ts @@ -0,0 +1,97 @@ +import { Colors, ColorConstants, Color, Key, MouseButton, CursorShape, Platform, getPlatform } from 'bloom/core'; +import { + Colors as RootColors, ColorConstants as RootColorConstants, Key as RootKey, + MouseButton as RootMouseButton, CursorShape as RootCursorShape, Platform as RootPlatform, + FILTER_LINEAR, FILTER_NEAREST, TEXTURE_KIND_COLOR, TEXTURE_KIND_NORMAL, TEXTURE_KIND_DATA, + MATERIAL_TEXTURE_SLOT_BASE_COLOR, MATERIAL_TEXTURE_SLOT_NORMAL, MATERIAL_TEXTURE_SLOT_METALLIC_ROUGHNESS, + MATERIAL_TEXTURE_SLOT_EMISSIVE, MATERIAL_TEXTURE_SLOT_OCCLUSION, +} from 'bloom'; + +function row(name: string, upper: Color, mixed: Color, rootUpper: Color, rootMixed: Color): string { + return '["' + name + '",' + upper.r + ',' + upper.g + ',' + upper.b + ',' + upper.a + + ',' + mixed.r + ',' + mixed.g + ',' + mixed.b + ',' + mixed.a + + ',' + rootUpper.r + ',' + rootUpper.g + ',' + rootUpper.b + ',' + rootUpper.a + + ',' + rootMixed.r + ',' + rootMixed.g + ',' + rootMixed.b + ',' + rootMixed.a + + ',' + (upper === mixed) + ',' + (upper === rootUpper) + ',' + (upper === rootMixed) + ']'; +} + +// Read the public barrel, not the implementation module. Both naming styles +// must retain their values and refer to the same mutable color objects. +let result = '{"colors":['; +result = result + row('SNOW', Colors.SNOW, ColorConstants.Snow, RootColors.SNOW, RootColorConstants.Snow); +result = result + ',' + row('WHITE', Colors.WHITE, ColorConstants.White, RootColors.WHITE, RootColorConstants.White); +result = result + ',' + row('BLACK', Colors.BLACK, ColorConstants.Black, RootColors.BLACK, RootColorConstants.Black); +result = result + ',' + row('RED', Colors.RED, ColorConstants.Red, RootColors.RED, RootColorConstants.Red); +result = result + ',' + row('GREEN', Colors.GREEN, ColorConstants.Green, RootColors.GREEN, RootColorConstants.Green); +result = result + ',' + row('BLUE', Colors.BLUE, ColorConstants.Blue, RootColors.BLUE, RootColorConstants.Blue); +result = result + ',' + row('YELLOW', Colors.YELLOW, ColorConstants.Yellow, RootColors.YELLOW, RootColorConstants.Yellow); +result = result + ',' + row('ORANGE', Colors.ORANGE, ColorConstants.Orange, RootColors.ORANGE, RootColorConstants.Orange); +result = result + ',' + row('PINK', Colors.PINK, ColorConstants.Pink, RootColors.PINK, RootColorConstants.Pink); +result = result + ',' + row('PURPLE', Colors.PURPLE, ColorConstants.Purple, RootColors.PURPLE, RootColorConstants.Purple); +result = result + ',' + row('DARKGRAY', Colors.DARKGRAY, ColorConstants.DarkGray, RootColors.DARKGRAY, RootColorConstants.DarkGray); +result = result + ',' + row('LIGHTGRAY', Colors.LIGHTGRAY, ColorConstants.LightGray, RootColors.LIGHTGRAY, RootColorConstants.LightGray); +result = result + ',' + row('GRAY', Colors.GRAY, ColorConstants.Gray, RootColors.GRAY, RootColorConstants.Gray); +result = result + ',' + row('DARKBLUE', Colors.DARKBLUE, ColorConstants.DarkBlue, RootColors.DARKBLUE, RootColorConstants.DarkBlue); +result = result + ',' + row('SKYBLUE', Colors.SKYBLUE, ColorConstants.SkyBlue, RootColors.SKYBLUE, RootColorConstants.SkyBlue); +result = result + ',' + row('LIME', Colors.LIME, ColorConstants.Lime, RootColors.LIME, RootColorConstants.Lime); +result = result + ',' + row('DARKGREEN', Colors.DARKGREEN, ColorConstants.DarkGreen, RootColors.DARKGREEN, RootColorConstants.DarkGreen); +result = result + ',' + row('GOLD', Colors.GOLD, ColorConstants.Gold, RootColors.GOLD, RootColorConstants.Gold); +result = result + ',' + row('MAROON', Colors.MAROON, ColorConstants.Maroon, RootColors.MAROON, RootColorConstants.Maroon); +result = result + ',' + row('BROWN', Colors.BROWN, ColorConstants.Brown, RootColors.BROWN, RootColorConstants.Brown); +result = result + ',' + row('BEIGE', Colors.BEIGE, ColorConstants.Beige, RootColors.BEIGE, RootColorConstants.Beige); +result = result + ',' + row('MAGENTA', Colors.MAGENTA, ColorConstants.Magenta, RootColors.MAGENTA, RootColorConstants.Magenta); +result = result + ',' + row('VIOLET', Colors.VIOLET, ColorConstants.Violet, RootColors.VIOLET, RootColorConstants.Violet); +result = result + ',' + row('BLANK', Colors.BLANK, ColorConstants.Blank, RootColors.BLANK, RootColorConstants.Blank); +function numbers(values: number[]): string { + let text = '['; + for (let i = 0; i < values.length; i++) { + if (i > 0) text = text + ','; + text = text + values[i]; + } + return text + ']'; +} +const inputs = [ + Key.A, Key.B, Key.C, Key.D, Key.E, Key.F, Key.G, Key.H, + Key.I, Key.J, Key.K, Key.L, Key.M, Key.N, Key.O, Key.P, + Key.Q, Key.R, Key.S, Key.T, Key.U, Key.V, Key.W, Key.X, + Key.Y, Key.Z, Key.ZERO, Key.ONE, Key.TWO, Key.THREE, Key.FOUR, Key.FIVE, + Key.SIX, Key.SEVEN, Key.EIGHT, Key.NINE, Key.F1, Key.F2, Key.F3, Key.F4, + Key.F5, Key.F6, Key.F7, Key.F8, Key.F9, Key.F10, Key.F11, Key.F12, + Key.UP, Key.DOWN, Key.LEFT, Key.RIGHT, Key.SPACE, Key.ENTER, Key.ESCAPE, Key.TAB, + Key.BACKSPACE, Key.DELETE, Key.INSERT, Key.HOME, Key.END, Key.PAGE_UP, Key.PAGE_DOWN, Key.LEFT_SHIFT, + Key.RIGHT_SHIFT, Key.LEFT_CONTROL, Key.RIGHT_CONTROL, Key.LEFT_ALT, Key.RIGHT_ALT, Key.LEFT_SUPER, Key.RIGHT_SUPER, Key.APOSTROPHE, + Key.COMMA, Key.MINUS, Key.PERIOD, Key.SLASH, Key.SEMICOLON, Key.EQUAL, Key.LEFT_BRACKET, Key.BACKSLASH, + Key.RIGHT_BRACKET, Key.GRAVE, MouseButton.LEFT, MouseButton.RIGHT, MouseButton.MIDDLE, CursorShape.Default, CursorShape.Hand, CursorShape.Move, + CursorShape.Text, CursorShape.ResizeH, CursorShape.ResizeV, CursorShape.Crosshair, Platform.UNKNOWN, Platform.MACOS, Platform.IOS, Platform.WINDOWS, + Platform.LINUX, Platform.ANDROID, Platform.TVOS, Platform.WEB, Platform.WATCHOS, Platform.VISIONOS, +]; +const rootInputs = [ + RootKey.A, RootKey.B, RootKey.C, RootKey.D, RootKey.E, RootKey.F, RootKey.G, RootKey.H, + RootKey.I, RootKey.J, RootKey.K, RootKey.L, RootKey.M, RootKey.N, RootKey.O, RootKey.P, + RootKey.Q, RootKey.R, RootKey.S, RootKey.T, RootKey.U, RootKey.V, RootKey.W, RootKey.X, + RootKey.Y, RootKey.Z, RootKey.ZERO, RootKey.ONE, RootKey.TWO, RootKey.THREE, RootKey.FOUR, RootKey.FIVE, + RootKey.SIX, RootKey.SEVEN, RootKey.EIGHT, RootKey.NINE, RootKey.F1, RootKey.F2, RootKey.F3, RootKey.F4, + RootKey.F5, RootKey.F6, RootKey.F7, RootKey.F8, RootKey.F9, RootKey.F10, RootKey.F11, RootKey.F12, + RootKey.UP, RootKey.DOWN, RootKey.LEFT, RootKey.RIGHT, RootKey.SPACE, RootKey.ENTER, RootKey.ESCAPE, RootKey.TAB, + RootKey.BACKSPACE, RootKey.DELETE, RootKey.INSERT, RootKey.HOME, RootKey.END, RootKey.PAGE_UP, RootKey.PAGE_DOWN, RootKey.LEFT_SHIFT, + RootKey.RIGHT_SHIFT, RootKey.LEFT_CONTROL, RootKey.RIGHT_CONTROL, RootKey.LEFT_ALT, RootKey.RIGHT_ALT, RootKey.LEFT_SUPER, RootKey.RIGHT_SUPER, RootKey.APOSTROPHE, + RootKey.COMMA, RootKey.MINUS, RootKey.PERIOD, RootKey.SLASH, RootKey.SEMICOLON, RootKey.EQUAL, RootKey.LEFT_BRACKET, RootKey.BACKSLASH, + RootKey.RIGHT_BRACKET, RootKey.GRAVE, RootMouseButton.LEFT, RootMouseButton.RIGHT, RootMouseButton.MIDDLE, RootCursorShape.Default, RootCursorShape.Hand, RootCursorShape.Move, + RootCursorShape.Text, RootCursorShape.ResizeH, RootCursorShape.ResizeV, RootCursorShape.Crosshair, RootPlatform.UNKNOWN, RootPlatform.MACOS, RootPlatform.IOS, RootPlatform.WINDOWS, + RootPlatform.LINUX, RootPlatform.ANDROID, RootPlatform.TVOS, RootPlatform.WEB, RootPlatform.WATCHOS, RootPlatform.VISIONOS, +]; +const sharedMaps = Colors === RootColors && ColorConstants === RootColorConstants && Key === RootKey + && MouseButton === RootMouseButton && CursorShape === RootCursorShape && Platform === RootPlatform; +const rootConstants = [FILTER_LINEAR, FILTER_NEAREST, TEXTURE_KIND_COLOR, TEXTURE_KIND_NORMAL, TEXTURE_KIND_DATA, + MATERIAL_TEXTURE_SLOT_BASE_COLOR, MATERIAL_TEXTURE_SLOT_NORMAL, MATERIAL_TEXTURE_SLOT_METALLIC_ROUGHNESS, + MATERIAL_TEXTURE_SLOT_EMISSIVE, MATERIAL_TEXTURE_SLOT_OCCLUSION]; +const white = Colors.WHITE; +white.r = 17; +const aliasMutation = ColorConstants.White.r === 17; +white.r = 255; +const restored = Colors.WHITE.r === 255 && ColorConstants.White.r === 255; +result = result + '],"aliasMutation":' + aliasMutation + ',"restored":' + restored + + ',"inputs":' + numbers(inputs) + ',"rootInputs":' + numbers(rootInputs) + + ',"sharedMaps":' + sharedMaps + ',"rootConstants":' + numbers(rootConstants) + '}'; +console.log('BLOOM_PALETTE_RESULT:' + result); +// The smoke runner appends getPlatform() only to its WASM guard-failure control. diff --git a/tools/ci/fixtures/scalar-math.ts b/tools/ci/fixtures/scalar-math.ts new file mode 100644 index 00000000..377ae4c5 --- /dev/null +++ b/tools/ci/fixtures/scalar-math.ts @@ -0,0 +1,36 @@ +import { + lerp, clamp, remap, easeInQuad, easeOutQuad, easeInOutQuad, + easeInCubic, easeOutCubic, easeInOutCubic, +} from '../../../src/math/index'; + +// Compile the public implementation, including its cross-module call paths. +// Scalar JSON avoids Perry WASM's separate object JSON.stringify limitation. +const samples = [-0.25, 0, 0.1, 0.25, 0.5, 0.75, 1, 1.25]; +let result = '{"samples":['; +for (let i = 0; i < samples.length; i++) { + const t = samples[i]; + if (i > 0) result = result + ','; + result = result + '[' + t + ',' + lerp(-2.5, 4.75, t) + + ',' + easeInQuad(t) + ',' + easeOutQuad(t) + ',' + easeInOutQuad(t) + + ',' + easeInCubic(t) + ',' + easeOutCubic(t) + ',' + easeInOutCubic(t) + + ',' + clamp(t, 0, 1) + ',' + remap(t, 0, 1, -2.5, 4.75) + ']'; +} +const nan = 0 / 0; +const infinity = 1 / 0; +const a = lerp(0, 1, nan); +const b = easeInQuad(nan); +const c = easeOutQuad(nan); +const d = easeInOutQuad(nan); +const e = easeInCubic(nan); +const f = easeOutCubic(nan); +const nanPreserved = a !== a && b !== b && c !== c && d !== d && e !== e && f !== f; +const infinityPreserved = lerp(0, 1, infinity) === infinity + && easeInQuad(infinity) === infinity && easeOutQuad(infinity) === -infinity + && easeInOutQuad(infinity) === -infinity && easeInCubic(infinity) === infinity + && easeOutCubic(infinity) === infinity; +const negativeZeroPreserved = 1 / easeOutQuad(-0) === -infinity + && 1 / easeInCubic(-0) === -infinity; +result = result + '],"nanPreserved":' + nanPreserved + + ',"infinityPreserved":' + infinityPreserved + + ',"negativeZeroPreserved":' + negativeZeroPreserved + '}'; +console.log('BLOOM_SCALAR_MATH_RESULT:' + result); diff --git a/tools/ci/native_example_smoke.py b/tools/ci/native_example_smoke.py new file mode 100644 index 00000000..e4e5b1a4 --- /dev/null +++ b/tools/ci/native_example_smoke.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Compile and run bounded copies of portable examples on the real native engine.""" + +import argparse +import ctypes +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from tools.ci.compile_examples import load_inventory, reject_duplicate_module_globals +from tools.ci.example_runtime import EXAMPLES, bounded_native_source, check_frame, validate_native_state +from tools.ci.native_package_smoke import npm_command + + +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/native-examples') + parser.add_argument('--backend', choices=['dx12', 'vulkan'], default='dx12') + args = parser.parse_args() + if os.name != 'nt' or not args.perry: + parser.error('this native example gate requires Windows and Perry 0.5.1220') + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=True) + report = dict(schema='bloom-native-examples-v1', status='running', commands=[], examples=[], + scope='Six portable examples: unchanged update/draw and cleanup bodies with bounded native capture instrumentation. Content checks do not replace quality goldens, presentation or gameplay acceptance.') + # Keep linked source and fixture on one drive so Perry's module prefixes + # retain their full relative paths instead of colliding index.ts names. + parent = (ROOT / 'target/ci').resolve() + parent.mkdir(parents=True, exist_ok=True) + temporary = Path(tempfile.mkdtemp(prefix='bne-', dir=parent)).resolve() + env = os.environ.copy() + env.pop('CARGO_TARGET_DIR', None) + env.setdefault('CARGO_BUILD_JOBS', '2') + + def save(): + (out / 'result.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + + def run(name, command, cwd, run_env, timeout): + record = dict(name=name, command=command, cwd=str(cwd)) + report['commands'].append(record) + save() + print(name, flush=True) + started = time.monotonic() + try: + with (out / f'{name}.stdout.log').open('wb') as stdout, (out / f'{name}.stderr.log').open('wb') as stderr: + process = subprocess.run(command, cwd=cwd, env=run_env, stdout=stdout, stderr=stderr, timeout=timeout) + record['exit_code'] = process.returncode + text = (out / f'{name}.stdout.log').read_text(encoding='utf-8', errors='replace') + error_text = (out / f'{name}.stderr.log').read_text(encoding='utf-8', errors='replace') + reject_duplicate_module_globals(text + error_text) + if process.returncode or 'Could not resolve import' in text + error_text: + raise RuntimeError(f'{name}: process or import resolution failed; see retained logs') + return text + except (OSError, RuntimeError, subprocess.SubprocessError) as error: + record['error'] = str(error) + raise + finally: + record['duration_seconds'] = round(time.monotonic() - started, 3) + save() + + save() + try: + inventory, failures = load_inventory() + if failures or any('examples/' + name not in inventory for name in EXAMPLES): + raise RuntimeError(f'canonical example inventory is invalid: {failures}') + 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() + if run('compiler-version', [args.perry, '--version'], ROOT, env, 30).strip() != 'perry 0.5.1220': + raise RuntimeError('native examples require Perry 0.5.1220') + report['runtime_source_sha256'] = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in [ + 'src/index.ts', 'src/math/index.ts', 'src/core/index.ts', 'tools/ci/example_runtime.py', + 'tools/ci/compile_examples.py', 'tools/ci/native_example_smoke.py']} + manifest = dict(name='bloom-native-example-smoke', private=True, + dependencies={'bloom': 'file:' + ROOT.as_posix()}, + perry={'allow': {'nativeLibrary': ['bloom', 'bloom/*']}}) + (temporary / 'package.json').write_text(json.dumps(manifest) + '\n', encoding='utf-8') + run('install', npm_command() + ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', '--install-links=false'], temporary, env, 180) + if (temporary / 'node_modules/bloom').resolve() != ROOT.resolve(): + raise RuntimeError('example dependency must resolve to this exact checkout') + ctypes.windll.kernel32.SetErrorMode(0x0002 | 0x8000) + for name in EXAMPLES: + case = dict(name=name, backend=args.backend, status='running') + report['examples'].append(case) + save() + try: + source = (ROOT / 'examples' / name / 'main.ts').read_bytes() + case['source_sha256'] = hashlib.sha256(source).hexdigest() + (out / (name + '.original.ts')).write_bytes(source) + bounded = bounded_native_source(source.decode('utf-8')) + entry = temporary / (name + '.ts') + entry.write_text(bounded, encoding='utf-8') + (out / (name + '.bounded.ts')).write_bytes(entry.read_bytes()) + binary = temporary / (name + '.exe') + run(name + '-compile', [args.perry, 'compile', str(entry), '-o', str(binary)], temporary, env, 1800) + with binary.open('rb') as stream: + if stream.read(2) != b'MZ': + raise RuntimeError('compiler produced no native Windows binary') + case['binary_sha256'] = hashlib.sha256(binary.read_bytes()).hexdigest() + runtime_dir = temporary / name + runtime_dir.mkdir() + runtime = env.copy() + runtime.update(BLOOM_HEADLESS='1', BLOOM_HEADLESS_PIXEL_EXACT='1', BLOOM_WGPU_BACKEND=args.backend) + try: + run(name + '-run', [str(binary)], runtime_dir, runtime, 180) + finally: + for file in ['state.txt', 'frame.png']: + if (runtime_dir / file).is_file(): + shutil.copyfile(runtime_dir / file, out / (name + '.' + file)) + case['state'] = validate_native_state((runtime_dir / 'state.txt').read_text(encoding='utf-8')) + case['frame'] = check_frame(name, runtime_dir / 'frame.png') + case['source_unchanged'] = (ROOT / 'examples' / name / 'main.ts').read_bytes() == source + if not case['source_unchanged']: + raise RuntimeError('example source changed during the native run') + case['status'] = 'pass' + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: + case.update(status='fail', error=str(error)) + save() + report['status'] = 'pass' if all(case['status'] == 'pass' for case in report['examples']) else 'fail' + print(report['status'].upper() + ': six native example render/cleanup checks') + return 0 if report['status'] == 'pass' else 1 + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error: + report.update(status='fail', error=str(error)) + print(f'FAIL: {error}') + return 1 + finally: + save() + if temporary.parent != parent or temporary.is_symlink() or temporary.is_junction(): + raise RuntimeError('refusing cleanup outside the owned example project') + shutil.rmtree(temporary) + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/ci/palette_smoke.py b/tools/ci/palette_smoke.py new file mode 100644 index 00000000..60d9d003 --- /dev/null +++ b/tools/ci/palette_smoke.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Qualify public constants and shared palette identity in actual native/WASM code.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +from tools.ci.native_package_smoke import npm_command +from tools.ci.compile_examples import reject_duplicate_module_globals + +PREFIX = 'BLOOM_PALETTE_RESULT:' +# Public palette compatibility reference; both aliases must retain all channels. +COLORS = dict(SNOW='f5f5f5ff', WHITE='ffffffff', BLACK='000000ff', RED='e62937ff', GREEN='00e430ff', + BLUE='0079f1ff', YELLOW='fdf900ff', ORANGE='ffa100ff', PINK='ff6dc2ff', PURPLE='c87affff', + DARKGRAY='505050ff', LIGHTGRAY='c8c8c8ff', GRAY='828282ff', DARKBLUE='0052acff', SKYBLUE='66bfffff', + LIME='009e2fff', DARKGREEN='00752cff', GOLD='ffcb00ff', MAROON='be2137ff', BROWN='7f6a4fff', + BEIGE='d3b083ff', MAGENTA='ff00ffff', VIOLET='873cbeff', BLANK='00000000') +INPUTS = (list(range(65, 91)) + list(range(48, 58)) + list(range(112, 124)) + list(range(256, 260)) + + [32, 265, 27, 9, 8, 127, 260, 261, 262, 263, 264] + list(range(280, 288)) + + [39, 44, 45, 46, 47, 59, 61, 91, 92, 93, 96] + [0, 1, 2] + list(range(7)) + list(range(10))) +ROOT_CONSTANTS = [0, 1, 0, 1, 2, 0, 1, 2, 3, 4] + + +def validate_observations(output): + rows = [line[len(PREFIX):] for line in output.splitlines() if line.startswith(PREFIX)] + if len(rows) != 1: raise RuntimeError('palette fixture must emit exactly one result') + data = json.loads(rows[0]) + if set(data) != {'colors', 'aliasMutation', 'restored', 'inputs', 'rootInputs', 'sharedMaps', 'rootConstants'} or any(data[key] is not True for key in ['aliasMutation', 'restored', 'sharedMaps']): + raise RuntimeError('palette aliases lost shared mutation/restoration behavior') + for key, expected in [('inputs', INPUTS), ('rootInputs', INPUTS), ('rootConstants', ROOT_CONSTANTS)]: + if data[key] != expected or any(type(n) not in (int, float) for n in data[key]): + raise RuntimeError(f'public constant values changed: {key}') + if len(data['colors']) != len(COLORS): raise RuntimeError('palette fixture omitted or duplicated a color') + for row, (name, rgba) in zip(data['colors'], COLORS.items()): + channels = list(bytes.fromhex(rgba)) + if len(row) != 20 or row[0] != name or any(v is not True for v in row[-3:]) or row[1:17] != channels * 4 or any(type(n) not in (int, float) for n in row[1:17]): + raise RuntimeError(f'palette value or identity changed for {name}: {row}') + return data + + +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/palette') + 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) + if (out / 'result.json').exists(): parser.error('choose a fresh palette evidence directory') + report = dict(schema='bloom-palette-v1', status='running', commands=[], cases=[]) + # The linked engine and fixture must share a drive. Perry 0.5.1220 falls + # back to colliding index.ts module names when no common ancestor exists. + parent = (ROOT / 'target/ci').resolve() + parent.mkdir(parents=True, exist_ok=True) + temporary = Path(tempfile.mkdtemp(prefix='bpc-', dir=parent)).resolve() + env = os.environ.copy() + env.pop('CARGO_TARGET_DIR', None) + env.setdefault('CARGO_BUILD_JOBS', '2') + def save(): (out / 'result.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + def run(name, command, expected_error=None): + record = dict(name=name, command=command, cwd=str(temporary)) + report['commands'].append(record) + started = time.monotonic() + save() + try: + with (out / (name + '.log')).open('wb') as log: + process = subprocess.run(command, cwd=temporary, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=1800) + record['exit_code'] = process.returncode + text = (out / (name + '.log')).read_text(encoding='utf-8', errors='replace') + reject_duplicate_module_globals(text) + if expected_error: + if process.returncode == 0 or expected_error not in text: raise RuntimeError(f'{name}: expected guard failure was not observed') + elif process.returncode or 'Could not resolve import' in text: raise RuntimeError(f'{name}: execution or import resolution failed') + return text + finally: + record['duration_seconds'] = round(time.monotonic() - started, 3) + save() + try: + 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_version'] = run('compiler-version', [args.perry, '--version']).strip() + if report['compiler_version'] != 'perry 0.5.1220': raise RuntimeError('palette requires Perry 0.5.1220') + report['compiler_sha256'] = hashlib.sha256(Path(args.perry).read_bytes()).hexdigest() + report['source_sha256'] = {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in [ + 'src/index.ts', 'src/core/index.ts', 'src/core/colors.ts', 'src/core/keys.ts', 'tools/ci/fixtures/palette.ts', 'tools/ci/palette_smoke.py', + 'tools/ci/perry_wasm_console.cjs', 'tools/ci/compile_examples.py', 'native/web/splice_game.cjs']} + manifest = dict(name='bloom-palette-contract', private=True, dependencies={'bloom': 'file:' + ROOT.as_posix()}, + perry={'allow': {'nativeLibrary': ['bloom', 'bloom/*']}}) + (temporary / 'package.json').write_text(json.dumps(manifest) + '\n', encoding='utf-8') + run('install', npm_command() + ['install', '--ignore-scripts', '--no-audit', '--no-fund', '--package-lock=false', '--install-links=false']) + if (temporary / 'node_modules/bloom').resolve() != ROOT.resolve(): raise RuntimeError('palette dependency differs from this checkout') + fixture = (ROOT / 'tools/ci/fixtures/palette.ts').read_bytes() + entry = temporary / 'palette.ts' + entry.write_bytes(fixture) + (out / 'palette.ts').write_bytes(fixture) + helper = ROOT / 'tools/ci/perry_wasm_console.cjs' + for mode in ['native', 'wasm']: + artifact = out / ('palette.html' if mode == 'wasm' else 'palette.exe' if os.name == 'nt' else 'palette') + run(mode + '-compile', [args.perry, 'compile', str(entry)] + (['--target', 'wasm'] if mode == 'wasm' else []) + ['-o', str(artifact)]) + command = ['node', str(helper), str(artifact), PREFIX, '--guard-unused-ffi'] if mode == 'wasm' else [str(artifact)] + result = validate_observations(run(mode + '-run', command)) + report['cases'].append(dict(mode=mode, status='pass', observations=result, artifact_sha256=hashlib.sha256(artifact.read_bytes()).hexdigest())) + save() + entry.write_bytes(fixture + b'\nconsole.log(getPlatform());\n') + control = out / 'guard-control.html' + run('guard-compile', [args.perry, 'compile', str(entry), '--target', 'wasm', '-o', str(control)]) + run('guard-run', ['node', str(helper), str(control), PREFIX, '--guard-unused-ffi'], expected_error='FFI execution forbidden: bloom_get_platform') + report.update(status='pass', unexpected_ffi_rejected=True) + print('PASS: native/WASM public palette values and shared identity; attempted engine call rejected') + return 0 + except (OSError, ValueError, RuntimeError, KeyError, TypeError, subprocess.SubprocessError) as error: + report.update(status='fail', error=str(error)) + print(f'FAIL: {error}') + return 1 + finally: + save() + if temporary.parent != parent or temporary.is_symlink() or temporary.is_junction(): raise RuntimeError('refusing cleanup outside owned palette project') + shutil.rmtree(temporary) + + +if __name__ == '__main__': raise SystemExit(main()) diff --git a/tools/ci/perry_wasm_console.cjs b/tools/ci/perry_wasm_console.cjs index 7b250e0e..a71c5754 100644 --- a/tools/ci/perry_wasm_console.cjs +++ b/tools/ci/perry_wasm_console.cjs @@ -2,18 +2,25 @@ // 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. +// renderer, GPU or network participates in the contract result. The optional +// unused-FFI mode installs guards that reject every attempted engine call. const fs = require("node:fs"); const vm = require("node:vm"); const { installPerryVoidReturnCompatibility } = require("../../native/web/splice_game.cjs"); async function main() { - if (process.argv.length !== 3) throw new Error("Usage: perry_wasm_console.cjs "); + if (process.argv.length < 3 || process.argv.length > 5 || (process.argv.length === 5 && process.argv[4] !== '--guard-unused-ffi')) throw new Error("Usage: perry_wasm_console.cjs [RESULT_PREFIX:] [--guard-unused-ffi]"); + const prefix = process.argv[3] || "BLOOM_FIXED_STEP_RESULT:"; + if (!/^[A-Z_]+:$/.test(prefix)) throw new Error("Contract result prefix must be an uppercase identifier followed by a colon"); 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 ffi = imports.filter(item => item.module === 'ffi'); + if (ffi.length && process.argv[4] !== '--guard-unused-ffi') throw new Error("Pure contract fixture unexpectedly requires engine FFI"); + const guardedFfi = Object.fromEntries(ffi.map(item => [item.name, () => { + throw new Error('FFI execution forbidden: ' + item.name); + }])); const scripts = [...html.matchAll(/