From d5951d3d1fc55e7b9ac13f7d03ac4310692a54f0 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Fri, 11 Sep 2026 09:55:34 +0200 Subject: [PATCH 1/3] Fix scalar interpolation and qualify shared-loop examples --- .github/workflows/test.yml | 28 ++++++ docs/evidence/example-loops-v1.md | 73 +++++++++++++++ docs/windows-engine-plan.md | 34 +++++-- examples/dungeon-crawl/main.ts | 15 ++-- examples/isometric-rpg/main.ts | 58 +++++------- examples/kart-racer/main.ts | 32 +++---- examples/space-blaster/main.ts | 17 ++-- examples/test3d/main.ts | 10 +-- examples/voxel-sandbox/main.ts | 28 +++--- scripts/ci-check.sh | 7 ++ src/math/index.ts | 30 +++---- tools/ci/example_runtime.py | 89 +++++++++++++++++++ tools/ci/fixtures/scalar-math.ts | 36 ++++++++ tools/ci/native_example_smoke.py | 138 +++++++++++++++++++++++++++++ tools/ci/perry_wasm_console.cjs | 8 +- tools/ci/scalar_math_smoke.py | 116 ++++++++++++++++++++++++ tools/ci/test_example_runtime.py | 39 ++++++++ tools/ci/test_scalar_math_smoke.py | 50 +++++++++++ 18 files changed, 693 insertions(+), 115 deletions(-) create mode 100644 docs/evidence/example-loops-v1.md create mode 100644 tools/ci/example_runtime.py create mode 100644 tools/ci/fixtures/scalar-math.ts create mode 100644 tools/ci/native_example_smoke.py create mode 100644 tools/ci/scalar_math_smoke.py create mode 100644 tools/ci/test_example_runtime.py create mode 100644 tools/ci/test_scalar_math_smoke.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c8c4ddf..146480fc 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 @@ -384,6 +397,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 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..2bfbe7e5 100644 --- a/docs/windows-engine-plan.md +++ b/docs/windows-engine-plan.md @@ -65,16 +65,24 @@ audit are saved in `tools/quality/out/windows-engine-plan/plan-requirements.json 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. + bridge and expanded named-hook contracts pass locally. The first hosted full + starter check rejected an omitted hidden website file before launch; #173's + scoped upload correction preserves the exact receipt. Its retry is pending. 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,23 @@ 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. 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/scripts/ci-check.sh b/scripts/ci-check.sh index 1e3e198e..54be6c29 100755 --- a/scripts/ci-check.sh +++ b/scripts/ci-check.sh @@ -289,6 +289,11 @@ 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/example_runtime.py \ + tools/ci/native_example_smoke.py \ + tools/ci/test_example_runtime.py \ tools/ci/starter_package_smoke.py \ tools/ci/starter_web_run.py \ tools/ci/starter_browser_smoke.py \ @@ -308,6 +313,8 @@ 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_example_runtime.py \ tools/ci/test_starter_browser_smoke.py \ -v hr "visual metric and fault-engine tests" 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/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/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..c6cb2aa8 --- /dev/null +++ b/tools/ci/native_example_smoke.py @@ -0,0 +1,138 @@ +#!/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 +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.') + parent = Path(tempfile.gettempdir()).resolve() + 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') + 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/math/index.ts', 'src/core/index.ts', 'tools/ci/example_runtime.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/perry_wasm_console.cjs b/tools/ci/perry_wasm_console.cjs index 7b250e0e..a3840999 100644 --- a/tools/ci/perry_wasm_console.cjs +++ b/tools/ci/perry_wasm_console.cjs @@ -8,12 +8,14 @@ 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 > 4) throw new Error("Usage: perry_wasm_console.cjs [RESULT_PREFIX:]"); + 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"); + if (imports.some(item => item.module === "ffi")) throw new Error("Pure contract fixture unexpectedly requires engine FFI"); const scripts = [...html.matchAll(/